C10K 和 C1000K 的首字母 C 是 Client 的缩写。C10K 就是单机同时处理 1 万个请求(并发连接 1 万)的问题,而 C1000K 也就是单机支持处理 100 万个请求(并发连接 100 万)的问题。
1、C10K
从资源上来说,对 2GB 内存和千兆网卡的服务器来说,同时处理 10000 个请求,只要每个请求处理占用不到 200KB(2GB/10000)的内存和 100Kbit (1000Mbit/10000)的网络带宽就可以。所以,物理资源是足够的,接下来自然是软件的问题,特别是网络的 I/O 模型问题。
1、I/O 模型优化
异步、非阻塞 I/O 的解决思路。
I/O 事件通知的方式分为水平触发和边缘触发:
- 水平触发:只要文件描述符可以非阻塞地执行I/O,就会触发通知。也就是说,应用程序可以随时检查文件描述符的状态,再根据状态,进行I/O操作。
边缘触发:只有在文件描述符的状态发生改变(也就是I/O请求达到)时,才发送一次通知。这个时候,应用程序需要尽可能多地执行 I/O ,直到无法继续读写,才可以停止。如果 I/O 没执行完,或者因为某种原因没来得及处理,那么这次通知也就丢失了。
I/O 多路复用的方法:
第一种,使用非阻塞 I/O 和水平触发通知,比如 select 或者 poll。
根据 水平触发 的原理,select 和 poll 需要从文件描述符列表中,找出那些可以执行 I/O ,然后进行真正的 I/O 读写。由于 I/O 是非阻塞的,一个线程中就可以同时监控一批套接字的文件描述符,达到了单线程处理多请求的目的。
优点是, api简单,对应用程序友好。
缺点是,需要对这些文件描述符列表进行轮训,这样,请求数多的时候比较耗时select 使用固定长度的相位量,表示文件描述符的集合,因此会有最大描述符数量的限制。如 32 位操作系统中,默认是1024。在select内部,检查套接字状态是轮训的方法。处理耗时跟描述符数量是 O(N) 的关系。
- poll 改进了 select 的表示方法,换成了没有固定长度的数组,但仍然需要轮询文件描述符列表。
应用程序每次调用 select 和 poll 时,还需要把文件描述符的集合,从用户空间传入内核空间,由内核修改后,再传出到用户空间中。内核空间和用户空间的切换,增加了处理成本。
第二种,使用非阻塞 I/O 和边缘触发通知,比如 epoll。
- epoll使用红黑树,在内核中管理文件描述符的集合,就不需要应用程序在每次操作时传入、传出这个集合
- epoll 使用事件驱动的机制,只关注有 I/O 事件发生的文件描述符,不需要轮训扫描整个集合。
第三种,使用异步I/O(Asynchronous I/O,简称为 AIO)。
异步I/O 允许应用程序同时发起很多 I/O 操作,而不需要等待这些操作完成。在I/O完成后,系统会用事件通知(如 信号或者回调函数)的方式,告诉应用程序。这是,才会去查询 I/O 操作结果。
2、工作模型优化
第一种,主线程 + 多个 worker 子进程,工作模式就是:
- 主进程执行 bind() + listen() 后,创建多个子进程
- 在每个子进程中,都通过 accept() 或 epoll_wait() ,来处理相同的套接字
nginx就是这么工作的。主进程 用来初始化 套接字,并管理子进程的生命周期;而 worker 进程,负责实际的请求处理。
accept() 和 epoll_wait() 调用,还存在一个惊群的问题。换句话说,当网络 I/O 事件发生时,多个进程被同时唤醒,但实际上只有一个进程来响应这个事件,其他被唤醒的进程都会重新休眠。linux在2.6版本修复 accept() ,在 4.5 修复 epoll。
为了避免惊群问题, Nginx 在每个 worker 进程中,都增加一个了全局锁(accept_mutex)。这些 worker 进程需要首先竞争到锁,只有竞争到锁的进程,才会加入到 epoll 中,这样就确保只有一个 worker 子进程被唤醒。
这些 worker 进程,实际上并不需要经常创建和销毁,而是在没任务时休眠,有任务时唤醒。只有在 worker 由于某些异常退出时,主进程才需要创建新的进程来代替它。因此,不用管多进程调度的消耗。
第二种,监听到相同端口的多进程模型
所有进程都监听相同的接口,并且开启 SO_REUSEPORT 选项(Linux 3.9以上),由内核负责将请求负载均衡到这些监听进程中去。
由于内核确保了只有一个进程被唤醒,就不会出现惊群问题。nginx在 1.9.1 就支持该模式
2、C1000K
首先从物理资源使用上来说,100 万个请求需要大量的系统资源。比如,
- 假设每个请求需要 16KB 内存的话,那么总共就需要大约 15 GB 内存。
- 从带宽上来说,假设只有 20% 活跃连接,即使每个连接只需要 1KB/s 的吞吐量,总共也需要 1.6 Gb/s 的吞吐量。千兆网卡显然满足不了这么大的吞吐量,所以还需要配置万兆网卡,或者基于多网卡 Bonding 承载更大的吞吐量。
其次,从软件资源上来说,大量的连接也会占用大量的软件资源,比如文件描述符的数量、连接状态的跟踪(CONNTRACK)、网络协议栈的缓存大小(比如套接字读写缓存、TCP 读写缓存)等等。
最后,大量请求带来的中断处理,也会带来非常高的处理成本。这样,就需要多队列网卡、中断负载均衡、CPU 绑定、RPS/RFS(软中断负载均衡到多个 CPU 核上),以及将网络包的处理卸载(Offload)到网络设备(如 TSO/GSO、LRO/GRO、VXLAN OFFLOAD)等各种硬件和软件的优化。
因此,解决方法,本质上还是 构建在 epoll 的非阻塞 I/O 模型上。还需要从应用程序到Linux内核、再到CPU、内存和网络等各个层次的深度优化。
3、C10M
实际上,在 C1000K 问题中,各种软件、硬件的优化很可能都已经做到头了。
根本原因,还是Linux内核协议栈 做了太多太繁重的工作。从网卡中断带来的硬中断处理程序开始,到软中断中的各层网络协议处理,最后再到应用程序,路径太长。
1、DPDK
是用户态网络的标准。它跳过内核协议栈,直接由用户态进程通过轮询的方式,来处理网络接收。
- 在 PPS 非常高的场景中,查询时间比实际工作时间少了很多,绝大部分时间都在处理网络包;
- 而跳过内核协议栈后,就省去了繁杂的硬中断、软中断再到 Linux 网络协议栈逐层处理的过程,应用程序可以针对应用的实际场景,有针对性地优化网络包的处理逻辑,而不需要关注所有的细节。
此外,DPDK 还通过大页、CPU 绑定、内存对齐、流水线并发等多种机制,优化网络包的处理效率。
2、XDP(eXpress Data Path)
是Linux内核提供的一种高性能网络数据路径。它允许网络包,在进入内核协议栈之前,就进行处理,带来更改的性能,基于Linux内核 的 eBPF 机制实现的。需要Linux 4.8 版本以上。

