Linux network I/O
1 缓存 I/O (Buffered I/O)介绍
对于传统的操作系统来说,普通的 I/O 操作一般会被内核缓存,这种 I/O 被称作缓存 I/O。缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。
(1) 对于读操作来说,当应用程序尝试读取某块数据的时候,如果这块数据已经存放在了页缓存中,那么这块数据就可以立即返回给应用程序,而不需要经过实际的物理读盘操作。当然,如果数据在应用程序读取之前并未被存放在页缓存中,那么就需要先将数据从磁盘读到页缓存中去。一个典型的读取磁盘中数据的流程图如下所示:
(2) 对于写操作来说,应用程序也会将数据先写到页缓存中去,数据是否被立即写到磁盘上去取决于应用程序所采用的写操作机制:如果用户采用的是同步写机制( synchronous writes ), 那么数据会立即被写回到磁盘上,应用程序会一直等到数据被写完为止;如果用户采用的是延迟写机制( deferred writes ),那么应用程序就完全不需要等到数据全部被写回到磁盘,数据只要被写到页缓存中去就可以了。在延迟写机制的情况下,操作系统会定期地将放在页缓存中的数据刷到磁盘上。与异步写机制( asynchronous writes )不同的是,延迟写机制在数据完全写到磁盘上的时候不会通知应用程序,而异步写机制在数据完全写到磁盘上的时候是会返回给应用程序的。所以延迟写机制本身是存在数据丢失的风险的,而异步写机制则不会有这方面的担心。
缓存 I/O 优点:
- 缓存 I/O 使用了操作系统内核缓冲区,在一定程度上分离了应用程序空间和实际的物理设备。缓存 I/O 可以减少读盘的次数,从而提高性能。
缓存I/O缺点:
- 在缓存 I/O 机制中,DMA 方式可以将数据直接从磁盘读到页缓存中,或者将数据从页缓存直接写回到磁盘上,而不能直接在应用程序地址空间和磁盘之间进行数据传输,这样的话,数据在传输过程中需要在应用程序地址空间和页缓存之间进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。对于某些特殊的应用程序来说,避开操作系统内核缓冲区而直接在应用程序地址空间和磁盘之间传输数据会比使用操作系统内核缓冲区获取更好的性能。
2 Linux环境下的network I/O
网络IO的本质就是socket的读取,socket在linux系统被抽象为流,IO可以理解为对流的操作。文章开始的时候也提到了,对于一次IO访问(以read为例),数据会先被拷贝到操作系统内核的缓冲区,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间中。
所以说,当一个read操作发生时,它会经历两个阶段:
第一个阶段:等待数据准备。
第二个阶段:将数据从内核拷贝到进程中
对于socket流而言:
第一步:通常涉及等待网络上的数据分组到达,然后复制到内核的某个缓冲区。
第二步:把数据从内核缓冲区复制到应用进程缓冲区。
Linux环境下的五种IO Modle: blocking IO, nonblocking IO, IO multiplexing, signal driven IO, asynchronous IO. 其中前四种比较常见。
(1) blocking IO
在阻塞IO模型中,从调用系统函数获取数据开始到得到数据,当前的进程或者线程始终是处于阻塞状态的,也就是什么都不干,直到等完数据准备好和将数据搬迁到用户空间为止:
1) 用户首先发出系统调用函数希望获取数据;
2) 系统调用会进入内核检查是否有数据准备完毕,如果没有就一直等待;
3) 当数据准备完毕的时候会将数据拷贝到用户空间;
4) 拷贝完毕返回一个获取数据成功的返回值来告诉用户可以进行数据的处理了;在此期间,用户进程或者线程一直是处于阻塞状态的,无论是等待数据还是进行数据的拷贝;
(2) nonblocking IO
和阻塞式的IO模型不同,当发出了系统调用的时候,如果这时候数据还没有准备好,进程或线程并不会进入阻塞模式一直等待,而是会反复轮询“数据好没…数据好没…数据好没…”,这是比较耗CPU资源的,而当数据准备好之后会和阻塞IO模型一样进行数据的拷贝:
1) 首先用户发出系统调用,去向内核申请获取想要的数据;
2) 内核检查发现数据还没准备好,就会返回一个错误值;
3) 用户接收到错误值并不甘心,就会反复反复询问内核是否有数据准备好;
4) 内核一直检查直到有数据准备完毕,进行数据的搬迁,这时用户会进入阻塞等待状态等待数据拷贝完毕;
5) 数据提取到用户空间之后会返回一个成功状态通知用户数据完毕,这时用户就可以进行数据的处理了;
(3) IO multiplexing
既然是复用,说明一次性可以管理或处理多个IO,主要是靠select函数或者poll(epoll)函数来完成;这些函数同样会阻塞进程,但是和阻塞式IO不同的是IO复用模型可以一次性阻塞多个IO操作。同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。
1) 用户首先调用select或者poll函数对多个IO接口操作进行检测,同时会使进程或者线程阻塞;
2) 当有至少一个IO接口响应的时候,系统就会通知内核调用相应的函数来获取数据;
3) 这时内核将数据拷贝迁移至内核空间,进程或者线程仍然处于阻塞状态;
4) 数据就绪,返回一个成功值告诉用户可以处理数据了;
(4) signal driven IO
用户首先注册一个处理IO信号的信号处理函数,当数据还没准备好的时候进程或者线程并不阻塞,当数据准备好的时候用户进程或者线程会收到一个信号SIGIO,这时候就会调用信号处理函数,在信号处理函数中调用IO函数操作数据,完成之后通知用户:
1) 用户程序中事先注册好一个对于SIGIO的信号处理函数;
2) 当数据准备完毕的时候会向用户进程或者线程发送一个SIGIO信号,这时信号就会被捕捉;
3) 捕捉信号之后就会执行用户自定义的一个信号处理函数,并且在函数中调用系统函数去获取数据;
4) 获取数据同样会将数据进行拷贝到用户空间,这时进程或者线程仍然会被阻塞;
5) 当数据准备完毕同样会通知用户,之后就可以进行数据的处理了;
(5) asynchronous IO
对于异步的IO模型来说,数据的等待和搬迁都不由当前的进程或者线程来处理,调用相应系统函数之后就会直接返回继续执行,因此当前用户程并不会被阻塞,当数据已经在用户空间准备就绪之后会以状态、通知或者回调来告诉用户可以进行数据的处理了:
1) 用户程序调用aio_read函数,告诉内核描述字、缓冲区指针、缓冲区大小、文件偏移以及通知的方式,之后便立即返回;
2) 这时内核相应的数据操作组件会进行数据的等待和搬迁,期间用户程序并不受影响继续执行;
3) 当数据都已经在用户空间准备就绪之后就会通过在函数中预留的通知方式来通知用户程序处理数据;
总结:
从上面的分析中不难发现,在对于数据的获取过程中都是进行了两个主要的部分:数据的等待和数据的搬迁;除此相同点之外,下面就总结一下各种IO模型的区别:
从上面的比较可以发现:前四种IO模型也就是阻塞IO、非阻塞IO、IO复用和信号驱动IO模型都是同步的,只有最后一种是异步的异步IO模型;默认情况下所创建出来的socket都是以阻塞的形式,比如网络通信中的recvfrom和sendto,或者read和write等函数都是以阻塞的方式来实现的。