线程、进程、协程是日常开发的并发编程中绕不开的内容,而且想要深入掌握这一块知识,也必须对计算机底层知识有一定的掌握,包括组成原理、操作系统、网络等等,所以我写了《线程进程协程》专栏,计划对相关知识做了一下梳理,既是自我的总结,也希望能帮助到读者。
本篇是该专栏的第四篇文章,重点讨论下Unix的IO模型,这会加深我们对底层概念的理解,有助于我们对编程语言层面的工具有更深入的认知。
前置知识
在介绍IO模型前,先梳理几个重要的知识点。
unix体系结构
- 内核(kernel)主要用于控制硬件以及提供运行环境,位于操作系统的核心部分。
- 内核提供的接口成为系统调用(system call)。
- 系统调用之上分别存在shell和公共函数库。
- 应用程序可以使用公共函数库,在部分情况下也可以使用系统调用。
文件描述符
Linux里一切皆文件。当打开一个文件时,内核会返回一个非负整数,用来标识该文件,成为文件描述符(file descriptor),简称fd。在此后的读、写等处理过程中,应用程序即可通过这个描述符来访问文件,而不需要记录有关文件的其他信息。
套接字
客户端和服务端本质上是利用套接字(socket)通信。
socket是抽象概念,表示TCP连接的一端,通过套接字可以进行数据发送或接收,我们把IP:Port看成套接字,一个TCP连接有两个套接字组成。
服务端处理socket的流程:创建套接字——绑定(bind)套接字——监听(listen)套接字——接收&处理信息
客户端处理socket的流程:创建套接字——连接套接字——发送信息
套接字也是一种文件类型,一个套接字便是一个有着对应描述符的打开的文件。
用户空间和内核空间
从内核安全和可靠性考虑,用户程序不能直接运行内核代码或操作内核数据,为此操作系统有内核空间和用户空间的区分。运行在用户空间的应用程序(比如图形及文本编辑器、音乐播放器等)想要执行某些系统调用时,则需要通过特定的机制来告知内核。
Unix IO模型
在Unix中,目前有5中IO模型,分为
- 阻塞式IO(blocking IO)
- 非阻塞式IO (nonblocking IO)
- IO多路复用(IO multiplexing)
- 信号驱动式IO (signal-driven IO)
- 异步IO(asynchronous IO)
阻塞式IO
阻塞式 IO 是最常见、并且也是最常用的 IO 模型。阻塞式 IO 会因为无法立即完成某个操作而被挂起。
如下图所示,当应用进程调用 recvfrom
时,其对应的系统调用会阻塞,等待至数据报到达,并复制到应用进程对应的缓冲区后才会返回。对应上述的两个过程,即等待数据和数据拷贝,阻塞式 IO 在这两个过程中都是阻塞的。
在内核实现层面上这种 IO 模型实现简单,并且能够在数据报准备好后无延迟的返回数据以进行后续处理,但对于用户进程来说往往需要耗费时间来等待操作完成。
非阻塞式IO
非阻塞IO相关的系统调用无论操作是否完成,总会立即返回。
在非阻塞式IO中,应用进程可以以这种形式不断轮询( polling )内核,通过循环调用 recvfrom 以查看数据报是否准备好,在每次轮询内核返回后,应用进程可以选择进行一些其他任务的处理后再次发起轮询。
非阻塞式 IO 可以在等待数据准备就绪的过程中不被阻塞(但在数据从内核复制到用户空间的过程中仍是阻塞的),从而可以在等待数据的过程中执行其他的任务,但与此同时,由于应用进程按照一定的频率进行轮询,数据准备好的时间点可能位于两次轮询之间,从而导致数据到达后不能及时的被后续过程处理,存在一定延迟。同时这种通过应用进程主动不断轮询内核数据是否就绪,往往存在多次轮询时并没有数据就绪,这也会造成 CPU 资源多余的消耗(通常非阻塞IO需要结合另外的IO通知机制一起发挥作用,比如IO复用等)。
IO多路复用
用户进程通过 IO 复用函数向内核注册一组期望监听的文件描述符及对应的事件,
在多个描述符中若有任意一个至多个数据准备就绪时,IO 复用函数便返回就绪数据。在这些描述符中,若没有数据准备就绪的描述符时,进程处于睡眠状态,从而可以释放 CPU 资源,当某个描述符就绪时唤醒进程,此时用户便可以直接处理数据准备好的描述符。
在 Unix 中,主要提供了 select 和 poll 这两种 IO 复用函数,在 Linux 中,提供了名为 epoll 的更高级的 IO 复用函数。下图中便展示了 IO 复用函数的基本处理流程, 应用进程在调用 select 时阻塞, select 会监听多个套接字描述符,等待其中任一描述符准备就绪。当其中任一个描述符准备就绪时, select 函数返回,应用进程便可以通过调用 recvfrom 把准备好的数据报复制至用户空间从而进行后续的处理。
如下图所示,IO 复用中的阻塞分别出现在调用 select 以及调用 recvfrom 时数据由内核拷贝至用户空间的过程中。与阻塞式 IO 及非阻塞式 IO 相比 ,IO 复用从内核层面上支持了在多个描述符上阻塞,并在其中任一描述符就绪时返回。
IO 多路复用的强项并不是针对单个描述符可以处理更快,而是可以同时处理更多描述符(一种常见的模式便是非阻塞 IO 结合 epoll 模型)。目前 IO 多路复用技术已经十分成熟,它的应用场景可能比我们想象的还要广泛,比如在网络编程中,除了同时处理多个套接字以外,在同时处理 TCP 和 UDP 协议的套接字等场景下也会使用 IO 复
用,另外很多著名的应用级软件产品(比如 Nginx)在底层也广泛使用了 IO 复用技术。
IO 复用在单线程下便可以处理多个描述符,避免了多线程下线程的创建、线程间的切换等,系统的开销会显著减少。
对于每一种方案来说,其优势和劣势往往都是共生的,需要根据特定的场景来选择最优的实现方案,比如,当需要处理的连接数较低时,往往多线程结合阻塞式 IO 的实现方案更好一些,这样在每个线程中可以及时的处理数据,整体延迟通常也会低一些;当需要处理的连接数较高时,多线程中线程数量过多反而会导致性能的下降,这时使用 IO 复用的相关实现更好一些。
信号驱动式 IO
信号驱动式 IO 的主要思想是让内核在描述符准备就绪时通过产生信号通知进程,这样可以在等待数据报准备好的过程中不阻塞。如下图所示,在一个套接字上使用信号驱动式 IO 首先需要建立 SIGIO 信号的信号处理函数,建立处理函数的系统调用会立即返回,进程不会阻塞。当数据准备就绪时,内核生成 SIGIO 信号,接着我们便可以
在信号处理函数中或主循环中调用 recvfrom 将数据从内核复制至用户空间。
异步 IO
我们将下图和上面的几个图示简单对比,便能看出一个显著的区别是在异步 IO 中不需要应用进程调用 recvfrom 来完成数据的复制过程。这也是异步 IO 最主要的工作机制:异步 IO 相关函数通知内核进行 IO 操作,在内核执行完包括等待数据和将数据复制到用户空间等所有 IO 操作后再通知我们。下图中所示,比如当我们调用 aio_read 函数时,会将描述符以及相关的数据传递给内核,并将整个操作完成后的通知方式告知内核,该调用会马上返回,进程不会阻塞。
可以看到,异步 IO 和上面的信号驱动式 IO 都包含内核通知的过程,但这两种 IO 模型有显著的区别。首先,对于信号驱动式 IO 来说,内核产生信号通知我们的信息是何时执行 IO 操作,IO 操作需要我们自己完成,而异步IO 则不同,内核通知我们的信息是何时完成 IO 操作,IO 操作由内核来完成。其次,前者在数据准备就绪时便
产生信号,后者在数据准备好并复制完成时才产生信号(另外,异步 IO 目前在 Linux 下的相关实现还不太成熟)。
IO模型对比
对于前四种IO,他们的第一阶段(等待数据)不同,但是第二阶段的处理相同(都在recvfrom阻塞等待),都属于同步 IO ,因为在这四种 IO 模型中,都存在阻塞进程的 IO 操作,而异步 IO 模型则属于异步 IO 操作,整个进程在等待数据和数据复制的过程中均不会阻塞。
有一个关于五种IO模型的形象的比喻是这样的:
- 同步阻塞IO: 去饭馆吃饭,去了之后都在门口等着不能离开
- 同步非阻塞IO: 去了饭馆吃饭,就可以去干别的了,时不时回来看看
- 同步阻塞IO复用:去了饭馆门口,饭馆有个服务员,付服务员,有位了就让你进去
- 同步非阻塞信号驱动: 去了饭馆吃饭,去了之后领个号,就可以去干别的了,到你了,直接微信给你弹消息让你来
- 异步IO模型: 你去签个到,就去干别的了,做好了,直接找你去送过来
IO多路复用
IO多路复用是最常用的IO模型了,这里来对它的具体实现做介绍。
select/poll/epoll 都是 IO 多路复用的具体实现,select 出现的最早,之后是 poll,再是 epoll。
select :把轮询工作交给内核
select 允许应用程序监视一组文件描述符,等待一个或者多个描述符成为就绪状态,从而完成 IO 操作。
poll:解决描述符上限问题并提高效率
poll() 的功能和 select() 类似,也是一种 IO 多路复用的实现方式。与 select 不同的是, poll() 使用了一个由 pollfd 结构体组成的可变长度( nfds 个)的数组,它用于指定我们期望监听的文件描述符以及对应的事件,解决了 select 中文件描述符存在上限的问题,同时也避免了由于文件描述符过大而影响效率的问题。
epoll:高效的事件通知
在调用 select() 和 poll() 时,不管是基于 fd_set 的描述符集还是基于 pollfd 组成的结构体数组,我们都会传递所有期望检查的文件描述符,而内核在处理的过程中,则针对每次调用都会轮询所有的文件描述符,当文件描述符数量很大时,则会产生性能问题。
Linux 2.6 中引入的 epoll (event poll)则有效的解决了这个问题,它是 Linux 特有的 IO 复用函数,但相对于 select 和 poll , epoll 使用红黑树管理数据结构,性能好。
应用场景比较
很容易产生一种错觉认为只要用 epoll 就可以了,select 和 poll 都已经过时了,其实它们都有各自的使用场景。
select 的 timeout 参数精度为微秒,而 poll 和 epoll 为毫秒,因此 select 更加适用于实时性要求比较高的场景,比如核反应堆的控制。select 可移植性更好,几乎被所有主流平台所支持。
poll 没有最大描述符数量的限制,如果平台支持并且对实时性要求不高,应该使用 poll 而不是 select。
epoll只需要运行在 Linux 平台上,有大量的描述符需要同时轮询,并且这些连接最好是长连接。需要同时监控小于 1000 个描述符,就没有必要使用 epoll,因为这个应用场景下并不能体现 epoll 的优势。
需要监控的描述符状态变化多,而且都是非常短暂的,也没有必要使用 epoll。因为 epoll 中的所有描述符都存储在内核中,造成每次需要对描述符的状态改变都需要通过 epoll_ctl() 进行系统调用,频繁系统调用降低效率。并且 epoll 的描述符存储在内核,不容易调试。
Apache和Nginx
Apache和Nginx都是常用的网络服务器,对比下它们的区别。
apache,通过多线程来支持客户端的并发,服务器资源利用率不及nginx,功能简单。
Nginx,使用的是IO多路复用,读取静态文件时就是磁盘IO和网络IO的过程,使用IO多路复用是天然的优势,可以用比较少的资源,支持大的并发,可以支持万级的并发。
参考资料
《Unix网络编程》