5种IO模型与阻塞IO,select,poll,epoll,LT和ET模式

5种IO模型与阻塞IO

核心:

一次 IO 操作 = 等待数据 + 拷贝数据 CPU 速度极快,外设(网卡、磁盘)很慢,大部分时间都在等待 ;高效 IO 的目标就是:减少等待所占的时间比重

钓鱼比喻讲解 5 种 IO 模型

核心类比:钓鱼 = IO,IO = 等待(等鱼上钩) + 拷贝(把鱼钓上来)

  • 河 = 操作系统内核
  • 鱼 = 数据
  • 桶 = 用户缓冲区
  • 鱼竿 = 文件描述符 sockfd

1. 张三:阻塞 IO

专注钓鱼,鱼漂不动,张三就不动 张三拿着一根鱼竿,盯着浮漂。没有鱼(没有数据),就原地卡住,啥都不干;直到鱼咬钩,把鱼钓上来,才结束。

2.李四:非阻塞 IO

不会因为鱼没有上钩而卡在检测鱼漂上 李四拿一根鱼竿,**不停反复看浮漂(**循环不断轮询)。没鱼的时候不卡住,直接返回,李四可以去干点别的,隔一会儿再来查看一次有没有鱼

3. 王五:信号驱动 IO

通过鱼上钩,反向通知我 鱼竿上装铃铛。王五不用一直盯着浮漂,可以去做别的事。鱼上钩,铃铛响(内核发信号通知进程),王五收到通知之后,再过来钓鱼(拷贝数据)。

王五依然要亲自参与 "钓起鱼(拷贝)" 这个步骤。

4.赵六:IO 多路复用(多路转接)

同时监视 赵六一次性拿很多根鱼竿,只需要一个人同时监控全部鱼竿的浮漂,哪根鱼竿鱼上钩了,再单独处理那一根。

5. 田七:异步 IO(AIO)

我喜欢吃鱼,发起钓鱼,小鱼帮我钓好了送过来 田七直接委托别人帮自己钓鱼,全程不用管:不用盯浮漂、不用亲自捞鱼。鱼钓好之后,别人直接把鱼送到桶里,通知田七。

等待 + 拷贝,全部由内核做完,用户进程全程不参与这两步

谁效率最高

IO 多路复用(赵六)。一次性监听大量连接,重要的是大幅降低了等待时间占比,高并发场景首选。

同步 IO vs 异步 IO

阻塞、非阻塞、信号驱动、多路复用 → 全部属于【同步 IO】

IO 分为两大阶段:等待数据、拷贝数据

  • 同步 IO:用户只要进程参与「等待」或者「拷贝」其中任意一步
  • 异步 IO:发起 IO 之后,等待 + 拷贝两件事都交给内核,用户进程完全不参与,内核做完再通知你。

非阻塞IO

fcntl函数

复制代码
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );
  • fd:目标文件描述符(socket、普通文件都可以)
  • cmd:命令,决定 fcntl 做什么事
  • 后面是可变参数,不同 cmd,参数不一样

fcntl 的 5 个功能cmd

  1. 复制文件描述符cmd=F_DUPFD,类似 dup/dup2
  2. 获取 / 设置文件描述符标记F_GETFD / F_SETFD
  3. 获取 / 设置文件状态标记F_GETFL / F_SETFL用来开启非阻塞
  4. 获取 / 设置异步 I/O 所有权:F_GETOWN / F_SETOWN(信号驱动 IO 用)
  5. 获取 / 设置文件记录锁:F_GETLK、F_SETLK、F_SETLKW(文件读写锁)

核心片段:

复制代码
int flags = fcntl(fd, F_GETFL);       // 获取原有状态
fcntl(fd, F_SETFL, flags | O_NONBLOCK); // 添加非阻塞标记

demo设置read为非阻塞

n < 0 返回负数(出错)

不是所有负数都是真的错误! 需要判断全局变量 errno

  1. errno == EAGAIN || errno == EWOULDBLOCK 假错误! 非阻塞特有:内核缓冲区暂时没有数据,不是故障。
  2. errno == EINTR 被信号打断本次 read 调用,不算致命错误,直接continue重试。
  3. 其他 errno 值 真正的 IO 错误(比如 fd 非法),需要处理报错。

Ctrl+D 作用

代表 EOF 文件结束 ,告诉操作系统:输入流已经结束,不会再有数据了 。 对应代码里 read 返回 0,直接 break 退出循环。

多路转接select

核心:select 属于 IO 多路复用,专门负责【等待】,一次性监视多个 fd,哪个 fd 事件就绪就通知用户 IO = 等待 + 拷贝;select 帮你做「等待」这一步,拷贝还是用户自己调用 read/write 完成,所以 select 仍然是同步 IO

新建 socket fd,一开始缓冲区全空

底层有数据 → 读事件就绪;底层有空间 → 写事件就绪

  • 接收缓冲区空:没有数据 → 读事件不就绪
  • 发送缓冲区是空,还有大量空位:写事件默认直接就绪!

一句话记住:新 socket,读事件不就绪,写事件默认就绪

select 做什么

  1. 一次性传入一堆 fd,告诉内核:帮我盯着这些 fd 的读 / 写事件
  2. 进程阻塞在 select 函数上,等待
  3. 只要任意一个 fd 的事件就绪,select 就返回,告诉上层:有 fd 准备好了,可以去 IO
  4. 用户接下来自己调用 read/write 做拷贝

select 函数完整讲解

复制代码
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

逐个参数

  1. nfdsmaxfd + 1
    • maxfd:你放入 fd_set 里最大的文件描述符
    • 内核只需要扫描 0 ~ maxfd 这些 fd,不用遍历全部,用来提高效
  2. readfds(读事件) / writefds(写事件) / exceptfds(异常事件,很少用一般传null)fd_set * 位图集合 fd_set 本质是位图(bit 数组)输入输出双向修改!(输入输出参数)

输入阶段(用户→内核):bit 位 = 1 代表:我关心这个 fd 的该类事件 返回阶段(内核→用户):bit 位 = 1 代表:这个 fd 事件就绪,0 代表未就绪

重要坑:每次 select 返回后,位图会被内核改写!下一轮循环必须重新添加 fd(后面写的代码中就是放到while循环里面)

fd_set 大小固定,默认最多只能监听 1024 个 fd,这是 select 的硬上限缺点

3.timeout:struct timeval 超时时间(输入输出参数)

复制代码
struct timeval {
    time_t      tv_sec;   // 秒
    suseconds_t tv_usec;  // 微秒
};

三种情况:

  1. timeout = NULL阻塞等待,没有 fd 就绪就一直卡在 select,直到有事件
  2. tv_sec=0 && tv_usec=0完全非阻塞,立刻检查一遍所有 fd,马上返回,不等待
  3. 填时间值:带超时阻塞。在指定时间内阻塞等待;超时还没有 fd 就绪,直接返回 0,有就返回就绪个数

举例子

timeout 设置 tv_sec=5(最多等 5 秒)

  • 等待 2 秒的时候,有1个 fd 就绪 → select 返回正数1;同时 timeout 被内核改写,tv_sec 变成 3,代表还剩 3 秒没等。
  • 整整等满 5 秒,全程无 fd 就绪 → select 返回 0;timeout 变成 0,剩余时间为 0。

select 返回值(3 种情况)

  • 返回 > 0:就绪 fd 的总数量,有事件发生了。
  • 返回 = 0:超时,在 timeout 时间内,没有任何 fd 就绪
  • 返回 < 0:出错

fd_set 配套 4 个宏

复制代码
FD_ZERO(&set);    // 清空位图,全部bit置0
FD_SET(fd, &set); // 把fd加入集合,对应bit置1
FD_CLR(fd, &set); // 把fd从集合移除,对应bit清0
FD_ISSET(fd, &set);// 判断返回后,该fd的bit是否为1(是否就绪)

select 三大缺点

  1. fd_set 固定大小,最多监听 1024 个 fd
  2. 位图会被内核修改,每一轮循环,必须重新 FD_ZERO + FD_SET
  3. 返回值只告诉你一共有多少就绪,不会直接告诉你是哪一个 fd ,需要循环遍历全部 fd,调用FD_ISSET挨个判断(O (n) 扫描)

Echo Server demo:

SelectServer.hpp:

复制代码
#pragma once
#include <iostream>
#include <memory>
#include <unistd.h>
#include "Socket.hpp"
#include "Log.hpp"
using namespace SocketModule;
using namespace LogMoudle;

// 基于select IO多路复用的TCP服务器
class SelectServer
{
    // 标记fd不使用,默认-1
    const static int defaultfd = -1;
    // fd_set的最大bit数量,代表select最多能管理的fd总数
    const static int size = sizeof(fd_set) * 8;
public:
    // 构造函数:初始化服务器,创建监听socket
    SelectServer(int port)
        : _is_running(false)
        ,_listensock(make_unique<TcpSocket>())
    {
        // 创建TCP监听套接字,完成socket/bind/listen
        _listensock->BuildTcpSocketMethod(port);
        // 初始化fd数组,全部置为-1,表示暂时不监控
        for (int i = 0; i < size; i++)
        {
            _fd_array[i] = defaultfd;
        }
        // 把监听fd放入数组,select需要监控listenfd的读事件(新连接)
        _fd_array[0] = _listensock->Fd();
    }

    // 启动服务器主循环
    void Start()
    {
        _is_running = true;
        while (_is_running)
        {
            fd_set rfds; // 读事件集合,每次select都要重新构造!select会修改这个集合
            FD_ZERO(&rfds); // 清空fd集合

            int maxfd = defaultfd; // 记录当前最大fd,select参数需要maxfd+1

            // 遍历保存fd的数组,把所有需要监控的fd加入rfds
            for (int i = 0; i < size; i++)
            {
                if (_fd_array[i] == defaultfd)
                    continue; // -1代表无效fd,跳过
                FD_SET(_fd_array[i], &rfds); // 将fd添加到读事件集合
                if (_fd_array[i] > maxfd)
                    maxfd = _fd_array[i]; // 更新最大fd
            }

            PrintFd(); // 打印当前正在管理的fd,调试用
            // select:只监控读事件,阻塞等待事件发生,写/异常集合传nullptr
            int n = select(maxfd + 1, &rfds, nullptr, nullptr, nullptr);

            switch (n)
            {
            case -1:
                LOG(LogLevel::ERROR) << "select error";
                break;
            case 0:
                LOG(LogLevel::INFO) << "time out";
                break;
            default:
                // n>0,代表有n个fd读就绪
                LOG(LogLevel::DEBUG) << "有事件就绪了....n:" << n;
                Dispatcher(rfds); // 事件分发,处理就绪fd
                break;
            }
        }
        _is_running = false;
    }

    // 事件分发函数:遍历所有fd,判断谁就绪,做对应处理
    void Dispatcher(fd_set &rfds)
    {
        for (int i = 0; i < size; i++)
        {
            if (_fd_array[i] == defaultfd)
                continue;

            // FD_ISSET判断该fd是否在就绪集合中
            if (FD_ISSET(_fd_array[i], &rfds))
            {
                if (_fd_array[i] == _listensock->Fd())
                {
                    // 监听fd就绪:代表有新客户端连接到来
                    Accepter();
                }
                else
                {
                    // 普通客户端fd就绪:代表客户端发来数据,可以读
                    Recver(_fd_array[i],i);
                }
            }
        }
    }

    // 处理新连接:accept获取新客户端fd,存入_fd_array数组
    void Accepter()
    {
        InetAddr client;
        int sockfd=_listensock->Accept(&client);
        if(sockfd>=0)
        {
             LOG(LogLevel::INFO) << "get a new link, sockfd: "
             << sockfd << ", client is: " << client.StringAddr();

             // 找_fd_array里第一个空位(值为-1)存放新的client fd
             int pos=0;
             for(;pos<size;pos++)
             {
                 if(_fd_array[pos]==defaultfd)break;
             }
             if(pos==size)
             {
                 // 数组满了,无法接入更多客户端
                 LOG(LogLevel::WARNING)<<"select server full";
                 close(sockfd);
             }
             else
             {
                 _fd_array[pos]=sockfd;
             }
        }
    }

    // 读取客户端数据
    // fd:客户端套接字,pos:fd在_fd_array数组的下标
    void Recver(int fd,int pos)
    {
        char buffer[1024];
        ssize_t n=recv(_fd_array[pos],buffer,sizeof(buffer)-1,0);
        if(n>0)
        {
            // 读到n字节有效数据
            buffer[n]=0;
            cout<<"clietn sat@"<<buffer<<endl;
        }
        else if(n==0)
        {
            // recv返回0:客户端正常关闭连接
            LOG(LogLevel::INFO)<<"client quit";
            _fd_array[pos]=defaultfd;// 标记这个位置不再监控
            close(fd);
        }
        else
        {
            // recv<0,读取出错
             LOG(LogLevel::ERROR) << "recv error";
             _fd_array[pos] = defaultfd; // 取消监控
             close(fd); // 关闭套接字,释放资源
        }
    }

    // 调试函数:打印当前管理的所有fd
    void PrintFd()
    {
        std::cout << "_fd_array[]: ";
        for (int i = 0; i < size; i++)
        {
            if (_fd_array[i] == defaultfd)
                continue;
            std::cout << _fd_array[i] << " ";
        }
        std::cout << "\r\n";
    }
private:
    int _fd_array[size];       // 保存所有需要select监控的fd,-1代表空位
    unique_ptr<Socket> _listensock; // 监听套接字
    bool _is_running;          // 服务器运行标记
};

多路转接poll

poll函数(IO 多路复用)

复制代码
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
  1. fdsstruct pollfd 数组,存放你要监控的所有文件描述符
  2. nfds:数组里面有多少个元素(要监听 fd 的数量)
  3. timeout:等待超时时间,单位毫秒
    • >0:等待多少 ms
    • 0:不阻塞,立刻返回(轮询)
    • -1:永久阻塞,直到有事件发生

struct pollfd

复制代码
struct pollfd {
    int fd;         // 要监听的文件描述符
    short events;   // 【你设置】你想要监听什么事件(输入)
    short revents;  // 【内核填充】实际发生了什么事件(输出)
};

events:用户写进去,告诉内核我关心什么; revents:poll 返回后,内核填好,告诉你这个 fd 发生了什么。

常用事件宏

  • POLLIN:可读事件
  • POLLOUT:可写事件
  • POLLERR:出错(内核自动设置,不用在 events 写)
  • POLLHUP:挂起,连接断开(内核自动设置,不用 events 写)

返回值

  • 0:有多少个 fd 就绪(有事件发生)

  • 0:超时,没有 fd 就绪

  • -1:出错,errno 记录错误

PollServer.hpp:

复制代码
#pragma once

#include <iostream>
#include <memory>
#include <sys/poll.h>
#include <unistd.h>
#include "Socket.hpp"
#include "Log.hpp"

using namespace SocketModule;
using namespace LogMoudle;

class PollServer
{
    const static int defaultfd = -1;
    const static int size = 4096; // 想给多少给多少,不像select有限制
public:
    PollServer(int port)
        : _is_running(false), _listensock(make_unique<TcpSocket>())
    {
        _listensock->BuildTcpSocketMethod(port);
        for (int i = 0; i < size; i++)
        {
            _fds[i].fd = defaultfd;
            // 置为0不要干扰
            _fds[i].events = 0;
            _fds[i].revents = 0;
        }
        //_fd_array的元素为-1表示不关心,为其它值有2种情况1是就绪2是没有就绪
        _fds[0].fd = _listensock->Fd();
        _fds[0].events = POLLIN;
    }
    void Start()
    {
        int timeout = -1;
        _is_running = true;
        while (_is_running)
        {
            PrintFd();
            int n = poll(_fds, size, timeout);
            switch (n)
            {
            case -1:
                LOG(LogLevel::ERROR) << "poll error";
                break;
            case 0:
                LOG(LogLevel::INFO) << "poll time out";
            default:
                LOG(LogLevel::DEBUG) << "有事件就绪了....n:" << n;
                Dispatcher();
                break;
            }
        }
        _is_running = false;
    }
    void Dispatcher()
    {
        for (int i = 0; i < size; i++)
        {
            if (_fds[i].fd == defaultfd)
                continue;
            // 走到这里一定是合法,但不一定就绪
            if (_fds[i].revents & POLLIN)
            {
                // 走到这里一定就绪,普通事件就绪,读就绪

                if (_fds[i].fd == _listensock->Fd())
                {
                    // 读事件就绪
                    Accepter();
                }
                else
                {
                    // 普通事件就绪
                    Recver(i);
                }
            }
        }
    }
    void Accepter()
    {
        InetAddr client;
        int sockfd = _listensock->Accept(&client);
        if (sockfd >= 0)
        {
            LOG(LogLevel::INFO) << "get a new link, sockfd: "
                                << sockfd << ", client is: " << client.StringAddr();
            int pos = 0;
            for (; pos < size; pos++)
            {
                if (_fds[pos].fd == defaultfd)
                    break;
                // break出来2种情况
                if (pos == size)
                {
                    LOG(LogLevel::WARNING) << "select server full";
                    close(sockfd);
                }
                else
                {
                    _fds[pos].fd = sockfd;
                    _fds[pos].events = POLLIN;
                    _fds->revents = 0;
                }
            }
        }
    }
    void Recver(int pos)
    {

        char buffer[1024];
        ssize_t n = recv(_fds[pos].fd, buffer, sizeof(buffer) - 1, 0);
        if (n > 0)
        {
            buffer[n] = 0;
            cout << "clietn sat@" << buffer << endl;
        }
        else if (n == 0)
        {
            LOG(LogLevel::INFO) << "client quit";
            close(_fds[pos].fd);
            _fds[pos].fd = defaultfd;
            _fds[pos].events = 0;
            _fds->revents = 0;
        }
        else
        {
            LOG(LogLevel::ERROR) << "recv error";
            close(_fds[pos].fd);
            _fds[pos].fd = defaultfd;
            _fds[pos].events = 0;
            _fds->revents = 0;
        }
    }
    void PrintFd()
    {
        std::cout << "_fd_array[]: ";
        for (int i = 0; i < size; i++)
        {
            if (_fds[i].fd == defaultfd)
                continue;
            std::cout <<_fds[i].fd << " ";
        }
        std::cout << "\r\n";
    }

private:
    struct pollfd _fds[size];//也可以写成指针满了就扩容
    unique_ptr<Socket> _listensock;
    bool _is_running;
};
//void AddFd(int fd, short events)
// {
//     if (_nfds >= _capacity)
//     {
//         // 扩容,两倍
//         int newcap = _capacity * 2;
//         struct pollfd* newfds = new pollfd[newcap];
//         memcpy(newfds, _fds, sizeof(pollfd)*_nfds);
//         delete[] _fds;
//         _fds = newfds;
//         _capacity = newcap;
//     }
//  }

多路转接之epoll

epoll 是 Linux 独有的 IO 多路复用,用来替代 select/poll,高性能,适合大量 fd。 一共 3 个核心函数:epoll_createepoll_ctlepoll_wait

1.epoll_create

复制代码
int epoll_create(int size);

作用:创建一个 epoll 模型,返回 epollfd(epoll 自己的文件描述符)

  • size:早期版本提示内核预估 fd 数量,新版本内核不再依赖这个值,随便传正数就行
  • 返回值:成功返回 epollfd,失败返回 - 1

epollfd 本质也是 fd,用完要 close ()

2.epoll_ctl(控制接口,最核心)

复制代码
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

功能:用户告诉内核,对哪个 fd 做什么操作,监听什么事件

参数:

  1. epfd:epoll_create 的返回值,epollfd

  2. op:操作类型,3 种:

    • EPOLL_CTL_ADD添加 fd到 epoll 的监听红黑树(注册,开始监听)
    • EPOLL_CTL_MOD修改已经注册 fd 的监听事件
    • EPOLL_CTL_DEL删除fd,不再监听
  3. fd:要操作的目标文件描述符(socket、listenfd 等)

  4. *event:结构体,告诉内核要监听什么事件

    struct epoll_event {
    uint32_t events; // 你关心的事件(EPOLLIN / EPOLLOUT等)
    epoll_data_t data; // 用户数据,内核不修改,就绪后原样还给你
    };
    typedef union epoll_data {
    void *ptr;
    int fd;
    uint32_t u32;
    uint64_t u64;
    } epoll_data_t;

events常用宏:

  • EPOLLIN:可读事件(新连接、收到数据)
  • EPOLLOUT:可写事件
  • EPOLLERR:出错,内核自动监听,不用手动注册
  • EPOLLHUP:连接挂断,内核自动监听
  • EPOLLET边缘触发 ET(默认是水平触发 LT)

3. epoll_wait(等待事件)

复制代码
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

作用:阻塞等待 fd 就绪,内核把就绪的事件填到 events 数组返回给用户

参数:

  1. epfd:epoll 句柄
  2. events:输出数组 ,内核把所有就绪的 fd 事件放到这个数组(是用户提前分配好的数组)放的时候就按照数组下标0,1,2....依次填充
  3. maxevents:数组最多能存多少个事件
  4. timeout:超时,单位毫秒,和 poll 一样
    • -1永久阻塞;0非阻塞立刻返回;大于 0 等待指定 ms

返回值

  • >0:就绪 fd 的数量

  • 0:超时,没有 fd 就绪

  • -1:出错

epoll 内核原理

  1. 红黑树 :保存所有你注册监听的 fd (通过epoll_ctl(ADD)添加进去)
    • 节点:红黑树节点(自带 rb_node 成员),里面存 fd、要监听的事件、红黑树左右指针、链表指针
    • 作用:快速查找、新增、删除 fd。
    • 对应图里:epoll_ctl ADD 就是把 fd 节点插入这棵红黑树。
  2. 就绪链表(就绪队列) :保存已经发生事件、就绪的 fd
    • 当某个 fd 事件就绪,内核自动把这个 fd 节点,从红黑树里拎出来,挂到就绪链表(回调函数作用)。
    • epoll_wait 就是直接读取这个就绪链表,拿就绪事件。

完整整套流程

① epoll_create

调用epoll_create,内核在内存里创建一个 epoll 对象,返回 epollfd。 这个 epoll 对象里面,就包含:红黑树 + 就绪链表

细节(底层):

  1. 内核分配 struct eventpoll
  2. 内核新建一个文件,返回 fd,就是epfd
  3. epfd 对应的struct filefile->private_data = eventpoll

后续 epoll_ctl /epoll_wait 传 epfd,内核就靠:epfd → file → private_data 拿到 eventpoll。

struct eventpoll 核心成员:

  • rbr:红黑树根,存放所有被监听 fd 对应的 epitem
  • rdllist:就绪链表,存放已经发生事件的 epitem
  • wq:epoll_wait 的等待队列,用户进程没事件时阻塞在这里

② epoll_ctl( EPOLL_CTL_ADD )

你调用epoll_ctl把 fd(listenfd 或者客户端 socket)注册进 epoll。 内核做两件事:

  1. 创建节点,存入 fd、监听的事件(EPOLLIN 等),插入红黑树;
  2. 给这个 fd 注册回调函数

重点:告诉内核:这个 fd 一旦就绪,就执行回调,即把这个节点丢进就绪链表

细节(底层):

  1. 内核通过 epfd 找到 eventpoll

  2. 分配struct epitem,填入 fd、要监听的事件(EPOLLIN/EPOLLOUT)

  3. 将 epitem 插入 eventpoll->rbr 红黑树(fd 作为 key,快速查找)

  4. epitem 有保存指向sockfd(指向sockfd,不是epfd)对应的struct file指针记住「当前这个 epitem 是监听哪一个文件(socket)」。

  5. 分配 struct eppoll_entry

    • eppoll_entry->epi = epitem,回调执行的时候,通过 eppoll_entry 就能找到 epitem。
    • eppoll_entry 里面的wait等待节点,注册回调:wait.func = ep_poll_callback

    struct eppoll_entry {
    struct epitem *epi; // ① 指向epitem
    wait_queue_entry_t wait; // ② 等待节点,里面存回调函数
    struct list_head llink;
    };

6.把eppoll_entry.wait这个节点挂载到 socket 底层的读 / 写等待队列

复制代码
struct sock {
    // ...
    wait_queue_head_t sk_sleep;   // ✅ socket的等待队列头!
    // ...
};

③ 数据从网卡到来,硬件中断 + 协议栈回调(图1下半部分)

  1. 网卡收到网络数据 → 触发硬件中断
  2. 内核网络协议栈处理数据包,发现这个 socket fd 收到数据,fd 就绪
  3. 触发我们提前注册好的回调函数
  4. 回调函数:把这个 fd 对应的节点,添加到就绪链表

细节(底层):

  1. 网卡收到数据,socket 底层感知到读事件就绪
  2. socket 自己的等待队列被唤醒,执行等待项里的回调函数:ep_poll_callback
  3. 通过ep_poll_callback拿到 eppoll_entry,找到 epitem,再找到 epitem 所属 eventpoll
  4. 将 epitem 放入 eventpoll 的 rdllist 就绪链表,唤醒 epoll_wait 阻塞的进程

关键区别 select/poll:select/poll 是用户调用的时候,内核挨个遍历全部 fd,检查有没有就绪(轮询) epoll:不是轮询!os去做,是 fd 就绪时,硬件中断触发回调,主动放进就绪链表

④ epoll_wait

调用epoll_wait(epfd, events, maxevents, timeout) 内核直接看就绪链表

  • 链表不为空:把就绪链表里面的事件拷贝到你传进去的events数组,返回就绪数量。
  • 链表为空:阻塞等待,直到超时或者有事件进就绪链表。

epoll_wait不用扫描全部 fd,只拿已经就绪的,因为是从就绪链表拿,肯定是已经就绪了

细节(底层)

  1. 内核拿 epfd 找到 eventpoll
  2. 检查 eventpoll->rdllist 就绪链表
    • 链表不为空:遍历 rdllist,把就绪事件拷贝到用户空间,清空 rdllist,返回就绪 fd 数量
    • 链表为空:当前进程阻塞,挂入 eventpoll->wq 等待队列,休眠,等待ep_poll_callback唤醒

总结(主要步骤):
epoll_create 生成 eventpoll 与 epfd,epfd 对应的 file 的 private_data 保存 eventpoll。epoll_ctl 添加 fd 时创建 epitem 放入红黑树,同时生成 eppoll_entry存放ep_poll_callback,ep_poll_callback 注册到 socket 底层等待队列。当 socket 事件就绪,触发 ep_poll_callback找到eppoll_entry进而找到epitem,将 epitem 放入 eventpoll 的 rdllist 就绪链表,唤醒 epoll_wait 阻塞的进程,epoll_wait 读取 rdllist 里就绪事件返回给用户

问题:

1.就绪检测是谁做的?操作系统内核,靠网卡中断 + 回调,不是 epoll_wait 去挨个轮询 fd!

2.复杂度:

  • 内核检测 fd 就绪:O (1),就绪了直接回调丢进就绪队列
  • epoll_wait 拿到就绪事件,遍历就绪数组:O (就绪 fd 数量),不是全部 fd

就绪队列 = epoll 本质就是生产者消费者模型

内核是生产者 :网卡收到数据 → fd 就绪 → 回调函数,把就绪节点放入就绪队列(生产事件)。用户进程(epoll_wait)是消费者 :调用 epoll_wait,从就绪队列取出就绪事件(消费事件)。缓冲区:内核就绪链表

核内部自带同步保护,epoll 接口本身线程安全( 多个线程,同时对同一个 epfd 调用 epoll_wait /epoll_ctl,内核内部做了保护,不会出现内核数据结构(红黑树、就绪链表)乱掉、崩溃)。

epoll_wait 用户缓冲区events数组不够大(maxevents 太小)怎么办?

没拿,内核保留就绪节点,下次 epoll_wait 继续拿。

举例子:就绪队列里面有 10 个就绪 fd,你maxevents=4

  1. 本次 epoll_wait,内核拷贝前 4 个就绪事件到你的 events 数组,返回 4
  2. 剩下 6 个就绪节点仍然留在内核就绪链表,不会丢掉
  3. 下一轮再次调用 epoll_wait,继续把剩下的给你,不会丢事件,只是分多次 epoll_wait 返回。
  • 拷贝规则:内核遍历内核就绪链表,依次把节点的事件拷贝到你的数组里,从数组下标 0 开始填

eg:内核拿到一个就绪节点,把事件信息复制到你传入的用户数组events

  • 第一个拷贝的放 events[0]
  • 第二个拷贝的放 events[1]
  • 第三个拷贝的放 events[2]

Echo EpollServer:

EpollSever.hpp:

复制代码
#pragma once
#include"Common.hpp"
#include <iostream>
#include <memory>
#include <sys/epoll.h>
#include <unistd.h>
#include "Socket.hpp"
#include "Log.hpp"

using namespace SocketModule;
using namespace LogMoudle;

class EpollServer
{
    const static int defaultfd = -1;
    const static int size = 64; 
public:
   EpollServer(int port)
        : _is_running(false), _listensock(make_unique<TcpSocket>()),_epfd(defaultfd)
    {
        _listensock->BuildTcpSocketMethod(port);
        //2.创建epoll模型
        _epfd=epoll_create(256);
        if(_epfd<0)
        {
            LOG(LogLevel::FATAL)<<"epoll_create error";
            exit(EPOLL_CREATE_ERR);
        }
        LOG(LogLevel::DEBUG)<<"epoll_create sucess "<<_epfd;
        //添加listen的fd
        struct epoll_event ev;
        ev.data.fd=_listensock->Fd();
        ev.events=EPOLLIN;
        int n=epoll_ctl(_epfd,EPOLL_CTL_ADD,_listensock->Fd(),&ev);
        if(n<0)
        {
            LOG(LogLevel::FATAL)<<"add listensockfd failed";
            exit(EPOLL_CTL);
        }
      
    }
    ~EpollServer()
    {
        _listensock->Close();
        if (_epfd > 0)
            close(_epfd);
    }
    void Start()
    {
        int timeout = -1;
        _is_running = true;
        while (_is_running)
        {
            int n=epoll_wait(_epfd,_revs,size,timeout);
            switch (n)
            {
            case -1:
                LOG(LogLevel::ERROR) << "poll error";
                break;
            case 0:
                LOG(LogLevel::INFO) << "poll time out";
            default:
                LOG(LogLevel::DEBUG) << "有事件就绪了....n:" << n;
                Dispatcher(n);
                break;
            }
        }
        _is_running = false;
    }
    void Dispatcher(int readynum)
    {
        LOG(LogLevel::DEBUG) << "event ready ...";
        for(int i=0;i<readynum;i++)
        {
            int sockfd=_revs[i].data.fd;
            uint32_t revent=_revs[i].events;
            
            if(revent&EPOLLIN)
            {
                //到这里说明有事件就绪
                if(sockfd==_listensock->Fd())
                {
                    //到这里说明是liten就绪
                    Accepter();
                }
                else
                {
                    //普通事件就绪
                    Recver(sockfd);
                    
                }

                
            }
        }
    }
    void Accepter()
    {
        InetAddr client;
        int sockfd = _listensock->Accept(&client);
        if (sockfd >= 0)
        {
            LOG(LogLevel::INFO) << "get a new link, sockfd: "
                                << sockfd << ", client is: " << client.StringAddr();

            struct epoll_event ev;
            ev.events = EPOLLIN;
            ev.data.fd = sockfd;
            int n=epoll_ctl(_epfd,EPOLL_CTL_ADD,sockfd,&ev);
            if (n < 0)
            {
                LOG(LogLevel::WARNING) << "add listensockfd failed";
            }
            else
            {
                LOG(LogLevel::INFO) << "epoll_ctl add sockfd success: " << sockfd;
            }
        }
    }
    void Recver(int sockfd)
    {

        char buffer[1024];
        //此次recv不会阻塞
        ssize_t n = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
        if (n > 0)
        {
            buffer[n] = 0;
            cout << "clietn sat@" << buffer << endl;
        }
        else if (n == 0)
        {
            LOG(LogLevel::INFO) << "client quit";
              // 2. 从epoll中移除fd的关心 && 关闭fd -- 细节:epoll_ctl: 只能移除合法fd -- 先移除,在关闭!!
            int m=epoll_ctl(_epfd,EPOLL_CTL_DEL,sockfd,nullptr);//nullptr表示不监听任何事件,只管删
            if(m > 0)
            {
                LOG(LogLevel::INFO) << "epoll_ctl remove sockfd success: " << sockfd;
            }
            close(sockfd);
        }
        else
        {
            LOG(LogLevel::ERROR) << "recv error";
            int m=epoll_ctl(_epfd,EPOLL_CTL_DEL,sockfd,nullptr);//nullptr表示不监听任何事件,只管删
            if(m > 0)
            {
                LOG(LogLevel::INFO) << "epoll_ctl remove sockfd success: " << sockfd;
            }
            close(sockfd);
           
        }
    }
    

private:
    std::unique_ptr<Socket> _listensock;
    int _epfd;
    struct epoll_event _revs[size];
 
    bool _is_running;
};

select /poll/epoll 优缺点对比

select poll epoll
优点 跨平台,兼容性好,简单 1. 去掉 select 1024 fd 上限2. 结构更直观,不用位图 1. 高并发性能强,只返回就绪 fd2. 支持 LT 水平触发、ET 边缘触发3. fd 数量无硬性上限
缺点 1. 默认最大 fd 10242. 每次调用拷贝全部 fd 集合3. 内核遍历全部 fd(O (n))4. 返回后用户态还要循环查找就绪 fd5. fd_set 会被内核修改,每次要重填 1. 依旧每次拷贝全部 pollfd 数组2. 内核遍历全部 fd(O (n))3. 返回后用户态遍历数组找就绪 fd4. Linux 以外平台支持一般 1. Linux 专属,不跨平台2. ET 边缘触发写代码容易出错,要小心

LT(水平触发)、ET(边缘触发)

LT 水平触发(select/poll 默认也是 LT)

只要底层缓冲区还有报文,就一直通知上层

  1. 缓冲区只要还有没读完的数据,每次调用epoll_wait,都会再次返回这个 fd,告诉你可读。
  2. 上层这次可以只读一部分,不用一次性读完。剩下的数据还在缓冲区,下一次 epoll_wait 还会收到通知。
  3. 优点:写代码简单,不容易丢事件。
  4. 缺点:会产生很多重复通知。缓冲区一直有数据,就反复通知。

举例子:缓冲区有 100 字节,你本次只读 20 字节,还剩 80。下次 epoll_wait仍然会再次通知这个 fd 就绪

ET 边缘触发(epoll 独有)

只有底层数据发生变化(从无→有、新增数据)那一刻,才通知一次

  1. 只有状态变化瞬间触发通知,只通知一次。
  2. 如果这次收到通知,没有一次性把缓冲区全部数据读完 ,剩下的数据留在缓冲区,不会再触发新通知!程序就卡住,读不到剩下的数据。
  3. 强制要求:ET 必须搭配非阻塞 read,循环读直到缓冲区读空。
  4. 优点:通知次数少,减少内核和用户态之间的交互,效率更高。
  5. 缺点:写代码容易出错,漏读数据就会丢消息。

例子:缓冲区从 0 字节变成 100 字节 → 触发一次通知。你只读 20,剩下 80 留在缓冲区,没有新数据进来的话,永远不再通知

一句话对比

  • LT:缓冲区有数据就通知,多次通知,好写代码。
  • ET:数据刚来那一刻只通知一次,通知少,代码难写,必须一次性读完。

ET 为什么必须是非阻塞 fd?

  1. 你不知道缓冲区还有多少剩余数据,只能循环调用recv反复读。
  2. 读到缓冲区空 的时候,如果 fd 是阻塞 的: recv卡住(阻塞休眠),程序停在这里不动,等新数据到来,整个线程卡死。
  3. 如果 fd 设置成非阻塞 : 缓冲区读完之后,再调用 recv 不会卡住,直接返回 - 1,并且 errno = EAGAIN 。 代码捕获 EAGAIN,就知道缓冲区空了,跳出读循环,完美结束本轮读取。

ET 固定组合:ET + 非阻塞 IO + 循环 recv 直到 EAGAIN

LT 阻塞 / 非阻塞,循环读/不循环(4种组合)

前面我们的代码再recv的时候,读一次绝对不会阻塞,因为epoll_wait 返回这个 fd 就绪 → 此刻缓冲区一定有数据。如果还要强行循环但却已经没有数据了的话,就会阻塞

1. LT + 阻塞 fd + 不循环读(最经典、最简单写法)

逻辑:epoll_wait 拿到就绪事件 → 调用 1 次 recv,读完本次拿到的数据,直接退出,不循环

  1. LT + 阻塞 fd + 循环读(不推荐,危险!)

逻辑:收到事件,while 循环不停 recv

  1. LT + 非阻塞 fd + 不循环读(没发挥非阻塞优势,很少这么写)

逻辑:收到事件,recv 一次,拿到数据就退出

4. LT + 非阻塞 fd + 循环读(推荐优化写法)

逻辑:收到事件,while 循环 recv,直到返回 EAGAIN,代表缓冲区空,退出循环

现在可以看到LT的第4种写法和ET功能一样即一次性读完,那为什么还要ET呢?

本质差别:LT 是程序员自愿优化 ;ET 是OS 强制要求

我们不能保证程序员选用LT一定会采取第4种方案,但一旦选了ET工作模式就是固定的了。

ET高效的原因?

1.有效的通知数量多(即通知过后真正读了数据的通知次数)

  • LT:缓冲区只要剩一点数据,每次 epoll_wait 都会上报事件,产生大量重复通知。
  • ET:只有数据新增(状态变化瞬间)通知一次,有效事件数量更少,减少内核 / 用户态来回交互开销。

2.强制读完缓冲区,接收窗口及时放大,提升 TCP 吞吐。

TCP 接收窗口代表内核缓冲区剩余空闲容量。应用把内核缓冲区的数据全部读走,缓冲区空闲空间变多,接收窗口 win 变大;发送方收到更大的窗口,就能一次性发送更多数据,提升 TCP 吞吐。

补充:epoll 可读的低水位线(low water mark)

epoll 判定 fd 变为可读就绪的规则:

内核缓冲区里的数据量 ≥ 低水位线 → epoll 标记这个 fd 就绪,epoll_wait 返回通知你。

  • 默认 TCP 读低水位线:1 字节。只要缓冲区有至少 1 个字节数据,就触发可读事件。
  • 低水位线是 epoll/IO 多路复用的判定阈值,和 TCP 本身无关。
相关推荐
荣合技术服务1 小时前
高校HPC超算集群平台建设服务商——荣合科算
服务器·云计算·超算集群·hpc超算·服务器集群·科研计算
DFT计算杂谈1 小时前
无图形界面服务器用 Codex 终端连接本地部署的 DeepSeek
运维·服务器·网络
汉克老师1 小时前
CSP-J 初赛(以满分为目标):第三十八课《数学与逻辑①——排列还是组合?先搞清楚“选”与“排”》
c++·csp-j·小学生·学c++编程
qeen871 小时前
【C++】智能指针介绍
开发语言·c++·笔记·指针
Lionel_Coder1 小时前
matmul 总体设计
c++
Dream Cosmos1 小时前
C++ 中 static 关键字的作用与使用规则
linux·c++
小此方2 小时前
告别云服务器连接困扰——Windows下利用VirtualBox安装配置Ubuntu虚拟机详细教程
服务器·windows·ubuntu
路径规划6662 小时前
(MATLAB代码) 船舶轨迹聚类:K-means 算法(260912)
算法·matlab·kmeans
GLAB-Mary3 小时前
90%的网络工程师,根本没必要考HCIE!
网络·华为·华为认证·hcie·hcia·hcip