关于select服务器(IO多路复用)的学习
学习自C++从0实现百万并发Reactor服务器_实战课程_慕课网
先给出完整代码,这个程序的核心功能是:
- 在指定的端口上监听客户端的连接。
- 可以同时处理多个客户端的连接。
- 当任何一个客户端发来消息时,服务器会把收到的消息原封不动地再发回给那个客户端。
- 它使用
select模型,使得单个线程就能管理所有的连接,而不会因为等待某个客户端而阻塞。
并体会sellect()的设计缺陷:
- 1024限制:
fd_set的大小通常被硬编码为1024,限制了并发连接数。 - 需要拷贝readfds
- 性能瓶颈: 每次都要进行线性扫描 (
for循环) 和fd_set的内核/用户空间拷贝,当连接数上千时,性能下降明显。
// 初始化服务端的监听端口。
int initserver(int port);
int main(int argc,char *argv[])
{
if (argc != 2) { printf("usage: ./tcpselect port
"); return -1; }
// 初始化服务端用于监听的socket。
int listensock = initserver(atoi(argv[1]));
printf("listensock=%d
",listensock);
if (listensock < 0) { printf("initserver() failed.
"); return -1; }
// 读事件:1)已连接队列中有已经准备好的socket(有新的客户端连上来了);
// 2)接收缓存中有数据可以读(对端发送的报文已到达);
// 3)tcp连接已断开(对端调用close()函数关闭了连接)。
// 写事件:发送缓冲区没有满,可以写入数据(可以向对端发送报文)。
fd_set readfds; // 需要监视读事件的socket的集合,大小为16字节(1024位)的bitmap。
FD_ZERO(&readfds); // 初始化readfds,把bitmap的每一位都置为0。
FD_SET(listensock,&readfds); // 把服务端用于监听的socket加入readfds。
int maxfd=listensock; // readfds中socket的最大值。
while (true) // 事件循环。
{
// 用于表示超时时间的结构体。
struct timeval timeout;
timeout.tv_sec=10; // 秒
timeout.tv_usec=0; // 微秒。
fd_set tmpfds=readfds; // 在select()函数中,会修改bitmap,所以,要把readfds复制一份给tmpfds,再把tmpfds传给select()。
// 调用select() 等待事件的发生(监视哪些socket发生了事件)。
int infds=select(maxfd+1,&tmpfds,NULL,NULL,0);
// 如果infds<0,表示调用select()失败。
if (infds<0)
{
perror("select() failed"); break;
}
// 如果infds==0,表示select()超时。
if (infds==0)
{
printf("select() timeout.
"); continue;
}
// 如果infds>0,表示有事件发生,infds存放了已发生事件的个数。
for (int eventfd=0;eventfd<=maxfd;eventfd++)
{
if (FD_ISSET(eventfd,&tmpfds)==0) continue; // 如果eventfd在bitmap中的标志为0,表示它没有事件,continue
// 如果发生事件的是listensock,表示已连接队列中有已经准备好的socket(有新的客户端连上来了)。
if (eventfd==listensock)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
int clientsock = accept(listensock,(struct sockaddr*)&client,&len);
if (clientsock < 0) { perror("accept() failed"); continue; }
printf ("accept client(socket=%d) ok.
",clientsock);
FD_SET(clientsock,&readfds); // 把bitmap中新连上来的客户端的标志位置为1。
if (maxfd0;ii--) // 从后面往前找。
{
if (FD_ISSET(ii,&readfds))
{
maxfd = ii; break;
}
}
}
}
else
{
// 如果客户端有报文发过来。
printf("recv(eventfd=%d):%s
",eventfd,buffer);
// 把接收到的报文内容原封不动的发回去。
send(eventfd,buffer,strlen(buffer),0);
}
}
}
}
return 0;
}
对于select(),需要把要监听的socket传给它。可以监控以下事件。
读事件:1)已连接队列中有已经准备好的socket(有新的客户端连上来了);
2)接收缓存中有数据可以读(对端发送的报文已到达);
3)tcp连接已断开(对端调用close()函数关闭了连接)。
写事件:发送缓冲区没有满,可以写入数据(可以向对端发送报文)。
下面来一步步拆解代码:
1.初始化阶段
// 初始化服务端用于监听的socket。
int listensock = initserver(atoi(argv[1]));
printf("listensock=%d
", listensock);
// ...
fd_set readfds; // 定义一个“主”fd集合,我们称之为“主监控列表”
FD_ZERO(&readfds); // 清空这个列表
FD_SET(listensock, &readfds); // 把监听socket这第一个需要关心的对象,加入到列表中
int maxfd = listensock; // 当前监控的所有fd中的最大值,目前只有listensock
readfds就像一个总的花名册,记录了所有我们需要关心的socket(包括监听socket和所有已连接的客户端socket)。maxfd是一个重要的优化。它告诉select函数只需要检查到哪个号码的socket即可,这样不必检查到FD_SETSIZE(1024)。
2.核心事件循环 while (true)
2.1为什么要复制一份传进select()?
fd_set tmpfds = readfds;
当select返回后,拿到的tmpfds已经被内核“污染”了。它不再是最初的“监视全集”,而变成了“已就绪的子集”。
select函数有一个非常重要的特性:它会修改你传入的fd_set集合!- 当
select返回时,你传入的tmpfds会被内核改写,里面只剩下那些真正发生了事件的socket。其他未发生事件的socket都会被从tmpfds中移除。 - 而我们的
readfds(主监控列表) 必须保持完整,记录着所有需要监控的socket。所以,每次循环都必须把readfds复制一份给tmpfds,然后把这个“临时工”tmpfds交给select去“蹂躏”。 -
select函数在设计时,为了追求极致的简洁,让fd_set这个参数身兼二职。1. 作为“输入”: 在调用
select之前,你通过FD_ZERO和FD_SET来构建一个fd_set(比如代码中的readfds)。这个fd_set的位图(bitmap)上,标记了所有你希望内核去监视的socket。2. 作为“输出”: 当
select函数阻塞结束并返回时,内核会直接修改你传进去的那份fd_set!它的修改规则是: - 对于那些没有发生事件的socket,内核会将其在
fd_set中对应的位(bit)从1清零。 - 对于那些已经发生事件的socket,内核会保留其对应的位为1。
2.2select的调用
int infds = select(maxfd + 1, &tmpfds, NULL, NULL, 0);
maxfd + 1: 内核需要检查的范围是[0, maxfd]。&tmpfds: 传入我们关心的读事件集合(的副本)。NULL, NULL: 我们在这个程序里不关心写事件和异常事件。0(作为最后一个参数): 这里的0是一个空指针(void *)0,等同于NULL。它告诉select无限期阻塞,直到有事件发生才返回。如果想设置超时,应该传递&timeout结构体的地址。
3.事件处理阶段
for (int eventfd = 0; eventfd <= maxfd; eventfd++)
线性扫描:
select只告诉你“有infds个事件发生了”,但没告诉你是哪些socket。- 所以,我们别无选择,只能从0开始,一直遍历到
maxfd,用FD_ISSET挨个去问:“是不是你?是不是你发生了事件?” - 这就是
select的主要性能瓶颈:O(n) 的轮询开销。即使只有1个socket活跃,也需要遍历所有maxfd个socket。
if (FD_ISSET(eventfd, &tmpfds) == 0) continue;
FD_ISSET用于检查eventfd是否在被select修改后的tmpfds集合中。如果不在,说明它没事件,跳过。
4.事件分发:新连接 vs. 客户端消息
程序通过判断eventfd是不是listensock来区分两种不同的读事件。
情况1:新连接
// ... accept() ...
int clientsock = accept(listensock, ...);
printf("accept client(socket=%d) ok.
", clientsock);
FD_SET(clientsock, &readfds); // 【关键】把新来的客户端加入“主监控列表”
if (maxfd < clientsock) maxfd = clientsock; // 【关键】更新maxfd
- 逻辑: 监听socket发生读事件,意味着“已连接队列”中有新的客户端在等待处理。
- 操作: 调用
accept()取出新连接,得到clientsock。然后,这个新的clientsock就成了我们下一个需要关心读事件的对象,所以必须用FD_SET将它加入到readfds(主列表)中,以便在下一次select循环时能被监控。同时,可能需要更新maxfd的值。
情况2:已连接的客户端事件 (else 分支)
char buffer[1024];
if (recv(eventfd, buffer, sizeof(buffer), 0) <= 0)
- 如何判断是有新数据可以读取还是连接断开了?
- 客户端socket的读事件有两种可能:有新数据,或者连接断开。
recv()函数的返回值是判断的唯一标准:> 0: 成功收到数据,返回值是数据长度。== 0: 这是一个明确的信号,表示对端(客户端)调用了close(),发送了FIN包,连接已正常关闭。< 0: 发生了错误,或者连接被异常重置。
- 所以
recv() <= 0是处理连接断开的通用写法。
// 连接断开的处理
close(eventfd); // 关闭socket,释放资源
FD_CLR(eventfd, &readfds); // 【关键】从“主监控列表”中移除
if (eventfd == maxfd) // 【关键】如果关闭的是当前最大的fd,需要重新计算maxfd
{
for (int ii = maxfd; ii > 0; ii--) {
if (FD_ISSET(ii, &readfds)) {
maxfd = ii; break;
}
}
}
- 逻辑: 客户端走了,我们就不再需要监控它了。
- 操作:
close()关闭连接,FD_CLR将其从readfds(主列表)中移除。 maxfd的维护: 这是一个非常细致的优化。如果不重新计算maxfd,即使最大的fd已经关闭,select和for循环仍然会扫描到那个无效的值,造成少量性能浪费。从后往前找是找到新的最大值的最快方法。







