文章目录
- TCP服务器:从一请求一线程到百万并发
-
- 引言
- 代码实现
- 问题与解决方案
-
- 问题一:同样是用于处理并发的`select`,它与`epoll`有什么不同,各有什么使用场景?
- 问题二:常有文件操作符有哪些类型,工作原理是什么?
- 总结
TCP服务器:从一请求一线程到百万并发
简介:本文介绍了C语言实现的服务器如何从单线程方式迭代到具有百万并发的数据处理能力。
引言
在C/S架构中,服务器扮演接收请求处理的角色,也就是说在大部分场景下服务器需要同时分配内存处理多个用户的请求。在有限的存储中总有被请求占满的时候,为了最大限度利用网络连接即网络I/O,引入了select/epoll。其核心原理是通过单个线程监控多个文件描述符的I/O事件高效处理高并发连接。但仅仅通过以上技术还不足以做到Linux平台上的百万并发,在Linux操作系统中往往有多方面的限制,进一步修改优化才能实现百万并发。
代码实现
在主函数中声明socket套接字,用于监听用户请求。接收到用户请求,使用线程或epoll处理。
对于每个来自于客户端的请求,都新建一个线程去处理。
int sockfd = socket(AF_INET,SOCK_STREAM,0);
struct sockaddr_in addr;
memset(&addr,0,sizeof(struct sockaddr_in));
addr.sin_family = AF_INET;//使用的协议族
addr.sin_port = htons(port);
addr.sin_addr.s_addr = INADDR_ANY;//令地址为 0.0.0.0
SOCK_STREAM设定服务器端采用TCP通信协议,INADDR_ANY注册地址为0.0.0.0用于监听所有网络的连接请求。将socket与地址信息进行绑定后开始监听请求。使用listen()被动监听连接,每个客户端请求过来与服务器进行三次握手建立连接并被放入连接队列,此时服务器调用accept()得到连接队列的套接字描述符。通过创建线程进行处理。
bind(sockfd,(struct sockaddr*)&addr,sizeof(struct sockaddr_in);
listen(sockfd,5);
while(1){
struct sockaddr_in client_addr;
memset(&client_addr,0,sizeof(struct sockaddr_in));
socklen_t client_len = sizeof(client_addr);
int clientfd = accept(sockfd,(struct sockaddr*)&client_addr,&client_len);
//创建线程处理该请求
pthread_t threadid;
pthread_create(&threadid,NULL,client_routine,&clientfd);
}
在回调函数中处理客户端的请求,recv()取出客户端发送的数据,只有当该函数返回值>0时该函数成功接收到数据。
void* client_routine(void* arg){
int client_fd = *(int *)arg;
while(1){
char buffer[BUFFER_LENGTH] = {0};
int len = recv(client_fd,buffer,BUFFER_LENGTH,0);
if(len < 0){//错误发生
close(client_fd);
break;
}else if(len == 0){//连接已关闭
close(client_fd);
break;
}else{
printf("Recv: %s, %d byte(s)\\n",buffer,len);
}
}
}
借用工具NetAssist输入服务器Ip与端口号发送数据,得到如下结果。
与方案一类似,需要初始化套接字与地址结构体绑定,开始监听。此处相较于方案一初始化epoll得到文件描述符。
int epfd = epoll_create(1);//此处参数只需要>0
初始化epoll_event数组用于存放所有的用户请求。定义具体事件用于和套接字绑定。最后epoll_ctl()交付epoll统一管理。
struct epoll_event events[EPOLL_SIZE] = {0};
//将监听的fd交给epoll管理
struct epoll_event ev;//用于处理已有的io listen
ev.events = EPOLLIN;//只关注输入
ev.data.fd = sockfd;
epoll_ctl(epfd,EPOLL_CTL_ADD,sockfd,&ev);
此时服务器持续监听等待客户端连接,epoll_wait()返回就绪事件即三报文握手成功的连接的数量。
int nready = epoll_wait(epfd,events,EPOLL_SIZE,5);
if(nready == –1) continue;
因为epoll将所有的I/O统一管理,所以需要判断当前的就绪事件是否是由sockfd触发。
if(events[i].data.fd == sockfd){//检查当前事件是否由sockfd触发 listen_fd client_fd
struct sockaddr_in client_addr;
memset(&client_addr,0,sizeof(struct sockaddr_in));
socklen_t client_len = sizeof(struct sockaddr_in);
int clientfd = accept(sockfd,(struct sockaddr*)&client_addr,&client_len);
//EPOLLET 边缘触发模式 一次性将数据读完
//EPOLLLT 水平输出模式 一直在接受数据
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = clientfd;
epoll_ctl(epfd,EPOLL_CTL_ADD,clientfd,&ev);
}
判断结果为真,进入if条件,accept()从队列中取出一个就绪事件返回操作符。将该事件设置为可读并且边缘触发ev.events = EPOLLIN | EPOLLET。对于事件常有两种触发方式:边缘触发、水平触发。边缘触发方式仅在文件描述符发生变化时触发一次(类似于信号从0跳变为1),但后续不会再有通知,此时读取数据就需要一直读到EAGAIN;水平触发则是当文件处于就绪状态就会持续触发事件通知(信号从0跳变为1以及保持信号在1状态)。
最后epoll_ctl()将就绪I/O操作符交付epoll加入管理队列中。
在epoll_events中那些并不是由sockfd触发的事件,只可能是被上一步处理完的客户端文件操作符clientfd。依次取到文件操作符int clientfd = events[i].data.fd,使用recv()取到客户端发送的数据。
else{
int clientfd = events[i].data.fd;
char buffer[BUFFER_LENGTH] = {0};
int len = recv(clientfd,buffer,BUFFER_LENGTH,0);
if(len < 0){
close(clientfd);
ev.events = EPOLLIN;
ev.data.fd = clientfd;
epoll_ctl(epfd,EPOLL_CTL_DEL,clientfd,&ev);
}else if(len == 0){
close(clientfd);
ev.events = EPOLLIN;
ev.data.fd = clientfd;
epoll_ctl(epfd,EPOLL_CTL_DEL,clientfd,&ev);
}else{
printf("Recv: %s, %d byte(s)\\n",buffer,len);
}
}
运行服务器端程序,监听8888端口。
所谓的百万并发也就是说在服务器崩溃之前能够承载的连接数量,本文采用本地虚拟机测试连接数。在另三个虚拟机上运行客户端代码向服务器发起连接请求,测试服务器的最大可保持连接数。三个虚拟机采用相同配置。
- 客户端
将需要连接的服务器IP及端口号作为参数输入,初始化发送数据的缓冲区,定义epoll事件,将其最大处理事件数量设置为MAX_EPOLLSIZE。
#define MAX_EPOLLSIZE (384*1024)
int main(int argc,char** argv){
const char* ip = argv[1];
int port = atoi(argv[2]);
int connections = 0;
char buffer[128] = {0};
int i = 0,index = 0;
struct epoll_event events[MAX_EPOLLSIZE];
int epoll_fd = epoll_create(MAX_EPOLLSIZE);
strcpy(buffer," Data From MulClient\\n");
struct sockaddr_in addr = {0};
memset(&addr,0,sizeof(struct sockaddr_in));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = inet_addr(ip);
struct timeval tv_begin;
gettimeofday(&tv_begin,NULL);//获取当前时间 存储结果 时区
循环向服务器发起请求,将请求设置为非阻塞状态交付给epoll统一管理。在TCP中每一个连接都由一个四元组确定:(源IP,源端口,目的IP,目的端口)。服务器通过目标端口区分服务,客户端需要源端口区分不同连接。若客户端仅使用一个端口,则其最多可以建立65535(2<sup>16</sup> – 1)个连接,且在具体的操作系统中对单个端口的并发连接数量仍有限制。所以最好的方式使用100个端口向服务器发起连接。
if(++index >= MAX_PORT) index = 0;//端口循环(8888-8987)
struct epoll_event ev;
int sockfd = 0;
if(connections < 340000 && !isContinue){
sockfd = socket(AF_INET,SOCK_STREAM,0);
addr.sin_port = htons(port+index);
connect(sockfd,(struct sockaddr*)&addr,sizeof(struct sockaddr_in));
ntySetNonblock(sockfd);//设置当前套接字非阻塞
ntySetReUseAddr(sockfd);//地址可重用
sprintf(buffer,"Hello Server: client –> %d\\n",connections);
send(sockfd,buffer,strlen(buffer),0);
ev.data.fd = sockfd;
ev.events = EPOLLIN || EPOLLOUT;//监听读写
epoll_ctl(epoll_fd,EPOLL_CTL_ADD,sockfd,&ev);
connections++;
}
每当成功建立1000个连接,暂停向服务器发送请求转而处理事件。
if(connections % 1000 == 999 || connections >= 34000){
struct timeval tv_cur;
memcpy(&tv_cur,&tv_begin,sizeof(struct timeval));
gettimeofday(&tv_begin,NULL);
int time_used = TIME_SUB_MS(tv_begin,tv_cur);
printf("connections: %d, sockfd: %d, time_used:%d\\n",connections,sockfd,time_used);
int nfds = epoll_wait(epoll_fd,events,connections,100);
for(i = 0;i < nfds;i++){
int clientfd = events[i].data.fd;
if(events[i].events & EPOLLOUT){
sprintf(buffer,"data from %d\\n",clientfd);
send(sockfd,buffer,strlen(buffer),0);
}else if(events[i].events & EPOLLIN){
char rBuffer[MAX_BUFFER] = {0};
ssize_t length = recv(sockfd,rBuffer,MAX_BUFFER,0);
if(length > 0){
printf(" RecvBuffer:%s\\n",rBuffer);
if(!strcmp(rBuffer,"quit")){
isContinue = 0;
}
}else if(length == 0){
printf(" Disconnect clientfd:%d\\n",clientfd);
epoll_ctl(epoll_fd,EPOLL_CTL_DEL,clientfd,NULL);
connections—;
close(clientfd);
}else {
if(errno == EINTR) continue;
printf(" Error clientfd:%d, errno:%d\\n",clientfd,errno);
epoll_ctl(epoll_fd,EPOLL_CTL_DEL,clientfd,NULL);
close(clientfd);
}
}else{
printf(" clientfd:%d, errno:%d\\n",clientfd,errno);
epoll_ctl(epoll_fd,EPOLL_CTL_DEL,clientfd,NULL);
close(clientfd);
}
}
}
usleep(1 * 1000);
至此客户端代码功能完成。
- 服务器端及参数调整
客户端使用了100个端口向服务器发起连接,所以服务器需要同时监听100个端口号。
for(i = 0;i < MAX_PORT;i++)
struct sockaddr_in addr;
memset(&addr,0,sizeof(struct sockaddr_in));
addr.sin_family = AF_INET;
addr.sin_port = htons(port+i);
addr.sin_addr.s_addr = INADDR_ANY;//令地址为 0.0.0.0
此时还仍无法做到百万并发,因为与客户端建立的每一个socket都是一个文件操作符,而在Linux操作系统中限制了每个进程的文件操作符最大数量。使用命令ulimit -a查看open files栏的最大文件打开数量。修改系统配置文件sudo vim /etc/security/limits.conf,在文件中添加最后两行。
* soft nofile 1048576
* hard nofile 1048576
*表示适用于所有用户,soft、hard都将文件打开数量限制为1048576,完全可以做到百万并发。
然而就算调整了上述参数,仍然会受到内核参数的限定,导致客户端无法与服务器建立连接出现连接超时现象。/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 | 用户/进程文件描述符数 | 单进程并发连接限制 |
同时配置其余相关参数:net.ipv4.tcp_mem控制TCP协议栈的内存使用,其三个值分别表示最小值、压力值、最大值。在低于最小值时TCP自由分配内存;到达压力值时调整发送速率;超过最大值则拒绝分配内存。net.ipv4.tcp_wmem控制发送缓冲区大小,用于适配不同网络条件下的TCP发送缓冲区。net.ipv4.tcp_rmem同理控制不同网络条件下TCP接收缓冲区 ,三个值分别是最小值、默认值、最大值。
基于以上配置每个socket都至少占用2k内存,配合着内存分配调优达到最理想状态。
最后测试得到百万连接。
问题与解决方案
问题一:同样是用于处理并发的select,它与epoll有什么不同,各有什么使用场景?
select 和 epoll 都是 Linux 系统中用于实现 I/O 多路复用(I/O Multiplexing) 的机制,但他们的原理和性能都有差异。
实现机制 | 轮询所有注册的 FD | 事件驱动,仅通知活跃的 FD |
时间复杂度 | O(n)(需遍历所有 FD) | O(1)(直接返回活跃事件列表) |
最大 FD 数量 | 通常受 FD_SETSIZE 限制(默认 1024) | 仅受系统资源限制(可达数十万) |
数据拷贝 | 每次调用需拷贝 FD 集合到内核态 | 通过共享内存避免拷贝 |
触发方式 | 仅支持水平触发(LT) | 支持水平触发(LT)和边缘触发(ET) |
内核版本 | 跨平台(POSIX 标准) | Linux 特有(2.6+ 内核) |
因为select可兼容多平台尝应用于简单跨平台开发,且适用于少量连接;epoll常用于高并发服务器:Nginx、Redis等,都是数万并发的连接场景,基于Linux平台的专属优化。
问题二:常有文件操作符有哪些类型,工作原理是什么?
标准流 | 预定义的文件描述符(0,1,2) | 终端输入/输出/错误 | 操作系统内核维护的缓冲区,与终端设备关联 | stdin, stdout, stderr | 全局唯一,不可关闭 |
常规文件 | 磁盘上的持久化数据文件 | 读写存储设备上的数据 | 通过文件系统元数据定位数据块,支持随机访问 | open(), read(), write() | 支持O_DIRECT绕过缓存 |
管道(Pipe) | 匿名进程间通信通道 | 父子进程/线程间单向数据传输 | 内核维护环形缓冲区,写满后阻塞写入者 | pipe(), read(), write() | 半双工,FIFO特性 |
命名管道(FIFO) | 特殊文件系统中的持久化管道 | 无亲缘关系进程间通信 | 同匿名管道,但通过文件系统路径访问 | mkfifo(), open() | 全双工需两个单向管道 |
套接字(Socket) | 网络通信端点 | 跨主机进程间通信 | 四层网络协议封装(TCP/UDP),通过端口+IP标识,内核维护连接状态表 | socket(), bind(), connect() | 支持SOCK_STREAM(TCP)和SOCK_DGRAM(UDP) |
字符设备 | 按字符流访问的硬件设备 | 串口/终端/虚拟设备(如/dev/null) | 直接操作硬件寄存器,无缓冲区 | open(), ioctl() | 特殊设备需驱动支持 |
内存文件 | 内存映射的伪文件 | 高速临时数据存储 | 通过mmap()将文件或匿名内存映射到进程地址空间 | fmemopen(), open_memstream() | 数据在RAM中,非持久化 |
事件流(Event) | 异步事件通知接口 | 高并发I/O多路复用 | 内核维护事件就绪列表,通过回调或轮询返回就绪事件(如epoll的红黑树+就绪链表) | epoll_ctl(), kqueue() | 边缘触发(ET)需一次性处理所有数据 |
总结
本文详细介绍了C语言实现TCP服务器从单线程处理到百万级并发连接的演进过程。首先通过一请求一线程的简单模型,然后引入epoll多路复用技术提升性能,最后通过调整Linux内核参数(文件描述符限制、TCP内存管理等)和优化客户端连接策略(多端口轮询)实现了百万并发连接。文章还对比了select和epoll的差异,并系统梳理了各类文件描述符的工作原理,为构建高性能服务器提供了全面的技术方案和优化思路。
代码见:https://github.com/208822032/server-client 相关链接:https://github.com/0voice
评论前必须登录!
注册