服务器解构为三个主要模块:
- IO处理单元。四种IO模型和两种高效事件处理模式。
- 逻辑单元。两种高效并发模式。
- 存储单元。(暂不讨论)
1.服务器模型
(1)C/S (客户端/服务器)模型
C/S模型的逻辑很简单。服务器启动后,首先创建一个或者多个监听socket,并调用bind函数将其绑定到服务器感兴趣的端口上,然后调用listen函数等待客户连接。服务器稳定运行后,客户端就可以调用Connect函数向服务器发起连接了。由于客户连接请求时随机到达的异步事件,服务器需要使用某种I/O模型来监听到连接请求后,服务器就调用accept函数接受它了,并且分配一个逻辑单元为新的连接服务。
C/S模型非常适合资源相对集中的场合,并且它的实现也很简单,但其缺点也很明显:服务器是通信中心,当访问量过大时,可能所有客户都将得到很慢的响应。P2P可以解决这个问题。
(2)P2P模型
P2P模型比C/S模型更符合网络通信的实际情况。它摒弃了以服务器为中心的格局,让网络上所有主机重新回归对等的地位。
P2P模型使得每台机器在消耗服务的同时也给比人提供服务,这杨的资源能够充分。自由的共享。云计算而已看做P2P模型的一个典范。P2P缺点:当用户之间传输的请求过多时,网络的负载将加重。
P2P的另外一个问题是,主机之间很难互相发现。所以实际使用的P2P模型通常带有一个专门的发现服务器,这个发现服务器通常还提供查找服务,甚至提供内容服务器,使每个客户能尽快找到自己需要的资源。
从编程的角度看,P2P模型可以看做C/S模型的扩展:每台主机既是客户端,又是服务器。
2.服务器编程框架
模块 | 单个服务器程序 | 服务器集群 |
---|---|---|
I/O处理模块 | 处理客户端连接,读写网络数据 | 作为接入服务器,实现负载均衡 |
逻辑单元 | 业务进程或者线程 | 逻辑服务器 |
网络存储单元 | 本地数据库,文件或者缓存 | 数据库服务器 |
请求队列 | 各个单元之间的通信方式 | 各个服务器之间的永久TCP连接。 |
2. I/O模型:
- 阻塞IO
- 非阻塞IO
- IO复用//程序阻塞于IO复用系统调用,但可同时监听多个IO事件。
- SIGIO信号//信号触发读写就绪事件,用户程序执行读写操作,程序没有阻塞阶段
- 异步IO//内核执行读写操作并触发读写完成事件。程序没有阻塞阶段
3.两种高效的事件处理模式
两种高效的事件处理模式:同步IO模型通常用于实现Reactor模式,异步IO模型用于实现Proactor模式。
服务器通常要处理三类事件:IO事件、信号事件、定时事件。这里整体介绍一下两种高效的事件处理模型:Reactor和Proactor模型。同步I/O通常用于实现Reactor模式,异步I/O模型则用于实现Proactor。不过也可以用同步I/O模拟Proactor。
1、Reactor模式
Reactor是这样一种模式,它要求主线程只负责监听文件描述上是否由事件发生,有的话就立即将该时间通知工作线程,除此之外,主线程不做任何其他实质的工作。
读写数据,接受新的连接,以及处理客户请求均在工作线程中完成。
使用同步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上写入服务器处理客户请求的结果。
工作流程图如下所示:
工作线程从请求队列中取出事件后,将根据时间的类型类决定如何处理它:对于可读事件,执行读数据和处理请求的操作,对于可写事件,执行写数据的操作,因此没有区分所谓的“读工作线程”和“写工作线程”。
2.Proactor模式
Proactor模式将所有I/O操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑。因此,Proactor模式更符合服务器框架。
使用异步I/O模型(以aio_read和aio_write为例)实现的Proactor模式的工作流程如下:
1)主线程调用aio_read函数向内核注册socket上的读完成时间,并告诉内核用户读缓冲区的位置,以及读操作完成时如何通知应用程序。
2)主线程继续处理其他逻辑。
3)当socket上的数据被读入用户缓冲区后,内核将向应用程序发送一个信号,以通知应用程序数据已经可用。
4)应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理客户请求之后,调用aio_write函数向内核注册socket上的写完成时间,并告诉内核用户写缓冲区的位置,以及写操作完成时如何通知应用程序。
5)主线程继续处理其他逻辑。
6)当用户缓冲区的数据被写入socket之后,内核将向应用程序发送一个信号,以通知应用程序数据已经发送完毕。
7)应用程序预先定义好的信号处理函数选择一个工作线程来做善后处理,比如决定是否关闭socket。
Proactor模式的工作流程如下所示:
上图,连接socket上的读写事件是通过aio_read/aio_write向内核注册的,因此内核将通过信号来想应用程序报告连接socket上的读写事件。所以,主线程中的epoll_wait调用仅能用来检测监听socket上的连接请求事件,而不能用来检测连接socket上的读写事件。
亦可以采用同步I/O模型模拟出的Proactor模式的工作流程。
5、两种高效的并发模式
并发编程等待目的是让程序“同时”执行多个任何。如果程序是计算密集型的,并发程序并没有优势,反而由于任何的切换是的效率降低。但如果程序是I/O密集型的,比如经常读写文件,访问数据库等,则情况就不同了。由于I/O操作的速度远远没有CPU计算速度快,所以让程序阻塞与I/O操作将浪费大量的CPU时间。如果程序有多个执行线程,则当前被I/O操作所阻塞的执行线程可主动放弃CPU(或者由操作系统来调度),并执行权转移到其他线程。这样一来,CPU就可以用来做更加有意义的事情,而不是等待I/O操作完成,因此CPU的利用率显著提升。
从实现上来说,并发编程主要有多进程和多线程两种方式,并发模式是值I/O处理单元和多个逻辑单元之间协调完成任何的方式。服务器主要有两种并发编程模式:半同步/半异步模式,领导者/追随者模式。
一、半同步/半异步模式
在I/O模型中,“同步”和“异步”区分的是内核向应用程序通知的是何种I/O事件(是就绪时间还是完成事件),以及该由谁来完成I/O读写(是应用程序还是内核。)。并发模式中,“同步”指的是程序完全按照代码序列的顺序执行:“异步”指的是程序的执行需要由系统事件驱动。常见的系统时间包括中断、信号等。
半同步/半异步模式中,同步线程用于处理客户逻辑。异步线程用于处理I/O事件,异步线程监听客户请求后,就将其封装成请求对象并插入请求队列中。请求队列将通知某个工作在同步模式的工作线程来读取该请求对象。具体选择哪个工作线程来为新的客户请求服务,取决于请求队列的设计。比如简单的轮流选择工作线程的Roud Robin算法,也可以通过条件变量或者信号量来随机选择一个工作线程。工作流程如下所示:
在服务器程序中,如果结合考虑两种时间处理模式和几种I/O模式,则半同步/半异步模式就存在多种变体。其中由一个变体称为半同步/半反应堆模式,如下图所示:
上图中,异步线程只有一个,由主线程来充当。它负责监听所有socket上的事件。如果监听socket上有可读事件发生,即新的连接请求到了,主线程就接受之以得到新的连接socket,然后往epoll内核事件表中注册该socket上的读写事件。如果连接socket上有读写事件发生,即有心的客户请求到来或有数据要发送至客户端,主线程就将该连接socket插入请求队列中。所有工作线程都睡眠在请求队列上,当有任何到来时,他们将通过竞争获得任何的接管权。这种竞争机制使得只有空闲的工作线程才有机会处理新的任何。
缺点如下:
主线程和工作线程共享请求对了。主线程往请求队列中添加任务,或者工作线程从请求队列中取出任务,都需要对请求队列加锁保护,从而白白消费CPU时间。
每个工作线程在同一时间只能处理一个客户请求。如果客户数据较多,而工作线程较少,则请求队列将堆积很多任何对象,客户端的响应时间越来越慢。如果通过增加工作线程来解决这一问题,则工作线程的切换也将耗费大量的CPU时间。
下图是另外一种相对高效的半同步/半异步模式,它的每个工作线程都能同时处理多个客户连接。
上图,主线程只管理监听socket,连接socket由工作线程来管理。当有新的连接到来时,主线程就接受之并将新返回的连接socket派发给某个工作线程,此后该新的socket上的任何I/O操作都由被选中的工作线程来处理,知道客户端关闭连接。主线程向工作线程派发socket的最简单方式,是往它和工作线程之间的管道里写数据。工作线程检测到管道上有数据可读时,就分析是否是一个新的客户连接请求到来。如果是,则把该新socket上的读写事件注册到自己的 epoll内核事件表中。
二、领导者/追随者模式
领导者/追随者模式是多个工作线程轮流获得事件源集合,轮流监听、分发并处理事件的一种模式。在任意时间点,程序都仅有一个领导者线程,它负责监听I/O事件。而其他线程则都是追随者,他们休眠在线程池中等待称为新的领导者。当前领导者如果检测到I/O事件,首先要从线程池中推选出新的领导线程,然后处理I/O事件。此时,新的领导者等待新的I/O时间,而原来的领导者则处理I/O事件,二者实现了并发。
领导者/追随者模式包含如下几个组件:句柄集(HandleSet)、线程集(ThreadSet)、事件处理器(EventHandler)和具体的事件处理器(ConcreteEventHandler)。其关系如下图所示:
1、句柄集
句柄用于表示I/O资源,在Linux下通常就是一个文件描述符。句柄集管理众多句柄,它使用wait_for_event方法来监听这些句柄上的I/O事件,并将其中的就绪时间通知给领导者线程。领导者则调用绑定到Handle上的事件处理器来处理事件。领导者则调用绑定到Handle上的事件处理器来处理事件。领导者将Handle和事件处理器绑定时通过调用句柄集中的register_handle方法实现的。
2、线程集
这个组件是所有工作线程(包括领导者线程和追随者线程)的管理者。它负责各线程之间的同步,以及新领导者线程的推选。线程集中的线程砸死热议时间必处于如下三种状态之一:
Leader:线程当前处于领导者身份,负责等待句柄集上的I/O事件。
Processing:线程正在处理事件。领导者检测到I/O事件之后,可以转移到Processing状态来处理该事件,并调用promote_new_leader方法推选新的领导者。也可以指定其他追随者来处理时间(Event Handoff),此时领导者的地位不变。当处于Processing状态的线程处理完事件之后,如果当前线程集中没有领导者,则它将成为新的领导者,否则他就直接转变成追随者。
Follower:线程当前处于追随者身份,通过调用线程集的join方法等等成为新的领导者,也可能被当前的领导者指定来处理新的任务。
需要注意的是,领导者线程推选新的领导者和追随者等待成为新领导者两个操作都将需要修改线程集,因此线程集提供一个成员Synhronizer来同步这两个操作。以避免竞态条件。‘
3、事件处理器和具体的事件处理器
事件处理器通常包含一个或多个回调函数handle_event。这些回调函数用于处理事件对应的业务逻辑。事件处理器在使用前需要被绑定到某个句柄上,当该句柄上有事件发生时,领导者就执行与之绑定的事件处理器中的回调函数。具体的事件处理器的派生类。他们必须重新实现基类的handle_event方法,以处理特定的任务。
领导者/追随者模式的工作流程总结如下图所示:
由于领导者线程自己监听I/O事件并处理客户请求,因而领导者/追随者模式不需要再线程之间传递任何额外的数据,也无须像半同步/半反应堆模式那样在线程之间同步对请求队列的访问。但领导者/追随者的一个明显缺点是仅支持一个事件源集合,因此也无法,让每个工作线程独立管理多个客户连接。
6、有限状态机
逻辑单元内部的一种高效编程方法:有限状态机。
状态之间的转移是需要状态机内部驱动的。
7、提高服务器性能的其他建议
性能对服务器来说是至关重要的,毕竟每个客户都期望其请求能很快的得到响应。影响服务器性能的首要因素就是系统的硬件资源,比如CPU的个数、速度,内存大小等。如下主要是软件角度提供建议,如系统允许用户打开的最大文件描述数量。还要注意如下方面:池、数据复制、上下文切换。
一、池
池中的资源是预先静态分配的,我们无法预期应该分配多少资源。这个问题又该如何解决呢?最简单的解决方案就是分配足够多的资源,即针对每个客户连接都分配必要的资源。这通常会导致资源的浪费,因为任一时刻的客户数据都可能远远没有达到服务器支持的的最大客户数量。
还有一种方案是预先分配一定的资源,此后这种资源不够用,再动态分配一些并加入池中。
根据不同的资源类型,池也分为多种,常见的是有内存池、进程池、线程池和连接池。
内存池通常用于socket的接收缓存和发送缓存。对于某些长度有限的客户请求,比如HTTP请求,预先分配一个大小足够(比如5000字节)的接收缓冲区是很合理的。当客户请求的长度超过接收缓冲区的大小时,我们选择丢弃请求或者动态扩大接收缓冲区。
进程池和线程池都是并发编程常用的“伎俩”,当我们需要一个工作进程或者工作线程来处理新到来的客户请求时,我们可以直接从进程池或线程池中取得一个执行实体,而无须东戴调用fork或者pthread_create等函数来创建进程和线程。
连接池通常用于服务器或服务器集群的内部永久连接。每个逻辑单元可能都需要频繁地访问本地的某个数据库。简单的做法是:逻辑单元每次需要访问数据库的时候,就向数据发起连接,而访问完毕后释放连接。这种做法效率太低。一种做法是采用连接池实现。连接池是服务器预先和数据库程序建立的一组连接的集合。当某个逻辑单元需要访问数据库时,它可以直接从连接池中取得一个连接的实体并使用之。待完成数据的访问后,逻辑单元再将连接返还给连接池。
二、数据复制
高性能服务器应该避免不必要的数据复制,尤其是当数据复制发生在用户代码和内核之间。例如FTP服务器就无须把目标文件的内容完整地读入到应用程序缓冲区中并调用send函数来发送,而是可以使用“零拷贝”函数sendfile来直接将其发送给客户端。
此外,用户代码内部的数据复制也应该避免。
三、上下文切换和锁
并发程序必须考虑上下文切换的问题,即进程切换或者现场切换导致的系统开销。即使是I/O密集型的服务器,也不应该使用过多的工作线程,否则线程间的切换占用大量的CPU时间,服务器真正用于处理业务逻辑的CPU时间的比重就显得不足了。因此,为每个客户连接都创建一个工作线程的服务器模型是不可取的。半同步/半异步模式是一种比较合理的解决方案,他允许一个线程同时处理多个客户连接。此外,多线程服务器的一个优点是不同线程可以同时运行在不同的CPU上。当线程的数量不大于CPU的数目时,上下文切换就不是问题了。
并发程序需要考虑的另外一个问题是共享资源的加锁保护。锁通常被认为导致服务器效率低下的一个因素,因为它引入代码不仅不处理任何业务逻辑,而且需要访问内核资源。因此,服务器如果有更好的解决方案,就应该避免使用锁。如果服务器必须使用锁,则可以考虑减少锁的粒度,比如使用读写锁。当所有工作线程都只读一块共享内存的内容时,读写锁并不会增加系统的额外开销。只有当其一个工作线程需要写这块内容时,系统才必须取锁住这块区域。