第8章 高性能服务器程序框架
8.1 服务器模型
服务器启动后,首先创建一个(或多个)监听socket,并调用bind函数将其绑定到服务器感兴趣的端口,然后调用listen函数等待客户连接。服务器稳定运行之后,客户端就可以调用connect函数向服务器发起连接。由于客户连接请求是随机到达的异步事件,版务器需要使用某种I/O模型来监听这一事件。
下图服务器使用的是I/O复用技术之一的select系统调用。当监听到连接请求后,服务器就调用accept函数接受它,并分配一个逻辑单元为新的连接服务。逻辑单元可以是新创建的子进程、子线程或者其他,下图服务器给客户端分配的逻辑单元是fork系统调用创建的子进程。逻辑单元读取客户请求,处理该请求,然后将处理结果返回给客户端,客户端接收到服务器反馈的结果之后,可以继续向服务器发送请求,也可以主动关闭连接。如果客户端主动关团连接,则服务器执行被动关闭连接。至此,双方的通信结束。服务器同时监听多个客户请求是通过select系统调用实现的。
访问量过大时,响应较慢。
P2P(Peer to Peer,点对点)模型使得每台机器在消耗服务的同时也给别人提供服务,每台主机既是客户端,也是服务器,专门的发现服务器用来提供查找服务。
8.2 服务器编程框架
8.3 I/O模型
socket创建时默认是阻塞的。阻塞的概念也能应用于文件描述符,(非)阻塞的文件描述符称为(非)阻塞I/O。
针对阻塞I/O执行的系统调用可能因为无法立即完成而被OS挂起;针对非阻塞I/O执行的系统调用则总是立即返回,而不管事件是否已经发生。
只有在事件已经发生的情况下操作非阻塞I/O,才能提高效率,非阻塞I/O要和其他I/O通知机制(I/O复用和SIGIO信号)一起使用。
同步I/O(阻塞I/O、I/O复用和信号驱动I/O)要求用户代码自行执行I/O 操作(将数据从内核缓冲区读入用户缓冲区,或将数据从用户缓冲区写入内核缓冲区),而异步 I/O机制则由内核来执行I/O操作(数据在内核缓冲区和用户缓冲区之间的移动是用内核在“后台”完成的。
同步I/O向应用程序通知的是I/O就绪事件,由应用程序完成I/O读写;而异步I/O向应用程序通知的是I/O完成事件,由内核完成I/O读写。
8.4 两种高效的事件处理模式
三类事件:I/O事件、信号事件、定时事件。
Reactor事件处理模式:要求主线程(I/O处理单元)只负责监听文件描述上是否有事件发生。有则立即将事件通知工作线程(逻辑单元),然后读写数据,接受新连接,以及处理客户请求。
同步I/O模型(epoll_wait为例)实现:
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事件处理模式:将所有I/O操作交给主线程和内核处理,工作线程仅负责业务逻辑。
异步I/O模型(aio_read和aio_write为例)实现:
1)主线程调用aio_read函数向内核注册socket上的读完成事件,并告诉内核用户读缓冲区的位置,以及读操作完成时如何通知应用程序(信号为例);
2)主线程继续处理其他逻辑;
3)当socket上的数据被读入用户缓冲区后,内核向应用程序发送信号,以通知应用程序数据已经可用;
4)应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理完客户请求之后,调用aio_write函数向内核注册socket上的写完成事件,并告诉内核用户写缓冲区的位置,以及写操作完成时如何通知应用程序(信号为例);
5)主线程继续处理其他逻辑;
6)用户缓冲区的数据被写入socket后,内核将向应用程序发送信号,通知应用程序数据发送完毕;
7)应用程序预先定义好的信号处理函数选择一个工作线程来做善后处理,比如决定是否关闭socket。
使用同步I/O模型模拟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上写入服务器处理客户请求的结果。
8.5 两种高效的并发模式
这里讨论模式。并发模式指I/O处理单元和多个逻辑单元之间协调完成任务。
半同步/异步模式:这里的同步异步指程序的执行是按顺序还是由系统事件驱动。
半同步/半异步模式中,同步线程用于处理客户逻辑,相当于逻辑单元;异步线程用于处理I/O事件,相当于I/O处理单元。异步线程监听到客户请求后,就将其封装成请求对象并插入请求队列中,请求队列将通知某个工作在同步模式的工作线程(选取方法:Round Robin、条件变量、信号量)读取并处理该请求对象。
其变体:半同步/半反应堆模式。
半同步/半反应堆模式采用的事件处理模式是Reactor模式:要求工作线程从socket上读取客户请求和往socket写入服务器应答。也可以使用模拟的Proactor事件处理模式。
缺点:
1)主线程和工作线程共享请求队列,添加和取出任务都要对请求队列加锁保护,耗费CPU时间;
2)每个工作线程在同一时间只能处理一个客户请求,客户多,则队列堆积,客户端响应慢,若增加工作线程,则工作线程的切换也会耗费CPU时间。
主线程只管理监听socket,工作线程管理连接socket。派发方式:往主线程和工作线程之间的管道里写数据,工作线程检测到管道上有数据可读时,就分析是否是一个新的客户连接请求到来。如果是,则把该新soeket上的读写事件注册到自己的epoll内核事件表中。
领导者/追随者模式:多个工作线程轮流获得事件源集合,轮流监听、分发并处理事件。在任意时间点,程序都仅有一个领导者线程,负责监听I/O事件,而其他线程则都是追随者,它们休眠在线程池中等待成为新的领导者,当前的领导者如果检测到 I/O事件,首先要从线程池中推选出新的领导者线程,然后处理I/O事件。此时,新的领导者等得新的I/O事件,而原来的领导者则处理I/O事件,二者实现了并发。
组件:句柄集(HandleSet)、线程集(ThreadSet)、事件处理器(EventHandler)、具体的事件处理器(ConcreteEventHandler)。
句柄集:句柄表示I/O资源(文件描述符),句柄集用wait_for_event方法监听句柄上的I/O事件,将就绪事件通知领导者线程。领导者则调用绑定到Handle上的事件处理器来处理事件。领导者将Handle和事件处理器绑定是通过调用句柄集中的register_handle方法实现。
线程集:所有工作线程管理者。负责个线程之间的同步,及新领导者推选。线程集中线程的三种状态:Leader(领导者)、Processing(处理事件中)、Follower(追随者)。
事件处理器和具体的事件处理器:事件处理器通常包含回调函数,用于处理事件对应的业务逻辑,使用前被绑定到句柄上。具体的事件处理器是其派生类,必须重新实现基类的handle_event方法来处理特定任务。
8.6 有限状态机
用于逻辑单元内部。有的应用层协议头部包含数据包类型字段,每种类型可以映射为逻辑单元的一种执行状态,服务器可以根据它来编写相应的处理逻辑,通过内部驱动实现状态转移。
很多网络协议,包括TCP协议和IP协议,都在其头部中提供头部长度字段。程序根据该字段值就可以知道是否接收到一个完整的协议头部。但HTTP协议并未提供这样的头部长度字段,并且其头部长度变化也很大。
判断HTTP头部结束的依据:遇到一个空行仅包含一对回车换行符(<CR><LF>)。如果一次读操作没有读入HTTP请求的整个头部,即没有遇到空行,那么必须等待客户继续写数据并再次读入。不过在寻找空行的过程中,可以同时完成对整个HTTP请求头部的分析(空行前面还有请求行和头部域),以提高解析HTTP请求的效率。
使用主、从两个有限状态机实现最简单的HTTP请求的读取和分析:
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <assert.h>
#define BUFFER_SIZE 4096 /* 读缓冲区大小 */
/* 主状态机的两种可能状态,分别表示:当前正在分析请求行, 当前正在分析头部字段 */
enum CHECK_STATE{
CHECK_STATE_REQUESTLINE = 0,
CHECK_STATE_HEADER
};
/* 从状态机的三种可能状态,即行的读取状态,分别表示:读取到一个完整的行、行出错
* 和行数据尚且不完整 */
enum LINE_STATUS{
LINE_OK = 0,
LINE_BAD,
LINE_OPEN
};
/* 服务器处理HTTP请求的结果: NO_REQUEST表示请求不完整,需要继续读取客户数据;
* GET_REQUEST表示获得了一个完整的客户请求;
* BAD_REQUEST表示客户请求有语法错误;
* FORBIDDEN_REQUEST表示客户对资源没有足够的访问权限;
* INTERNAL_ERROR表示服务内部错误;
* CLOSED_CONNECTION表示客户端已经关闭连接了 */
enum HTTP_CODE{
NO_REQUEST,
GET_REQUEST,
BAD_REQUEST,
FORBIDDEN_REQUEST,
INTERNAL_ERROR,
CLOSED_CONNECTION
};
/* 为了监护问题,我们没有给客户端发送一个完整的HTTP应答报文,
* 而只是根据服务器的处理结果发送如下成功或失败信息 */
static const char *szret[] = {"I get a correct result\n",
"Somethin wrong\n"};
/* 从状态机,用于解析出一行内容 */
LINE_STATUS parse_line(char *buffer, int &checked_index, int &read_index)
{
char temp;
/* checked_index指向buffer(应用程序的读缓冲区)中当前正在分析的字节,
* read_index指向buffer中客户数据的尾部的下一字节,buffer中第0~checked_index
* 字节都已经分析完毕,第checked_index~(read_index - 1)字节由下面的循环挨个分析 */
for (; checked_index < read_index; ++checked_index)
{
/* 获得当前要分析的字节 */
temp = buffer[checked_index];
/* 如果当前的字节是"\r", 即回车符,则说明可能读取到一个完整的行 */
if (temp == '\r')
{
/* 如果"\r"字符碰巧是目前buffer中的最后一个已经被读入的客户数据,
* 那么分析没有读取到一个完整的行,返回LINE_OPEN以表示还需要继续
* 读取客户数据才能进一步分析 */
if ((checked_index + 1) == read_index)
{
return LINE_OPEN;
}
/* 如果下一个字符是“\n”,则说明我们成功读取到一个完整的行 */
else if (buffer[checked_index + 1] == '\n')
{
buffer[checked_index++] = '\0';
buffer[checked_index++] = '\0';
return LINE_OK;
}
/* 否则的话,说明客户范松的HTTP请求存在语法问题 */
return LINE_BAD;
}
/* 如果当前的字节是"\n", 即换行符,则也说明可能读取到一个完整的行 */
else if (temp == '\n')
{
if ((checked_index > 1) && buffer[checked_index - 1] == '\r')
{
buffer[checked_index++] = '\0';
buffer[checked_index++] = '\0';
return LINE_OK;
}
return LINE_BAD;
}
}
/* 如果所有内容都分析完毕也没有遇到"\r"字符,则返回LINE_OPEN,
* 表示还需要继续读取客户数据才能进一步分析 */
return LINE_OPEN;
}
/* 分析请求行 */
HTTP_CODE parse_requestline(char *temp, CHECK_STATE &checkstate)
{
char *url = strpbrk(temp, " \t");
/* 如果请求航中没有空白字符或“\t”字符,则HTTP请求必有问题 */
if (!url)
{
return BAD_REQUEST;
}
*url++ = '\0';
char *method = temp;
if (strcasecmp(method, "GET") == 0) /* 仅支持GET方法 */
{
printf("The request method is GET\n");
}
else
{
return BAD_REQUEST;
}
url += strspn(url, " \t");
char *version = strpbrk(url, " \t");
if (!version)
{
return BAD_REQUEST;
}
*version++ = '\0';
version += strspn(version, " \t");
/* 仅支持HTTP/1.1 */
if (strcasecmp(version, "HTTP/1.1") != 0)
{
return BAD_REQUEST;
}
/* 检查URL是否合法 */
if (strncasecmp(url, "http://", 7) == 0)
{
url += 7;
url = strchr(url, '/');
}
if (!url || url[0] != '/')
{
return BAD_REQUEST;
}
printf("The request URL is %s\n", url);
/* HTTP 请求行处理完毕,状态转移到头部字段的分析 */
checkstate = CHECK_STATE_HEADER;
return NO_REQUEST;
}
/* 分析头部字段 */
HTTP_CODE parse_headers(char *temp)
{
/* 遇到一个空行,说明我们得到了一个正确的HTTP请求 */
if (temp[0] == '\0')
{
return GET_REQUEST;
}
else if (strncasecmp(temp, "Host:", 5) == 0) /* 处理"HOST"头部字段 */
{
temp += 5;
temp += strspn(temp, " \t");
printf("the request host is: %s\n", temp);
}
else /* 其他头部字段暂不处理 */
{
printf("I can not handle this header\n");
}
return NO_REQUEST;
}
/* 分析HTTP请求的入口函数 */
HTTP_CODE parse_content(char *buffer, int &checked_index, CHECK_STATE &checkstate,
int &read_index, int &start_line)
{
LINE_STATUS linestatus = LINE_OK; /* 记录当前行的读取状态 */
HTTP_CODE retcode = NO_REQUEST; /* 记录HTTP请求的处理结果 */
/* 主状态机,用于从buffer中取出所有完整的行 */
while ((linestatus == parse_line(buffer, checked_index, read_index)) == LINE_OK)
{
char *temp = buffer + start_line; /* start_line是行在buffer中的起始位置 */
start_line = checked_index; /* 记录下一行的起始位置 */
/* checkstate 记录主状态机当前的状态 */
switch(checkstate)
{
case CHECK_STATE_REQUESTLINE: /* 第一个状态,分析请求行 */
{
retcode = parse_requestline(temp, checkstate);
if (retcode == BAD_REQUEST)
{
return BAD_REQUEST;
}
break;
}
case CHECK_STATE_HEADER: /* 第二个状态,分析头部字段 */
{
retcode = parse_headers(temp);
if (retcode == BAD_REQUEST)
{
return BAD_REQUEST;
}
else if (retcode == GET_REQUEST)
{
return GET_REQUEST;
}
break;
}
default:
{
return INTERNAL_ERROR;
}
}
}
/* 若没有读取到一个完整的行,则表示还需要继续读取客户数据才能进一步分析 */
if (linestatus == LINE_OPEN)
{
return NO_REQUEST;
}
else
{
return BAD_REQUEST;
}
}
int main(int argc, char *argv[])
{
if (argc < 2)
{
printf("usage: %s ip_address port_number \n", basename(argv[0]));
return 1;
}
const char *ip = argv[1];
int port = atoi(argv[2]);
struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
inet_pton(AF_INET, ip, &address.sin_addr);
address.sin_port = htons(port);
int listenfd = socket(PF_INET, SOCK_STREAM, 0);
assert(listenfd >= 0);
int ret = bind(listenfd, (struct sockaddr *)&address, sizeof(address));
assert(ret != -1);
ret = listen(listenfd, 5);
assert(ret != -1);
struct sockaddr_in client_address;
socklen_t client_addrlength = sizeof(client_address);
int fd = accept(listenfd, (struct sockaddr *)&client_address, &client_addrlength);
if (fd < 0)
{
printf("errno is : %d\n", errno);
}
else
{
char buffer[BUFFER_SIZE]; /* 读缓冲区 */
memset(buffer, '\0', BUFFER_SIZE);
int data_read = 0;
int read_index = 0; /* 当前已经读取了多少字节的客户数据 */
int checked_index = 0; /* 当前已经分析完了多少字节的客户数据 */
int start_line = 0; /* 行在buffer中的起始位置 */
/* 设置主状态机的初始状态 */
CHECK_STATE checkstate = CHECK_STATE_REQUESTLINE;
while (1) /* 循环读取客户数据并分析之 */
{
data_read = recv(fd, buffer + read_index, BUFFER_SIZE - read_index, 0);
if (data_read == -1)
{
printf("reading failed\n");
break;
}
else if (data_read == 0)
{
printf("remote client has closed the connection\n");
break;
}
read_index += data_read;
/* 分析目前已经获得的所有客户数据 */
HTTP_CODE result = parse_content(buffer, checked_index, checkstate,
read_index, start_line); /* 尚未得到一个完整的HTTP请求 */
if (result == NO_REQUEST)
{
continue;
}
else if (result == GET_REQUEST) /* 得到一个完整的、正确的HTTP请求 */
{
send(fd, szret[0], strlen(szret[0]), 0);
break;
}
else /* 其他情况表示发生错误 */
{
send(fd, szret[1], strlen(szret[1]), 0);
break;
}
}
close(fd);
}
close(listenfd);
return 0;
}
8.7 提高服务器性能的其他建议
软件上:系统软件资源(OS允许用户打开的最大文件描述符数量)、服务器程序本身。
池:用服务器硬件资源换取运行效率。池是一组资源的集合,在服务器启动时就被完全创建好并初始化(静态资源分配)。直接从池中获取资源,也可在处理完客户连接后放回池中。池相当于服务器管理系统资源的应用层设施,避免对内核的频繁访问。内存池:用于socket的接收缓存和发送缓存。比如HTTP请求,预先分配足够大的接收缓存区。
进程池、线程池:需要工作进程或线程处理客户请求时,从池中取得执行实体,无须动态调用fork或pthread_create。
连接池:用于服务器或服务器机群的内部永久连接。如服务器预先和DB建立连接。
数据复制:内核直接处理从socket或者文件读入的数据,如ftp服务器使用”零拷贝“函数sendfile;使用共享内存,而不是管道或消息队列;指针。
上下文切换和锁:进程切换或线程切换导致的的系统开销,半同步/半异步模式(一个线程同时处理多个客户连接)、多线程服务器的一个优点是不同的线程可以同时运行在不同的CPU上,当线程的数量不大于CPU的数目时,上下文的切换就不是问题;共享资源的加锁保护,高效的半同步/半异步模式、减小锁的粒度,使用读写锁,读一块共享内存不增加开销,写内存才会锁。**