1. 五种IO模型
1. 阻塞 I/O (Blocking IO) ------ "死等的大爷"
-
形象比喻 :大爷去快递站取件,快递还没到。大爷就在窗口死死守着,啥也不干,谁叫也不理,直到快递送到手里才回公司。
-
特点:效率最低,一个大爷只能等一个快递。
2. 非阻塞 I/O (Non-blocking IO) ------ "多动症的大爷"
-
形象比喻:大爷每隔 5 分钟跑一趟快递站,问:"货到了吗?" 没到就回公司扫个地,5 分钟后再来问。
-
特点:大爷很累(CPU 占用高),虽然能抽空干点别的,但大部分时间在跑冤枉路。
3. I/O 多路复用 (IO Multiplexing) ------ "带清单的大爷"
-
形象比喻 :这就是我们之前说的
select/epoll。大爷手里有一张 "取件清单" (100 个快递)。他在快递站坐着,只要这 100 个快递里任何一个到了,快递员喊一声,大爷就跳起来去处理。 -
特点:一个人能盯住成千上万个连接,是目前互联网高并发的主流。
4. 信号驱动 I/O (Signal Driven IO) ------ "带传呼机的大爷"
-
形象比喻 :大爷给快递员留了个 "传呼机号码",然后回公司喝茶去了。货一到,快递员呼叫大爷,大爷再跑过去取货。
-
特点 :大爷不用轮询,也不用死等,但"取货(把数据从内核搬到用户空间)"这个动作还得大爷亲自跑一趟。
5. 异步 I/O (Asynchronous IO) ------ "甩手掌柜大爷"
-
形象比喻 :大爷直接给快递员留了地址和钥匙,说:"货到了直接送进我办公室,放好后再告诉我。" 货到的时候,大爷在睡觉,等他醒来,货已经在桌子上了。
-
特点 :真·全自动。大爷连"搬运"的过程都不参与,效率最高,但实现最复杂(Linux 下常用
io_uring)。
| 模型 | 别名 | 大爷的状态 | 数据谁搬运? |
|---|---|---|---|
| 阻塞 | BIO | 在窗口干等,啥也不干 | 大爷自己搬 |
| 非阻塞 | NIO | 没到就走,过会儿再来问 | 大爷自己搬 |
| 多路复用 | IO Multiplexing | 拿着清单等,谁到了处理谁 | 大爷自己搬 |
| 信号驱动 | Signal IO | 等快递员打传呼 | 大爷自己搬 |
| 异步 | AIO | 就在家躺着,快递员送上门 | 快递员送上门 |
2. select函数
2.1 基本定义
select 允许程序同时监控多个文件描述符(fd),直到其中一个或多个 fd 状态发生变化(可读、可写或异常)时返回。
函数原型:
cpp
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
2.2 参数详解
| 参数名 | 数据类型 | 通俗含义 | 详细功能与注意事项 |
|---|---|---|---|
nfds |
int |
监控范围 | 填入所有监控对象中最大的文件描述符(fd)值 + 1。内核会检查从 0 到 nfds-1 的所有位。 |
readfds |
fd_set* |
读集合 | 监控是否有新数据可读 、新连接请求 或连接已关闭。最常用的参数。 |
writefds |
fd_set* |
写集合 | 监控网络发送缓冲区是否有空间。若可发送数据且不阻塞,则该位会被置位。 |
exceptfds |
fd_set* |
异常集合 | 监控异常情况(如 TCP 带外数据/紧急数据)。普通业务逻辑中较少用到。 |
timeout |
timeval* |
等待时长 | 它告诉内核,如果没有任何一个文件描述符就绪,最长允许阻塞(挂起)多长时间。 |
readfds、writefds、exceptfds是输入输出型参数,含义如下:
参数输入:用户告诉内核,你要关心哪些fd上的事件;
比特位位置表示fd编号 ,比特位的内容表示是否关心。
参数输出:内核告诉用户,你让我关心的这些fd上面的事件已经就绪了;
比特位位置表示fd编号 ,比特位内容表示是否就绪。
**timeout参数:**它是系统通用的时间结构体,如下
cpp
struct timeval
{
long tv_sec; // 秒 (seconds)
long tv_usec; // 微秒 (microseconds) ------ 1秒 = 1,000,000微秒
};
-
计算公式 :总时长 =
tv_sec(秒) +tv_usec(微秒)。 -
注意 :在给
tv_usec赋值时,规范做法是不要超过999,999。如果你需要等待 1.5 秒,请写成{1, 500000}而不是{0, 1500000},虽然有些内核能兼容,但前者是标准写法。
| 赋值状态 | 等待模式 | 形象比喻 | 行为描述 |
|---|---|---|---|
NULL |
永久阻塞 | "死等" | 程序停在 select 处,直到监控的 fd 有动静才唤醒。不占 CPU。 |
== 0 |
非阻塞 (轮询) | "瞄一眼就走" | 结构体成员全为 0。内核立即检查一遍 fd 状态并返回,极其消耗 CPU。 |
> 0 |
限时阻塞 | "定个闹钟" | 在指定时间内等待。有动静则提前返回 ,时间到仍没动静则超时返回。 |
2.3 函数返回值
当 select 执行完毕,其返回值会告诉我们等待的结果:
-
返回
> 0:表示就绪的文件描述符个数(有人说话了)。 -
返回
== 0:表示超时(时间到了,但谁也没说话)。 -
返回
< 0:表示出错 (通常是收到系统信号被中断,errno为EINTR)。
2.4 四个核心宏(操作 fd_set)
因为**fd_set这个清单是个复杂的位图**,你不能直接用等号赋值,必须用四个"小工具":
| 宏名称 | 语法示例 | 详细功能说明 |
|---|---|---|
FD_ZERO |
FD_ZERO(&set); |
初始化集合,把位图里的所有位都设为 0。使用集合前的第一步必须是这个。 |
FD_SET |
FD_SET(fd, &set); |
将指定的文件描述符 fd 加入名单。告诉内核:"帮我盯着这个人的动静。" |
FD_CLR |
FD_CLR(fd, &set); |
将指定的文件描述符 fd 从名单中删除。以后内核就不再管这个 fd 了。 |
FD_ISSET |
FD_ISSET(fd, &set); |
重点: 在 select 返回后调用。用来检查某个 fd 是否还在名单里(即是否有动静)。 |
关于**FD_ISSET:**调用时机决定了它的"身份"
在 select 之前调用:检查"是否参加监控"
- 这通常用于调试,确认
FD_SET是否生效了。
在 select 之后调用:检查"是否就绪"(最核心用法)
这是它 99% 的使用场景。为什么此时它代表"就绪"呢?
-
输入时 :你交给内核的清单里,1, 2, 3 号全是 1(表示都要监控)。
-
内核处理:内核发现只有 2 号有数据,1 和 3 都没动静。
-
返回时 :内核会把清单里的 1 和 3 抹掉(变成 0 ),只把 2 留在里面(保持为 1)。
-
判断 :此时你调用
FD_ISSET(2, &set),结果为真。这就意味着:"2 号不仅在集里,而且它是被内核'特意留下来'的,说明它有动静了。"
3. 实际运用
3.1 获得新连接
cpp
// selectServer.hpp文件
class SelectServer
{
public:
SelectServer(uint16_t port) : _listensock(std::make_unique<TcpSocket>()),
_isrunning(false)
{
_listensock->BuildListenSocketMethod(port);
}
void Start()
{
if (!_isrunning)
_isrunning = true;
while (true)
{
fd_set rfds; // 定义文件描述符集
FD_ZERO(&rfds); // 先清空rfds
FD_SET(_listensock->Fd(), &rfds); // 将listensock增加到rfds里去,此处并非设置进了内核
struct timeval timeout = {2, 0}; // 设置超时时间,2秒
// 设置进内核(暂时这样写)
int n = select(_listensock->Fd() + 1, &rfds, nullptr, nullptr, &timeout);
switch (n)
{
case -1:
LOG(LogLevel::ERROR) << "select error";
break;
case 0:
LOG(LogLevel::WARNING) << "select time out ...";
break;
default:
LOG(LogLevel::DEBUG) << "读事件就绪...";
break;
}
}
_isrunning = false;
}
~SelectServer() {}
private:
std::unique_ptr<Socket> _listensock;
bool _isrunning;
};
cpp
// Main.cc文件
#include "selectServer.hpp"
int main(int argc, char *argv[])
{
if (argc != 2)
{
std::cout << "Usage: " << argv[0] << " port" << std::endl;
exit(ExitCode::USAGE_ERR);
}
uint16_t port = std::stoi(argv[1]);
LogToConsole();
std::unique_ptr<SelectServer> svr = std::make_unique<SelectServer>(port);
svr->Start();
return 0;
}
现象:现在没有读事件就绪,所以每隔2秒显示超时一下。

用telnet连接一下:
cpp
telnet 127.0.0.1 8081
现象:此时我们并没有用accept处理这个链接,所以会一直打印有链接来了。

现在我们有事件就绪了,我们就要处理这个已经就绪的事件。
cpp
// Socket.hpp 文件
int Accept(InetAddr *client) override
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int fd = ::accept(_sockfd, CONV(peer), &len);
if (fd < 0)
{
LOG(LogLevel::WARNING) << "accept warning...";
return -1;
}
return fd;
}
cpp
//selectServer.pp文件
void Start()
{
if (!_isrunning)
_isrunning = true;
while (true)
{
fd_set rfds; // 定义文件描述符集
FD_ZERO(&rfds); // 先清空rfds
FD_SET(_listensock->Fd(), &rfds); // 将listensock增加到rfds里去,此处并非设置进了内核
// struct timeval timeout = {2, 0}; // 设置超时时间,2秒
// 设置进内核(暂时这样写)
// 等待时间设为nullptr之后这里就相当于阻塞等待了,此处为演示现象
int n = select(_listensock->Fd() + 1, &rfds, nullptr, nullptr, nullptr);
switch (n)
{
case -1:
LOG(LogLevel::ERROR) << "select error";
break;
case 0:
LOG(LogLevel::WARNING) << "select time out ...";
break;
default:
LOG(LogLevel::DEBUG) << "读事件就绪..., n = " << n;
handlerEvent(); // 处理事件
break;
}
}
_isrunning = false;
}
void handlerEvent()
{
InetAddr client;
// 此时accept获取链接的时候就不是阻塞的了
int fd = _listensock->Accept(&client);
if (fd >= 0)
{
LOG(LogLevel::DEBUG) << "获得一个链接,fd = " << fd;
}
}
现象:这个连接被accept之后,相当于被处理了,就不会一直显示事件就绪了,此时就是将连接从内核拿到用户层了。

3.2 辅助数组
我们之前聊过的,select 的第二、三、四参数是输入输出型参数,既负责告诉内核"我要监控谁",又负责接收内核的反馈"谁就绪了"。
-
调用前(输入):你设置了 1, 2, 3 号。
-
调用后(输出) :如果只有 2 号有消息,内核会把 1 和 3 从位图中抹去 。此时,你的
readfds里面只剩下 2 号了。
当你想进行第二次 select 监控时,你手里的清单已经只剩 2 号了,1 号和 3 号彻底"失联",其实就是**select 会"破坏"你传给它的原始数据。**
所以此时我们就需要借助一个额外的数组帮我们记录下历史上所有连接的fd ,这个数组就是select的辅助数组(或者叫备份数组)。
3.2.1 定义并初始化辅助数组
我们就设置辅助数组的大小为fd_set*8,并且设置一个缺省的文件描述符为-1。
cpp
const static int size = sizeof(fd_set) * 8;
const static int defaultfd = -1;
cpp
private:
std::unique_ptr<Socket> _listensock;
bool _isrunning;
int _fd_array[size]; //辅助数组
我们在构造的时候就将这个辅助数组全部位置都初始化为-1。
3.2.2 重置rfds并更新出maxfd
- select的第一个参数要传的是监控对象中最大的文件描述符(fd)值 + 1,我们现在不知道这个最大是多少,但这个值未来一定是变化的,不可能就是之前传的那个listensocket;
- 因为这个文件描述符集rfds被select修改或是被用户修改,这都是变化的,所以要求每次select之前都要对这个rfds进行重置;
所以就意味着未来每一个"监控"的文件描述符都要存放在这个辅助数组里,包括listen套接字。这里就选择将listen套接字默认放在数字的0号下标,存放的值就是fd编号。
cpp
public:
SelectServer(uint16_t port) :
_listensock(std::make_unique<TcpSocket>()),
_isrunning(false)
{
_listensock->BuildListenSocketMethod(port);
for (int i = 0; i < size; i++) // 初始化辅助数组
{
_fd_array[i] = defaultfd;
}
_fd_array[0] = _listensock->Fd(); // listen套接字放在0号下标
}
在select之前我们要把辅助数组里的所有有效的fd全部添加到这个文件描述符集rfds里,也就是重置rfds,同时还要更新出最大的fd。
cpp
void Start()
{
if (!_isrunning)
_isrunning = true;
while (true)
{
int maxfd = defaultfd; // 定义最大fd
fd_set rfds; // 定义文件描述符集
FD_ZERO(&rfds); // 先清空rfds
for (int i = 0; i < size; i++)
{
if (_fd_array[i] == defaultfd) // 无效就跳过
continue;
// 重置rfds :将辅助数组里的有效fd添加到rfds
FD_SET(_fd_array[i], &rfds);
// 更新最大fd
if (maxfd < _fd_array[i])
maxfd = _fd_array[i];
}
}
// TUDO
}
3.2.3 处理新到来的套接字
listen套接字获取到新的sockfd之后我们还不能直接用recv或read读取,因为有的时候对方可能只是建立了连接,并没有往里写入东西,所以获得新sockfd之后我们还是要将这个sockfd继续交给select,让select托管。
用pos记录辅助数组中空闲位置的下标,pos出循环后有两种情况:
- pos等于size:select被打满了,无法再处理sockfd
- pos小于size:直接加入到辅助数组中去
cpp
void handlerEvent()
{
InetAddr client;
// 此时accept获取链接的时候就不是阻塞的了
int sockfd = _listensock->Accept(&client);
if (sockfd >= 0)
{
LOG(LogLevel::DEBUG) << "获得一个链接,sockfd = " << sockfd;
int pos = 0; // 记录一下新的sockfd存放的下标
while (pos < size)
{
// 找到辅助数组的空位置存放新的sockfd
if (_fd_array[pos++] == defaultfd)
break;
}
if (pos >= size)
{
LOG(LogLevel::WARNING) << "select full...";
close(sockfd);
}
else
{
_fd_array[pos] = sockfd; // 将新的sockfd加入到辅助数组中
}
}
}
这里顺便打印一下这个数组,写一个函数,如下。
cpp
void printFdArr()
{
std::cout << "fd_array: ";
for (int i = 0; i < size; i++)
{
if (_fd_array[i] == defaultfd)
continue;
std::cout << _fd_array[i] << " ";
}
std::cout << std::endl;
}
Start完整部分如下:
cpp
void Start()
{
if (!_isrunning)
_isrunning = true;
while (true)
{
int maxfd = defaultfd; // 定义最大fd
fd_set rfds; // 定义文件描述符集
FD_ZERO(&rfds); // 先清空rfds
for (int i = 0; i < size; i++)
{
if (_fd_array[i] == defaultfd) // 无效就跳过
continue;
// 重置rfds :将辅助数组里的有效fd添加到rfds
FD_SET(_fd_array[i], &rfds);
// 更新最大fd
if (maxfd < _fd_array[i])
maxfd = _fd_array[i];
}
printFdArr();
int n = select(maxfd + 1, &rfds, nullptr, nullptr, nullptr);
switch (n)
{
case -1:
LOG(LogLevel::ERROR) << "select error";
break;
case 0:
LOG(LogLevel::WARNING) << "select time out ...";
break;
default:
LOG(LogLevel::DEBUG) << "事件就绪..., n = " << n;
handlerEvent(); // 处理事件
break;
}
}
_isrunning = false;
}
3.3 对到来的连接做区分
在这个情况下,连接有2种,一种是listensocket,另一种就是普通的读事件,在处理链接的时候要对到来的连接做区分,之前我们在HandlerEvent部分一直都是处理的listen套接字。
- 遍历这个辅助数组,先找出辅助数组中合法的fd
- 判断合法的fd是否在rfds中,也就是判断是否就绪,用FD_ISSET
- 分情况讨论:listensocket的新连接到来 ,还是sockfd的读事件就绪了
下面的Accepter就是前面我们实现的那些,重新做了封装,把HandlerEvent名字改成DispatchEvent。
cpp
void DispatchEvent(fd_set &rfds)
{
for (int i = 0; i < size; i++)
{
if (_fd_array[i] == defaultfd) // 判断fd是否合法
continue;
// fd合法,但是不一定就绪
// 这里的FD_ISSET是放在select调用后使用的,用来判断事件是否就绪
if (FD_ISSET(_fd_array[i], &rfds))
{
if (_fd_array[i] == _listensock->Fd())
{
// listensocket新连接到来
Accepter();
}
else
{
// socketfd普通读事件就绪
Recver();
}
}
}
}
void Accepter()
{
InetAddr client;
// 此时accept获取链接的时候就不是阻塞的了
int sockfd = _listensock->Accept(&client);
if (sockfd >= 0)
{
LOG(LogLevel::DEBUG) << "获得一个链接,sockfd = " << sockfd;
int pos = 0; // 记录一下新的sockfd存放的下标
while (pos < size)
{
// 找到辅助数组的空位置存放新的sockfd
if (_fd_array[pos++] == defaultfd)
break;
}
if (pos >= size)
{
LOG(LogLevel::WARNING) << "select full...";
close(sockfd);
}
else
{
_fd_array[pos] = sockfd; // 将新的sockfd加入到辅助数组中
}
}
}
void Recver()
{
// TUDO
}
Recver函数的实现
Recver函数是在DispatchEvent函数里被调用的,Recver的参数就可以传就绪的这个文件描述符的下标,方便函数从辅助数组里获取到fd。
- 正常读取:这里做简单处理,打印一下读到的内容即可
- 客户端退出:我们不需要再关注这个fd了,关闭对应的fd,并且将这个fd从辅助数组中移除
- 读出错:也不需要再关注这个fd了,关闭对应的fd,并且将这个fd从辅助数组中移除
cpp
void Recver(int pos)
{
// 此时用recv/read读取数据的时候就不会被阻塞了
char buffer[1024] = {0};
int n = recv(_fd_array[pos], buffer, sizeof(buffer) - 1, 0);
if (n > 0)
{
// 将数据都当字符串处理
buffer[n] = 0;
LOG(LogLevel::INFO) << "client say@ " << buffer; // 回显一下
}
else if (n == 0)
{
// 此时就是客户端关闭了
LOG(LogLevel::INFO) << "client quit...";
close(_fd_array[pos]); // 关闭文件描述符
_fd_array[pos] = defaultfd; // 从辅助数组中移除
}
else
{
// 读出错
LOG(LogLevel::ERROR) << "recv error...";
close(_fd_array[pos]); // 关闭文件描述符
_fd_array[pos] = defaultfd; // 从辅助数组中移除
}
}
客户端用telnet测试一下效果:

4. select优缺点
优点:
| 优点 | 详细说明 |
|---|---|
| 极致的移植性 | 几乎所有的操作系统(Linux, Windows, macOS, Unix)都支持,代码跨平台首选。 |
| 高精度超时控制 | 支持微秒级别的超时设置,比早期的 poll 更精细。 |
| 资源消耗可控 | 在连接数较少(如几十个)的情况下,比创建几十个线程要节省内存和上下文切换开销。 |
缺点:
- 数量限制 (The 1024 Limit)
-
问题 :单个进程监控的 fd 数量受
FD_SETSIZE限制,默认通常只有 1024。 -
影响:无法处理海量并发连接,强行修改内核参数会带来稳定性问题。
- 线性扫描 (O(n) 遍历)
-
问题 :
select返回后,只告诉你"有人准备好了",不告诉你是谁。 -
影响 :用户程序必须用
for循环把 1024 个位置全部检查一遍。如果只有 1 个活跃连接,剩下的 1023 次检查都是浪费 CPU。
- 用户态与内核态的频繁拷贝
-
问题 :每次调用
select,都需要把整个fd_set从用户内存拷贝到内核空间。 -
影响:在高频调用下,这种内存拷贝的开销非常巨大。
- 集合不可重用
-
问题:内核会直接修改传入的集合(抹除未就绪的 fd)。
-
影响:程序员必须自己维护一个"备份数组",每次循环重新初始化集合,增加了编码负担。
