Linux| 网络IO模型

IO(Input/Output, 输入/输出)是计算机体系的重要的一部分。IO有两种操作,同步IO异步IO同步IO指的是,必须等待IO操作完成,控制权才返回给用户进程。异步IO指的是,无需等待IO操作完成,就将控制权返回给用户进程。
网络中的IO,由于不同的IO设备有着不同的特点,网络通信中往往需要等待。常见的有以下4种情况

  • 输入操作:等待数据到达套接字接收缓冲区
  • 输出操作:等待套接字发送缓冲区有足够的空间容纳将要发送的数据
  • 服务器接收连接请求:等待新的客户端连接请求的到来
  • 客户端发送连接请求:等待服务器回送客户的发起的SYN对应的ACK

4种网络IO模型

  • 阻塞IO模型
  • 非阻塞IO模型
  • 多路IO复用模型
  • 异步IO模型

阻塞IO模型

在 Linux 中,默认情况下所有的 socket 都是阻塞的,一个典型的读操作流程如下所示

阻塞IO模型

当应用进程调用了 recvfrom 这个系统调用后,系统内核就开始了 IO 的第一个阶段:准备数据。对于网络 IO 来说,很多时候数据在一开始还没到达时(比如还没有收到一个完整的 TCP 包),系统内核就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。当系统内核一直等到数据准备好了,它就会将数据从系统内核中拷贝到用户内存中,然后系统内核返回结果,用户进程才解除阻塞的状态,重新运行起来。所以,阻塞 IO 模型的特点就是在 IO 执行的两个阶段(等待数据和拷贝数据)都被阻塞了。

阻塞和非阻塞的概念描述的是用户线程调用内核 IO 操作的方式:阻塞是指 IO 操作需要彻底完成后才返回到用户空间;而非阻塞是指 IO 操作被调用后立即返回给用户一个状态值,不需要等到IO操作彻底完成。

大部分的 socket 接口都是阻塞的。所谓阻塞型接口是指系统调用时(一般是 IO 接口)却不返回调用结果,并让当前线程一直处于阻塞状态,只有当该系统调用获得结果或者超时出错时才返回结果。除非特别指定,几乎所有的 IO 接口(包括 socket 接口)都是阻塞型的。这给网络编程带来了一个很大的问题,如在调用 send()的同时,线程处于阻塞状态,则在此期间,线程将无法执行任何运算或响应任何网络请求。一个简单的改进方案是在服务器端使用多线程(或多进程)。多线程(或多进程)的目的是让每个连接都拥有独立的线程(或进程),这样任何一个连接的阻塞不会影响其他的连接。
多线程的服务器模型似乎完美地解决了多个客户机提供问答服务的要求,但其实并不尽然。如果要同时响应成百上千路的连接请求,则无论多线程还是多进程都会严重的占据系统资源,降低系统对外界响应的效率,而线程与进程本身也更容易进入假死状态。此时考虑使用线程池或者连接池

  • 线程池旨在降低创建和销毁线程的频率,使其维持一定合理数量的线程,并让空闲的线程重新承担新的执行任务
  • 连接池是指维持连接的缓存池,尽量重中已有的连接,减低创建和关闭连接的频率。

线程池连接池或许可以缓解部分压力,但是不能解决所有问题,多线程模型可以方便高效的解决小规模的服务请求,但面对大规模的服务请求,多线程模型也会遇到瓶颈,可以用非阻塞模型来尝试解决这个问题


非阻塞IO模型

在 Linux 中,可以通过设置 socket 使IO变为非阻塞状态。当对一个非阻塞的 socket 执行读 read 操作时,其流程如下

非阻塞IO模型

当用户进程发出 read 操作时,如果内核中的数据还没有准备好,那么它并不会 block 用户进程,而是立刻返回一个错误。从用户进程角度讲,它发起一个 read 操作后,并不需要等待,而是马上就得到了一个结果。当用户进程判断结果是一个错误时,它就知道数据还没有准备好,于是它可以再次发送 read 操作。一旦内核中的数据准备好了,并且又再次收到了用户进程的系统调用,那么它马上就将数据复制到了用户内存中,然后返回正确的返回值。所以,在非阻塞式 IO 中,用户进程其实需要不断地主动询问 kernel 数据是否准备好。非阻塞的接口相比于阻塞型接口的显著差异在于被调用之后立即返回。使用如下的函数可以将某句柄归设为非阻塞状态

1
fcntl (fd, F_SETFL, O_NONBLOCK);

在非阻塞状态下, recv()接口在被调用后立即返回,返回值代表了不同的含义,如下所述

  • recv()返回值大于0,表示接收数据完毕,返回值即是接收到的字节数
  • recv()返回 0,表示连接已经正常断开
  • recv()返回-1 ,且 errno 等于 EAGAIN,表示 recv 操作还没执行完成
  • recv()返回-1 ,且 errno 不等于 EAGAIN,表示 recv 操作遇到系统错误errno

可以看到服务器线程可以通过循环调用recv()接口,可以在单个线程内实现对所有连接的数据接收工作。但是上述模型绝不被推荐,因为循环调用recv()将大幅度占用CPU使用率;此外,在这个方案中recv()更多的是起到检测”操作是否完成”的作用,实际操作系统提供了更为高效的检测”操作是否完成”作用的接口,例如 select()多路复用模式,可以一次检测多个连接是存活跃


多路IO复用模型

多路 IO 复用,有时也称为事件驱动 IO。它的基本原理就是有个函数(例如 select)会不断地轮询所负责的所有 socket,当某个 socket 有数据到达了,就通知用户进程, 多路 IO 复用模型的流程如下所示

多路IO复用模型

当用户进程调用了 select ,那么整个进程会被阻塞,而同时,内核会“监视”所有 select 负责的 socket,当任何一个 socket 中的数据准备好了,select 就会返回。这个时候用户进程再调用 read 操作,将数据从内核拷贝到用户进程。
这个模型和阻塞IO 的模型其实并没有太大的不同,事实上还更差一些。因为这里需要使用两个系统调用(selectrecvfrom),而阻塞 IO 只调用了一个系统调用(recvfrom)。但是,用select的优势在于它可以同时处理多个连接。所以,如果处理的连接数不是很高的话,使用select/epoll的 Web server 不一定比使用多线程的阻塞IO的 Web server 性能更好,可能延迟还更大;select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。
多路复用IO模型中,对于每一个socket,一般都设置成为非阻塞的,但是,如上图所示,整个用户的进程其实是一直被阻塞的。只不过进程是被 select 这个函数阻塞,而不是被 socket IO 阻塞。 因此使用 select()的效果与非阻塞IO类似

select、epoll相关将会在后续学习中进行补充


异步IO模型

异步IO模型的流程

异步IO模型

用户进程发起 read 操作之后,立刻就可以开始去做其他的事;而另一方面,从内核的角度,当它收到一个异步的 read 请求操作之后,首先会立刻返回,所以不会对用户进程产生任何阻塞。然后,内核会等待数据准备完成,然后将数据拷贝到用户内存中,当这一切都完成之后,内核会给用户进程发送一个信号,返回read操作已完成的信息。

调用阻塞 IO 会一直阻塞住对应的进程直到操作完成,而非阻塞 IO 在内核还在准备数据的情况下会立刻返回。两者的区别就在于同步 IO 进行 IO 操作时会阻塞进程。按照这个定义,之前所述的阻塞 IO、非阻塞 IO 及多路 IO 复用都属于同步 IO。 实际上,真实的 IO 操作,就是例子中的 recvfrom 这个系统调用。 非阻塞 IO 在执行 recvfrom 这个系统调用的时候,如果内核的数据没有准备好,这时候不会阻塞进程。但是当内核中数据准备好时,recvfrom 会将数据从内核拷贝到用户内存中,这个时候进程则被阻塞。而异步 IO 则不一样,当进程发起 IO 操作之后,就直接返回,直到内核发送一个信号,告诉进程 IO 已完成,则在这整个过程中,进程完全没有被阻塞。


模型比较

各个 IO 模型的比较

各种IO模型比较

非阻塞 IO异步 IO 的区别还是很明显的。 在非阻塞 IO 中,虽然进程大部分时间都不会被阻塞,但是它仍然要求进程去主动检查,并且当数据准备完成以后,也需要进程主动地再次调用 recvfrom 来将数据拷贝到用户内存中。 而异步 IO则完全不同,它就像是用户进程将整个 IO 操作交给了他人(内核)完成,然后内核做完后发信号通知。在此期间,用户进程不需要去检查 IO 操作的状态,也不需要主动地拷贝数据。