网络 Server 模型的演进

程序员圈子的快速发展使得 Web 应用开发人员大多数情况下面对的是一个 Web Framework 如 Python 的 Django、tornado, PHP 的 Laravel 等,但是在这些 framework 之前的 Server 如 Nginx,Apache 的原理却显有了解。本文就网络 Server 模型的原理与演进展开描述,这里的“网络 Server 模型”指的是具有高阻塞、低占用特点的一类应用,不仅仅 HTTP 服务,其他的如 ftp 服务,SQL 数据库链接服务等也都在此列。网络 Server 的发展先后经历了 Process(进程模型),Thread(线程模型),Prefork(进程池),ThreadPool,Event Driven(事件模型)等,本文一一介绍

没有模型

网络编程刚刚兴起的时候还没有人考虑并发这个问题,当做传统的应用来编写的 Server 是阻塞并且没有使用任何模型的。只是简单的监听某个端口 accept,接收数据 recv,然后返回 send。逻辑非常简单,易于实现。但是缺点显而易见,阻塞占据了大多数 CPU 时间,并发数只有 1,也就是说某个端口上的应用在服务于某个用户的时候其他用户都要等待。

进程、线程模型

上面“没有模型”的设计显然是低效率的,结合操作系统多进程的概念提出了一个主进程监听端口,对于每个连接都使用一个独立的 worker 子进程去处理,连接的读写数据操作全部阻塞在这个子进程中,这样对 server 的并发能力有了很大的提升。但是进程在操作系统中是个相对比较重的概念,进程的创建、销毁、切换都是非常大的开销,同时随着线程的兴起,在 server 端使用多个子线程的 worker 处理不同连接的方式进一步提升了单机并发性能。

面对进程的创建、销毁、切换成本开销非常大的问题,除了使用线程替代进程处理不同连接外,有人想出了一个很秒的方法,那就是预先创建数个进程在内存中,不同连接到来时将请求分发到内存中不同的进程里去处理,也就是预先开辟进程池,这样避免了频繁重复创建销毁进程的问题,从而大大提升了 server 性能。与进程池相对应的便是线程池的概念,线程池的使用也大大提高的线程模型的效率。

当然进程模型与线程模型有各自的优缺点,并不存在一方占据绝对优势的情况。比如在稳定性方面如果一个进程挂了对另外的进程没有影响,而线程模型中一个线程挂了那么所有程序都挂了;但是线程间共享内存空间所以线程间数据共享要比进程之间更容易。在这个基础上我们再考虑 memcached 使用的单进程多线程模型就更好理解了,memcached 进程启动后,所有连接过来写的数据全部存储在一个进程空间中,不同的线程可以无障碍访问,即满足高并发的性能要求又不至于去编写不同进程之间 IPC 的复杂逻辑。同时 redis 在平时工作时也是单进程多线程模型,但在涉及到诸如持久化的耗时操作时使用多进程的方式来组织。

进程、线程的优缺点对比:

多进程多线程总结
CPU,内存消耗内存占用多,CPU 利用率低内存占用少,CPU 利用率高线程占优
创建、销毁、切换比较复杂比较简单线程占优
数据同步与共享数据间相互隔离,同步简单,共享复杂需要 IPC数据存在于同一个内存空间,共享简单,但是同步涉及到加锁等问题各有优势
调试复杂度简单复杂进程占优
可靠性进程间不相互影响一个线程会导致该进程下所有线程挂掉进程占优
扩展性多核心、多设备扩展是适用于多核心扩展进程占优

事件模型

上面提到的进程、线程或者进程池、线程池模型都是阻塞模式的。那么在阻塞的时候连接仍然以进程或者线程的形式占据耗费着系统资源。而在事件驱动模型中只有在阻塞事件就绪时才会分配相应的系统资源,自然大大提高了系统并发处理性能。

得益于操作系统的快速发展,从操作系统层面提供了 select,poll,epoll(Linux),kqueue(BSD)等基于事件的 IO 多路复用机制。一旦某个文件描述符转变为可读或者可写的状态,就通知相应的程序进行操作。但他们本质上都是同步 IO,因为在收到读或者写事件后程序需要自己负责进行读或者写操作,也就是说这个读写过程还是阻塞的。而异步 IO 则无需程序自己负责进行读写操作而是操作系统内核直接把数据存储到用户空间提供使用。我们先来看看同步 IO 的 select, poll, 和 epoll。

select 调用过程select 调用过程

上图是 select() 调用过程,select 有 3 大缺点:

  1. 每次调用 select 需要把进程空间中所有 fd 从用户态拷贝到内核态,这在 fd 数量很大的时候开销很大
  2. 同时每次调用 select 需要在内核态遍历所有的 fd 并挂进阻塞队列,如果 fd 数量很大的情况开销很大
  3. select 支持的文件描述符数量少,默认是1024,这限制了并发连接的上限

poll 方式相对于 select 方式只是文件描述符的结构由 fd_set 变成了 pollfd,并没有从本质上解决 select 的问题。

epoll 是对 select、poll 方式的改进,那么 epoll 是怎样解决上面 3 个问题的呢。首先从暴露出的 API 上来看,select 和 poll 只有一个同名函数,而 epoll 提供了 epoll_create,epoll_ctl和epoll_wait 三个函数,分别为了创建一个 epoll 句柄,注册要监听的事件类型,等待事件的产生。对于缺点1,每次注册新事件到 epoll 句柄中时(在 epoll_ctl 中指定 EPOLL_CTL_ADD)会把 fd 拷贝到内核中,而不是在 epoll_wait 时重复拷贝,这样保证了每个 fd 在整个过程中只会被拷贝一次。对于缺点2,epoll 不像 select 和 poll 每次都把 fd 挂进阻塞队列,而是只在 epoll_ctl 时挂一次并同时给相应 fd 注册一个回调函数,当相应设备被唤醒执行这个回调函数的时候实际上就是把这个 fd 放入就绪队列,然后 epoll_wait 的时候就查看就绪队列中有没有内容并返回即可。对于缺点3,epoll 没有这个限制,epoll 支持的 fd 数量是系统最大 fd 数量,通常 cat /proc/sys/fs/file-max 查看,跟设备内存有很大关系。

异步 IO

上面事件驱动的 select,poll,epoll 机制即使很大的提升了性能,但是在数据的读写操作上还是同步的。而异步 IO 的出现进一步提升了 Server 的处理能力,应用程序发起一个异步读写操作,并提供相关参数(如用于存放数据的缓冲区、读写数据的大小、以及请求完成后的回调函数等),操作系统在自身的内核线程中执行实际的读或者写操作,并将结果存入程序制定的缓冲区中,然后把事件和缓冲区回调给应用程序。目前有很多语言已经封装了各自的异步 IO 库,如 Python 的 asyncio。

总结

在没有新的模型提出来之前,我们能做的就是结合实际的应用场景和上面模型的优缺点组合出最高效的解决方案,比如 epoll + ThreadPool 就是 muduo 这个高效 C++ 网络库采取的方案。

在当前 C10K 问题的主流背景下,epoll 和异步 IO 这种事件驱动模型正逐渐变为人们的首选方案,这也是 Nginx 能不断从 Apache 中抢占市场的一个重要原因。然而从 Linux 社区来看对填平 aio(异步 IO)这个大坑并没有太大兴趣,那么为了异步 IO 的统一只能从应用层进行兼容,免不了多次内核态与用户态的交互,这对程序性能自然会有损失。当然随着时间的发展单机并发性能的解决办法越来越高效,但是对应的程序开发复杂度也越来越高,我们要做的就是在这两者之间做出最优权衡。