如何编写一个 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 模型设计

设计图:

img.png

如上图,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 的数据包,会进行解码反序列化,并唤醒阻塞在客户端的线程。从而完成一次调用。

线程模型

设计图:

img_1.png

如上图所示。

在 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 满载。

img_2.png

代码地址:https://github.com/stateIs0/send_file

同时,欢迎大家 star, pr,issue。我来改进。


如何编写一个 SendFile 服务器和客户端
http://thinkinjava.cn/2019/10/29/2019/1029-SF/
作者
莫那·鲁道
发布于
2019年10月29日
许可协议