随着互联网的发展,面对海量用户的高并发业务场景,传统的阻塞式的服务端架构模式已经无能为力,C10K 问题越来越凸显,需要一种新型的 IO 模型来解决此类问题。本文我们先来一起学习一下几种常见的 I/O 模型。

本文讨论的背景是 Linux 环境下的 Network IO

基本概念

用户空间与内核空间

现在操作系统都是采用虚拟存储器,那么对 32 位操作系统而言,它的寻址空间(虚拟存储空间)为 4G(2 的 32 次方)。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对 linux 操作系统而言,将最高的 1G 字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF),供内核使用,称为内核空间,而将较低的 3G 字节(从虚拟地址 0x00000000 到 0xBFFFFFFF),供各个进程使用,称为用户空间

网络请求处理流程

服务端处理网络请求的大致流程为:

Network process flow

主要处理步骤如下:

  • 获取请求数据。客户端与服务器建立连接发出网络请求,服务器接受请求(1-3)
  • 构建响应。当服务器接收完请求,并在用户空间处理客户端的请求,直到构建响应完成(4)
  • 返回数据。服务器将已构建好的响应再通过内核空间的网络 I/O 返还给客户端(5-7)

设计服务端并发模型时,主要有如下两个关键点:

  • 服务器如何管理连接,获取输入数据
  • 服务器如何处理请求

以上两个关键点最终都与操作系统的 I/O 模型以及线程 (进程) 模型相关,下面详细介绍这两个模型

Unix IO 模型

Unix 可用的 I/O 模型有 5 种,分别如下:

  • 阻塞式 I/O
  • 非阻塞式 I/O
  • I/O 复用(select、poll 和 epoll)
  • 信号驱动式 I/O(SIGIO)
  • 异步 I/O(POSIX 的 aio_系列函数)

在介绍这 5 中 I/O 模型之前,我们先来理解一些的概念:

概念理解

阻塞调用与非阻塞调用

  1. 阻塞调用是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。
  2. 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。

两者的最大区别在于被调用方在收到请求到返回结果之前的这段时间内,调用方是否一直在等待。阻塞是指调用方一直在等待而且别的事情什么都不做。非阻塞是指调用方在结果返回之前先去处理其他任务。

同步处理与异步处理

  1. 同步处理是指被调用方得到最终结果之后才返回给调用方。

  2. 异步处理是指被调用方先返回应答,然后再计算调用结果,计算完最终结果后再通知并返回给调用方

阻塞、非阻塞的讨论对象是调用者

同步、异步的讨论对象是被调用者

recvfrom 函数

定义函数:

1
int recvfrom(int s, void *buf, int len, unsigned int flags, struct sockaddr *from,int *fromlen);

函数说明:

recv () 用来接收远程主机经指定的 socket 传来的数据,并把数据存到由参数 buf 指向的内存空间,参数 len 为可接收数据的最大长度。参数 flags 一般设 0,其他数值定义请参考 recv ()。参数 from 用来指定欲传送的网络地址,结构 sockaddr 请参考 bind ()。参数 fromlen 为 sockaddr 的结构长度。

返回值:成功则返回接收到的字符数,失败则返回 - 1,错误原因存于 errno 中。

介绍 Unix IO 模型,我们使用 UDP 而不是 TCP 作为例子的原因在于就 UDP 而言,数据准备好读取的概念比较简单:要么整个数据报已经收到,要么还没有。然而对于 TCP 来说,诸如套接字低水位标记(low-water mark)等额外变量开始起作用,导致这个概念变得复杂。

我们把 recvfrom 函数视作系统调用。

5 种 I/O 模型

前面提到过,对于一次 IO 访问(以 read 举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个 read 操作发生时,它会经历两个阶段:

  1. 等待数据准备 (Waiting for the data to be ready)
  2. 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)

对于一个套接字上的输入操作,第一步通常涉及等待数据从网络中到达。当数据包到达时,它被复制到内核中的某个缓冲区。第二步就是把数据从内核缓冲区复制到应用进程缓冲区。

阻塞式 I/O 模型

Blocking I:O Model

当用户进程调用了 recvfrom 这个系统调用,kernel 就开始了 IO 的第一个阶段:准备数据(对于网络 IO 来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的 UDP 包。这个时候 kernel 就要等待足够的数据到来)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。而在用户进程这边,整个进程会被阻塞(当然,是进程自己选择的阻塞)。当 kernel 一直等到数据准备好了,它就会将数据从 kernel 中拷贝到用户内存,然后 kernel 返回结果,用户进程才解除 block 的状态,重新运行起来。

所以,blocking IO 的特点就是在 IO 执行的两个阶段都被 block 了。

优点:程序简单,在阻塞等待数据期间进程 / 线程挂起,基本不会占用 CPU 资源。

缺点:每个连接需要独立的进程 / 线程单独处理,当并发请求量大时为了维护程序,内存、线程切换开销较大,这种模型在实际生产中很少使用。

非阻塞式 I/O 模型

linux 下,可以通过设置 socket 使其变为 non-blocking。当对一个 non-blocking socket 执行读操作时,流程是这个样子:

Nonblocking I:O Model

当用户进程发出 read 操作时,如果 kernel 中的数据还没有准备好,那么它并不会 block 用户进程,而是立刻返回一个 error。从用户进程角度讲 ,它发起一个 read 操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个 error 时,它就知道数据还没有准备好,于是它可以再次发送 read 操作。一旦 kernel 中的数据准备好了,并且又再次收到了用户进程的 system call,那么它马上就将数据拷贝到了用户内存,然后返回。

所以,nonblocking IO 的特点是用户进程需要不断的主动询问 kernel 数据好了没有。

优点:不会阻塞在内核的等待数据过程,每次发起的 I/O 请求可以立即返回,不用阻塞等待,实时性较好。

缺点:轮询将会不断地询问内核,这将占用大量的 CPU 时间,系统资源利用率较低,所以一般 Web 服务器不使用这种 I/O 模型。

I/O 复用

IO multiplexing 就是我们说的 select,poll,epoll,有些地方也称这种 IO 方式为 event driven IO。select/epoll 的好处就在于单个 process 就可以同时处理多个网络连接的 IO。它的基本原理就是 select,poll,epoll 这个 function 会不断的轮询所负责的所有 socket,当某个 socket 有数据到达了,就通知用户进程。

I:O Multiplexing Model

当用户进程调用了 select,那么整个进程会被 block,而同时,kernel 会 “监视” 所有 select 负责的 socket,当任何一个 socket 中的数据准备好了,select 就会返回。这个时候用户进程再调用 read 操作,将数据从 kernel 拷贝到用户进程。

所以,I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select () 函数就可以返回。

这个图和 blocking IO 的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个 system call (select 和 recvfrom),而 blocking IO 只调用了一个 system call (recvfrom)。但是,用 select 的优势在于它可以同时处理多个 connection。

所以,如果处理的连接数不是很高的话,使用 select/epoll 的 web server 不一定比使用 multi-threading + blocking IO 的 web server 性能更好,可能延迟还更大。select/epoll 的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。

在 IO multiplexing Model 中,实际中,对于每一个 socket,一般都设置成为 non-blocking,但是,如上图所示,整个用户的 process 其实是一直被 block 的。只不过 process 是被 select 这个函数 block,而不是被 socket IO 给 block。

优点:可以基于一个阻塞对象,同时在多个描述符上等待就绪,而不是使用多个线程 (每个文件描述符一个线程),这样可以大大节省系统资源

缺点:当连接数较少时效率相比多线程 + 阻塞 I/O 模型效率较低,可能延迟更大,因为单个连接处理需要 2 次系统调用,占用时间会有增加

信号驱动式 I/O

Signal-Driven IO Model

简介:在信号驱动式 I/O 模型中,应用程序使用套接口进行信号驱动 I/O,并安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个 SIGIO 信号,可以在信号处理函数中调用 I/O 操作函数处理数据

优点:线程并没有在等待数据时被阻塞,可以提高资源的利用率

缺点

  • 信号 I/O 在大量 IO 操作时可能会因为信号队列溢出导致没法通知
  • 信号驱动 I/O 尽管对于处理 UDP 套接字来说有用,即这种信号通知意味着到达一个数据报,或者返回一个异步错误。但是,对于 TCP 而言,信号驱动的 I/O 方式近乎无用,因为导致这种通知的条件为数众多,每一个来进行判别会消耗很大资源,与前几种方式相比优势尽失

异步 I/O

Asynchronous I:O Model

用户进程发起 read 操作之后,立刻就可以开始去做其它的事。而另一方面,从 kernel 的角度,当它受到一个 asynchronous read 之后,首先它会立刻返回,所以不会对用户进程产生任何 block。然后,kernel 会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel 会给用户进程发送一个 signal,告诉它 read 操作完成了。

优点:异步 I/O 能够充分利用 DMA 特性,让 I/O 操作与计算重叠

缺点:要实现真正的异步 I/O,操作系统需要做大量的工作。目前 Windows 下通过 IOCP 实现了真正的异步 I/O,而在 Linux 系统下,Linux2.6 才引入,目前 AIO 并不完善,因此在 Linux 下实现高并发网络编程时都是以 IO 复用模型模式为主

同步 I/O 和异步 I/O 对比

Comparison of the I:O Models

可以看出,前 4 种模型的主要区别在于第一阶段,因为它们的第二阶段是一样的:在数据从内核复制到调用者的缓冲区期间,进程阻塞于 recvfrom 调用。相反,异步 I/O 模型在这两个阶段都要处理,从而不同于其他 4 种模型。

POSIX 把这两个术语定义如下:
同步 I/O 操作(synchronous I/O opetation)导致请求进程阻塞,直到 I/O 操作完成;
异步 I/O 操作(asynchronous I/O opetation)不导致请求进程阻塞。

根据上述定义,我们的前 4 种模型 —— 阻塞式 I/O 模型、非阻塞式 I/O 模型、I/O 复用模型和信号驱动式 I/O 模型都是同步 I/O 模型,因为其中真正的 I/O 操作(recvfrom)将阻塞进程。只有异步 I/O 模型与 POSIX 定义的异步 I/O 相匹配。

recvfrom 才是真正的 I/O 操作,recvfrom 是否阻塞是同步异步的区分标识

参考资料