目录
[一、五种 I/O 模型](#一、五种 I/O 模型)
[1. I/O 为什么慢?](#1. I/O 为什么慢?)
[2. 五种 I/O 模型](#2. 五种 I/O 模型)
[3. 模型比较](#3. 模型比较)
[4. 各模型特点](#4. 各模型特点)
[二、非阻塞 I/O](#二、非阻塞 I/O)
[1. 设置文件非阻塞属性](#1. 设置文件非阻塞属性)
[2. 非阻塞读取标准输入](#2. 非阻塞读取标准输入)
[三、多路转接 select 接口](#三、多路转接 select 接口)
[1. select 函数原型](#1. select 函数原型)
[2. fd_set 结构体](#2. fd_set 结构体)
[3. fd_set 操作接口](#3. fd_set 操作接口)
[4. select 服务器实现](#4. select 服务器实现)
[5. select 的缺点](#5. select 的缺点)
[四、多路转接 poll 接口](#四、多路转接 poll 接口)
[1. poll 函数原型](#1. poll 函数原型)
[2. pollfd 结构体](#2. pollfd 结构体)
[3. poll 服务器实现(基于 select 修改)](#3. poll 服务器实现(基于 select 修改))
[4. poll 与 select 对比](#4. poll 与 select 对比)
[五、epoll 简介](#五、epoll 简介)
[1. epoll 核心接口](#1. epoll 核心接口)
[2. epoll 原理](#2. epoll 原理)
[3. epoll 效率分析](#3. epoll 效率分析)
一、五种 I/O 模型
1. I/O 为什么慢?
网络 I/O 的本质是操作网卡等硬件设备。当进程读取数据时,数据并不是一直就绪的:
-
没有数据时,进程需要阻塞等待
-
数据到达后,进行数据拷贝
类比钓鱼:
-
等待鱼上钩的时间很长
-
拉杆子(拷贝数据)的时间很短
因此,I/O = 等待 + 拷贝,主要耗时在等待阶段。
2. 五种 I/O 模型
| I/O 模型 | 特点 |
|---|---|
| 阻塞 I/O | 没有数据时就一直等待 |
| 非阻塞 I/O | 没有数据时立即返回,可以干别的事 |
| 信号驱动 I/O | 用信号通知进程数据就绪 |
| I/O 多路复用 | 同时等待多个文件描述符 |
| 异步 I/O | 让操作系统帮忙完成整个 I/O 过程 |
3. 模型比较
阻塞 vs 非阻塞:
-
阻塞 I/O:检测到数据未就绪,进程被挂起直到就绪
-
非阻塞 I/O:检测到数据未就绪,立即返回错误(
EAGAIN或EWOULDBLOCK)
非阻塞效率为什么高?
-
不是因为能更快地处理同一个任务
-
而是因为可以同时处理不同种类的任务(在等待期间干别的事)
谁效率最高?
- 多路复用效率最高,因为它在单位时间内等待的占比最小
同步 vs 异步:
-
同步 I/O:只要进程参与了 I/O 过程(无论是等待还是拷贝),就是同步 I/O
- 阻塞 I/O、非阻塞 I/O、信号驱动 I/O、多路复用都是同步 I/O
-
异步 I/O:进程只发起 I/O 请求,后续工作完全由内核完成,完成后通知进程
4. 各模型特点
-
非阻塞 I/O:使用最广泛
-
信号驱动 I/O:信号由针脚连接 CPU,如果信号来得太快,可能会丢失,因此不常用
-
异步 I/O :
aio_read等系统调用,让内核帮助等待,数据准备好后直接拷贝到用户缓冲区,然后通知进程
二、非阻塞 I/O
1. 设置文件非阻塞属性
使用 fcntl 系统调用修改文件描述符的属性:
cpp
void setnoblock(int fd) {
int fl = fcntl(fd, F_GETFL);
if (fl < 0) {
perror("fcntl error");
exit(1);
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
2. 非阻塞读取标准输入
cpp
setnoblock(0); // 设置标准输入为非阻塞
char buff[1024];
while (1) {
int n = read(0, buff, sizeof(buff));
if (n > 0) {
buff[n - 1] = 0; // 去掉换行符
std::cout << buff << std::endl;
} else if (n == 0) {
break; // EOF
} else {
// 数据未就绪不是错误,但 read 返回 -1
if (errno == EAGAIN || errno == EWOULDBLOCK) {
std::cout << "数据空" << std::endl;
sleep(1);
continue;
} else {
break; // 其他错误
}
}
}
关键点:
-
数据未准备好时,
read返回-1,错误码为EAGAIN或EWOULDBLOCK -
在 C/C++ 标准库中,回车键(
\n)通常被过滤掉 -
但
read等系统调用会读取换行符,需要在n-1位置放\0
三、多路转接 select 接口
1. select 函数原型
cpp
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
参数说明:
| 参数 | 说明 |
|---|---|
nfds |
最大文件描述符值 + 1 |
readfds |
可读文件描述符集合(输入输出参数) |
writefds |
可写文件描述符集合 |
exceptfds |
异常文件描述符集合 |
timeout |
超时时间(输入输出参数) |
timeout 结构体:
cpp
struct timeval {
long tv_sec; // 秒
long tv_usec; // 微秒
};
-
输入:设置超时时间
-
输出:超时后变为
{0, 0};否则返回剩余时间
返回值:
-
大于 0:就绪的文件描述符个数
-
等于 0:超时
-
小于 0:出错
2. fd_set 结构体
fd_set 是一个位图,每一位代表一个文件描述符是否被监视。
std::cout << 8 * sizeof(fd_set) << std::endl; // 通常输出 1024

限制 :fd_set 最大只能表示 1024 个文件描述符(可调整,但一般不推荐)。
select 的特点:
-
是一个较老的技术,但兼容性好
-
在一些老系统上,新的多路转接接口可能没有,但 select 一定有
3. fd_set 操作接口
| 宏 | 作用 |
|---|---|
FD_CLR(fd, set) |
从集合中移除 fd |
FD_ISSET(fd, set) |
判断 fd 是否在集合中 |
FD_SET(fd, set) |
将 fd 加入集合 |
FD_ZERO(set) |
清空集合 |
4. select 服务器实现
成员变量与构造
cpp
const static int defaultsize = sizeof(fd_set) * 8; // 1024
const int defaultid = -1;
std::unique_ptr<mysocket> _listensocket;
int _fdarray[defaultsize]; // 存储需要监视的文件描述符
bool _isrunning;
pollserver(int port)
: _listensocket(std::make_unique<tcpsocket>())
, _isrunning(1) {
_listensocket->buildtcpsocket(port);
// 初始化数组
for (int i = 0; i < defaultsize; i++) {
_fdarray[i] = defaultid;
}
_fdarray[0] = _listensocket->fd(); // 监听套接字放在第一个位置
}
为什么需要数组?
-
fd_set每次调用select后都会被修改(只保留就绪的 fd) -
需要一个数组保存所有需要监视的 fd,每次循环开始前重新构建
fd_set
主循环
cpp
void start() {
_isrunning = 1;
while (_isrunning) {
fd_set fs;
FD_ZERO(&fs);
int maxfd = -1;
// 构建 fd_set
for (int i = 0; i < defaultsize; i++) {
if (_fdarray[i] != defaultid) {
FD_SET(_fdarray[i], &fs);
maxfd = std::max(maxfd, _fdarray[i]);
}
}
print();
// struct timeval to = {1, 0}; // 可选超时
int n = select(maxfd + 1, &fs, nullptr, nullptr, nullptr);
switch (n) {
case -1:
LOG(LogLevel::ERROR) << "select失败";
break;
case 0:
LOG(LogLevel::WARNING) << "select超时";
break;
default:
LOG(LogLevel::INFO) << "有事件就绪";
Dispatcher(fs);
break;
}
}
_isrunning = 0;
}
事件分发
cpp
void Dispatcher(fd_set& fs) {
for (int i = 0; i < defaultsize; i++) {
if (_fdarray[i] == defaultid) continue;
if (FD_ISSET(_fdarray[i], &fs)) {
if (_fdarray[i] == _listensocket->fd()) {
Accept(); // 新连接
} else {
Recever(_fdarray[i], i); // 已有连接的消息
}
}
}
}
区分新连接和已有连接:
-
监听套接字收到新连接时,内核中其状态变化,
select会将其标记为可读 -
已有连接的套接字收到数据时,也会被标记为可读
-
通过比较 fd 是否为监听套接字的 fd 来区分
处理新连接
cpp
void Accept() {
addr ad;
int fd = _listensocket->accept(&ad);
if (fd >= 0) {
LOG(LogLevel::INFO) << "收到新连接" << fd;
int pos = 0;
for (int i = 0; i < defaultsize; i++) {
if (_fdarray[i] == defaultid) {
pos = i;
break;
}
}
if (pos == 0) {
LOG(LogLevel::WARNING) << "服务器满了,丢弃fd" << fd;
close(fd);
} else {
_fdarray[pos] = fd;
}
}
}
处理消息
cpp
void Recever(int fd, int pos) {
char buffer[1024];
ssize_t n = recv(fd, buffer, sizeof(buffer) - 1, 0);
if (n < 0) {
LOG(LogLevel::WARNING) << "读取失败";
_fdarray[pos] = defaultid;
close(fd);
return;
} else if (n == 0) {
LOG(LogLevel::INFO) << "退出" << fd;
_fdarray[pos] = defaultid;
close(fd);
return;
} else {
buffer[n] = 0;
std::cout << fd << " say: " << buffer << std::endl;
}
}
5. select 的缺点
-
每次调用都要重置 fd_set:用户态需要遍历数组重建
-
每次调用都要拷贝 fd_set 到内核态(严格来说,这不是 select 独有的缺点)
-
内核需要遍历整个 fd_set:检查哪些 fd 就绪
-
支持的文件描述符数量太小(通常 1024)
四、多路转接 poll 接口
1. poll 函数原型
cpp
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数说明:
-
fds:pollfd结构体数组的指针 -
nfds:数组元素个数 -
timeout:超时时间(毫秒)-
大于 0:等待对应毫秒
-
等于 0:非阻塞
-
小于 0:阻塞
-
返回值:就绪的文件描述符个数
2. pollfd 结构体
cpp
struct pollfd {
int fd; // 文件描述符
short events; // 需要监视的事件(输入)
short revents; // 实际发生的事件(输出)
};
常见事件:
-
POLLIN:可读事件 -
POLLOUT:可写事件 -
POLLERR:错误事件
3. poll 服务器实现(基于 select 修改)
成员变量与构造
cpp
const static int defaultsize = 1024;
const int defaultid = -1;
std::unique_ptr<mysocket> _listensocket;
bool _isrunning;
struct pollfd _fds[defaultsize];
pollserver(int port)
: _listensocket(std::make_unique<tcpsocket>())
, _isrunning(1) {
_listensocket->buildtcpsocket(port);
// 初始化 pollfd 数组
for (int i = 0; i < defaultsize; i++) {
_fds[i].fd = -1;
_fds[i].events = 0;
_fds[i].revents = 0;
}
_fds[0].fd = _listensocket->fd();
_fds[0].events = POLLIN;
}
主循环
cpp
void start() {
_isrunning = 1;
while (_isrunning) {
int n = poll(_fds, defaultsize, -1); // 阻塞等待
if (n < 0) {
LOG(LogLevel::ERROR) << "poll失败";
break;
} else if (n == 0) {
LOG(LogLevel::WARNING) << "poll超时";
} else {
LOG(LogLevel::INFO) << "有事件就绪";
Dispatcher();
}
}
_isrunning = 0;
}
事件分发
cpp
void Dispatcher() {
for (int i = 0; i < defaultsize; i++) {
if (_fds[i].fd == defaultid) continue;
if (_fds[i].revents & POLLIN) {
if (_fds[i].fd == _listensocket->fd()) {
Accept();
} else {
Recever(i);
}
}
}
}
处理新连接
cpp
void Accept() {
addr ad;
int fd = _listensocket->accept(&ad);
if (fd >= 0) {
LOG(LogLevel::INFO) << "收到新连接" << fd;
int pos = 0;
for (int i = 0; i < defaultsize; i++) {
if (_fds[i].fd == defaultid) {
pos = i;
break;
}
}
if (pos == 0) {
LOG(LogLevel::WARNING) << "服务器满了,丢弃fd" << fd;
close(fd);
} else {
_fds[pos].fd = fd;
_fds[pos].events = POLLIN;
_fds[pos].revents = 0;
}
}
}
处理消息
cpp
void Recever(int pos) {
char buffer[1024];
ssize_t n = recv(_fds[pos].fd, buffer, sizeof(buffer) - 1, 0);
if (n < 0) {
LOG(LogLevel::WARNING) << "读取失败";
close(_fds[pos].fd);
_fds[pos].fd = defaultid;
return;
} else if (n == 0) {
LOG(LogLevel::INFO) << "退出" << _fds[pos].fd;
close(_fds[pos].fd);
_fds[pos].fd = defaultid;
return;
} else {
buffer[n] = 0;
std::cout << _fds[pos].fd << " say: " << buffer << std::endl;
}
}
4. poll 与 select 对比
poll 的优势:
-
没有文件描述符数量的硬性限制(数组大小可调)
-
使用
events和revents分离,不需要像 select 那样每次重置集合
poll 的缺点(与 select 相同):
-
仍然需要遍历整个数组来获取就绪的 fd(O(n))
-
每次调用都要将
pollfd数组从用户态拷贝到内核态
如果大量客户端连接,但大部分只是占位没有数据,效率会降低。
五、epoll 简介
1. epoll 核心接口
| 接口 | 作用 |
|---|---|
epoll_create() |
创建 epoll 实例,返回文件描述符 |
epoll_ctl() |
控制 epoll 实例:添加、修改、删除监视的 fd 和事件 |
epoll_wait() |
等待事件就绪,返回就绪的事件列表 |
2. epoll 原理
内核数据结构:
-
红黑树:存储所有被监视的 fd 和事件信息,键为 fd
-
就绪队列:存储已经就绪的事件
回调机制:
-
网络协议栈有回调机制
-
当底层事件就绪时,会触发回调函数
-
回调函数将对应的 epoll 节点挂载到就绪队列上
-
一个节点可以通过指针同时存在于红黑树和就绪队列中
工作流程:
-
epoll_create:创建红黑树、就绪队列,注册回调机制 -
epoll_ctl:对红黑树进行增删改操作 -
epoll_wait:从就绪队列中取出就绪事件
3. epoll 效率分析
| 操作 | select/poll | epoll |
|---|---|---|
| 检查是否有就绪 | O(n) 遍历 | O(1) 检查队列是否为空 |
| 用户态到内核态拷贝 | O(n) | O(n)(每次调用) |
| 查找 fd | O(n) | O(log n)(红黑树) |
epoll 的优势:
-
就绪事件直接放入队列,无需遍历所有 fd
-
就绪队列是生产者消费者模型,缓冲区满时可以暂存,下次继续取
-
线程安全
-
内核按顺序拷贝就绪事件(从下标 0 开始),不会有 poll 中 fd = -1 的空位问题
回调机制的触发:
-
epoll_ctl调用sys_epoll_ctl系统调用,初始化回调 -
当数据就绪后,通过中断向量表触发回调,唤醒等待的进程