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