目录

4.16 C10K 与网络I/O模型

各种网络I/O模型是面试的必考考点,那么到底有哪些I/O模型,C10K 问题到底又是怎么解决的呢?

1. C10K问题

所谓 C10K 问题就是如何在单机中同时处理 1 万个请求(并发连接 1 万)的问题。从资源上来说,对 2GB 内存和千兆网卡的服务器来说,同时处理 10000 个请求,只要每个请求处理占用不到 200KB(2GB/10000)的内存和 100Kbit (1000Mbit/10000)的网络带宽就可以。所以,物理资源是足够的,接下来自然是软件的问题,特别是网络的 I/O 模型问题。

到目前为止有两种最基本的I/O模型:

  1. 同步阻塞: 也就是每个请求都分配一个进程或者线程。当并发请求数增加到 10000 时,10000 个进程或线程的调度、上下文切换乃至它们占用的内存,都会成为瓶颈。
  2. 非阻塞I/O: 非阻塞I/O 可以让我们周期性轮询检查某个文件描述符上的I/O是否可以执行。我们可以在一个线程内同时轮询多个文件描述,但是如果轮询的频率不高,那么应用程序响应I/O事件的延时可能达到难以接受的程度。但是一个紧凑的轮询是非常浪费CPU的。

显然通过每个请求分配一个线程的方式不合适,通过轮询的方式也不合适。那么核心问题就变成如何在一个线程内处理多个请求呢?这需要我们解决以下几个问题:

  1. 如何同时检查多个文件描述,在它们准备就绪时(即网络请求到来时),及时处理。我们需要新的I/O模型。
  2. 一个线程内处理多个I/O请求,需要维护每个请求的上下文信息,这就引申出另外两种并发机制: 回调和协程

1.1 I/O 模型

“新的”I/O模型有下列几种备选方案:

  1. I/O多路复用: 允许进程同时检查多个文件描述符,看其中任何一个是否可以执行I/O操作,系统调用 select,poll 可以用来执行I/O多路复用
  2. 信号驱动I/O: 指当有输入或者数据可以写到指定的文件描述符时,内核向请求数据的进程发送一个信号。
  3. epoll: Linux 专有特性,select,poll 的升级版本,使用事件驱动的机制,只关注有 I/O 事件发生的文件描述符,不需要轮询扫描整个集合。

I/O多路复用,信号驱动,epoll 都是用来实现同一个目标技术: 同时检查多个文件描述符,看它们是否准备好了执行I/O操作。

1.2 事件通知方式

在深入讨论各种I/O机制之前,我们需要先区分两种文件描述符准备就绪的通知模式:

  1. 水平触发: 只要文件描述符可以非阻塞地执行 I/O ,就会触发通知。也就是说,应用程序可以随时检查文件描述符的状态,然后再根据状态,进行 I/O 操作。
  2. 边缘触发:只有在文件描述符的状态发生改变(也就是 I/O 请求达到)时,才发送一次通知。这时候,应用程序需要尽可能多地执行 I/O,直到无法继续读写,才可以停止。如果 I/O 没执行完,或者因为某种原因没来得及处理,那么这次通知也就丢失了。

select,pool 支持水平触发,信号驱动I/O支持边缘触发。epoll 同时支持水平触发和边缘触发,默认情况下提供的是水平触发机制。

2. I/O模型的对比

下面是几种I/O模型之间的对比图:

/images/linux_pf/io_model.jpg

在具体比较这几种 I/O 模式的区别之前,我们需要明白下面几个要点:

  1. 同步阻塞,非阻塞I/O 通常都是用在单个进程每次只在一个文件描述符上执行I/O操作
  2. I/O多路复用,信号驱动则是用来同时检查多个文件描述符,因为需要同时处理多个I/O请求,所以在单个文件描述符上采用的仍然是非阻塞的I/O模式。
  3. 非阻塞I/O需要在I/O发生时,通知进程及时进行I/O操作,“通知”包含以下几个方面:
    • 什么时候通知: 水平触发还是边缘触发
    • 在I/O栈的什么位置通知: 数据到达时即通知,还是在内核将数据完全拷贝到用户空间时在通知
    • 通知什么: 回调注册在每个文件描述符上的回调函数,每个文件描述符上的回调函数即维护了每个请求的上下文信息。

最开始我们提到了两种并发机制: 回调和协程,本质上协程也是回调,因为协程最终也是要为每个描述符注册回调函数。

但是协程与回调的区别在于,协程保存了回调前后的上下文信息,简单的理解,协程在通知的前后位于同一个函数栈中,其保留了程序执行的上下文信息,可以让我们像编写同步代码一样编写异步调用,避免了“回调地狱”的问题。

2.1 I/O多路复用

I/O多路复用包括 select,poll 和 epoll。epoll 是 select,poll 的升级版。

select 和 poll 存在下面这些问题:

  1. 每次调用 select 和 poll,内核都必须检查所有被指定的文件描述符
  2. 每次调用 select 和 poll 时,程序都必须传递一个表示所有需要被检查的文件描述符的结构到内核,内核检查过后,要把这个结构返回给用户程序,显然随着文件描述符的增多,拷贝所有文件描述所需的内存和CPU都会增多
  3. select 和 poll调用完成后,程序还必须检查返回的数据结构中的每个元素才能知道哪些描述符可用

epoll 使用红黑树,在内核中管理文件描述符的集合,这样,就不需要应用程序在每次操作时都传入、传出这个集合。

epoll 使用事件驱动的机制,只关注有 I/O 事件发生的文件描述符,不需要轮询扫描整个集合。

3.1 信号驱动I/O

信号驱动I/O使用信号作为I/O就绪的触发机制。在效率上甚至比I/O多路复用更高。但是使用信号的I/O的问题在于:

  1. 内核可用的信号类型有限,不太容易随着I/O事件的类型而扩展。I/O多路复用为不同描述符定义了不同的I/O事件集合,并且可以指定希望检查的事件类型。
  2. 可排队的实时信号的数量是有限的,超过限制信号就会丢失,这样I/O请求就无法及时得到处理。使用信号驱动I/O 需要复杂的信号处理流程,I/O多路复用不需要。

4.1 异步I/O

信号驱动I/O有时也被称为异步I/O,这一点从打开的文件标志O_ASYNC 中就能看出(注: 使用信号驱动I/O必须要使用 O_ASYNC 标识打开文件描述符)。现在异步I/O专指有POSIX_AIO 规范所提供的功能。

异步I/O与I/O多路复用不同在于在I/O栈的什么位置通知。异步I/O使用的较少,详细内容后续在补充。

5. 工作模型

使用 I/O 多路复用后,就可以在一个进程或线程中处理多个请求,其中,又有下面两种不同的工作模型:

  1. 第一种,主进程 + 多个 worker 子进程,这也是最常用的一种模型
  2. 第二种,监听到相同端口的多进程模型

5.1 主进程 + 多个 worker 子进程

主进程 + 多个 worker 子进程:

  1. 主进程执行 bind() + listen() 后,创建多个子进程;
  2. 然后,在每个子进程中,都通过 accept() 或 epoll_wait() ,来处理相同的套接字。

最常用的反向代理服务器 Nginx 就是这么工作的。主进程主要用来初始化套接字,并管理子进程的生命周期;而 worker 进程,则负责实际的请求处理。

这里要注意,accept() 和 epoll_wait() 调用,还存在一个惊群的问题。换句话说,当网络 I/O 事件发生时,多个进程被同时唤醒,但实际上只有一个进程来响应这个事件,其他被唤醒的进程都会重新休眠。

  1. 其中,accept() 的惊群问题,已经在 Linux 2.6 中解决了;
  2. epoll 的问题,到了 Linux 4.5 ,才通过 EPOLLEXCLUSIVE 解决。

为了避免惊群问题, Nginx 在每个 worker 进程中,都增加一个了全局锁(accept_mutex)。这些 worker 进程需要首先竞争到锁,只有竞争到锁的进程,才会加入到 epoll 中,这样就确保只有一个 worker 子进程被唤醒。

当然,也可以用线程代替进程:主线程负责套接字初始化和子线程状态的管理,而子线程则负责实际的请求处理。由于线程的调度和切换成本比较低,实际上你可以进一步把 epoll_wait() 都放到主线程中,保证每次事件都只唤醒主线程,而子线程只需要负责后续的请求处理。

5.2 监听到相同端口的多进程模型

在这种方式下,所有的进程都监听相同的接口,并且开启 SO_REUSEPORT 选项,由内核负责将请求负载均衡到这些监听进程中去。由于内核确保了只有一个进程被唤醒,就不会出现惊群问题了。比如,Nginx 在 1.9.1 中就已经支持了这种模式。想要使用 SO_REUSEPORT 选项,需要用 Linux 3.9 以上的版本才可以。

6. C1000K,C10M 问题

随着并发请求的进一步提高,原本不是瓶颈的地方也会出现问题。

从软件资源上来说,大量的连接也会占用大量的软件资源,比如文件描述符的数量、连接状态的跟踪(CONNTRACK)、网络协议栈的缓存大小(比如套接字读写缓存、TCP 读写缓存)等等。

大量请求带来的中断处理,也会带来非常高的处理成本。这样,就需要多队列网卡、中断负载均衡、CPU 绑定、RPS/RFS(软中断负载均衡到多个 CPU 核上),以及将网络包的处理卸载(Offload)到网络设备(如 TSO/GSO、LRO/GRO、VXLAN OFFLOAD)等各种硬件和软件的优化。

在 C1000K 问题中,各种软件、硬件的优化很可能都已经做到头了。无论你怎么优化应用程序和内核中的各种网络参数,想实现 1000 万请求的并发,都是极其困难的。

究其根本,还是 Linux 内核协议栈做了太多太繁重的工作。从网卡中断带来的硬中断处理程序开始,到软中断中的各层网络协议处理,最后再到应用程序,这个路径实在是太长了,就会导致网络包的处理优化,到了一定程度后,就无法更进一步了。

要解决这个问题,最重要就是跳过内核协议栈的冗长路径,把网络包直接送到要处理的应用程序那里去。这里有两种常见的机制,DPDK 和 XDP。

6.1 DPDK

DPDK,是用户态网络的标准。它跳过内核协议栈,直接由用户态进程通过轮询的方式,来处理网络接收。

6.2 XDP

XDP(eXpress Data Path),则是 Linux 内核提供的一种高性能网络数据路径。它允许网络包,在进入内核协议栈之前,就进行处理,也可以带来更高的性能。XDP 底层跟我们之前用到的 bcc-tools 一样,都是基于 Linux 内核的 eBPF 机制实现的。

详细的原理参见: xdp

/images/linux_pf/XDP.png

6.3 分布式

在大多数场景中,我们并不需要单机并发 1000 万的请求。通过调整系统架构,把这些请求分发到多台服务器中来处理,通常是更简单和更容易扩展的方案。常见的CDN,LVS,Nginx 负载均衡等就是可选解决方案之一。

7. 文件描述符

7.1 打开标识

前面我们说的信号驱动I/O,非阻塞I/O都有文件的打开文件描述符有关。

open() 调用的flags 有如下参数:

标志 作用
O_ASYNC 信号驱动I/O,当操作可行是,产生信号通知进程
O_ASYNC 信号驱动I/O,仅对特定的文件有效
O_CLOSEXEC 为新创建的文件描述符设置close-on-exec标志
O_DSYNC 根据同步I/O数据完整性的完成要求来执行写操作,与内核I/O 缓冲有关
O_SYNC 同步I/O,与内核I/O 缓冲有关
O_NONBLOCK 非阻塞方式打开文件

O_NONBLOCK:

  1. 非阻塞方式打开文件。管道,FIFO,设备都支持非阻塞模式。
  2. 由于内核缓冲区保证了普通文件I/O不会陷入阻塞,故而打开普通文件时一般会忽略 O_NONBLOCK 标志。
  3. 当使用强制文件锁时,O_NONBLOCK 对普通文件也是起作用的。

O_CLOSEXEC:

  1. 为新创建的文件描述符设置close-on-exec标志
  2. 在创建进程,成功执行 exec()系统调用之前关闭文件描述符。如果 exce() 失败,文件描述符会保持打开状态。

7.2 I/O事件

I/O 多路复用为不同描述符何时就绪定义了不同的I/O事件。不同的I/O事件表示文件描述符可执行的不同I/O操作。

对于普通文件的文件描述符总是被 select 标记为可读可写。但是 epoll 没有普通文件的I/O事件,不能将普通文件的文件描述符添加到 epoll 中。