一. 五种IO模型
- 重新理解IO过程:
已知在应用层read和write的时候,本质是把数据从用户层写给OS,本质就是拷贝函数。但是IO的过程不仅仅是拷贝这么简单,还有等待的过程,当OS中发送缓冲区满的时候write就要阻塞等待,当接收缓冲区为空的时候read就要阻塞等待.所以IO等于等待+拷贝,拷贝前必须判断读写条件成立。高效的IO就是在单位时间内,等待的比重越小,IO效率越高。
任何IO过程中, 都包含两个步骤. 第一是等待, 第二是拷贝
1.阻塞IO
- 在内核将数据准备好之前, 系统调用会一直等待. 所有的套接字, 默认都是阻塞方式.阻塞IO是最常见的IO模型

2.非阻塞IO
- 非阻塞IO: 如果内核还未将数据准备好, 系统调用仍然会直接返回, 并且返回EWOULDBLOCK错误码.非阻塞IO往往需要程序员循环的方式反复尝试读写文件描述符, 这个过程称为轮询. 这对CPU来说是较大的浪费, 一般只有特定场景下才使用.

2.1fcntl
- 一个系统调用,用于对已打开的文件描述符进行各种控制操作,是一个多功能函数
- 返回值:
1.成功时,返回值取决于 cmd(例如,F_GETFL 返回文件状态标志,F_GETFD 返回文件描述符标志,F_LOCK/F_TLOCK 成功返回 0)。
2.失败时,返回 -1,并设置 errno。 - fcntl函数有5种功能:
复制一个现有的描述符(cmd=F_DUPFD).
获得/设置文件描述符标记(cmd=F_GETFD或F_SETFD).
获得/设置文件状态标记(cmd=F_GETFL或F_SETFL).
获得/设置异步I/O所有权(cmd=F_GETOWN或F_SETOWN).
获得/设置记录锁(cmd=F_GETLK,F_SETLK或F_SETLKW). - 将文件描述符设置为非阻塞模式先F_GETFL获得文件状态,再由F_SETFL来更新文件标志位,常用有以下:
O_NONBLOCK: 设置为非阻塞模式
O_APPEND: 设置为追加模式
O_ASYNC: 启用信号驱动 I/O - 代码实现:
cpp
#include<iostream>
#include<unistd.h>
#include<fcntl.h>
#include<cstdio>
#include<cerrno>
#include<cstring>
using namespace std;
void SetNonBlock(int fd)
{
int fl=fcntl(fd,F_GETFL);//获取文件状态标志
if(fl<0)
{
perror("fcntl");
return;
}
fcntl(fd,F_SETFL,fl|O_NONBLOCK);//新增加状态为标志
cout<<"set "<<fd<<"nonblock done"<<endl;
}
int main()
{
char buffer[1024];
SetNonBlock(0);
sleep(1);
while(true)
{
ssize_t n=read(0,buffer,sizeof(buffer)-1);
if(n>0)
{
buffer[n-1]=0;
cout<<"echo: "<<buffer<<endl;
}
else if(n==0)//读取到文件末尾
{
cout<<"read done"<<endl;
break;
}
else{
if(errno==EWOULDBLOCK)//底层没有就绪
{
cout << "0 fd data not ready, try again!" << endl;
//此时可以处理其他任务
sleep(1);
}
else //读取错误
{
cerr<<"read error, n= "<<n<<"errno code: "<<errno<<", error str: "<<strerror(errno)<<endl;
}
}
}
return 0;
}

3.信号驱动
- 信号驱动IO: 内核将数据准备好的时候, 使用SIGIO信号通知应用程序进行IO操作

4.多路转接
- 虽然从流程图上看起来和阻塞IO类似. 实际上最核心在于IO多路转接能够同时等待多个文件描述符的就绪状态.

- 核心优势是 高效支撑高并发 I/O 连接,大幅降低系统资源消耗:
1.用少量资源管理海量连接 :
传统阻塞 I/O 需为每个连接分配独立进程 / 线程,1 万并发连接就需 1 万线程,线程切换和内存占用会拖垮系统。
多路转接通过 "事件监听" 统一管理所有连接,仅需 1 个或少量进程 / 线程,即可处理数万甚至百万级并发,资源利用率大幅提升。
2. 避免无效阻塞,提升 CPU 利用率 :
传统模式下,进程 / 线程多数时间处于 "阻塞等待" 状态(如等待客户端发送数据),CPU 资源被浪费。
多路转接仅在连接触发 I/O 事件(可读 / 可写 / 连接建立)时才唤醒进程处理,CPU 只处理有效任务,无空闲等待开销。
3. 架构灵活,适配不同场景 :
可搭配单进程、多进程、线程池等多种部署模式,适配 I/O 密集型、业务简单(如 Redis)或高稳定性要求(如 Nginx)的场景。
解耦 I/O 监听与业务处理,核心逻辑(事件分发)与业务逻辑(数据处理)分离,便于维护和扩展。
4.1select
- 系统提供select函数来实现多路复用输入/输出模型.
1.select系统调用是用来让我们的程序监视多个文件描述符的状态变化的;
2.程序会停在select这里等待,直到被监视的文件描述符有一个或多个发生了状态改变

- 返回值:
大于0:有n个fd就绪了
等于0:等待超时,没有错误,也没有fd就绪
小于0,等待出错了 - 参数解释:
- 参数nfds是需要监视的最大的文件描述符值+1;因为文件描述符从0开始计数,所以需要n+1位才能覆盖需要监视的n位,可通过数组下标来理解
- rdset,wrset,exset分别对应于需要检测的可读文件描述符的集合,可写文件描述符的集 合及异常文件描述符的集合;输入时用户告诉内核,我给你一个或多个fd,你要帮我关心这些fd上面的读写事件,就绪了要通知我。输出时,内核告诉用户,你让我关心的这些读写事件已经就绪了赶紧读取吧
- 参数timeout为结构timeval,用来设置select()的等待时间。关于取值:
NULL:则表示select()没有timeout,select将一直被阻塞,直到某个文件描述符上发生了事件;
0:仅检测描述符集合的状态,然后立即返回,并不等待外部事件的发生。
特定的时间值:如果在指定的时间段里没有事件发生,select将超时返回。注意超时时间需要循环设定,否则到时之后会置0相当于改变了设定的时间变成立即返回 - fd_set结构:
是内核提供的一种数据类型,就是一个整数数组, 更严格的说, 是一个 "位图". 使用位图中对应的位来表示要监视的文件描述符.在使用select的时候注定会有大量的位图操作

- timeval结构


代码验证
cpp
#pragma once
#include<iostream>
#include<sys/select.h>
#include<sys/time.h>
#include"Socket.hpp"
using namespace std;
const uint16_t defaultport=8888;
const int fd_num_max=(sizeof(fd_set)*8);
int defaultfd=-1;
class SelectServer
{
public:
SelectServer(uint16_t port=defaultport):_port(port)
{
for(int i=0;i<fd_num_max;i++) fd_array[i]=defaultfd;
}
bool Init()
{
_listensock.Socket();
_listensock.Bind(_port);
_listensock.Listen();
return true;
}
void Accepter()
{
//连接事件就绪
string clientip;
uint16_t clientport=0;
int sock=_listensock.Accept(&clientip,&clientport);
if(sock<0)return;
lg(Info,"accept success, %s:%d,sock fd:%d",clientip.c_str(),clientport,sock);
int pos=1;//0位置默认为监听套接字,找到空位置去select
for(;pos<fd_num_max;pos++)
{
if(fd_array[pos]!=defaultfd) continue;
else break;
}
if(pos==fd_num_max)
{
lg(Warning,"server is full,close %d now!",sock);
close(sock);
}
else{
fd_array[pos]=sock;
Printfd();
}
}
void Recver(int fd,int pos)
{
char buffer[1024];
ssize_t n=read(fd,&buffer,sizeof(buffer)-1);//这样不能保证读上来的数据是完整的
if(n>0)
{
buffer[n]=0;
cout<<"get a message: "<<buffer<<endl;
}
else if(n==0)//读取到文件末尾
{
lg(Info,"client quit, me too,close fd is: %d",fd);
close(fd);
fd_array[pos]=defaultfd;//从select中移除
}
else{
lg(Warning,"recv error: fd is : %d",fd);
close(fd);
fd_array[pos]=defaultfd;//从select中移除
}
}
void Dispatcher(fd_set &rfds)//任务分配器
{
for(int i=0;i<fd_num_max;i++)
{
int fd=fd_array[i];
if(fd==defaultfd) continue;
if(FD_ISSET(fd,&rfds))//检验就绪的文件描述符
{
if(fd==_listensock.Fd()) Accepter();//等于监听套接字
else Recver(fd,i);//其他套接字
}
}
}
void Start()
{
int listensock=_listensock.Fd();
fd_array[0]=listensock;
while(true)
{
fd_set rfds;
FD_ZERO(&rfds);
int maxfd=fd_array[0];
for(int i=0;i<fd_num_max;i++)
{
if(fd_array[i]==defaultfd) continue;//不是新增的,继续循环
FD_SET(fd_array[i],&rfds);//将新增的文件描述符都加入到数组中来select
if(maxfd<fd_array[i])
{
maxfd=fd_array[i];
lg(Info,"max fd update, max fd is: %d",maxfd);
}
}
//注意建立监听套接字不能直接accept,因为新连接的到来等同于读事件就绪,也需要select帮忙等待
struct timeval timeout={2,0};//输入输出要进行周期性的重复设置,否则将会被置0到时间后
//int n=select(maxfd+1,&rfds,nullptr,nullptr,&timeout);
int n=select(maxfd+1,&rfds,nullptr,nullptr,nullptr);
switch(n)
{
case 0://等待超时的情况
cout<<"time out:"<<timeout.tv_sec<<timeout.tv_usec<<endl;
break;
case -1://等待失败的情况
cout<<"select error"<<endl;
break;
default:
cout<<"get a new link!!!"<<endl;//有新事件就绪了
Dispatcher(rfds);
break;
}
}
}
void Printfd()
{
cout<<"online fd list: ";
for(int i=0;i<fd_num_max;i++)
{
if(fd_array[i]==defaultfd) continue;
cout<<fd_array[i]<<" ";
}
cout<<endl;
}
~SelectServer()
{
_listensock.Close();
}
private:
Sock _listensock;
uint16_t _port;
int fd_array[fd_num_max];//用户维护的文件描述符数组
};
cpp
#include"SelectServer.hpp"
#include<memory>
int main()
{
std::unique_ptr<SelectServer> svr(new SelectServer());
svr->Init();
svr->Start();
return 0;
}
其余代码复用前面文章写过的


该代码通过单进程就可以监听多个文件描述符,从而实现同时连接多客户端并进行多任务处理,这就是select的优势
细节问题
1.开始时会进行FD_SET,select后没有就绪的文件描述符会被清空,只会保留就绪的文件描述符,如果想要继续等待,那么就得循环设置文件描述符
2.可监控的文件描述符个数取决与sizeof(fd_set)的值,每bit表示一个文件描述符,将fd加入select监控集的同时,还要再使用一个数据结构array保存放到select监控集中的fd,作为原数据方便select后重新将文件描述符重新加入监控集,扫描array的同时取得fd最大值maxfd,用于select的第一个参数。
- select的缺点:
1.等待的fd是有上限的
2.输入输出型参数比较多,数据拷贝频率比较高
3.每次都要对fd进行事件重置,从接口使用角度来说不方便
4.用户层管理fd时,需要遍历,内核检测fd事件就绪状态时也需要遍历,开销大
所以说并不是select等待的fd越多效率越高,而是到一定程度就不怎么高效了
4.2poll

-
参数说明:
1.fds是一个poll函数监听的结构列表. 每一个元素中, 包含了三部分内容: 文件描述符, 监听的事件集合, 返回的事件集合.
2.nfds表示fds数组的长度.
3.timeout表示poll函数的超时时间, 单位是毫秒(ms)
-
返回值:
返回值小于0, 表示出错;
返回值等于0, 表示poll函数等待超时;
返回值大于0, 表示poll由于监听的文件描述符就绪而返回
-
events和revents的取值:


-
poll的优点:
struct pollfd将用户层传入内核和内核返回用户层的文件描述符分开了,也就是说还在等待和已经就绪的fd分开了,不需要每次poll之后重新设置,接口使用更加方便。poll是数组,解决了select中fd上限的问题,select的上限问题是因为其通过位图来维护,位图大小有限制,属于自身设计问题,而poll数组大小理论上可以很大,操作系统和内存分配不了是它们的问题不是自身设计的问题
-
poll缺点:
虽然解决了select的fd上限问题和每次等待后重新设置fd的问题,但是在用户层和内核中的遍历问题没法解决,依然存在效率问题。
代码验证
cpp
#pragma once
#include<iostream>
#include<poll.h>
#include<sys/time.h>
#include"Socket.hpp"
using namespace std;
const uint16_t defaultport=8888;
const int fd_num_max=64;
int non_event=0;
int defaultfd=-1;
class SelectServer
{
public:
SelectServer(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;
_event_fds[i].revents=non_event;
}
}
bool Init()
{
_listensock.Socket();
_listensock.Bind(_port);
_listensock.Listen();
return true;
}
void Accepter()
{
//连接事件就绪
string clientip;
uint16_t clientport=0;
int sock=_listensock.Accept(&clientip,&clientport);
if(sock<0)return;
lg(Info,"accept success, %s:%d,sock fd:%d",clientip.c_str(),clientport,sock);
int pos=1;//0位置默认为监听套接字,找到空位置去select
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();
}
}
void Recver(int fd,int pos)
{
char buffer[1024];
ssize_t n=read(fd,&buffer,sizeof(buffer)-1);//这样不能保证读上来的数据是完整的
if(n>0)
{
buffer[n]=0;
cout<<"get a message: "<<buffer<<endl;
}
else if(n==0)//读取到文件末尾
{
lg(Info,"client quit, me too,close fd is: %d",fd);
close(fd);
_event_fds[pos].fd=defaultfd;//从select中移除
}
else{
lg(Warning,"recv error: fd is : %d",fd);
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 Recver(fd,i);//其他套接字
}
}
}
void Start()
{
int listensock=_listensock.Fd();
_event_fds[0].fd=listensock;
_event_fds[0].events=POLLIN;
int timeout=3000;//毫秒->3s
while(true)
{
int n=poll(_event_fds,fd_num_max,timeout);
switch(n)
{
case 0://等待超时的情况
cout<<"time out..."<<endl;
break;
case -1://等待失败的情况
cout<<"select error"<<endl;
break;
default:
cout<<"get a new link!!!"<<endl;//有新事件就绪了
Dispatcher();
break;
}
}
}
void Printfd()
{
cout<<"online fd list: ";
for(int i=0;i<fd_num_max;i++)
{
if(_event_fds[i].fd==defaultfd) continue;
cout<<_event_fds[i].fd<<" ";
}
cout<<endl;
}
~SelectServer()
{
_listensock.Close();
}
private:
Sock _listensock;
uint16_t _port;
struct pollfd _event_fds[fd_num_max];//用户维护的文件描述符数组
};

4.3epoll
epoll_create
- int epoll_create(int size);
创建一个epoll的句柄。自从linux2.6.8之后,size参数是被忽略的。用完之后, 必须调用close()关闭. - 句柄的概念:
句柄是操作系统或编程框架提供的、用于唯一标识和操作内核 / 系统资源的抽象标识符,本质是关联 "用户态代码" 与 "内核态 / 底层资源" 的桥梁,自身不存储资源数据,仅提供访问资源的入口。
epoll_ctl
-
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll的事件注册函数。它不同于select()是在监听事件时告诉内核要监听什么类型的事件, 而是在这里先注册要监听的事件类型,之后就不需要像select一样每次建通都重新指定要监听的fd集合和事件,在内核中遍历文件描述符就绪没有也不用像select一样每个都遍历一遍,只需要遍历就绪列表即可。
-
参数解析:
1.第一个参数是epoll_create()的返回值(epoll的句柄).
2.第二个参数表示动作,用三个宏来表示.
EPOLL_CTL_ADD :注册新的fd到epfd中;
EPOLL_CTL_MOD :修改已经注册的fd的监听事件;
EPOLL_CTL_DEL :从epfd中删除一个fd
3.第三个参数是需要监听的fd
4.第四个参数是告诉内核需要监听什么事
-
struct epoll_event结构

epoll_event 结构体:
uint32_t events,以位图的形式标记事件类型,是一系列宏的组合
EPOLLIN : 表示对应的文件描述符可以读 (包括对端SOCKET正常关闭);
EPOLLOUT : 表示对应的文件描述符可以写;
EPOLLPRI : 表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外数据到来);
EPOLLERR : 表示对应的文件描述符发生错误;
EPOLLHUP : 表示对应的文件描述符被挂断;
EPOLLET : 将EPOLL设为边缘触发(Edge Triggered)模式, 这是相对于水平触发(Level Triggered)来说的.
EPOLLONESHOT:只监听一次事件, 当监听完这次事件之后, 如果还需要继续监听这个socket的话, 需要再次把这个socket加入到EPOLL队列里
epoll_data_t 联合体:
共用同一块内存,用于存储与 epoll 事件关联的用户数据,作用是让开发者能在 epoll 事件中附加自定义信息,比如通过 fd 直接拿到被监听的文件描述符,或通过 ptr 关联更复杂的业务数据。
epoll_wait
- int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
- 参数events是分配好的epoll_event结构体数组.
- epoll将会把发生的事件赋值到events数组中 (events不可以是空指针,内核只负责把数据复制到这个events数组中,不会去帮助我们在用户态中分配内存).
- maxevents告之内核这个events有多大,这个 maxevents的值不能大于创建epoll_create()时的size.
- 参数timeout是超时时间 (毫秒,0会立即返回,-1是永久阻塞).
- 如果函数调用成功,返回对应I/O上已准备好的文件描述符数目,如返回0表示已超时, 返回小于0表示函数失败.
epoll工作原理

-
当调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体包含红黑树和就绪队列
-
每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件.
-
这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是lgn,其中n为树的高度)
-
回调函数:
而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当响应的事件发生时会调用这个回调方法,那么操作系统在硬件层面是如何知道网卡上有数据了呢?通过硬件中断。当条件满足时,首先将数据向上层交付,给tcp的接收队列,当内核发现某个fd的状态发生变化,就会检查该fd是否被epoll监听,如果是就会调用ep_poll_callback回调函数,将内核中eventpoll结构体中红黑树的对应epitem节点添加到就绪队列中去,然后唤醒epoll_wait,遍历就绪队列将其中存储的节点拷贝到用户态的epoll_event数组中去,最后返回给应用程序
-
注意:
epitem 节点在红黑树(rbr)和就绪链表(rdlist)中是同时存在的,两者并不互斥。
红黑树的作用是维护所有已注册的事件(记录用户关心哪些 fd 和事件),只要用户没有通过 epoll_ctl 删除该事件,红黑树中的 epitem 节点就会一直存在。
就绪链表的作用是临时存放已经发生的就绪事件,当 epoll_wait 取出这些就绪事件后,节点会从就绪链表中移除,但红黑树中的节点依然保留(因为用户可能还在监听这些事件,后续可能再次触发就绪)。
epoll的优点
与select和poll的缺点相对应
- 1.接口使用方便: 虽然拆分成了三个函数, 但是反而使用起来更方便高效. 不需要每次循环都设置关注的文件描述符, 也做到了输入输出参数分离开
2.数据拷贝轻量: 只在合适的时候调用 EPOLL_CTL_ADD 将文件描述符结构拷贝到内核中, 这个操作并不频繁(而select/poll都是每次循环都要进行拷贝)
3.事件回调机制: 避免使用遍历, 而是使用回调函数的方式, 将就绪的文件描述符结构加入到就绪队列中, epoll_wait 返回直接访问就绪队列就知道哪些文件描述符就绪. 这个操作时间复杂度O(1). 即使文件描述符数目很多, 效率也不会受到影响. 检测就绪的fd时间复杂度为O(1),获取就绪fd的时间复杂度为O(n)
4.没有数量限制: 文件描述符数目无上限
epoll的工作方式
- 水平触发Level Triggered 工作模式
epoll默认状态下就是LT工作模式.
当epoll检测到socket上事件就绪的时候, 可以不立刻进行处理. 或者只处理一部分.
只要 fd 满足 "可读 / 可写" 状态(比如接收缓冲区还有未读数据、发送缓冲区还有可写空间),内核就会一直将对应的 epitem 节点保留在 rdlist 中,不会移除。
后续再调用epoll_wait 时,会再次读取到该 epitem 节点,相当于 "再次提示",直到应用程序将数据处理完毕(fd 不再满足 "可读 / 可写" 状态),内核才会将其移出 rdlist。支持阻塞读写和非阻塞读写
- 边缘触发Edge Triggered工作模式
如果我们在第1步将socket添加到epoll描述符的时候使用了EPOLLET标志, epoll进入ET工作模式
当epoll检测到socket上事件就绪时, 必须立刻处理,且只有一次处理机会。只有当数据或连接,从无到有、从有到多、变化的时候才会通知我们一次。内核是监控者,当 socket 状态符合 ET 模式的触发条件(状态变化的边缘),内核就通过 epoll_wait 向用户程序汇报这个事件
注意:
ET工作模式下,由于每次通知都要把本轮数据都取走,如果fd是阻塞读写,可能让程序卡死或出现数据丢失,核心原因是 ET 模式的 "一次触发" 特性与阻塞读写的 "无限等待" 特性完全冲突
阻塞 read:
若一次读完缓冲区所有数据,read 会继续阻塞(等待新数据),但 ET 模式不会再触发 EPOLLIN 事件(因为状态未发生 "从无到有" 的变化),导致程序卡在 read 调用,无法处理其他 socket 事件。
阻塞 write:
若一次写完所有数据,write 会正常返回;但如果缓冲区仍有空间(未写满),write 会继续阻塞(等待更多数据写入),而 ET 模式不会再触发 EPOLLOUT 事件,导致程序卡死。
更危险的是:若提前注册 EPOLLOUT 事件,epoll 会在 socket 初始化时(缓冲区默认有空间)立即触发一次 EPOLLOUT,此时调用阻塞 write 会因 "无数据可写" 而一直阻塞,直接拖死程序。
所以ET模式必须搭配非阻塞IO:
ET 模式的设计初衷是高效处理高并发场景,其核心是 "一次触发后,用非阻塞读写循环处理完所有数据,避免重复触发事件"。而非阻塞读写的关键作用是:
当缓冲区无数据 / 无空间时,read/write 会立即返回 -1 并设置 errno = EAGAIN,此时可停止循环,等待下一次状态变化的触发事件,不会阻塞程序。
- 对比LT和ET
- 一般来说ET的通知效率更高,所以ET的IO效率也要更高,当ET每次通知时,用户会尽量把本轮数据全部读走,这样缓冲区剩余空间会增大,本质就是通信时tcp会向对方通告一个更大的窗口,从而从概率上让对方一次给我发送更多的数据。如果LT在一些情况下也能做到每次就绪的文件描述符都立刻处理,不让这个就绪被重复提示的话, 其实性能也是一样的.ET的代码复杂度会高一些,所以单纯的对比LT和ET的性能其实不一定谁高
- 实际开发中,大多数场景(如普通 Web 服务器、中小并发服务)会选 LT,因为 "代码稳定" 的优先级高于 "微乎其微的性能提升";只有在百万级并发、极致性能优化的场景( Reactor)来降低复杂度。
4.3.1代码验证
默认为LT模式,main.cc,log.hpp.Socket.hpp代码块与前文相同
EpollServer.hpp
cpp
#pragma once
#include<iostream>
#include<memory>
#include<sys/epoll.h>
#include"Socket.hpp"
#include"log.hpp"
#include"Epoller.hpp"
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())
{}
~EpollServer()
{
_listsocket_ptr->Close();
}
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()
{
string clientip;
uint16_t clientport;
int sock=_listsocket_ptr->Accept(&clientip,&clientport);
if(sock>0)
{
//加入到监听中
_epoller_ptr->EpollerUpdate(EPOLL_CTL_ADD,sock,EVENT_IN);
lg(Info,"get a new link, client info@%s:%d",clientip.c_str(),clientport);
}
}
void Recver(int fd)
{
char buffer[1024];
ssize_t n=read(fd,&buffer,sizeof(buffer)-1);//这样不能保证读上来的数据是完整的
if(n>0)
{
buffer[n]=0;
cout<<"get a message: "<<buffer<<endl;
//write,回显
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);
_epoller_ptr->EpollerUpdate(EPOLL_CTL_DEL,fd,0);
close(fd);//要先将红黑树中监听信息先删除,再关闭文件描述符
}
else{
lg(Warning,"recv error: fd is : %d",fd);
_epoller_ptr->EpollerUpdate(EPOLL_CTL_DEL,fd,0);
close(fd);
}
}
void Dispatcher(epoll_event* revs,int n)//n代表就绪事件的数量
{
//遍历就绪事件
for(int i=0;i<n;i++)
{
uint32_t events=revs[i].events;
if(events&EVENT_IN)
{
if(revs[i].data.fd==_listsocket_ptr->Fd()) Accepter();//为监听套接字的情况
else Recver(revs[i].data.fd);//为其他套接字情况
}
else if(events&EVENT_OUT)
{}
else{}
}
}
void Start()
{//开始,先将监听套接字信息加入epoll中
_epoller_ptr->EpollerUpdate(EPOLL_CTL_ADD,_listsocket_ptr->Fd(),EVENT_IN);
struct epoll_event revs[num];
while(true)
{
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");
}
}
private:
shared_ptr<Sock> _listsocket_ptr;
shared_ptr<Epoller> _epoller_ptr;
uint16_t _port;
};
Epoller.hpp
封装了epoll的系统调用,向外界提供更简便的接口
cpp
#pragma once
#include"log.hpp"
#include<cerrno>
#include<cstring>
#include<sys/epoll.h>
#include"nocopy.hpp"
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: %s",strerror(errno));
}
int EpollerWait(struct epoll_event revents[],int num)
{
int n=epoll_wait(_epfd,revents,num,/*_timeout*/-1);
return n;
}
int EpollerUpdate(int op,int sock,uint32_t event)
{
int n=0;
if(op==EPOLL_CTL_DEL)
{
n=epoll_ctl(_epfd,op,sock,0);
if(n!=0) lg(Error,"epoll_ctl delete error!");
}
else{//增加和修改
struct epoll_event ev;
ev.data.fd=sock;//方便后续得知哪一个fd就绪了
ev.events=event;
n=epoll_ctl(_epfd,op,sock,&ev);
if(n!=0) lg(Error,"epoll_ctl error!");
}
return n;
}
~Epoller()
{
if(_epfd>0) close(_epfd);
}
private:
int _epfd;
int _timeout{3000};
};
CMakeLists.txt
在 CMake 中,CMakeLists.txt 是必须的核心配置文件,它是 CMake 识别项目、生成构建系统(如 Makefile、Visual Studio 项目等)的 "入口"。
- cmake和make区别:
CMake:是跨平台的构建系统生成工具,本身不直接编译代码。它通过解析开发者编写的 CMakeLists.txt 文件,自动生成适用于不同平台的构建文件(如 Linux 下的 Makefile、Windows 下的 Visual Studio 项目等)。主要解决跨平台构建的复杂性,简化大型项目的配置。
Make:是具体的构建执行工具,通过解析 Makefile 中的编译、链接规则来执行构建过程。Makefile 需手动编写或由 CMake 等工具生成,主要适用于 Unix/Linux 平台的小型项目构建。
cpp
cmake_minimum_required(VERSION 3.10)#声明CMake版本要求
project(EpollServer)#定义项目名称
#指定c++11编译标准
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_executable(epoll_server main.cc)#指定可执行文件的名称和源代码,让CMake自动生成编译规则
nocopy.hpp
cpp
#pragma once
class nocopy
{
public:
nocopy(){}
nocopy(const nocopy&)=delete;//禁用拷贝构造
const nocopy& operator=(const nocopy&)=delete;//禁用拷贝赋值运算符
};


5.异步IO
- 由内核在数据拷贝完成时, 通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据)

理解同步与互斥
- 在消息通信机制方面:
所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回. 但是一旦调用返回,就得到返回值了; 换句话说,就是由调用者主动等待这个调用的结果;
异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果; 换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果; 而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用. - 多进程与多线程之间的同步:
与消息通信的概念毫不相关,这里是相互制约的关系,是为完成某种任务而建立的两个或多个线程,这个线程需要在某些位置上协调他们的工作次序而等待、传递信息所产生的制约关系. 尤其是在访问临界资源的时候