阅读 UNP 的时候了解到了多种客户端/服务器程序设计的范式,这些模式很容易理解。由于本书写于20多年前,目前大部分模式都已经过时了。不过现在流行的模式自然是在此基础上演化而来的,因此有必要了解一下这些模式。

所谓客户/服务器模式,就是我们常说的 C/S 模式,客户端和服务器基于网络进行通信,通常基于 TCP 协议。本文中总结了常见的客户端/服务器程序设计模式。

0. 迭代服务器

收到一个请求后就同步地处理它,在未处理完之前不能处理其他的请求。这就是最简单的迭代服务器,这种模式只适用于单个请求耗时很少的场景,比如下面的例子中,接收到请求后服务器返回当前的世界,而后立刻断开连接。

1. 为每个连接创建子进程

如果每个请求都需要消耗不少时间才能处理完,那么在处理一个请求的时候,其他客户的请求就被会阻塞。可以为每个请求创建一个进程,用该进程来处理该请求。为每个新的连接创建进程,连接完成后会销毁进程,这导致进程频繁地创建与销毁,存在不少系统开销。

2. 创建多个子进程,在子进程中 accept

在多个进程中进行 accept 操作,新来一个请求最终只会被单个进程成功 accept,这样就能够把多个请求分散到不同的进程中。

在主进程中创建多个进程,各个进程都执行 accept 操作,新的连接到来后,所有子进程在同一个 listen_fd 上执行 accept 操作的子进程均被唤醒,但只有最快运行的那个子进程能够 accept 成功,该子进程就负责处理此次连接。

缺点是单个连接会导致多个子进程被唤醒,如果子进程较多的时候,这种做法会导致性能受损。

3. 预先派生子进程,accept 使用锁保护

为了避免多个子进程同时阻塞在 accept 调用上,可以在 accept 前面那使用某种锁,让多个子进程阻塞在锁上,其中只有一个能够拿到锁,进而只有单个进程能够阻塞在 accept 调用上。

4. 每个客户一个线程

accept 到新的客户连接后,就创建一个线程,在此线程中处理用户请求。由于处理请求过程中往往涉及到读取文件、数据库,这类 IO 操作都相当耗时,当执行这些 IO 操作的时候,操作系统可以调度其他线程。这样可以让计算资源得到更高效的利用。

5. 预先创建线程

尽管创建和销毁线程的代价较进程的创建与销毁要少,但是毕竟存在消耗。另外如果请求量很大,那么就会创建大量的进程,可能会耗尽内存。因此,可以使用线程池。同样可以在各个线程上做 accept 也可以又主线程来 accept 而后交给子线程。

后者可以使用队列来实现。主线程 accept 之后,把文件描述符等信息放入队列,其他子线程从队列中取出文件描述符,并完成后续的服务。这是一个典型的生产者消费者的模型,在实现的时候使用一个互斥锁和条件变量即可轻松实现。

6. IO 多路复用

前面提到的方法中,任何时刻,进程和线程都是在为一个连接服务。如果该连接的处理需要做耗时的 IO 操作,则需要操作系统切换进程或者线程,以此保存不浪费系统资源。而基于 select、poll、epoll 这样的系统调用,可以订阅多个描述符上的事件,一个进程/线程可以同时处理多个连接。

这种模式下,当监听的文件描述符上有事件发生时,根据发生的事件类型和对应的文件描述符,可以决定该如何采取行动。但是采取行动往往是在当前线程中进行的,即在处理某事件的时候,其他事件不能得到处理。

因此,这种模式常常和多线程搭配使用,在工作线程来处理事件。