在上一篇文章中我们简单提到了select,这篇文章我们就来详细介绍一下select,然后使用select来改写一下基于TCP实现的EchoServer
文章目录
- [1. select核心定位](#1. select核心定位)
- [2. 认识select函数](#2. 认识select函数)
- [3. 使用select改写TCP的EchoServer](#3. 使用select改写TCP的EchoServer)
- [4. 服务端主程序](#4. 服务端主程序)
- [5. select缺点](#5. select缺点)
1. select核心定位
select 是一个 I/O 多路复用 的等待与通知机制。它的核心任务是 "等" ------ 让应用进程能同时监控多个文件描述符(fd),并在其中任意一个或多个 fd 的 I/O 事件就绪时,通知上层应用。

事件:
- 什么叫可读?底层有数据,读事件就绪
- 什么叫可写?底层有空间,写事件就绪
fd 的默认事件状态:读事件通常不就绪(需等待数据到达);写事件通常默认就绪(只要缓冲区未满)。
读事件就绪 指当一个文件描述符(fd)对应的底层内核缓冲区已有数据可读,即数据已到达,上层应用可以立即进行读取操作而不会阻塞等待。例如,当网络套接字的接收缓冲区有数据到达时,该 fd 的读事件就变为"就绪"状态。
写事件就绪 指当一个文件描述符对应的底层内核缓冲区尚有空间可写,即应用可以立即向其中写入数据而不会因缓冲区满而阻塞。例如,当网络套接字的发送缓冲区未满时,该 fd 的写事件通常就是"就绪"的。
注意:虽然select函数历史悠久,但在一些系统中,它可能被更高效的替代品(如poll、epoll等)取代。然而,理解select仍然很重要,因为它广泛存在于现有代码中,并且它的概念对于理解其他多路复用技术有帮助。
2. 认识select函数
select函数的原型通常如下所示:
cpp
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
-
int nfds:指定待监视的文件描述符的范围,其值应为所有被监视描述符中的 最大值加1。这能帮助内核缩小检查范围,提升效率。 -
fd_set *readfds:指向一个文件描述符集合。应用程序关心从这些描述符中 读取数据 而不会发生阻塞。传入 NULL表示不关心任何描述符的可读事件。 -
fd_set *writefds:指向一个文件描述符集合。应用程序关心向这些描述符 写入数据 而不会发生阻塞。传入 NULL表示不关心任何描述符的可写事件。 -
fd_set *exceptfds:指向一个文件描述符集合。应用程序关心这些描述符上是否发生 异常条件(如带外数据)。传入 NULL表示不关心异常。 -
struct timeval *timeout:指定 select函数的 超时时间。其结构包含 tv_sec(秒)和 tv_usec(微秒)成员。此参数的行为有三种情况:- NULL: 无限期阻塞,直到有描述符就绪。
- 时间值设为0: 非阻塞检查,立即返回。
- 时间值大于0: 阻塞指定时间,超时后返回。
注意:struct timeval *timeout 是一个输入输出型参数,如果设置为5秒时,在第2秒就有文件描述符就绪时,select返回,该结构体返回剩余时间3秒
cpp
struct timeval {
time_t tv_sec; /* seconds */
suseconds_t tv_usec; /* microseconds */
};
select的返回值提供了关键信息:
-
返回值 > 0: 表示已就绪的文件描述符的 总数量。
-
返回值 == 0: 表示在超时时间内,没有任何被监视的文件描述符就绪。
-
返回值 == -1: 表示调用过程中 发生错误,此时可通过 errno获取具体错误原因。
关于fd_set :
fd_set可以理解为一个文件描述符的集合,在底层通常通过位图(bit mask)实现,每一位(bit)对应一个文件描述符(fd)。系统通过常量 FD_SETSIZE来定义单个 fd_set集合能容纳的最大文件描述符数量,通常为 1024。
- fd_set是输入输出型参数:在调用 select()时,你传入的 fd_set(如 readfds)作为输入参数,告诉内核你关心哪些文件描述符。当 select()返回时,内核会修改这个集合,只保留那些状态就绪的文件描述符,此时它作为输出参数。因此,每次调用 select()前,都必须重新设置(用 FD_ZERO和 FD_SET)你关心的 fd_set集合。
用户与内核的交互流程:
-
用户通过 FD_SET设置关心的描述符位,调用 select时,将该位图传给内核。
-
内核监视这些描述符,当事件发生时,修改位图,将就绪的描述符位保留,未就绪的清零。
-
select返回后,用户用 FD_ISSET遍历检查哪些位仍为 1,即可知哪些描述符就绪。
示例:
- 输入阶段(用户 -> 内核)
用户通过位图(比特位)告诉内核自己关心哪些文件描述符(fd)上的读事件。
-
比特位的位置:代表特定的文件描述符编号(如 0, 1, 2, 3, 7)。
-
比特位的值:
-
1:表示用户希望内核监视此描述符,例如是否有数据可读。
-
0:表示用户不关心此描述符。
-
假设输入:1000 1111
- 这表示用户请求内核同时监视描述符 0、1、2、3、7 上的读事件,不关心其他描述符。
- 内核处理阶段
- 内核接收到位图后,会监视用户指定的所有描述符(0, 1, 2, 3, 7)。在此期间,调用 select的进程会进入阻塞状态,直到有事件发生或超时。
- 输出阶段(内核 -> 用户)
内核在返回时,会修改同一个位图 ,告诉用户哪些之前被关心的描述符上的读事件已经就绪。
-
比特位的位置:依旧代表描述符编号。
-
比特位的值:
-
1:表示此描述符上用户关心的事件已就绪(例如,确实有数据可读了)。
-
0:表示此描述符上用户关心的事件未就绪。
-
假设输出:0000 0010
- 这表示在用户关心的描述符(0, 1, 2, 3, 7)中,只有描述符1上的读事件就绪了,可以立即进行无阻塞的读取操作。其他描述符(0, 2, 3, 7)此时没有数据可读。
关于fd_set的细节:
- fd_set是系统提供的数据结构,本质是位图,大小固定(通常为1024位或更小),因此select能监控的fd数量有限。
- 由于是输入输出参数,调用后其内容会被修改,因此通常需要在每次调用前重新设置。
fd_set操作宏
虽然 fd_set在底层通过位图(bit array)实现,但直接操作这些位是危险且不可移植的。使用 POSIX 标准定义的宏是唯一安全、可靠的方法。
cpp
void FD_ZERO(fd_set *set); // 清空集合
void FD_SET(int fd, fd_set *set); // 添加fd到集合
void FD_CLR(int fd, fd_set *set); // 从集合移除fd
int FD_ISSET(int fd, fd_set *set); // 检查fd是否在集合中
关于"同时关心读写"的问题:
- 可以同时将同一个fd加入readfds和writefds集合中,这样select会同时监听该fd的读和写事件。
- 例如:
cpp
FD_SET(fd, &readfds);
FD_SET(fd, &writefds);
-
此时,如果fd可读或可写,select都会返回,并且对应的集合位会被置位。
-
如果希望优先处理读事件,可以在select返回后先检查readfds,再检查writefds。但select本身不保证顺序,需用户自行处理逻辑。
-
如果关心异常事件,可以将fd加入exceptfds集合。
3. 使用select改写TCP的EchoServer
这里我们改写是学习怎么去使用select,帮助我们更好理解select,不过我们在改写的时候只处理读取,因为我们刚开始使用select,读写都处理的话比较复杂,对于第一次学习不太友好,不过等后面介绍epoll时,再将读写都处理。但是学会了处理读取,写入也就同理能够理解。
我们直接复用之前的代码,只需要使用select改写服务器代码即可

cpp
#pragma once
#include <iostream>
#include <memory>
#include <unistd.h>
#include "Socket.hpp"
using namespace SocketModule;
class SelectServer
{
public:
SelectServer(int port):_listensock(std::make_unique<TcpSocket>()), _isrunning(false)
{
_listensock->BuildTcpSocketMethod(port);
}
void Start()
{
_isrunning = true;
while(_isrunning)
{
// auto res = _listensock->Accept(); // 我们在这里,可以进行accept吗?
// 因为: listensockfd,也是一个fd,进程怎么知道listenfd上面有新连接到来了呢?
}
_isrunning = false;
}
~SelectServer() {}
private:
std::unique_ptr<Socket> _listensock;
bool _isrunning;
}
之前我们在创建套接字、绑定套接字、监听套接字之后,是需要accept接受连接的。但是,接受连接是会阻塞的,因为在没有连接到来时是会阻塞等待的,
服务器程序刚启动时,通常只有一个关键的文件描述符(fd)------ 监听套接字(listensockfd)。它由 socket()创建,并通过 bind()和 listen()设定为监听状态,等待客户端连接。
accept()系统调用本身是阻塞IO。这意味着,如果直接调用 accept()而没有新连接在排队,进程将会一直阻塞(睡眠),直到有客户端发起连接。
对于监听套接字 而言,所谓的"读事件就绪 "并非指有数据可读,而是特指有新的连接请求到达 (已完成 TCP 三次握手的连接请求已到达内核的未完成连接队列),即 accept()可以立即无阻塞地返回。
所以,新连接到来 = 监听套接字的读事件就绪
但是,我们这里采用多路转接的IO模式,就需要让select帮我们等
所以,我们需要:
-
将 listensockfd加入 select的读事件监视集合(readfds)。
-
select会持续监控所有被监视的 fd,当 listensockfd的"读事件"变为就绪状态时,select会立即返回。
cpp
void Start()
{
_isrunning = true;
while(_isrunning)
{
// auto res = _listensock->Accept(); // 我们在这里,可以进行accept吗?
// 因为: listensockfd,也是一个fd,进程怎么知道listenfd上面有新连接到来了呢?
// accept()系统调用本身是阻塞IO。如果直接调用 accept(),进程会阻塞直到有新连接到达。但通过 select,我们可以将这种"被动阻塞等待"转为"主动事件通知"。
// 将 listensockfd加入 select的读事件监视集合(readfds)。
// select会持续监控所有被监视的 fd,当 listensockfd的"读事件"变为就绪状态时,select会立即返回。
fd_set rfds; // 定义读事件集合
FD_ZERO(&rfds); // 初始化
FD_SET(_listensock->Fd(), &rfds); // // 此时还没有设置到内核中
// 我们知道rfds是输入输出参数,调用后其内容会被修改
// 但是我们在循环中,每次rfds都会被重置清零,那select还怎么去关心之前的事件呢
// 因为select可以一次关心不止1个文件描述符,此时要是只有一个文件描述符就绪,那么其他需要关心的文件描述符就会丢失
// 所以我们需要存储交给OS之前的读事件集合------使用辅助数组
int n = select(_listensock->Fd()+1, &rfds, nullptr, nullptr, nullptr);
switch (n)
{
case -1:
LOG(LogLevel::ERROR) << "select error ...";
break;
case 0:
LOG(LogLevel::INFO) << "time out ...";
break;
default:
// 有事件就绪, 就不仅仅是新连接到来, 还有可能是读事件就绪
LOG(LogLevel::DEBUG) << "有事件就绪了..., 事件个数n : " << n;
break;
}
}
_isrunning = false;
}
我们知道rfds是输入输出参数,调用后其内容会被修改,但是我们在循环中,每次rfds都会被重置清零,那select还怎么去关心之前的事件呢?
因为select可以一次关心不止1个文件描述符,此时要是只有一个文件描述符就绪返回之后,那么其他需要关心的文件描述符就会丢失,所以我们需要存储交给OS之前的读事件集合------使用辅助数组
cpp
class SelectServer
{
const static int size = sizeof(fd_set) * 8;
const static int defaultfd = -1;
public:
SelectServer(int port):_listensock(std::make_unique<TcpSocket>()), _isrunning(false)
{
_listensock->BuildTcpSocketMethod(port);
for(int i = 0; i < size; i++)
_fd_array[i] = defaultfd;
_fd_array[0] = _listensock->Fd(); // 默认将 listensockfd 添加到 _fd_array 开头
}
void Start()
{
_isrunning = true;
while(_isrunning)
{
// auto res = _listensock->Accept(); // 我们在这里,可以进行accept吗?
// 因为: listensockfd,也是一个fd,进程怎么知道listenfd上面有新连接到来了呢?
// accept()系统调用本身是阻塞IO。如果直接调用 accept(),进程会阻塞直到有新连接到达。但通过 select,我们可以将这种"被动阻塞等待"转为"主动事件通知"。
// 将 listensockfd加入 select的读事件监视集合(readfds)。
// select会持续监控所有被监视的 fd,当 listensockfd的"读事件"变为就绪状态时,select会立即返回。
fd_set rfds; // 定义读事件集合
FD_ZERO(&rfds); // 初始化
// FD_SET(_listensock->Fd(), &rfds); // // 此时还没有设置到内核中
// 我们知道rfds是输入输出参数,调用后其内容会被修改
// 但是我们在循环中,每次rfds都会被重置清零,那select还怎么去关心之前的事件呢
// 因为select可以一次关心不止1个文件描述符,此时要是只有一个文件描述符就绪,那么其他需要关心的文件描述符就会丢失
// 所以我们需要存储交给OS之前的读事件集合------使用辅助数组
int max_fd = defaultfd;
for(int i = 0; i < size; i++)
{
if(_fd_array[i] == defaultfd)
continue;
FD_SET(_fd_array[i], &rfds);
max_fd = std::max(max_fd, _fd_array[i]);// 更新最大文件描述符
}
PrintFd();
int n = select(max_fd+1, &rfds, nullptr, nullptr, nullptr);
switch (n)
{
case -1:
LOG(LogLevel::ERROR) << "select error ...";
break;
case 0:
LOG(LogLevel::INFO) << "time out ...";
break;
default:
// 有事件就绪, 就不仅仅是新连接到来, 还有可能是读事件就绪
LOG(LogLevel::DEBUG) << "有事件就绪了..., 事件个数n : " << n;
break;
}
}
_isrunning = false;
}
void PrintFd()
{
std::cout << "_fd_array[]: ";
for (int i = 0; i < size; i++)
{
if (_fd_array[i] == defaultfd)
continue;
std::cout << _fd_array[i] << " ";
}
std::cout << "\r\n";
}
~SelectServer() {}
private:
std::unique_ptr<Socket> _listensock;
bool _isrunning;
int _fd_array[size];
};
select等待事件就绪后,此时我们就需要处理事件,但是,我们不知道具体是哪个事件就绪,有可能是新连接到来,也有可能是普通的读事件就绪,以后还有可能是写事件就绪,异常就绪等(这里我们只处理读,所以不需要关心其余事件)
所以我们需要根据不同的事件来派发不同的任务处理
cpp
void Start()
{
_isrunning = true;
while(_isrunning)
{
// auto res = _listensock->Accept(); // 我们在这里,可以进行accept吗?
// 因为: listensockfd,也是一个fd,进程怎么知道listenfd上面有新连接到来了呢?
// accept()系统调用本身是阻塞IO。如果直接调用 accept(),进程会阻塞直到有新连接到达。但通过 select,我们可以将这种"被动阻塞等待"转为"主动事件通知"。
// 将 listensockfd加入 select的读事件监视集合(readfds)。
// select会持续监控所有被监视的 fd,当 listensockfd的"读事件"变为就绪状态时,select会立即返回。
fd_set rfds; // 定义读事件集合
FD_ZERO(&rfds); // 初始化
// FD_SET(_listensock->Fd(), &rfds); // // 此时还没有设置到内核中
// 我们知道rfds是输入输出参数,调用后其内容会被修改
// 但是我们在循环中,每次rfds都会被重置清零,那select还怎么去关心之前的事件呢
// 因为select可以一次关心不止1个文件描述符,此时要是只有一个文件描述符就绪,那么其他需要关心的文件描述符就会丢失
// 所以我们需要存储交给OS之前的读事件集合------使用辅助数组
int max_fd = defaultfd;
for(int i = 0; i < size; i++)
{
if(_fd_array[i] == defaultfd)
continue;
FD_SET(_fd_array[i], &rfds);
max_fd = std::max(max_fd, _fd_array[i]);// 更新最大文件描述符
}
PrintFd();
int n = select(max_fd+1, &rfds, nullptr, nullptr, nullptr);
switch (n)
{
case -1:
LOG(LogLevel::ERROR) << "select error ...";
break;
case 0:
LOG(LogLevel::INFO) << "time out ...";
break;
default:
// 有事件就绪, 就不仅仅是新连接到来, 还有可能是读事件就绪
LOG(LogLevel::DEBUG) << "有事件就绪了..., 事件个数n : " << n;
Dispatcher(rfds); // 处理就绪的事件
break;
}
}
_isrunning = false;
}
// 事件派发器
void Dispatcher(fd_set rfds)
{
// 此时读事件集合rfds被OS修改为哪些文件描述符已经就绪
// 有事件就绪, 不仅仅是新连接到来, 还有可能是读事件就绪
// 指定的文件描述符,在rfds里面,就证明该fd就绪了
for(int i = 0; i < size; i++)
{
if(_fd_array[i] == defaultfd)
continue;
// fd合法,但不一定就绪
if(FD_ISSET(_fd_array[i], &rfds))
{
// 此时事件就绪,是新连接到来,还是读事件就绪?
if(_fd_array[i] == _listensock->Fd())
{
// 如果是监听套接字就绪,那就是新连接到来
Accepter();
}
else
{
// 读事件就绪
Recver(_fd_array[i], i);
}
}
}
}
如果是新连接到来,那我们就需要accept接受连接
在前面学习过accept系统调用后,我们知道socket创建的listensockfd是监听套接字,是负责监听是否有连接到来,不直接传输数据,而accept返回的连接套接字sockfd则是负责与建立连接的客户端进行数据传输的。
那此时建立连接成功后,我们可以直接读取数据(read/recv)吗?
当然不可以!因为读取数据也会阻塞,那么只要数据还没有就绪就需要等,所以我们不能直接读取数据,我们要让select来帮我们等。所以我们可以把连接套接字存放在辅助数组中,在下次循环时设置在rfds集合中,让select帮我们关心。
cpp
// 连接管理器
void Accepter()
{
// 新连接到来,我们需要accept接受新连接
InetAddr client;
int sockfd = _listensock->Accept(&client);
if(sockfd >= 0)
{
// 获取新链接到来成功, 然后呢??能不能直接read/recv()
// 当然不行,sockfd是否读就绪,我们不清楚
// 只有谁最清楚,未来sockfd上是否有事件就绪?肯定是select!
// 所以我们需要将新的sockfd,托管给select!
// 如何托管? 将新的fd放入辅助数组!
LOG(LogLevel::INFO) << "get a new link, sockfd: " << sockfd << ", client is: " << client.StringAddr();
int pos = 0;
for( ; pos < size; pos++)
{
if(_fd_array[pos] == defaultfd)
break;
}
if(pos == size)
{
LOG(LogLevel::WARNING) << "select server full ...";
close(sockfd);
}
else
{
_fd_array[pos] = sockfd;
}
}
}
如果是读事件就绪,这个时候我们就可以读取数据了,那么这个时候recv读取数据就不会阻塞了
但是我们这里有bug,因为tcp是流式数据,我们并不能读取一个完整的请求,不过我们后面介绍epoll的时候再处理,这里先不做处理
cpp
// IO处理器
void Recver(int fd, int pos)
{
// 处理 sockfd 读事件
// 我们在这里读取的时候,就不会阻塞了 --- 因为 select 已经完成等操作了!
char buffer[1024];
size_t n = recv(fd, buffer, sizeof(buffer)-1, 0);
// recv 读的时候会有bug!因为无法保证能够读取到一个完整的请求!--- TCP 是流式协议!
// 我们目前先不做处理,等到 epoll 的时候,再做处理!
if(n > 0)
{
buffer[n] = 0;
std::cout << "client say# " << buffer << std::endl;
}
else if(n == 0)
{
LOG(LogLevel::INFO) << "client quit ...";
// 此时不再需要让select帮我们再关心fd
_fd_array[pos] = defaultfd;
// 关闭fd
close(fd);
}
else
{
LOG(LogLevel::ERROR) << "recv error ...";
// 此时不再需要让select帮我们再关心fd
_fd_array[pos] = defaultfd;
// 关闭fd
close(fd);
}
}
4. 服务端主程序
cpp
#include "SelectServer.hpp"
int main(int argc, char *argv[])
{
if (argc != 2)
{
std::cout << "Usage: " << argv[0] << " port" << std::endl;
exit(USAGE_ERR);
}
Enable_Console_Log_Strategy();
uint16_t port = std::stoi(argv[1]);
std::unique_ptr<SelectServer> svr = std::make_unique<SelectServer>(port);
svr->Start();
return 0;
}
运行结果 :

5. select缺点
-
需要手动设置 fd 集合:接口使用不便,编程繁琐且易出错。每次调用 select前,都必须使用 FD_ZERO和 FD_SET等宏重新构建需要监控的文件描述符集合(fd_set)。这是因为 select返回后,内核会修改传入的 fd_set,只保留就绪的描述符
-
内核态/用户态拷贝开销大:性能瓶颈。每次调用 select时,都需要将整个 fd_set 从用户空间拷贝到内核空间;返回时,内核又需要将修改后的 fd_set 拷回用户空间。当监控大量文件描述符(fd)时,这种频繁的内存拷贝会带来显著的 CPU 开销。
-
内核线性扫描所有 fd:效率随 fd 数量增加而线性下降。无论文件描述符是否活跃,select在内核中都需要线性遍历整个传入的 fd_set 集合,以检查每个 fd 的状态。这是一种 O(n) 时间复杂度的操作,在并发连接数很高但只有少量连接活跃时,这种无差别的轮询会浪费大量 CPU 时间。
-
支持的文件描述符数量有限:制约高并发场景。select能监控的文件描述符数量有上限,通常由编译时常量 FD_SETSIZE决定(默认通常是 1024)。这对于需要处理成千上万并发连接的高性能网络服务器来说是一个严重的限制。
正是由于 select的上述缺点,我们就需要使用另一种多路转接的方案------poll,下一篇我们就来介绍poll。