前言

C10K 问题

C10K 问题是由Dan Kegel在1999年提出的。C代表并发连接,指的是在一个单机网络服务器能够同时1万个并发请求。在过去很长一段时间,这个目标一度是很难实现的,所以也就产生了C10K的问题,但是随着网络技术的发展,C10早已经被解决,现如今已经不再是一个具有挑战性的问题了。

上面提到的C10K问题,是在32位Linux2.2内核的机器上遇到的问题,在当时由于机器的内存、网卡和网络带宽等硬件的限制,人们发现很难突破这个问题。随着电子工业的发展,根据摩尔定律,计算机的处理能力,每隔一段时间都会翻倍, 计算机的处理能力已经渐渐不再是瓶颈。与此同时,随着网络技术的发展,网络连接的IO模型和架构模式也在不断的发展,在现如今分布式集群的环境下,当时的问题虽然早已经不复存在,但是弄清楚背后的原理,能够帮助我们写出高性能的程序。

IO 模型的演进

随着操作系统内核的发展,推动软件技术的进步,结合编程语言的支持,诞生了许多种不同的IO模型,大致概括为一下五类:

阻塞式 IO

阻塞式IO也就是一对一建立连接,一个客户端和一个服务端建立连接,在服务端返回数据之前,客户端会阻塞,直到有数据返回,对应的调用过程如下图

这种模式的IO连接,操作系统会给每一个连接分配一个线程(进程),在有数据返回之前,调用线程都会阻塞,如果连接数变多,就会频繁创建、销毁线程,给操作系统带来巨大的开销,除此之外,在数据发送过程中,数据要在操作系统内核态和用户态之前切换,又增加了操作系统的开销

非阻塞式 IO

上述阻塞式IO缺点明显,支持不了很多的连接,那么这时就会产生一个问题,一个网络服务如何服务更多的用户?

阻塞式IO在上述的基础上发展了一点儿,在用户态发生一次系统调用后,内核会立即返回,在用户态第一个阶段不是阻塞的,而是会不断的轮询内核数据是否准备好,第二个阶段数据在发生上下文切换的过程中,仍然是阻塞的

与此同时,我们可以看到,这种IO模型依然需要频繁的上下文切换,增大系统的开销

IO 多路复用

前面两个IO模型,本质上都是一个连接请求分配一个进程(线程),有没有一个办法让一个进程维护多个Socket状态呢?这就是IO多路复用技术,也是目前使用最广泛的IO模型技术。

IO多路复用本质上和非阻塞式IO一样,不同的是它利用了select/poll、epoll这样的操作系统内核支持的特性,来完成更高并发数连接的支持,从模型本质上它仍然是同步非阻塞式IO。

select/poll

select 实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。

所以,对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。

select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。

poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。

但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。

epoll

epoll 通过两个方面,很好解决了 select/poll 的问题。

第一点,epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述符,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删查一般时间复杂度是 O(logn),通过对这棵黑红树进行操作,这样就不需要像 select/poll 每次操作时都传入整个 socket 集合,只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。

第二点, epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。从下图你可以看到 epoll 相关的接口作用:

epoll 的方式即使监听的 Socket 数量越多的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也非常的多了,上限就为系统定义的进程打开的最大文件描述符个数。因而,epoll 被称为解决 C10K 问题的利器。epoll 支持两种事件触发模式,分别是边缘触发(edge-triggered,ET)和水平触发(level-triggered,LT)

  • 边缘触发:使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;
  • 水平触发:使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取。

select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。

信号驱动 IO

异步 IO

前面四种IO模型从同步异步角度来看,都是同步IO,那么有没有真正的异步IO模型呢?答案是有的,这需要操作系统内核的支持

总结

以上列出了五种IO模型,其中IO多路复用代表的有Reactor模型,异步IO代表的有Proactor模型,今天这篇文章讨论的主要是Reactor模型

Reactor IO 模型

上述IO模型着重写了IO多路复用技术,在工业软件领域,IO多路复用技术有非常多成熟的应用,例如Nginx、Redis、Netty等。前面说了,从模型本质上来看,IO多路复用是同步非阻塞式IO,对于非阻塞式IO,不同的语言也有不同的实现,例如Java在JDK1.4引入了NIO,其就是非阻塞式IO(Netty就是基于Java NIO实现)

说了这么多IO多路复用技术,但是我们实际应用还是一个叫做Reactor模型的东西。Reactor模型是一帮大佬们结合面向对象的思想,在IO多路复用上做了一层封装,并且取了一个很牛逼的名字——Reactor模型。目前Reactor模型有三种方案,分别是:

  • 单Reactor单线程/单进程
  • 单Reactor多线程/多进程
  • 主从Reactor多线程/多进程

以上三种方案都有对应的应用,至于方案中具体选用是线程还是进程,这个跟实现的程序语言有关,一般来说,Java语言使用的是线程,例如Netty;C语言则使用的是线程或者进程都可以,例如Nginx使用的是进程,Memcache使用的是线程。

单 Reactor 单线程/单进程

一般来说,C 语言实现的是「单 Reactor 单进程」的方案,因为 C 语编写完的程序,运行后就是一个独立的进程,不需要在进程中再创建线程。而 Java 语言实现的是「单 Reactor 单线程」的方案,因为 Java 程序是跑在 Java 虚拟机这个进程上面的,虚拟机中有很多线程,我们写的 Java 程序只是其中的一个线程而已。以C语言进程为例,下面是它的方案示意图:

可以看到进程里有Reactor、Acceptor和Handler三个对象:

  • Reactor:监听和分发事件
  • Acceptor:获取连接
  • Handler:业务处理

上图中select、accept、read和send是系统调用函数,dispatch和业务处理是需要我们完成的逻辑处理,其中dispatch是事件分发操作

下面是这个方案的流程:

  • Reactor 对象通过 select (IO 多路复用接口) 监听事件,收到事件后通过 dispatch 进行分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型;
  • 如果是连接建立的事件,则交由 Acceptor 对象进行处理,Acceptor 对象会通过 accept 方法 获取连接,并创建一个 Handler 对象来处理后续的响应事件;
  • 如果不是连接建立事件, 则交由当前连接对应的 Handler 对象来进行响应;
  • Handler 对象通过 read -> 业务处理 -> send 的流程来完成完整的业务流程。

这个方案因为全部的工作都在同一个进程里面进行,不需要考虑进程间通信,所以实现起来较为简单,但是这个方案存在2个明显缺点:

  1. 因为只有一个进程,无法充分利用多核CPU的能力
  2. Handler 对象在业务处理时,整个进程是无法处理其他连接的事件的,如果业务处理耗时比较长,那么就造成响应的延迟;

所以,单 Reactor 单进程的方案不适用计算机密集型的场景,只适用于业务处理非常快速的场景。Redis 是由 C 语言实现的,它采用的正是「单 Reactor 单进程」的方案,因为 Redis 业务处理主要是在内存中完成,操作的速度是很快的,性能瓶颈不在 CPU 上,所以 Redis 对于命令的处理是单进程的方案。

单 Reactor 多线程/多进程

如果要克服「单 Reactor 单线程 / 进程」方案的缺点,那么就需要引入多线程 / 多进程,这样就产生了单 Reactor 多线程 / 多进程的方案。下图是它的方案示意图:

下面是这个方案的流程:

  • Reactor 对象通过 select (IO 多路复用接口) 监听事件,收到事件后通过 dispatch 进行分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型;
  • 如果是连接建立的事件,则交由 Acceptor 对象进行处理,Acceptor 对象会通过 accept 方法 获取连接,并创建一个 Handler 对象来处理后续的响应事件;
  • 如果不是连接建立事件, 则交由当前连接对应的 Handler 对象来进行响应;

上面这三个步骤和单Reactor单线程/单进程方案是一样的,下面就开始不一样了,

  • Handler 对象不再负责业务处理,只负责数据的接收和发送,Handler 对象通过 read 读取到数据后,会将数据发给子线程里的 Processor 对象进行业务处理;
  • 子线程里的 Processor 对象就进行业务处理,处理完后,将结果发给主线程中的 Handler 对象,接着由 Handler 通过 send 方法将响应结果发送给 client;

单 Reator 多线程的方案优势在于能够充分利用多核 CPU 的能,那既然引入多线程,那么自然就带来了多线程竞争资源的问题。例如,子线程完成业务处理后,要把结果传递给主线程的 Reactor 进行发送,这里涉及共享数据的竞争。要避免多线程由于竞争共享资源而导致数据错乱的问题,就需要在操作共享资源前加上互斥锁,以保证任意时间里只有一个线程在操作共享资源,待该线程操作完释放互斥锁后,其他线程才有机会操作共享数据。

再来看单 Reactor 多进程的方案:

事实上,单 Reactor 多进程相比单 Reactor 多线程实现起来很麻烦,主要因为要考虑子进程 <-> 父进程的双向通信,并且父进程还得知道子进程要将数据发送给哪个客户端。而多线程间可以共享数据,虽然要额外考虑并发问题,但是这远比进程间通信的复杂度低得多,因此实际应用中也看不到单 Reactor 多进程的模式。

另外,「单 Reactor」的模式还有个问题,因为一个 Reactor 对象承担所有事件的监听和响应,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能的瓶颈的地方。

主从 Reactor 多线程/多进程

直接来看方案示意图:

下面是此方案的流程:

  • 主线程中的 MainReactor 对象通过 select 监控连接建立事件,收到事件后通过 Acceptor 对象中的 accept 获取连接,将新的连接分配给某个子线程;
  • 子线程中的 SubReactor 对象将 MainReactor 对象分配的连接加入 select 继续进行监听,并创建一个 Handler 用于处理连接的响应事件;
  • 如果有新的事件发生时,SubReactor 对象会调用当前连接对应的 Handler 对象来进行响应;
  • Handler 对象通过 read -> 业务处理 -> send 的流程来完成完整的业务流程。

这种方案虽然看起来多了一步,但是实现起来却比单Reactor多线程/多进程要简单的多,原因如下:

  • 主线程和子线程分工明确,主线程只负责接收新连接,子线程负责完成后续的业务处理;
  • 主线程和子线程的交互很简单,主线程只需要把新连接传给子线程,子线程无须返回数据,直接就可以在子线程将处理结果发送给客户端。

其中Netty和Memcache采用的都是主从Reactor多线程的方案,Nginx采用的是主从Reactor多进程的方案。