文章目录
- 前言
-
- poll
-
- 接口介绍
- 基于poll实现的服务端
- poll的缺点
- epoll
-
- 接口介绍
- epoll的工作原理
- epoll的优点
- 基于epoll实现的服务端
- epoll的工作方式
- Reactor
- 作业部分
前言
在Linux网络编程中,高并发IO场景的性能瓶颈往往集中在“等待事件就绪”与“数据拷贝”的效率上——这也是高级IO技术的核心优化方向。此前我们已经梳理了五种IO模型的本质的区别,明确了同步与异步IO的核心边界,也初步探讨了select多路转接机制的用法与局限。而在实际工程应用中,面对更高的并发需求、更灵活的事件管理,select的不足逐渐凸显,由此诞生了poll、epoll等更高效的IO多路转接技术,搭配Reactor设计模式,成为高并发网络编程的主流解决方案。
本文将围绕高级IO的核心知识点展开,从select的改进方案poll入手,拆解其接口设计、实战实现与固有局限;再深入epoll的底层原理——包括三大核心接口的用法、eventpoll结构体的工作机制、红黑树与就绪队列的协同逻辑,以及LT(水平触发)与ET(边缘触发)两种工作模式的差异与应用场景;随后引入Reactor设计模式,解读其“异步监控事件、同步处理事件”的核心思想,适配高并发IO场景的设计思路;最后结合典型练习题与解析,巩固核心知识点,帮大家理清select、poll、epoll的优缺点对比。
poll
接口介绍

返回值:>0表示就绪的文件描述符数量 =0表示超时,没有fd就绪 =-1表示调用出错,此时会设置errno
作用:跟select一样
形参:
fds:输入输出型参数
nfds:表示需要监控的文件描述符总数
timeout:单位是毫秒,其他的跟select的差不多–外加一个:如果传的是-1的话,是无限阻塞,直到至少有一个fd的事件就绪才返回
poll这里使用的是struct pollfd的结构体,所以解决了select里面fd的上限的问题

fd:需要监控的文件描述符
events:需要监控fd的哪些事件(用户告诉内核的)
POLLIN:表示普通的读事件 POLLOUT:表示写事件 POLLPRI:高优先级的数据的读事件(比如:TCP的带外数据)
eg:可以POLLIN|POLLOUT
revents:输出型参数(内核告诉用户的)–表示这个fd的哪些事件就绪了
–也是用的上面那套宏
基于poll实现的服务端
static const uint16_t defaultport = 8888;
static const int fd_num_max = 64;
int defaultfd = –1;
int non_event = 0;
class PollServer
{
public:
PollServer(uint16_t port = defaultport) : _port(port)
{
for (int i = 0; i < fd_num_max; i++)
{
_event_fds[i].fd = defaultfd;
_event_fds[i].events = non_event;
}
}
bool Init()
{
_listensock.Socket();
_listensock.Bind(_port);
_listensock.Listen();
return true;
}
void Accepter()
{
// 我们的连接事件就绪了
std::string clientip;
uint16_t clientport = 0;
int sock = _listensock.Accept(&clientip, &clientport);
if (sock < 0) return;
int pos = 1;
for (; pos < fd_num_max; pos++) // 第二个循环
{
if (_event_fds[pos].fd != defaultfd)
continue;
else
break;
}
if (pos == fd_num_max)
{
lg(Warning, "server is full, close %d now!", sock);
close(sock);
// 扩容
}
else
{
_event_fds[pos].fd = sock;
_event_fds[pos].events = POLLIN;
_event_fds[pos].revents = non_event;
PrintFd();
// TODO
}
}
void Recver(int fd, int pos)
{
// demo
char buffer[1024];
ssize_t n = read(fd, buffer, sizeof(buffer) – 1);
if (n > 0)
{
buffer[n] = 0;
cout << "get a messge: " << buffer << endl;
}
else if (n == 0)
{
close(fd);
_event_fds[pos].fd = defaultfd; // 这里本质是从select中移除
}
else
{
close(fd);
_event_fds[pos].fd = defaultfd; // 这里本质是从select中移除
}
}
void Dispatcher()
{
for (int i = 0; i < fd_num_max; i++) // 这是第三个循环
{
int fd = _event_fds[i].fd;
if (fd == defaultfd)
continue;
if (_event_fds[i].revents & POLLIN)
{
if (fd == _listensock.Fd())
{
Accepter(); // 连接管理器
}
else // non listenfd
{
Recver(fd, i);
}
}
}
}
void Start()
{
_event_fds[0].fd = _listensock.Fd();
_event_fds[0].events = POLLIN;
int timeout = 3000; // 3s
for (;;)
{
int n = poll(_event_fds, fd_num_max, timeout);
switch (n)
{
case 0:
cout << "time out… " << endl;
break;
case –1:
cerr << "poll error" << endl;
break;
default:
// 有事件就绪了,TODO
cout << "get a new link!!!!!" << endl;
Dispatcher();
break;
}
}
}
~PollServer()
{
_listensock.Close();
}
private:
Sock _listensock;
uint16_t _port;
struct pollfd _event_fds[fd_num_max]; // 数组, 用户维护的!
};
poll的缺点
如果需要监控的文件描述符太多了的话,遍历就成主要矛盾了–但是poll还是需要遍历所有的fd,所以需要epoll
epoll
实际应用中最常用的是epoll,但是如果公司的平台太老了可能就只支持select这些
接口介绍

作用:创建一个epoll实例
形参:size:现在版本没啥用了,随便传个大于0的数都行
返回值:>=0表示的是epoll实例对应的文件描述符;=-1表示创建失败,错误原因由errno标识
注意:使用完之后要用close去关闭

作用:等待事件就绪
形参:
epfd:epoll实例对应的文件描述符
events:用于存储内核返回的就绪事件
maxevents:表示events数组的最大元素数量,但是不超过events数组的实际长度
timeout:单位是毫秒,跟poll的一样
返回值:>0表示就绪事件的数量;=0表示超时,没有事件就绪;=-1表示调用出错,并设置了errno
关于struct_epoll_event:

events也是通过位图的方式去标记事件类型的
data是用户用来关联事件对应信息的变量


作用:管理 epoll 实例内文件描述符及事件
形参:
epfd:epoll实例对应的文件描述符
op:常用的有三个
EPOLL_CTL_ADD:把fd添加到epfd对应的 epoll 实例的监控列表中
EPOLL_CTL_MOD:修改已经被添加到 epoll 实例中的fd对应的监控事件
EPOLL_CTL_DEL:把fd从epfd对应的 epoll 实例的监控列表中移除
fd:文件描述符
event:执行EPOLL_CTL_ADD和EPOLL_CTL_MOD时,表示fd里的什么事件要被监控;执行EPOLL_CTL_DEL时,传NULL就行了
返回值:0表示操作成功,-1操作失败并设置errno
epoll_ctl的本质其实就是对红黑树进行增删查改
epoll使用的三部曲:
调用epoll_create创建一个epoll实例
调用epoll_ctl, 将要监控的文件描述符进行注册
调用epoll_wait, 等待文件描述符就绪
epoll的工作原理
当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体
struct eventpoll{
struct rb_root rbr; //红黑树
struct list_head rdlist; //就绪队列
....
};
网卡收到数据后,会调用内核的callback函数
这个回调函数会干4件事:
1.把收到的数据交给TCP的接收队列
2.去红黑树里找到这个数据对应的fd和他要监控的事件
3.把这个已经就绪的fd和他要监控的事件打包成就绪队列的节点
4.把这个节点插入到就绪队列中
他的这个工作原理让用户在调用epoll_wait时,不需要遍历所有监控的fd,从就绪队列中取出就绪的节点就行了
引申:操作系统在硬件层面,怎么知道网卡上有数据–因为硬件中断
注意:epoll并没有使用内存映射机制
因为内存映射机制可以避免内存需要拷贝内存到用户层
但是struct epoll_event是在用户空间中分配好的内存;势必还是需要将内核的数据拷贝到这个用户空间的内存中的
epoll的优点
1.检测就绪是O(1),获取就绪是O(n)(因为要一个一个拷贝过去)
2.fd和event没用上限
3.就绪事件是连续的(在event参数指向的数组里面连续)
注意:
select, poll, epoll之间的优点和缺点 –非常重要,面试很常考
基于epoll实现的服务端
注释:nocopy是一个自己写的禁止拷贝的一个基类
下面这些是对epoll的一个封装
class Epoller : public nocopy
{
static const int size = 128;
public:
Epoller()
{
_epfd = epoll_create(size);
if (_epfd == –1)
{
lg(Error, "epoll_create error: %s", strerror(errno));
}
else
{
lg(Info, "epoll_create success: %d", _epfd);
}
}
int EpollerWait(struct epoll_event revents[], int num)
{
int n = epoll_wait(_epfd, revents, num, –1);
return n;
}
int EpllerUpdate(int oper, int sock, uint32_t event)
{
int n = 0;
if (oper == EPOLL_CTL_DEL)
{
n = epoll_ctl(_epfd, oper, sock, nullptr);
if (n != 0)
{
lg(Error, "epoll_ctl delete error!");
}
}
else
{
// EPOLL_CTL_MOD || EPOLL_CTL_ADD
struct epoll_event ev;
ev.events = event;
ev.data.fd = sock; // 目前,方便我们后期得知,是哪一个fd就绪了!
n = epoll_ctl(_epfd, oper, sock, &ev);
if (n != 0)
{
lg(Error, "epoll_ctl error!");
}
}
return n;
}
~Epoller()
{
if (_epfd >= 0)
close(_epfd);
}
private:
int _epfd;
int _timeout = 3000;
};
服务端:
uint32_t EVENT_IN = (EPOLLIN);
uint32_t EVENT_OUT = (EPOLLOUT);
class EpollServer : public nocopy
{
static const int num = 64;
public:
EpollServer(uint16_t port)
: _port(port),
_listsocket_ptr(new Sock()),
_epoller_ptr(new Epoller())
{
}
void Init()
{
_listsocket_ptr->Socket();
_listsocket_ptr->Bind(_port);
_listsocket_ptr->Listen();
lg(Info, "create listen socket success: %d\\n", _listsocket_ptr->Fd());
}
void Accepter()
{
// 获取了一个新连接
std::string clientip;
uint16_t clientport;
int sock = _listsocket_ptr->Accept(&clientip, &clientport);
if (sock > 0)
{
_epoller_ptr->EpllerUpdate(EPOLL_CTL_ADD, sock, EVENT_IN);
lg(Info, "get a new link, client info@ %s:%d", clientip.c_str(), clientport);
}
}
void Recver(int fd)
{
// demo
char buffer[1024];
ssize_t n = read(fd, buffer, sizeof(buffer) – 1);
if (n > 0)
{
buffer[n] = 0;
std::cout << "get a messge: " << buffer << std::endl;
//write
std::string echo_str = "server echo $ ";
echo_str += buffer;
write(fd, echo_str.c_str(), echo_str.size());
}
else if (n == 0)
{
lg(Info, "client quit, me too, close fd is : %d", fd);
//细节3
_epoller_ptr->EpllerUpdate(EPOLL_CTL_DEL, fd, 0);
close(fd);//这里要先移除再关闭–来保证fd是合法的
}
else
{
lg(Warning, "recv error: fd is : %d", fd);
_epoller_ptr->EpllerUpdate(EPOLL_CTL_DEL, fd, 0);
close(fd);
}
}
void Dispatcher(struct epoll_event revs[], int num)
{
for (int i = 0; i < num; i++)
{
uint32_t events = revs[i].events;
int fd = revs[i].data.fd;
if (events & EVENT_IN)
{
if (fd == _listsocket_ptr->Fd())
{
Accepter();
}
else
{
// 其他fd上面的普通读取事件就绪
Recver(fd);
}
}
else if (events & EVENT_OUT)
{
}
else
{
}
}
}
void Start()
{
_epoller_ptr->EpllerUpdate(EPOLL_CTL_ADD, _listsocket_ptr->Fd(), EVENT_IN);
struct epoll_event revs[num];
for (;;)
{
int n = _epoller_ptr->EpollerWait(revs, num);
if (n > 0)
{
// 有事件就绪
lg(Debug, "event happened, fd is : %d", revs[0].data.fd);
Dispatcher(revs, n);
}
else if (n == 0)
{
lg(Info, "time out …");
}
else
{
lg(Error, "epll wait error");//lg是自己写的一个日志
}
}
}
~EpollServer()
{
_listsocket_ptr->Close();
}
private:
std::shared_ptr<Sock> _listsocket_ptr;
std::shared_ptr<Epoller> _epoller_ptr;
uint16_t _port;
};
epoll的工作方式
epoll有两种工作方式
1.LT(水平触发)
2.ET(边缘触发)
epoll默认状态(也就是缺省工作状态)下是LT工作模式
select和poll的触发机制跟epoll的LT模式一样
LT工作模式:就是有就绪事件的话就一直通知
ET工作模式:就绪事件从无到有或者从有到多才会通知(数据内容变了,但是数据量没变的话也是不会通知的)
–LT就是次次都添加到就绪队列里面,ET就是变化时才添加一次
但是相比于LT,ET的通知效率和IO效率更高
–因为ET的通知次数远少于LT
–LT就算改成所有fd都是非阻塞并且循环读取,跟ET的差距也是很大的
ET的话,所有的fd必须是非阻塞,然后循环读取:
因为ET的通知触发机制倒逼程序员在设计时必须把本轮的数据全部读走,但是循环读取的话,fd默认是阻塞的
–这样搞了之后,还会让TCP向对方通告一个更大的窗口,让对方一次发送更多的数据的概率变大,效率进一步提高
Reactor
一种专门用来处理高并发 I/O 场景的网络编程设计模式
是一种半同步半异步模型
–异步监控事件 + 同步处理事件(有点像打地鼠)
反应堆相当于是玩家
地鼠洞是多个客户端连接 / 待处理的 I/O 事件
地鼠冒头相当于事件就绪了
用锤子打地鼠就是回调函数(提前定义好的处理逻辑)
还有一种设计就是一个线程一个Reactor,然后主线程采用负载均衡的策略,把收到的连接分配给其他的线程
引申:
1.把事件异常转化成读写问题可以简化代码
2.定时任务的超时管理逻辑:
用最小堆来维护一批定时任务;如果堆顶任务超时了,执行任务绑定的回调函数,并从堆中删除;如果堆顶任务还没超时,就更新所剩时间
作业部分
以下关于异步IO说法不正确的是(D)
A.System V的异步IO信号是SIGPOLL
B.POSIX异步IO接口使用AIO控制块来描述IO操作
C.当使用异步IO的时候,由内核在数据拷贝完成时, 通知应用程序
D.当使用异步IO的时候,由内核告诉应用程序何时可以开始拷贝数据//这是同步IO的特性
以下关于ET模式说法错误的是(B)
A.epoll_wait只有在客户端每次发数据时才会返回,除此以外即使接收缓冲区里还有数据也不会触发事件返回
B.在使用ET模式的时候描述符必须设置为阻塞模式
//在边缘模式下,通常是尽量设置为非阻塞操作,而并非阻塞操作
C.使用ET模式的时候最好使用循环读取,将自己需要处理的数据全部处理完毕。
D.ET事件发生仅通知一次的原因是只被添加到rdlist中一次,而LT可以有多次添加的机会
以下关于事件放入epoll等待队列说法不正确的是(D)
A.当LT模式下,有新数据到来才会加入到epoll等待队列中
B.有老数据,并且通过epoll_ctl设置EPOLL_CTL_MOD(ET模式)
//没有新数据,所以不会添加到就绪队列里面
C.数据可写,并且通过epoll_ctl设置EPOLL_CTL_MOD(ET模式)
//一直都是可写,没有从不可写变成可写,所以不会加入到就绪队列
D.以上说法中都不正确



