0%

事件驱动reactor的原理与实现

上文拾遗

1.对于断开连接的处理
当tcp客户端断开时我们的recv返回的值为0,因此我们的输出会不断出现
recv:0
由此我们学要对我们的client_thread方法进行修改
‘’’c/c++
if(count==0)//disconnect
{
printf(“disconnected\n”);
close(clientfd);
break;
}
‘’’

2.网络io
io就是fd(linux下面一切皆文件)
fd则是一个不断递增的int
其中0,1,2是被系统固定为stdin,stdout,stderr
每一个进程的io数量是有限制的
当一个fd被close时会被回收此时下一次分配fd时会自动分配最小的fd(close后需要等待一段时间,回收时间可以通过系统修改)

io多路复用

我们的代码目前是一请求一线程
优点:
代码逻辑简单
缺点:
不利于并发(线程越多会导致内核调度的负担越重)c1k

select

作用:让程序能同时监控多个文件描述符(如网络套接字),并在它们中的任何一个(或几个)准备好进行 I/O 操作时被唤醒并返回。
当没有io可以使用时则会阻塞在select函数

使用步骤
1.定义集合 fd_set read_fds;定义一个 fd_set 类型的变量
2.清空集合 FD_ZERO(&read_fds); 必须先清空集合,移除所有残留的描述符。
3.添加描述符 FD_SET(fd, &read_fds); 把需要监控的套接字(如 listen_fd)添加进集合。
4.复制集合 tmp_fds = read_fds; select() 注意!!!会修改原集合,所以必须复制一份副本传入。
5.调用 select select(max+1, &tmp_fds, …) 程序在这里阻塞,等待事件发生。
6.判断就绪 FD_ISSET(fd, &tmp_fds) 遍历所有关心的 fd,检查哪一个在集合中仍被置位,即代表它已就绪。
7.处理事件 accept(), read(), write() 调用对应的 I/O 函数处理就绪的套接字,这些函数现在会立即返回。

‘’’c/c++
fd_set rfds,rset;//fd的集合,本质是一个位图
FD_SET(socketfd,&rfds);//相当于把对应的fd的bit位置为1
int maxfd=socketfd;//相当为fd集合遍历的最大值
while(1)
{
rset=rfds;
int nready=select(maxfd+1,&rset,NULL,NULL,NULL);//max+1,比如说此时maxfd为9但是要遍历10次,其他参数依次为可读集合,可写集合,erro,timeout返回
if(FD_ISSET(socketfd,&rset))//监听的fd有连接
{
struct sockaddr_in clientaddr;
socklen_t len=sizeof(clientaddr);
int clientfd=accept(socketfd,(struct sockaddr*)&clientaddr,&len);
printf(“accept fd:%d\n”,clientfd);
FD_SET(clientfd,&rfds);
if(clientfd>maxfd)maxfd=clientfd;//防止fd被回收后再被分配导致clientfd不是最大值
}
for(int clientfd=socketfd+1;clientfd<=maxfd;clientfd++)//处理其他有fd,由于socketfd一定是最小的fd故可以从sockefd开始
{
if(FD_ISSET(clientfd,&rset))
{
char buffer [1024]={0};
int count = recv(clientfd,buffer,1024,0);
if(count==0)//disconnect
{
printf(“disconnected\n”);
close(clientfd);
FD_CLR(clientfd,&rfds);
break;
}
printf(“recv %d %s\n”,count,buffer);
send(clientfd,buffer,count,0);
printf(“send %d %s\n”,count,buffer);
}
}
}
‘’’

是fd_set?
本质是一个bit位集合

注意事项
1.每次调用需要把fd_set集合从用户空间复制到内核空间
2.maxfd,遍历到最大的fd

poll

select
优点:
实现了io多路复用
缺点:
参数多且麻烦

pollfd的参数
fd:要监控的fd
event;我们所关心要发生的事件(可以组合)
revent:实际发生的事件

使用步骤几乎和select一致
‘’’c/c++
struct pollfd fds[1024]={0};
fds[socketfd].fd=socketfd;
fds[socketfd].events=POLLIN;
int maxfd=socketfd;
while(1)
{
int nready = poll(fds,maxfd+1,-1);
if(fds[socketfd].revents& POLLIN)//注意是计算与&不是逻辑与&&,因为poll返回值可以理解为掩码
{
struct sockaddr_in clientaddr;
int len = sizeof(clientaddr);
int clientfd=accept(socketfd,(struct sockaddr*)&clientaddr,&len);
fds[clientfd].fd=clientfd;
fds[clientfd].events = POLLIN;
if(maxfd<clientfd)maxfd=clientfd;
}
for(int clientfd =socketfd+1;clientfd<maxfd;clientfd++)
{
if(fds[clientfd].revents & POLLIN)
{
char buffer [1024]={0};
int count = recv(clientfd,buffer,1024,0);
if(count==0)//disconnect
{
printf(“disconnected\n”);
close(clientfd);
fds[clientfd].events=-1;
fds[clientfd].revents=-1;
continue;
}
printf(“recv %d %s\n”,count,buffer);
send(clientfd,buffer,count,0);
printf(“send %d %s\n”,count,buffer);
}
}
}

‘’’

每次使用poll函数时,依旧要把fds复制到内核空间里
poll的底层使用的是select,参数要比select少
poll的使用场景

epoll(很重要!!!)

epoll_create()
epoll_ctl()
epoll_wait()

epoll机制
应用程序
|
| 1. epoll_create()
v
+———————+
| epoll 实例 |
| +—————+ |
| | 红黑树 | |<——-+
| +—————+ | |
| +—————+ | | 2. epoll_ctl(ADD)
| | 就绪队列 | | | 添加 fd 到红黑树
| +—————+ | |
+———————+ |
| |
| 3. epoll_wait()|
| 阻塞等待 |
| |
+———————+ |
| 内核回调 | |
+———————+ |
| |
数据到达 socket1 ——+ |
触发回调机制 ————————————-+
|
| 4. 返回就绪事件
v
+———————+
| 就绪事件数组 |
| [socket1, |
| socket3, …]|
+———————+
|
| 5. 遍历处理
v
accept() / recv() / send()

epoll所有fd的存储依靠红黑树
就绪的fd依靠队列

‘’’c/c++
int epollfd=epoll_create(1);//这个参数只要不小于0即可,这个是为了兼容以前的版本
struct epoll_event ev;
ev.events =EPOLLIN;
ev.data.fd=socketfd;
epoll_ctl(epollfd,EPOLL_CTL_ADD,socketfd,&ev);
while(1)
{
struct epoll_event events [1024];
int nready = epoll_wait(epollfd,events,1024,-1);
for(int i= 0;i<nready;i++)
{
int connfd =events[i].data.fd;
if(connfd==socketfd)
{
struct sockaddr_in clientaddr;
int len = sizeof(clientaddr);
int clientfd=accept(socketfd,(struct sockaddr*)&clientaddr,&len);
ev.events=EPOLLIN;
ev.data.fd=clientfd;
epoll_ctl(epollfd,EPOLL_CTL_ADD,clientfd,&ev);
}
else if(events[i].events&EPOLLIN)
{
char buffer [1024]={0};
int count = recv(connfd,buffer,1024,0);
if(count==0)//disconnect
{
printf(“disconnected\n”);
close(connfd);
epoll_ctl(epollfd,EPOLL_CTL_DEL,connfd,&ev);
continue;
}
printf(“recv %d %s\n”,count,buffer);
send(connfd,buffer,count,0);
printf(“send %d %s\n”,count,buffer);
}
}
}
‘’’

epoll的边缘触发与水平触发

边缘触发:来数据的时候触发
水平触发:有数据的时候触发

边缘触发:适合非阻塞io,且需要搭配while让数据读完,适合数据大小不确定的情况
水平触发:适合阻塞io,不需要while,适合数据大小一致的情况

epoll相比于select的优势

1.监控数量无上限 vs 有硬性限制

select:默认最多只能监控 1024 个文件描述符(FD_SETSIZE),虽然有办法修改,但需要重新编译内核,很不方便。
epoll:理论上无上限,可监控的文件描述符数量只受操作系统最大打开文件数(ulimit -n)的限制

2.效率差异巨大,复杂度 O(1) vs O(n)
这是最核心的性能区别。
select:每次调用都需要线性扫描全部 fd 集合,时间复杂度为 O(n)
epoll:通过内核回调机制,只返回真正就绪的 fd。程序直接处理这些就绪的 fd,无需扫描全集。时间复杂度为 O(1),海量连接下依然高效。

3.内存拷贝方式不同
select:每次调用 select() 都需要将整个 fd_set 从用户态拷贝到内核态,连接很多时,内存拷贝开销巨大。
epoll:通过 epoll_ctl() 添加 fd 时只拷贝一次并存入内核红黑树,后续 epoll_wait() 无需再拷贝,大大减少了内存复制的开销。

4.触发模式灵活
select:仅支持水平触发(LT),只要缓冲区有数据,就会一直通知。
epoll:支持边缘触发(ET),只在文件描述符状态发生变化时(如无数据→有数据)才通知一次。这迫使程序一次性处理完所有数据,结合非阻塞 I/O,能显著减少 epoll_wait 的调用次数,进一步提升性能。

poll对select的优势

1.没有最大文件描述符数量限制 (FD_SETSIZE)
select:使用固定大小的 fd_set 位图,默认最多只能监控 1024 个文件描述符。虽然可以修改宏重新编译内核,但非常麻烦且容易出错。
poll:基于 数组 (struct pollfd *fds) 管理,理论上只受系统最大打开文件数 (ulimit -n) 的限制。

2.事件描述更清晰,解决了“输入输出混杂”问题
select:使用 fd_set 位图时,传入的集合会被内核修改,只保留就绪的 fd。这意味着每次调用 select 前都必须重新添加所有关心的 fd,既繁琐又容易出错。
poll:将事件分为 events(输入,表示程序关心的事件)和 revents(输出,表示实际发生的事件)。内核不会修改 events,每次调用前只需设置一次,调用后检查 revents 即可。代码逻辑更清晰。

3.事件类型更丰富
select:只提供相对有限的几类事件:read、write、except。
poll:支持更精细的事件,比如:

POLLRDHUP:对端关闭连接,对于检测客户端断开非常有用。
POLLPRI:紧急数据带外数据。

POLLERR、POLLHUP、POLLNVAL 等错误状态,可以直接通过 revents 获取,无需额外调用。

4.管理大量描述符时,性能略优
虽然两者时间复杂度都是 O(n),但 poll 在内核中的实现通常比 select 略有效率。select 需要复制三个位图并扫描整个 fd_set 范围(从 0 到 max_fd),而 poll 只需复制用户态的 pollfd 数组,只遍历数组中有意义的部分,减少了遍历无效描述符的开销。

poll,select对epoll的优势

1.无与伦比的可移植性

2.epoll 并非在所有场景下都是最优解。当满足以下条件时,select/poll 的性能与 epoll 几乎无差别,甚至因为实现简单而略占优势:

监控的文件描述符数量很少:
例如只监控 几十个 以内的 fd。此时,select/poll 线性扫描的开销(O(n))极小,epoll 建立红黑树和回调机制的复杂开销反而显得“杀鸡用牛刀”。

所有监控的 fd 都非常活跃:
例如一个 FTP 服务器,所有连接都在疯狂传输数据。此时,select/poll 每次调用扫描后,发现几乎所有 fd 都就绪了。epoll 的优势在于“只返回活跃的”,但如果所有 fd 都活跃,epoll 就失去了这个优势,反而还要负担红黑树管理和回调的额外开销。在这种场景下,select/poll 的性能和 epoll 几乎持平,甚至可能更好。

总结

select,poll,epoll解决的都是io事件触发
一个io的生命周期由无数个事件组成

server端,事件—>返回不同的回调函数

什么是reactor:
由以前的io管理,改为了事件管理