参考文献

I/O读写的基本原理

  • 为了避免用户进程直接操作内核,保证内核安全,操作系统将内存(虚拟内存)划分为两个部分:

    • 内核空间(Kernel-Space)
    • 用户空间(User-Space)
  • 针对Linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间

  • I/O 请求可以分为两个阶段,分别为调用阶段和执行阶段.

    • 第一个阶段为I/O 调用阶段,即用户进程向内核发起系统调用.
    • 第二个阶段为I/O 执行阶段.此时,内核等待 I/O 请求处理完成返回.
      • 该阶段分为两个过程:首先等待数据就绪,并写入内核缓冲区;
      • 随后将内核缓冲区数据拷贝至用户态缓冲区.
  • 在Linux系统中,内核模块运行在内核空间,对应的进程处于内核态;用户程序运行在用户空间,对应的进程处于用户态

  • 用户进程不能访问内核空间中的数据,也不能直接调用内核函数,因此需要将进程切换到内核状态才能进行系统调用.用户态进程必须通过系统调用(System Call)向内核发出指令,完成调用系统资源之类的操作

  • 用户程序进行I/O的读写依赖于底层的I/O读写,基本上会用到底层的readwrite两大系统调用.但read/write系统调用都不是直接在物理设备做读取/写入操作.

    • 即上层应用通过操作系统的read系统调用把数据从内核缓冲区复制到应用程序的进程缓冲区,
    • 通过操作系统的write系统调用把数据从应用程序的进程缓冲区复制到操作系统的内核缓冲区.
    • 内核缓冲区<==>进程缓冲区

内核态空间

  • 内核态空间通常包括以下几个组件:
    • 进程管理器:负责管理系统中的进程,例如创建、撤销、调度进程等操作.
    • 内存管理器:负责管理系统中的内存,例如内存分配、释放、页面置换等操作.
    • 文件系统:负责管理系统中的文件和目录,例如文件的创建、读写、删除等操作.
    • 网络协议栈:负责处理网络数据包和协议,例如TCP/IP协议栈、UDP协议栈等.
    • 设备驱动程序:负责管理系统中的硬件设备,例如磁盘、键盘、鼠标等.
    • 安全模块:负责管理系统的安全性,例如访问控制、文件权限等.
    • 系统调用接口:提供给用户空间程序使用的系统调用接口,例如open()、read()、write()等.

为什么需要区分内核态和用户态?

  • 保证安全性

    • 限制用户程序的内存访问、IO 访问等权限,防止用户程序对系统造成损害
    • 提供一个中间层,对不同种类的硬件,驱动进行屏蔽隔离.
    • 保证系统的安全和稳定,内核基于权限和规则进行访问控制.
  • 用户态空间应用无法执行内核代码,不能直接调用内核函数.内核在受保护的地址空间运行

    • 通过通知来告诉操作系统来执行内核调用,如用户态应用需要打开文件,则需要由用户态切换到内核态,从而代表用户线程来运行
    • 通知使用软中断来通知,软中断即引发一个异常,让内核来处理这个异常,处理异常的程序就是system_call

磁盘I/O如何进行交互

  • 从磁盘到磁盘缓冲区
    • 当主机需要进行磁盘I/O操作时,首先会发出I/O请求,请求会被传递到磁盘控制器.磁盘控制器会根据I/O请求中的信息,将磁盘寻道臂移动到正确的位置,然后旋转磁盘盘片,将所需数据读取到磁盘缓存区中.
  • 从磁盘缓冲区到内核态缓冲区
    • 当数据读取到磁盘缓存区中后,磁盘控制器会向主机发送一个中断信号,通知主机数据已经准备好了.主机在接收到中断信号后,会通过DMA(Direct Memory Access,直接内存访问)技术,将数据从磁盘缓存区中读取到内核态缓冲区中.
  • 在数据传输完成后,主机会向磁盘控制器发送一个完成信号,磁盘控制器会将磁头移动到原来的位置,等待下一次I/O请求的到来.
  • 需要注意的是,在磁盘I/O操作中,磁盘控制器的缓存区和主机内存之间的数据传输通常是通过DMA技术来实现的,这样可以减少CPU的负担,提高数据传输的效率.

网络I/O如何进行交互

  • 等待网络数据到达网卡

    • 在进行网络输入操作时,首先需要等待网络数据到达网卡.当数据到达网卡时,网卡会产生一个中断信号,通知操作系统内核有数据到达.
  • 从网卡读取到内核缓冲区

    • 当内核接收到中断信号后,内核会调用相应的网络中断处理程序,将数据从网卡中读取到内核缓冲区中.内核缓冲区是在内核态中分配的一块内存区域,用于存储网络数据包.在读取数据包时,内核会进行一些基本的网络协议处理,例如校验和的验证、分片的重组等操作.
  • 从内核缓冲区复制数据到用户缓冲区

    • 当数据包被存储在内核缓冲区中后,应用程序需要将其从内核缓冲区中读取到用户空间中.这个过程通常通过系统调用实现,例如recv()或read()等系统调用.在调用这些系统调用时,应用程序需要提供一个缓冲区,内核会将数据从内核缓冲区中复制到这个缓冲区中,并返回实际读取的数据长度.

    • 在将数据从内核缓冲区复制到用户空间的过程中,内核会进行一些安全检查,例如检查缓冲区是否有足够的空间来存储数据、检查用户空间的访问权限等操作.复制数据的过程也可能会涉及到内存映射等技术,以提高数据传输的效率.

DMA

  • 直接内存访问(Direct Memory Access): 在进行 I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务.
  • DMA 是这样一种机制: 要把外设的数据读入内存或把内存的数据传送到外设,原来都要通过 CPU 控制完成,但是这会占用 CPU,影响 CPU 处理其他事情,所以有了 DMA 模式.CPU 只需向 DMA 控制器下达指令,让 DMA 控制器来处理数据的传送,数据传送完毕再把信息反馈给 CPU,这样就可以解放 CPU.

未使用DMA时的传输过程

  • 在没有 DMA 技术前,I/O 的过程是这样的:

    • CPU 发出对应的指令给磁盘控制器,然后返回;

    • 磁盘控制器收到指令后,于是就开始准备数据,会把数据放入到磁盘控制器的内部缓冲区中,然后产生一个中断;

    • CPU 收到中断信号后,停下手头的工作,接着把磁盘控制器的缓冲区的数据一次一个字节地读进自己的寄存器,然后再把寄存器里的数据写入到内存,而在数据传输的期间 CPU 是无法执行其他任务的.

    img

  • 可以看到,整个数据的传输过程,都要需要 CPU 亲自参与搬运数据的过程,而且这个过程,CPU 是不能做其他事情的.

使用了DMA的传输过程

img

  • 具体过程:

    • 用户进程调用 read 方法,向操作系统发出 I/O 请求,请求读取数据到自己的内存缓冲区中,进程进入阻塞状态;

    • 操作系统收到请求后,进一步将 I/O 请求发送 DMA,然后让 CPU 执行其他任务;

    • DMA 进一步将 I/O 请求发送给磁盘;

    • 磁盘收到 DMA 的 I/O 请求,把数据从磁盘读取到磁盘控制器的缓冲区中,当磁盘控制器的缓冲区被读满后,向 DMA 发起中断信号,告知自己缓冲区已满;

    • DMA 收到磁盘的信号,将磁盘控制器缓冲区中的数据拷贝到内核缓冲区中,此时不占用 CPU,CPU 可以执行其他任务;

    • 当 DMA 读取了足够多的数据,就会发送中断信号给 CPU;

    • CPU 收到 DMA 的信号,知道数据已经准备好,于是将数据从内核拷贝到用户空间,系统调用返回;

  • 可以看到, CPU 不再参与「将数据从磁盘控制器缓冲区搬运到内核空间」的工作,这部分工作全程由 DMA 完成.但是 CPU 在这个过程中也是必不可少的,因为传输什么数据,从哪里传输到哪里,都需要 CPU 来告诉 DMA 控制器.

  • 早期 DMA 只存在在主板上,如今由于 I/O 设备越来越多,数据传输的需求也不尽相同,所以每个 I/O 设备里面都有自己的 DMA 控制器.

零拷贝(零拷贝内容摘抄自小林coding)

未使用的零拷贝时传输文件过程

  • 如果服务端要提供文件传输的功能,我们能想到的最简单的方式是:将磁盘上的文件读取出来,然后通过网络协议发送给客户端.

  • 传统 I/O 的工作方式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入.

  • 代码通常如下,一般会需要两个系统调用:

    1
    2
    read(file, tmp_buf, len);
    write(socket, tmp_buf, len);

来自于小林coding

  • 图片来自于小林coding-什么是零拷贝?

  • 首先,期间共发生了 4 次用户态与内核态的上下文切换,因为发生了两次系统调用,一次是 read() ,一次是 write(),每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态.

    • 上下文切换到成本并不小,一次切换需要耗时几十纳秒到几微秒,虽然时间看上去很短,但是在高并发的场景下,这类时间容易被累积和放大,从而影响系统的性能.
  • 其次,还发生了 4 次数据拷贝,其中两次是 DMA 的拷贝,另外两次则是通过 CPU 拷贝的,下面说一下这个过程:

    • 第一次拷贝,把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过 DMA 搬运的.
    • 第二次拷贝,把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由 CPU 完成的.
    • 第三次拷贝,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然还是由 CPU 搬运的.
    • 第四次拷贝,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由 DMA 搬运的.
补充

img

  • 图片来自于https://www.linuxjournal.com/article/6345,该小节文字均翻译至上述地址
  • 第一步:read 系统调用导致上下文从用户模式(user mode)切换到内核模式(kernel mode).第一个复制由 DMA 引擎执行,该引擎从磁盘读取文件内容并将其存储到内核地址空间缓冲区(kernel buffer)中.
  • 第二步:数据从内核缓冲区(kernel buffer)复制到用户缓冲区(user buffer),然后read系统调用返回.调用的返回导致上下文从内核模式(kernel mode)切换回用户模式(user mode).现在数据存储在用户地址空间缓冲区中,并且可以再次开始向下传输.
  • 第三步:write 系统调用导致上下文从用户模式切换到内核模式.执行第三次复制以将数据再次放入内核地址空间缓冲区中.不过,这一次,数据被放入不同的缓冲区中,该缓冲区专门与套接字相关联.
  • 第四步:write 系统调用返回,创建第四个上下文切换.当 DMA 引擎将数据从内核缓冲区传递到协议引擎时,会独立且异步地进行第四次复制.您可能会问自己:“独立和异步是什么意思?数据不是在调用返回之前就已经传输了吗?”事实上,调用返回并不能保证传输;它甚至不能保证传输的开始.它只是意味着以太网驱动程序在其队列中有空闲描述符并且已接受我们的数据进行传输.可能有很多数据包在我们的前面排队.除非驱动程序/硬件实现优先级环或队列,否则数据将按照先进先出的原则传输. (上图中的分叉 DMA 复制说明了最后一个复制可以延迟的事实).

如何优化文件传输的性能

先来看看,如何减少「用户态与内核态的上下文切换」的次数呢?

  • 读取磁盘数据的时候,之所以要发生上下文切换,这是因为用户空间没有权限操作磁盘或网卡,内核的权限最高,这些操作设备的过程都需要交由操作系统内核来完成,所以一般要通过内核去完成某些任务的时候,就需要使用操作系统提供的系统调用函数.

  • 而一次系统调用必然会发生 2 次上下文切换:首先从用户态切换到内核态,当内核执行完任务后,再切换回用户态交由进程代码执行.

  • 所以,要想减少上下文切换到次数,就要减少系统调用的次数.

再来看看,如何减少「数据拷贝」的次数?

  • 在前面我们知道了,传统的文件传输方式会历经 4 次数据拷贝,而且这里面,「从内核的读缓冲区拷贝到用户的缓冲区里,再从用户的缓冲区里拷贝到 socket 的缓冲区里」,这个过程是没有必要的.

  • 因为文件传输的应用场景中,在用户空间我们并不会对数据「再加工」,所以数据实际上可以不用搬运到用户空间,因此用户的缓冲区是没有必要存在的.

如何实现零拷贝

  • 零拷贝技术实现的方式通常有 2 种:

    • mmap + write

    • sendfile

  • mmapsendFile 的区别:

    • mmap 适合小数据量读写,sendFile 适合大文件传输.
    • mmap 需要 4 次上下文切换,3 次数据拷贝;sendFile 需要 3 次上下文切换,最少 2 次数据拷贝.
    • sendFile 可以利用 DMA 方式,减少 CPU 拷贝,mmap 则不能(必须从内核拷贝到 Socket 缓冲区).
mmap + write
  • 在前面我们知道,read() 系统调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里,于是为了减少这一步开销,我们可以用 mmap() 替换 read() 系统调用函数.

    1
    2
    buf = mmap(file, len);
    write(sockfd, buf, len);
  • mmap() 系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作.

img

  • 具体过程如下:

    • 应用进程调用了 mmap() 后,DMA 会把磁盘的数据拷贝到内核的缓冲区里.接着,应用进程跟操作系统内核「共享」这个缓冲区;
    • 应用进程再调用 write(),操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,由 CPU 来搬运数据;
    • 最后,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 搬运的.
  • 我们可以得知,通过使用 mmap() 来代替 read(), 可以减少一次数据拷贝的过程.

  • 但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换,因为系统调用还是 2 次.

补充

img

  • 图片来自于https://www.linuxjournal.com/article/6345,该小节文字均翻译至上述地址
  • 第一步:mmap 系统调用导致 DMA 引擎将文件内容复制到内核缓冲区中.然后,该缓冲区与用户进程共享,而无需在内核和用户内存空间之间执行任何复制.
  • 第二步:write 系统调用使内核将数据从原始内核缓冲区复制到与套接字关联(sockets)的内核缓冲区中.
  • 第三步:第三次复制发生在 DMA 引擎将数据从内核套接字缓冲区传递到协议引擎时.
  • 通过使用 mmap 而不是read,我们将内核必须复制的数据量减少了一半.当传输大量数据时,这会产生相当好的结果.然而,这种改进并不是没有代价的.使用mmap+write方法时存在隐藏的陷阱.当您内存映射一个文件,然后在另一个进程截断同一文件时调用 write 时,您就会陷入其中一种情况.您的写入系统调用将被总线错误信号 SIGBUS 中断,因为您执行了错误的内存访问.该信号的默认行为是终止进程并转储核心,这对于网络服务器来说不是最理想的操作.有两种方法可以解决这个问题.
    • 第一种方法是为 SIGBUS 信号安装一个信号处理程序,然后在处理程序中简单地调用 return 即可.通过执行此操作,写入系统调用将返回其在中断之前写入的字节数,并将 errno 设置为成功.让我指出,这将是一个糟糕的解决方案,只是治标不治本.因为 SIGBUS表明进程中出现了严重错误,所以我不鼓励使用它作为解决方案.
    • 第二种解决方案涉及从内核租用文件(在 Microsoft Windows 中称为“机会锁定”).这是解决此问题的正确方法.通过在文件描述符上使用租赁,您可以在特定文件上与内核进行租用.然后,您可以向内核请求读/写租约.当另一个进程试图截断您正在传输的文件时,内核会向您发送一个实时信号,即 RT_SIGNAL_LEASE 信号.它告诉您内核正在破坏该文件的写入或读取租约.在您的程序访问无效地址并被 SIGBUS 信号杀死之前,您的写入调用会被中断.write调用的返回值是中断前写入的字节数,errno将被设置为success
sendfile
  • 在 Linux 内核版本 2.1 中,提供了一个专门发送文件的系统调用函数 sendfile(),函数形式如下:

    1
    2
    #include <sys/socket.h>
    ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
  • 它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度.

  • 首先,它可以替代前面的 read()write() 这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销.

  • 其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换,和 3 次数据拷贝.如下图:

img

补充

img

  • 图片来自于https://www.linuxjournal.com/article/6345,该小节文字均翻译至上述地址
  • 第一步:sendfile 系统调用导致 DMA 引擎将文件内容复制到内核缓冲区中.然后数据被内核复制到与套接字关联的内核缓冲区中.
  • 第二步:第三次复制发生在 DMA 引擎将数据从内核套接字缓冲区传递到协议引擎时.
真正的零拷贝
  • 但是这还不是真正的零拷贝技术,如果网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程.

  • 你可以在你的 Linux 系统通过下面这个命令,查看网卡是否支持 scatter-gather 特性:

    1
    2
    $ ethtool -k eth0 | grep scatter-gather
    scatter-gather: on
  • 于是,从 Linux 内核 2.4 版本开始起,对于支持网卡支持 SG-DMA 技术的情况下, sendfile() 系统调用的过程发生了点变化,具体过程如下:

    • 第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;

    • 第二步,缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝;

  • 所以,这个过程之中,只进行了 2 次数据拷贝,如下图:

img

  • 这就是所谓的零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的..

  • 零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运.

  • 所以,总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上.

补充

img

  • 图片来自于https://www.linuxjournal.com/article/6345,该小节文字均翻译至上述地址
  • 第一步:sendfile 系统调用导致 DMA 引擎将文件内容复制到内核缓冲区中.
  • 第二步:没有数据复制到套接字缓冲区中.相反,只有包含数据位置和长度信息的描述符才会附加到套接字缓冲区. DMA 引擎将数据直接从内核缓冲区传递到协议引擎,从而消除了剩余的最终副本.
  • 由于数据实际上仍然从磁盘复制到内存,再从内存复制到线路,因此有些人可能会认为这不是真正的零复制.不过,从操作系统的角度来看,这是零复制,因为数据在内核缓冲区之间不重复.使用零复制时,除了避免复制之外,还可以获得其他性能优势,例如更少的上下文切换、更少的 CPU 数据缓存污染以及无需 CPU 校验和计算.
Java中的零拷贝
  • 通过JavaFileChannel.transferTo()方法实现零拷贝
    • 底层是调用Linux操作系统中的sendfile()实现的.
  • 通过JavaFileChannel.map()方法实现零拷贝
    • 底层是调用Linux操作系统中的mmap()实现的.
Netty中的零拷贝
  • netty中的零拷贝和操作系统中零拷贝有点不一样,操作系统实现零拷贝主要是在内核态的优化,netty完全是在用户状态实现零拷贝.

  • CompositeByteBuf实现零拷贝

    1
    2
    3
    ByteBuf allBuf = Unpooled.buffer(header.readableBytes() + body.readableBytes());
    allBuf.writeBytes(header);
    allBuf.writeBytes(body);
    1
    2
    CompositeByteBuf compositeByteBuf = Unpooled.compositeBuffer();
    compositeByteBuf.addComponents(true, header, body);
  • wrap实现零拷贝

    • 将byte[]数组、ByteBuf、ByteBuffer等包装成一个Netty ByteBuf对象进而避免了拷贝操作.
    1
    2
    3
    byte[] bytes = ...
    ByteBuf byteBuf = Unpooled.buffer();
    byteBuf.writeBytes(bytes);
    1
    2
    byte[] bytes = ...
    ByteBuf byteBuf = Unpooled.wrappedBuffer(bytes);
  • slice实现零拷贝

    • 将ByteBuf 拆解成多个ByteBuf,但是共享同一存储空间不同分区,避免了内存拷贝.
    1
    2
    3
    ByteBuf byteBuf = ...
    ByteBuf header = byteBuf.slice(0, 5);
    ByteBuf body = byteBuf.slice(5, 10);
  • FileRegion实现零拷贝

    • 通过FileRegion包装的FileChannel.tranferTo实现文件传输,可以直接将文件缓冲区的数据发送到目标Channel,避免了传统通过循环write方式导致的内存拷贝问题.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public static void copyFile(String srcFile, String destFile) throws Exception {
    byte[] temp = new byte[1024];
    FileInputStream in = new FileInputStream(srcFile);
    FileOutputStream out = new FileOutputStream(destFile);
    int length;
    while ((length = in.read(temp)) != -1) {
    out.write(temp, 0, length);
    }

    in.close();
    out.close();
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public static void copyFileWithFileChannel(String srcFileName, String destFileName) throws Exception {
    RandomAccessFile srcFile = new RandomAccessFile(srcFileName, "r");
    FileChannel srcFileChannel = srcFile.getChannel();

    RandomAccessFile destFile = new RandomAccessFile(destFileName, "rw");
    FileChannel destFileChannel = destFile.getChannel();

    long position = 0;
    long count = srcFileChannel.size();

    srcFileChannel.transferTo(position, count, destFileChannel);
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    // io.netty.example.file.FileServerHandler.channelRead0
    @Override
    public void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
    RandomAccessFile raf = null;
    long length = -1;
    try {
    raf = new RandomAccessFile(msg, "r");
    length = raf.length();
    } catch (Exception e) {
    ctx.writeAndFlush("ERR: " + e.getClass().getSimpleName() + ": " + e.getMessage() + '\n');
    return;
    } finally {
    if (length < 0 && raf != null) {
    raf.close();
    }
    }

    ctx.write("OK: " + raf.length() + '\n');
    if (ctx.pipeline().get(SslHandler.class) == null) {
    // SSL not enabled - can use zero-copy file transfer.
    ctx.write(new DefaultFileRegion(raf.getChannel(), 0, length));
    } else {
    // SSL enabled - cannot use zero-copy file transfer.
    ctx.write(new ChunkedFile(raf));
    }
    ctx.writeAndFlush("\n");
    }

同步与异步

  • 所谓同步,线程自己去获取结果(一个线程)
    • 就是在发出一个功能调用时,在没有得到结果之前,该调用就不返回.也就是必须一件一件事做,等前一件做完了才能做下一件事.(死等结果)
  • 所谓异步,线程自己不去获取结果,而是由其它线程送结果(至少两个线程)
    • 就是当一个异步过程调用发出后,调用者不能立刻得到结果,调用者不用等待这件事完成,可以继续做其他的事情.实际处理这个调用的部件在完成后,通过状态、通知和回调来通知调用者.(回调通知)

阻塞与非阻塞

  • 阻塞调用是指调用结果返回之前,当前线程会被挂起(线程进入非可执行状态,在这个状态下,CPU不会给线程分配时间片,即线程暂停运行).函数只有在得到结果之后才会返回.
  • 非阻塞调用是指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回.

I/O事件

  • I/O 事件通常包括以下几种:
    • 读事件 (Read Event):当一个套接字(Socket)有数据可以读取时触发。例如,TCP 连接上有新的数据包到达,可以从缓冲区读取。
    • 写事件 (Write Event):当一个套接字可以写数据时触发。例如,缓冲区有足够空间,可以向连接写入更多的数据。
    • 连接事件 (Connect Event):当一个非阻塞连接请求完成时触发。例如,客户端发起的异步连接操作完成,连接成功或失败。
    • 接受事件 (Accept Event):当有新的连接请求到达监听套接字时触发。例如,服务器端有新的客户端连接请求,可以接受新的连接。
    • 关闭事件 (Close Event):当连接关闭时触发。例如,远程主机关闭了连接,或者本地关闭了连接。

Linux五种I/O模型

  • Linux中的五种I/O模型
    • 阻塞I/O模型
    • 非阻塞I/O模型
    • I/O多路复用模型
    • 信号驱动I/O模型
    • 异步I/O模型

同步阻塞I/O模型blocking I/O

img

  • 当用户进程调用了recv()/recvfrom()这个系统调用,内核进入I/O的第一个阶段: 准备数据
    • 这个阶段过程用户进程进入阻塞状态,即数据从磁盘上拷贝到内核态缓冲区这个过程;
  • 当数据准备好了,进入第二个阶段: 数据拷贝
    • 这个阶段将数据从内核态缓冲区拷贝到用户态缓冲区,用户进程解除阻塞状态,重新运行起来;
  • BIO的特点是I/O的两个阶段都被阻塞住了;

同步非阻塞I/O模型nonblocking I/O NIO

img

  • 当用户进程调用了recv()/recvfrom()这个系统调用,内核进入I/O的第一个阶段: 准备数据

    • NIO模型中这个过程用户进程是出于非阻塞状态的,通过轮询(polling)的方式,来轮询数据是否准备好,若数据尚未状态,则系统会立即返回一个错误代码.

    “when an I/O operation that I request cannot be completed without putting the process to sleep, do not put the process to sleep, but return an error instead.”

  • 当数据准备好了,进入第二阶段: 数据拷贝

    • 在这个节点NIOBIO一样,用户进程同样会进入阻塞状态.
  • Nonblocking IO的特点是用户进程需要不断的主动询问kernel数据好了没有.

I/O多路复用模型 multiplexing I/O

img

  • 与同步非阻塞模型相比,多路复用则不需要用户进程来进行轮询操作,而是由操作系统来提供轮询操作.如UNIX/Linux下的select、poll、epoll.
  • I/O复用模型会用到select、poll、epoll函数,这几个函数也会使进程阻塞,但是和阻塞I/O所不同的是这些函数可以同时阻塞多个I/O操作.而且可以同时对多个读操作,多个写操作的I/O函数进行检测,直到有数据可读或可写时(注意不是全部数据可读或可写),才真正调用I/O操作函数.
    • 用户进程调用select、poll、epoll,若数据尚未准备好,则会进入阻塞,当有数据可读/可写时,调用recv()/recvfrom()来获取数据.
  • IO多路复用是阻塞在select、epoll这样的系统调用之上,而没有阻塞在真正的I/O系统调用如recvfrom之上
  • I/O多路复用技术通过把多个I/O的阻塞复用到同一个select的阻塞上,从而使得系统在单线程的情况下可以同时处理多个客户端请求.与传统的多线程/多进程模型比,I/O多路复用的最大优势是系统开销小,系统不需要创建新的额外进程或者线程,也不需要维护这些进程和线程的运行,降底了系统的维护工作量,节省了系统资源.

信号驱动I/O模型signal blocking I/O

img

  • 使用信号,让内核在文件描述符就绪的时候使用 SIGIO 信号来通知用户进程.
  • 允许Socket使用信号驱动 I/O ,还要注册一个 SIGIO 的处理函数,这时的系统调用将会立即返回.然后我们的程序可以继续做其他的事情,当数据就绪时,进程收到系统发送一个 SIGIO 信号,可以在信号处理函数中调用IO操作函数处理数据.
  • 信号驱动IO在实际中并不常用.

异步I/O模型

img

  • 相对于同步IO,异步IO不是顺序执行.用户进程进行aio_read系统调用之后,无论内核数据是否准备好,都会直接返回给用户进程,然后用户态进程可以去做别的事情.等到socket数据准备好了,内核直接复制数据给进程,然后从内核向进程发送通知.IO的两个阶段,进程都是非阻塞的.