《Linux 高性能服务器编程》一书中,把这一章节作为全书的核心,同时作为后续章节的总览。这也意味着我们在经历了前置知识的学习后,正式进入了 Web 服务器项目的核心部分!
前置内容回顾:
1. C++ TinyWebServer项目总结(1. 配置安装)
2. C++ TinyWebServer项目总结(2. IP协议)
3. C++ TinyWebServer项目总结(3. TCP协议)TCP 三次握手、四次挥手,流量控制、拥塞控制等。
4. C++ TinyWebServer项目总结(4. 访问一个网页的全过程)
5. C++ TinyWebServer项目总结(5. Linux网络编程基础API)socket 的创建、绑定、监听、连接等。
6. C++ TinyWebServer项目总结(6. 高级 I/O 函数)文件描述符、管道、readv
/writev
函数等。
7. C++ TinyWebServer项目总结(7. Linux服务器程序规范)进程 PID,用户 UID,组 GID,会话 ID 等。
按照服务器程序的一般原理,服务器可以解构为三个主要模块:
- I/O处理单元:四种I/O模型,两种高效事件处理模式。
- 逻辑单元:两种高效并发模式;逻辑处理方式——有限状态机。
- 存储单元:服务器程序的可选模块,其内容与网络编程本身无关。
服务器模型
C/S 模型
TCP/IP协议在设计和实现上没有客户端和服务器的概念,在通信过程中所有机器都是对等的。但由于资源(视频、新闻、软件等)被数据提供者所垄断,所以几乎所有网络应用程序都采用了下图所示的C/S(客户端/服务器)模型:所有客户端都通过访问服务器来获取所需资源:
采用C/S模型的TCP服务器和客户端的工作流程:
C/S模型中,服务器启动后,首先创建一个或多个监听socket,并调用bind
将其绑定到服务器感兴趣的端口上,然后调用listen
等待客户连接,之后客户端就可以调用connect
向服务器发起连接了。由于客户连接请求是随机到达的异步事件,服务器需要使用某种I/O模型来监听这一事件。
上图中,服务器使用的是I/O复用技术(select
系统调用),当监听到连接请求后,服务器就调用accept
函数接受它,并分配一个逻辑单元来服务新的连接,逻辑单元可以是新创建的子进程、子线程或其他,上图中,服务器给客户端分配的逻辑单元是由fork
系统调用创建的子进程。逻辑单元读取客户请求,处理该请求,然后将处理结果返回给客户端,客户端接收到服务器反馈的结果后,可以继续向服务器发送请求,也可以立即主动关闭连接。如果客户端主动关闭连接,则服务器执行被动关闭连接。至此,双方的通信结束。服务器在处理一个客户请求的同时还会继续监听其他客户请求,否则就变成了效率低下的串行服务器。
C/S模型适合资源相对集中的场合,且它的实现也很简单,但其缺点也很明显:服务器是通信的中心,当访问量过大时,可能所有客户都将得到很慢的响应。P2P模型解决了这个缺点。
P2P 模型
P2P(Peer to Peer,点对点)模型比C/S模型更符合网络通信的实际情况。
- 所有主机地位对等,每台机器在消耗服务的同时,也给其他机器提供服务。
- 资源能够更充分、自由地共享。
- 缺点:当用户之间的传输请求过多时,网络的负载将加重。
上图 a) 所示的 P2P 模型存在一个问题:主机之间很难相互发现。所以P2P模型通常有一个发现服务器,如 b) 所示,专门用来提供查找服务(甚至可以提供内容服务),使得客户能够很快找到需要的资源。
从编程角度讲,P2P模型可看作C/S模型的扩展:每台主机既是客户端,又是服务器。
服务器编程框架
上图既能用来描述一台服务器,也能用来描述一个服务器机群,两种情况下各个部件的含义和功能见下表:
对于单个服务器程序来说:
- I/O处理单元是服务器管理客户连接的模块。它通常要完成以下工作:等待并接受新的客户连接,接收客户数据,将服务器响应数据返回给客户端。但是,数据的收发不一定在 I/O处理单元中执行,也可能在逻辑单元中执行,具体在何处执行取决于事件处理模式(见后文)。
- 一个逻辑单元通常是一个进程或线程。它分析并处理客户数据,然后将结果传递给I/O处理单元或者直接发送给客户端(具体使用哪种方式取决于事件处理模式)。服务器通常拥有多个逻辑单元,以实现对多个客户任务的并行处理。
- 网络存储单元可以是数据库、缓存和文件,甚至是一台独立的服务器。但它不是必须的,比如ssh、telnet等登录服务就不需要这个单元。
- 请求队列是各单元之间的通信方式的抽象。I/O处理单元接收到客户请求时,需要以某种方式通知一个逻辑单元来处理该请求。同样,多个逻辑单元同时访问一个存储单元时,也需要采用某种机制来协调处理竞态条件。请求队列通常被实现为池的一部分。
对于一个服务器机群来说:
- I/O处理单元是一个专门的接入服务器。它实现负载均衡,从所有逻辑服务器中选取负荷最小的一台来为新客户服务。
- 一个逻辑单元本身就是一台逻辑服务器。
- 对于服务器机群而言,请求队列是各台服务器之间预先建立的、静态的、永久的TCP连接。这种TCP连接能提高服务器之间交换数据的效率,因为它避免了动态建立TCP连接导致的额外的系统开销。
I/O 模型
5. Linux网络编程基础API 提到,sockct在创建的时候默认是阻塞的。阻塞和非阻塞的概念能应用于所有文件描述符,而不仅仅是socket。我们称阻塞的文件描述符为阻塞 I/O,称非阻塞的文件描述符为非阻塞 I/O。
针对阻塞 I/O执行的系统调用可能因为无法立即完成而被操作系统挂起,直到等待的事件发生为止。比如,客户端通过connect
向服务器发起连接时,connect
将首先发送同步报文段给服务器,然后等待服务器返回确认报文段。如果服务器的确认报文段没有立即到达客户端,则 connect
调用将被挂起,直到客户端收到确认报文段并唤醒connect
调用。socket的基础API中,可能被阻塞的系统调用包括accept
、send
、recv
和connect
。
针对非阻塞I/O执行的系统调用则总是立即返回,而不管事件是否已经发生。如果事件没有立即发生,这些系统调用就返回-1
,和出错的情况一样。此时我们必须根据errno
来区分这两种情况。对accept
、send
和recv
而言,事件未发生时 errno
通常被设置成EAGAIN
(意为“再来一次”)或者 EWOULDBLOCK
(意为“期望阻塞”);对connect
而言,errno
则被设置成EINPROGRESS
(意为“在处理中”)。
非阻塞 I/O 通常要和其他 I/O 通知机制一起使用,比如 I/O 复用和 SIGIO 信号。
I/O 复用是最常使用的I/O 通知机制。它指的是,应用程序通过I/O复用函数向内核注册一组事件,内核通过 I/O 复用函数把其中就绪的事件通知给应用程序。Linux上常用的I/O复用函数是 select
、poll
和 epoll_wait
。I/O 复用函数本身是阻塞的,它们能提高程序效率的原因在于它们具有同时监听多个 I/O事件的能力。
SIGIO信号也可以用来报告I/O事件。例如,我们可以为一个目标文件描述符指定宿主进程,那么被指定的宿主进程将捕获到SIGIO信号。这样,当目标文件描述符上有事件发生时,SIGIO信号的信号处理函数将被触发,我们也就可以在该信号处理函数中对目标文件描述符执行非阻塞 I/O操作了。
从理论上说,阻塞 I/O、I/O复用和信号驱动I/O都是同步I/O模型。因为在这三种I/O模型中,I/O的读写操作,都是在I/O事件发生之后,由应用程序来完成的。而 POSIX 规范所定义的异步I/O模型则不同。对异步I/O而言,用户可以直接对 I/O 执行读写操作,这些操作告诉内核用户读写缓冲区的位置,以及 I/O 操作完成之后内核通知应用程序的方式。异步I/O的读写操作总是立即返回,而不论 I/O 是否是阻塞的,因为真正的读写操作已经由内核接管。
也就是说,同步 I/O 模型要求用户代码自行执行 I/O 操作(将数据从内核缓冲区读人用户缓冲区,或将数据从用户缓冲区写人内核缓冲区),而异步 I/O 机制则由内核来执行 I/O 操作(数据在内核缓冲区和用户缓冲区之间的移动是由内核在“后台”完成的)。
同步 I/O 向应用程序通知的是 I/O 就绪事件,而异步 I/O 向应用程序通知的是 I/O 完成事件。Linux环境下,aio.h
头文件中定义的函数提供了对异步 I/O 的支持。
两种事件处理模式
服务器程序通常需要处理三类事件:I/O事件、信号及定时事件。这一节先从整体上介绍一下两种高效的事件处理模式:Reactor和Proactor.
同步 I/O 模型通常用于实现 Reactor 模式,异步 I/O 模型则用于实现 Proactor 模式。不过后面我们将看到,如何使用同步 I/O 方式模拟出 Proactor 模式。
Reactor 模式
Reactor 要求主线程( I/O 处理单元,下同)只负责监听文件描述上是否有事件发生,有的话就立即将该事件通知工作线程(逻辑单元,下同)。除此之外,主线程不做任何其他实质性的工作。读写数据,接受新的连接,以及处理客户请求均在工作线程中完成。
使用同步 I/O 模型(以 epoll_wait 为例)实现的 Reactor 模式的工作流程是:
- 主线程往 epoll 内核事件表中注册 socket 上的读就绪事件。
- 主线程调用 epoll_wait 等待 socket 上有数据可读。
- 当socket上有数据可读时, epoll_wait 通知主线程。主线程则将socket 可读事件放入请求队列。
- 睡眠在请求队列上的某个工作线程被唤醒,它从socket读取数据,并处理客户请求然后往 epoll 内核事件表中注册该socket上的写就绪事件。
- 主线程调用 epoll_wait 等待 socket 可写。
- 当socket可写时, epoll_wait 通知主线程。主线程将socket可写事件放入请求队列。
- 睡眠在请求队列上的某个工作线程被唤醒,它往socket上写入服务器处理客户请求的结果。
Proactor 模式
与Reactor模式不同,Proactor模式将所有I/O操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑。因此,Proactor模式更符合图8-4所描述的服务器编程框架。使用异步 I/O 模型(以 aio_read
和 aio_write
为例)实现的 Proactor 模式的工作流程是:
- 主线程调用
aio_read
函数向内核注册socket上的读完成事件,并告诉内核用户读缓冲区的位置,以及读操作完成时如何通知应用程序(这里以信号为例)。 - 主线程继续处理其他逻辑。
- 当socket上的数据被读入用户缓冲区后,内核将向应用程序发送一个信号,以通知应用程序数据已经可用。
- 应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理完客户请求之后,调用
aio_write
函数向内核注册socket上的写完成事件,并告诉内核用户写缓冲区的位置,以及写操作完成时如何通知应用程序(仍然以信号为例)。 - 主线程继续处理其他逻辑。
- 当用户缓冲区的数据被写入 socket之后,内核将向应用程序发送一个信号,以通知应用程序数据已经发送完毕。
- 应用程序预先定义好的信号处理函数选择一个工作线程来做善后处理,比如决定是否关闭 socket。
在图8-6中,连接socket上的读写事件是通过 aio_read
/aio_write
向内核注册的,因此内核将通过信号来向应用程序报告连接socket上的读写事件。所以,主线程中的 epoll_wait
调用仅能用来检测监听socket上的连接请求事件,而不能用来检测连接 socket 上的读写事件。
模拟 Proactor 模式
提到了使用同步 I/O 方式模拟出 Proactor 模式的一种方法。其原理是:主线程执行数据读写操作,读写完成之后,主线程向工作线程通知这一“完成事件”。那么从工作线程的角度来看,它们就直接获得了数据读写的结果,接下来要做的只是对读写的结果进行逻辑处理。
使用同步 I/O 模型(仍然以epoll_wait
为例)模拟出的 Proactor 模式的工作流程如下:
- 主线程往epoll 内核事件表中注册socket上的读就绪事件。
- 主线程调用
epoll_wait
等待socket上有数据可读。 - 当socket上有数据可读时,
epoll_wait
通知主线程。主线程从socket循环读取数据,直到没有更多数据可读,然后将读取到的数据封装成一个请求对象并插入请求队列。 - 睡眠在请求队列上的某个工作线程被唤醒,它获得请求对象并处理客户请求,然后往epoll 内核事件表中注册socket上的写就绪事件。
- 主线程调用
epoll_wait
等待 socket可写。 - 当socket可写时,
epoll_wait
通知主线程。主线程往socket上写人服务器处理客户请求的结果。
Reactor 模式和 Proactor 模式的区别
在计算机网络编程中,Reactor 模式和 Proactor 模式是两种常用的事件处理模式,它们都旨在有效地处理I/O操作,尤其是在高性能网络服务器的设计中。
Reactor模式
- 基本原理:Reactor模式基于同步I/O(Synchronous I/O)。在这种模式下,应用程序首先注册感兴趣的事件(如读、写、连接、接受等)到一个中心调度器(通常称为reactor或事件循环),然后同步等待事件的发生。一旦事件发生,调度器通知相应的事件处理器进行处理。
- 事件处理:事件处理器(handlers)是事先定义好的,它们在事件发生时被同步调用。处理器需要快速完成工作,以避免阻塞整个事件循环。
- 主要用途:这种模式适用于处理大量小的、短时的请求,例如Web服务器中处理HTTP请求。它能高效地处理并发,因为不需要为每个请求创建新的线程或进程。
Proactor模式
- 基本原理:Proactor模式基于异步I/O(Asynchronous I/O)。在这种模式下,应用程序启动一个异步操作(如异步读或写),并立即返回,继续执行其他任务。当I/O操作完成时,系统通过所谓的完成处理器(completion handler)通知应用程序。
- 事件处理:完成处理器在I/O操作完成后被异步调用,这允许应用程序在等待I/O完成时继续处理其他任务。这种方式非常适合于长时间运行的I/O操作,如数据库操作或文件传输。
- 主要用途:Proactor模式更适合于需要处理复杂I/O操作和长时间运行任务的应用程序,它可以提高程序的响应性和吞吐量。
关键区别
- I/O模型:Reactor使用同步I/O,而Proactor使用异步I/O。
- 阻塞方式:在Reactor模式中,调度器等待事件的发生可能会阻塞,而在Proactor模式中,应用程序启动异步操作后可以继续执行,不会阻塞。
- 性能和适用性:Reactor适合处理大量短暂的请求,Proactor适合处理持久的、耗时的I/O操作。
两种高效的并发模式
对于计算密集型的程序,并发编程并没有优势,反而由于任务的切换使效率降低。但对于 I/O密集型的程序,比如经常读写文件,访问数据库等。由于I/O操作的速度远没有CPU的计算速度快,所以让程序阻塞于 I/O操作将浪费大量的CPU时间。
从实现上来说,并发编程主要有多进程和多线程两种方式。
这一节先讨论并发模式。对应于图8-4,并发模式是指I/O处理单元和多个逻辑单元之间协调完成任务的方法。
服务器主要有两种并发编程模式:半同步/半异步(half-sync/half-async)模式和领导者/追随者(Leader/Followers)模式。
半同步 / 半异步模式
首先需要明确一点,半同步/半异步模式中的“同步”和“异步”与前面讨论的I/O模型中的“同步”和“异步”是完全不同的概念。
在 I/O模型中,“同步”和“异步”区分的是内核向应用程序通知的是何种IO事件(是就绪事件还是完成事件),以及该由谁来完成I/O读写(是应用程序还是内核)。
在并发模式中,“同步”指的是程序完全按照代码序列的顺序执行;“异步”指的是程序的执行需要由系统事件来驱动。常见的系统事件包括中断、信号等。比如,图8-8 a)描述了同步的读操作,而图8-8 b)则描述了异步的读操作。
按照同步方式运行的线程称为同步线程,按照异步方式运行的线程称为异步线程。
异步线程的执行效率高,实时性强。但程序相对复杂,难于调试和扩展,而且不适合于大量的并发。
同步线程则相反,虽然效率相对较低,实时性较差,但逻辑简单。
因此,对于像服务器这种既要求较好的实时性,又要求能同时处理多个客户请求的应用程序,我们就应该同时使用同步线程和异步线程来实现,即采用半同步/半异步模式来实现。
半同步/半异步模式中,同步线程用于处理客户逻辑,相当于图8-4中的逻辑单元;异步线程用于处理I/O事件,相当于图8-4中的I/O处理单元。异步线程监听到客户请求后,来读取并处理该请求对象。具体选择哪个工作线程来为新的客户请求服务,则取决于请求队列的设计。比如最简单的轮流选取工作线程的Round Robin算法,也可以通过条件变量或信号量来随机地选择一个工作线程。
在服务器程序中,如果结合考虑两种事件处理模式和几种 I/O 模型,则半同步/半异步模式就存在多种变体。其中有一种变体称为半同步/半反应堆(half-sync/half-reactive)模式,如图8-10所示。
图8-10中,异步线程只有一个,由主线程来充当。它负责监听所有socket上的事件。如果监听 socket 上有可读事件发生,即有新的连接请求到来,主线程就接受之以得到新的连接socket,然后往epoll 内核事件表中注册该socket上的读写事件。如果连接socket上有读写事件发生,即有新的客户请求到来或有数据要发送至客户端,主线程就将该连接socket插入请求队列中。所有工作线程都睡眠在请求队列上,当有任务到来时,它们将通过竞争(比如申请互斥锁)获得任务的接管权。这种竞争机制使得只有空闲的工作线程才有机会来处理新任务,这是很合理的。
图8-10中,主线程插入请求队列中的任务是就绪的连接socket。这说明该图所示的半同步/半反应堆模式采用的事件处理模式是Reactor模式:它要求工作线程自己从socket上读取客户请求和往 socket写入服务器应答。这就是该模式的名称中“half-reactive”的含义。实际上,半同步/半反应堆模式也可以使用模拟的Proactor事件处理模式,即由主线程来完成数据的读写。在这种情况下,主线程一般会将应用程序数据、任务类型等信息封装为一个任务对象,然后将其(或者指向该任务对象的一个指针)插入请求队列。工作线程从请求队列中取得任务对象之后,即可直接处理之,而无须执行读写操作了。
半同步/半反应堆模式存在如下缺点:
- 主线程和工作线程共享请求队列。主线程往请求队列中添加任务,或者工作线程从请求队列中取出任务,都需要对请求队列加锁保护,从而白白耗费CPU时间。
- 每个工作线程在同一时间只能处理一个客户请求。如果客户数量较多,而工作线程较少,则请求队列中将堆积很多任务对象,客户端的响应速度将越来越慢。如果通过增加工作线程来解决这一问题,则工作线程的切换也将耗费大量 CPU 时间。
图 8-11描述了一种相对高效的半同步/半异步模式,它的每个工作线程都能同时处理多个客户连接。
图8-11中,主线程只管理监听socket,连接socket由工作线程来管理。当有新的连接到来时,主线程就接受之并将新返回的连接socket派发给某个工作线程,此后该新socket 上的任何 I/O操作都由被选中的工作线程来处理,直到客户关闭连接。主线程向工作线程派发socket的最简单的方式,是往它和工作线程之间的管道里写数据。工作线程检测到管道上有数据可读时,就分析是否是一个新的客户连接请求到来。如果是,则把该新socket上的读写事件注册到自己的 epoll内核事件表中。
可见,图8-11中,每个线程(主线程和工作线程)都维持自己的事件循环,它们各自独立地监听不同的事件。因此,在这种高效的半同步/半异步模式中,每个线程都工作在异步模式,所以它并非严格意义上的半同步/半异步模式。
领导者 / 追随者模式
领导者/追随者模式是多个工作线程轮流获得事件源集合,轮流监听、分发并处理事件的一种模式。在任意时间点,程序都仅有一个领导者线程,它负责监听 I/O 事件。而其他线程则都是追随者,它们休眠在线程池中等待成为新的领导者。当前的领导者如果检测到I/O 事件,首先要从线程池中推选出新的领导者线程,然后处理I/O事件。此时,新的领导者等待新的 I/O事件,而原来的领导者则处理 I/O事件,二者实现了并发。
领导者/追随者模式包含如下几个组件:句柄集(HandleSet)、线程集(ThreadSet)、事件处理器(EventHandler)和具体的事件处理器(ConcreteEventHandler)。
1.句柄集
句柄(Handle)用于表示I/O资源,在Linux下通常就是一个文件描述符。句柄集管理众多句柄,它使用wait_for_event
方法来监听这些句柄上的I/O事件,并将其中的就绪事件通知给领导者线程。领导者则调用绑定到Handle上的事件处理器来处理事件。
2.线程集
这个组件是所有工作线程(包括领导者线程和追随者线程)的管理者。它负责各线程之间的同步,以及新领导者线程的推选。线程集中的线程在任一时间必处于如下三种状态之一:
- Leader:线程当前处于领导者身份,负责等待句柄集上的 I/O事件。
- Processing:线程正在处理事件。领导者检测到I/O 事件之后,可以转移到Processing状态来处理该事件,并调用
promote_new_leader
方法推选新的领导者;也可以指定其他追随者来处理事件(Event Hando),此时领导者的地位不变。当处于Processing状态的线程处理完事件之后,如果当前线程集中没有领导者,则它将成为新的领导者,否则它就直接转变为追随者。 - Follower:线程当前处于追随者身份,通过调用线程集的
join
方法等待成为新的领导者,也可能被当前的领导者指定来处理新的任务。
需要注意的是,领导者线程推选新的领导者和追随者等待成为新领导者这两个操作都将修改线程集,因此线程集提供一个成员Synchronizcr
来同步这两个操作,以避免竞态条件。
3.事件处理器和具体的事件处理器
事件处理器通常包含一个或多个回调函数handle_event
。这些回调函数用于处理事件对应的业务逻辑。事件处理器在使用前需要被绑定到某个句柄上,当该句柄上有事件发生时领导者就执行与之绑定的事件处理器中的回调函数。具体的事件处理器是事件处理器的派生类。它们必须重新实现基类的handle_event
方法,以处理特定的任务。
由于领导者线程自己监听I/O事件并处理客户请求,因而领导者/追随者式不需要在线程之间传递任何额外的数据,也无须像半同步/半反应堆模式那样在线程之间同步对请求队列的访问。但领导者/追随者的一个明显缺点是仅支持一个事件源集合,因此也无法像图8-11所示的那样,让每个工作线程独立地管理多个客户连接。
有限状态机
前面两节探讨的是服务器的I/O处理单元、请求队列和逻辑单元之间协调完成任务的各种模式,这一节我们介绍逻辑单元内部的一种高效编程方法:有限状态机(fnite state machine)。
有的应用层协议头部包含数据包类型字段,每种类型可以映射为逻辑单元的一种执行状态,服务器可以根据它来编写相应的处理逻辑。
STATE_MACHINE() {
State cur_State = type_A;
while (cur_State != type_C) {
Package _pack = getNewPackage();
switch (cur_State) {
case type_A:
process_package_state_A(_pack);
cur_State = type_B;
break;
case type_B:
process_package_state_B(_pack);
cur_State = type_C;
break;
}
}
}
提高服务器性能的其他建议
影响服务器性能的首要因素就是系统的硬件资源,比如CPU的个数、速度,内存的大小等。不过由于硬件技术的飞速发展,现代服务器都不缺乏硬件资源。因此,我们需要考虑的主要问题是如何从“软环境”来提升服务器的性能。服务器的“软环境”,一方面是指系统的软件资源,比如操作系统允许用户打开的最大文件描述符数量;另一方面指的就是服务器程序本身,即如何从编程的角度来确保服务器的性能,这是本节要讨论的问题。前面我们介绍了几种高效的事件处理模式和并发模式,以及高效的逻辑处理方式——有限状态机,它们都有助于提高服务器的整体性能。下面我们进一步分析高性能服务器需要注意的其他几个方面:池、数据复制、上下文切换和锁。
池
以空间换时间的方法。池(pool)是一组资源的集合,这组资源在服务器启动之初就被完全创建好并初始化,这称为静态资源分配。当服务器进入正式运行阶段,即开始处理客户请求的时候,如果它需要相关的资源,就可以直接从池中获取,无须动态分配。直接从池中取得所需资源比动态分配资源的速度要快得多,因为分配系统资源的系统调用都是很耗时的。当服务器处理完一个客户连接后,可以把相关的资源放回池中,无须执行系统调用来释放资源。从最终的效果来看,池相当于服务器管理系统资源的应用层设施,它避免了服务器对内核的频繁访问。
不过,既然池中的资源是预先静态分配的,我们就无法预期应该分配多少资源。最简单的解决方案就是分配“足够多”的资源,即针对每个可能的客户连接都分配必要的资源。这通常会导致资源的浪费,因为任一时刻的客户数量都可能远远没有达到服务器能支持的最大客户数量。还有一种解决方案是预先分配一定的资源,此后如果发现资源不够用,就再动态分配一些并加入池中。根据不同的资源类型,池可分为多种,常见的有内存池、进程池、线程池和连接池。
- 内存池通常用于socket的接收缓存和发送缓存。对于某些长度有限的客户请求,比如HTTP请求,预先分配一个大小足够(比如5000字节)的接收缓存区是很合理的。当客户请求的长度超过接收缓冲区的大小时,我们可以选择丢弃请求或者动态扩大接收缓冲区。
- 进程池和线程池都是并发编程常用的“伎俩”。当我们需要一个工作进程或工作线程来处理新到来的客户请求时,我们可以直接从进程池或线程池中取得一个执行实体,而无须动态地调用
fork
或pthread_create
等函数来创建进程和线程。 - 连接池通常用于服务器或服务器机群的内部永久连接。图8-4中,每个逻辑单元可能都需要频繁地访问本地的某个数据库。简单的做法是:逻辑单元每次需要访问数据库的时候,就向数据库程序发起连接,而访问完毕后释放连接。这种做法的效率太低。一种解决方案是使用连接池。连接池是服务器预先和数据库程序建立的一组连接的集合。当某个逻辑单元需要访问数据库时,它可以直接从连接池中取得一个连接的实体并使用之。待完成数据库的访问之后,逻辑单元再将该连接返还给连接池。
数据复制
高性能服务器应该避免不必要的数据复制,尤其是当数据复制发生在用户代码和内核之间的时候。如果内核可以直接处理从socket或者文件读入的数据,则应用程序就没必要将这些数据从内核缓冲区复制到应用程序缓冲区中。
比如 ftp 服务器,当客户请求一个文件时,服务器只需要检测目标文件是否存在,以及客户是否有读取它的权限,而绝对不会关心文件的具体内容。这样的话,ftp 服务器就无须把目标文件的内容完整地读入到应用程序缓冲区中并调用send
函数来发送,而是可以使用“零拷贝”函数 sendfile
直接将其发送给客户端。
此外,用户代码内部(不访问内核)的数据复制也是应该避免的。举例来说,当两个工作进程之间要传递大量的数据时,我们就应该考虑使用共享内存来在它们之间直接共享这些数据,而不是使用管道或者消息队列来传递。
上下文切换和锁
并发程序必须考虑上下文切换(context switch)的问题,即进程切换或线程切换导致的的系统开销。即使是 I/O 密集型的服务器,也不应该使用过多的工作线程(或工作进程,下同),否则线程间的切换将占用大量的CPU时间,服务器真正用于处理业务逻辑的CPU时间的比重就显得不足了。因此,为每个客户连接都创建一个工作线程的服务器模型是不可取的。图8-11所描述的半同步/半异步模式是一种比较合理的解决方案,它允许一个线程同时处理多个客户连接。此外,多线程服务器的一个优点是不同的线程可以同时运行在不同的 CPU 上。当线程的数量不大于CPU的数目时,上下文的切换就不是问题了。
并发程序需要考虑的另外一个问题是共享资源的加锁保护。锁通常被认为是导致服务器效率低下的一个因素,因为由它引入的代码不仅不处理任何业务逻辑,而且需要访问内核资源。因此,服务器如果有更好的解决方案,就应该避免使用锁。如果服务器必须使用“锁”,则可以考虑减小锁的粒度,比如使用读写锁。当所有工作线程都只读取一块共享内存的内容时,读写锁并不会增加系统的额外开销。只有当其中某一个工作线程需要写这块内存时,系统才必须去锁住这块区域。