You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

228 lines
11 KiB
Markdown

2 years ago
# 24 | C10K问题高并发模型设计
你好我是盛延敏这里是网络编程实战第24讲欢迎回来。
在性能篇的前4讲里我们陆续讲解了select、poll、epoll等几种I/O多路复用技术以及非阻塞I/O模型为高性能网络编程提供了必要的知识储备。这一讲里我们了解一下历史上有名的C10K问题并借着C10K问题系统地梳理一下高性能网络编程的方法论。
## C10K问题
随着互联网的蓬勃发展一个非常重要的问题摆在计算机工业界面前。这个问题就是如何使用最低的成本满足高性能和高并发的需求。这个问题在过去可能不是一个严重的问题但是在2000年前后互联网用户的人数井喷如果说之前单机服务的用户数量还保持在一个比较低的水平比如说只有上百个用户那么在互联网逐渐普及的情况下服务于成千上万个用户就将是非常普遍的情形在这种情形下如果还按照之前单机的玩法成本就将超过人们想象只有超级有钱的大玩家才可以继续下去。
于是C10K问题应运而生。C10K问题是这样的如何在一台物理机上同时服务10000个用户这里C表示并发10K等于10000。得益于操作系统、编程语言的发展在现在的条件下普通用户使用Java Netty、Libevent等框架或库就可以轻轻松松写出支持并发超过10000的服务器端程序甚至于经过优化之后可以达到十万乃至百万的并发但在二十年前突破C10K问题可费了不少的心思是一个了不起的突破。
C10K问题是由一个叫Dan Kegel的工程师提出并总结归纳的你可以通过访问[这个页面](http://www.kegel.com/c10k.html)来获得最新有关这方面的信息。
## 操作系统层面
C10K问题本质上是一个操作系统问题要在一台主机上同时支持1万个连接意味着什么呢? 需要考虑哪些方面?
### 文件句柄
首先,通过前面的介绍,我们知道每个客户连接都代表一个文件描述符,一旦文件描述符不够用了,新的连接就会被放弃,产生如下的错误:
```
Socket/File:Can't open so many files
```
在Linux下单个进程打开的文件句柄数是有限制的没有经过修改的值一般都是1024。
```
$ulimit -n
1024
```
这意味着最多可以服务的连接数上限只能是1024。不过我们可以对这个值进行修改比如用 root 权限修改 /etc/sysctl.conf 文件使得系统可以支持10000个描述符上限。
```
fs.file-max = 10000
net.ipv4.ip_conntrack_max = 10000
net.ipv4.netfilter.ip_conntrack_max = 10000
```
### 系统内存
每个TCP连接占用的资源可不止一个连接套接字这么简单在前面的章节中我们多少接触到了类似发送缓冲区、接收缓冲区这些概念。每个TCP连接都需要占用一定的发送缓冲区和接收缓冲区。
这里有一段shell代码分别显示了在Linux 4.4.0下发送缓冲区和接收缓冲区的值。
```
$cat /proc/sys/net/ipv4/tcp_wmem
4096 16384 4194304
$ cat /proc/sys/net/ipv4/tcp_rmem
4096 87380 6291456
```
这三个值分别表示了最小分配值、默认分配值和最大分配值。按照默认分配值计算,一万个连接需要的内存消耗为:
```
发送缓冲区: 16384*10000 = 160M bytes
接收缓冲区: 87380*10000 = 880M bytes
```
当然我们的应用程序本身也需要一定的缓冲区来进行数据的收发为了方便我们假设每个连接需要128K的缓冲区那么1万个链接就需要大约1.2G的应用层缓冲。
这样我们可以得出大致的结论支持1万个并发连接内存并不是一个巨大的瓶颈。
### 网络带宽
假设1万个连接每个连接每秒传输大约1KB的数据那么带宽需要 10000 x 1KB/s x8 = 80Mbps。这在今天的动辄万兆网卡的时代简直小菜一碟。
## C10K问题解决之道
通过前面我们对操作系统层面的资源分析可以得出一个结论在系统资源层面C10K问题是可以解决的。
但是,能解决并不意味着可以很好地解决。我们知道,在网络编程中,涉及到频繁的用户态-内核态数据拷贝,设计不够好的程序可能在低并发的情况下工作良好,一旦到了高并发情形,其性能可能呈现出指数级别的损失。
举一个例子如果没有考虑好C10K问题一个基于select的经典程序可能在一台服务器上可以很好处理1000的并发用户但是在性能2倍的服务器上却往往并不能很好地处理2000的并发用户。
要想解决C10K问题就需要从两个层面上来统筹考虑。
第一个层面应用程序如何和操作系统配合感知I/O事件发生并调度处理在上万个套接字上的 I/O操作前面讲过的阻塞I/O、非阻塞I/O讨论的就是这方面的问题。
第二个层面,应用程序如何分配进程、线程资源来服务上万个连接?这在接下来会详细讨论。
这两个层面的组合就形成了解决C10K问题的几种解法方案下面我们一起来看。
### 阻塞I/O + 进程
这种方式最为简单直接每个连接通过fork派生一个子进程进行处理因为一个独立的子进程负责处理了该连接所有的I/O所以即便是阻塞I/O多个连接之间也不会互相影响。
这个方法虽然简单,但是效率不高,扩展性差,资源占用率高。
下面的伪代码描述了使用阻塞I/O为每个连接fork一个进程的做法
```
do{
accept connections
fork for conneced connection fd
process_run(fd)
}
```
虽然这个方式比较传统, 但是可以很好地帮我们理解父子进程、僵尸进程等,我们将在下一讲中详细讲一下如何使用这个技术设计一个服务器端程序。
### 阻塞I/O + 线程
进程模型占用的资源太大,幸运的是,还有一种轻量级的资源模型,这就是线程。
通过为每个连接调用pthread\_create创建一个单独的线程也可以达到上面使用进程的效果。
```
do{
accept connections
pthread_create for conneced connection fd
thread_run(fd)
}while(true)
```
因为线程的创建是比较消耗资源的,况且不是每个连接在每个时刻都需要服务,因此,我们可以预先通过创建一个线程池,并在多个连接中复用线程池来获得某种效率上的提升。
```
create thread pool
do{
accept connections
get connection fd
push_queue(fd)
}while(true)
```
我将在第26讲中详细讲解这部分内容。
### 非阻塞I/O + readiness notification + 单线程
应用程序其实可以采取轮询的方式来对保存的套接字集合进行挨个询问从而找出需要进行I/O处理的套接字像给出的伪码一样其中is\_readble和is\_writeable可以通过对套接字调用read或write操作来判断。
```
for fd in fdset{
if(is_readable(fd) == true){
handle_read(fd)
}else if(is_writeable(fd)==true){
handle_write(fd)
}
}
```
但这个方法有一个问题如果这个fdset有一万个之多每次循环判断都会消耗大量的CPU时间而且极有可能在一个循环之内没有任何一个套接字准备好可读或者可写。
既然这样CPU的消耗太大那么干脆让操作系统来告诉我们哪个套接字可以读哪个套接字可以写。在这个结果发生之前我们把CPU的控制权交出去让操作系统来把宝贵的CPU时间调度给那些需要的进程这就是select、poll这样的I/O分发技术。
于是,程序就长成了这样:
```
do {
poller.dispatch()
for fd in registered_fdset{
if(is_readable(fd) == true){
handle_read(fd)
}else if(is_writeable(fd)==true){
handle_write(fd)
}
}while(ture)
```
第27讲中我将会讨论这样的技术实现。
但是这样的方法需要每次dispatch之后对所有注册的套接字进行逐个排查效率并不是最高的。如果dispatch调用返回之后只提供有 I/O事件或者I/O变化的套接字这样排查的效率不就高很多了么这就是前面我们讲到的epoll设计。
于是基于epoll的程序就长成了这样
```
do {
poller.dispatch()
for fd_event in active_event_set{
if(is_readable_event(fd_event) == true){
handle_read(fd_event)
}else if(is_writeable_event(fd_event)==true){
handle_write(fd_event)
}
}while(ture)
```
Linux是互联网的基石epoll也就成为了解决C10K问题的钥匙。FreeBSD上的kqueueWindows上的IOCPSolaris上的/dev/poll这些不同的操作系统提供的功能都是为了解决C10K问题。
### 非阻塞I/O + readiness notification +多线程
前面的做法是所有的I/O事件都在一个线程里分发如果我们把线程引入进来可以利用现代CPU多核的能力让每个核都可以作为一个I/O分发器进行I/O事件的分发。
这就是所谓的主从reactor模式。基于epoll/poll/select的I/O事件分发器可以叫做reactor也可以叫做事件驱动或者事件轮询eventloop
我没有把基于select/poll的所谓“level triggered”通知机制和基于epoll的“edge triggered”通知机制分开C10K问题总结里是分开的我觉得这只是reactor机制的实现高效性问题而不是编程模式的巨大区别。
从27讲开始我们就会引入reactor模式并使用一个自己编写的简单reactor框架来逐渐掌握它。
### 异步I/O+ 多线程
异步非阻塞 I/O 模型是一种更为高效的方式当调用结束之后请求立即返回由操作系统后台完成对应的操作当最终操作完成就会产生一个信号或者执行一个回调函数来完成I/O处理。
这就涉及到了Linux下的aio机制我们在第30讲对Linux下的aio机制进行简单的讨论。
## 总结
支持单机1万并发的问题被称为C10K问题为了解决C10K问题需要重点考虑两个方面的问题
* 如何和操作系统配合感知I/O事件的发生
* 如何分配和使用进程、线程资源来服务上万个连接?
基于这些组合产生了一些通用的解决方法在Linux下解决高性能问题的利器是非阻塞I/O加上epoll机制再利用多线程能力。
## 思考题
最后给你布置两道思考题:
第一道查询一下资料看看著名的Netty网络编程库用的是哪一种C10K解决方法呢
第二道现在大家又把眼光放到了更有挑战性的C10M问题即单机处理千万级并发你认为能实现吗挑战和瓶颈又在哪里呢
欢迎你在评论区写下你对这两个问题的思考,我会和你一起交流,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。