C10K:如何在一台物理机上同时服务 10000 个用户?这里 C 表示并发,10K 等于 10000。
得益于操作系统、编程语言的发展,在现在的条件下,普通用户使用 Java Netty、Libevent 等框架或库就可以轻轻松松写出支持并发超过 10000 的服务器端程序,甚至于经过优化之后可以达到十万,乃至百万的并发,但在二十年前,突破 C10K 问题可费了不少的心思,是一个了不起的突破。
支持10K个连接需要考虑什么方面?
在 Linux 下,单个进程打开的文件句柄数是有限制的,没有经过修改的值一般都是 1024。这意味着最多可以服务的连接数上限只能是 1024。不过,我们可以对这个值进行修改,比如用 root 权限修改 /etc/sysctl.conf 文件,使得系统可以支持 10000 个描述符上限。
每个 TCP 连接都需要占用一定的发送缓冲区和接收缓冲区。下面的代码表示发送/接收缓冲区的值(最小、默认、最大)。10K个连接需要大约1.2G应用层缓冲。内存不是巨大的瓶颈。
$cat /proc/sys/net/ipv4/tcp_wmem
4096 16384 4194304
$ cat /proc/sys/net/ipv4/tcp_rmem
4096 87380 6291456
假设 1 万个连接,每个连接每秒传输大约 1KB 的数据,那么带宽需要 10000 x 1KB/s x8 = 80Mbps。这在今天的动辄万兆网卡的时代简直小菜一碟。
从上面可以看出,C10K问题在系统资源方面可以解决。但是在网络编程中,涉及到频繁的用户态 - 内核态数据拷贝,设计不够好的程序可能在低并发的情况下工作良好,一旦到了高并发情形,其性能可能呈现出指数级别的损失。
1.应用程序如何和操作系统配合,感知 I/O 事件发生,并调度处理在上万个套接字上的 I/O 操作?前面讲过的阻塞 I/O、非阻塞 I/O 讨论的就是这方面的问题。
2.应用程序如何分配进程、线程资源来服务上万个连接?
概念:每个连接通过fork派生一个子进程进行处理。独立的子进程负责处理该连接所有的I/O,所以即使是阻塞I/O,多个连接也不会互相影响。
伪代码:
do{
accept connections
fork for conneced connection fd
process_run(fd)
}
缺点:虽然简单,但是效率不高,扩展性差,资源占用率高
概念:在上一种方法的基础上将进程改为线程,线程比进程更加轻量级。
伪代码:
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)
非阻塞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 分发技术。
改进1:使用select poll来代替挨个询问,但是select、poll需要每次 dispatch 之后,对所有注册的套接字进行逐个排查,效率并不是最高的。如果 dispatch 调用返回之后只提供有 I/O 事件或者 I/O 变化的套接字,即epoll
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)
改进2:使用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)
概念:两个线程池,主处理accptor,从处理read write。这就是所谓的主从 reactor 模式。前面的做法是所有的 I/O 事件都在一个线程里分发,如果我们把线程引入进来,可以利用现代 CPU 多核的能力,让每个核都可以作为一个 I/O 分发器进行 I/O 事件的分发。
reactor:基于 epoll/poll/select 的 I/O 事件分发器可以叫做 reactor,也可以叫做事件驱动,或者事件轮询(eventloop)。
概念:异步非阻塞 I/O 模型是一种更为高效的方式,当调用结束之后,请求立即返回,由操作系统后台完成对应的操作,当最终操作完成,就会产生一个信号,或者执行一个回调函数来完成 I/O 处理
在 Linux 下,解决高性能问题的利器是非阻塞 I/O 加上 epoll 机制,再利用多线程能力。