如何编写一个 SendFile 服务器和客户端
前言
之前讨论零拷贝的时候,我们知道,两台机器之间传输文件,最快的方式就是 send file,众所周知,在 Java 中,该技术对应的则是 FileChannel 类的 transferTo 和 transferFrom 方法。
在平时使用服务器的时候,比如 nginx ,tomcat ,都有 send file 的选项,利用此技术,可大大提高文件传输效能。
另外,可能也有人谈论 send file 的缺点,例如不能利用 gzip 压缩,不能加密。这里本文不做探讨。
纸上得来终觉浅,绝知此事要躬行。
那么,如何使用这两个 api 实现一个 send file 服务器和客户端呢?
想象一下,你写的 send file 服务器利用 send file 技术,利用万兆网卡,从各个 client 端 copy 海量文件,瞬间打爆你那 1TB 的磁盘和 48核的 CPU。并且,注意:只需很小的 JVM 内存就可以实现这样一台强悍的服务器。为什么?如果你知道 send file 的原理,就会知道,使用 send file 技术时, 在用户态中,是不需要多少内存的,数据都在内核态。
是不是很有成就感?什么?没有?那打扰了 🤣。
另外,关于 send file,我们都知道,由于是直接从内核缓冲区进入到网卡驱动,我们几乎可以称之为 “零拷贝”,他的性能十分强劲。
但是。
除了这个,还有其他的吗?答案是有的,send file 利用 DMA 的方式 copy 数据,而不是利用 CPU。注意,不利用 CPU 意味着什么?意味着数据不会进入“缓存行”,进一步,不会进入缓存行,代表着缓存行不会因为这个被污染,再进一步,就是不需要维护缓存一致性。
还记得我们因为这个特性搞的那些关于 “伪共享” 的各种黑科技吗?是不是又学到了一点呢?😎
理念
作为一个纯粹的,高尚的,有趣的 sendFile 服务器或者客户端,使用场景是嵌入到某个服务中,或者某个中间件中,不需要搞成夸张的容器。我们可以借鉴一下,客户端可以做成 Jedis 那样的,如果你想搞个连接池也不是不可以,但 client 自身实例,还是单连接的。服务端可以做成 sun 的 httpServer 那种轻量的,随时启动,随时关闭。
同时, 支持 oneway 的高性能发送,因为,只要机器不宕机,发送到网卡就意味着发送成功,这样能大幅提高发送速度,减少客户端阻塞时间。
另外,也支持带有 ack 的稳定发送,即只有返回 ack 了,才能确认数据已经写到目标服务器磁盘了。
server 端支持海量连接,必须得是 reactor 网络模型,但我们不想在这么小的组件里用 netty,太重了,还容易和使用方有 jar 冲突。所以,我们可以利用 Java 的 selector + nio 自己实现 Reactor 模型。
设计
IO 模型设计
设计图:
如上图,Server 端支持海量客户端连接。
server 端含有 多个处理器,其中包括 accept 处理器,read 处理器 group, write 处理器 group。
accept 处理器将 serverSocketChannel 作为 key 注册到一个单独的 selector 上。专门用于监听 accept 事件。类似 netty 的 boss 线程。
当 accept 处理器成功连接了一个 socket 时,会随机将其交给一个 readProcessor(netty worker 线程?) 处理器,readProcessor 又会将其注册到 readSelector 上,当发生 read 事件时,readProcessor 将接受数据。
可以看到,readProcessor 可以认为是一个多路复用的线程,利用 selector 的能力,他高效的管理着多个 socket。
readProcessor 在读到数据后,会将其写入到磁盘中(DMA 的方式,性能炸裂)。
然后,如果 client 在 RPC 协议中声明“需要回复(id 不为 -1)” 时,那就将结果发送到 Reply Queue 中,反之不必。
当结果发送到 Reply Queue 后,writer 组中的 写线程,则会从 Queue 中拉取回复包,然后将结果按照 RPC 协议,写回到 client socket 中。
client socket 也会监听着 read 事件,注意:client 是不需要 select 的,因为没必要,selector 只是性能优化的一种方式——即一个线程管理海量连接,如果没有 select, 应用层无法用较低的成本处理海量连接,注意,不是不能处理,只是不能高效处理。
回过来,当 client socket 得到 server 的数据包,会进行解码反序列化,并唤醒阻塞在客户端的线程。从而完成一次调用。
线程模型
设计图:
如上图所示。
在 client 端:
每个 Client 实例,维护一个 TCP 连接。该 Client 的写入方法是线程安全的。
当用户并发写入时,可并发写的同时并发回复,因为写和回复是异步的(此时可能会出现,线程 A 先 send ,线程 B 后 send,但由于网络延迟,B 先返回)。
在 server 端:
server 端维护着一个 ServerSocketChannel 实例,该实例的作用就是接收 accep 事件,且由一个线程维护这个 accept selector 。
当有新的 client 连接事件时,accept selector 就将这个连接“交给“ read 线程(默认 server 有 4 个 read 线程)。
什么是“交给”?
注意:每个 read 线程都维护着一个单独的 selector。 4 个 read 线程,就维护了 4 个 selector。
当 accept 得到新的客户端连接时,先从 4 个read 线程组里 get 一个线程,然后将这个 客户端连接 作为 key 注册到这个线程所对应的 read selector 上。从而将这个 Socket “交给” read 线程。
而这个 read 线程则使用这个 selector 轮询事件,如果 socket 可读,那么就进行读,读完之后,利用 DMA 写进磁盘。
RPC 协议
Server RPC 回复包协议
字段名称 | 字段长度(byte) | 字段作用 |
---|---|---|
magic_num | 4 | 魔数校验,fast fail |
version | 1 | rpc 协议版本 |
id | 8 | Request id, TCP 多路复用 id |
length | 8 | rpc 实际消息内容的长度 |
Content | length | rpc 实际消息内容(JSON 序列化协议) |
Client RPC 发送包协议
字段名称 | 字段长度(byte) | 字段作用 |
---|---|---|
magic_num | 4 | 魔数校验,fast fail |
id | 8 | Request id, TCP 多路复用 id, 默认 -1,表示不回复 |
nameContent | 2 | Request id, TCP 多路复用 id |
bodyLength | 8 | rpc 实际消息内容的长度 |
nameContent | bodyLength | 文件名 UTF-8 数组 |
为什么 发送包和返回包协议不同?为了高效。
总结
注意:这是一个能用的,性能不错的,轻量的 SendFile 服务器实现,本地测试时, IO写盘达到 824MB/S,4c 4.2g inter i7 CPU 满载。
代码地址:https://github.com/stateIs0/send_file
同时,欢迎大家 star, pr,issue。我来改进。