Linux:五种IO模型
前言:
本片将介绍五种IO模型的理念,以及重点讲述非阻塞IO以及多路转接IO模式的具体使用。
五种IO模型的理论概念:

我们常熟知的IO可能就分为阻塞IO与非阻塞IO,举一个简单的例子,在使用sacnf或者cin时候,操作系统就会等待用户从键盘拷贝数据,当用户按下回车键时候则表示数据准备就绪,操作系统就从0号文件描述符下标(标准输入)的文件开始拷贝数据到内存里,而如果用户一直不按回车时,进程就会一直阻塞住直到用户按下回车为止。如果采用非阻塞调用用户迟迟不安下回车键时,进程并不会一直阻塞住,而是会采用轮询检测方式来监测数据是否准备好,可以简单理解为非阻塞就是用循环一直去检测数据是否准备就绪。
因此我们是否可以简单理解为 IO=等+拷贝数据,第一步等待数据就绪,第二步拷贝数据。
一个钓鱼的例子:
上图举例了5种人分别不同的钓鱼方式,他们各自用自己的方式钓鱼,而他们不同钓鱼的方式本质可以归为五种不同的IO模型:阻塞IO,非阻塞IO,信号驱动IO,多路检测IO,以及异步IO。除了异步IO外其他都归于同步IO。我们说一个人的钓鱼效率高,本质是他上鱼的速度快,因为钓鱼究其本质分为两个步骤,等鱼+钓鱼与IO十分类似。
非阻塞IO比阻塞IO效率高?
如果有人跟你说非阻塞IO比阻塞IO效率高,那么这个人绝对对IO是一知半解的。就像张三和李四,张三与李四同用一根鱼竿钓鱼,他们两唯一的区别就是张三是死死盯着鱼鳔,而李四是时不时瞟一眼,剩下时间做自己的事情。
但不要忘记,我们之前说个人的钓鱼效率高,本质是他上鱼的速度快,缩短了钓鱼的时间,而李四只是把等鱼的时间利用起来,但并没有缩短钓鱼事件,有可能他钓了一天,手机玩了一天,一条鱼也没钓到。
总结:非阻塞与阻塞IO效率是一样的。
谁的效率高?
观察上图五人的钓鱼方式,可以显而易见的是赵六的钓鱼效率是最高的。因为他有100根鱼竿同时参与钓鱼。假设我是鱼,我面前有104根鱼竿,我随机咬一根,其他人的概率都是1/104,而赵六的概率为100/104,因此多路转接IO 的效率是最高的,因为他缩短了等比重。
同步IO与异步IO:
除了田七外,其他所有人都属于同步IO。不要看王五是信号通知IO就不属于同步IO,虽然王五没有直接参与等的过程,但当王五上鱼时,他自己也同样要拿起鱼竿钓鱼。
只有田七除外,田七本身只是发起了钓鱼的任务,但田七本身并不参与钓鱼,钓鱼的是随从。
所以区分同步IO与异步IO最重要的关键在于,自己本身是否有参与IO的过程,及等或拷贝,只要有参与任何一项都视为同步IO,其他为异步IO。
非阻塞IO:
int fcntl(int fd, int cmd, ... /* arg */ );
fcntl用于对已打开的文件描述符进行各种控制操作。它的功能非常丰富,可以用于获取或设置文件描述符的属性,例如文件状态标志、文件锁等。
第一个参数是文件描述符fd,用于指定控制哪一个文件描述符
第二个参数cmd表示控制命令,指定要执行的操作类型
之后为可选参数。

我们知道fd指向的是文件系统里一个file_struct 该结构体描述了一个文件的各种属性。而其中flag是file_struct的其中一个属性,用于表示该文件是读写,是阻塞还是非阻塞调用。
因此F_GETFL表示获取该fd里的flag标志位,F_SETFL标色设置flag标志位属性后与O_NONBLOCK结合使用。
返回值:
成功时,根据不同的命令返回不同的值。失败时,返回-1,并设置errno。这里需要说明当非阻塞调用时,如果发现文件数据没有就绪,就会返回-1,并且错误码被设置,但并不表示函数调用失败,因此还需要区分错误码是真的错了还是数据没有准备好,如果是没有准备好则需要继续轮询检测。
错误码等于EAGAIN则表述数据没准备好。


上图示例,将0号fd(标准输入/键盘输入)的flag设置为非阻塞后,可以看到在使用read系统调用后进程就不会阻塞住,而是往下接着执行。在n<0后还需接着判断全局指针erron的属性是否是 EAGAIN,是的话就表示数据还没准备好。
Select多路转接:
IO=等+拷贝,而select只做一件事情就是等。Select可以一次性等待多个fd事件。当有一个或者多个fd事件就绪,select就会通知调用方,有哪些fd已经就绪。
可读可写?
Fd的事件分为就绪,读事件就绪以及写事件就绪。可读表示当底层缓冲区有数据就表示读事件就绪。
而可写的意思则不同,它表示底层有剩余空间就表示可写事件就绪,当底层的输入缓冲区没有剩余空间了,则表示写事件不就绪。

从上示意图可以很简单明了的看到,当select返回读事件就绪时,再调用recv后肯定****不会阻塞,因为此时读事件一定就绪。****如果直接使用recv而不使用select,可能就是阻塞调用,因为recv内部会等待读事件就绪。
系统调用select函数:

- nfds:该参数表示你传入的最大fd+1
- Timeout:是一个结构体

如果传入的timeout结构体为 NULL 表示阻塞调用select直到有fd就绪才返回,如果为0表示非阻塞调用。而如果在结构体里设置具体时间则表示,在单位时间内阻塞调用,超时返回。
- *Readfds:readfds是一个fd_set类型的数组,并且是一个位图结构。每一个bit位都可以表示一个fd。而只要有具体类型就表示这个类型有大小上限的。在windows环境下大小位520字节*8=4160bit,也就是说fd_set最大可以存储4160个fd。


readfds还是一个输入输出参数,当用户传入时表示用户告诉内核需要关注哪些fd,当内核传出时会修改readfds,表示有哪些fd就绪。但也侧面说明了readfds数值不是固定的,是会变动。但这也就表明如果有些fd这次没有就绪,下一次如果不重新调整readfds就不会关心了。
Readfds只关系读,如果还想关心写或者异常,同样的传入writefds与exceptfds。
返回值:
select返回值有三种情
>0:表示有fd就绪
=0:表示超时,没有fd就绪
<0:表示select出错,错误码被设置。
采用Select方案实现socket通信:
Socket.hpp:
cpp
#pragma once
#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cstdlib>
#include "Common.hpp"
#include "Log.hpp"
#include "InetAddr.hpp"
namespace SocketModule
{
using namespace LogModule;
const static int gbacklog = 16;
// 模版方法模式
// 基类socket, 大部分方法,都是纯虚方法
class Socket
{
public:
virtual ~Socket() {}
virtual void SocketOrDie() = 0;
virtual void BindOrDie(uint16_t port) = 0;
virtual void ListenOrDie(int backlog) = 0;
// virtual std::shared_ptr<Socket> Accept(InetAddr *client) = 0;
virtual int Accept(InetAddr *client) = 0;
virtual void Close() = 0;
virtual int Recv(std::string *out) = 0;
virtual int Send(const std::string &message) = 0;
virtual int Connect(const std::string &server_ip, uint16_t port) = 0;
virtual int Fd() = 0;
public:
void BuildTcpSocketMethod(uint16_t port, int backlog = gbacklog)
{
SocketOrDie();
BindOrDie(port);
ListenOrDie(backlog);
}
void BuildTcpClientSocketMethod()
{
SocketOrDie();
}
// void BuildUdpSocketMethod()
// {
// SocketOrDie();
// BindOrDie();
// }
};
const static int defaultfd = -1;
class TcpSocket : public Socket
{
public:
TcpSocket() : _sockfd(defaultfd)
{
}
TcpSocket(int fd) : _sockfd(fd)
{
}
~TcpSocket() {}
void SocketOrDie() override
{
_sockfd = ::socket(AF_INET, SOCK_STREAM, 0);
if (_sockfd < 0)
{
LOG(LogLevel::FATAL) << "socket error";
exit(SOCKET_ERR);
}
int opt = 1;
setsockopt(_sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
LOG(LogLevel::INFO) << "socket success: " << _sockfd;
}
void BindOrDie(uint16_t port) override
{
InetAddr localaddr(port);
int n = ::bind(_sockfd, localaddr.NetAddrPtr(), localaddr.NetAddrLen());
if (n < 0)
{
LOG(LogLevel::FATAL) << "bind error";
exit(BIND_ERR);
}
LOG(LogLevel::INFO) << "bind success";
}
void ListenOrDie(int backlog) override
{
int n = ::listen(_sockfd, backlog);
if (n < 0)
{
LOG(LogLevel::FATAL) << "listen error";
exit(LISTEN_ERR);
}
LOG(LogLevel::INFO) << "listen success";
}
//std::shared_ptr<Socket> Accept(InetAddr *client) override
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; // TODO
}
return fd;
// client->SetAddr(peer);
// return std::make_shared<TcpSocket>(fd);
}
// n == read的返回值
int Recv(std::string *out) override
{
// 流式读取,不关心读到的是什么
char buffer[4096*2];
ssize_t n = ::recv(_sockfd, buffer, sizeof(buffer) - 1, 0);
if (n > 0)
{
buffer[n] = 0;
*out += buffer; // 故意
}
return n;
}
int Send(const std::string &message) override
{
return send(_sockfd, message.c_str(), message.size(), 0);
}
void Close() override //??
{
if (_sockfd >= 0)
::close(_sockfd);
}
int Connect(const std::string &server_ip, uint16_t port) override
{
InetAddr server(server_ip, port);
return ::connect(_sockfd, server.NetAddrPtr(), server.NetAddrLen());
}
int Fd()
{
return _sockfd;
}
private:
int _sockfd; // _sockfd , listensockfd, sockfd;
};
// class UdpSocket : public Socket
// {
// };
}
Select.hpp:
cpp
#pragma once
#include <iostream>
#include <sys/select.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
#include"Socket.hpp"
#include"InetAddr.hpp"
#include<memory.h>
using namespace SocketModel;
const int defaultfactor=4;
class SelectServer
{
const int defaultfd=-1;
const int maxfdsize=sizeof(fd_set)*8;
public:
SelectServer(int port,int factor=defaultfactor)
:_listensocket(std::make_unique<TcpSocket>(port))
,_factor(factor)
{
_array=new int[_factor];
_listensocket->BulidTcpSocketMethod(port);
for(int i=0;i<_factor;i++)
{
_array[i]=defaultfd;
}
_array[0]=_listensocket->Fd();
}
void Start()
{
if(running==true)
return;
int maxfd=defaultfd;
fd_set readfd;
FD_ZERO(&readfd);
while (true)
{
for (size_t i = 0; i < _factor; i++)
{
if(_array[i]!=defaultfd)
{
FD_SET(_array[i],&readfd);
if(maxfd<_array[i])
maxfd=_array[i];
}
}
int n=select(maxfd+1,&readfd,nullptr,nullptr,nullptr);
if(n>0)
{
LOG(LogLeve::INFO)<<"有事件就绪";
Assign(readfd);
}
else if(n==0)
{
std::cout<<"time out"<<std::endl;
continue;
}
else
{
std::cout<<"select error"<<std::endl;
continue;
}
}
running=false;
}
void Assign(fd_set &readfd)
{
for (int i = 0; i < _factor; i++)
{
if(FD_ISSET(_array[i],&readfd))
{
if(_array[i]==_listensocket->Fd())
{
Accept();
}
else
{
Recv(i);
}
}
}
}
void Accept()
{
InetAddr clinet;
int sockid=_listensocket->AcceptOrdie(clinet);
LOG(LogLeve::INFO)<<"get new link,Client is"<<clinet.IP();
int pos=0;
for ( ;pos < _factor; pos++)
{
if(_array[pos]==defaultfd)
break;
}
if(pos==_factor)
{
if(pos>=maxfdsize)
{
LOG(LogLeve::ERROR)<<"超出最大容量";
close(sockid);
}
std::cout<<"容量已满,进行扩容"<<std::endl;
ExCapacity();
}
else
{
_array[pos]=sockid;
}
}
void ExCapacity()
{
int newfactor=_factor*2;
int*newcapacity=new int [newfactor];
for (int i = 0; i < newfactor; i++)
{
if(i>=_factor)
{
newcapacity[i]=defaultfd;
}else
{
newcapacity[i]=_array[i];
}
}
delete[] _array;
_array=newcapacity;
_factor=newfactor;
}
void Recv(int pos)
{
char buff[1024];
int n=read(_array[pos],buff,sizeof(buff)-1);
if(n>0)
{
buff[n-1]=0;
LOG(LogLeve::INFO)<<"client say:"<<buff;
}
else if (n==0)
{
LOG(LogLeve::INFO)<<"client quit";
close(_array[pos]);
_array[pos]=defaultfd;
}else
{
LOG(LogLeve::ERROR)<<"Recv error";
}
}
private:
int*_array;
int _factor;
std::unique_ptr<Socket>_listensocket;
int running=false;
};
拆解Select.hpp:
成员变量:

首先为什么会有一个_array,这是因为我们之前说过,如果单纯的只靠readfds,在调用完之后历史的fd将会被刷新,不会被保存。因此我们需要一个辅助函数来保存历史fd数据,每次调用select之前,都对readfds做一次修正,确保能关心到所有需要的fd。
其余的_factor负载因子则是用来扩容的,其实直接使用数组也是可以的。而_listensocket与runnning则不需要特别说明,就是用来监听通信的已经被我封装好的套接字。
构造函数:

在构造函数之上还有一个defaultfd,以及maxfdsize。Defaultfd是用来初始化_array数组的,maxfdsize则是用来判断当前最大select是否超出。
构造函数就是先建立listen套接字进行创建socket,绑定监听,以及初始化array,并且将创建好的listensock套接字放入_array[0]下标里。
Void Start():

start函数里其他都很好理解,在while死循环里每次死循环开始都会刷新readfd,而一开始时候只有一个listensock的套接字会被保存进去,并且还需要记录最大的maxfd。此时readfd还没被设置到内核里,所以接着调用select函数,并设置阻塞调用。
当有事件就绪时候底层select就会通知会上层,并返回n,接着就可以调用Assign分配任务。
Void Assign(fd_set &readfd):

Assign函数里会对每一个就绪的fd进行获取数据,但不能简单的进行获取,因为listensocket的套接字比较特殊,当listensocket获取到数据表示底层有新的连接,需要用accpet进行获取新连接。所以Assign里还需要区分是普通socket还是listensocket。
Accept函数:

在Accpet函数里此时调用底层accept获取连接时一定不会阻塞。
获取到连接后就需要把新sock放入_array数组里进行下次的select检测,这是理所应当的。因为获取新链接后,如果客户端有数据发送给服务端,服务端就需要调用recv函数进行读取。而你不能保证客户端说明时候发送数据给服务端,所以还是跟钓鱼一样,服务端需要进行等待。因此等待的操作就交给select。
这里也做了一些防止数组溢出和检查是否需要扩容的措施也很好理解。Recv就不细说了。
Select的特点:
Select可以监听多个fd,在将fd加入select的监听范围内时,还需要一个array的辅助数组来保存历史fd。
每次调用select时,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大。
Select的缺点:
每次调用select,都需要手动重置fd集合,从使用角度来说也是非常不便。
Select支持的文件描述符数量太小。
POLL:
Poll是select后续的优化版本,其功能与select一直,使用poll可以一次等待多个文件描述符就绪。
int poll 函数

int poll(struct pollfd *fds, nfds_t nfds, int timeout);
Timeout:与select类似,null表示非阻塞调用,0表示阻塞调用,设置具体时间则表示在该时间阻塞调用,超时返回。
Nfds:表示传入的元素个数.
Struct pollfd:是一个结构体类型,该结构体里包含三个参数
Int fd表示文件描述符
Short events:表示用户传入内核的事件
Short revents:表示内核返回给用户的事件
而设置events与判断revents是需要用OS提供给上层特定的宏,POLLIN 表示读事件,POLLOUT表示写事件
Events|=POLLIN :设置需要关心的事件 Revents&POLLIN 判断是否是写事件。
POLL是Select的优化版本,那Poll解决了select的什么问题?
首先从events与revents中可以看出,Poll解决了select中的输入输出参数重复的问题, 用户将事件设置进event后,只需要关心revent即可,并且不需要像select一样重置readfd参数
poll等待的fd个数没有上限,需要多少fd,就new多少个struct pollfd 使用,并且如果将fd设置成-1,则内核不会关心这类fd的events。
改写POLL方案实现socket通信:
PollServer.hpp:
cpp
#pragma once
#include <iostream>
#include <signal.h>
#include <poll.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
#include "Socket.hpp"
#include "InetAddr.hpp"
#include <memory.h>
using namespace SocketModel;
const int defaultfactor = 4;
class PollServer
{
const int defaultfd = -1;
public:
PollServer(int port, int factor = defaultfactor)
: _listensocket(std::make_unique<TcpSocket>(port)), _factor(factor)
{
_array = new struct pollfd[_factor];
_listensocket->BulidTcpSocketMethod(port);
for (int i = 0; i < _factor; i++)
{
_array[i].fd=defaultfd;
_array[i].events=0;
_array[i].revents=0;
}
_array[0].fd= _listensocket->Fd();
_array[0].events=POLLIN;
}
void Start()
{
if (running == true)
return;
while (true)
{
int n = poll(_array,_factor,-1);
if (n > 0)
{
LOG(LogLeve::INFO) << "有事件就绪";
Assign( );
}
else if (n == 0)
{
std::cout << "time out" << std::endl;
continue;
}
else
{
std::cout << "select error" << std::endl;
continue;
}
}
running = false;
}
void Assign()
{
for (int i = 0; i < _factor; i++)
{
if (_array[i].revents&POLLIN)
{
if (_array[i].fd == _listensocket->Fd())
{
Accept();
}
else
{
Recv(i);
}
}
}
}
void Accept()
{
InetAddr clinet;
int sockid = _listensocket->AcceptOrdie(clinet);
LOG(LogLeve::INFO) << "get new link,Client is" << clinet.IP();
int pos = 0;
for (; pos < _factor; pos++)
{
if (_array[pos].fd == defaultfd)
break;
}
if (pos >= _factor)
{
std::cout << "容量已满,进行扩容" << std::endl;
ExCapacity();
}
else
{
_array[pos].fd = sockid;
_array[pos].events=POLLIN;
}
}
void ExCapacity()
{
int newfactor = _factor * 2;
struct pollfd *newcapacity = new struct pollfd [newfactor];
for (int i = 0; i < newfactor; i++)
{
if (i >= _factor)
{
newcapacity[i].fd = defaultfd;
newcapacity[i].events=newcapacity[i].revents=0;
}
else
{
newcapacity[i].fd = _array[i].fd;
newcapacity[i].events=_array[i].events;
newcapacity[i].revents=_array[i].revents;
}
}
delete[] _array;
_array = newcapacity;
_factor = newfactor;
}
void Recv(int pos)
{
char buff[1024];
int n = read(_array[pos].fd, buff, sizeof(buff) - 1);
if (n > 0)
{
buff[n - 1] = 0;
LOG(LogLeve::INFO) << "client say:" << buff;
}
else if (n == 0)
{
LOG(LogLeve::INFO) << "client quit";
close(_array[pos].fd);
_array[pos].fd = defaultfd;
}
else
{
LOG(LogLeve::ERROR) << "Recv error";
}
}
private:
struct pollfd *_array;
int _factor;
std::unique_ptr<Socket> _listensocket;
int running = false;
};
poll的优点:
不同于select使用三个位图来表示三种方式的fdset的发生方式,poll用一个结构体指针来实现,并且poll没有上限,poll的上限取决于内存上限。
每次调用poll同样的需要将大量pollfd的结构体从用户态拷贝到内核态。
Pool的缺点:
与select一样,poll在返回后,同样需要采用循环的方式,来获取就绪的文件描述符。并且在同时连接的大量客户端中,只有少部分处于就绪时刻,因此随着链接的增多效率也会持续下降。
最终的Epoll:
Epoll是多路转接的最终版本,也是最实用的一版,它解决了select与poll的缺点,也就是当poll链接的数量多了,效率会线性下降的问题。
接口介绍:
函数 int epoll_create(int size);
可以简单的为返回一个文件描述符fd,并且该fd与后续的epoll_ctl与epoll_wait相关联。
函数 int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

Int Epfd:需传入在epoll_create中创建的fd参数
Int op:表示需要进行的操作是增(ADD),删除(DEL),修改(MOD)
Int fd:需传入需要关心的fd
struct epoll_event:是一个结构体类型,结构体里包含着events事件与poll类似,以及一个epoll_data_t data,该data本质也是一个文件描述符,只是说可以选择传入的fd类型可以为指针,以及不同大小的int。可能会有以后,我们不是已经在之前的参数中传入了fd,为什么这里还需要再传入一遍。这里到epoll_wait会进行解释。
返回值:调用成功返回0,否则-1被返回。
函数int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);

Int epfd:与epoll_ctl一样需传入epoll_create 创建的fd。
struct epoll_event *events:这个是需要用户自己建立一个struct epoll_event 类型的指针,后续就绪的事件都会放入这个指针里,并且我们知道当事件就绪后我们需要获取就绪事件的fd,因此再epoll_ctl里二次放入的fd就在这里体现作用。
Int maxevents:传入struct epoll_event 指针的最大容量,是左闭右开的形式。
Int timeout:与之前时间参数类似,-1阻塞调用,0非阻塞轮询,具体时间内阻塞,结束返回。
当我们在 Linux 网络编程中使用 epoll 实现 IO 多路复用时,它的底层运行机制可以拆解为以下完整过程:
Linux epoll 底层运行机制详解

一、epoll实例初始化
当执行 epoll_create1(0) 时,内核会创建一个名为 eventpoll 的核心结构体,这是epoll实例的"总控中心"。该结构体包含三个关键部分:
红黑树:存储所有被监控的文件描述符(fd)及其对应事件(如读、写事件),以(fd, file指针)为键值,确保操作时间复杂度为O(logN)
就绪双向链表:专门存放已经触发就绪事件的epitem节点
进程等待队列:当调用epoll_wait且无就绪事件时,用于挂起当前进程
每个被监控的fd都会关联一个ep_poll_callback回调函数,当fd就绪时,内核会调用这个函数将对应节点加入就绪链表。
二、监控fd的注册与管理
通过epoll_ctl系统调用注册监控fd时,内核执行以下操作:
1. 添加监控(EPOLL_CTL_ADD)
① 封装节点:将传入的socket fd和关注事件封装成epitem结构体,该节点同时作为红黑树和就绪链表的节点
② 插入红黑树:检查红黑树中是否存在该fd,若存在则返回EEXIST错误;若不存在则插入节点,时间复杂度为O(logN)
③ 注册回调:为socket对应的内核文件对象的poll操作(如tcp_poll、udp_poll)注册ep_poll_callback回调函数
2. 修改/删除监控
修改事件(EPOLL_CTL_MOD):在红黑树中找到对应epitem节点,更新事件掩码
删除监控(EPOLL_CTL_DEL):从红黑树中移除节点,并取消回调函数注册
红黑树的高效查找特性使epoll在管理大量fd时仍能保持高性能。
三、网络数据到达与事件触发
当网络数据包到达时的完整处理流程:
1. 数据接收处理
① 硬件中断:网卡收到数据包后触发硬件中断
② 软中断处理:在高速网络场景下,Linux使用NAPI机制和软中断(NET_RX_SOFTIRQ)处理数据包,避免频繁中断
③ 协议栈处理:数据包经内核协议栈(TCP/IP层)解析后,放入对应socket的接收缓冲区
④ 状态更新:协议栈将socket标记为就绪状态
2. 回调触发机制
当socket状态变为就绪时:
① 调用poll方法:内核调用socket的poll操作(如tcp_poll)
② 触发回调:poll方法检查到有epoll实例监控此socket,调用对应的ep_poll_callback函数
③ 加入就绪链表:回调函数从红黑树中找到对应epitem节点,将其添加到eventpoll的就绪链表中
④ 唤醒等待进程:如果eventpoll的等待队列中有进程阻塞,则唤醒它们
关键点:回调函数不是"检测数据",而是在socket就绪后被内核主动调用,每个监控的fd都有独立的回调函数。
四、获取就绪事件
调用epoll_wait时的内核处理逻辑:
- 就绪链表非空(立即返回 时间为O(N))
① 拷贝事件信息:将就绪链表中所有epitem节点的事件信息(fd和就绪事件类型)拷贝到用户态缓冲区
② 返回就绪数量:返回就绪fd的数量,用户程序可直接遍历这些fd处理数据
- 就绪链表为空(进程阻塞 检查时间为O(1))
① 进程挂起:将当前进程加入eventpoll的等待队列,进程进入阻塞状态
② 等待事件或超时:进程放弃CPU使用权,直到:
有fd触发就绪事件(回调函数唤醒进程)
等待超时
③ 唤醒处理:被唤醒后,epoll_wait继续执行,拷贝就绪事件并返回
- 触发模式处理差异
内核根据触发模式决定是否从就绪链表中移除节点:
水平触发(LT,默认模式):如果事件未被完全处理(如缓冲区数据未读完),节点会保留在就绪链表中,下次epoll_wait会再次返回该事件
边缘触发(ET):只有在状态变化时(如从无可读数据变为有可读数据)才通知一次,节点从就绪链表移除,即使数据未完全读取也不会重复通知
- 数据读取
epoll_wait仅返回就绪事件信息,不读取socket缓冲区的数据。应用程序需要通过recv或read系统调用主动读取数据。
五、同步与并发处理
- 同步机制
自旋锁保护:内核使用自旋锁保护eventpoll结构,确保多CPU环境下就绪链表的操作安全
原子操作:关键状态更新使用原子操作避免竞态条件
- 文件描述符管理
epoll_create返回的也是一个文件描述符,它有自己的file_operations,包含epoll特有的操作。当epoll fd被关闭时,内核会自动清理所有相关资源。
六、epoll与select/poll的核心差异
select/poll的局限性
每次调用需全量拷贝:每次调用都需要将用户态的fd集合拷贝到内核态
线性遍历开销大:内核需要遍历所有传入的fd检测就绪状态,时间复杂度O(N)
进程挂起机制低效:无就绪事件时,进程被加入每个监控socket的等待队列,唤醒后需二次遍历所有fd确认就绪状态
fd数量限制:select受FD_SETSIZE限制(通常1024),poll理论上无限制但性能随fd数量线性下降
epoll的核心优势
一次注册,多次等待:仅在epoll_ctl注册fd时做一次拷贝,后续epoll_wait无需重复拷贝
事件驱动,效率极高:通过回调机制+就绪链表,内核只需处理就绪的fd,时间复杂度降为O(M)(M为就绪fd数量)
进程挂起机制优化:进程仅挂在epoll实例的等待队列中,避免"惊群"问题
无fd数量限制:红黑树结构支持海量连接管理
支持两种触发模式 :
水平触发(LT):类似select/poll的行为,容易使用
边缘触发(ET):高性能模式,减少事件通知次数
内核内存共享:epoll实例在内核中持续存在,避免重复初始化开销
Epoll方案实现通信:
cpp
#pragma once
#include <iostream>
#include <signal.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
#include "Socket.hpp"
#include <sys/epoll.h>
#include "InetAddr.hpp"
#include <memory.h>
using namespace SocketModel;
const int defaultfactor = 4;
class EPollServer
{
const int defaultfd = -1;
public:
EPollServer(int port, int factor = defaultfactor)
: _listensocket(std::make_unique<TcpSocket>(port)), _factor(factor)
{
_listensocket->BulidTcpSocketMethod(port);
_epfd=epoll_create(1111);
_revents=new epoll_event[_factor];
for (size_t i = 0; i < factor; i++)
{
_revents[i].events=-1;
_revents[i].data.fd=-1;
}
struct epoll_event event;
event.events=EPOLLIN;
event.data.fd=_listensocket->Fd();
int n=epoll_ctl(_epfd,EPOLL_CTL_ADD,_listensocket->Fd(),&event);
if(n==0)
{
LOG(LogLeve::INFO)<<"epoll set listensock success";
}else if(n<0)
{
LOG(LogLeve::INFO)<<"epoll set listensock fail";
exit(-1);
}
}
void Start()
{
if (running == true)
return;
while (true)
{
int n = epoll_wait(_epfd,_revents,_factor,-1);
if (n > 0)
{
LOG(LogLeve::INFO) << "有事件就绪";
Assign(n);
}
else if (n == 0)
{
std::cout << "time out" << std::endl;
continue;
}
else
{
std::cout << "select error" << std::endl;
continue;
}
}
running = false;
}
void Assign(int n)
{
for (int i = 0; i < n; i++)
{
if(_revents[i].data.fd==_listensocket->Fd())
{
Accept();
}
else
{
Recv(i);
}
}
}
void Accept()
{
_capacity++;
InetAddr clinet;
int sockid = _listensocket->AcceptOrdie(clinet);
LOG(LogLeve::INFO) << "get new link,Client is" << clinet.IP();
struct epoll_event event;
event.events=EPOLLIN;
event.data.fd=sockid;
if(_capacity>=_factor)
{
ExCapacity();
}
int n=epoll_ctl(_epfd,EPOLL_CTL_ADD,sockid,&event);
}
void ExCapacity()
{
int newfactor = _factor * 2;
struct epoll_event *newrevents = new epoll_event [newfactor];
memcpy(newrevents,_revents,sizeof(_revents)*_factor);
for(int i=_factor;i<newfactor;i++)
{
newrevents[i].data.fd=-1;
newrevents[i].events=-1;
}
delete []_revents;
_revents=nullptr;
_revents=newrevents;
_factor=newfactor;
}
void Recv(int pos)
{
char buff[1024];
int n = read(_revents[pos].data.fd,buff,sizeof(buff)-1);
if (n > 0)
{
buff[n - 1] = 0;
LOG(LogLeve::INFO) << "client say:" << buff;
}
else if (n == 0)
{
LOG(LogLeve::INFO) << "client quit";
close(_revents[pos].data.fd);
_revents[pos].data.fd= -1;
}
else
{
LOG(LogLeve::ERROR) << "Recv error";
}
}
private:
int _epfd;
struct epoll_event*_revents;
int _factor;
int _capacity=0;
std::unique_ptr<Socket> _listensocket;
int running = false;
};
理解Epoll的水平触发模式(LT)与边缘触发模式(ET):

通过上图示例,我们可以得出,Epoll工作模式里的LT与ET,主要区别在于再LT模式下,如果事件来临,Epoll则会一直通知上层,直到上层吧数据取走。而在ET模式下Epoll只会通知一次,如果上层没有取走,那么就等待下次新事件通知再一并取走,如果没有事件通知就拿不到数据。
所以如果使用LT工作模式,那么上层就可以对新到的数据不是那么关心,可以一次只取一部分。但如果是ET工作模式,就会倒逼着上层必须一次性将本轮所有数据取完。
ET工作模式下的IO处理模式:

上图假设Recv缓冲区此时来了3000个数据,而读取一次读100字节。但由于我们是处在上帝视角下因此我们直到Recv有3000个数据,而上层是不知道的。所以当上层读了30次数据后,第31次你读不读?答案是肯定读。那么这一读取就出问题了,由于缓冲区没有数据,recv就会阻塞住,本来我们采用Epoll就是为了不让进程陷入单独的阻塞调用模式。
因此使用ET工作模式就必须采用非阻塞调用IO,将Recv设置非阻塞模式,当在第31次调用时,发现没有数据就会报错返回,错误码宏值为EAGAIN。
结论:采用ET工作模式必须配置非阻塞IO。
LT与ET谁更高效?
ET的工作特性,LT也能够完成但LT并非强制性。因此使用ET工作模式就会强制性约束程序员必须使用非阻塞读取IO。能够增加IO读写方式的确定性。
而ET的通知效率更高,有效通知数量也最多。ET强逼程序员尽快读取完所有数据,因此就能给发送方直到一个更大的win窗口,提高TCP传输效率。