单服务器理论最大网络连接数是多少?

你是否曾经有想过这个问题,我们的一台 web 服务器最多能连接多少个客户端,或者说是服务多少个用户?是不是说,无论用户数量有多少,只要 CPU 和内存足够,就能支持?

今天我们就来说说,操作系统的网络部分。(本文会一直围绕着这个问题来进行)

PS:由于网络部分的知识体系过于庞大,计算机网络出的书数不胜数,单单我想用一篇博客写完是不可能的,所以我选择了其中很多人最关心,也在实际中能运用到的一个知识点来拓展 —— I/O 多路复用

本文可能需要你有一些预备知识:

  1. TCP 和 UDP 协议的基本概念
  2. OSI 7 层网络模型的概念

引子

我们知道 OSI 模型有着 7 层,每层都有着自己的职责,而网络的本质就是不同协议针对数据的不断包装,因为外部的网络环境复杂,只有将快递(数据包)包装完整,写清楚地址,才能尽可能的不丢失在茫茫互联网海洋中。

其中,传输层有着我们的 TCP 和 UDP 两个非常重要的协议,他们保证了我们的数据传输。

那么问题来了,我们知道 TCP 和 UDP 的协议本身还是比较复杂的,作为应用来说,我们不可能每个应用再去实现一遍 TCP 协议,显然需要操作系统来帮帮忙,于是 Linux 就出现了 socket 方法,有了它,让我们的网络通信操作如同操作文件一样。

第一个问题-FD

那么既然 socket 方法能让我们的网络通信如同操作文件一样,那么势必也有一个 fd(文件描述符) 。

所以第一个问题就是 fd 的数量,一方面是 linux 对于 fd 的数量是有限制条件的,通过命令 ulimit -n 可以查看,这个是单个进程 fd 的限制,一般默认是 1024。另一方面是 fd 也需要占用空间,虽然占用不多,但也是一个条件(当然除了 fd 之外还有很多别的需要占用资源的东西)。

假设我们一个连接需要占用一个 fd,那么显然,对于 fd 的限制条件就会影响 “单机最大连接数的问题”

第二个问题-IP/Port

我们想到的第二个资源问题,就是 ip 和端口,TCP 的连接需要四元祖:源 IP、源端口、目的 IP、目的端口,服务端通常会固定一个端口,所以按照这个计算,单机最大连接数为:客户端 IP 数 × 客户端端口数 2^48 (这里还不算一些不可用端口和 ip)

第三个问题-IO

这个问题是我们今天的重点,当我们通过 socket 创建了对应的 fd 进行读写,那么势必需要“人”来维护这个读写,我们不知道什么时候请求会来,也不知道什么时候突然就要发送请求了。

首先我们就会排除,一个 fd 开一个进程去管理,因为进程实在是太重了,所需要的资源也太多,我们最先想到的应该是使用线程,也就是多线程去管理 fd。那么假设我们对于每个 fd 使用一个线程去管理可行吗?

经过我们之前进程和线程的学习,就会发现,其实线程本身还是需要一个 task_struct 结构去保存它的状态,那么本质和进程差别不大,好在是上下文切换会方便很多。

那如何优化呢?于是 Linux 就提出了 I/O 多路复用

IO 多路复用

既然无法创建很多线程或进程来分别管理 fd,那么有没有办法就用一个进程去处理所有的 fd 呢?这就是 IO 多路复用。

就如同 CPU 一样,CPU 其实每个时刻也只能处理一件事,但是他处理的快,一秒钟可以处理很多事,看起来就像是并行处理一样,IO 多路复用的原理类似,多个请求复用了一个进程去处理。

那么它的实现方式有:select/poll/epoll….

select

这个方式是我们其实能想到的,它就是将所有的已经建立连接的 socket 的 fd 都放到一个集合中,然后让内核进行遍历,如果有读写事件,就标记一下,然后返回给用户态,用户态再次遍历找出标记的 socket 然后进行读写。

select 使用 bitmap 来存储 fd 的集合,默认大小为 1024。

1
2
3
typedef struct {
unsigned long fds_bits[__FDSET_LONGS];
} fd_set;

当然 select 有着明显的问题:

  1. 需要将这个集合在用户态和内核态之间拷贝传递
  2. 这个集合的遍历是循环,O(n) 的复杂度
  3. bitmap 有一个大小限制,一开始创建太大也不行,太小也不好,并且是个数组,删除元素的时候很麻烦

poll

poll 主要优化的是 select 存储结构,使用了链表来存储集合,这样容量大小就没有限制了(没有数组长度的限制)并且对于删除更加友好了。

当然由于链表本身还是线性结构,遍历的效率还是一样的。

epoll

我们可以想到的,由于线性的遍历效率太低,转而用红黑树来存储整个集合,一方面是增删改的时间复杂度降低 O(logn) 并且由于是树形存储,那么传递的时候只需要传递 root 就可以了,减少了数据拷贝

还有一个点是,维护了一个链表用于记录就绪的事件,当某个 socket 有事件出现的时候就会加入到这个列表中,当需要知道那些 socket 就绪需要读写时,调用 epoll_wait 只需要返回这个列表就可以了。

epoll 有两种触发方式

边缘触发

当被监控的 fd 有事件时,只会从 epoll_wait 返回一次,即使你不读,也没有下次触发了,所以你这次必须读完所有 buffer 中的内容。

水平触发

当被监控的 fd 有事件时,会不断的进行触发,直到你处理完,才不会触发

总的来说就是,一种就是不停的提醒你,直到你处理完成;一种是提醒你一次,就好了,就不管了。那当然提醒一次耗费的资源少,提醒多次就不会出现意外情况。

总结

通过这些介绍,我相信你对开头的问题的回答已经有所思路了,操作系统网络部分,一方面是封装了可靠的 API 让我们不用面向协议编程,而只需调用接口即可,另一方面也为了满足更多的请求需要,设计了多路复用的各种方案。

其实第一次看完这些方案的设计思想真的就想说一句,真的在编程中太多的设计都是相通的:比如数组和链表的效率不高就转红黑树树,这在 java 的 hashmap 中就有过这样的优化;再比如事件监听,与其轮询等待,不如 event-listen…

IO 多路复用技术运用的地方很多,如 redis、nginx 等,当然除了 Linux 的 epoll,还有 BSD 和 MACOSX 用的 kqueue。