「Linux Networking」- C10K and C10M

  CREATED BY JENKINSBOT

C10K 和 C1000K 的首字母 C 是 Client 的缩写。C10K 就是单机同时处理 1 万个请求(并发连接 1 万)的问题,而 C1000K 也就是单机支持处理 100 万个请求(并发连接 100 万)的问题。

C10K

Dan Kegel,在 1999 年,32-bit OS,Linux 2.2,2GB,千兆网卡,怎么支持并发 1 万的请求?

从资源上来说,对 2GB RAM、千兆网卡的服务器来说,同时处理 10000 个请求,只要每个请求处理占用不到 200KB(2GB/10000) RAM 存和 100Kbit (1000Mbit/10000) Bandwidth 就可以。所以物理资源是足够的,接下来自然是软件的问题,特别是网络的 I/O 模型问题。

在 C10K 前,网络处理采用同步阻塞,即每个请求都分配一个进程或者线程。存在的问题就是,如果请求量增加,进程或线程的调度、上下文切换、占用内存,都会成为瓶颈。

这里就有两个问题需要解决:
1)怎样在一个线程内处理多个请求?
2)怎么更节省资源地处理客户请求?

I/O 模型优化

两种 I/O 事件通知的方式:

水平触发(Level Triggered):只要文件描述符可以非阻塞地执行 I/O,就会触发通知。也就是说,应用程序可以随时检查文件描述符的状态,然后再根据状态,进行 I/O 操作。

边缘触发(Edge Triggered):只有在文件描述符的状态发生改变(也就是 I/O 请求达到)时,才发送一次通知。这时候,应用程序需要尽可能多地执行 I/O,直到无法继续读写,才可以停止。如果 I/O 没执行完,或者因为某种原因没来得及处理,那么这次通知也就丢失了。

I/O 多路复用(I/O Multiplexing):

第一种,使用非阻塞 I/O 和水平触发通知,比如使用 select 或者 poll。

根据刚才水平触发的原理,select 和 poll 需要从文件描述符列表中,找出哪些可以执行 I/O ,然后进行真正的网络 I/O 读写。由于 I/O 是非阻塞的,一个线程中就可以同时监控一批套接字的文件描述符,这样就达到了单线程处理多请求的目的。

优点:对应用程序比较友好,它的 API 简单;

缺点:应用软件使用 select 和 poll 时,需要对这些文件描述符列表进行轮询,这样请求数多的时候就会比较耗时。并且 select 和 poll 还有一些其他的限制。

select 使用固定长度的位相量,表示文件描述符的集合,因此会有最大描述符数量的限制。比如,在 32 位系统中,默认限制是 1024。并且,在 select 内部,检查套接字状态是用轮询的方法,再加上应用软件使用时的轮询,就变成了一个 O(n^2) 的关系。

poll 改进了 select 的表示方法,换成了一个没有固定长度的数组,这样就没有了最大描述符数量的限制(当然还会受到系统文件描述符限制)。但应用程序在使用 poll 时,同样需要对文件描述符列表进行轮询,这样,处理耗时跟描述符数量就是 O(N) 的关系。

除此之外,应用程序每次调用 select 和 poll 时,还需要把文件描述符的集合,从用户空间传入内核空间,由内核修改后,再传出到用户空间中。这一来一回的内核空间与用户空间切换,也增加了处理成本。

第二种,使用非阻塞 I/O 和边缘触发通知,比如 epoll。

epoll 使用红黑树,在内核中管理文件描述符的集合,这样,就不需要应用程序在每次操作时都传入、传出这个集合。epoll 使用事件驱动的机制,只关注有 I/O 事件发生的文件描述符,不需要轮询扫描整个集合。

epoll 是在 Linux 2.6 中才新增的功能(2.4 虽然也有,但功能不完善)。由于边缘触发只在文件描述符可读或可写事件发生时才通知,那么应用程序就需要尽可能多地执行 I/O,并要处理更多的异常事件。

第三种,使用异步 I/O(Asynchronous I/O,简称为 AIO)

异步 I/O 允许应用程序同时发起很多 I/O 操作,而不用等待这些操作完成。在 I/O 完成后,系统会用事件通知(比如信号或者回调函数)的方式,告诉应用程序。这时,应用程序才会去查询 I/O 操作的结果。

异步 I/O 也是到了 Linux 2.6 才支持的功能,并且在很长时间里都处于不完善的状态,比如 glibc 提供的异步 I/O 库,就一直被社区诟病。同时,由于异步 I/O 跟我们的直观逻辑不太一样,想要使用的话,一定要小心设计,其使用难度比较高。

工作模型优化

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

第一种,主进程 + 多个 worker 子进程,这也是最常用的一种模型
1)主进程执行 bind() + listen() 后,创建多个子进程;
2)在每个子进程中,都通过 accept() 或 epoll_wait() ,来处理相同的套接字。

accept() 和 epoll_wait() 的惊群问题:当网络 I/O 事件发生时,多个进程被同时唤醒,但实际上只有一个进程来响应这个事件,其他被唤醒的进程都会重新休眠。
1)accept() 的惊群问题,已经在 Linux 2.6 中解决了
2)epoll 的问题,到了 Linux 4.5 ,才通过 EPOLLEXCLUSIVE 解决。

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

这些 worker 进程,实际上并不需要经常创建和销毁,而是在没任务时休眠,有任务时唤醒。只有在 worker 异常退出时,主进程才需要创建新的进程来代替它。因此进程的管理、调度、上下文切换的成本低,性能好。

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

第二种,监听到相同端口的多进程模型
所有的进程都监听相同的接口,并且开启 SO_REUSEPORT 选项(Linux 3.9),由内核负责将请求负载均衡到这些监听进程中去。

由于内核确保了只有一个进程被唤醒,就不会出现惊群问题了。比如 Nginx 在 1.9.1 就已经支持这种模式。

C100K

从 10K 到 100K ,其实还是基于 C10K 的这些理论,epoll 配合线程池,再加上 CPU、内存、网络接口的性能和容量提升。大部分情况下,C100K 很自然就可以达到。

C1000K

再进一步 C1000K 没有那么简单:
1)从内存上来说,每个请求需要 16KB 内存的话,那么总共就需要大约 15 GB 内存
2)从带宽上来说,假设只有 20% 活跃连接,即使每个连接只需要 1KB/s 的吞吐量,总共也需要 1.6 Gb/s 的吞吐量。所以需要万兆网卡,或者基于多网卡 Bonding 承载更大的吞吐量。
3)从软件资源上来说,大量的连接也会占用大量的软件资源,比如文件描述符的数量、连接状态的跟踪(CONNTRACK)、网络协议栈的缓存大小(比如套接字读写缓存、TCP 读写缓存)等等。
4)大量请求带来的中断处理,也会带来非常高的处理成本。这样,就需要多队列网卡、中断负载均衡、CPU 绑定、RPS/RFS(软中断负载均衡到多个 CPU 核上),以及将网络包的处理卸载(Offload)到网络设备(如 TSO/GSO、LRO/GRO、VXLAN OFFLOAD)等各种硬件和软件的优化。

C1000K 的解决方法,本质上还是构建在 epoll 的非阻塞 I/O 模型上。只不过,除了 I/O 模型之外,还需要从应用程序到 Linux 内核、再到 CPU、内存和网络等各个层次的深度优化,特别是需要借助硬件(卸载那些原来通过软件处理的大量功能)。

C10M

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

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

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

DPDK

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

每时每刻都有新的网络包需要处理,轮询的优势就很明显了。比如:
1)在 PPS 非常高的场景中,查询时间比实际工作时间少了很多,绝大部分时间都在处理网络包
2)跳过内核协议栈后,就省去了繁杂的硬中断、软中断再到 Linux 网络协议栈逐层处理的过程,应用程序可以针对应用的实际场景,有针对性地优化网络包的处理逻辑,而不需要关注所有的细节。

DPDK 还通过大页、CPU 绑定、内存对齐、流水线并发等多种机制,优化网络包的处理效率。

XDP – eXpress Data Path

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

XDP 对内核的要求比较高,需要的是 Linux 4.8 以上版本。它也不提供缓存队列。基于 XDP 的应用程序通常是专用的网络应用,常见的有 IDS(入侵检测系统)、DDoS 防御、 cilium 容器网络插件等。

参考文献

35 | 基础篇:C10K 和 C1000K 回顾