前面内容,咱们学习了 Linux 网络的基础原理以及性能观测方法。简单回顾一下,Linux网络基于 TCP/IP 模型,构建了其网络协议栈,把繁杂的网络功能划分为应用层、传输层、网络层、网络接口层等四个不一样的层次,既解决了网络环境中设备异构的问题,也解耦了网络协议的复杂性。编程
基于 TCP/IP 模型,咱们还梳理了 Linux 网络收发流程和相应的性能指标。在应用程序经过套接字接口发送或者接收网络包时,这些网络包都要通过协议栈的逐层处理。咱们一般
用带宽、吞吐、延迟、PPS 等来衡量网络性能。数组
今天,咱们主要来回顾下经典的 C10K 和 C1000K 问题,以更好理解 Linux 网络的工做原理,并进一步分析,如何作到单机支持 C10M缓存
注意,C10K 和 C1000K 的首字母 C 是 Client 的缩写。C10K 就是单机同时处理 1 万个请求(并发链接 1 万)的问题,而 C1000K 也就是单机支持处理 100 万个请求(并发链接100 万)的问题。bash
C10K 问题最先由 Dan Kegel 在 1999 年提出。那时的服务器还只是 32 位系统,运行着Linux 2.2 版本(后来又升级到了 2.4 和 2.6,而 2.6 才支持 x86_64),只配置了不多的
内存(2GB)和千兆网卡。服务器
从资源上来讲,对 2GB 内存和千兆网卡的服务器来讲,同时处理 10000 个请求,只要每一个请求处理占用不到 200KB(2GB/10000)的内存和 100Kbit (1000Mbit/10000)的网络
网络带宽就能够。因此,物理资源是足够的,接下来天然是软件的问题,特别是网络的I/O 模型问题。架构
说到 I/O 的模型,我在文件系统的原理中,曾经介绍过文件 I/O,其实网络 I/O 模型也相似。在 C10K 之前,Linux 中网络处理都用同步阻塞的方式,也就是每一个请求都分配一个
进程或者线程。请求数只有 100 个时,这种方式天然没问题,但增长到 10000 个请求时,10000 个进程或线程的调度、上下文切换乃至它们占用的内存,都会成为瓶颈。并发
第一,怎样在一个线程内处理多个请求,也就是要在一个线程内响应多个网络 I/O。之前的同步阻塞方式下,一个线程只能处理一个请求,到这里再也不适用,是否是能够用非阻塞
I/O 或者异步 I/O 来处理多个网络请求呢?负载均衡
第二,怎么更节省资源地处理客户请求,也就是要用更少的线程来服务这些请求。是否是能够继续用原来的 100 个或者更少的线程,来服务如今的 10000 个请求呢?异步
固然,事实上,如今 C10K 的问题早就解决了,在继续学习下面的内容前,你能够先本身思考一下这两个问题。结合前面学过的内容,你是否是已经有了解决思路呢?
异步、非阻塞 I/O 的解决思路,你应该据说过,其实就是咱们在网络编程中常常用到的I/O 多路复用(I/O Multiplexing)。I/O 多路复用是什么意思呢?
别急,详细了解前,我先来说两种 I/O 事件通知的方式:水平触发和边缘触发,它们经常使用在套接字接口的文件描述符中。
一、水平触发:只要文件描述符能够非阻塞地执行 I/O ,就会触发通知。也就是说,应用程序能够随时检查文件描述符的状态,而后再根据状态,进行 I/O 操做。
二、边缘触发:只有在文件描述符的状态发生改变(也就是 I/O 请求达到)时,才发送一次通知。这时候,应用程序须要尽量多地执行 I/O,直到没法继续读写,才能够中止。
若是 I/O 没执行完,或者由于某种缘由没来得及处理,那么此次通知也就丢失了
接下来,咱们再回过头来看 I/O 多路复用的方法。这里其实有不少实现方法,我带你来逐个分析一下。
根据刚才水平触发的原理,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 时,还须要把文件描述符的集合,从用户空间传入内核空间,由内核修改后,再传出到用户空间中。这一来一回的内核空间与用户空
间切换,也增长了处理成本。
有没有什么更好的方式来处理呢?答案天然是确定的。
既然 select 和 poll 有那么多的问题,就须要继续对其进行优化,而 epoll 就很好地解决了这些问题。
epoll 使用红黑树,在内核中管理文件描述符的集合,这样,就不须要应用程序在每次操做时都传入、传出这个集合。
epoll 使用事件驱动的机制,只关注有 I/O 事件发生的文件描述符,不须要轮询扫描整个集合。
不过要注意,epoll 是在 Linux 2.6 中才新增的功能(2.4 虽然也有,但功能不完善)。因为边缘触发只在文件描述符可读或可写事件发生时才通知,那么应用程序就须要尽量多
地执行 I/O,并要处理更多的异常事件。
在前面文件系统原理的内容中,我曾介绍过异步 I/O 与同步 I/O 的区别。异步 I/O 容许应用程序同时发起不少 I/O
操做,而不用等待这些操做完成。而在 I/O 完成后,系统会用事件通知(好比信号或者回调函数)的方式,告诉应用程序。这时,应用程序才会去查询 I/O 操做的结果。
异步 I/O 也是到了 Linux 2.6 才支持的功能,而且在很长时间里都处于不完善的状态,好比 glibc 提供的异步 I/O 库,就一直被社区诟病。同时,因为异步 I/O 跟咱们的直观逻辑
不太同样,想要使用的话,必定要当心设计,其使用难度比较高。
了解了 I/O 模型后,请求处理的优化就比较直观了。使用 I/O 多路复用后,就能够在一个进程或线程中处理多个请求,其中,又有下面两种不一样的工做模型。
这种方法的一个通用工做模式就是:
主进程执行 bind() + listen() 后,建立多个子进程; 而后,在每一个子进程中,都经过 accept() 或 epoll_wait() ,来处理相同的套接字。
好比,最经常使用的反向代理服务器 Nginx 就是这么工做的。它也是由主进程和多个 worker进程组成。主进程主要用来初始化套接字,并管理子进程的生命周期;而 worker 进程,
则负责实际的请求处理。我画了一张图来表示这个关系。
这里要注意,accept() 和 epoll_wait() 调用,还存在一个惊群的问题。换句话说,当网络I/O 事件发生时,多个进程被同时唤醒,但实际上只有一个进程来响应这个事件,其余被
唤醒的进程都会从新休眠。
为了不惊群问题, Nginx 在每一个 worker 进程中,都增长一个了全局锁(accept_mutex)。这些 worker 进程须要首先竞争到锁,只有竞争到锁的进程,才会加
入到 epoll 中,这样就确保只有一个 worker 子进程被唤醒。
不过,根据前面 CPU 模块的学习,你应该还记得,进程的管理、调度、上下文切换的成本很是高。那为何使用多进程模式的 Nginx ,却具备很是好的性能呢?
这里最主要的一个缘由就是,这些 worker 进程,实际上并不须要常常建立和销毁,而是在没任务时休眠,有任务时唤醒。只有在 worker 因为某些异常退出时,主进程才须要创
建新的进程来代替它。
固然,你也能够用线程代替进程:主线程负责套接字初始化和子线程状态的管理,而子线程则负责实际的请求处理。因为线程的调度和切换成本比较低,实际上你能够进一步把
epoll_wait() 都放到主线程中,保证每次事件都只唤醒主线程,而子线程只须要负责后续的请求处理。
在这种方式下,全部的进程都监听相同的接口,而且开启 SO_REUSEPORT 选项,由内核负责将请求负载均衡到这些监听进程中去。这一过程以下图所示。
因为内核确保了只有一个进程被唤醒,就不会出现惊群问题了。好比,Nginx 在 1.9.1 中就已经支持了这种模式。
不过要注意,想要使用 SO_REUSEPORT 选项,须要用 Linux 3.9 以上的版本才能够。
基于 I/O 多路复用和请求处理的优化,C10K 问题很容易就能够解决。不过,随着摩尔定律带来的服务器性能提高,以及互联网的普及,你并不难想到,新兴服务会对性能提出更高的要求。
很快,原来的 C10K 已经不能知足需求,因此又有了 C100K 和 C1000K,也就是并发从原来的 1 万增长到 10 万、乃至 100 万。从 1 万到 10 万,其实仍是基于 C10K 的这些理
论,epoll 配合线程池,再加上 CPU、内存和网络接口的性能和容量提高。大部分状况下,C100K 很天然就能够达到。
首先从物理资源使用上来讲,100 万个请求须要大量的系统资源。好比,
假设每一个请求须要 16KB 内存的话,那么总共就须要大约 15 GB 内存。而从带宽上来讲,假设只有 20% 活跃链接,即便每一个链接只须要 1KB/s 的吞吐量,总
共也须要 1.6 Gb/s 的吞吐量。千兆网卡显然知足不了这么大的吞吐量,因此还须要配置万兆网卡,或者基于多网卡 Bonding 承载更大的吞吐量。
其次,从软件资源上来讲,大量的链接也会占用大量的软件资源,好比文件描述符的数量、链接状态的跟踪(CONNTRACK)、网络协议栈的缓存大小(好比套接字读写缓存、
TCP 读写缓存)等等。
最后,大量请求带来的中断处理,也会带来很是高的处理成本。这样,就须要多队列网卡、中断负载均衡、CPU 绑定、RPS/RFS(软中断负载均衡到多个 CPU 核上),以及将
网络包的处理卸载(Offload)到网络设备(如 TSO/GSO、LRO/GRO、VXLANOFFLOAD)等各类硬件和软件的优化。
C1000K 的解决方法,本质上仍是构建在 epoll 的非阻塞 I/O 模型上。只不过,除了 I/O模型以外,还须要从应用程序到 Linux 内核、再到 CPU、内存和网络等各个层次的深度优
化,特别是须要借助硬件,来卸载那些原来经过软件处理的大量功能。
显然,人们对于性能的要求是无止境的。再进一步,有没有可能在单机中,同时处理1000 万的请求呢?这也就是 C10M 问题。
实际上,在 C1000K 问题中,各类软件、硬件的优化极可能都已经作到头了。特别是当升级完硬件(好比足够多的内存、带宽足够大的网卡、更多的网络功能卸载等)后,你可能
会发现,不管你怎么优化应用程序和内核中的各类网络参数,想实现 1000 万请求的并发,都是极其困难的。
究其根本,仍是 Linux 内核协议栈作了太多太繁重的工做。从网卡中断带来的硬中断处理程序开始,到软中断中的各层网络协议处理,最后再到应用程序,这个路径实在是太长
了,就会致使网络包的处理优化,到了必定程度后,就没法更进一步了。
要解决这个问题,最重要就是跳过内核协议栈的冗长路径,把网络包直接送到要处理的应用程序那里去。这里有两种常见的机制,DPDK 和 XDP。
(图片来自 https://blog.selectel.com/introduction-dpdk-architecture-principles/)
提及轮询,你确定会下意识认为它是低效的象征,可是进一步反问下本身,它的低效主要体如今哪里呢?是查询时间明显多于实际工做时间的状况下吧!那么,换个角度来想,如
果每时每刻都有新的网络包须要处理,轮询的优点就很明显了。好比:
在 PPS 很是高的场景中,查询时间比实际工做时间少了不少,绝大部分时间都在处理网络包;
而跳过内核协议栈后,就省去了繁杂的硬中断、软中断再到 Linux 网络协议栈逐层处理的过程,应用程序能够针对应用的实际场景,有针对性地优化网络包的处理逻辑,而不
须要关注全部的细节。
此外,DPDK 还经过大页、CPU 绑定、内存对齐、流水线并发等多种机制,优化网络包的处理效率。
XDP 底层跟咱们以前用到的 bcc-tools 同样,都是基于 Linux 内核的 eBPF 机制实现的。XDP 的原理以下图所示:
(图片来自 https://www.iovisor.org/technology/xdp)
你能够看到,XDP 对内核的要求比较高,须要的是 Linux 4.8 以上版本,而且它也不提供缓存队列。基于 XDP 的应用程序一般是专用的网络应用,常见的有 IDS(入侵检测系
统)、DDoS 防护、 cilium 容器网络插件等。
今天我带你回顾了经典的 C10K 问题,并进一步延伸到了 C1000K 和 C10M 问题。
C10K 问题的根源,一方面在于系统有限的资源;另外一方面,也是更重要的因素,是同步阻塞的 I/O 模型以及轮询的套接字接口,限制了网络事件的处理效率。Linux 2.6 中引入
的 epoll ,完美解决了 C10K 的问题,如今的高性能网络方案都基于 epoll。
从 C10K 到 C100K ,可能只须要增长系统的物理资源就能够知足;但从 C100K 到C1000K ,就不只仅是增长物理资源就能解决的问题了。这时,就须要多方面的优化工做
了,从硬件的中断处理和网络功能卸载、到网络协议栈的文件描述符数量、链接状态跟踪、缓存队列等内核的优化,再到应用程序的工做模型优化,都是考虑的重点。
再进一步,要实现 C10M ,就不仅是增长物理资源,或者优化内核和应用程序能够解决的问题了。这时候,就须要用 XDP 的方式,在内核协议栈以前处理网络包;或者用 DPDK
直接跳过网络协议栈,在用户空间经过轮询的方式直接处理网络包。
固然了,实际上,在大多数场景中,咱们并不须要单机并发 1000 万的请求。经过调整系统架构,把这些请求分发到多台服务器中来处理,一般是更简单和更容易扩展的方案。