认识IO多路转接
**IO 多路转接核心作用是:**一个进程 / 线程同时监听多个文件描述符(socket / fd),当任意一个描述符就绪,就通知应用程序进行处理就绪描述符。
它解决了传统阻塞 IO 的痛点:
- **阻塞 IO:**一个线程只能处理一个连接,并发量极低;
- 多线程 / 多进程:资源消耗大,线程切换开销高;
- **IO 多路转接:**单线程监听大量描述符,无需为每个连接创建线程,极大提升并发能力。
select、poll、epoll就是系统提供的IO多路复用系统调用。
IO多路转接之select
认识select
select系统调用是用来让我们的应用程序监视多个文件描述符的状态变化的,程序会停在select这里等待,直到被监视的多个文件描述符里有一个或多个fd就绪,就通知上层该fd可以IO了。
结论:select通过等待多个fd的一种就绪事件通知机制。
事件就绪:
文件描述符可读:底层有数据,读事件就绪。
文件描述符可写:底层有空间,写事件就绪。
select核心思想
- 用户将需要监听的文件描述符集合传给内核。
- 内核轮询所有的文件描述符,检测是否有事件就绪。
- 如果有一个或者多个就绪,就返回就绪的数量,并将集合中未就绪的描述符清空。
- 用户遍历集合,找到就绪描述符进行处理IO。
本质是我们调用select系统调用,让select去和系统做交互。
select接口认识
相关接口
int select(int nfds, fd_set * readfds, fd_set * writefds, fd_set * exceptfds,
struct timeval * t timeout);
参数解释:
**nfds:**需要等的最大文件描述符值+1。
**readfds/writefds/exceptfds:**输入输出型参数。
调用时:用户告知内核需要监视哪些文件描述符的读/写/异常事件是否就绪。
返回时:内核告知用户哪些文件描述符的读/写/异常事件已经就绪。
**timeout:**调用时由用户设置select的等待时间,返回时表示timeout的剩余时间。
fd_set类型
fd_set:文件描述符集合,内核提供给用户的数据结构,一次可以向fd_set添加多个fd。

从上图Linux内核中对fd_set类型的构造我们可以看出,它的结构内部封装了一个位图数组,使用位图中对应的位置来表示要监视的文件描述符。
我们知道在select接口中,**readfds, writefds, exceptfds,**三个参数分别表示读/写/异常,他们的类型值都是fd_set。我们以readfds为例来说明用位图怎么表示的。
以readfds(读文件描述符集)为例:
假设在程序中我们打开的文件描述符分别为0,1,2,4,7,位图结构为0000 0000。然后我们想要关心这几个文件描述符的读事件。
作为输入型参数: 我们需要把想要关心的文件描述符设置到位图里边:我们设置为1001 0111就可以表示我们把关心所要关心的文件描述符已经设置到位图了。由此也可以看出,对于比特位的位置也就是文件描述符对应的编号。比特位表示1,证明我们需要关心这个文件描述符读事件,否则就不关心。
作为输出型参数: 假设当文件描述符为2,4的事件就绪了,那么select函数返回,把没有就绪的比特位清0,我们拿到的输出型参数就是 0001 0100 这时对于位图对应位置的1,就表示该事件已经就绪,0就表示没有就绪。
**总上可知:**fd_set本质就是一张位图结构,主要目的就是为了让用户与内核相互传递fd。
**作为输入参数:**用户告诉内核你帮我监控哪几个文件描述符什么事件。
**作为输出参数:**内核告诉用户哪几个文件描述符已经就绪。
细节:
输入输出参数用的是同一张位图,后续肯定会频繁的修改位图。
因为某个事件就绪,修改了位图,但是还有其他的未就绪事件,我们需要继续去等待。
位图的比特位表示fd编号,位图有多少个比特位就决定了select关心多少个fd
fd_set是一个数据类型,大小固定,比特位大小固定,select能同时等待的fd有上限(fd_set)类型大小字节数。后边可用poll、epoll扩展。
readfds:如果把fd添加到readfds中,表示告诉内核,只关心该fd的读事件。
同时关心读和写,需要把fd同时添加到readfds/writefds
fd_set位图操作接口
对于位图的操作可以使用现成的接口来直接操作。
void FD_CLR(int fd, fd_set *set); // 用来清除描述符集合 set 中 fd 的位
int FD_ISSET(int fd, fd_set *set); // 用来判断 set 中相关 fd 的位是否存在
void FD_SET(int fd, fd_set *set); // 把fd设置到set位图中
void FD_ZERO(fd_set *set); // 用来清除set 的全部位
set位图本身并不真的关心是读还是写,只用来在底层当前是否有数据/读写入的条件是否满足,满足就修改对应文件描述符位图就行。
timeval结构体
select最后一个参 数timeout类型是 struct timeval类型。

由以上结构可知:time_t就是无符号整数,以秒为单位,代表时间戳;第二个代表微秒。
timeval结构用于描述一段时间长度,如果在这个时间内,需要监视的描述符没有事件
发生则函数返回,返回值为0。
假设给select设置时间为{5,5}:表示让select每隔5.5秒返回一次,
如果5.5秒内没有任何一个文件描述符就绪,就超时返回,重新开始。
如果期间有文件描述符就绪就立刻返回。
如果设置为{0,0}:就类似于非阻塞轮询检测,无论检测众多描述符是否就绪都返回。
如果设置为NULL:就相当于阻塞等待了,只要有一个文件描述符就绪就直接返回。
该结构也是一个输入输出型参数,如果设定规定时间,在此时间内如果有事件就绪,那么该结构输出参数就是剩余超出时间。
select函数返回值
返回值大于0:是几 就表示几个fd就绪了。
返回值等于0:表示超时了,timeout设置。不能为null,在timeout fd没有就绪
返回值小于0:select报错,可能文件描述符不合法
基于select tcp编写echo_server
注意:这里只处理读操作。
主题函数实现(需要整个代码项目的可以私信小编)。
cpp
#pragma once
#include<iostream>
#include<sys/select.h>
#include "Common.hpp"
#include"Log.hpp"
#include<memory>
//select socket_serve
#include"Socket.hpp"
class SelectServer{
const static int size3=sizeof(fd_set)*8;
const static int defauID=-1;
public:
SelectServer(int port):_listensockfd(make_unique<TcpSocket>()){
_listensockfd->BuildTcpSocketMethod(port);
//初始化辅助数组
for(int i=0;i<size3;i++){
fd_arry[i]=defauID;
}
fd_arry[0]=_listensockfd->Fd();//listenfd肯定在最开始
}
void start(){
while(true){
//当前的服务器已经设置好监听状态了
//常规构建服务器,这里就可以使用accept去获取连接了,但在select这里我们不可以这样做。accept本质就是IO,如果没有链接就会阻塞
//accept只负责获取链接,处理文件描述的读事件。 //但是获取链接需要等,所有我们可以把等的操作交给select
//我们创建的网络sockfd,需要从这个sockfd中读,所以我们创建select让os帮我们去从这个文件描述符中读数据。
//要对select调用需要先定义fds集合
fd_set rfds;//先定义fds集合
FD_ZERO(&rfds);//做一下清空
//把每一个辅助数组里边要监听的fd设置到rfds中
int maxfd=defauID;//最大的fd一直都变,因为一直在连接新连接
for(int i=0;i<size3;i++){
if(fd_arry[i]==defauID){
continue;
}
//证明存在描述符
FD_SET(fd_arry[i],&rfds);
//更新最大的maxfd
if(maxfd<fd_arry[i]){
maxfd=fd_arry[i];
}
}
PrintFD();
//FD_SET(_listensockfd->Fd(),&rfds);//添加当前描述符到该集合中
//struct timeval timeout ={0,0};
//任何文件描述符都应该交给select同一管理
/*对于select,rfds输入前可能需要等待的fd很多,但是执行完函数他又作为输出参数,所以很难保证历史没有就绪的文件描述符再次被内核等待*/
//所以针对中情况,一般设计select时,会增加一个辅助数组来记录,历史存在fds
//因为对于rfds,每一次对应的位图值都需要对应的修改,变化所以我们要每一次都更新
// int sel=select(_listensockfd->Fd()+1,&rfds,nullptr,nullptr,nullptr);//通过select函数把set集合设置到内核。
int sel=select(maxfd+1,&rfds,nullptr,nullptr,nullptr);//通过select函数把set集合设置到内核。
switch(sel)
{
case -1:
//说明select出错了
LOG(LogLevel::ERROR)<<"select error";
break;
case 0:
//说明设置时间超时了
LOG(LogLevel::WARNING)<<"时间超时了.....";
break;
default:
//大于0,某个事件就绪了
LOG(LogLevel::DEBUG)<<"有事件就绪......,sel: "<<sel;
Dispatcher(rfds);
break;
}
}
}
//连接管理器
void Acceptor(){
//去处理就绪事件
InetAddr client;
int fd=_listensockfd->Accept(&client); //这里accept不会在阻塞了,因位有连接已经就绪,select帮我们等待了。
LOG(LogLevel::INFO)<<"get a new link,fd:"<<fd<<"client is:"<<client.StringAddr();
//这里我们获取了新链接,但不能直接进行读取操作/read/recv,因为建立连接并不等于必须要通信,所以我们要想法把新连接的fd放到select中,交给内核来等待读取就绪
//这里我们直接把获得的新sockfd放入到辅助数组中即可
int pos=0;
for(;pos<size3;pos++){
if(fd_arry[pos]==defauID){
break;
}
}
if(pos==size3){
LOG(LogLevel::WARNING)<<"server full...";
close(fd);
}else{
fd_arry[pos]=fd;
}
}
void Dispatcher(fd_set &rfds){
//处理事件不仅仅是处理事件,而且读事件就绪,需要处理
//我们需要知道哪个文件描述符已经就绪
for(int i=0;i<size3;i++){
if(fd_arry[i]==defauID){
continue;
}
//fd合法,不一定就绪 比特位可能为0 可能为 1
if(FD_ISSET(fd_arry[i],&rfds)){
//证明该文件描述符已经就绪
//listensockfd 新连接的到来 也是读事件就绪
//sockfd 数据到了 读事件就绪。
if(fd_arry[i]==_listensockfd->Fd()){
//证明是监听就绪
Acceptor();
}else{
//证明是read就绪
Recver(fd_arry[i],i);
}
}
}
}
//IO处理器
void Recver(int fd,int pos){
char buffer[1024];
int r=recv(fd,buffer,sizeof(buffer)-1,0);
if(r>0){
buffer[r]=0;
cout<<"client say:"<<buffer<<endl;
}else if(r==0){
//代表读完数据了
LOG(LogLevel::INFO)<<"client quit....";
//不要再select关心该fd
fd_arry[pos]=defauID;
close(fd);
}else{
LOG(LogLevel::INFO)<<"Recv err....";
fd_arry[pos]=defauID;
close(fd);
}
}
void PrintFD(){
cout<<"fd_arry[]:";
for(int i=0;i<size3;i++){
if(fd_arry[i]==defauID){
continue;
}
cout<<fd_arry[i]<<" ";
}
cout<<endl;
}
~SelectServer(){}
private:
std::unique_ptr<Socket> _listensockfd; //创建socket
int fd_arry[size3];//辅助数组
};
select优缺点
select的优点
- 单进程下可以同时等待多个文件描述符,并且只负责等待,实际的拷贝动作由read,write等接口完成,并且最大的好处就是select之后再调用read这些接口不会再被阻塞。
- select同时等待多个文件描述符,可以将"等"的时间重叠,提高IO效率。
select缺点
- 因为select函数中,输入输出型参数比较多,所以对应设定的FD_SET集合调用前需要重置,调用后又被清除。
- 每次调用select,都要把fd集合从用户态拷贝到内核态,调用select后又要吧fd集合从内核态拷贝到用户态。这给开销再fd很多时是非常大的。
- select中支持fd的数量是有限的一般根os系统有关,具体大小:sizeof(fd_set)*8
IO多路转接之poll
认识poll
poll同select作用一样,也是只是负责等,都是让一个线程同时监听多个文件描述符,等待其中一个或多个文件符就绪,通知上层。
poll接口认识
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数介绍:
**fds:**当成一个数组的起始地址。
**nds:**数组元素的个数。
timeout: 单纯的时间设置,ms为单位,作用同select
struct pollfd结构
struct pollfd {
int fd; // 要监听的文件描述符
short events; // 等什么事件(POLLIN 读事件 / POLLOUT 写事件)
short revents; // 内核返回:实际发生了什么事件
};
设置事件的时候 evects |= POLLIN
判断是否就绪 revects & POLLOUT
再调用poll函数时:只需要关心fd && events:用户告诉内核你需要帮我关心fd的events事件
poll返回成功时:只需要关系fd && revents:内核告诉用户关心的evects事件已经就绪。
poll解决了select的问题
1.select输入输出参数没有分离,poll分离了,不用在调用函数前对参数进行重置了。
poll输入输出参数分离
2.poll等待的fd没有上线。
poll的第一个参数是一个动态结构体数组,可以随时扩容。
改写select代码,变成poll服务器
cpp
#pragma once
#include<iostream>
#include<sys/select.h>
#include "Common.hpp"
#include"Log.hpp"
#include<memory>
#include<poll.h>
//select socket_serve
#include"Socket.hpp"
class PollServer{
// const static int size3=sizeof(fd_set)*8;
const static int size3=10001;
const static int defauID=-1;
public:
PollServer(int port):_listensockfd(make_unique<TcpSocket>()){
_listensockfd->BuildTcpSocketMethod(port);
//初始化数组
for(int i=0;i<size3;i++){
fds[i].fd=defauID;
fds[i].events=0;
fds[i].revents=0;
}
fds[0].fd=_listensockfd->Fd();
fds[0].events=POLLIN;
}
void start(){
while(true){
PrintFD();
int timeout=1000;
int sel=poll(fds,size3,-1);
switch(sel)
{
case -1:
//说明select出错了
LOG(LogLevel::ERROR)<<"poll error";
break;
case 0:
//说明设置时间超时了
LOG(LogLevel::WARNING)<<"poll 时间超时了.....";
break;
default:
//大于0,某个事件就绪了
LOG(LogLevel::DEBUG)<<"有事件就绪......,sel: "<<sel;
Dispatcher();
break;
}
}
}
//连接管理器
void Acceptor(){
//去处理就绪事件
InetAddr client;
int fd=_listensockfd->Accept(&client); //这里accept不会在阻塞了,因位有连接已经就绪,select帮我们等待了。
LOG(LogLevel::INFO)<<"get a new link,fd:"<<fd<<"client is:"<<client.StringAddr();
//这里我们获取了新链接,但不能直接进行读取操作/read/recv,因为建立连接并不等于必须要通信,所以我们要想法把新连接的fd放到select中,交给内核来等待读取就绪
//这里我们直接把获得的新sockfd放入到辅助数组中即可
int pos=0;
for(;pos<size3;pos++){
if(fds[pos].fd==defauID){
break;
}
}
if(pos==size3){
LOG(LogLevel::WARNING)<<"server full...";
close(fd);
}else{
fds[pos].fd=fd;
fds[pos].events=POLLIN;
}
}
void Dispatcher(){
//处理事件不仅仅是处理事件,而且读事件就绪,需要处理
//我们需要知道哪个文件描述符已经就绪
for(int i=0;i<size3;i++){
if(fds[i].fd==defauID){
continue;
}
//fd合法,不一定就绪 比特位可能为0 可能为 1
if( fds[i].revents & POLLIN ){
//证明该文件描述符已经就绪
//listensockfd 新连接的到来 也是读事件就绪
//sockfd 数据到了 读事件就绪。
if(fds[i].fd==_listensockfd->Fd()){
//证明是监听就绪
Acceptor();
}else{
//证明是read就绪
Recver(i);
}
}
}
}
//IO处理器
void Recver(int pos){
char buffer[1024];
int r=recv(fds[pos].fd,buffer,sizeof(buffer)-1,0);
if(r>0){
buffer[r]=0;
cout<<"client say:"<<buffer<<endl;
}else if(r==0){
//代表读完数据了
LOG(LogLevel::INFO)<<"client quit....";
close(fds[pos].fd);
//不要再select关心该fd
fds[pos].fd=defauID;
fds[pos].events=0;
fds[pos].revents=0;
}else{
close(fds[pos].fd);
//不要再select关心该fd
fds[pos].fd=defauID;
fds[pos].events=0;
fds[pos].revents=0;
}
}
void PrintFD(){
cout<<"fds[]: ";
for(int i=0;i<size3;i++){
if(fds[i].fd==defauID){
continue;
}
cout<<fds[i].fd<<" ";
}
cout<<endl;
}
~PollServer(){}
private:
std::unique_ptr<Socket> _listensockfd; //创建socket
//int fd_arry[size3];//辅助数组
struct pollfd fds[size3];
};
poll的优点
struct pollfd结构体可以将select的输入输出型参数分离,数据不会覆盖
poll可监控的文件描述符没有限制,因为数组大小是用户定的,也可以进行扩容
poll的缺点
和select一样,poll底层判断哪个文件描述符时也需要遍历fds数组来获取就绪的文件描述符。
每次调用poll,都伴随着大量的struct pollfd在用户态和内核态之间的转换,并且当poll监视的文件描述符很多时,效率就很低。
IO多路转接之epoll
认识epoll
同select/poll功能类似:epoll也是基于可以同时等待多个文件描述符就绪,对上层进行通知的功能。
对于poll虽然解决了select函数的文件描述符有限,输入输出型参数混乱的情况,但poll对于文件描述符的管理仍然是使用数组来进行管理的,随着文件描述符的增加,内核对其管理的成本就增加了,效率就下降了。
所以针对这个问题,epoll对其进行了改进。但epoll的实现原理相对于poll来讲非常大。
epoll相关系统调用接口
//创建 epoll 模型,返回 epfd(epoll文件描述符)。
int epfd = epoll_create(int size);//size>0即可
//增 / 删 / 改监听 FD(文件描述符)//向epfd中增删查改//该函数主要是用户告诉内核,你要帮我关心epfd模型的哪些事件
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
**epfd:**创建的epoll文件描述符
op: 对操作符进行操作:
EPOLL_CTL_ADD / ``EPOLL_CTL_MOD / ``EPOLL_CTL_DELevent: 指定监听事件(
EPOLLIN可读、EPOLLOUT可写,够用)
//等待就绪事件//内核告诉用户:让我关心的哪个fd上的事件已经就绪
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
**epfd:**创建的实例epoll文件描述符
**events:**就绪的文件描述符放到数组里
**maxevents:**就绪数组中最大的文件描述符
**timeout:**设置等待时间,单位毫秒
struct epoll_eveny* event结构

epoll的核心原理
epoll在内核中的核心实现原理主要是维护了一个实例epfd,在这个实例中包含两个核心的数据结构和一个回调机制。
我们通过函数epoll_create就能创建出一个实例,这也就是为什么epoll_create、epoll_wait第一个参数为啥是epoll_create的返回值。
红黑树结构
在epofd中会维护一个红黑树结构,红黑树每个节点存放的是注册的fd以及事件。在上层我们调用的epoll_ctl就是用来维护这张红黑树结构的。根据参数OP具体含义对红黑树节点进行增删该操作。
只要对应的实例创建,在内核中就会为我们长期维护这个数据结构,对这个树的操作也是O(logn)
就绪队列结构
epoll实例创建之后也会维护一个就绪对立,在就绪对立仅保存就绪事件的fd节点。对于监听的文件描述符fd,内核通过回调机制将节点从红黑树结构移入到就绪对立结构。对于我们使用的系统调用接口epoll_wait就是用来维护就绪队列的。
事件回调机制
事件在调用epoll_ctl注册fd时,内核自动为队以ing的fd绑定poll_callback回调机制。当网卡接收数据 触发硬件中断(数据到来),内核协议栈完成收包后,套接字缓冲区状态发生变更,会自动触发预设的回调函数,将就绪 fd 对应的节点移入就绪队列,同时唤醒阻塞等待的 epoll_wait。
对于节点从红黑树结构转到就绪队列时无需遍历红黑树全部文件描述符,仅靠中断驱动、内核主动推送就绪事件,摆脱 select/poll 的轮询遍历,这也是 epoll 能支撑百万级高并发连接、实现高效 IO 调度的核心底层原理。