Linux I/O模型与零拷贝|青训营笔记

177 阅读17分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的第4篇笔记

Linux I/O模型与零拷贝

1.I/O种类

根据前面Linux的读写方式提到,I/O主要分为两步:①数据从磁盘/网卡拷贝到内核缓冲区 ②数据从内核缓冲区拷贝到用户缓冲区(read)(两步都由内核态完成)

①同步阻塞I/O: 用户线程system call后阻塞,切换到内核线程。内核态完成①②后返回

②同步非阻塞I/O: 用户线程system call后不阻塞,而是收到一个成功/失败的结果。当内核态完成①后用户线程system call则成功,否则失败。用户线程可以不用阻塞一直轮询的system call直到返回成功。此时用户线程也就知道内核把①完成了。用户线程再调用system call后阻塞,交由内核线程完成②操作。由此可见,同步非阻塞I/O时①操作不阻塞,②操作依旧阻塞

③I/O多路复用: 与同步阻塞I/O基本相同,也是①②操作两段都阻塞。只不过I/O多路复用监听多个端口信息,如果有一个①操作处理成功就会返回成功(Reactor)

④信号驱动I/O: 与同步非阻塞I/O类似。用户线程system call后不阻塞,但也不会轮询system call,内核完成操作①会给用户线程发送信号。操作②依旧阻塞

⑤异步I/O: 完整的异步I/O,用户线程system call后不需要阻塞。内核完成①后自动完成②,都完成后再用信号通知用户线程。用户线程整个①②过程都不阻塞(Proactor)

异步I/O的缺点

  1. 编程比同步IO要复杂,需要额外的学习成本,不容易掌握;
  2. 多线程的引入提高了调试的复杂度,有时候很难debug;
  • 阻塞/非阻塞——是站在应用程序角度来说的,如果调用者要数据,此时内核没准备好,调用者不用傻等,那就是非阻塞的,否则就是阻塞的
  • 同步/异步——是站在内核角度来说的,如果内核不会主动给请求者它想要的数据,那就是同步的,否则就是异步的

read是把数据从内核缓冲区复制到进程缓冲区。write是把进程缓冲区复制到内核缓冲区。当然,write并不一定导致内核的写动作(不等价于写入磁盘),比如os可能会把内核缓冲区的数据积累到一定量后,再一次写入。这也就是为什么断电有时会导致数据丢失。所以说内核缓冲区,是为了在OS级别,提高磁盘IO效率,优化磁盘写操作。

2.I/O多路复用

当一个socket收到多个网络请求时,应当如何处理呢?为达到异步效果,可以使用多线程,但是当连接数很大的时候,线程数太多,频繁的切换线程会极大的增大系统的开销。那就选择使用单线程。但单线程如何实现I/O多路异步复用呢?我们可以有这样一个思路:

在linux中,网络连接是一个文件描述符fd,那么我们在单线程中可以遍历一组fd,如果fd中有数据,则处理,没有数据就跳过,遍历完之后又要继续重新遍历,所以在外层再设置一个死循环。伪代码如下(假设有五个网络连接fdA~fdE):

while(true){
    for(fdx in fdA~fdE)
        if(fdx 有数据){
            处理
        }
    continue;
}

1.select

select是干什么的呢?select是linux内部的一个函数,主要的作用就是完成上述伪代码的“判断fd是否有数据”。实现方式是传入两个参数:①文件编号最大值+1 ②一个bitmap,bitmap是一个1024位的比特序列,传入bitmap前要对bitmap进行文件编号对应位初始化:

如文件编号 1,2,5,7,9,则bitmap的比特流为01100101010。传入select函数后,select将bitmap从用户态拷贝到内核态进行是否有数据校验,有数据则对对应的bitmap比特位 置位为0,然后返回,如果一直没有fd有数据,则select一直阻塞

image-20220222201322200

优点:

  • 内核监听fd效率高

缺点:

  • bitmap虽然有max设置大小,但是它是有上限的,上限是1024
  • rset不能复用,每次使用完后都要重新赋值
  • 用户态和内核态的切换开销
  • select没有返回哪些网络连接是有数据的,所以select后又要重新遍历fd[i]和rset[i],增加时间复杂度

2.poll

poll是对select的改进,它不选择使用bitmap记录fd的信息,而是重新定义一个pollfd的结构体,结构体内部有三个属性,一个fd对应fd的编号,一个events对应这个fd关心的事件,一个revents对应fd关心事件的反馈。poll和select一样,也是把记录好的pollfd结构体队列拷贝到内核态,然后让内核进行判断,内核发现有数据就会对revents置位,没有数据则阻塞。poll返回之后,外部代码就直接拿到pollfd的revents属性进行判断,然后进行相应的事件处理,同时把revents置为0,保证下一个循环pollfd的复用

image-20220222203400436

相比select的改善:

  • 用pollfd代替bitmap,没有上限
  • pollfd只需要将revents置为0即可重用

但依旧遗留select的后两个缺点

3.epoll

epoll是对poll的进一步改进,它使用epfd结构体,相比pollfd结果体少了revent属性。那epoll是如何解决select的后两个缺点的呢?

epfd不像poll的pollfd要拷贝到内核态,在内核态和用户态之间切换,而是直接放在内核态和用户态的共享区域进行监听,不需要内核态和用户态的切换。同时epfd发现fd有数据时并不进行置位,而是对epfd队列进行重排,有数据的都放前面,然后返回有数据的fd个数。这样一来,外部代码拿到epfd后只需要遍历它返回的个数的fd执行相应的处理即可。故epoll可以解决select的剩余两个问题

image-20220222204824108

3.高性能网络模式

针对一个网络程序,当接收到多个网络请求时,为了保证响应效率,我们应该如何处理呢?

前面提到,为了到达异步响应的要求,可以使用多线程,但是当网络中请求的数量非常大的时候,多线程就需要不断切换上下文,极大的增加系统开销。如果选择使用单线程,我们可以逐个遍历网络请求连接符,就是网络连接的文件描述符,如何逐个循环判断其中是否有数据输出,如果有,则该网络请求需要处理,如果没有,这使用非阻塞socket直接跳过,然后再在外部套接一个死循环,不停的对网络请求进行处理。虽然这种方式非常的粗暴,运行效率还不错,但是每次都判断是否有数据需要极大的开销,我们可以对它进行优化,即选择I/O多路复用的模式。上面已经对该模式进行了说明。但是I/O复用是面向过程的,我们平常都是面向对象的,所以对我们来说开发效率并不高。于是,一些大佬就对I/O多路复用再进行了一次封装,让使用者不用考虑底层网络的api细节,只需要关注应用代码的编写。有两种模式:①Reactor模式 ②Proactor模式

1.Reactor

  • Reactor模式有三种实现方式:

    • 单reactor/单线程

    一个应用程序设计有三个对象,reactor、acceptor、handler:reactor对象内部有两个api,分别是I/O多路复用监听api和事件类型分配api。I/O多路复用监听就是上面提到的select、poll、epoll等系统调用,主要负责监听哪些网络连接是有数据的(即需要处理的)然后根据事件类型转发到不同对象(这里的事件分为两种,第一种为建立连接事务,分发给acceptor对象进行连接建立,并创建一个hanlder对象来处理后续的响应事件。其他的事件才会分配给hanlder进行事务处理(read->业务处理->send))但是,这种模式有两个缺点:①单线程无法充分利用多核cpu的效率 ②所有的请求有无数据判断和事件分配都由一个Reactor完成,当网络连接量非常大的时候,单Reactor就会成为影响该模型性能的瓶颈 ③当handler在处理业务的时候,整个进程是无法处理其他连接事件的,如果一个业务处理的耗时比较长,那么其他连接就会出现长时间未响应的情况,造成网络的延时(redis)

image-20220223114250605

  • 单Reactor/多线程

这种模式相对与第一种模式有了一些变化,最主要的变化是handler只完成(read->业务处理->send)三步中的read和send,而其中的业务处理由其内部产生的子线程处理,从而解决一个耗时业务的处理造成其他业务长时间无响应的情况,解决了单reactor/单线程的第三个缺点,同时又是多线程,所以也解决了第一个缺点。但依旧遗留了单reactor成为性能瓶颈的缺点

image-20220223114531905

  • 多Reactor/多线程

这个模式相比第二种模式又有了一定的改变,reactor使用多线程,其中主线程主要处理网络请求的连接事件,然后将链接请求交给子线程管理,子线程对连接的网络请求进行监听并且分发,其余和第二种模式一样。这个模式就在第二个模式的基础上解决了单reactor成为性能瓶颈的缺点(netty,memcache)

image-20220223114939078

还有一个多reactor/多进程的模式,和这个稍微有点差别,即主进程只创建socket,把连接的任务也分给了其下的子进程,如nginx服务器

2.Proactor

reactor是非阻塞的同步网络模式,而proactor是异步网络模式,这里的同步与异步指的并不是响应,而是指应用程序是否需要主动调用read方法把数据读入内存。Reactor是对网络请求进行监听,如果发现网络请求有数据了,就应用程序进行处理。而Proactor是对网络请求进行监听,且完成网络请求的读写事件,再交给应用程序进行处理,所以我们可以这样理解,reactor是来了事件操作系统就通知应用进程进行处理,而proactor是来了事件之后操作系统进行处理,处理完之后交由应用进程进行处理,所以说reactor是基于I/O已准备好待完成的I/O事件分发,而proactor是基于I/O已完成事件的分发

零拷贝

1.传统读写操作

  • 根据Linux 读写I/O方式的分析,执行read/ write 系统调用读/写数据到磁盘/网络时,数据都需要在用户空间<——>内核空间<——>磁盘/网卡缓存区之间进行转化,用户空间到内核空间由cpu拷贝完成,采用DMA技术的情况下内核空间到设备缓冲区由DMA拷贝完成,因此需要用到一次CPU拷贝、一次DMA拷贝

2. 在 Linux 中零拷贝技术主要有 3 个实现思路:用户态直接 I/O、减少数据拷贝次数(mmap + write/sendfile)、写时复制

  • 用户态直接 I/O:应用程序可以直接访问硬件存储, 操作系统内核只是辅助数据传输。这种方式依旧存在用户空间和内核空间的上下文切换,硬件上的数据直接拷贝至了用户空间,不经过内核空间。 因此,直接 I/O 不存在内核空间缓冲区和用户空间缓冲区之间的数据拷贝用户态直接 I/O 只能适用于不需要内核缓冲区处理的应用程序,这些应用程序通常在进程地址空间有自己的数据缓存机制,称为自缓存应用程序,如数据库管理系统就是一个代表。其次,这种零拷贝机制会直接操作磁盘 I/O,由于 CPU 和磁盘 I/O 之间的执行时间差距,会造成大量资源的浪费,解决方案是配合异步 I/O 使用。

    使用直接 I/O 读写数据必须要注意缓冲区对齐( buffer alignment )以及缓冲区的大小的问题,即对应 read() 以及 write() 系统调用的第二个和第三个参数。这里边说的对齐指的是文件系统块大小的对齐,缓冲区的大小也必须是该块大小的整数倍。

    ①缓冲区大小与文件系统块对齐 ②缓冲区的大小也必须是文件系统块大小的整数倍

    直接I/O 缺点

    1. 这种方法只能适用于那些不需要内核缓冲区处理的应用程序,这些应用程序通常在进程地址空间有自己的数据缓存机制,称为自缓存应用程序,如数据库管理系统就是一个代表。
    2. 这种方法直接操作磁盘 I/O,由于 CPU 和磁盘 I/O 之间的执行时间差距,会造成资源的浪费,解决这个问题需要和异步 I/O 结合使用。

img

  • 减少数据拷贝次数(mmap + write/sendfile): 使用 mmap 的目的是将内核读缓冲区(read buffer)(注意,内核的读写缓冲区是分开的,用户缓冲区只能选择其中一个映射)的地址与用户空间的缓冲区(user buffer)进行映射,从而实现内核缓冲区与应用程序内存的共享,省去了将数据从内核读缓冲区(read buffer)拷贝到用户缓冲区(user buffer)的过程,然而内核读缓冲区(read buffer)仍需将数据到内核写缓冲区(socket buffer)。

  • 用户进程通过 mmap() 函数向内核(kernel)发起系统调用,上下文从用户态(user space)切换为内核态(kernel space)。

  • 将用户进程的内核空间的读缓冲区(read buffer)与用户空间的缓存区(user buffer)进行内存地址映射。

    • CPU利用DMA控制器将数据从主存或硬盘拷贝到内核空间(kernel space)的读缓冲区(read buffer)。
  • 上下文从内核态(kernel space)切换回用户态(user space),mmap 系统调用执行返回。

    • 用户进程通过 write() 函数向内核(kernel)发起系统调用,上下文从用户态(user space)切换为内核态(kernel space)。
  • CPU将读缓冲区(read buffer)中的数据拷贝到的网络缓冲区(socket buffer)。

    • CPU利用DMA控制器将数据从网络缓冲区(socket buffer)拷贝到网卡进行数据传输。
  • 上下文从内核态(kernel space)切换回用户态(user space),write 系统调用执行返回。

因为内核缓冲区有两个,分别为读和写。而用户缓冲区只有一个,读写并用。所以mmap的系统调用会让内核把磁盘数据拷贝到内核读缓冲区,同时,让用户缓冲区映射该块内存。达到零拷贝的目的。write系统调用原意是修改用户缓存区的数据,但由于用户缓冲区和内核读缓冲区发生了映射,所以实际修改的是内核读缓冲区的数据。而内核如果想要把这些数据写入磁盘就必须把读缓冲区数据拷贝到写缓冲区,再写入磁盘。

上面的分析是一个用户进程共享内核的某块读缓冲区。如何多个进程共享且都要修改呢?(例如多个进程都从磁盘读取文件a,同时用write对其修改)那么此时这些用户进程的用户缓冲区都会映射内核的同一块读缓冲区,所以为了避免进程间write的相互影响,采用了写时复制技术。即只有进程调用write的时候,内核才会把读缓冲区数据拷贝到用户进程的缓冲区,而其他进程继续通过映射的方式读取数据,而不是拷贝

img

sendfile

  1. 用户进程通过sendfile()方法向操作系统发起调用,上下文从用户态转向内核态
  2. DMA控制器把数据从硬盘中拷贝到读缓冲区离散存储
  3. CPU把读缓冲区中的文件描述符和数据长度发送到socket缓冲区
  4. DMA控制器根据文件描述符和数据长度,使用scatter/gather把数据从内核缓冲区拷贝到网卡
  5. sendfile()调用返回,上下文从内核态切换回用户态

image-20220517225703011

sendfile方法IO数据对用户空间完全不可见,所以只能适用于完全不需要用户空间处理的情况,比如静态文件服务器

Linux2.4内核版本之后对sendfile做了进一步优化,通过引入新的硬件支持,这个方式叫做DMA Scatter/Gather 分散/收集功能。DMA Scatter/Gather 分散/收集功能:cpu将读缓冲区中的数据描述信息--内存地址和偏移量记录到socket缓冲区,由 DMA 根据这些将数据从读缓冲区拷贝到网卡,相比之前版本减少了一次CPU拷贝的过程。但是还是需要占用数据总线。

由于CPU和IO速度的差异问题,产生了DMA技术,通过DMA搬运来减少CPU的等待时间。

总结

对于 MQ 而言,无非就是生产者发送数据到 MQ 然后持久化到磁盘,之后消费者从 MQ 读取数据。

对于 RocketMQ 来说这两个步骤使用的是mmap+write,而 Kafka 则是使用mmap+write持久化数据,发送数据使用sendfile

传统的IO**read+write方式会产生2次DMA拷贝+2次CPU拷贝,同时有4次上下文切换**。

而通过**mmap+write方式则产生2次DMA拷贝+1次CPU拷贝,4次上下文切换**,通过内存映射减少了一次CPU拷贝,可以减少内存使用,适合大文件的传输。

**sendfile**方式是新增的一个系统调用函数,产生2次DMA拷贝+1次CPU拷贝,但是只有2次上下文切换。因为只有一次调用,减少了上下文的切换,但是用户空间对IO数据不可见,适用于静态文件服务器。

sendfile+DMA gather方式产生2次DMA拷贝,没有CPU拷贝,而且也只有2次上下文切换。虽然极大地提升了性能,但是需要依赖新的硬件设备支持。

3. 写时复制技术

  • 在某些情况下(例如上面的mmap+writer),内核缓冲区可能被多个进程的缓冲区所共享,如果某个进程想要这个共享区进行 write 操作,由于 write 不提供任何的锁操作,那么就会对共享区中的数据造成破坏,写时复制的引入就是 Linux 用来保护数据的
  • 写时复制指的是当多个进程共享同一块数据时,如果其中一个进程需要对这份数据进行修改,那么就需要将其拷贝到自己的进程地址空间中。这样做并不影响其他进程对这块数据的操作,每个进程要修改的时候才会进行拷贝,所以叫写时拷贝。 这种方法在某种程度上能够降低系统开销,如果某个进程永远不会对所访问的数据进行更改,那么也就永远不需要拷贝。