Linux网络编程 - C10K问题:高并发模型的设计初篇

C10K问题

这一篇,借着 C10K 问题,系统地梳理一下高性能网络编程的方法论。

C10K 问题是这样的:如何在一台物理机上同时服务 10000 个用户?这里 C 表示并发,10K 等于 10000。得益于操作系统、编程语言的发展,在现在的条件下,普通用户使用 Java Netty、Libevent 等框架或库就可以轻轻松松写出支持并发超过 10000 的服务器端程序,甚至于经过优化之后可以达到十万,乃至百万的并发。

操作系统层面

C10K 问题本质上是一个操作系统问题,要在一台主机上同时支持 1 万个连接,意味着什么呢? 需要考虑哪些方面?

  • 文件句柄数

每个客户连接都代表一个文件描述符,一旦文件描述符不够用了,新的连接就会被放弃,产生如下的错误:

Socket/File:Can't open so many files

在 Linux 下,单个进程打开的文件句柄数是有限制的,没有经过修改的值一般都是 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 连接占用的资源除了连接套接字,还有一定的发送缓冲区和接收缓冲区。文稿里有一段 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)

因为线程的创建是比较消耗资源的,况且不是每个连接在每个时刻都需要服务,因此,我们可以预先通过创建一个线程池,并在多个连接中复用线程池来获得某种效率上的提升。

  • 非阻塞 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 时间调度给那些需要的进程,这就是 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)

但是,这样的方法需要每次 dispatch 之后,对所有注册的套接字进行逐个排查,效率并不是最高的。如果 dispatch 调用返回之后只提供有 I/O 事件或者 I/O 变化的套接字,这样排查的效率不就高很多了么?这就是前面我们讲到的 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 上的 kqueue,Windows 上的 IOCP,Solaris 上的 /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 机制的实现高效性问题,而不是编程模式的巨大区别。

  • 异步 I/O+ 多线程

异步非阻塞 I/O 模型是一种更为高效的方式,当调用结束之后,请求立即返回,由操作系统后台完成对应的操作,当最终操作完成,就会产生一个信号,或者执行一个回调函数来完成 I/O 处理。这就涉及到了 Linux 下的 aio 机制,后面接着讨论。

总之, 为了解决 C10K 问题,需要重点考虑两个方面的问题:

  • 如何和操作系统配合,感知 I/O 事件的发生?
  • 如何分配和使用进程、线程资源来服务上万个连接?

基于这些组合,产生了一些通用的解决方法,在 Linux 下,解决高性能问题的利器是非阻塞 I/O 加上 epoll 机制,再利用多线程能力。

这里再提了一个延伸性的问题,C10M问题?

有一个思路,10个处理epoll队列的线程。 每个线程处理一个epoll队列,每个epoll队列容纳最多100万个socket。

温故而知新 !

发布了56 篇原创文章 · 获赞 39 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/qq_24436765/article/details/104597644