【Linux】高级IO

一. 五种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

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

理解同步与互斥

  • 在消息通信机制方面:
    所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回. 但是一旦调用返回,就得到返回值了; 换句话说,就是由调用者主动等待这个调用的结果;
    异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果; 换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果; 而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用.
  • 多进程与多线程之间的同步:
    与消息通信的概念毫不相关,这里是相互制约的关系,是为完成某种任务而建立的两个或多个线程,这个线程需要在某些位置上协调他们的工作次序而等待、传递信息所产生的制约关系. 尤其是在访问临界资源的时候
相关推荐
灵晔君2 小时前
C++标准模板库(STL)——list的使用
c++·list
努力学习的小廉2 小时前
我爱学算法之—— 字符串
c++·算法
算法如诗3 小时前
**MATLAB R2025a** 环境下,基于 **双向时间卷积网络(BITCN)+ 双向长短期记忆网络(BiLSTM)** 的多特征分类预测完整实现
开发语言·网络·matlab
我是好小孩3 小时前
【Android】RecyclerView的高度问题、VH复用概念、多子项的实现;
android·java·网络
xiaoxue..3 小时前
用 Node.js 手动搭建 HTTP 服务器:从零开始的 Web 开发之旅!
服务器·前端·http·node.js
LCG元3 小时前
Linux 下高效开发环境搭建:VSCode Remote + 容器开发
linux
哈里谢顿3 小时前
深入理解 Linux 系统 PATH 目录:从理论到实践
linux
闻缺陷则喜何志丹3 小时前
【分块 差分数组 逆元】3655区间乘法查询后的异或 II|2454
c++·算法·leetcode·分块·差分数组·逆元
拾忆,想起3 小时前
Dubbo监控中心全解析:构建微服务可观测性的基石
java·服务器·网络·tcp/ip·微服务·架构·dubbo