Linux高性能服务器程序框架

Posted Jqivin

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux高性能服务器程序框架相关的知识,希望对你有一定的参考价值。

点击查看:高性能服务器程序框架(1)

四、两种高效的事件处理模式

服务器程序通常需要处理三类事件:I/O事件、信号、定时事件。

同步I/O模型通常用于实现Reactor模式。

异步I/O模型则用于实现Proactor模式。

1. Reactor模式: 

Reactor是这样一种模式, 它要求主线程(I/O 处理单元) 只负责监听文件描述上是否有事件发生,有的话就立即将该事件通知工作线程(逻辑单元)。 除此之外,主线程不做任何其他实质性的工作。读写数据,接受新的连接,以及处理客户请求均在工作线程中完成。


使用同步I/O模型(以epoll_ wait 为例)实现的Reactor模式的工作流程是: 

  1. 主线程往epoll内核事件表中注册socket 上的读就绪事件。
  2. 主线程调用epoll_ wait 等待socket上有数据可读。
  3. 当socket上有数据可读时,epoll_ wait 通知主线程。主线程则将socket可读事件放人请求队列。
  4. 睡眠在请求队列上的某个工作线程被唤醒,它从socket读取数据,并处理客户请求,然后往epoll内核事件表中注册该socket上的写就绪事件。
  5. 主线程调用epoll_ wait等待socket可写。
  6. 当socket可写时,epoll wait通知主线程。主线程将socket可写事件放入请求队列。
  7. 睡眠在请求队列上的某个工作线程被唤醒,它往socket上写服入务器处理客户请求的结果。
  8. 工作线程从请求队列中取出事件后,将根据事件的类型来决定如何处理它,对于可读事件,执行读数据和处理请求的操作,对于可写事件,执行写数据的操作。
     

 2. Proactor模式:

与Reactor模式不同,Proactor 模式将所有IO操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑。因此,Proactor 模式更符合图前文中所描述的服务器编程框架。

 使用异步I/O模型(以aio_ read 和aio_ write 为例)实现的Proactor模式的工作流程是:

  1. 主线程调用aio. read丽数向内核注册socket上的读完成事件,并告诉内核用户读缓冲区的位置,以及读操作完成时如何通知应用程序(这里以信号为例,详情请参考sigevent的man手册)。
  2. 主线程继续处理其他逻辑。
  3. 当socket上的数据被读入用户缓冲区后,内核将向应用程序发送-一个信号, 以通知应用程序数据已经可用。
  4. 应用程序预先定义好的信号处理函数选择-一个工作线程来处理客户请求。工作线程处理完客户请求之后,调用aio write 兩数向内核注册socket上的写完成事件,并告诉内核用户写缓冲区的位置,以及写操作完成时如何通知应用程序(仍然以信号为例)。
  5. 主线程继续处理其他逻辑。
  6. 当用户缓冲区的数据被写人socket之后,内核将向应用程序发送一-个信号,以通知应用程序数据已经发送完毕。
  7. 应用程序预先定义好的信号处理函数选择一个工作线程来做善后处理,比如决定是否关闭socket.
     

3. 使用同步模型模拟Proactor模式: 

原理是:主线程执行数据读写操作,读写完成之后,主线程向工作线程通知这一“完成事件”。 那么从工作线程的角度来看,它们就直接获得了数据读写的结果,接下来要做的只是对读写的结果进行逻辑处理。

使用同步I/O模型(仍然以epoll_ wait 为例)模拟出的Proactor 模式的工作流程如下:

  1. 主线程往epoll内核事件表中注册socket上的读就绪事件。
  2. 主线程调用epoll wait等待socket上有数据可读。
  3. 当socket上有数据可读时,epoll _wait 通知主线程。主线程从socket循环读取数据,直到没有更多数据可读,然后将读取到的数据封装成一个请求对象并插人请求队列。
  4. 睡眠在请求队列上的某个工作线程被唤醒,它获得请求对象并处理客户请求,然后往epoll内核事件表中注册socket上的写就绪事件。
  5. 主线程调用epoll_ wait 等待socket可写。
  6. 当socket可写时,epoll wait 通知主线程。主线程往socket,上写人服务器处理客户请求的结果。
     

五、两种高效的并发模式

并发模式是指I/0处理单元和多个逻辑单元之间协调完成任务的方法。

服务器主要有两种并发编程模式:

半同步半异步(half-syne/half-async)模式

领导者/追随者(Leader/Followers) 模式。

  • 并发编程的目的是让程序“同时”执行多个任务。
  • 如果程序是计算密集型的,并发编程并没有优势,反而由于任务的切换使效率降低。但如果程序是I/O密集型的,比如经常读写文件,访问数据库等,则情况就不同了。由于I/O操作的速度远没有CPU的计算速度快,所以让程序阻塞于I/O操作将浪费大量的CPU时间。
  • 如果程序有多个执行线程,则当前被I/O操作所阻塞的执行线程可主动放弃CPU (或由操作系统来调度),并将执行权转移到其他线程。这样一来,CPU就可以用来做更加有意义的事情(除非所有线程都同时被I/O操作所阻塞),而不是等待I/O操作完成,因此CPU的利用率显著提升。
  • 从实现上来说,并发编程主要有多进程和多线程两种方式。

1. 半同步/半异步模式:

半同步半异步模式中的“同步”和“异步”与前面讨论的I/0模型中的“同步”和“异步”是完全不同的概念。在I/O模型中:

  • “同步”和“异步”区分的是内核向应用程序通知的是何种I/O事件(是就绪事件还是完成事件),以及该由谁来完成/o读写(是应用程序还是内核)。

在并发模式中:

  • “ 同步”指的是程序完全按照代码序列的顺序执行。“异步”指的是程序的执行需要由系统事件来驱动。常见的系统事件包括中断、信号等。比如,图8-8a描述了同步的读操作,而图8-8b则描述了异步的读操作。

并发模式中同步、异步优缺点:

  • 按照同步方式运行的线程称为同步线程,按照异步方式运行的线程称为异步线程。
  • 显然,异步线程的执行效率高,实时性强,这是很多嵌入式程序采用的模型。但编写以异步方式执行的程序相对复杂,难于调试和扩展,而且不适合于大量的并发。
  • 而同步线程则相反,它虽然效率相对较低,实时性较差,但逻辑简单。

因此,对于像服务器这种既要求较好的实时性,又要求能同时处理多个客户请求的应用程序,我们就应该同时使用同步线程和异步线程来实现,即采用半同步/半异步模式来实现。

工作流程:

  • 半同步半异步模式中,同步线程用于处理客户逻辑,相当于逻辑单元
  • 异步线程用于处理I/O事件,相当于I/O处理单元。
  • 异步线程监听到客户请求后,就将其封装成请求对象并插人请求队列中。
  • 请求队列将通知某个工作在同步模式的工作线程来读取并处理该请求对象,比如最简单的轮流选取工作线程的Round Robin算法,也可以通过条件变量或信号量来随机地选择一个工作线程。

半同步/半反应堆(half- sync/half-reactive)模式:

在服务器程序中,如果结合考虑两种事件处理模式和几种1/O模型,则半同步/半异步模式就存在多种变体。其中有一种变体称为半同步/半反应堆(half- sync/half-reactive)模式.

半同步/半反应堆工作流程:

  • 异步线程只有一个,由主线程来充当。它负贵监听所有socket上的事件。
  • 如果监听socket上有可读事件发生,即有新的连接请求到来,主线程就接受之以得到新的连socket,然后往epoll内核事件表中注册该socket上的读写事件。
  • 如果连接socket上有读写事件发生,即有新的客户请求到来或有数据要发送至客户端,主线程就将该连接socket插人请求队列中。
  • 所有工作线程都睡眠在请求队列上,当有任务到来时,它们将通过竞争(比如申请互斥锁)获得任务的接管权。这种竞争机制使得只有空闲的工作线程才有机会来处理新任务,这是很合理的。
  • 图8-10中,主线程插入请求队列中的任务是就绪的连接socket这说明该图所示的半同步半反应堆模式采用的事件处理模式是Reactor模式:它要求工作线程自己从socket上读取客户请求和往socket写入服务器应答。这就是该模式的名称中“half-reactive"的含义。
  • 实际上,半同步1半反应堆模式也可以使用模拟的Proactor事件处理模式,即由主线程来完成数据的读写。
  • 在这种情况下,主线程一般会将应用程序数据、任务类型等信息封装为一个任务对象,然后将其(或者指向该任务对象的一个指针)插入请求队列。
  • 工作线程从请求队列中取得任务对象之后,即可直接处理之,而无须执行读写操作了。

 

半同步半反应堆模式缺点:

  • 主线程和工作线程共享请求队列。主线程往请求队列中添加任务,或者工作线程从请求队列中取出任务,都需要对请求队列加锁保护,从而白白耗费CPU时间。
  • 每个工作线程在同一时间只能处理一个客户 请求。如果客户数量较多,而工作线程较少,则请求队列中将堆积很多任务对象,客户端的响应速度将越来越慢。
  • 如果通过增加工作线程来解决这一问题,则工作线程的切换也将耗费大量CPU时间。
     

另一种相对高效的半同步/半异步模式:

  • 主线程只管理监听socket,发连接socket由工作线程来管理。
  • 当有新的连接到来时,主线程就接受之并将新返回的连接socket派给某个工作线程,此后该新socket上的任何IO操作都由被选中的工作线程来处理,直到客户关闭连接。
  • 主线程向工作线程派发socket的最简单的方式,是往它和工作线程之间的管道里写数据。工作线程检测到管道上有数据可读时,就分析是否是-一个新的客户连接请求到来。如果是,则把该新socket上的读写事件注册到自己的epoll内核事件表中。
  • 每个线程(主线程和工作线程)都维持自己的事件循环,它们各自独立地监听不同的事件。
  • 因此,在这种高效的半同步/半异步模式中,每个线程都工作在异步模式,所以它并非严格意义上的半同步/半异步模式。

2. 领导者/追随者模式:

领导者/追随者模式是多个工作线程轮流获得事件源集合,轮流监听、分发并处理事件的一种模式。

  • 在任意时间点,程序都仅有一个领导者线程,它负责监听I/O事件。而其他线程则都是追随者,它们休眠在线程池中等待成为新的领导者。
  • 当前的领导者如果检测到I/O事件,首先要从线程池中推选出新的领导者线程,然后处理I/O事件。此时,新的领导者等待新的I/O事件,而原来的领导者则处理I/O事件,二者实现了并发。

领导者/追随者模式包含如下几个组件:

(1)句柄集(HandleSet)

  • 句柄(Handle) 用于表示I/O资源,在Linux下通常就是一个文件描述符。句柄集管理众多句柄,它使用wait for_ event 方法来监听这些句柄上的I0事件,并将其中的就绪事件通知给领导者线程。领导者则调用绑定到Handle上的事件处理器来处理事件。领导者将Handle和事件处理器绑定是通过调用句柄集中的register_ handle 方法实现的。

(2)线程集(ThreadSet)

这个组件是所有工作线程(包括领导者线程和追随者线程)的管理者。它负责各线程之间的同步,以及新领导者线程的推选。线程集中的线程在任一时间必处于如下三种状态之一:

  • Leader:线程当前处于领导者身份,负责等待句柄集上的1O事件。
  • Processing:线程正在处理事件。领导者检测到IO事件之后,可以转移到Processing状态来处理该事件,并调用promote_ new _leader 方法推选新的领导者:也可以指定其他追随者来处理事件(Event Handoff),此时领导者的地位不变。当处于Processing状态的线程处理完事件之后,如果当前线程集中没有领导者,则它将成为新的领导者,否则它就直接转变为追随者。
  • Follower:线程当前处于追随者身份,通过调用线程集的join方法等待成为新的领导者,也可能被当前的领导者指定来处理新的任务。

(3)事件处理器( EventHandler) 和 具体的事件处理器(ConcreteEventHandler)。 

  • 事件处理器通常包含一个或多个回调函数handle_ event。 这些回调函数用于处理事件对应的业务逻辑。事件处理器在使用前需要被绑定到某个句柄上,当该句柄上有事件发生时,领导者就执行与之绑定的事件处理器中的回调函数。具体的事件处理器是事件处理器的派生类。它们必须重新实现基类的handle_ event 方法,以处理特定的任务。

领导者/追随者模式工作模式总结:

由于领导者线程自己监听I/O事件并处理客户请求,因而领导者追随者模式不需要在线程之间传递任何额外的数据,也无须像半同步1半反应堆模式那样在线程之间同步对请求队列的访问。但领导者追随者的一个明显缺点是仅支持一个事件源集合,因此也无法让每个工作线程独立地管理多个客户连接。
 

六、有限状态机:

有限状态机是逻辑单元内部的一种高效编程方法。

有的应用层协议头部包含数据包类型字段,每种类型可以映射为逻辑单元的一种执行状态,服务器可以根据它来编写相应的处理逻辑。

(暂不详细介绍)

七、提高服务器性能的其他建议:

性能对服务器来说是至关重要的,每个客户都期望其请求能很快地得到响应。

影响服务器性能的首要因素就是系统的硬件资源,比如CPU的个数、速度,内存的大小等。不过由于硬件技术的飞速发展,现代服务器都不缺乏硬件资源。

因此,我们需要考虑的主要问题是如何从“软环境”来提升服务器的性能。

服务器的“软环境”:

  • 一方面是指系统的软件资源,比如操作系统允许用户打开的最大文件描述符数量:
  • 另一方面指的就是服务器程序本身,即如何从编程的角度来确保服务器的性能,这是要讨论的问题。

前面我们介绍了几种高效的事件处理模式和并发模式,以及高效的逻辑处理方式一有限状态机,它们都有助于提高服务器的整体性能。

下面我们进一步分析高性能服务器需要注意的其他几个方面:池、数据复制、上下文切换和锁
 

1. 池:

服务器的硬件资源“充裕”,那么提高服务器性能的一个很直接的方法就是以空间换时间,即“浪费”服务器的硬件资源,以换取其运行效率。这就是池(pool) 的概念。

  • 池是一组资源的集合,这组资源在服务器启动之初就被完全创建好并初始化,这称为静态资源分配。
  • 当服务器进人正式运行阶段,即开始处理客户请求的时候,如果它需要相关的资源,就可以直接从池中获取,无须动态分配。
  • 很显然,直接从池中取得所需资源比动态分配资源的速度要快得多,因为分配系统资源的系统调用都是很耗时的。当服务器处理完一个客户连接后,可以把相关的资源放回池中,无须执行系统调用来释放资源。

从最终的效果来看,池相当于服务器管理系统资源的应用层设施,它避免了服务器对内核的频繁访问。不过,既然池中的资源是预先静态分配的,我们就无法预期应该分配多少资源这个问题又该如何解决呢?

  • 最简单的解决方案就是分配“足够多”的资源,即针对每个可能的客户连接都分配必要的资源。这通常会导致资源的浪费,因为任一时刻的客户数量都可能远远没有达到服务器能支持的最大客户数量。好在这种资源的浪费对服务器来说一般不会构成问题。
  • 还有一种解决方案是预先分配一定的资源,此后如果发现资源不够用,就再动态分配一些并加入池中。
  • 根据不同的资源类型,池可分为多种,常见的有内存池、进程池、线程池和连接池。它们的含义都很明确。

内存池:

  • 通常用于socket的接收缓存和发送缓存。对于某些长度有限的客户请求,比如HTTP请求,预先分配一个大小足够(比如5000字节)的接收缓存区是很合理的。当客户请求的长度超过接收缓冲区的大小时,我们可以选择丢弃请求或者动态扩大接收缓冲区。


进程池和线程池:

  • 都是并发编程常用的“伎俩”。当我们需要一个工作进程或工作线程来处理新到来的客户请求时,我们可以直接从进程池或线程池中取得一个执行实体,而无须动态地调用fork或pthread_ create 等函数来创建进程和线程。

连接池:

  • 通常用于服务器或服务器机群的内部永久连接。每个逻辑单元可能都需要频繁地访问本地的某个数据库。
  • 简单的做法是:逻辑单元每次需要访问数据库的时候,就向数据库程序发起连接,而访问完毕后释放连接。很显然,这种做法的效率太低。
  • 一种解决方案是使用连接池。连接池是服务器预先和数据库程序建立的一组连接的集合。 当某个逻辑单元需要访问数据库时,它可以直接从连接池中取得一个连接的实体并使用之。待完成数据库的访问之后,逻辑单元再将该连接返还给连接池。
     

2. 数据复制: 

高性能服务器应该避免不必要的数据复制,尤其是当数据复制发生在用户代码和内核之间的时候。如果内核可以直接处理从socket或者文件读入的数据,则应用程序就没必要将这些数据从内核缓冲区复制到应用程序缓冲区中。这里说的“直接处理”指的是应用程序不关心这些数据的内容,不需要对它们做任何分析。

  • 比如ftp服务器,当客户请求一个文件时,服务器只需要检测目标文件是否存在,以及客户是否有读取它的权限,而绝对不会关心文件的具体内容。这样的话,ftp 服务器就无须把目标文件的内容完整地读人到应用程序缓冲区中并调用send函数来发送,而是可以使用“零拷贝”函数sendfile来直接将其发送给客户端。

此外,用户代码内部(不访问内核)的数据复制也是应该避免的。

举例来说,当两个工作进程之间要传递大量的数据时,我们就应该考虑使用共享内存来在它们之间直接共享这些数据,而不是使用管道或者消息队列来传递。
 

3. 上下文切换和锁:

并发程序必须考虑上下文切换(context switch)的问题,即进程切换或线程切换导致的的系统开销。即使是IO密集型的服务器,也不应该使用过多的工作线程(或工作进程),否则线程间的切换将占用大量的CPU时间,服务器真正用于处理业务逻辑的CPU时间的比重就显得不足了。

因此,为每个客户连接都创建一个工作线程的服务器模型是不可取的。

  • 半同步半异步模式是一种比较合理的解决方案,它允许一个线程同时处理多个客户连接。
  • 此外,多线程服务器的一个优点是不同的线程可以同时运行在不同的CPU上。当线程的数量不大于CPU的数目时,上下文的切换就不是问题了。

并发程序需要考虑的另外一个问题是共享资源的加锁保护。

  • 锁通常被认为是导致服务器效率低下的一个因素,因为由它引入的代码不仅不处理任何业务逻辑,而且需要访问内核资源。因此,服务器如果有更好的解决方案,就应该避免使用锁。

显然,半同步半异步模式就比半同步/半反应堆模式的效率高。

  • 如果服务器必须使用“锁”,则可以考虑减小锁的粒度,比如使用读写锁。当所有工作线程都只读取一块共享内存的内容时,读写锁并不会增加系统的额外开销。只有当其中某一个工作线程需要写这块内存时,系统才必须去锁住这块区域。

Linux高性能服务器编程

以上是关于Linux高性能服务器程序框架的主要内容,如果未能解决你的问题,请参考以下文章

《Linux高性能服务器编程》学习总结——高性能服务器程序框架

Linux高性能服务器程序框架

Linux高性能服务器程序框架

高性能I/O框架库Libevent

项目总结50:Linux服务器上web项目Java项目性能调优

《Linux高性能服务器编程》学习总结——Linux服务器程序规范