前言
'零拷贝'这个词大家应该不陌生了,也算是大厂面试中的一个高频考点,玩过 NETTY 的朋友应该对此相当熟悉了,NETTY 的「高并发」很大程度上都是因为 NIO,而 NIO 的核心就是零拷贝技术了,今天就让你十分钟玩懂零拷贝。
传统的IO模型是怎么样的?
我们来看一张图,让我们看看一个文件从磁盘传输到网卡究竟要经历什么样的磨难:
- 「第一步」:将文件通过 「DMA」 技术从磁盘中拷贝到内核缓冲区
- 「第二步」:将文件从内核缓冲区拷贝到用户进程缓冲区域中
- 「第三步」:将文件从用户进程缓冲区中拷贝到 socket 缓冲区中
- 「第四步」:将socket缓冲区中的文件通过 「DMA」 技术拷贝到网卡
这种数据存储的区域整体我们把它叫做「非直接缓冲区」。
我们发现,居然有四步数据拷贝的过程!!并且整个数据的传输过程都是「需要 CPU 去执行」的。
这个过程也太繁琐了,我就想传输一些数据,干嘛要传到用户这里,还要我自己再走一遍后续的流程,写到 socket 缓冲区再发出去,你不能帮我实现吗?
怎么去优化传统 IO 的流程呢?
我们继续看上面的流程图理一下,看看哪些步骤是可以去掉的
我们发现在整个过程中,数据从磁盘读出来到发送给网卡,「文件内容」都是「不会发生改变」的,但是我却要经历「4次文件内容的拷贝」才真正能将文件传输到网卡。
那么以最简单的的方式来说,「能不能直接将磁盘中的数据传输到网卡呢?」
当然不可以,这个原因也很简单,因为「网卡和磁盘都是外部设备」,所以一定要有一个中间的缓冲区域来取存储数据,做一个转发的作用。
那么我们看上图中能做缓冲的有两个区域,一个是 「socket缓冲区」,一个是「内核缓冲区」,那么用哪一个?
这个问题应该很好选择了,socket 肯定不可以,socket 和我操作系统无瓜,那么只有用内核缓冲区来做缓冲区。
那么能不能通过「内核缓冲区直接给网卡」发送数据呢?
看样子是可以的,那么我们来看看,socket 缓冲区的作用是什么?
socket缓冲区的作用
每个 socket 被创建后,都会分配两个缓冲区,输入缓冲区和输出缓冲区。
write()/send() 并不立即向网络中传输数据,而是先将数据写入缓冲区中,再由 TCP 协议将数据从缓冲区发送到目标机器。一旦将数据写入到缓冲区,函数就可以成功返回,不管它们有没有到达目标机器,也不管它们何时被发送到网络,这些都是 TCP 协议负责的事情。
所以socket就是用来「传输网络数据」的,看来没它还不行。
但是我们换个思路,是不是说,只需要「告诉 socket 要传输哪些数据」就可以了?然后文件内容就可以直接用内核缓冲区的就好了。
零拷贝(zero copy)是怎么做到性能提升的
当你读懂了上面的内容,基本上已经能摸到零拷贝的核心脉络了,其实零拷贝就是使用「内存映射」来消除数据拷贝次数的,然后使用 「DMA」 技术来减少CPU的工作时间。
就只从拷贝次数的性能来看,可以讲性能提高至少百分之五十以上。
DMA
上文中经常提到一个很重要的词汇 - DMA ,它在整个零拷贝的流程当中是有很大的占比的,「能帮助 CPU 做大量的工作」,我们来介绍一下这个神奇的技术。
DMA就是「直接存储器访问」,DMA (Direct Memory Access,「直接存储器访问」) 是所有现代电脑的重要特色,它允许不同速度的硬件装置来沟通,而不需要依赖于 CPU 的大量中断负载。否则,CPU 需要从来源把每一片段的资料复制到暂存器,然后把它们再次写回到新的地方。在这个时间中,CPU 对于其他的工作来说就无法使用。
「原理」:DMA 传输将数据「从一个地址空间复制到另外一个地址空间」。当 CPU 初始化这个传输动作,传输动作本身是由 DMA 控制器来实行和完成。
零拷贝整体流程图
看到这里的话相信你对零拷贝已经有了深刻的理解,那么 NIO 到底是什么的?既然说了十分钟让你玩懂 NIO 和零拷贝,那 NIO 必不可少。
为什么需要 NIO ?
所有的系统I/O都分为「两个阶段」:
- 1.等待就绪
- 2.读写操作
需要说明的是等待就绪的阻塞是不使用CPU的,是在“「空等」”;而真正的读写操作的阻塞是使用CPU的,真正在”干活”,而且这个过程非常快,属于memory copy,带宽通常在1GB/s级别以上,可以理解为「基本不耗时」。
我们先来看看传统IO是怎么做的
在传统的 socket IO中,需要为每个连接创建一个线程。
「一个线程对应一个连接,只处理一个连接的事情」,这就是传统的socket IO。
当「并发的连接数量非常巨大」时,线程所占用的栈内存和CPU线程「切换的开销就会非常大」。
在这种情境下还可能会出现「线程数量小于连接数量」的情况,所以每个线程进行 I O操作时就不能阻塞,如果阻塞的话,有些连接就得不到处理。
如上图,假设有三条线程在管理三条连接,如果此时有第四个任务插入,那么就只能等待前面任务执行完成。
其操作就像是一条流水线一样,是串行阻塞的,故传统 IO 我们也称为 「BIO」。
传统 IO 也「不知道什么时候该处理数据」,所以只能一直傻等。
为了解决这些问题,NIO 就出现了。
NIO 是 怎么解决这些问题的?
我们先来介绍一下 NIO 的核心组件
- channel(通道)
- 一个channel(通道)代表和某一实体的连接,这个实体可以是文件、网络套接字等。也就是说,通道是Java NIO提供的一座「桥梁」,用于我们的程序和操作系统底层I/O服务进行交互
- buffer(缓冲区)
- selectors(选择器)
- selector(选择器)是一个特殊的组件,用于采集各个通道的状态(或者说事件)。我们先将「通道注册到选择器,并设置好关心的事件」,然后就可以通过调用select()方法,静静地等待事件发生。
通道有如下4个事件可供我们监听:
Accept:有可以接受的连接
Connect:连接成功
Read:有数据可读
Write:可以写入数据了
我们首先需要注册当这几个事件到来的时候所对应的处理器。然后在合适的时机告诉事件选择器:我对这个事件感兴趣。
也就是说,在选择器上注册了这四个事件的处理器,用来处理 channel 的事件,「当 channel 某个事件真的准备就绪了,可以进行下一步的动作时,再告诉服务端来处理相应的数据,把相应的任务分配给服务端」,这样就能更好的利用 cpu 的资源。
前面我们说的零拷贝,就是在这时数据处理时发生的。
NIO 和 IO 有什么区别?
- 1.NIO是以「缓冲区(块)」 的方式处理数据,IO是以「流」的形式去写入和读出的。
- 2.NIO 又是基于这种流的形式,采用了通道和缓冲区的形式来进行处理数据的
- 3.还有一点就是 NIO 的通道是可以「双向」的,但是 IO 中的流只能是「单向」的
- 4.还有就是 NIO 的缓冲区还可以进行分片,可以建立「只读缓冲区、直接缓冲区和间接缓冲区」,直接缓冲区是为加快 I/O 速度,而以一种特殊的方式分配其内存的缓冲区
- 5.读写触发方式不同,NIO 是以选择器的轮询机制来触发的, IO是收到信息即触发。
总结
从传统 IO 模型 到 NIO 零拷贝模型我们可以看出,一个新技术的产生到崛起肯定是因为「其能满足之前技术满足不了的需求,或者相对于之前技术的性能有很高的提升」。
传统 IO 传输需要进行四次的数据内容拷贝,包括「内核态和用户态」的切换,「内核态和数据载体」(磁盘、网卡)的切换,整个过程是阻塞的,过程浪费了很多资源。
而 NIO 是通过选择器,通道等核心模块,将整个 IO 处理过程变为异步的方式,只有其数据任务真正就绪了,才会让 cpu 去做处理,大量的节省了资源,提高了性能。
零拷贝就是让用户态和内核态之间的数据不再通过拷贝的方式传输,使用了「内存映射」,做到了内核态和用户态数据的零拷贝。
其拷贝方式使用了 「DMA」 技术,其目的就是为了解决 CPU 拷贝数据的方式,让「拷贝数据」这种累活「不再占用 CPU 的资源」,有 DMA 去完成。