Linux中一切皆文件,无论是操作文件还是通信,都会涉及文件的读写,在不同的应用场景下,我们可能会有不同的IO需求,需要使用不同的IO编程模型。
在理解IO模型时我们也需要从三个方面来理解:API接口、系统调用、驱动实现(这里的驱动对于普通文件就是系统默认实现的文件系统的ops)。通常我们调用read等API接口时,执行系统调用,系统调用中,可能实现了一些策略,并最终会调用驱动中实现函数实体。例如poll函数,就不可忽略系统调用中实现的核心策略,以及和驱动程序的配合。下面介绍Linux中常用的几种IO模型。
1. 阻塞IO
阻塞IO是read、write默认使用的IO模型,对于read,当有数据读取时可以直接返回,没有数据读取时就会阻塞,直到有数据读取,write也同理,这符合我们正常的使用逻辑。下文主要以read为例说明。
阻塞IO的主要实现在驱动中,驱动程序需要在read实现中判断是否可读,不可读需要将进程休眠,并在可读条件触发的时候唤醒该进程(例如有write写入后)。可见,阻塞IO的实现依赖于休眠唤醒。内核中有关于休眠唤醒的相关函数,是基于等待队列实现的,有关休眠唤醒的内容在其他文章中有说明。
阻塞IO的优点是非常简单,缺点也比较明显,因为阻塞了当前进程的执行,导致后面的内容没办法执行。
2. 非阻塞IO
非阻塞IO和阻塞IO不同,如果当前未满足条件,也会退出,返回错误。使用非阻塞IO,我们就可以使用轮询的方式读取数据,同时做一些其他的工作。
在linux中,应用层置位文件的O_NOBLOCK非阻塞打开标志,即可进行非阻塞的读写,以及打开操作。在驱动的实现中,也比较简单,如果函数不能执行就直接返回。
3. 多路复用IO
多路复用IO同它的名字一样,可以同时阻塞等待多个文件描述符的事件,如果有文件描述符的事件就绪,就可以退出,用户可以根据就绪的事件做相应的处理。
对于同时需要对多个文件进行IO的情况:使用阻塞IO只能开多个线程,但是这样做如果文件比较多的话,会消耗系统的线程资源以及存在上下文切换开销;使用非阻塞,我们可以在一个循环中遍历所有监听的文件,但是问题在于系统调用开销太大。而使用多路复用IO模型,其实是在内核中实现了对于文件的监听,原理也是基于休眠唤醒,只是具有多个唤醒点,实现上就是把当前进程加入到每一个驱动中维护的等待队列上,并在条件满足时唤醒。
多路复用IO有select、poll、epoll三种API,下面简要说一下他们的实现和特点。
3.1 select
select是最早的实现,它使用位图来表示监听的文件描述符,使用循环,在循环中调用每一个监听文件的poll驱动实现,一般在驱动poll中需要调用poll_wait函数,将当前进程添加到自己的等待队列上,并返回当前的事件,然后在事件发生的地方会调用wake_up唤醒等待队列上的进程。回到select循环中对poll的调用,通过调用的返回值,即可知道当前是否有监听的事件发生,如果有,则会退出返回。如果没有,会开启定时调度。此时,如果哪个驱动中事件发生了事件,调用了wake_up,因为该进程已经在poll中加入了每个驱动的等待队列,因此可以被唤醒,继续回到循环中重复轮询步骤。
select相对于阻塞多线程实现,节省了资源,但是实际上响应是更慢的;相对于非阻塞轮询实现,优点是将轮询放在了内核实现,则减少了轮询时的系统调用开销。另外,它也有可以优化的地方:由于使用位图,最大文件描述符有1024的限制;每次调用需要将所有监听情况从用户态拷贝到内核态,存在开销;内部也是通过轮询实现的事件的确定,时间复杂度是线性的,可以改成异步确定;返回的内容也是通过位图,因此仍需要用户表遍历确定哪些事件发生。
3.2 poll
poll相对于select,对于监听的文件描述符事件的表示方法发生了变化,使用struct pollfd结构体来表示对于一个文件的的监听事件,其有三个成员:fd监听的文件描述符、events监听的事件位、revents返回发生的事件位。系统调用内部使用链表结构来组织这些监听信息,所以没有了1024的限制。其他实现同select。
3.3 epoll
epoll相对于select、poll有很大的不同,他们的架构都变化了,但是底层原理仍然是基于等待队列的。epoll最核心的改变是将多路复用IO抽象到内核中实现了,即使用专门的struct eventpoll结构来管理一个多路复用IO。
用户可以通过epoll_create函数在内核中创建一个eventpoll结构,其在内核中会绑定到一个文件描述符上,并返回该文件描述符,用户后期可使用该描述符作为句柄开操作该IO复用接口。
eventpoll中使用红黑树来维护用户需要监听的文件事件集合,用户通过epoll_ctl函数即可向该结构中添加自己的监听信息。
epoll中实现了异步设置准备好的需要返回的事件信息,并且返回时进返回这些发生的事件。其在epoll_ctl中就调用了驱动中的poll,将自己添加到驱动中实现的等待队列上,在驱动中唤醒时,调用的是系统调用注册的唤醒回调函数,该回调函数不仅会唤醒进程,还会将该文件的事件添加到准备好的事件列表中。当用户调用epoll_wait函数时,只要在循环中检查是否有就绪的事件即可,有则退出返回,无则休眠。
可以看到,epoll解决了select中提到的所有可优化问题,epoll可以优化的原因,还是在于它将IO复用接口独立到内核中了,这样,对于应用和驱动来说,它都是全局的,因此他们才可以对这个结构进行操作,使得可以影响到后面的使用。
另外,需要注意epoll提供了水平触发和边沿触发两种机制。默认是水平触发,即只要事件满足,poll_wait即会触发返回;边沿触发可以通过poll_ctl设置,只要在事件从无到有的时候会触发。对于读取数据来说,可读触发一次,对于水平触发,后期只要可读就可以触发,而对于边沿触发,只有不可读到可读才能触发一次,因此如果本次数据没有读取完,则则残留数据不会再次触发,一般会一直非阻塞读取知道返回错误保证无残留数据。具体应用需要根据实际场景,我也不是很清楚,大概如果确保事件响应不会丢失,则采用水平,但是如果没读取完的话会导致频繁的触发。
epoll最大的优势就是在于高并发,通常使用在服务器接收请求上,可以解决C10K问题。
4. 异步IO
上面将的阻塞非阻塞,都是属于同步IO。异步IO和同步IO的区别是,同步IO需要主动发起数据的读写,而异步IO是通过信号通知的,在信号处理函数中可以进行相关读写操作。但是无法知道是哪个文件的什么事件发生了,所以一般还需要在信号处理函数中使用多路复用IO来判断。
异步IO是通过信号机制来实现的,具体的驱动支持以及应用API,我大概看了一下,不详细了解了。
好了,以上介绍了几种IO模型,我们也可以看到,在分析他们时,我们需要从API接口了解用户是怎么使用的,从系统调用和驱动实现中了解背后的实现原理,在分析各种API时,我们也可以按照这三个部分来理解,同时,可以能牵扯到内核中实现的其他机制,比如上面使用的等待队列。