Netty学习之从下到上成就零拷贝

简述

在做网络开发时,时常听到零拷贝、Reactor编程模型、JAVA NIO SocketChannel等的概念,还有一个优秀的网络编程构架,如Netty、Akka等,这篇Blog就“零拷贝”的概念及在Netty中的应用浅短讨论一下,文中不免有许多抄借和错误之处,请自行留意。

何为零拷贝?

操作系统中的零拷贝

一般地,用户程序进程工作在用户态的内存空间,而操作系统本身占用的内存属于系统太空间,因此如果我们想通过Socket通信进行收发数据时,由于用户程序产生的数据所处的内存空间与套接字Socket缓存区所处的空间不一样,这就需要CPU介入,将用户太空间的数据首先拷贝到系统空间的Socket缓存区中,然后才能由操作系统来读写这些数据,最终放入到网上的缓存区中如图1所示。这就存在了一次数据的拷贝,在大量数据需要传输的场景下,数据的拷贝无疑会占用更多 的CPU资源,导致整个系统的吞吐量的下降。
不幸的是,当请求的数据大于内核缓冲区大小时这种方法往往会成为性能瓶颈。数据在最终被发送之前,在磁盘,内核缓冲区和用户缓冲区之间发生多次拷贝。零拷贝通过减少不必要的数据拷贝以提供性能。
Netty学习之从下到上成就零拷贝
图1. 非零拷贝模式下的文件数据发送流程

正如你所看到的,上面的过程中存在很多的数据冗余。某些冗余可以被消除,以减少开销、提高性能。作为一名驱动程序开发人员,我的工作围绕着拥有先进特性的硬件展开。某些硬件支持完全绕开内存,将数据直接传送给其他设备的特性。这一特性消除了系统内存中的数据副本,因此是一种很好的选择,但并不是所有的硬件都支持。此外,来自于硬盘的数据必须重新打包(地址连续)才能用于网络传输,这也引入了某些复杂性。为了减少开销,我们可以从消除内核缓冲区与用户缓冲区之间的复制入手,如图2所示。
Netty学习之从下到上成就零拷贝
图2. 零拷贝模式下的文件数据发送流程

两种常见的实现”零拷贝“的方法:

方法一:sendfile(socket, file, len)

这个方法系统调用在内核版本2.1中被引入,以期在操作系统层实现文件数据的”零拷贝“。
Netty学习之从下到上成就零拷贝
磁盘数据被DMA加载到内核缓存区后,通过sendfile(…)调用,可以直接将内核缓存区的数据拷贝到套接字缓存区,一旦所有的数据都被拷贝到socket缓存区,那么sendfile方法就直接retrun,表示数据发送的准备工作已经完成,后面的工作就将由DMA来做就行了。注意到,应用程序不用等待数据再被拷贝到网卡缓存区(协议缓存区),这是因为DMA能够独立完成剩余的工作,这充分体现的硬件上的并行化。

方法二:mmap技术(内存映射技术)系统调用

DMA加载磁盘数据到kernel buffer后,应用程序缓冲区(application buffers)和内核缓冲区(kernel buffer)进行映射,数据再应用缓冲区和内核缓存区的改变就能省略。
Netty学习之从下到上成就零拷贝
那么实现”零拷贝“功能需要如下的基本代码流程:

tmp_buf = mmap(file, len);
write(socket, tmp_buf, len);

步骤一:mmap系统调用导致文件的内容通过DMA模块被复制到内核缓冲区中,该缓冲区之后与用户进程共享,这样就内核缓冲区与用户缓冲区之间的复制就不会发生。

步骤二:write系统调用导致内核将数据从内核缓冲区复制到与socket相关联的内核缓冲区中。

步骤三:DMA模块将数据由socket的缓冲区传递给协议引擎时,第3次复制发生。

通过调用mmap而不是read,我们已经将内核需要执行的复制操作减半。当有大量数据要进行传输是,这将有相当良好的效果。然而,性能的改进需要付出代价的;是用mmap与write这种组合方法,存在着一些隐藏的陷阱。例如,考虑一下在内存中对文件进行映射后调用write,与此同时另外一个进程将同一文件截断的情形。此时write系统调用会被进程接收到的SIGBUS信号中断,因为当前进程访问了非法内存地址。对SIGBUS信号的默认处理是杀死当前进程并生成dump core文件——而这对于网络服务器程序而言不是最期望的操作。

有两种方式可用于解决该问题:

第一种方式是为SIGBUS信号设置信号处理程序,并在处理程序中简单的执行return语句。在这样处理方式下,write系统调用返回被信号中断前已写的字节数,并将errno全局变量设置为成功。必须指出,这并不是个好的解决方式——治标不治本。由于收到SIGBUS信号意味着进程发生了严重错误,我不鼓励采取这种解决方式。

第二种方式应用了文件租借(在Microsoft Windows系统中被称为“机会锁”)。这才是解劝前面问题的正确方式。通过对文件描述符执行租借,可以同内核就某个特定文件达成租约。从内核可以获得读/写租约。当另外一个进程试图将你正在传输的文件截断时,内核会向你的进程发送实时信号——RT_SIGNAL_LEASE。该信号通知你的进程,内核即将终止在该文件上你曾获得的租约。这样,在write调用访问非法内存地址、并被随后接收到的SIGBUS信号杀死之前,write系统调用就被RT_SIGNAL_LEASE信号中断了。write的返回值是在被中断前已写的字节数,全局变量errno设置为成功。
更为详细的说明,请参考:https://blog.csdn.net/caianye/article/details/7576198

方法三:使用最新的Linux内核

在内核版本2.4中,socket缓冲区描述符结构发生了改动,以适应聚合操作的要求——这就是Linux中所谓的"零拷贝“。这种方式不仅减少了多个上下文切换,而且该特性意味着待发送的数据不要求存放在地址连续的内存空间中;相反,可以是分散在各个内存位置,最终只需要2次DMA拷贝即可完成工作。
Netty学习之从下到上成就零拷贝
从用户层应用程序的角度来开,没有发生任何改动,所有代码仍然是类似下面的形式:
sendfile(socket, file, len);

方法四:Splice技术

Linux 2.6.17 支持splice,该方法的操作复杂度同sendfile(…)调用一样,但不同的是sendfile(…)需要硬件支持scatter/gather的能力,但是Splice技术并不依赖硬件,而是在内核缓存区和Socket缓存区之间构建一条Pipe管道。在数据发送时,DMA通过这条数据管道,能够间接从kernel bbuffer读取数据,从而避免内核空间到socket缓存区的数据拷贝。因此这个技术的操作复杂度同sendfile一样。

到此为止,我们已经能够避免内核进行多次复制,然而我们还存在一分多余的副本。由于数据实际上仍然由磁盘复制到内存,再由内存复制到发送设备,有人可能会声称这并不是真正的"零拷贝"。然而,从操作系统的角度来看,这就是"零拷贝",因为内核空间内不存在冗余数据。应用"零拷贝"特性,出了避免复制之外,还能获得其他性能优势,例如更少的上下文切换,更少的CPU cache污染以及没有CPU必要计算校验和。

JAVA中的零拷贝

预备知识:Channel / FileChannel / ByteBuffer
ByteBuffer:字节缓存区,要么是直接缓存区,要么非直接缓存区。所谓直接缓存区,是指分配在JAVA堆外的内存区域,因此不受JAVA GC的控制,需要特别小心的使用和释放;而非直接缓存区是指定由JAVA系统管理的内存区域。直接缓存区的分配和回收都需要手动地处理,同时需要与底层的本地API接口调用,如C语言库,因此相对于非直接缓存区的分配和回收,这种方式的时间消耗更大,更适合于需要大内存空间且需要共享的数据。尤其要注意的是,这个类的实现,只提供读写JAVA原子类型的数据,除了boolean类型。

Channel: IO操作的接口,一个channel就代表了一个连接通道,能够与底层的硬件设备、文件、网络Socket或是另外一个能够读、写的程序组件的交互。一般地Channel的实现类都是线程安全的。

FIleChannel:文件操作的抽象类型,这类实例能够读、写、映射或操纵一个文件。这个channel提供了两个接口,可以用来加载或是拷贝文件内容到指定目标位置。

public abstract MappedByteBuffer map(MapMode mode, long position, long size) throws IOException; 这个方法返回MappedByteBuffer类型的对象,包含了一个文件在内存中的的映射区域。但不会保证并发修改这个缓存区的内容是的一致性,因此在并发环境下可能会发生异常,需要用户捕获。当然如果仅仅是读入、写出,倒没什么问题。JAVA的默认实现类,会返回一个DirectByteBuffer的实例,它实现了DirectBuffer接口,继承自MappedByteBuffer。

public abstract long transferTo(long position, long count, WritableByteChannel target) throws IOException;这个方法将当前的FileChannel实例的内容,转移到目标可写的Channel通道,(这里说转移,而不说拷贝,是因为这个方法的行为依赖于底层操作系统的实现,如果操作系统支持将指定缓冲区的内容直接转移到目标区域,而不是简单的拷贝),具体能够写出到目标通道多少内容,既跟当前Channel的读取位置有关,也有与目标Channel的可用缓存区大小有关。

综上,如果想要在JAVA程序中实现”零拷贝“的功能,可以通过Channel的技能的方法,比如就FileChannel来讲,可以通过mmap技术,或是sendfile(…)的系统调用完成,分为对应于FileChannel.map(…)
和FileChannel.transferTo(…)方法。

Netty中的零拷贝

预备知识:ByteBuf /CompositeByteBuf
ByteBuf:一个可以随机和顺序访问的字节内容的缓存区。一般地,通过Unpooled辅助类的来创建此类的实例,这个类主要通过两个指针来完成工作,一个是读指针,一个是写指针,就像文件指针那样,因此具体的行为跟JAVA NIO ByteBuffer并不相同,这一点尤为重要,这也就导致在程序中如果想转换Netty的ByteBuf类型到JAVA的ByteBuffer类型时,需要通过nioBufferCount
方法做一次类型判断。

CompositeByteBuf:ByteBuf的子类,可以将一个或多个ByteBuf的所包含的内存区域,组合成一个虚拟的缓存区,所谓虚拟,是指其中的每一个缓存区还是原来的ByteBuf所持有的,并不会通过拷贝的方式来将所有的这些不连续的内存区域合并成一块连续的内存区域,而是逻辑上将这些ByteBuf对象组织成一个顺序的内存区域,因此说是虚拟的。同样,推荐通过Unpooled辅助类来创建此对象。

FileRegion:一个文件操作类的基类,能够以零拷贝的方式完成文件内容的转移,底层依赖于JAVA的FileChannel.transferTo(…)方法,默认的实现是DefaultFileRegion,可以认为就是对FileChannel.transferTo(…)方法的封装。
Netty学习之从下到上成就零拷贝
Netty学习之从下到上成就零拷贝
Netty学习之从下到上成就零拷贝
Netty学习之从下到上成就零拷贝

总结

综上,Netty中的Zero-copy体现在如下几个方面:

  • Netty提供了CompositeByteBuf类,它可以将多个ByteBuf合并为一个逻辑上的ByteBuf,避免了各个ByteBuf之间的拷贝。
  • 通过wrap操作,我们可以将byte[]数组、ByteBuf、 ByteBuffer 等包装成一个 Netty ByteBuf对象,进而避免了拷贝操作。
  • ByteBuf支持slice 操作,因此可以将ByteBuf分解为多个共享同一个存储区域的ByteBuf,避免了内存的拷贝。
  • 通过FileRegion包装的FileChannel.tranferTo实现文件传输,可以直接将文件缓冲区的数据发送到目标Channel,避免了传统通过循环write方式导致的内存拷贝问题。
    前三个是在数据表示层的零拷贝的过程,而第四个方面就是与操作系统有关的零拷贝的过程 。

参考资料
【1】java中的零拷贝,https://www.jianshu.com/p/2fd2f03b4cc3

上一篇:Netty实战十之编解码器框架


下一篇:阿里高工手写13万字的“Netty速成手册” 魔战3个月直接成功拿下阿里offer