当前位置: 首页 > 科技观察

吃透Linux(Unix)的五种IO模型

时间:2023-03-17 15:11:24 科技观察

IO模型用一张图说明支持的I/O模型在垂直维度上有“阻塞(Blocking)”和“非阻塞(Non-blocking)”;水平维度是“同步异步”。总结一下,四种模型就是同步阻塞,同步非阻塞;异步阻塞,异步非阻塞。《Unix网络编程》分“第五”模型——“信号驱动IO”其实属于异步阻塞类型。这个模型有多种通知方式,后面会解释。同步/异步,阻塞/非阻塞从内核的角度来看,I/O操作分为两步:用户层API调用;内核层完成系统调用(发起I/O请求)。所以“异步/同步”指的是API调用;“阻塞/非阻塞”是指内核完成I/O调用的方式。用一张图就更明显了,synchronization的意思是函数会一直等到函数完成;阻塞是指在等待事件(比如有新数据)发生之前,系统调用时进程会被设置为Sleep状态。明白了这一点,再看这五款车型就会清楚很多。我们一一分析:同步阻塞是最常见的模型。用户空间调用API(读、写)会转化为I/O请求,等待I/OO请求完成后API调用才会完成。这意味着:用户程序在API调用期间是同步的;这个API调用会导致系统以阻塞方式进行I/O,如果此时没有数据,会一直“等待”(放弃CPU主动挂起-休眠状态)(注意不会有阻塞对于硬盘来说,不管什么时候读取,总会有数据,常见的阻塞设备有终端、网卡等)。以read为例,它由三个参数组成,第一个函数是文件描述符;第二个是应用程序缓冲区;第三个参数是要读取的字节数。系统调用后,I/O将以阻塞方式执行。I/O模块读取数据后,放入PageCache;最后一步是将数据从PageCache复制到应用程序缓冲区。如果不能满足I/O请求——没有数据,会主动放弃CPU,直到有数据为止(注意,即使系统调用放弃CPU,也不一定真的放弃。read函数是同步的,所以CPU还是会被用户空间代码占用)。同步非阻塞方式在调用读写时指定O_NONBLOCK参数。与“同步阻塞”模式不同的是,它在系统调用时以非阻塞方式执行,无论有无数据都会立即返回。以read为例,如果数据读取成功,则返回读取的字节数;如果此时没有数据,则返回-1,并设置errno为EAGAIN(或EWOULDBLOCK,两者相同)。所以在这种模式下,我们一般会使用一个“循环”来不断尝试读取和处理数据。异步阻塞同步模型的主要问题是占用CPU。阻塞I/O会主动让出CPU,但是用户空间的系统调用仍然不会返回,依然会消耗CPU;非阻塞I/O必须不断“轮询”并一次又一次地尝试。读取数据(会消耗较多的CPU,效率较低)。如果仔细分析同步模型占用CPU的原因,不难得出结论——它们都是在等待数据的到来。异步模式意识到了这一点,所以将I/O读取细化为订阅I/O事件,真正的读写I/O。在“订阅I/O事件”的事件部分,它会主动让出CPU,直到事件发生。异步模式下的I/O函数与同步模式下的I/O函数相同(都是read和write)。唯一不同的是,异步模式下的“读取”必须有数据,而同步模式下可能没有。常见的异步阻塞函数有select、poll、epoll。这些函数的使用需要相当大的篇幅来介绍。在本文中,我想着重介绍“I/O模型”。以select为例,看一下大致原理。我们异步模式下的API调用分为两步。第一步是通过select订阅读写事件。该函数会主动让出CPU,直到事件发生(设置为Sleep状态,等待事件发生);一旦select返回,证明可以开始读取,所以第二部分是通过read读取数据(“读取”必须有数据)。异步阻塞模型的信号驱动的“乐观主义”看完上面的select会有些不安——我还是要“等待”读写事件(即使select会主动让出CPU),能不能有读写事件时间主动通知我?我们可以借助“信号”机制来实现,但这并不完美,有点弄巧成拙。具体用法:通过fcntl函数设置一个F_GETFL|O_ASYNC(信号驱动的I/O也叫“异步I/O”,所以才有了O_ASYNC这个名词),当有时操作系统会触发SIGIO信号输入/输出时间。在程序中,只需要绑定SIGIO信号的处理函数即可。但是这里有一个问题——信号处理函数是由哪个进程执行的呢?答案是:“所有者”进程。操作系统只负责参数信号,实际的信号处理功能必须由用户空间进程来实现。(这就是为什么将F_SETOWN设置为当前进程PID的原因)信号驱动性能比select和poll高(避免了文件描述符的复制)但是缺点是致命的——*Linux中的信号队列是有限的如果你manipulatethisnumber的问题是根本无法读取数据。异步非阻塞模型是最“省事”的模型。系统调用完成后,只需要等待数据即可。是不是特别爽?其实问题出在执行上。AIO在Linux上有两个实现版本,POSIX的实现最差(蓝巨人的锅)性能很差而且是基于“事件驱动”的,会出现“信号不足”的问题queue”(所以偷偷创建了线程,线程也是不可控的);一种是Linux自己实现的NativeAIO(redhat贡献)。NativeAIO中主要涉及的两个函数io_submit需要设置I/O动作(读、写、数据大小、申请缓冲区等);io_getevents等待I/O操作完成。没错,即使你的整个I/O行为都是非阻塞的,你仍然需要一种方法来知道数据是否读/写成功。注意图中,内核不再为I/O分配PageCache,所有数据都必须由用户在应用程序缓冲区中读取和维护。所以AIO必须和“直接I/O”一起使用。AIO对于网卡设备意义不大。首先,它的实现与epoll在本质上是相似的;其次,它在Linux中的作用更多的是针对磁盘I/O(异步非阻塞在没有多线程的情况下会造成大量的I/O,O请求方便I/O模块“合并”,优化会提高总体I/O吞吐率-并且CPU开销相对较小)。Nginx中使用了一个trick来实现AIO和epoll的联动。AIO读取数据后,触发epoll发送数据。(这个功能很囧,如果是磁盘文件,用sendfile就可以搞定)。DirectI/O和BufferedI/OLinux在进行I/O操作时,会先将数据放入PageCache中,然后通过“内存映射”返回给应用程序。当多个进程读取相同的数据时,它充当缓存。应用程序不能直接使用PageCache中的数据,通常会将其复制到一块“用户空间”内存中进行复用。直接I/O是指数据不落入PageCache,而是直接从设备中读取数据,放到用户空间。BufferedI/O是指数据竞标PageCache同步I/O,只能使用BufferedI/O;异步阻塞I/O/O可以是BufferedI/O或DirectI/O;异步非阻塞I/O只能使用DirectI/O。零拷贝考虑从磁盘读取文件并通过网卡发送。会有四种内存拷贝:1.DMA将磁盘数据拷贝到内核空间,2.应用程序将内核空间中的数据拷贝到用户空间;3、应用程序用户空间的数据会被复制到Socket缓冲区(内核空间);4.协议栈会将数据复制到网卡并发送。简单的说,ZeroCopy就是保存这个过程中内存拷贝的次数。有几种方法:DirectI/O直接将磁盘数据拷贝到内核空间;但是DirectI/O没有办法直接把数据放到网卡中——必须要经过协议栈。所以你可以保存一个内存副本;sendfile,将磁盘数据通过DMA读入内核空间,直接交给TCP/IP协议栈;真的不需要内存拷贝;另外,还可以使用splice和mmap做一些优化,根据不同的设备需要不同的方法,这里不再展开。【本文为专栏作家邢森原创文章,转载请联系作者获得授权】点此阅读更多该作者好文