在开发TCP服务器时,一个核心挑战是如何同时服务多个客户端。一个基础的、依次处理客户端请求的服务器(迭代服务器)在任一时刻只能与一个客户端通信,这显然无法满足大多数真实场景的需求。本文将深入探讨TCP并发服务器的核心问题、主流解决方案,并手把手带你用C语言实现高效的并发服务器模型。
TCP并发服务器的根本问题在于阻塞。参考文档中指出,服务器需要同时处理accept(等待新连接)和recv(等待客户端数据)两个可能阻塞的操作。在基础的服务器模型中,程序流程会卡在其中一个调用上,导致无法及时响应另一个事件。例如,如果服务器正在recv等待某个客户端的数据,那么此时即使有新客户端尝试连接,服务器也无法立即处理。
为了解决这个问题,主要发展出了两种模型:
1. 多进程/多线程模型
这是最直观的解决方案。核心思想是:主线程/进程只负责accept新连接。每当建立一个新的TCP连接(获得一个新的通信套接字),就创建一个新的子进程或子线程,专门负责与该客户端进行通信(send/recv)。这样,主流程永远不会阻塞在数据读写上,可以持续接收新连接;而每个客户端的通信在独立的执行流中完成,互不干扰。
-
优点:实现简单,逻辑清晰,符合直觉。
-
缺点:资源消耗大。每个连接都会占用一个进程或线程的资源。当并发连接数很高(如成千上万)时,创建、调度、销毁这么多执行单元会带来巨大的CPU和内存开销,性能会急剧下降。文档中也明确指出了其"资源消耗大"的缺点。
2. 多路复用I/O模型
这是构建高性能并发服务器的核心。其核心思想是:用一个特殊的函数(select/poll/epoll)来同时监听多个文件描述符(包括监听套接字和所有通信套接字)的状态。当这个函数返回时,它会告诉我们哪些描述符"就绪"了(例如,监听套接字有新连接可接受,或某个通信套接字有数据可读)。服务器随后只需处理这些就绪的事件即可。
Linux提供了三种主要的I/O多路复用机制,文档中对它们的区别进行了精辟的总结:
-
select:最早期的接口。有描述符数量上限(通常1024);每次调用需要在用户态和内核态之间拷贝整个描述符集合;返回后需要遍历整个集合来查找就绪的描述符。 -
poll:使用链表存储描述符,解决了数量上限问题,但保留了select的其他缺点。 -
epoll:Linux下性能最高的多路复用机制,也是当今高并发服务器的首选。它通过以下方式解决了前两者的缺陷:-
无数量上限。
-
内核事件表:维护一张内核中的表来管理描述符,避免了每次调用的数据拷贝开销。
-
事件驱动:返回时只告诉我们哪些描述符就绪,无需遍历。
-
支持边沿触发(ET)模式:仅在描述符状态发生变化时通知,比默认的水平触发(LT)模式效率更高,可以减少系统调用次数。
-
下面,我们将使用效率最高的epoll来实现一个TCP并发服务器。文档中提供了epoll_create, epoll_ctl, epoll_wait等函数接口的说明,我们将据此编写代码。
cpp
#include "head.h"
int CreateListenSocket(const char *pip, int port)
{
int ret = 0;
int sockfd = 0;
struct sockaddr_in seraddr;
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (-1 == sockfd)
{
perror("fail to socket");
return -1;
}
seraddr.sin_family = AF_INET;
seraddr.sin_port = htons(port);
seraddr.sin_addr.s_addr = inet_addr(pip);
ret = bind(sockfd, (struct sockaddr *)&seraddr, sizeof(seraddr));
if (-1 == ret)
{
perror("fail to bind");
return -1;
}
ret = listen(sockfd, 10);
if (-1 == ret)
{
perror("fail to listen");
return -1;
}
return sockfd;
}
int main(void)
{
int sockfd = 0;
int confd = 0;
int epfd = 0;
int nready = 0;
int ret = 0;
int i = 0;
ssize_t nret = 0;
char tmpbuff[4096] = {0};
struct pollfd fds[1024];
struct epoll_event env;
struct epoll_event retenv[1024];
epfd = epoll_create(1024);
if(-1 == epfd)
{
perror("fail to epoll_create");
return -1;
}
sockfd = CreateListenSocket("192.168.0.144", 50000);
env.events = EPOLLIN;
env.data.fd = sockfd;
ret = epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &env);
if (-1 == ret)
{
perror("fail to epoll_ctl");
return -1;
}
while (1)
{
nready = epoll_wait(epfd, retenv, 1024, -1);
if(-1 == nready)
{
perror("fail to epoll_wait");
return 0;
}
for(i = 0; nready > i; i++)
{
if (retenv[i].data.fd == sockfd)
{
confd = accept(sockfd, NULL, NULL);
if (-1 == confd)
{
epoll_ctl(epfd, EPOLL_CTL_DEL, sockfd, NULL);
close(sockfd);
continue;
}
env.events = EPOLLIN;
env.data.fd = confd;
ret = epoll_ctl(epfd, EPOLL_CTL_ADD, confd, &env);
if (-1 == ret)
{
perror("fail to epoll_ctl");
return -1;
}
}
else
{
memset(tmpbuff, 0, sizeof(tmpbuff));
nret = recv(retenv[i].data.fd, tmpbuff, sizeof(tmpbuff), 0);
if(-1 == nret)
{
perror("fail to recv");
return -1;
}
else if(0 == nret)
{
printf("关闭链接");
epoll_ctl(epfd, EPOLL_CTL_DEL, retenv[i].data.fd, NULL);
close(retenv[i].data.fd);
continue;
}
printf("RECV:%s\n", tmpbuff);
sprintf(tmpbuff, "%s --- echo", tmpbuff);
nret = send(retenv[i].data.fd, tmpbuff, strlen(tmpbuff), 0);
if (-1 == nret)
{
perror("fail to send");
epoll_ctl(epfd, EPOLL_CTL_DEL, retenv[i].data.fd, NULL);
close(retenv[i].data.fd);
continue;
}
}
}
}
close(sockfd);
return 0;
}
代码与模型解析
-
核心结构 :这个服务器是单进程、单线程 的。它所有的并发处理能力都依赖于
epoll这个"事件分发器"。 -
epoll三部曲:
-
epoll_create1: 创建epoll实例,获得一个文件描述符,用于管理事件表。 -
epoll_ctl: 用于管理事件表。我们将server_fd(监听)和所有client_fd(通信)通过此函数加入监控列表,并指定我们关心的事件(如EPOLLIN可读事件)。 -
epoll_wait: 这是核心等待函数。程序会阻塞在这里,直到一个或多个被监控的描述符上发生了我们关心的事件。它返回就绪的事件数量及对应描述符的数组。
-
-
事件循环 : 程序主体是一个无限循环,每次循环调用
epoll_wait等待事件,然后遍历处理所有就绪的事件。这完美解决了本文开头提到的"阻塞困境":无论是新连接到达还是旧连接发来数据,都会作为一个"就绪事件"被epoll_wait返回,服务器得以在一个线程内有序处理所有I/O。 -
高性能根源 :
epoll的高效源于其设计。与多线程模型为每个连接分配独立执行单元的巨大开销相比,epoll模型仅在活跃连接有事件时才进行工作,极大地节省了资源。这正是文档中将其作为TCP并发服务器高效解决方案的原因
Linux系统的4种IO模型:
1. 阻塞IO
- 效率高,数据没来时,CPU阻塞等待,不占用CPU资源
2. 非阻塞IO
- 效率低,需要轮询是否有IO事件发生
3. 异步IO
- 当内核监测到有IO事件发生时,主动向应用层上报信号事件
4. 多路复用IO
- 用一个函数接口监听多个文件描述符是否产生IO事件,只要其中一个产生事件,则不再
阻塞,用户可以处理对应的事件
总结
构建TCP并发服务器的演进之路,是从"为每个连接创建资源"的多进程/线程模型,走向"用事件驱动管理所有连接"的多路复用模型。而epoll以其无上限、低开销、高效的事件通知机制,成为在Linux下实现高性能、高并发网络服务的基石。理解并掌握epoll模型,是每一位C语言后端/嵌入式网络开发者的必备技能。