全文参考《高性能服务器编程》
一、服务器模型
1.C/S模型
C/S模型的逻辑很简单,服务器先建立起一个(或者多个)监听socket,然后将这些socket绑定到不同的端口上(bind函数),最后调用listen来进行实际的监听客户端的连接。待服务器准备好之后,客户端就可以通过connect函数去连接服务器指定的端口获取服务,当服务器监听到客户端的连接后,就会用accept去接受它,并且返回一个新的socket文件描述符来进行数据交互。
由于客户端的连接请求是一种随机到达的异步事件,服务器需要去选择一种I/O模型来监听这一事件,这样才能保证在处理一个客户端请求的同时还能继续监听其他客户端的请求,否则服务器就变成了效率低下的串行服务器。
如下图,使用的是I/O服用技术之一的select系统调用。当监听到连接请求之后,服务器就调用accept函数接受它,并且分配一个逻辑单元为新的连接服务。这个逻辑单元可以是一个新的子进程或者子线程等。逻辑单元处理客户端的请求,并将处理的结果返回给客户端。
C/S模型非常适合资源相对集中的场合,并且他的实现也很简单,其缺点就是当访问量过大的时候,可能所有的客户都将得到很慢的响应。
2.P2P模型
P2P(Peer to Peer,点对点)模型相比C/S模型更适合网络通信的实际情况。它摒弃了以服务器为中心的格局,让网络上的所有主机重新回到对等的地位。
P2P使得每一台机器在消耗服务的同时,也向外提供服务,这样资源能够充分、自由的共享。云计算集群可以看做P2P模型的一个典范。但是P2P的缺点也很明显:当用户之间传输的请求过多时,网络的负载将会加重。而且主机之间有时很难互相发现。所以在实际的使用过程中,还需要有一个专门的发现服务器。这个服务器会提供查找服务,使得客户能够快速的找到自己所需资源的目标主机。
从编程的角度来看,每台主机既是服务器也是客户端,因此其网络编程思路并无很大差异。
二、服务器编程框架
虽然服务其程序种类繁多,但其中基本框架都一样,不同之处在于逻辑处理。
1.I/O处理单元
I/O处理单元是服务器管理客户连接的模块。它通常要完成以下工作∶ 等待并接受新的客户连接,接收客户数据,将服务器响应数据返回给客户端。但是,数据的收发不一定在I O处理单元中执行,也可能在逻辑单元中执行,具体在何处执行取决于事件处理模式。对于一个服务器机群来说,I/O处理单元是一个专门的接入服务器。它实现负载均衡,从所有逻辑服务器中选取负荷最小的一台来为新客户服务。
2.逻辑单元
一个逻辑单元通常是一个进程或线程。它分析并处理客户数据,然后将结果传递给I/O处理单元或者直接发送给客户端(具体使用哪种方式取决于事件处理模式)。对服务器机群而言,一个逻辑单元本身就是一台逻辑服务器。服务器通常拥有多个逻辑单元,以实现对多个客户任务的并行处理。
3.网络存储单元
网络存储单元可以是数据库、缓存和文件,甚至是一台独立的服务器。但它不是必须的,比如 ssh、telnet 等登录服务就不需要这个单元。
4.请求队列
请求队列是各单元之间的通信方式的抽象。VO处理单元接收到客户请求时,需要以某种方式通知一个逻辑单元来处理该请求。同样,多个逻辑单元同时访问一个存储单元时,也需要采用某种机制来协调处理竞态条件。请求队列通常被实现为池的一部分,我们将在后面讨论池的概念。对于服务器机群而言,请求队列是各台服务器之间预先建立的、静态的、永久的TCP连接。这种TCP连接能提高服务器之间交换数据的效率,因为它避免了动态建立 TCP 连接导致的额外的系统开销。
三、I/O模型
socket在创建的时候默认是阻塞的。我们可以给socket系统调用的第2个参数传递 SOCK_NONBLOCK标志,或者通过 fcntl 系统调用的F_SETFL命令,将其设置为非阻塞的。阻塞和非阻塞的概念能应用于所有文件描述符,而不仅仅是socket。我们称阻塞的文件描述符为阻塞 I/O,称非阻塞的文件描述符为非阻塞 I/O。
- 阻塞I/O:程序程序阻塞于读写函数
- I/O复用:程序阻塞于I/O复用系统调用,但可同时监听多个I/O事件。对I/O本身的读写操作是非阻塞的。
- SIGIO信号:信号触发读写就绪事件,用户程序执行读写操作,程序没有阻塞阶段。
- 异步I/O:内核执行读写操作并触发读写完成事件,程序没有阻塞阶段。
四、事件驱动模型
随着网络设计模式的兴起,Rcactor和Proactor 事件处理模式应运而生。同步 I/O 模型通常用于实现Reactor模式,异步I/O模型则用于实现Proactor模式。不过后面我们将看到,如何使用同步 I/O 方式模拟出 Proactor 模式。
1.Reactor模型
Reactor是这样一种模式,它要求主线程只负责监听文件描述符上是否有事件发生,有的话就立即将该事件通知到工作线程。
使用同步I/O模型(以epoll为例)实现Reactor模式的工作流程是:
- a.主线程往epoll内核事件表中注册socket上的读就绪事件。
- b.然后主线程调用epoll_wait等待socket上有数据可读。
- c.当socket上有数据可读时,epoll_wait会返回可读的文件描述符,然后主线程将其放入请求队列。
- d.睡眠在请求队列上的某个工作线程被唤醒,它接管主线程放入的socket文件描述符,读取并处理客户端请求,然后再往epoll内核事件中注册该socket上的写就绪事件。
- e.主线程调用epoll_wait等待socket可写。
- f.当epoll_wait返回时,主线程再将该可写socket放入请求队列。
- g.睡眠在请求队列上的工作线程被唤醒,他接管该socket并写入数据。
2.Proactor模型
与Reactor模式不同,Proactor模式将所有I/O操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑。
使用异步I/O模型(以 aio_read 和 aio_write为例)实现的Proactor模式的工作流程是∶
- a.主线程调用 aio_read 函数向内核注册 socket上的读完成事件,并告诉内核用户读缓冲区的位置,以及读操作完成时如何通知应用程序。
- b.主线程继续处理其他逻辑。
- c.当 socket上的数据被读入用户缓冲区后,内核将向应用程序发送一个信号,以通知应用程序数据已经可用。
- d.应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理完客户请求之后,调用 aio_write 函数向内核注册 socket上的写完成事件,并告诉内核用户写缓冲区的位置,以及写操作完成时如何通知应用程序。
- e.主线程继续处理其他逻辑。
- f.当用户缓冲区的数据被写人socket之后,内核将向应用程序发送一个信号,以通知应用程序数据已经发送完毕。
- g.应用程序预先定义好的信号处理函数选择一个工作线程来做善后处理,比如决定是否关闭socket。
3.模拟Proactor模型
主线程执行数据读写操作,读写完成之后,主线程向工作线程通知这一"完成事件"。那么从工作线程的角度来看,它们就直接获得了数据读写的结果,接下来要做的只是对读写的结果进行逻辑处理。
使用同步 I/O模型(仍然以epoll_wait 为例)模拟出的Proactor模式的工作流程如下∶
- a.主线程往 epoll内核事件表中注册 socket 上的读就绪事件。
- b.主线程调用epoll_wait 等待 socket 上有数据可读。
- c.当socket上有数据可读时,epoll_wait通知主线程。主线程从socket循环读取数据,直到没有更多数据可读,然后将读取到的数据封装成一个请求对象并插入请求队列。
- d.睡眠在请求队列上的某个工作线程被唤醒,它获得请求对象并处理客户请求,然后往 epoll 内核事件表中注册 socket 上的写就绪事件。
- e.主线程调用 epoll_wait 等待 socket 可写。
- f.当socket 可写时,epoll wait通知主线程。主线程往 socket上写入服务器处理客户请求的结果。
五、高效的并发模式
并发编程的目的是让程序"同时"执行多个任务。如果程序是计算密集型的,并发编程并没有优势,反而由于任务的切换使效率降低。但如果程序是I/O密集型的,比如经常读写文件,访问数据库等,则情况就不同了。由于I/O操作的速度远没有 CPU的计算速度快,所以让程序阻塞于I/O操作将浪费大量的 CPU时间。如果程序有多个执行线程,则当前被 I/O操作所阻塞的执行线程可主动放弃 CPU(或由操作系统来调度),并将执行权转移到其他线程。这样一来,CPU 就可以用来做更加有意义的事情(除非所有线程都同时被 I/O操作所阻塞),而不是等待 I/O 操作完成,因此 CPU 的利用率显著提升。
从实现上来说,并发编程主要有多进程和多线程两种方式,这一节先讨论并发模式。并发模式是指 I/O处理单元和多个逻辑单元之间协调完成任务的方法。服务器主要有两种并发编程模式∶半同步/半异步(half-sync/ half-async)模式和领导者/追随者(Leader/Followers)模式。
1.半同步/半异步模式
首先,半同步/半异步模式中的"同步"和"异步"与前面讨论的 I/O模型中的"同步"和"异步"是完全不同的概念。在I/O模型中,"同步"和"异步"区分的是内核向应用程序通知的是何种 I/O事件(是就绪事件还是完成事件),以及该由谁来完成 I/O读写(是应用程序还是内核)。在并发模式中,"同步"指的是程序完全按照代码序列的顺序执行∶"异步"指的是程序的执行需要由系统事件来驱动。常见的系统事件包括中断、信号等。比如,图a描述了同步的读操作,而图b则描述了异步的读操作。
按照同步方式运行的线程称为同步线程,按照异步方式运行的线程称为异步线程。显然,异步线程的执行效率高,实时性强,这是很多嵌入式程序采用的模型。但编写以异步方式执行的程序相对复杂,难于调试和扩展,而且不适合于大量的并发。而同步线程则相反,它虽然效率相对较低,实时性较差,但逻辑简单。因此,对于像服务器这种既要求较好的实时性,又要求能同时处理多个客户请求的应用程序,我们就应该同时使用同步线程和异步线程来实现,即采用半同步/ 半异步模式来实现。
半同步/半异步模式中,同步线程用于处理客户逻辑。异步线程用于处理I/O事件。异步线程监听到客户请求后,就将其封装成请求对象并插入请求队列中。请求队列将通知某个工作在同步模式的工作线程来读取并处理该请求对象。具体选择哪个工作线程来为新的客户请求服务,则取决于请求队列的设计。比如最简单的轮流选取工作线程的Round Robin 算法,也可以通过条件变量或信号量来随机地选择一个工作线程。下图总结了半同步/半异步模式的工作流程。
在服务器程序中,如果结合考虑两种事件处理模式和几种 I/O 模型,则半同步/半异步模式就存在多种变体。其中有一种变体称为半同步/半反应堆(half-sync/half-reactive)模式,如下图所示。
上图中,异步线程只有一个,由主线程来充当。它负责监听所有socket上的事件。如果监听 socket 上有可读事件发生,即有新的连接请求到来,主线程就接受之以得到新的连接 socket,然后往epoll内核事件表中注册该socket 上的读写事件。如果连接 socket上有读写事件发生,即有新的客户请求到来或有数据要发送至客户端,主线程就将该连接 socket插入请求队列中。所有工作线程都睡眠在请求队列上,当有任务到来时,它们将通过竞争(比如申请互斥锁)获得任务的接管权。这种竞争机制使得只有空闲的工作线程才有机会来处理新任务,这是很合理的。
主线程插入请求队列中的任务是就绪的连接 socket。这说明该图所示的半同步/半反应堆模式采用的事件处理模式是Rcactor模式∶它要求工作线程自己从socket上读取客户请求和往 socket写入服务器应答。这就是该模式的名称中"half-reactive"的含义。实际上,半同步/半反应堆模式也可以使用模拟的Proactor事件处理模式,即由主线程来完成数据的读写。在这种情况下,主线程一般会将应用程序数据、任务类型等信息封装为一个任务对象,然后将其(或者指向该任务对象的一个指针)插入请求队列。工作线程从请求队列中取得任务对象之后,即可直接处理之,而无须执行读写操作了。
半同步/ 半反应堆模式存在如下缺点∶
- 1.主线程和工作线程共享请求队列。主线程往请求队列中添加任务,或者工作线程从请求队列中取出任务,都需要对请求队列加锁保护,从而白白耗费 CPU时间。
- 2.每个工作线程在同一时间只能处理一个客户请求。如果客户数量较多,而工作线程较少,则请求队列中将堆积很多任务对象,客户端的响应速度将越来越慢。如果通过增加工作线程来解决这一问题,则工作线程的切换也将耗费大量 CPU 时间。
下图描述了一种相对高效的半同步/半异步模式,它的每个工作线程都能同时处理多个客户连接。
上图中,主线程只管理监听socket,连接socket由工作线程来管理。当有新的连接到来时,主线程就接受之并将新返回的连接 socket 派发给某个工作线程,此后该新 socket上的任何 I/O操作都由被选中的工作线程来处理,直到客户关闭连接。主线程向工作线程派发 socket 的最简单的方式,是往它和工作线程之间的管道里写数据。工作线程检测到管道上有数据可读时,就分析是否是一个新的客户连接请求到来。如果是,则把该新socket上的读写事件注册到自己的 epoll 内核事件表中。
可见,每个线程(主线程和工作线程)都维持自己的事件循环,它们各自独立地监听不同的事件。因此,在这种高效的半同步/半异步模式中,每个线程都工作在异步模式,所以它并非严格意义上的半同步/半异步模式。
2.领导者/追随者模式
领导者/追随者模式是多个工作线程轮流获得事件源集合,轮流监听、分发并处理事件的一种模式。在任意时间点,程序都仅有一个领导者线程,它负责监听I/O事件。而其他线程则都是追随者,它们休眠在线程池中等待成为新的领导者。当前的领导者如果检测到IO事件,首先要从线程池中推选出新的领导者线程,然后处理I/O事件。此时,新的领导者等待新的 I/O 事件,而原来的领导者则处理 I/O 事件,二者实现了并发。
领导者/追随者模式包含如下几个组件∶句柄集(HandleSet)、线程集(ThreadSet)、事件处理器(EventHandler)和具体的事件处理器(ConcreteEventHandler)。它们的关系如下图所示。
1. 句柄集
句柄(Handle)用于表示 I/O资源,在Linux下通常就是一个文件描述符。句柄集管理众多句柄,它使用wait_for_event方法来监听这些句柄上的LIO事件,并将其中的就绪事件通知给领导者线程。领导者则调用绑定到 Handle上的事件处理器来处理事件。领导者将 Handle 和事件处理器绑定是通过调用句柄集中的 register_handle方法实现的。
2. 线程集
这个组件是所有工作线程(包括领导者和追随者线程)的管理者。它负责各线程之间的同步,以及新领导者线程的推选。线程集中的线程在任一时间必处于如下三种状态之一∶
- Leader∶ 线程当前处于领导者身份,负责等待句柄集上的 I/O 事件。
- Processing∶线程正在处理事件。领导者检测到I/O事件之后,可以转移到Processing状态来处理该事件,并调用 promote_new_leader方法推选新的领导者;也可以指定其他追随者来处理事件(Event Handof),此时领导者的地位不变。当处于Processing状态的线程处理完事件之后,如果当前线程集中没有领导者,则它将成为新的领导者,否则它就直接转变为追随者。
- Follower∶线程当前处于追随者身份,通过调用线程集的join方法等待成为新的领导者,也可能被当前的领导者指定来处理新的任务。
下图显示了这三种状态之间的转换关系。
需要注意的是,领导者线程推选新的领导者和追随者等待成为新领导者这两个操作都将修改线程集,因此线程集提供一个成员 Synchronizer来同步这两个操作,以避免竞态条件。
3. 事件处理器和具体的事件处理器
事件处理器通常包含一个或多个回调函数 handle_event。这些回调函数用于处理事件对应的业务逻辑。事件处理器在使用前需要被绑定到某个句柄上,当该句柄上有事件发生时,领导者就执行与之绑定的事件处理器中的回调函数。具体的事件处理器是事件处理器的派生类。它们必须重新实现基类的 handle_event方法,以处理特定的任务。
根据上面的讨论,我们将领导者/追随者模式的工作流程总结于下图中。
由于领导者线程自己监听 I/O事件并处理客户请求,因而领导者/追随者模式不需要在线程之间传递任何额外的数据,也无须像半同步/半反应堆模式那样在线程之间同步对请求队列的访问。但领导者/追随者的一个明显缺点是仅支持一个事件源集合,因此也无法像上图所示的那样,让每个工作线程独立地管理多个客户连接。
六、如何进一步提高服务器性能
性能对服务器来说是至关重要的,毕竞每个客户都期望其请求能很快地得到响应。影响服务器性能的首要因素就是系统的硬件资源,比如 CPU的个数、速度,内存的大小等。不过由于硬件技术的飞速发展,现代服务器都不缺乏硬件资源。因此,我们需要考虑的主要问题是如何从"软环境"来提升服务器的性能。服务器的"软环境",一方面是指系统的软件资源,比如操作系统允许用户打开的最大文件描述符数量;另一方面指的就是服务器程序本身,即如何从编程的角度来确保服务器的性能,这是本节要讨论的问题。
前面我们介绍了几种高效的事件处理模式和并发模式,它们都有助于提高服务器的整体性能。后面文章中我们再进一步分析高性能服务器需要注意的其他几个方面∶池、数据复制、上下文切换和锁。