5种IO模型与阻塞IO
核心:
一次 IO 操作 = 等待数据 + 拷贝数据 CPU 速度极快,外设(网卡、磁盘)很慢,大部分时间都在等待 ;高效 IO 的目标就是:减少等待所占的时间比重。
钓鱼比喻讲解 5 种 IO 模型
核心类比:钓鱼 = IO,IO = 等待(等鱼上钩) + 拷贝(把鱼钓上来)
- 河 = 操作系统内核
- 鱼 = 数据
- 桶 = 用户缓冲区
- 鱼竿 = 文件描述符 sockfd
1. 张三:阻塞 IO
专注钓鱼,鱼漂不动,张三就不动 张三拿着一根鱼竿,盯着浮漂。没有鱼(没有数据),就原地卡住,啥都不干;直到鱼咬钩,把鱼钓上来,才结束。

2.李四:非阻塞 IO
不会因为鱼没有上钩而卡在检测鱼漂上 李四拿一根鱼竿,**不停反复看浮漂(**循环不断轮询)。没鱼的时候不卡住,直接返回,李四可以去干点别的,隔一会儿再来查看一次有没有鱼
3. 王五:信号驱动 IO
通过鱼上钩,反向通知我 鱼竿上装铃铛。王五不用一直盯着浮漂,可以去做别的事。鱼上钩,铃铛响(内核发信号通知进程),王五收到通知之后,再过来钓鱼(拷贝数据)。
王五依然要亲自参与 "钓起鱼(拷贝)" 这个步骤。

4.赵六:IO 多路复用(多路转接)
同时监视 赵六一次性拿很多根鱼竿,只需要一个人同时监控全部鱼竿的浮漂,哪根鱼竿鱼上钩了,再单独处理那一根。

5. 田七:异步 IO(AIO)
我喜欢吃鱼,发起钓鱼,小鱼帮我钓好了送过来 田七直接委托别人帮自己钓鱼,全程不用管:不用盯浮漂、不用亲自捞鱼。鱼钓好之后,别人直接把鱼送到桶里,通知田七。
等待 + 拷贝,全部由内核做完,用户进程全程不参与这两步

谁效率最高?
IO 多路复用(赵六)。一次性监听大量连接,重要的是大幅降低了等待时间占比,高并发场景首选。
同步 IO vs 异步 IO
阻塞、非阻塞、信号驱动、多路复用 → 全部属于【同步 IO】
IO 分为两大阶段:等待数据、拷贝数据
- 同步 IO:用户只要进程参与「等待」或者「拷贝」其中任意一步
- 异步 IO:发起 IO 之后,等待 + 拷贝两件事都交给内核,用户进程完全不参与,内核做完再通知你。
非阻塞IO
fcntl函数
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );
fd:目标文件描述符(socket、普通文件都可以)cmd:命令,决定 fcntl 做什么事- 后面是可变参数,不同 cmd,参数不一样
fcntl 的 5 个功能cmd
- 复制文件描述符 :
cmd=F_DUPFD,类似 dup/dup2 - 获取 / 设置文件描述符标记 :
F_GETFD / F_SETFD - 获取 / 设置文件状态标记 :
F_GETFL / F_SETFL用来开启非阻塞 - 获取 / 设置异步 I/O 所有权:
F_GETOWN / F_SETOWN(信号驱动 IO 用) - 获取 / 设置文件记录锁:
F_GETLK、F_SETLK、F_SETLKW(文件读写锁)
核心片段:
int flags = fcntl(fd, F_GETFL); // 获取原有状态
fcntl(fd, F_SETFL, flags | O_NONBLOCK); // 添加非阻塞标记
demo设置read为非阻塞

n < 0 返回负数(出错)
但不是所有负数都是真的错误! 需要判断全局变量
errno
errno == EAGAIN || errno == EWOULDBLOCK假错误! 非阻塞特有:内核缓冲区暂时没有数据,不是故障。errno == EINTR被信号打断本次 read 调用,不算致命错误,直接continue重试。- 其他 errno 值 真正的 IO 错误(比如 fd 非法),需要处理报错。
Ctrl+D 作用
代表 EOF 文件结束 ,告诉操作系统:输入流已经结束,不会再有数据了 。 对应代码里
read返回 0,直接 break 退出循环。
多路转接select
核心:select 属于 IO 多路复用,专门负责【等待】,一次性监视多个 fd,哪个 fd 事件就绪就通知用户 IO = 等待 + 拷贝;select 帮你做「等待」这一步,拷贝还是用户自己调用 read/write 完成,所以 select 仍然是同步 IO。
新建 socket fd,一开始缓冲区全空
底层有数据 → 读事件就绪;底层有空间 → 写事件就绪
- 接收缓冲区空:没有数据 → 读事件不就绪
- 发送缓冲区是空,还有大量空位:写事件默认直接就绪!
一句话记住:新 socket,读事件不就绪,写事件默认就绪
select 做什么
- 一次性传入一堆 fd,告诉内核:帮我盯着这些 fd 的读 / 写事件
- 进程阻塞在 select 函数上,等待
- 只要任意一个 fd 的事件就绪,select 就返回,告诉上层:有 fd 准备好了,可以去 IO
- 用户接下来自己调用 read/write 做拷贝
select 函数完整讲解
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
逐个参数
- nfds :
maxfd + 1- maxfd:你放入 fd_set 里最大的文件描述符
- 内核只需要扫描
0 ~ maxfd这些 fd,不用遍历全部,用来提高效
- readfds(读事件) / writefds(写事件) / exceptfds(异常事件,很少用一般传null) :
fd_set *位图集合fd_set本质是位图(bit 数组) ,输入输出双向修改!(输入输出参数)
输入阶段(用户→内核):bit 位 = 1 代表:我关心这个 fd 的该类事件 返回阶段(内核→用户):bit 位 = 1 代表:这个 fd 事件就绪,0 代表未就绪
重要坑:每次 select 返回后,位图会被内核改写!下一轮循环必须重新添加 fd(后面写的代码中就是放到while循环里面)
fd_set 大小固定,默认最多只能监听 1024 个 fd,这是 select 的硬上限缺点。
3.timeout:struct timeval 超时时间(输入输出参数)
struct timeval {
time_t tv_sec; // 秒
suseconds_t tv_usec; // 微秒
};
三种情况:
timeout = NULL:阻塞等待,没有 fd 就绪就一直卡在 select,直到有事件tv_sec=0 && tv_usec=0:完全非阻塞,立刻检查一遍所有 fd,马上返回,不等待- 填时间值:带超时阻塞。在指定时间内阻塞等待;超时还没有 fd 就绪,直接返回 0,有就返回就绪个数
举例子
timeout 设置 tv_sec=5(最多等 5 秒)
- 等待 2 秒的时候,有1个 fd 就绪 → select 返回正数1;同时 timeout 被内核改写,tv_sec 变成 3,代表还剩 3 秒没等。
- 整整等满 5 秒,全程无 fd 就绪 → select 返回 0;timeout 变成 0,剩余时间为 0。
select 返回值(3 种情况)
- 返回 > 0:就绪 fd 的总数量,有事件发生了。
- 返回 = 0:超时,在 timeout 时间内,没有任何 fd 就绪
- 返回 < 0:出错
fd_set 配套 4 个宏
FD_ZERO(&set); // 清空位图,全部bit置0
FD_SET(fd, &set); // 把fd加入集合,对应bit置1
FD_CLR(fd, &set); // 把fd从集合移除,对应bit清0
FD_ISSET(fd, &set);// 判断返回后,该fd的bit是否为1(是否就绪)
select 三大缺点
- fd_set 固定大小,最多监听 1024 个 fd
- 位图会被内核修改,每一轮循环,必须重新 FD_ZERO + FD_SET
- 返回值只告诉你一共有多少就绪,不会直接告诉你是哪一个 fd ,需要循环遍历全部 fd,调用
FD_ISSET挨个判断(O (n) 扫描)
Echo Server demo:

SelectServer.hpp:
#pragma once
#include <iostream>
#include <memory>
#include <unistd.h>
#include "Socket.hpp"
#include "Log.hpp"
using namespace SocketModule;
using namespace LogMoudle;
// 基于select IO多路复用的TCP服务器
class SelectServer
{
// 标记fd不使用,默认-1
const static int defaultfd = -1;
// fd_set的最大bit数量,代表select最多能管理的fd总数
const static int size = sizeof(fd_set) * 8;
public:
// 构造函数:初始化服务器,创建监听socket
SelectServer(int port)
: _is_running(false)
,_listensock(make_unique<TcpSocket>())
{
// 创建TCP监听套接字,完成socket/bind/listen
_listensock->BuildTcpSocketMethod(port);
// 初始化fd数组,全部置为-1,表示暂时不监控
for (int i = 0; i < size; i++)
{
_fd_array[i] = defaultfd;
}
// 把监听fd放入数组,select需要监控listenfd的读事件(新连接)
_fd_array[0] = _listensock->Fd();
}
// 启动服务器主循环
void Start()
{
_is_running = true;
while (_is_running)
{
fd_set rfds; // 读事件集合,每次select都要重新构造!select会修改这个集合
FD_ZERO(&rfds); // 清空fd集合
int maxfd = defaultfd; // 记录当前最大fd,select参数需要maxfd+1
// 遍历保存fd的数组,把所有需要监控的fd加入rfds
for (int i = 0; i < size; i++)
{
if (_fd_array[i] == defaultfd)
continue; // -1代表无效fd,跳过
FD_SET(_fd_array[i], &rfds); // 将fd添加到读事件集合
if (_fd_array[i] > maxfd)
maxfd = _fd_array[i]; // 更新最大fd
}
PrintFd(); // 打印当前正在管理的fd,调试用
// select:只监控读事件,阻塞等待事件发生,写/异常集合传nullptr
int n = select(maxfd + 1, &rfds, nullptr, nullptr, nullptr);
switch (n)
{
case -1:
LOG(LogLevel::ERROR) << "select error";
break;
case 0:
LOG(LogLevel::INFO) << "time out";
break;
default:
// n>0,代表有n个fd读就绪
LOG(LogLevel::DEBUG) << "有事件就绪了....n:" << n;
Dispatcher(rfds); // 事件分发,处理就绪fd
break;
}
}
_is_running = false;
}
// 事件分发函数:遍历所有fd,判断谁就绪,做对应处理
void Dispatcher(fd_set &rfds)
{
for (int i = 0; i < size; i++)
{
if (_fd_array[i] == defaultfd)
continue;
// FD_ISSET判断该fd是否在就绪集合中
if (FD_ISSET(_fd_array[i], &rfds))
{
if (_fd_array[i] == _listensock->Fd())
{
// 监听fd就绪:代表有新客户端连接到来
Accepter();
}
else
{
// 普通客户端fd就绪:代表客户端发来数据,可以读
Recver(_fd_array[i],i);
}
}
}
}
// 处理新连接:accept获取新客户端fd,存入_fd_array数组
void Accepter()
{
InetAddr client;
int sockfd=_listensock->Accept(&client);
if(sockfd>=0)
{
LOG(LogLevel::INFO) << "get a new link, sockfd: "
<< sockfd << ", client is: " << client.StringAddr();
// 找_fd_array里第一个空位(值为-1)存放新的client fd
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;
}
}
}
// 读取客户端数据
// fd:客户端套接字,pos:fd在_fd_array数组的下标
void Recver(int fd,int pos)
{
char buffer[1024];
ssize_t n=recv(_fd_array[pos],buffer,sizeof(buffer)-1,0);
if(n>0)
{
// 读到n字节有效数据
buffer[n]=0;
cout<<"clietn sat@"<<buffer<<endl;
}
else if(n==0)
{
// recv返回0:客户端正常关闭连接
LOG(LogLevel::INFO)<<"client quit";
_fd_array[pos]=defaultfd;// 标记这个位置不再监控
close(fd);
}
else
{
// recv<0,读取出错
LOG(LogLevel::ERROR) << "recv error";
_fd_array[pos] = defaultfd; // 取消监控
close(fd); // 关闭套接字,释放资源
}
}
// 调试函数:打印当前管理的所有fd
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";
}
private:
int _fd_array[size]; // 保存所有需要select监控的fd,-1代表空位
unique_ptr<Socket> _listensock; // 监听套接字
bool _is_running; // 服务器运行标记
};
多路转接poll
poll函数(IO 多路复用)
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
fds:struct pollfd数组,存放你要监控的所有文件描述符nfds:数组里面有多少个元素(要监听 fd 的数量)timeout:等待超时时间,单位毫秒>0:等待多少 ms0:不阻塞,立刻返回(轮询)-1:永久阻塞,直到有事件发生
struct pollfd
struct pollfd {
int fd; // 要监听的文件描述符
short events; // 【你设置】你想要监听什么事件(输入)
short revents; // 【内核填充】实际发生了什么事件(输出)
};
events:用户写进去,告诉内核我关心什么; revents:poll 返回后,内核填好,告诉你这个 fd 发生了什么。
常用事件宏
POLLIN:可读事件POLLOUT:可写事件POLLERR:出错(内核自动设置,不用在 events 写)POLLHUP:挂起,连接断开(内核自动设置,不用 events 写)
返回值
-
0:有多少个 fd 就绪(有事件发生)
-
0:超时,没有 fd 就绪
-
-1:出错,errno 记录错误

PollServer.hpp:
#pragma once
#include <iostream>
#include <memory>
#include <sys/poll.h>
#include <unistd.h>
#include "Socket.hpp"
#include "Log.hpp"
using namespace SocketModule;
using namespace LogMoudle;
class PollServer
{
const static int defaultfd = -1;
const static int size = 4096; // 想给多少给多少,不像select有限制
public:
PollServer(int port)
: _is_running(false), _listensock(make_unique<TcpSocket>())
{
_listensock->BuildTcpSocketMethod(port);
for (int i = 0; i < size; i++)
{
_fds[i].fd = defaultfd;
// 置为0不要干扰
_fds[i].events = 0;
_fds[i].revents = 0;
}
//_fd_array的元素为-1表示不关心,为其它值有2种情况1是就绪2是没有就绪
_fds[0].fd = _listensock->Fd();
_fds[0].events = POLLIN;
}
void Start()
{
int timeout = -1;
_is_running = true;
while (_is_running)
{
PrintFd();
int n = poll(_fds, size, timeout);
switch (n)
{
case -1:
LOG(LogLevel::ERROR) << "poll error";
break;
case 0:
LOG(LogLevel::INFO) << "poll time out";
default:
LOG(LogLevel::DEBUG) << "有事件就绪了....n:" << n;
Dispatcher();
break;
}
}
_is_running = false;
}
void Dispatcher()
{
for (int i = 0; i < size; i++)
{
if (_fds[i].fd == defaultfd)
continue;
// 走到这里一定是合法,但不一定就绪
if (_fds[i].revents & POLLIN)
{
// 走到这里一定就绪,普通事件就绪,读就绪
if (_fds[i].fd == _listensock->Fd())
{
// 读事件就绪
Accepter();
}
else
{
// 普通事件就绪
Recver(i);
}
}
}
}
void Accepter()
{
InetAddr client;
int sockfd = _listensock->Accept(&client);
if (sockfd >= 0)
{
LOG(LogLevel::INFO) << "get a new link, sockfd: "
<< sockfd << ", client is: " << client.StringAddr();
int pos = 0;
for (; pos < size; pos++)
{
if (_fds[pos].fd == defaultfd)
break;
// break出来2种情况
if (pos == size)
{
LOG(LogLevel::WARNING) << "select server full";
close(sockfd);
}
else
{
_fds[pos].fd = sockfd;
_fds[pos].events = POLLIN;
_fds->revents = 0;
}
}
}
}
void Recver(int pos)
{
char buffer[1024];
ssize_t n = recv(_fds[pos].fd, buffer, sizeof(buffer) - 1, 0);
if (n > 0)
{
buffer[n] = 0;
cout << "clietn sat@" << buffer << endl;
}
else if (n == 0)
{
LOG(LogLevel::INFO) << "client quit";
close(_fds[pos].fd);
_fds[pos].fd = defaultfd;
_fds[pos].events = 0;
_fds->revents = 0;
}
else
{
LOG(LogLevel::ERROR) << "recv error";
close(_fds[pos].fd);
_fds[pos].fd = defaultfd;
_fds[pos].events = 0;
_fds->revents = 0;
}
}
void PrintFd()
{
std::cout << "_fd_array[]: ";
for (int i = 0; i < size; i++)
{
if (_fds[i].fd == defaultfd)
continue;
std::cout <<_fds[i].fd << " ";
}
std::cout << "\r\n";
}
private:
struct pollfd _fds[size];//也可以写成指针满了就扩容
unique_ptr<Socket> _listensock;
bool _is_running;
};
//void AddFd(int fd, short events)
// {
// if (_nfds >= _capacity)
// {
// // 扩容,两倍
// int newcap = _capacity * 2;
// struct pollfd* newfds = new pollfd[newcap];
// memcpy(newfds, _fds, sizeof(pollfd)*_nfds);
// delete[] _fds;
// _fds = newfds;
// _capacity = newcap;
// }
// }
多路转接之epoll
epoll 是 Linux 独有的 IO 多路复用,用来替代 select/poll,高性能,适合大量 fd。 一共 3 个核心函数:epoll_create、epoll_ctl、epoll_wait
1.epoll_create
int epoll_create(int size);
作用:创建一个 epoll 模型,返回 epollfd(epoll 自己的文件描述符)
- size:早期版本提示内核预估 fd 数量,新版本内核不再依赖这个值,随便传正数就行
- 返回值:成功返回 epollfd,失败返回 - 1
epollfd 本质也是 fd,用完要 close ()
2.epoll_ctl(控制接口,最核心)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
功能:用户告诉内核,对哪个 fd 做什么操作,监听什么事件
参数:
-
epfd:epoll_create 的返回值,epollfd -
op:操作类型,3 种:EPOLL_CTL_ADD:添加 fd到 epoll 的监听红黑树(注册,开始监听)EPOLL_CTL_MOD:修改已经注册 fd 的监听事件EPOLL_CTL_DEL:删除fd,不再监听
-
fd:要操作的目标文件描述符(socket、listenfd 等) -
*event:结构体,告诉内核要监听什么事件struct epoll_event {
uint32_t events; // 你关心的事件(EPOLLIN / EPOLLOUT等)
epoll_data_t data; // 用户数据,内核不修改,就绪后原样还给你
};
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
events常用宏:
EPOLLIN:可读事件(新连接、收到数据)EPOLLOUT:可写事件EPOLLERR:出错,内核自动监听,不用手动注册EPOLLHUP:连接挂断,内核自动监听EPOLLET:边缘触发 ET(默认是水平触发 LT)
3. epoll_wait(等待事件)
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
作用:阻塞等待 fd 就绪,内核把就绪的事件填到 events 数组返回给用户
参数:
- epfd:epoll 句柄
- events:输出数组 ,内核把所有就绪的 fd 事件放到这个数组(是用户提前分配好的数组)放的时候就按照数组下标0,1,2....依次填充
- maxevents:数组最多能存多少个事件
- timeout:超时,单位毫秒,和 poll 一样
-1永久阻塞;0非阻塞立刻返回;大于 0 等待指定 ms
返回值:
-
>0:就绪 fd 的数量
-
0:超时,没有 fd 就绪
-
-1:出错
epoll 内核原理


- 红黑树 :保存所有你注册监听的 fd (通过
epoll_ctl(ADD)添加进去)- 节点:红黑树节点(自带
rb_node成员),里面存 fd、要监听的事件、红黑树左右指针、链表指针 - 作用:快速查找、新增、删除 fd。
- 对应图里:
epoll_ctl ADD就是把 fd 节点插入这棵红黑树。
- 节点:红黑树节点(自带
- 就绪链表(就绪队列) :保存已经发生事件、就绪的 fd
- 当某个 fd 事件就绪,内核自动把这个 fd 节点,从红黑树里拎出来,挂到就绪链表(回调函数作用)。
epoll_wait就是直接读取这个就绪链表,拿就绪事件。
完整整套流程
① epoll_create
调用epoll_create,内核在内存里创建一个 epoll 对象,返回 epollfd。 这个 epoll 对象里面,就包含:红黑树 + 就绪链表。
细节(底层):
- 内核分配
struct eventpoll - 内核新建一个文件,返回 fd,就是epfd
- epfd 对应的
struct file:file->private_data = eventpoll
后续 epoll_ctl /epoll_wait 传 epfd,内核就靠:epfd → file → private_data 拿到 eventpoll。
struct eventpoll 核心成员:
rbr:红黑树根,存放所有被监听 fd 对应的 epitemrdllist:就绪链表,存放已经发生事件的 epitemwq:epoll_wait 的等待队列,用户进程没事件时阻塞在这里
② epoll_ctl( EPOLL_CTL_ADD )
你调用epoll_ctl把 fd(listenfd 或者客户端 socket)注册进 epoll。 内核做两件事:
- 创建节点,存入 fd、监听的事件(EPOLLIN 等),插入红黑树;
- 给这个 fd 注册回调函数
重点:告诉内核:这个 fd 一旦就绪,就执行回调,即把这个节点丢进就绪链表。
细节(底层):
-
内核通过 epfd 找到 eventpoll
-
分配
struct epitem,填入 fd、要监听的事件(EPOLLIN/EPOLLOUT) -
将 epitem 插入
eventpoll->rbr红黑树(fd 作为 key,快速查找) -
epitem 有保存指向
sockfd(指向sockfd,不是epfd)对应的struct file指针记住「当前这个 epitem 是监听哪一个文件(socket)」。 -
分配 struct eppoll_entry
eppoll_entry->epi = epitem,回调执行的时候,通过 eppoll_entry 就能找到 epitem。- eppoll_entry 里面的
wait等待节点,注册回调:wait.func = ep_poll_callback
struct eppoll_entry {
struct epitem *epi; // ① 指向epitem
wait_queue_entry_t wait; // ② 等待节点,里面存回调函数
struct list_head llink;
};
6.把eppoll_entry.wait这个节点挂载到 socket 底层的读 / 写等待队列
struct sock {
// ...
wait_queue_head_t sk_sleep; // ✅ socket的等待队列头!
// ...
};
③ 数据从网卡到来,硬件中断 + 协议栈回调(图1下半部分)
- 网卡收到网络数据 → 触发硬件中断
- 内核网络协议栈处理数据包,发现这个 socket fd 收到数据,fd 就绪
- 触发我们提前注册好的回调函数
- 回调函数:把这个 fd 对应的节点,添加到就绪链表
细节(底层):
- 网卡收到数据,socket 底层感知到读事件就绪
- socket 自己的等待队列被唤醒,执行等待项里的回调函数:ep_poll_callback
通过ep_poll_callback拿到 eppoll_entry,找到 epitem,再找到 epitem 所属 eventpoll- 将 epitem 放入 eventpoll 的 rdllist 就绪链表,唤醒 epoll_wait 阻塞的进程
关键区别 select/poll:select/poll 是用户调用的时候,内核挨个遍历全部 fd,检查有没有就绪(轮询) epoll:不是轮询!os去做,是 fd 就绪时,硬件中断触发回调,主动放进就绪链表。
④ epoll_wait
调用epoll_wait(epfd, events, maxevents, timeout) 内核直接看就绪链表:
- 链表不为空:把就绪链表里面的事件拷贝到你传进去的
events数组,返回就绪数量。 - 链表为空:阻塞等待,直到超时或者有事件进就绪链表。
epoll_wait不用扫描全部 fd,只拿已经就绪的,因为是从就绪链表拿,肯定是已经就绪了
细节(底层):
- 内核拿 epfd 找到 eventpoll
- 检查
eventpoll->rdllist就绪链表- 链表不为空:遍历 rdllist,把就绪事件拷贝到用户空间,清空 rdllist,返回就绪 fd 数量
- 链表为空:当前进程阻塞,挂入
eventpoll->wq等待队列,休眠,等待ep_poll_callback唤醒
总结(主要步骤):
epoll_create 生成 eventpoll 与 epfd,epfd 对应的 file 的 private_data 保存 eventpoll。epoll_ctl 添加 fd 时创建 epitem 放入红黑树,同时生成 eppoll_entry存放ep_poll_callback,ep_poll_callback 注册到 socket 底层等待队列。当 socket 事件就绪,触发 ep_poll_callback找到eppoll_entry进而找到epitem,将 epitem 放入 eventpoll 的 rdllist 就绪链表,唤醒 epoll_wait 阻塞的进程,epoll_wait 读取 rdllist 里就绪事件返回给用户。
问题:
1.就绪检测是谁做的?操作系统内核,靠网卡中断 + 回调,不是 epoll_wait 去挨个轮询 fd!
2.复杂度:
- 内核检测 fd 就绪:O (1),就绪了直接回调丢进就绪队列
- epoll_wait 拿到就绪事件,遍历就绪数组:O (就绪 fd 数量),不是全部 fd
就绪队列 = epoll 本质就是生产者消费者模型
内核是生产者 :网卡收到数据 → fd 就绪 → 回调函数,把就绪节点放入就绪队列(生产事件)。用户进程(epoll_wait)是消费者 :调用 epoll_wait,从就绪队列取出就绪事件(消费事件)。缓冲区:内核就绪链表
核内部自带同步保护,epoll 接口本身线程安全( 多个线程,同时对同一个 epfd 调用 epoll_wait /epoll_ctl,内核内部做了保护,不会出现内核数据结构(红黑树、就绪链表)乱掉、崩溃)。
epoll_wait 用户缓冲区events数组不够大(maxevents 太小)怎么办?
没拿,内核保留就绪节点,下次 epoll_wait 继续拿。
举例子:就绪队列里面有 10 个就绪 fd,你maxevents=4
- 本次 epoll_wait,内核拷贝前 4 个就绪事件到你的 events 数组,返回 4
- 剩下 6 个就绪节点仍然留在内核就绪链表,不会丢掉
- 下一轮再次调用 epoll_wait,继续把剩下的给你,不会丢事件,只是分多次 epoll_wait 返回。
- 拷贝规则:内核遍历内核就绪链表,依次把节点的事件拷贝到你的数组里,从数组下标 0 开始填 。
eg:内核拿到一个就绪节点,把事件信息复制到你传入的用户数组events
- 第一个拷贝的放
events[0] - 第二个拷贝的放
events[1] - 第三个拷贝的放
events[2]
Echo EpollServer:

EpollSever.hpp:
#pragma once
#include"Common.hpp"
#include <iostream>
#include <memory>
#include <sys/epoll.h>
#include <unistd.h>
#include "Socket.hpp"
#include "Log.hpp"
using namespace SocketModule;
using namespace LogMoudle;
class EpollServer
{
const static int defaultfd = -1;
const static int size = 64;
public:
EpollServer(int port)
: _is_running(false), _listensock(make_unique<TcpSocket>()),_epfd(defaultfd)
{
_listensock->BuildTcpSocketMethod(port);
//2.创建epoll模型
_epfd=epoll_create(256);
if(_epfd<0)
{
LOG(LogLevel::FATAL)<<"epoll_create error";
exit(EPOLL_CREATE_ERR);
}
LOG(LogLevel::DEBUG)<<"epoll_create sucess "<<_epfd;
//添加listen的fd
struct epoll_event ev;
ev.data.fd=_listensock->Fd();
ev.events=EPOLLIN;
int n=epoll_ctl(_epfd,EPOLL_CTL_ADD,_listensock->Fd(),&ev);
if(n<0)
{
LOG(LogLevel::FATAL)<<"add listensockfd failed";
exit(EPOLL_CTL);
}
}
~EpollServer()
{
_listensock->Close();
if (_epfd > 0)
close(_epfd);
}
void Start()
{
int timeout = -1;
_is_running = true;
while (_is_running)
{
int n=epoll_wait(_epfd,_revs,size,timeout);
switch (n)
{
case -1:
LOG(LogLevel::ERROR) << "poll error";
break;
case 0:
LOG(LogLevel::INFO) << "poll time out";
default:
LOG(LogLevel::DEBUG) << "有事件就绪了....n:" << n;
Dispatcher(n);
break;
}
}
_is_running = false;
}
void Dispatcher(int readynum)
{
LOG(LogLevel::DEBUG) << "event ready ...";
for(int i=0;i<readynum;i++)
{
int sockfd=_revs[i].data.fd;
uint32_t revent=_revs[i].events;
if(revent&EPOLLIN)
{
//到这里说明有事件就绪
if(sockfd==_listensock->Fd())
{
//到这里说明是liten就绪
Accepter();
}
else
{
//普通事件就绪
Recver(sockfd);
}
}
}
}
void Accepter()
{
InetAddr client;
int sockfd = _listensock->Accept(&client);
if (sockfd >= 0)
{
LOG(LogLevel::INFO) << "get a new link, sockfd: "
<< sockfd << ", client is: " << client.StringAddr();
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sockfd;
int n=epoll_ctl(_epfd,EPOLL_CTL_ADD,sockfd,&ev);
if (n < 0)
{
LOG(LogLevel::WARNING) << "add listensockfd failed";
}
else
{
LOG(LogLevel::INFO) << "epoll_ctl add sockfd success: " << sockfd;
}
}
}
void Recver(int sockfd)
{
char buffer[1024];
//此次recv不会阻塞
ssize_t n = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
if (n > 0)
{
buffer[n] = 0;
cout << "clietn sat@" << buffer << endl;
}
else if (n == 0)
{
LOG(LogLevel::INFO) << "client quit";
// 2. 从epoll中移除fd的关心 && 关闭fd -- 细节:epoll_ctl: 只能移除合法fd -- 先移除,在关闭!!
int m=epoll_ctl(_epfd,EPOLL_CTL_DEL,sockfd,nullptr);//nullptr表示不监听任何事件,只管删
if(m > 0)
{
LOG(LogLevel::INFO) << "epoll_ctl remove sockfd success: " << sockfd;
}
close(sockfd);
}
else
{
LOG(LogLevel::ERROR) << "recv error";
int m=epoll_ctl(_epfd,EPOLL_CTL_DEL,sockfd,nullptr);//nullptr表示不监听任何事件,只管删
if(m > 0)
{
LOG(LogLevel::INFO) << "epoll_ctl remove sockfd success: " << sockfd;
}
close(sockfd);
}
}
private:
std::unique_ptr<Socket> _listensock;
int _epfd;
struct epoll_event _revs[size];
bool _is_running;
};
select /poll/epoll 优缺点对比
| select | poll | epoll | |
|---|---|---|---|
| 优点 | 跨平台,兼容性好,简单 | 1. 去掉 select 1024 fd 上限2. 结构更直观,不用位图 | 1. 高并发性能强,只返回就绪 fd2. 支持 LT 水平触发、ET 边缘触发3. fd 数量无硬性上限 |
| 缺点 | 1. 默认最大 fd 10242. 每次调用拷贝全部 fd 集合3. 内核遍历全部 fd(O (n))4. 返回后用户态还要循环查找就绪 fd5. fd_set 会被内核修改,每次要重填 | 1. 依旧每次拷贝全部 pollfd 数组2. 内核遍历全部 fd(O (n))3. 返回后用户态遍历数组找就绪 fd4. Linux 以外平台支持一般 | 1. Linux 专属,不跨平台2. ET 边缘触发写代码容易出错,要小心 |
LT(水平触发)、ET(边缘触发)
LT 水平触发(select/poll 默认也是 LT)
只要底层缓冲区还有报文,就一直通知上层
- 缓冲区只要还有没读完的数据,每次调用
epoll_wait,都会再次返回这个 fd,告诉你可读。 - 上层这次可以只读一部分,不用一次性读完。剩下的数据还在缓冲区,下一次 epoll_wait 还会收到通知。
- 优点:写代码简单,不容易丢事件。
- 缺点:会产生很多重复通知。缓冲区一直有数据,就反复通知。
举例子:缓冲区有 100 字节,你本次只读 20 字节,还剩 80。下次 epoll_wait仍然会再次通知这个 fd 就绪。
ET 边缘触发(epoll 独有)
只有底层数据发生变化(从无→有、新增数据)那一刻,才通知一次
- 只有状态变化瞬间触发通知,只通知一次。
- 如果这次收到通知,没有一次性把缓冲区全部数据读完 ,剩下的数据留在缓冲区,不会再触发新通知!程序就卡住,读不到剩下的数据。
- 强制要求:ET 必须搭配非阻塞 read,循环读直到缓冲区读空。
- 优点:通知次数少,减少内核和用户态之间的交互,效率更高。
- 缺点:写代码容易出错,漏读数据就会丢消息。
例子:缓冲区从 0 字节变成 100 字节 → 触发一次通知。你只读 20,剩下 80 留在缓冲区,没有新数据进来的话,永远不再通知。
一句话对比
- LT:缓冲区有数据就通知,多次通知,好写代码。
- ET:数据刚来那一刻只通知一次,通知少,代码难写,必须一次性读完。
ET 为什么必须是非阻塞 fd?
- 你不知道缓冲区还有多少剩余数据,只能循环调用
recv反复读。 - 读到缓冲区空 的时候,如果 fd 是阻塞 的:
recv会卡住(阻塞休眠),程序停在这里不动,等新数据到来,整个线程卡死。 - 如果 fd 设置成非阻塞 : 缓冲区读完之后,再调用 recv 不会卡住,直接返回 - 1,并且 errno = EAGAIN 。 代码捕获 EAGAIN,就知道缓冲区空了,跳出读循环,完美结束本轮读取。
ET 固定组合:ET + 非阻塞 IO + 循环 recv 直到 EAGAIN
LT 阻塞 / 非阻塞,循环读/不循环(4种组合)
前面我们的代码再recv的时候,读一次绝对不会阻塞,因为epoll_wait 返回这个 fd 就绪 → 此刻缓冲区一定有数据。如果还要强行循环但却已经没有数据了的话,就会阻塞
1. LT + 阻塞 fd + 不循环读(最经典、最简单写法)
逻辑:epoll_wait 拿到就绪事件 → 调用 1 次 recv,读完本次拿到的数据,直接退出,不循环
- LT + 阻塞 fd + 循环读(不推荐,危险!)
逻辑:收到事件,while 循环不停 recv
- LT + 非阻塞 fd + 不循环读(没发挥非阻塞优势,很少这么写)
逻辑:收到事件,recv 一次,拿到数据就退出
4. LT + 非阻塞 fd + 循环读(推荐优化写法)
逻辑:收到事件,while 循环 recv,直到返回 EAGAIN,代表缓冲区空,退出循环
现在可以看到LT的第4种写法和ET功能一样即一次性读完,那为什么还要ET呢?
本质差别:LT 是程序员自愿优化 ;ET 是OS 强制要求。
我们不能保证程序员选用LT一定会采取第4种方案,但一旦选了ET工作模式就是固定的了。
ET高效的原因?
1.有效的通知数量多(即通知过后真正读了数据的通知次数)
- LT:缓冲区只要剩一点数据,每次 epoll_wait 都会上报事件,产生大量重复通知。
- ET:只有数据新增(状态变化瞬间)通知一次,有效事件数量更少,减少内核 / 用户态来回交互开销。
2.强制读完缓冲区,接收窗口及时放大,提升 TCP 吞吐。
TCP 接收窗口代表内核缓冲区剩余空闲容量。应用把内核缓冲区的数据全部读走,缓冲区空闲空间变多,接收窗口 win 变大;发送方收到更大的窗口,就能一次性发送更多数据,提升 TCP 吞吐。
补充:epoll 可读的低水位线(low water mark)
epoll 判定 fd 变为可读就绪的规则:
内核缓冲区里的数据量 ≥ 低水位线 → epoll 标记这个 fd 就绪,epoll_wait 返回通知你。
- 默认 TCP 读低水位线:1 字节。只要缓冲区有至少 1 个字节数据,就触发可读事件。
- 低水位线是 epoll/IO 多路复用的判定阈值,和 TCP 本身无关。
