【Linux | 网络】高级IO

发布于:2025-08-18 ⋅ 阅读:(13) ⋅ 点赞:(0)

在这里插入图片描述

一、IO是什么

IO-input/output,就是输入输出,也就是访问外设的问题。

IO = 等 + 拷贝,等就是等待数据准备就绪,拷贝就是数据从外部缓冲区拷贝到内存(input)或数据从内存拷贝到外部缓冲区(output)。

我们都知道IO慢,而IO慢的主要原因不是拷贝,而是等。高效IO就是单位时间,减少等的比重,这样就能提高IO的效率。


二、五种IO模型

2.1 理解五种IO模型

五种常见的 IO 模型有阻塞 IO、非阻塞 IO、多路复用 IO、信号驱动 IO和异步 IO。

可以分为两类:同步 IO(阻塞 IO、非阻塞 IO、多路复用 IO、信号驱动 IO)和异步 IO

下面我就将一个钓鱼故事帮助大家理解:

  • 小A
    • 小A带着自己的钓鱼工具来到河边,选了一个位置,拿出自己的鱼竿,在鱼钩上弄上鱼饵,就抛竿开始钓鱼了,在钓鱼的过程中,小A会一直盯着鱼漂,其他什么事都不管,鱼漂动了就是鱼上钩了,小A就开始提竿把鱼钓上来
    • 从始至终,小A一直在等待鱼儿上钩,和将鱼儿钓上来,并且在等待的过程中没有干任何事
  • 小B
    • 小B带着自己的钓鱼工具来到河边,看到小A就向小A打招呼,小A并没有回应他,小B也在河边选了一个位置,拿出自己的鱼竿,在鱼钩上弄上鱼饵,就抛竿开始钓鱼了,在钓鱼的期间,小B并没有像小A一样一直盯着鱼漂,而是隔一段时间看一下,没有鱼就看看书、看看手机等,当小B重复看鱼漂的过程中,鱼漂动了就是鱼上钩了,小B就开始提竿把鱼钓上来
    • 小B一直在等待鱼儿上钩,和将鱼儿钓上来,小B在等待的过程中,是每隔一段时间就检查一次是否上钩,在等待的过程中,还干了其他事
  • 小C
    • 小C带着自己的钓鱼工具来到河边,选了一个位置,拿出自己的鱼竿,在鱼竿的顶部绑了一个小铃铛,然后在鱼钩上弄上鱼饵,也抛竿开始钓鱼了,小C在抛杆后就开始干其他事了,此时鱼漂动了,此时小铃铛发出响动,小C就开始提竿把鱼钓上来
    • 小C将鱼竿抛下后,就去干其他事了,并没有等待的过程,但鱼儿上钩了,小铃铛会提醒他,还是需要自己将鱼儿钓上来
  • 小D
    • 小D为了提高钓鱼的效率,带着自己的96条鱼竿,也开始钓鱼了,小D会巡视这96条鱼竿的情况,当有一条鱼竿上鱼漂动了,小D就去使用指定的鱼竿,将鱼钓上来
    • 这条河一共100条鱼竿,小D的鱼竿占96%,鱼儿咬他的鱼饵的概率更大,但是小D还是需要自己等待鱼儿上钩,然后将鱼儿钓上来
  • 大E
    • 大E想吃鱼,但是他又不想自己钓,他就叫小E去钓,小E也带着他的钓鱼工具去钓鱼,等待鱼儿上钩,小E就将鱼钓上来
    • 从始至终,大E都只是让小E去钓鱼,但等待鱼儿上钩和将鱼儿钓上来的过程都是小E完成的

在上面这个故事中,河就是操作系统,鱼儿就是数据,鱼漂就是就绪条件,鱼竿就是文件描述符,人就是进程(目前这么理解,描述为进程并不准确),钓鱼 = 等 + 掉,IO = 等 + 拷贝。

  • 小A钓鱼过程可以理解为阻塞IO,进程在等待的过程不会被其他事影响,一直进行等待,期间无法做其他事,直到条件就绪,并且条件就绪了,进程需要自己拷贝数据,参与了等和拷贝
  • 小B钓鱼过程可以理解为非阻塞IO,进程在等待的过程可以去干其他事,需要循环查看条件是否就绪,但条件就绪了,进程还是需要自己拷贝数据,参与了等和拷贝
  • 小C钓鱼过程可以理解为信号驱动IO,进程在发起IO请求后,就去干其他事了,条件就绪了就会有信号通知进程,但是进程还是需要自己拷贝数据,参与了拷贝
  • 小D钓鱼过程可以理解为多路复用IO,进程发起并监控多个IO请求,当其中一个条件就绪,进程需要自己拷贝数据,参与了等和拷贝
  • 大E钓鱼过程可以理解为异步IO,大E发送请求后,是小E在等待条件就绪,并且在就绪后,也是小E拷贝数据,大E并没有参与了等和拷贝

同步IO和异步IO的区别就是是否参与了IO过程,也就是是否参与了等或拷贝中的一个过程。


2.2 五种IO模型的定义

  1. 阻塞IO

    • 在内核将数据准备好之前,系统调用会一直等待,所有的套接字,默认都是阻塞方式
    • 阻塞IO是最常见的IO模型
      在这里插入图片描述
  2. 非阻塞IO

    • 如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回EWOULDBLOCK错误码

    • 非阻塞IO往往需要程序员循环的方式反复尝试读写文件描述符,这个过程称为轮询,这对CPU来说是较大的浪费,一般只有特定场景下才使用
      在这里插入图片描述

  3. 信号驱动IO

    • 内核将数据准备好的时候,使用SIGIO信号通知应用程序进行IO操作
      在这里插入图片描述
  4. IO多路转接

    • 虽然从流程图上看起来和阻塞IO类似,实际上最核心在于IO多路转接能够同时等待多个文件描述符的就绪状态
      在这里插入图片描述
  5. 异步IO

    • 由内核在数据拷贝完成时。通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据)
      在这里插入图片描述

三、 非阻塞IO

3.1 fcntl函数

#include <unistd.h>
#include <fcntl.h>

int fcntl(int fd, int cmd, ... /* arg */);

功能:用于对已打开的文件描述符进行各种底层操作,如设置非阻塞模式、文件锁、信号驱动 I/O 等。

参数

  • fd:文件描述符(如 socket、普通文件、管道等)
  • cmd:控制命令,决定 fcntl 的行为
  • arg(可选):某些命令需要额外参数

常见cmd命令

命令 作用
F_GETFL 获取文件状态标志(如 O_RDONLY、O_NONBLOCK 等)
F_SETFL 设置文件状态标志(如设置非阻塞模式 O_NONBLOCK)
F_GETFD 获取文件描述符标志(如 FD_CLOEXEC)
F_SETFD 设置文件描述符标志(如 FD_CLOEXEC)
F_GETLK / F_SETLK / F_SETLKW 获取/设置/阻塞设置文件锁(struct flock)

3.2 实现函数SetNoBlock(将文件描述符设置为非阻塞)

一个文件描述符,默认都是阻塞IO,下面我写了一段代码,使程序重复从键盘中读取数据,又由于文件描述符默认是阻塞IP,所以运行程序后,会发现只要我不从键盘中输入数据,进程就会在read这个一直等待。
在这里插入图片描述

下面我就实现SetNoBlock函数,用来将文件描述符设置为非阻塞。

这里就有一个问题,从指定文件描述符中读取数据,并且文件描述符为非阻塞,当数据未就绪时,read的返回值是什么?

read的返回值是-1,那么如何区分返回值-1是因为read读取错误了,还是数据未就绪呢?

当read返回值为-1的时候,就证明读取错误了,操作系统会将 errno 全局变量设置为对应的错误码,如果是数据未就绪errno就会被设置为EAGAIN 或 EWOULDBLOCK 这两个宏,这两个宏对应的值是相同的。

read返回值为-1就代表读取错误,那数据未就绪算是读取错误吗?数据未就绪不算读取错误,但是会以出错的形式告诉上层,数据还未准备好

在这里插入图片描述

在这里插入图片描述

#include <iostream>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>

using namespace std;

void SetNoBlock(int fd)
{
    int mode = fcntl(fd,F_GETFL);
    if(mode < 0)
    {
        perror("fcntl");
        return;
    }

    fcntl(fd,F_SETFL,mode | O_NONBLOCK);
}

int main()
{
    SetNoBlock(0);

    while(1)
    {
        char buffer[1024] = {0};
        ssize_t n = read(0,buffer,sizeof(buffer)-1);

        if(n > 0)
        {
            buffer[n] = 0;
            cout << "echo : " << buffer << endl;
        }
        else if(n == 0)
        {
            cout << "end stdin" << endl;
            break;
        }
        else
        {
            if(errno == O_NONBLOCK || errno == EAGAIN)
            {
                cout << "The data is not ready yet , errno : " << errno << endl;
                // 干其他事
            }
            else if(errno == EINTR)
            {
                // 进程在进行IO的时候,可能会因为信号机制,导致IO直接返回
                // 这时函数的返回值也是-1,并会将errno 设置为 EINTR
                // 这种情况也不算是读取错误
                cout << "IO interpreted by signal , try again" << endl;
            }
            else
            {
                cout << "read error" << endl;
                break;
            }
        }

        sleep(1);
    }

    return 0;
}

四、多路转接IO

4.1 多路转接IO之select

4.1.1 select函数

#include <sys/select.h>

int select(int nfds, fd_set *readfds, fd_set *writefds, 
				fd_set *exceptfds, struct timeval *timeout);

功能:用于同时监控多个文件描述符的 IO 状态,通过 select,程序可以在单个线程中处理多个 IO 事件,避免为每个 IO 操作创建单独的线程,从而提高资源利用率和并发性能。但是select只负责等待,拷贝需要read、recv、write、send等负责

参数

  • nfds:需要检查的最大文件描述符值 + 1
  • readfds:输入输出型参数,fd_set本质上是一张位图
    • 输入时
      • 比特位的位置:对应文件描述符的值
      • 比特位的内容:用户告诉内核,是否监控对应文件描述符的读事件
    • 输出时
      • 比特位的位置:对应文件描述符的值
      • 比特位的内容:内核告诉用户,对应文件描述符的读事件 是否就绪
  • writefds:输入输出型参数,fd_set本质上是一张位图
    • 输入时
      • 比特位的位置:对应文件描述符的值
      • 比特位的内容:用户告诉内核,是否监控对应文件描述符的写事件
    • 输出时
      • 比特位的位置:对应文件描述符的值
      • 比特位的内容:内核告诉用户,对应文件描述符的写事件 是否就绪
  • exceptfds:输入输出型参数,fd_set本质上是一张位图
    • 输入时
      • 比特位的位置:对应文件描述符的值
      • 比特位的内容:用户告诉内核,是否监控对应文件描述符的异常事件
    • 输出时
      • 比特位的位置:对应文件描述符的值
      • 比特位的内容:内核告诉用户,对应文件描述符的异常事件 是否就绪
  • timeout:超时时间,控制 select 的阻塞行为:
    • NULL:永久阻塞,直到有文件描述符就绪
    • 0:立即返回(非阻塞模式)
    • >0:指定超时时间(秒 + 微秒),超时后返回。
      在这里插入图片描述

返回值

  • 正数:表示就绪的文件描述符总数
  • 0:表示超时(无文件描述符就绪)
  • -1:表示错误,并设置 errno(如 EINTR 表示被信号中断)

操作系统不建议用户直接修改fd_set位图,所以操作系统提供以下宏操作,来操作fd_set位图。

FD_ZERO(fd_set *set);    // 清空集合
FD_SET(int fd, fd_set *set);  // 将 fd 添加到集合
FD_CLR(int fd, fd_set *set);  // 从集合中移除 fd
FD_ISSET(int fd, fd_set *set);  // 检查 fd 是否在集合中(就绪时返回非零)

4.1.2 select的优缺点

  • 优点
    • select只负责等待,可以等待多个文件描述符,在IO的时候效率会比较高
  • 缺点
    1. 使用select的时候,用户每次都需要对select的参数进行重置
    2. 在编写代码的时候,select需要用到第三方数组,会充满遍历,可能会影响select的效率
    3. 用户到内核,内核到用户,每次select的调用和返回,都需要对位图进行重置操作;用户和内核之间,需要一直进行数据拷贝
    4. select会让操作系统在底层遍历要关心的所有文件描述符,会导致效率降低
    5. fd_set是操作系统提供的一个类型,本身是一个位图,fd_set的大小是固定的,也就是fd_set的比特位位数是有上线的,所以select能够检测文件描述符的个数也是有限的

4.2 多路转接IO之poll

4.2.1 poll函数

#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

功能:poll 是 Linux 系统中的一种 I/O 多路复用机制,主要用于同时监控多个文件描述符的状态

参数

  • fds:指向 struct pollfd 数组的指针,每个元素指定一个要监控的文件描述符及其关注的事件
  • nfds:数组中元素的数量(即监控的文件描述符总数)
  • timeout:超时时间(毫秒):
    • -1:永久阻塞,直到有事件发生
    • 0:立即返回(非阻塞模式)
    • >0:指定超时时间,超时后返回

返回值

  • 正数:表示就绪的文件描述符总数
  • 0:表示超时(无文件描述符就绪)
  • -1:表示错误,并设置 errno

struct pollfd 结构

struct pollfd {
    int fd;         // 文件描述符
    short events;   // 关注的事件(输入掩码,如 POLLIN、POLLOUT)
    short revents;  // 实际发生的事件(输出掩码,由内核填充)
};

poll 函数支持的标准事件类型,本质上是宏,只有一个比特位为1,通过与events和revents异或分为两种情况:

  • 调用时:用户告诉内核,需要关注文件描述符中的events事件
  • 返回时:内核告诉用户,用户关注的文件描述符,有revents中的事件准备就绪
事件 描述 是否可以作为输入 是否可以作为输出
POLLIN 有普通数据或优先数据可读
POLLRDNORM 有普通数据可读
POLLRDBAND 有优先级带数据可读
POLLPRI 有高优先级带数据可读
POLLOUT 有普通数据或优先数据可写
POLLWRNORM 有普通数据可写
POLLWRBAND 有优先级带数据可写
POLLRDHUP TCP连接的对端关闭连接,或关闭了写操作
POLLHUP 挂起
POLLERR 错误
POLLNVAL 文件描述符未打开

4.2.2 poll的优缺点

  • 优点
    1. poll 只负责等待,可以等待多个文件描述符,在IO的时候效率会比较高
    2. 输入和输出参数进行分离,events和revents,不需要再对poll的参数进行频繁的重置了
    3. poll使用了动态数组,所以 poll 能够检测文件描述符的个数也是没有有限的
  • 缺点
    1. 用户和内核之间,需要一直进行数据拷贝
    2. 在编写代码的时候,需要遍历动态数组,可能会影响select的效率
    3. poll 会让操作系统在底层遍历要关心的所有文件描述符,会导致效率降低

4.3 多路转接IO之epoll

4.3.1 epoll相关接口

4.3.1.1 epoll_create函数
#include <sys/epoll.h>

int epoll_create(int size);

功能:创建一个 epoll 实例(文件描述符),用于管理被监控的文件描述符。

参数

  • size:在 Linux 2.6.8 之后被忽略,但需传入大于 0 的值(历史遗留参数)

返回值:成功返回 epoll 实例的文件描述符,失败返回 -1。


4.3.1.2 epoll_ctl函数
#include <sys/epoll.h>

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

功能:注册、修改或删除对指定文件描述符的监控。

参数

  • epfd:epoll 实例的文件描述符(由 epoll_create 返回)
  • op:操作类型:
    • EPOLL_CTL_ADD:注册监控
    • EPOLL_CTL_MOD:修改已注册的监控事件
    • EPOLL_CTL_DEL:删除监控(此时 event 可为 NULL)
  • fd:要监控的文件描述符
  • event:指向 struct epoll_event 的指针,指定监控的事件类型和用户数据

struct epoll_event 结构

struct epoll_event {
    uint32_t     events;    // 事件掩码(如 EPOLLIN、EPOLLOUT)
    epoll_data_t data;      // 用户数据(可存储 fd 或指针)
};

typedef union epoll_data {
    void        *ptr;
    int          fd;
    uint32_t     u32;
    uint64_t     u64;
} epoll_data_t;

返回值

  • 0:操作成功
  • -1:操作失败,并设置 errno 以指示具体错误类型

常用事件标志

事件 描述
EPOLLIN 对应的文件描述符可以读
EPOLLOUT 对应的文件描述符可以写
EPOLLPRI 对应的文件描述符有紧急的数据可读
EPOLLERR 对应的文件描述符发生错误
EPOLLHUP 将EPOLL设为边缘触发(Edge Triggered)模式
EPOLLET 将EPOLL设为水平触发(Level Triggered)模式
EPOLLONESHOT 只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里

4.3.1.3 epoll_wait函数
#include <sys/epoll.h>

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

功能:等待 epoll 实例中监控的文件描述符上有事件发生。

参数

  • epfd:epoll 实例的文件描述符
  • events:用于存储就绪事件的数组(由用户分配)
  • maxevents:数组大小(必须大于 0)
  • timeout:超时时间(毫秒):
    • -1:永久阻塞,直到有事件发生
    • 0:立即返回,不阻塞
    • >0:指定超时时间

返回值

  • 正数:就绪事件的数量
  • 0:超时
  • -1:错误(如 epfd 无效)

4.3.2 epoll的原理 + 相关接口

我们知道select和poll需要一直遍历所有的文件描述符才能确认是否有事件就绪,这势必会导致效率低下的问题。

epoll不需要一直遍历就可以知道哪些文件描述符的哪些事件已经就绪了,它是如何做到的呢?

之前的文章中讲到过,操作系统并不需要轮询遍历硬件,就能知道硬件上是否有数据就绪,这是通过硬件中断实现的,再通过中断号,执行对应中断向量表中的方法,就可以将硬件上的数据拿到内存中了。

epoll就是通过硬件中断+回调函数的方法,实现不需要一直遍历所以的文件描述符就能知道哪些文件描述符的哪些事件已经就绪了。


下面我就详细的讲解一下 epoll 的原理和 epoll 相关接口。

进程在运行的时候,操作系统会为其创建一个 task_struct,操作系统还会为每个进程创建一个 files_struct,files_struct 中有一个进程文件描述符表,文件描述符表中有一个数组用来存储被打开文件的结构体对象的地址。

进程在调用 epoll_create 函数的时候,操作系统会为进程创建一个 epoll模型,并返回一个文件描述符,操作系统还会创建一个 struct file 结构体,通过文件描述符指向 struct file 结构体,而 struct file 结构体中有一个字段会指向 epoll 模型。

epoll 模型中有两个重要的部分就是红黑树就绪队列,红黑树类似于 select 和 poll 中需要用户手动维护的数组,而这里的红黑树是由内核维护的。

当用户需要增加对某个文件描述符上的某个事件关注时,可以调用 epoll_ctl 函数,操作系统会创建一个 epitem 节点(结构体) ,节点中包含文件描述符、对应关注事件等数据,然后该节点会被链接到红黑树中,并且操作系统会在网卡的驱动中添加回调函数

epoll_ctl 函数的作用是注册、修改或删除对指定文件描述符的关注,同理修改对某个文件描述符上的某个事件关注,就是修改红黑树中对应的 epitem 节点,删除对某个文件描述符上的某个事件关注,就是删除红黑树中对应的 epitem 节点。

当网卡中有数据时,网卡会发送中断信号,操作系统就知道网卡中有事件就绪,此时会调用回调函数,判断该事件是否与用户关注的文件描述符中的事件有关,有关则将相关在红黑树中的 epitem 节点 链入到就绪队列中,此时节点还在红黑树中

用户通过调用 epoll_wait 函数等待 epoll 实例中监控的文件描述符上有事件发生,epoll_wait 函数只需要通过就绪队列中是否有节点,就能判断是否有事件就绪,时间复杂度为O(1),而 epoll_wait 函数获取所有就绪节点,需要遍历就绪队列,时间复杂度为O(N),这是无法优化的。

epoll_wait 函数获取就绪节点的时候,会严格按照就绪队列中节点的顺序,将数据放在用户传入的 struct epoll_event 数组中,并返回就绪事件的个数。如果说就绪队列中的节点数量大于用户传入的 maxevents 时,epoll_wait 函数存放 maxevents 个数据后就返回,其余的节点会保留在就绪队列中,方便用户下一次读取。

进程调用 epoll_wait 函数完后,存放 struct epoll_event 数组中的都是有效的文件描述符和对应就绪事件,用户根据函数的返回值,从头开始遍历,中途不会出现无效文件描述符和没有就绪的时间,也就是说用户不会访问到无效信息

操作系统中可以有多个进程,一个进程中可以有多个 epoll模型,一个 epoll模型中又有红黑树、就绪队列等相关数据,操作系统就需要对 epoll模型进行管理先描述再组织,将红黑树、就绪队列等数据统一保存到结构体 eventpoll 中,再通过链表的方式将所有的 eventpoll 节点进行链接,对epoll模型的管理,就转变为了对链表的增删查改

struct file结构体中有一个字段会指向 epoll模型,实际上指的就是 eventpoll 结构体,也就是说通过struct file结构体也可以管理epoll模型。之前的文章中讲到过,struct file结构体可以指向各种本地设备,例如键盘、磁盘、网卡等,struct file结构体还可以指向网络中的套接字,我们还讲过Linux操作系统下,一切皆文件,所以在操作系统中只需要将struct file结构体管理好,就可以将操作系统中的绝大部分资源管理好

在这里插入图片描述


4.3.3 epoll 工作方式

4.3.3.1 理解水平触发和边缘触发

epoll 有2种工作方式:水平触发(LT)和边缘触发(ET)

这里以故事的方式帮助大家理解两种工作模式:

前几天小Z在网络平台上购买了五个快递,此时小Z正在家中参加学校的线上会议。小A和小B是快递站的两名员工,刚好今天五个快递都到了快递站,小A就带着小Z的三个快递和其他人的快递来到了小Z的小区,小B就带着小Z的两个快递和其他人的快递来到了小Z的小区。

当小A到达了小Z所在的这栋楼时,小A就给小Z打电话,告诉小Z你的快递到了,下来拿一下。小Z就告诉小A自己正在开会,让小A等一下,小A就说行吧,然后就去派送其他人的快递了,过了一段时间,小A又给小Z打电话,小Z还是在开会没时间,又让小A等一下,小A又去派送别人的快递,重复了几个几次后,小A再次给小Z打电话,此时小Z的会议刚好开完,就下楼去取快递了,但是快递太大了,小Z只能拿两个上去,此时小A还有一个小Z的快递,小Z上楼后,学校有突发事件,要求小Z再次参加会议,小A在楼下等了一段时间后,又给小Z打电话,说小Z还有一个快递未取,小Z又说正在开会,让小A等一下,重复几次后,小B也来到了小Z所在的这栋楼,小B看见小A就打招呼,问他在干什么,小A说正在派送小Z的快递,小B一看自己也有两个小Z的快递,就让小A一起派送了,此时小A手上就有三个小Z的快递,然后小A又给小Z打电话,告诉小Z又到了两个快递,让小Z下来取一下,小Z还是再开会,让小A等一下,重复几次后,会议开完了,小A打电话给小Z,小Z这时候就下楼将所有的快递全部拿上去了。

拿到快递后,小Z又买了五个快递,过了几天,小Z的五个快递到了快递站,又是小A和小B送快递,小B就带着小Z的三个快递和其他人的快递来到了小Z的小区,小A就带着小Z的两个快递和其他人的快递来到了小Z的小区。

当小B到达了小Z所在的这栋楼时,此时小Z正在参加学校的线上会议,小B就给小Z打电话,告诉小Z你的快递到了,下来拿一下,还告诉了小Z,他就只打这一次电话,如果小Z不下来拿,他就再也不打电话了。小Z一想小B只打一次电话,就有点害怕今天拿不到快递了,就先不管会议,下楼拿快递了,但是快递太大了,小Z只能拿两个快递,此时小B手上还有一个小Z的快递,小Z上去以后就没有动静了,但是小B也不会再给小Z打电话了,此时小A也来到了小Z所在的这栋楼,小A看见小B就打招呼,问小B在干什么,小B就说在派送小A的快递,小A一看自己刚好有两个小Z的快递,就让小B一起送了,小B此时手上就有三个小Z的快递,由于新到了两个快递,小B就给小Z打电话,你又到了两个快递,你下来拿一下快递,还告诉了小Z,他就只打这一次电话,如果小Z不下来拿,他就再也不打电话了,小Z一看小B就不好惹,就下楼将所有的快递都拿上楼了。

在上面的故事中讲到了小A和小B为小Z送快递的事情

  • 小A只要手上有小Z的快递,就会一直给小Z打电话
  • 小B只有第一次送快递和后序有新快递到来的时候才会给小Z打电话

小A送快递的方式就是采用了水平触发策略,小B送快递的方式就是采用了边缘触发策略

  • 水平触发策略:底层只要有数据,epoll就会一直通知上层取数据
  • 边缘触发策略:底层有数据了,epoll只通知一次,让上层取数据,后序不再通知,直到底层收到了新的数据,epoll才会进行下一次通知

4.3.3.2 对比水平触发和边缘触发

水平触发是 epoll 的默认行为,那水平触发和边缘触发哪个效率更高呢?

显然是边缘触发(ET)的效率更高,在ET策略下,没有无效的通知,全部都是有效的,ET策略下 epoll 只会通知上层一次,倒逼上层要取数据,并且要将本轮数据取完。

数据取完后,底层的接收缓冲区的空间更大,给对方发送的窗口大小也就更大,对方的滑动窗口也就更大了,从概率上就提高了双方的通信效率。

但是应该如何保证上层将本轮数据取完呢?那就只能让上层循环读取,但是上层不知道底层是否有数据,必定会导致阻塞,如何让上层不阻塞呢?使用非阻塞IO的方式进行读取。

上层使用非阻塞IO的方式循环读取底层的数据,就能保证上层将本轮数据取完。


4.3.3.3 理解ET模式和非阻塞文件描述符

使用 ET 模式的 epoll,需要将文件描述设置为非阻塞,这个不是接口上的要求,而是 “工程实践” 上的要求。

假设这样的场景:服务器接受到一个10k的请求,会向客户端返回一个应答数据,如果客户端收不到应答,不会发送第二个10k请求。

如果服务端写的代码是阻塞式的read,并且一次只 read 1k 数据的话(read不能保证一次就把所有的数据都读出来,参考 man 手册的说明,可能被信号打断),剩下的9k数据就会待在缓冲区中。

此时由于 epoll 是ET模式,并不会认为文件描述符读就绪,epoll_wait 就不会再次返回,剩下的 9k 数据会一直在缓冲区中,直到下一次客户端再给服务器写数据,epoll_wait 才能返回。

但是问题来了,服务器只读到1k个数据,要10k读完才会给客户端返回响应数据,客户端要读到服务器的响应,客户端发送了下一个请求,epoll_wait 才会返回,才能去读缓冲区中剩余的数据。

所以,为了解决上述问题(阻塞read不一定能一下把完整的请求读完),于是就可以使用非阻塞轮训的方式来读缓冲区,保证一定能把完整的请求都读出来。而如果是LT没这个问题,只要缓冲区中的数据没读完,就能够让 epoll_wait 返回文件描述符读就绪。


4.3.4 epoll的优点(和 select 的缺点对应)

  • 接口使用方便:虽然拆分成了三个函数,但是反而使用起来更方便高效,不需要每次循环都设置关注的文件描述符,也做到了输入输出参数分离开
  • 数据拷贝轻量:只在合适的时候调用 EPOLL_CTL_ADD 将文件描述符结构拷贝到内核中,这个操作并不频繁(而select/poll都是每次循环都要进行拷贝)
  • 事件回调机制:避免使用遍历,而是使用回调函数的方式,将就绪的文件描述符结构加入到就绪队列中,epoll_wait 返回直接访问就绪队列就知道哪些文件描述符就绪. 这个操作时间复杂度O(1),即使文件描述符数目很多,效率也不会受到影响
  • 没有数量限制:文件描述符数目无上限

注意!!
网上有些博客说,epoll中使用了内存映射机制

  • 内存映射机制:内核直接将就绪队列通过mmap的方式映射到用户态,避免了拷贝内存这样的额外性能开销

这种说法是不准确的,我们定义的struct epoll_event是我们在用户空间中分配好的内存,势必还是需要将内核的数据拷贝到这个用户空间的内存中的。


4.3.5 epoll的使用场景

epoll的高性能,是有一定的特定场景的,如果场景选择的不适宜,epoll的性能可能适得其反。

对于多连接,且多连接中只有一部分连接比较活跃时,比较适合使用epoll。
例如,典型的一个需要处理上万个客户端的服务器,例如各种互联网APP的入口服务器,这样的服务器就很适合epoll。

如果只是系统内部,服务器和服务器之间进行通信,只有少数的几个连接,这种情况下用epoll就并不合适。具体要根据需求和场景特点来决定使用哪种IO模型。


结尾

如果有什么建议和疑问,或是有什么错误,大家可以在评论区中提出。
希望大家以后也能和我一起进步!!🌹🌹
如果这篇文章对你有用的话,希望大家给一个三连支持一下!!🌹🌹
在这里插入图片描述


网站公告

今日签到

点亮在社区的每一天
去签到