8 高性能服务器程序框架
服务器解构为三个主要模块:
IO处理单元。四种IO模型和两种高效事件处理模式。
逻辑单元。两种高效并发模式。
存储单元。(暂不讨论)
IO模型:
阻塞IO
非阻塞IO
IO复用//程序阻塞于IO复用系统调用,但可同时监听多个IO事件。
SIGIO信号//信号触发读写就绪事件,用户程序执行读写操作,程序没有阻塞阶段
异步IO//内核执行读写操作并触发读写完成事件。程序没有阻塞阶段
两种高效的事件处理模式:
服务器通常要处理三类事件:IO事件、信号事件、定时事件。
同步IO模型通常用于实现Reactor模式,异步IO模型用于实现Proactor模式。
Reactor模式:
同步IO模型(以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上写入服务器处理客户请求的结果。
工作线程从队列中取出事件后,将根据事件是可读或可写执行读写数据和处理请求的操作。因此,在Reactor模式中,没必要区分所谓的“读工作线程”和“写工作线程”。
Proactor模式:
与Reactor模式不同,Proactor模式将所有IO操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑。
(以aio_read和aio_write为例)工作流程:
1、主线程调用aio_read函数向内核注册socket上的读完成事件,并告诉内核用户读缓冲区的位置,以及读操作完成时如何通知应用程序(这里以信号为例,详情sigevent的man手册)
2、主线程继续处理其他逻辑。
3、当socket上的数据被读入用户缓冲区后,内核将向应用程序发送一个信号,以通知应用程序数据已经可用。
4、应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理完客户请求之后,调用aio_write函数向内核注册socket上的写完成事件,并告诉内核用户写缓冲区位置,以及写操作完成时如何通知应用程序(仍以信号为例)
5、主线程继续处理其他逻辑。
6、当用户缓冲区的数据被写入socket之后,内核将向应用程序发送一个信号,以通知应用程序数据已经发送完毕。
7、应用程序预先定义好的信号处理函数选择一个工作线程来做善后处理,比如决定是否关闭socket.
使用同步IO模型(仍然以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上写入服务器处理客户端请求的结果。
两种高效的并发模式:
半同步半异步模式:
这里的“同步”和“异步”和前面的IO的“同步”“异步”是完全不同的概念。在IO模型中,“同步”和“异步”区分的是内核向应用程序通知的是何种IO事件(是就绪事件还是完成事件),以及该由谁来完成IO读写(是应用程序还是内核)。在并发模式中,“同步”指的是程序完全按照代码序列的顺序执行;“异步”指的是程序的执行需要由系统事件来驱动。常见的系统事件包括中断、信号等。
显然异步线程的执行效率高,实时性强,是很多嵌入式系统采用的模型。但编写异步方式执行的程序相对复杂,难于调试和扩展,而且不适合于大量的并发。而同步线程则相反,它虽然效率相对较低,实时性较差,但逻辑简单。
在半同步半异步模式中,同步线程用于处理客户逻辑,异步线程用于处理IO事件。异步线程监听到客户请求后,就将其封装成请求对象并插入到请求队列中。请求队列将通知某个工作在同步模式的工作线程来读取并处理该请求对象。
半同步半异步模式存在如下缺点:
1、主线程和工作线程共享请求队列。
2、每一个工作线程在同一时间只能处理一个客户请求。
高效模式:每个工作线程都能同时处理多个客户连接。
主线程只管理监听socket,连接socket由工作线程来管理。当有新的连接到来时,主线程就接受之并将新返回的连接socket派发给某个工作线程,此后该socket上的任何IO操作都由被选中的工作线程来处理,直到客户端关闭连接。主线程向工作线程派发socket的最简单的方式,是往它和工作线程之间的管道里写数据。工作线程检测到管道里有数据可读时,就分析是否是一个新的客户连接请求到来。如果是,则把该新socket上的读写事件注册到自己的epoll内核事件表中。
每个线程(主线程和工作线程)都维持自己的事件循环,它们各自独立的监听不同的事件。因此在这种模式中,每个线程都工作在异步模式,所以它并非严格意义上的半同步半异步模式。
领导者追随者模式:
是多个工作线程轮流获得事件源集合,轮流监听、分发并处理事件的一种模式。在任意时间点,程序都仅有一个领导者线程,它负责监听IO事件。而其他线程都是追随者,它们休眠在线程池中等待成为新的领导者。当前的领导者如果检测到IO事件,首先要从线程池中推选出新的领导者线程,然后处理IO事件。此时,新的领导者等待新的IO事件,而原来的领导者则处理IO事件,二者实现了并发。
包含如下几个组件:句柄集(HandleSet)、线程集(ThreadSet)、事件处理器(EventHandler)、具体的事件处理器(ConcreteEventHandler)。
提高服务器性能的其他建议:
池:
服务器硬件资源相对“充裕”,那么提高服务器性能的一个很直接的方法就是以空间换时间,即“浪费”服务器的硬件资源,以换取其运行效率。这就是池(pool)的概念。池是一种资源的集合,这组资源在服务器启动之初就被完全创建并初始化,这称为静态资源分配。速度要快得多,因为分配系统资源的系统调用都是很耗时的。当服务器处理完一个客户连接后,可以把相关的资源放回池中,无须执行系统调用来释放资源。从最终效果来看,池相当于服务器管理系统资源的应用层设施,它避免了服务器对内核的频繁访问。
按照资源类型分类:
内存池:通常用于socket的接收缓存和发送缓存。
进程池、线程池:并发编程常用“伎俩”。
连接池:常用于服务器或服务器集群的内部永久连接。
数据复制:
应该避免不必要的数据复制,尤其当数据复制发生在用户代码和内核之间的时候。如果内核可以直接处理从socket或者文件读入的数据,则应用程序就没有必要将这些数据从内核缓冲区复制到应用程序缓冲区。如ftp服务器,服务器只需检测目标文件是否存在,以及客户是否有读取权限,而不用关心文件具体内容。就可以使用“零拷贝”sendfile来直接将其发送给客户。
此外,用户代码内部(不访问内核)的数据复制也是应该避免的。如两个工作进程之间要传递大量的数据时,我们就应该考虑使用共享内存来在它们之间直接共享这些数据,而不是使用管道或者消息队列来传递。
上下文切换和锁:
并发程序必须考虑上下文切换(context switch)的问题,即进程线程切换导致的系统开销。即使是IO密集型的服务器,也不应该使用过多的工作线程(或进程,下同),否则切换将占用大量CPU时间,服务器真正用于业务逻辑的CPU时间比重就显得不足了。因此为每个客户连接都建立一个服务器线程的模型不可取。之前描述的半同步半异步模型是一个比较合理的解决方案,它允许一个线程同时处理多个客户连接。此外,多线程服务器的一个优点是不同的线程可以同时运行在不同的cpu上。当线程数量不大于cpu的数目时,上下文切换就不是问题了。
并发程序需要考虑的另一个问题是共享资源的枷锁保护。锁通常被认为是导致服务器效率低下的一个因素,因为由它引入的代码不仅不处理任何业务逻辑,而且需要访问内核资源。因此,服务器如果有更好的解决方案,就应该避免使用锁。如果服务器必须使用锁,则可以考虑减小锁的粒度,比如使用读写锁。当所有工作线程都只读取一块共享内存的内容时,读写锁并不会增加系统的额外开销。只有当其中一个工作线程需要写这块内存时,系统才必须去锁住这块区域。
阅读(3228) | 评论(0) | 转发(0) |