网络性能篇:C10K 和 C1000K 回顾
问题
1. C10K 就是单机同时处理 1 万个请求(并发连接 1 万)的问题
2. C1000K 也就是单机支持处理 100 万个请求(并发连接 100 万)的问题
C10K I/O 模型
两种 I/O 事件通知的方式:
水平触发和边缘触发
(1) 水平触发:只要文件描述符可以非阻塞地执行 I/O ,就会触发通知。
应用程序持续检查文件描述符的状态,根据状态进行 I/O 操作。
(2) 边缘触发:只有在文件描述符的状态发生改变(也就是 I/O 请求达到)时,才发送一次通知。
应用程序需要尽可能多地执行 I/O,直到无法继续读写,才可以停止。如果 I/O 没执行完,那么这次通知也就丢失了。
I/O 多路复用的方法1:
使用非阻塞 I/O 和水平触发通知,比如使用 select 或者 poll。
(1) select 使用固定长度的位相量,会有最大描述符数量的限制,默认限制是 1024。
检查套接字状态是用轮询,再加上应用软件使用时的轮询,复杂度是O(n平方)
(2) poll 换成了一个没有固定长度的数组,没有最大描述符数量的限制。
同样需要对文件描述符列表进行轮询,复杂度是O(n)
(3) 每次调用 select 和 poll 时,还需要把文件描述符的集合,从用户空间传入内核空间,由内核修改后,再传出到用户空间。
这一来一回的切换,也增加了处理成本。
I/O 多路复用的方法2:
使用非阻塞 I/O 和边缘触发通知,比如epoll。
(1) epoll 使用红黑树,在内核中管理文件描述符的集合,不需要应用程序在每次操作时传入、传出这个集合.
(2) epoll 使用事件驱动的机制,只关注有 I/O 事件发生的文件描述符,不需要轮询扫描整个集合。复杂度是O(1)
I/O 多路复用的方法3:
使用异步 I/O, 比如glibc 提供的异步 I/O 库
(1) 异步 I/O 允许应用程序同时发起很多 I/O 操作
(2) I/O 完成后,系统会用事件通知的方式,告诉应用程序。应用程序才会去查询 I/O 操作的结果。
I/O 多路复用下的工作模型
主进程 + 多个 worker 子进程
(1) 主进程执行 bind() + listen() 后,创建多个子进程;
(2) 每个子进程都 accept() 或epoll_wait() ,来处理相同的套接字。
这里要注意,accept() 和 epoll_wait() 调用,还存在一个惊群的问题。换句话说,当网络 I/O 事件发生时,多个进程被同时唤醒,但实际上只有一个进程来响应这个事件,其他被唤醒的进程都会重新休眠。
其中,accept() 的惊群问题,已经在 Linux 2.6 中解决了;
而 epoll 的问题,到了 Linux 4.5 ,才通过 EPOLLEXCLUSIVE 解决。
解决方案:
为了避免惊群问题, Nginx 在每个 worker 进程中,都增加一个了全局锁(accept_mutex)。这些 worker 进程需要首先竞争到锁,只有竞争到锁的进程,才会加入到 epoll 中,这样就确保只有一个 worker 子进程被唤醒
监听到相同端口的多进程模型
在这种方式下,所有的进程都监听相同的接口,并且开启 SO_REUSEPORT 选项,由内核负责将请求负载均衡到这些监听进程中去
由于内核确保了只有一个进程被唤醒,就不会出现惊群问题了
C1000K
C1000K 的解决方法,本质上还是构建在 epoll 的非阻塞 I/O 模型上。只不过,除了 I/O 模型之外,还需要从应用程序到 Linux 内核、再到 CPU、内存和网络等各个层次的深度优化,特别是需要借助硬件,来卸载那些原来通过软件处理的大量功能。
C10M
原因:
在 C1000K 问题中,各种软件、硬件的优化很可能都已经做到头了。特别是当升级完硬件(比如足够多的内存、带宽足够大的网卡、更多的网络功能卸载等)后,你可能会发现,无论你怎么优化应用程序和内核中的各种网络参数,想实现 1000 万请求的并发,都是极其困难的。
究其根本,还是 Linux 内核协议栈做了太多太繁重的工作。从网卡中断带来的硬中断处理程序开始,到软中断中的各层网络协议处理,最后再到应用程序,这个路径实在是太长了,就会导致网络包的处理优化,到了一定程度后,就无法更进一步了
要解决这个问题,最重要就是跳过内核协议栈的冗长路径,把网络包直接送到要处理的应用程序那里去。这里有两种常见的机制,DPDK 和 XDP
第一种机制,DPDK
是用户态网络的标准。它跳过内核协议栈,直接由用户态进程通过轮询的方式,来处理网络接收。
第二种机制,XDP(eXpress Data Path)
则是 Linux 内核提供的一种高性能网络数据路径。它允许网络包,在进入内核协议栈之前,就进行处理,也可以带来更高的性能。XDP 底层跟我们之前用到的 bcc-tools 一样,都是基于 Linux 内核的 eBPF 机制实现的
XDP 对内核的要求比较高,需要的是 Linux 4.8 以上版本,并且它也不提供缓存队列。基于 XDP 的应用程序通常是专用的网络应用,常见的有 IDS(入侵检测系统)、DDoS 防御、 cilium 容器网络插件等
理解总结
C10K 问题的根源
一方面在于系统有限的资源;
另一方面,也是更重要的因素,是同步阻塞的 I/O 模型以及轮询的套接字接口,限制了网络事件的处理效率。
Linux 2.6 中引入的 epoll ,完美解决了 C10K 的问题,现在的高性能网络方案都基于 epoll
从 C10K 到 C100K ,可能只需要增加系统的物理资源就可以满足;
但从 C100K 到 C1000K ,就不仅仅是增加物理资源就能解决的问题了。
这时,就需要多方面的优化工作了,从硬件的中断处理和网络功能卸载、到网络协议栈的文件描述符数量、连接状态跟踪、缓存队列等内核的优化,再到应用程序的工作模型优化,都是考虑的重点。
要实现 C10M ,就不只是增加物理资源,或者优化内核和应用程序可以解决的问题了。
这时候,就需要用 XDP 的方式,在内核协议栈之前处理网络包;
或者用 DPDK 直接跳过网络协议栈,在用户空间通过轮询的方式直接处理网络包
当然了,实际上,在大多数场景中,我们并不需要单机并发 1000 万的请求。通过调整系统架构,把这些请求分发到多台服务器中来处理,通常是更简单和更容易扩展的方案
select/poll是LT模式,epoll缺省使用的也是水平触发模式(LT)。
目前业界对于ET的最佳实践大概就是Nginx了,单线程redis也是使用的LT
说下我对水平触发(LT)和边缘触发(ET)我的理解。
LT:文件描述符准备就绪时(FD关联的读缓冲区不为空,可读。写缓冲区还没满,可写),触发通知。
也就是你文中表述的"只要文件描述符可以非阻塞地执行 I/O ,就会触发通知..."
ET:当FD关联的缓冲区发生变化时(例如:读缓冲区由空变为非空,有新数据达到,可读。写缓冲区满变有空间了,有数据被发送走,可写),触发通知,仅此一次
也就是你文中表述的"只有在文件描述符的状态发生改变(也就是 I/O 请求达到)时"