基于reactor的百万并发和webserver、websocket应用
基于reactor的百万并发和webserver、websocket应用
简介:基于reactor事件驱动方案,使用少量线程去处理大量IO事件。本文实现了服务器维持百万级来自客户端的连接,和webserver、websocket应用。
引言
在reactor事件驱动模型下,一个IO事件往往需要完成多个任务,将每个IO在epoll中注册不同的回调函数,做到逻辑与业务分离。通过一个服务器多个客户端测试最大可维持连接数量,使用性能测试工具wrk评估服务器的性能和负载能力。基于http协议实现了webserver和websocket应用,进一步理解reactor在具体业务中如何发挥作用。
原理与架构
reactor模型通过回调函数实现了逻辑与业务分离,在注册事件时指定其回调函数,当IO就绪时自动执行预设定的回调函数。
百万并发
客户端与服务器之间的连接由一个五元组表示:(源ip,源端口,目的ip,目的端口,协议)。端口号是一个16位的二进制值,其可用范围为0-65535。当服务器只启用一个端口时,客户端对服务器的理论最大可连接数量为65535,距离百万并发还有一段距离。所以服务器应该同时开始多个端口监听连接,客户端向这些端口发起连接,理论上可以做到百万并发。
webserver、websocket
webserver基于http协议,在服务器端接收客户端请求并发送回应报文。回应报文由报文头和报文体组成,在回应报文头中返回连接成功状态码,本文中分别向请求端回应文本和图片数据。
websocket基于http实现了全双工的持久化连接,常用于实时应用。服务器端对客户端的请求往往有两步处理:HTTP握手升级、数据帧通信。HTTP握手升级即握手阶段,服务器接收到客户端的握手请求进行验证,生成响应密钥。响应密钥从客户端的Sec-WebSocket-Key字段中取出拼接值,拼接成一个固定的GUID,进而对拼接后的字符依次做SHA-1、Base64编码,得到Sec-WebSocket-Accept。最后将状态和Sec-WebSocket-Accept返回给客户端,至此TCP连接升级为WebSocket协议。当握手成果之后服务端开始以帧为单位处理数据。其整体工作流程如下图所示。
代码实现
一、百万并发
在主函数中同时监听20个端口,在数量级上足以实现百万并发。
int i = 0;
for(i = 0;i < MAX_PORT;i++){
int sockfd = init_server(port+i);
conn_list[sockfd].fd = sockfd;
conn_list[sockfd].r_action.recv_callback = accept_cb;
set_event(sockfd,EPOLLIN,1);
}
在accept_cb()回调函数中,每当建立了1000个连接输出连接信息。当accept()接收到新的连接同样要将其交给epoll管理,同时需要将新的IO事件绑定回调函数。
if((clientfd % 1000) == 0){
struct timeval current;
gettimeofday(¤t,NULL);
int time_used = TIME_SUB_MS(current,begin);
memcpy(&begin,¤t,sizeof(struct timeval));
}
至此所有的事件都交给epoll统一管理,并且根据事件的读写就绪状态绑定了回调函数。
在操作系统中每一个fd对应一个文件,服务器与三个客户端同时建立百万连接,需要服务器可以打开百万数量级的文件,相对应的客户端需要达到300000级别的文件打开数量。
在终端输入ulimut -n
可以查看当前可允许最大文件打开数量。若没有满足要求修改/etc/security/limits.conf
配置文件,表示永久修改当前操作系统运行打开的最大文件数量。
然而就算调整了上述参数,仍然会受到内核参数的限定,导致客户端无法与服务器建立连接出现连接超时现象。/proc/sys/fs/file-max
将最大文件打开数量修改为1048576。然而对于每个连接Linux
内核对应建立了连接跟踪表,用于记录每个网络连接的状态,因此该参数值仍然会影响并发的连接数量。sudo vim /etc/sysctl.conf
修改配置文件sysctl -p
应用配置,sudo modprobe ip_conntrack
加载连接跟踪内核模块。
参数 | 作用对象 | 典型场景 |
---|---|---|
nf_conntrack_max |
内核连接跟踪表 | NAT、防火墙、高并发 TCP 连接 |
fs.file-max |
系统文件描述符总数 | 全局 I/O 资源控制 |
ulimit -n |
用户/进程文件描述符数 | 单进程并发连接限制 |
对于一个服务器,其性能往往由多个因素体现。一般情况下考虑并发、qps、时延以及业务测试用例等。本文仅从前三个因素测试该服务器。使用三个客户端同时向服务器发送请求。最后可以看到,服务器端成功建立了101000个与客户端的连接,能够做到百万级别的并发。
二、webserver
webserver应用在事件阶段以reactor驱动,在应用层通过http_request()、http_response()处理业务逻辑。为了实现reactor与应用层的交互引入状态机。
当sockfd收到请求,获取到clientfd。在recv_cb()中将该事件设置为可写事件并调用http_request()函数,将待写缓冲区置零并将状态值置为0。此时该请求转变为可写事件,调用send_cd()。在该方法中调用http_response(),此时状态值为0,设置当状态值为0时发送回应头,而后将状态值设为1。因为该事件仍然为EPOLLIN状态,但状态值为1。当事件就绪再次来到send_cb()方法,设置状态值为1的EPOLLIN事件发送写缓冲区数据,并将该事件状态置为EPOLLOUT。在http_response()方法中,使用sendfile()处理1状态的事件。当发送完毕后,将状态值设为2事件设为EPOLLOUT。
修改后的recv_cb()如下:
int recv_cb(int fd){
memset(conn_list[fd].rbuffer, 0, BUFFER_LENGTH );
int count = recv(fd,conn_list[fd].rbuffer,BUFFER_LENGTH,0);
conn_list[fd].rlength = count;
http_request(&conn_list[fd]);
set_event(fd,EPOLLOUT,0);//此时事件状态置为可写
return count;
}
与简单的处理客户端不同,收到客户端发送的数据后除了设置其回调函数和交予epoll管理,还调用http_request()函数初始化了待读缓冲区,以及将状态值设置为0(作为状态机的入口状态)。
http_request()如下:
int http_request(struct conn* c){
memset(c->wbuffer,0,BUFFER_LENGTH);
c->wlength = 0;
c->status = 0;
}
- 第一次进入send_cb()
首先进入http_response()中,此时status值为0,且当前事件为可写就绪。当主函数中开始处理该IO,根据其绑定的回调函数执行send_cb()。当为初始状态进入该函数时,将http回应头放入写入缓冲区,将status值置为1。
if(c->status == 0){
c->wlength = sprintf(c->wbuffer,
"HTTP/1.1 200 OK\r\n"
"Content-Type: text/html\r\n"
"Accept-Ranges: bytes\r\n"
"Content-Length: %ld\r\n"
"Data: sun, 11 May 2025 20:34:12 GMT\r\n\r\n",stat_buf.st_size);
c->status = 1;
}
- 第二次进入send_cb()
此时该IO状态仍为可写,再一次执行到该函数的回调函数send_cb()。先进入http_response()。
else if(c->status == 1){
int ret = sendfile(c->fd,filefd,NULL,stat_buf.st_size);
if(ret == -1){
printf("errno: %d\n",errno);
}
c->status = 2;
}
从http_response()中出来,进入send_cb()回调函数。状态值为1,将请求头中的值交由send()发送到内核,因为还有回应体值还未发送该事件仍然为可写状态。
http_response(&conn_list[fd]);
if(conn_list[fd].status == 1){
count = send(fd,conn_list[fd].wbuffer,conn_list[fd].wlength,0);
set_event(fd,EPOLLOUT,0);
}else if(conn_list[fd].status == 2){
set_event(fd,EPOLLOUT,0);
}
-
第三次进入send_cb()
仍然先进入http_response(),此时状态值为2,标志着所有数据都发送完毕,清除数据将状态值归零。
else if(c->status == 2){
c->wlength = 0;
memset(c->wbuffer,0,BUFFER_LENGTH);
c->status = 0;
}
http_response()执行结束,继续执行send_cb()。所有待处理数据发送完毕将事件重新置为可读状态。
else if(conn_list[fd].status == 2){
set_event(fd,EPOLLOUT,0);
}
至此一个简单的webserver就基本上实现了,可以读取指定地址的文件读取并发送给客户端。可以看出,http_rquest()与http_response()都是基于reactor层的recv_cb()、send_cb()两个回调函数,进行业务逻辑的实现。无论是哪种业务其实都是结合对数据的收发,在回调函数中进行功能的拓展。
使用浏览器作为请求体,向服务器请求数据,服务器返回图片。
使用wrk工具测试该服务器性能:
三、websocket
与webserver类似,websocket也是针对recv_cb、send_cb()两个回调函数进行处理。整个reactor.c与websocket.c之间交互如下图所示:
在接收到客户端请求后,recv_cb()调用ws_request()。
int ws_request(struct conn *c) {
if (c->status == 0) {
handshake(c);
c->status = 1;
}
}
在该阶段status以初始值0进入,首先进行http握手升级。服务器接收到客户端的握手请求进行验证,生成响应密钥。响应密钥从客户端的Sec-WebSocket-Key字段中取出拼接值,拼接成一个固定的GUID,进而对拼接后的字符依次做SHA-1、Base64编码,得到Sec-WebSocket-Accept。最后将状态和Sec-WebSocket-Accept返回给客户端,至此TCP连接升级为WebSocket协议。将事件设为EPOLLOUT状态。
在send_cb()中,简单地将数据发送给即可。
if (conn_list[fd].wlength != 0) {
count = send(fd, conn_list[fd].wbuffer, conn_list[fd].wlength, 0);
}
set_event(fd, EPOLLIN, 0);
当握手结束,状态值设为1,表示已经成功建立了连接,此时为EPOLLIN状态等待客户端的数据帧。当服务器收到数据帧,recv_cb()收到数据,调用ws_request(),处理用户数据。将用户数据解码解析:
else if (c->status == 1) {
char mask[4] = {0};
int ret = 0;
c->payload = decode_packet(c->rbuffer, c->mask, c->rlength, &ret);
printf("data : %s , length : %d\n", c->payload, ret);
c->wlength = ret;
c->status = 2;
}
此时事件被设置为EPOLLOUT状态,进入send_cb()函数,ws_response()调用encode_packet()编码数据并发送,将status状态值重新置为1,等待接收用户数据帧:
if (c->status == 2) {
c->wlength = encode_packet(c->wbuffer, c->mask, c->payload, c->wlength);
c->status = 1;
}
当数据解析并被放入发送缓冲区中,send_cb()函数即发送数据,同时将事件重新设置为EPOLLIN等待用户数据帧。
if (conn_list[fd].wlength != 0) {
count = send(fd, conn_list[fd].wbuffer, conn_list[fd].wlength, 0);
}
set_event(fd, EPOLLIN, 0);
从浏览器向服务器建立连接,可以看到成果建立连接,并能够正确发送数据。
总结
本文基于Reactor事件驱动模型实现了一个高性能服务器,支持百万级并发连接、WebServer和WebSocket应用。通过多端口监听和系统参数调优,成功达到百万并发。在WebServer实现中,采用状态机机制处理HTTP请求/响应流程,实现了文件传输功能;WebSocket则通过握手升级和帧数据解析实现了全双工通信。测试表明,该系统能稳定处理高并发请求,展示了Reactor模型在I/O密集型场景下的高效性,为构建高性能网络服务提供了实践参考。