写点什么

网络 IO 是如何一步一步走向零拷贝的

作者:C++后台开发
  • 2022 年 9 月 08 日
    湖南
  • 本文字数:3766 字

    阅读完需:约 12 分钟

你们知道当程序需要读取或者写入数据的时候,CPU 是如何操作我们的磁盘的吗?首先 CPU 肯定是要把读写数据的命令告诉给磁盘,这个命令可以通过 IO 总线传给磁盘,那这里有个细节,其实我们常说的磁盘不仅仅是只包含存储数据的媒介,还有接口,接口相信大家都熟悉,接口的意义不仅仅是为了连接到 IO 总线上的,其实这个接口里还有个叫做控制器的东西,控制器才是真正控制磁盘读写的东西,当 CPU 发出读写指令的时候,这个指令其实是告诉磁盘控制器的。以读为例,当控制器收到读的请求时,它告诉磁盘:“你把 xx 数据给我吧”,当机械硬盘经过转动、寻道找到目标扇区后,把目标数据给磁盘控制器:“哥,这是你要的数据”,控制器收到数据之后,其实不会立马通知 CPU,因为需要读的数据可能涉及到多个扇区,如果每读一个扇区的数据就通知,会导致效率低下。

CPU:“控制器老弟,你这是搞事啊,我很忙的,每次搞这么点数据就通知我,能不能把我需要的数据都准备好,再通知我”。“控制器”:“好的,CPU 老哥”。

于是控制器内部就搞了个缓冲区,把读到的数据先缓存起来,然后通知 CPU 来取数据,但是问题又发生了...

CPU:“控制器老弟,数据你是准备好了,但是你给我的数据已经是损坏的,玩我呢!”“控制器”:“CPU 老哥,俺错了,下次一定不会”。

于是控制器为了判断读到的数据是否发生了损坏,会先计算下校验和,如果校验和不通过,那么就不会通知 CPU 来取坏的数据了。

当缓冲区快要满了或者需要读的数据已经读完了并且校验数据也是 OK 的,这时控制器就会发出个中断:“CPU 老哥,你要的数据好了,过来取吧”,于是 CPU 屁颠屁颠的过来拿数据,当然它也是分批拿的,每次从控制器的缓冲区中一个字节一个字节的拿,直至取完。整个过程看起来还不错,但是有个很严重的效率问题:CPU 每次取数据的单位有点小(一个字节),这样势必造成 CPU 多次往返,那有什么办法解决这个问题呢?我们接着往下看。

缓冲

在讲缓冲之前,我们先了解一下当我们的程序发出 read 的时候,数据是怎么返回的,首先和设备打交道的时候,需要发起系统调用,系统调用会导致进入内核态,然后 CPU 去读数据,读到数据后,在把数据返给用户程序,这时又回到用户态。

​这里我们先着重看下数据从内核态到到用户态的过程,通过上文我们知道 CPU 是一个字节一个字节的读取数据的,当 CPU 拿到数据之后,可以有这样几个选择:

  1. 每次读到一个字节后立马发出中断,然后由中断程序把每个字节交给用户进程,用户进程收到数据之后,再发起下个字节的读取,就这样不停的循环...,直至把数据读完。这种模式的问题在于每个字节都要唤起进程,然后用户进程继续阻塞等待下个字节的到来,很傻很低效。


  1. 用户程序可以每次多读点数据,比如每次告诉 CPU:“我要读 n 个字节”,CPU 收到指令后去磁盘把数据读到,当然这里肯定不是一个字节一个字节的发起中断,不然和 1 无区别,由于一开始已经告诉 CPU 要读 n 个字节,所以要等读满 n 个字节后才能发起中断,那如何知道读满 n 个字节了呢?这就需要缓冲了,可以在用户空间开辟一个 n 个字节的缓冲区,当缓冲区满了,再发起中断,相比第一种 n 次中断,这里只需要一次中断,是不是效率提高了许多。


  1. 第二种方法解决了用户程序低效的问题,但是不要忘记了还有 CPU,CPU 还是一个字节一个字节的把数据搬运到用户的缓冲区中,这样看 CPU 还是挺辛苦的,不仅要读取数据,还要低效的把数据从内核空间搬运到用户空间,注意这个在内核空间和用户空间之间的切换还是挺耗费时间的,于是为了减少切换开销,内核空间干脆也搞个缓冲区,等缓冲区有足够多的数据之后,一次性的给到用户程序,这样是不是就高效多了。

可以发现最后一种肯定是效率最高的,这也是现代操作系统普遍使用的方式,然而这种模式也不是百分百的完美,我们来看下相关的时序图。

时序图中我们先重点看下 CPU 这块,可以发现当控制器的缓冲区满了之后需要 CPU 把数据 copy 到内核缓冲区,然后 CPU 再把内核缓冲区的数据 copy 到用户缓冲区,CPU 不仅要负责数据的读写还要负责数据的搬运。

【文章福利】另外小编还整理了一些 C/C++后台开发教学视频,相关面试题,后台学习路线图免费分享,需要的可以自行添加:Q群:720209036 点击加入~ 群文件共享
小编强力推荐 C++后台开发免费学习地址:C/C++Linux服务器开发高级架构师/C++后台开发架构师​

进阶-DMA

“我堂堂 CPU,竟然要为了缓慢的磁盘而卑躬屈膝,能不能给我安排个下手呀,和低等磁盘打交道的任务就交给下手去做吧,还有其他很多进程在等着我调度呢”。于是设计者们就意识到这个问题,为了让 CPU 全身心的投入到调度、计算等工作中,后来就搞了个 DMA(Direct Memory Access),中文名叫直接存取器存取,中文名挺抽象的,别急,我们接着往下看。

首先这个 DMA 它内部也有些寄存器,这些寄存器可以存什么呢?答案是内存地址,严格来说是内核缓冲区的地址。有了 DMA 后,read 操作不再由 CPU 告诉磁盘,而是由 CPU 告诉 DMA:“DMA 同学现在某个程序员要读 xx 数据,你把 xx 数据放到内存地址是 0x1234 的内存里去吧”,DMA 收到老大 CPU 的通知后:“收到了老大,这种小事交给小弟吧,你去忙吧”,到这里 CPU 就去忙别的事了,然后 DMA 就去通知我们的磁盘控制器了:“你先把 xx 数据的这一部分直接读到 0x1234 内存里去吧,读完告诉我一下,我这边还有 xx 数据的另一部分”,磁盘控制器:“好的,老大哥”,就这样每次控制器读完一部分数据之后就会通知 DMA,然后 DMA 让它再读下一个数据,直至把需要读的数据读完,在读完了数据之后,肯定不能完事呀,这时得告诉老大哥 CPU,于是 DMA 发出一个中断:“CPU 大哥,数据已读取完毕,请享用~”,CPU 收到通知后,发现数据已经在内核缓冲区了,不需要亲自干一个字节一个字节搬运的鸟事了,而且这期间 CPU 指挥了三次交通(调度)、扶了四个老奶奶过了马路(计算)。


  1. CPU 告诉 DMA

  2. DMA 告诉磁盘

  3. 磁盘读完之后告诉 DMA

  4. DMA 如果还需要读的话,会重复 2,3 步骤

  5. DMA 干完活之后通知 CPU

DMA 的出现无疑是帮助了 CPU 很多,特别是和 IO 设备打交道这块。

​正常来说我们的程序在发起读数据后,需要等待数据的返回,因此需要 CPU 把内核缓冲区的数据再次 COPY 到用户缓冲区中,同时整个过程用户进程是阻塞的(因为要等数据),这一切看起来很合理,然而其实有这样一种场景:我们需要把读出的数据通过网络发出去,比如 kafka,我们知道 kafka 是非常经典的消息引擎,当消费者需要消费消息的时候,kafka 中的 broker 会把数据读出来,然后发给我们的消费者。

​图中有两次看起来非常沙雕的操作,分别是第 2 步和第 3 步,关键这两步都需要 CPU 亲自参与搬运,并且涉及到内核态->用户态->内核态的上下文切换,这个上下文切换会导致什么呢?答案就是 CPU 需要进行现场保护(活干到一半就被打断了,等忙完了回来还要接着干),这个保护需要花费一定的开销,比如把当前的运行状态给保存下来,程序执行到哪了,寄存器该保存什么值...。

那有什么办法能省掉这次的开销呢?

升华

mmap + write

其实明眼人都看出来了,没必要把一份数据 copy 来 copy 去的,直接用内核态的缓冲区不就行了,这就是 mmap(内存映射),我们还是先来看个例子,通过例子你就明白 mmap 的好处了:

​现在有两个进程 A 和 B,他们都需要读同一份数据,因此每个进程都要开辟一块用户态的缓冲区,即使数据是一样的,并且 CPU 还要发生两次 copy,而且这只是两个进程,如果有更多的进程势必造成更多的内存空间浪费,于是就出现了 mmap,有了 mmap 之后,不需要 cpu copy 数据了,并且进程 A 和进程 B 共享用户空间的一块内存,然后这块内存和内核空间的内存打通,注意这里并不是 copy 而是开启了一个映射,相当于开了一个 VIP 通道,有了 VIP 通道之后,同一份数据对于不同的进程不需要维护不同的内存空间了,因为大家共享一个公共的内存空间。

​mmap 只是打通了用户空间和内核空间之间的通路,可以说路是通了,接下来还要发数据呀,因此这时一般调用 write 把数据发出去,有了 mmap+write ,我们再来看看这时数据是如何发出的:


  1. 首先肯定是程序发出 mmap 系统调用请求,然后 DMA 把数据 copy 到内核缓冲区去

  2. DMA copy 完之后,把内核缓冲区映射到用户缓冲区,注意映射和 copy 不一样,比 copy 的开销小

  3. 然后用户程序再次发起 write 请求

  4. 这时系统会把内核缓冲区的数据直接发到 socket 缓冲区

  5. DMA copy socket 缓冲区数据到网卡

通过 mmap + write 的方式可以发现少了一次 CPU copy,但是系统调用并没有减少,有没有什么办法让系统调用再少些?

sendfile

没有什么能阻止进步的脚步,于是出现了 sendfile,有了 sendfile 函数之后,首先它不需要进行两次系统调用,只需要一次系统调用,当我们 sendfile 之后等于告诉系统:“帮我把 xx 数据直接发出去吧,别再 copy 或者映射进来了,俺不需要,直接发出去就好”。

  1. 当我们发起 sendfile 之后,首先会切到内核态

  2. 然后 DMA 把数据 copy 到内核缓冲区

  3. DMA 把 socket 描述符等传到 socket 缓冲区

  4. 同时 DMA 把数据直接从内核缓冲区 copy 到网卡

可以发现这种方式是目前最优的方式了,通过 sendfile+DMA 技术可以实现真正的零拷贝,整个过程都不要 cpu 搬运数据,也没有上下文切换,kafka 就是利用这种方式来提供吞吐的。

参考资料

​推荐一个零声教育 C/C++后台开发的免费公开课程,个人觉得老师讲得不错,分享给大家:C/C++后台开发高级架构师,内容包括Linux,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK等技术内容,立即学习


原文:网络IO是如何一步一步走向零拷贝的

用户头像

C/C++后台开发技术交流qun:720209036 2022.05.06 加入

还未添加个人简介

评论

发布
暂无评论
网络IO是如何一步一步走向零拷贝的_cpu_C++后台开发_InfoQ写作社区