【Linux】网络高级 IO

回顾 read && write 系统调用

  • 在编写 TCP 服务器时,应用层调用 read && write 的时候,read 的本质是把内核的接收缓冲区的数据拷贝到用户缓冲区,write 的本质是把用户缓冲区的数据拷贝到发送缓冲区。所以 read && write 本质就是拷贝函数。
  • 一次 IO = 等待 + 拷贝。IO 时等待的是什么呢?等待的是条件成立。我们把这个条件称为读写事件 。条件成立称为读写事件就绪。对于 read,如果接收缓冲区不为空,那么读事件就绪。对于 write,如果内核发送缓冲区有剩余空间(send 和 write 的本质是将用户发送缓冲区的数据拷贝到内核发送缓冲区),那么写事件就绪。
  • 什么叫做高效的 IO 呢?如果单位时间内,拷贝的字节数越多,即等待的时间越少,我们称 IO 的效率越高。几乎所有提高 IO 效率的策略,都是围绕如何减少等待的时间展开的

五种 IO 模型

1. 阻塞 IO(Blocking IO)

比喻 :你去餐厅点菜,然后一直坐在座位上,什么事都不干,眼巴巴等服务员把菜端上来,才离开。

流程

  • 用户进程发起 recvfrom 系统调用。

  • 内核等待数据准备好(比如网络数据包到达)。

  • 数据从内核拷贝到用户空间。

  • 返回成功,进程继续运行。

特点 :简单,但进程在等待 IO 期间被挂起,浪费 CPU。

常见例子:传统 Java OIO、最简单的 socket 编程。

2. 非阻塞 IO(Non-blocking IO)

比喻:你每隔 3 秒跑去问服务员"菜好了吗?",没好就回来刷手机,反复轮询,直到菜好了才坐着等上菜。

流程

  • 用户进程发起 recvfrom,如果内核数据没准备好,立刻返回 EWOULDBLOCK 错误。

  • 进程不断轮询(重复系统调用)。

  • 直到某次数据准备好了,再阻塞(或拷贝数据)完成。

特点:进程不会被挂起,但频繁轮询消耗 CPU,一般不单独使用。

3. 信号驱动 IO(Signal-driven IO)

比喻:你跟餐厅说"菜好了按门铃叫我",你回房间玩手机。门铃响了你再去取菜。

流程

  • 进程通过 sigaction 注册信号处理函数,立即返回(不阻塞)。

  • 内核在数据准备好时发送 SIGIO 信号。

  • 信号处理函数中调用 recvfrom 读取数据。

特点:不轮询,也不一直阻塞;但信号处理复杂,实际网络编程中用得很少(主要用于一些异步 I/O 场景或特定设备驱动)。

4. IO 多路转接(IO Multiplexing)

比喻 :你请一个服务员(select/poll/epoll)帮你同时看着 10 个餐桌,哪个桌的菜好了就通知你,你再去那个桌处理吃饭(读取数据)。你一个人能同时"盯着"很多个 IO。

流程

  • 进程调用 selectepoll,阻塞等待多个 socket 中的任意一个就绪。

  • 内核返回可读/可写的 socket 集合。

  • 进程再调用 recvfrom 从就绪 socket 读取数据。

特点 :单线程可管理大量连接;阻塞点从单个 IO 变成多路复用器

常见例子:Redis、Nginx、Java NIO。
很多人疑惑:为什么它比阻塞 IO 好?

因为阻塞 IO 要一个连接一个线程,多路复用只需一个线程监控所有连接,对高并发友好。

5. 异步 IO(Asynchronous IO)

比喻:你点完菜,告诉餐厅"菜做好后直接帮我打包送到我家"。你完全不用管什么时候做好、怎么取,最后直接拿到结果。

流程

  • 进程调用 aio_read 系统调用,立即返回,不阻塞。

  • 内核自行等待数据、拷贝数据到用户空间(全过程不用进程参与)。

  • 完成后通过信号或回调通知进程。

特点 :真正的异步 ------ 进程发起 IO 后可以做别的事,数据已在内核完成拷贝。

常用实现 :Windows 的 IOCP(完成端口),Linux 的 native AIO(对普通文件支持,网络 Socket 支持不完善),目前高性能网络库常用 io_uring(Linux 5.1+)。

对比总结表

IO 模型 第一阶段(等待数据) 第二阶段(拷贝数据到用户空间) 是否阻塞进程
阻塞 IO 阻塞 阻塞 全程阻塞
非阻塞 IO 不阻塞(轮询) 阻塞 仅在拷贝阶段阻塞
IO 多路复用 阻塞在 select/epoll 阻塞 select & read 阻塞
信号驱动 IO 不阻塞(信号通知) 阻塞 仅在拷贝时阻塞
异步 IO 不阻塞 不阻塞 全程不阻塞

五种 IO 模型(阻塞、非阻塞、多路复用、信号驱动、异步)主要是针对 socket 和管道等"慢设备"定义的。 磁盘文件的行为有很大差异。

同步 IO VS 异步 IO

同步 IO:只要进程参与了 IO 的过程,不管是参与了等待数据的过程还是拷贝数据的过程的 IO,都叫同步 IO。

异步 IO:进程全程不参与 IO 的过程,只获取 IO 的数据

POSIX 规范将:

  • 阻塞 IO、非阻塞 IO、多路复用、信号驱动 IO 归类为 同步 IO(它们都参与了拷贝数据的过程)。

  • 异步 IO 是真正的异步 IO。

注意:IO 的同步与线程的同步是两个完全不同的概念,它们毫无关系。

系统调用 - fcntl

  • TCP 的读写操作的系统调用 recv/send 的第四个参数是 int flags,默认为 0 表示阻塞式,如果设置为 MSG_DONTWAIT ,表示非阻塞式。
  • 用 open 打开文件时,也可以设置 flag 包含 O_NONBLOCK 来设置该文件描述符为非阻塞式。
  • 但这些将文件描述符设置为非阻塞式的方法不通用,下面介绍一种通用的方法。

fcntl(file control)是 Linux/Unix 系统中一个非常重要的文件控制系统调用,用来对已打开的文件描述符执行各种操作。

cpp 复制代码
#include <fcntl.h>
#include <unistd.h>

int fcntl(int fd, int cmd, ... /* arg */);
  • fd:文件描述符(由 opensocket 等返回)

  • cmd:操作命令(告诉 fcntl 要做什么)

  • arg:可选参数,取决于 cmd

常用 cmd

功能类别 常用 cmd 作用
复制文件描述符 F_DUPFDF_DUPFD_CLOEXEC 复制 fd 到新的编号
获取/设置文件状态标志 F_GETFLF_SETFL 读取/修改 O_NONBLOCK、O_APPEND 等标志
获取/设置文件锁 F_GETLKF_SETLKF_SETLKW 记录锁(进程间同步)
获取/设置所有者 F_GETOWNF_SETOWN 设置接收 SIGIO/SIGURG 信号的进程/进程组
获取/设置管道容量 F_GETPIPE_SZF_SETPIPE_SZ 修改管道缓冲区大小

如果要使用上面的系统调用设置一个文件描述符为非阻塞,我们只需要使用第二个功能获取/设置文件状态标志,常用 cmd 是 F_GETFLF_SETFL。

使用方法:

cpp 复制代码
// 1. 获取当前标志
int flags = fcntl(fd, F_GETFL);
if (flags < 0) {
    perror("fcntl F_GETFL");
}

// 2. 添加 O_NONBLOCK(设为非阻塞)
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

// 将上面的步骤封装为函数 SetNoBlock
// 函数作用:传入文件描述符,设置为非阻塞
void SetNoBlock(int fd) { 
    int fl = fcntl(fd, F_GETFL); 
    if (fl < 0) { 
        perror("fcntl");
        return; 
    }
    fcntl(fd, F_SETFL, fl | O_NONBLOCK); 
}

下面以用 read 系统调用来读取标准输入为例使用 SetNoBlock:

  1. 如果用 SetNoBlock 设置标准输入为非阻塞式:SetNoBlock(0);
  2. 此时用 read 读取标准输入,如果我们不输入数据,read 会立刻返回,返回值不是 0,而是 -1,即出错返回,我们用 strerrno 查看错误信息为:Resource temporarily unavailable,表示资源暂时未就绪。但我们认为资源暂时未就绪不是出错了,而是正常的非阻塞返回。
  3. 哪如果 read 真的出错了呢?我们该如何区分 read 是非阻塞返回还是其他出错返回呢?(因为 read 出错返回都是 -1)。答案是通过错误码 errno 区分。
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)
    {
        // printf("Please Enter# ");
        // fflush(stdout);

        ssize_t n = read(0, buffer, sizeof(buffer) - 1);
        if (n > 0)
        {
            buffer[n - 1] = 0; // n - 1:去掉输入时的回车
            cout << "echo : " << buffer << endl;
        }
        else if (n == 0)
        {
            cout << "read done" << endl;
            break;
        }
        else
        {
            // 1. 设置成为非阻塞,如果底层fd数据没有就绪,
            // recv/read/write/send, 返回值会以出错的形式返回
            // 2. a. 真的出错 b. 底层没有就绪
            // 3. 我怎么区分呢?通过errno区分!!!
            if (errno == EWOULDBLOCK)
            {
                cout << "0 fd data not ready, try again!" << endl;
                // do_other_thing();
                sleep(1);
            }
            else
            {
                cerr << "read error, n = " << n << "errno code: "
                     << errno << ", error str: " << strerror(errno) << endl;
            }
            // TODO 信号中断IO?
        }
    }

    return 0;
}

IO 多路转接 - select

select 是 Linux 中实现 IO 多路转接 的系统调用,允许程序同时监视多个文件描述符,等待其中任意一个变为就绪状态(可读、可写或发生异常)。

cpp 复制代码
#include <sys/select.h>
#include <sys/time.h>

int select(int nfds, 
           fd_set *readfds, 
           fd_set *writefds, 
           fd_set *exceptfds, 
           struct timeval *timeout);
  • nfds:最大文件描述符值 + 1(比如要监听的 fd 有 1 2 3 4 5,那么 nfds 就是 6)

  • readfds:要监听的可读事件的 fd 集合,这是一个位图,下标从右往左从 0 开始,这是一个输入输出型参数,在输入时如果某一位为 1,表示要求内核监听 fd = 该位下标的文件描述符的读事件是否就绪,如果某一位为 0,表示要求内核不监听;在输出时,如果某一位为 1,表示 fd = 该位下标的文件描述符的读事件已经就绪,如果某一位为 0,表示不监听或没有就绪。如果不关心任何一个 fd 的读事件,该参数可以为 nullptr。

  • writefds:要监听的可写事件的 fd 集合,具体含义类比 readfds。一个 fd 既可以设置进 readfds ,也可以同时设置进 writefds,表示既关心该 fd 的读事件是否就绪,也关心该 fd 的写事件是否就绪。如果先把 fd 设置进readfds ,后把 fd 设置进 writefds,表示先关心该 fd 的读事件,后关心该 fd 的写事件,下面的 exceptfds同理。

  • exceptfds:要监听的异常事件的 fd 集合(一般不常用),具体含义类比 readfds

  • timeout:这是一个输入输出型参数。首先了解 timeval 结构体的定义:

    cpp 复制代码
    struct timeval {
        long tv_sec;   // 秒
        long tv_usec;  // 微秒(1 秒 = 1,000,000 微秒)
    };

    该参数的含义为超时时间,NULL = 永久阻塞,直到有就绪或出错再返回,0 = 有没有就绪或出错都立即返回,函数返回值是就绪的 fd 数量(如果 > 0),5 = 最多阻塞等待 5 秒,5 秒内如果没有就绪或出错就立刻返回。具体如何设置:

    cpp 复制代码
    struct timeval tv;
    
    // 阻塞 3 秒
    tv = {3,0};
    
    // 阻塞 500 毫秒
    tv = {0,500000};
    
    // 阻塞 1.5 秒
    tv = {1,500000};
    
    // 非阻塞轮询
    tv = {0,0}

    易错点:这是一个输入输出型参数,select 返回后,timeout 的值会被内核修改为剩余未等待的时间 (但并非所有 Unix 系统都这样,Linux 会修改)。比如设置 tv = {5,0},在等待时,如果第 2 秒有 fd 就绪,那么返回时 tv 被修改为了 {3,0}。实际影响:如果要在循环中使用,每次循环必须重新设置 timeout。

返回值

> 0:就绪的文件描述符总数

0:超时(没有错误,也没有就绪)

-1:出错(检查 errno)

fd_set 操作函数

cpp 复制代码
void FD_ZERO(fd_set *set);              // 清空集合
void FD_SET(int fd, fd_set *set);       // 添加 fd 到集合
void FD_CLR(int fd, fd_set *set);       // 从集合删除 fd
int  FD_ISSET(int fd, fd_set *set);     // 检查 fd 是否在集合中(就绪)

select 同时等待的文件描述符的个数是有上限的 ,因为 fd_set是 Linux 操作系统下具体的一种数据类型,有具体的大小,我们打印出 sizeof(fd_set) * 8 的值是 1024,即 fd_set 最多有 1024 个比特位,即 select最多同时等待文件描述符为 0 到 1023 的所有文件。

基础使用示例:同时监听两个 socket

cpp 复制代码
#include <sys/select.h>
#include <unistd.h>

int sock1, sock2;  // 假设已创建并连接

fd_set readfds;
int max_fd = (sock1 > sock2 ? sock1 : sock2);

while (1) {
    
    // 使用前记得清零
    FD_ZERO(&readfds);
    FD_SET(sock1, &readfds);
    FD_SET(sock2, &readfds);
    
    // 阻塞等待任意 fd 可读
    int ret = select(max_fd + 1, &readfds, NULL, NULL, NULL);
    
    if (ret < 0) {
        perror("select error");
        break;
    }
    
    // 检查哪个 fd 就绪
    if (FD_ISSET(sock1, &readfds)) {
        // 从 sock1 读取数据
    }
    if (FD_ISSET(sock2, &readfds)) {
        // 从 sock2 读取数据
    }
}

使用 select 的多路转接版本的 tcp 服务器

由于 select 的 readfds 是输入输出型参数,也就是说 select 会更改 readfds,所以每次调用 select 之前都要重新设置 readfds,哪我们怎么记得上次的 readfds 长啥样呢?而且服务器的客户 fd 数量是动态变化的,最大 fd 是动态变化的,即每次调用 select 之前都要获取最大客户 fd,以便设置 select 函数的第一个参数,如何知道最大客户 fd?解决这些问题的方法是创建一个辅助数组 fd_array,这个数组的大小就是 sizeof(fd_set) * 8,即 fd_set 的比特位个数,如果 fd_arrayi == default_fd,说明 fd_arrayi 不是存的有效数据,如果 fd_arrayi != default_fd,说明 fd_arrayi 存着一个需要被 select 等待的文件描述符,我们规定 fd_arry0 默认是 listen_sock。

cpp 复制代码
#define _CRT_SECURE_NO_WARNINGS 1
#pragma once

#include <iostream>
#include <sys/select.h>
#include <sys/time.h>
#include "Socket.hpp"

using namespace std;

static const uint16_t defaultport = 8888;
static 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;
            // std::cout << "fd_array[" << i << "]" << " : " << fd_array[i] << std::endl;
        }
    }
    bool Init()
    {
        _listensock.Socket();
        _listensock.Bind(_port);
        _listensock.Listen();

        return true;
    }
    void Accepter()
    {
        std::string clientip;
        uint16_t clientport = 0;
        int client_sock = _listensock.Accept(&clientip, &clientport); 
        // 会不会阻塞在Accept?不会
        if (client_sock < 0) return;
        lg(Info, "accept success, %s: %d, sock fd: %d", clientip.c_str(), clientport, sock);

        // accept 之后不能直接 read,可能会阻塞
        // 要把 client_sock 写入 fd_array 中,让 select 来等待!

        // 接下来在 fd_array 中寻找一个值为 defaultfd 的位置,写入 client_sock
        
        int pos = 1; // 从下标为 1 的位置开始寻找,pos = 0 默认是 listensock
        for (; pos < fd_num_max; pos++) // 第二个循环
        {
            if (fd_array[pos] != defaultfd) continue;
            else break;
        }

        if (pos == fd_num_max) // fd_array 已经满了
        {
            lg(Warning, "server is full, close %d now!", sock);
            close(sock);
        }
        else
        {
            fd_array[pos] = client_sock;
            PrintFd();
            // TODO
        }
    }
    void Recver(int fd, int pos)
    {
        // demo
        char buffer[1024];
        ssize_t n = read(fd, buffer, sizeof(buffer) - 1); // bug?
        if (n > 0)
        {
            buffer[n] = 0;
            cout << "get a messge: " << 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) // rfds:由 select 输出的的 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 // 客户 fd 就绪
                {
                    Recver(fd, i);
                }
            }
        }
    }
    void Start()
    {
        int listensock = _listensock.Fd();
        fd_array[0] = listensock;
        for (;;)
        {
            fd_set rfds;
            FD_ZERO(&rfds);

            int maxfd = fd_array[0];

            // 用 fd_array 设置 rfds,同时记录最大客户 fd
            for (int i = 0; i < fd_num_max; i++) // 第一次循环
            {
                if (fd_array[i] == defaultfd) continue;
                FD_SET(fd_array[i], &rfds);
                if (maxfd < fd_array[i])
                {
                    maxfd = fd_array[i];
                    lg(Info, "max fd update, max fd is: %d", maxfd);
                }
            }

            // 每次调用 select,都要重新设置 rfds,上面的代码不能在 for (;;) 循环之外

            struct timeval timeout = { 0, 0 } // 非阻塞式
            
            int n = select(maxfd + 1, &rfds, nullptr, nullptr,&timeout);
            switch (n)
            {
            case 0:
                cout << "time out, timeout: " << timeout.tv_sec << "." << timeout.tv_usec << endl;
                break;
            case -1:
                cerr << "select error" << endl;
                break;
            default:
                // 有事件就绪了,TODO
                cout << "get a new link or new message" << 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];   // 数组, 用户维护的!
    // int wfd_array[fd_num_max];
};

select 的优缺点

优点

select 的主要优势在于其广泛的兼容性和简单的模型

  • 跨平台性极佳select 是 POSIX 标准的一部分,几乎在所有操作系统(包括 Linux、Unix、macOS 和 Windows)上都支持。如果你编写的程序需要跨平台编译运行,select 是 I/O 多路复用中唯一可靠的选择。

  • 使用模型简单 :相比 epoll 这种需要多个函数(create, ctl, wait)配合的复杂机制,select 的接口相对直观,易于理解和使用。

缺点

随着并发量的提升,select 的性能瓶颈会变得非常突出,主要体现在以下三个方面:

缺点维度 具体表现与原因 说明与影响
描述符数量限制 默认最多只能监控 1024 个文件描述符(受 FD_SETSIZE 限制)。 对于需要处理数千甚至上万个连接的高并发服务器而言,这个硬性限制是致命的。
频繁的"拷贝"开销 每次调用 select,都需要将整个文件描述符集合从用户态 内存拷贝到内核态内存,经过内核修改后,又要将整个文件描述符集合从内核态内存拷贝到用户态内存。并且每次调用 select,必须重新设置那些输入输出型参数 当监控的描述符数量很多时(比如上千个),这种反复的数据拷贝会消耗大量 CPU 时间。
O(n) 的"遍历"效率 内核需要线性扫描所有被监控的描述符,才能确定哪些描述符已经就绪;程序收到返回后,也需要遍历查找就绪的描述符。 这导致 select 的效率随着监控的文件描述符数量增加而线性下降。

IO 多路转接 - poll

poll 可以看作是 select改进版 ,它解决了 select 最让人头疼的两个问题:文件描述符数量上限每次都要重新初始化集合 。但在性能的核心瓶颈上(如效率随连接数增多而下降),它与 select 仍然处于同一层级。

核心数据结构:struct pollfd

poll 不再使用 select 那套基于位掩码(fd_set)的方式,而是引入了一个结构体数组:

cpp 复制代码
struct pollfd {
    int   fd;         // 要监控的文件描述符
    short events;     // 感兴趣的事件(输入:POLLIN, POLLOUT 等)
    short revents;    // 实际发生的事件(输出,由内核填充)
};

// 注意:
// 如果 fd == -1,poll 会忽略,若 fd >= 0 但 fd 未打开或已关闭
// 返回的 revents 就是 POLLNVAL,并且整个 poll 调用不会失败(返回值仍可能大于 0)
  • 每个描述符独立 :每个 struct pollfd 对应一个需要监控的 fd,不再有1024的硬限制。

  • 输入输出分离events 是用户告诉内核"我想监控什么",revents 是内核告诉用户"发生了什么"。这解决了 select 每次调用前都必须重新设置全部描述符集合的痛点。

cpp 复制代码
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
  • struct pollfd *fds:pollfd 类型的结构体数组
  • nfds:结构体数组的大小
  • timeout:最大等待时间,单位是毫秒
  • 返回值:>0 返回就绪的文件描述符数量(即 revents 非 0 的 pollfd 个数), =0 表示在指定的 timeout 时间内没有任何事件发生,<0 调用出错,具体错误码保存在 errno

events 的设置非常直接:使用按位或 | 操作符,将一个或多个事件宏组合起来 ,赋给 struct pollfd 结构体中的 events 字段。

cpp 复制代码
struct pollfd fds;
fds.fd = sockfd;           // 要监控的 socket 或文件描述符
fds.events = POLLIN;       // 只监控可读事件

// 或者同时监控多个事件,用 | 组合
fds.events = POLLIN | POLLOUT;      // 同时监控可读和可写
fds.events = POLLIN | POLLRDHUP;    // 监控可读 或 对端关闭连接

常用 events 事件宏

事件宏 含义 典型使用场景
POLLIN 普通数据可读(最常见) 监听 socket 有新连接到达;已建立连接的 socket 收到数据
POLLOUT 可写(缓冲区有空闲空间) 当发送缓冲区满导致阻塞后,等待缓冲区有空间时再次发送
POLLRDHUP 对端关闭连接或半关闭写端 TCP 连接中,对方调用 shutdown(SHUT_WR)close() 时触发。需要定义 _GNU_SOURCE
POLLPRI 紧急数据可读(带外数据 Out-of-Band Data) TCP 紧急指针或某些协议的高优先级数据
POLLERR 错误 (通常不需要设置,内核会自动在 revents 中返回) 如连接被重置(RST)或设备错误
POLLHUP 挂断(不需要设置,自动返回) 对端正常关闭连接(写端已关闭),一般表示连接断开
POLLNVAL 无效请求(不需要设置,自动返回) fd 未打开(如已经 close

优点:相对于 select 的进步

改进点 说明
无 1024 硬限制 不再受 FD_SETSIZE 限制,理论上可以监控任意数量的文件描述符(受系统资源限制)。
避免重复初始化 select 每次调用后 fd_set 集合会被内核修改,下次调用必须重新添加全部 fd。而 pollevents 字段由用户维护,内核只修改 revents,因此下次调用时无需重新设置。
事件类型更丰富 支持更多的事件类型,如 POLLRDHUP(对端关闭连接)、POLLPRI(紧急数据)等,比 select 的读、写、异常更精细。

仍未解决的问题

O(n) 的线性扫描: poll 每次调用时,内核仍然需要线性遍历整个 pollfd 数组,检查每个 fd 对应的设备是否就绪。poll 返回后,应用程序需要重新遍历整个数组 ,找到哪些 fd 的 revents 非 0。

大量数据拷贝: 每次调用 poll 时,用户程序需要将整个 pollfd 数组从用户态内核态来回拷贝

使用 poll 的多路转接版本的 tcp 服务器

与使用 select 的 tcp 服务器相比较,该版本的服务器将 int _fd_array 数组换成了 struct pollfd _fd_array 结构体数组,每个元素的 fd 如果 == -1,表示无效的元素,poll 会直接忽略。我们直接在 Select_server.hpp 的基础上修改

cpp 复制代码
#pragma once
//#include <sys/select.h>
#include <poll.h>
#include "Socket.hpp"

//const static int fd_num_max = sizeof(fd_set) * 8;
const static int fd_num_max = 64;
const static uint16_t default_port = 8080;
const static int default_fd = -1;
const static int non_event = 0;

class Poll_server 
{
public: 
    Poll_server(uint16_t port = default_port):_port(port)
    {
        // for(int i = 0; i < fd_num_max; i++)
        // {
        //     _fd_array[i] = default_fd;
        // }

        for(int i = 0; i < fd_num_max; i++)
        {
            _fd_array[i].fd = default_fd;
            _fd_array[i].events = non_event;
            _fd_array[i].revents = non_event;
        }
    }

    void Init()
    {
        _Listen_sock.Socket();
        _Listen_sock.Bind(_port);
        _Listen_sock.Listen();
    }

    void Start()
    {
        while(true)
        {
            // fd_set rdset;
            // FD_ZERO(&rdset);

            // int max_fd = _fd_array[0];
            // for(int i = 0; i < fd_num_max; i++)
            // {
            //     if(_fd_array[i] != default_fd)
            //     {
            //         FD_SET(_fd_array[i], &rdset);
            //     }

            //     max_fd = std::max(max_fd, _fd_array[i]);
            // }

            // struct timeval tv = {5, 0};
            // int ret = select(max_fd + 1, &rdset, nullptr, nullptr, &tv);

            _fd_array[0].fd = _Listen_sock.FD();
            _fd_array[0].events = POLLIN;

            while(true)
            {
                int ret = poll(_fd_array, fd_num_max, 5000);

                switch(ret)
                {
                    case -1:
                        std::cerr << "poll error" << std::endl;
                        break;
                    case 0:
                        std::cout << "poll timeout" << std::endl;
                        break;
                    default:
                        Dispatcher(_fd_array);
                        break;
                }
            }
            
        }
    }

    void Dispatcher(const pollfd* fd_array)
    {
        for(int i = 0; i < fd_num_max; i++)
        {
            // if(FD_ISSET(_fd_array[i], &rdset))
            // {
            //     if(_fd_array[i] == _Listen_sock.FD())
            //     {
            //         Accepter();
            //     }
            //     else
            //     {
            //         Recver(_fd_array[i],i);
            //     }
            // }

            if(fd_array[i].revents & POLLIN)
            {
                if(fd_array[i].fd == _Listen_sock.FD())
                {
                    Accepter();
                }
                else
                {
                    Recver(fd_array[i].fd, i);
                }
            }
        }
    }

    void Accepter()
    {
        uint16_t client_port;
        std::string client_ip;

        int client_fd = _Listen_sock.Accept(&client_ip, &client_port);

        // int pos = 1;
        // for(; pos < fd_num_max; pos++)
        // {
        //     if(_fd_array[pos] == default_fd)
        //     {
        //         _fd_array[pos] = client_fd;
        //         break;
        //     }
        // }

        // if(pos == fd_num_max)
        // {
        //     std::cerr << "too many clients" << std::endl;
        //     close(client_fd);
        //     return;
        // }

        // _fd_array[pos] = client_fd;

        int pos = 1;
        for(;pos < fd_num_max; pos++)
        {
            if(_fd_array[pos].fd == default_fd) break;
        }

        if(pos == fd_num_max) 
        {
            std::cerr << "too many clients" << std::endl;
            close(client_fd);
            return;
        }

        _fd_array[pos].fd = client_fd;
        _fd_array[pos].events = POLLIN;
    }

    void Recver(int fd, int pos)
    {
        char buf[1024];
        
        int ret = read(fd, buf, sizeof(buf) - 1);
        if(ret == -1)
        {
            std::cerr << "read error" << std::endl;
            close(fd);
            //_fd_array[pos] = default_fd;
            _fd_array[pos].fd = default_fd;
            _fd_array[pos].events = non_event;
            _fd_array[pos].revents = non_event;
        }
        else if(ret == 0)
        {
            std::cout << "client close" << std::endl;
            close(fd);
            _fd_array[pos].fd = default_fd;
            _fd_array[pos].events = non_event;
            _fd_array[pos].revents = non_event;
        }
        else
        {
            buf[ret] = '\0';
            std::cout << "client say: " << buf << std::endl;
        }
        
    }
private:
    uint16_t _port;
    Sock _Listen_sock;
    //int _fd_array[fd_num_max];
    struct pollfd _fd_array[fd_num_max];
};

IO 多路转接 - epoll

epoll 是 Linux 下高性能网络编程的基石 。它专门为了解决 selectpoll高并发场景下的性能瓶颈而设计,是目前 Linux 平台上处理海量连接的事实标准。

  • select/poll :每次都要把全部待监控的文件描述符(fd)列表传给内核,内核再暴力遍历一遍,效率随连接数线性下降 → O(n)

  • epoll :把"关注 fd"和"等待事件"拆成两步,在内核中维护一个红黑树就绪链表 ,事件发生时只返回真正就绪的 fd → O(1)

epoll 在内核中维护了三个核心数据结构(epoll 模型 ),理解它们就知道 epoll 为什么快了:

  1. 红黑树(rbtree) :存储所有被监控的 fd。添加 (EPOLL_CTL_ADD)、删除 (EPOLL_CTL_DEL)、修改 (EPOLL_CTL_MOD) 操作的复杂度都是 O(log n) ,比 select/poll 每次全量拷贝要高效得多。

  2. 就绪链表(rdlist) :双向链表,存储已经发生事件的就绪 fd。一旦 fd 就绪,内核通过回调机制将其放入此链表。

  3. 回调机制(callback) :这是 epoll 性能的核心。当向内核添加一个 fd 时,会注册一个回调函数,与设备(网卡)驱动程序建立回调关系。当 fd 上有事件发生时(比如数据到达),内核自动调用这个回调函数,将该 fd 放入就绪链表。这就避免了 poll/select 那种"每次都要扫描全部 fd 才能找出哪些就绪"的笨办法。

epoll_create() -- 创建 epoll 模型

cpp 复制代码
int epoll_fd = epoll_create(1);  // 参数 size 已废弃,但必须 > 0
// 或者使用更现代的 epoll_create1(0)

返回值:返回一个文件描述符,指向内核中新创建的 eventpoll 对象(里面包含红黑树和就绪链表)。如果创建失败,返回 -1,设置 errno。

epoll_ctl() -- 管理监控列表

这是 epoll 的"配置接口"

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

epfd:epoll_create 返回的 epoll fd

op 操作类型:

  • EPOLL_CTL_ADD:添加一个 fd 到红黑树

  • EPOLL_CTL_MOD:修改 fd 上监控的事件(比如从只读改为读写都监控)

  • EPOLL_CTL_DEL:从红黑树中删除 fd(不再监控)

struct epoll_event 结构体:

cpp 复制代码
struct epoll_event {
    uint32_t events;   // 事件掩码:EPOLLIN, EPOLLOUT, EPOLLET 等
    epoll_data_t data; // 用户数据(通常放 fd 或自定义指针)
};

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

struct epoll_event 的 uint32_t events 事件掩码:

事件宏 含义 输入(设置) 输出(检查) 说明
EPOLLIN 普通数据可读 socket 收到数据;监听 socket 有新连接
EPOLLOUT 可写 发送缓冲区有空间(通常一开始就就绪)
EPOLLRDHUP 对端关闭连接 TCP 对端调用了 shutdown(SHUT_WR)close()
EPOLLPRI 紧急数据可读 带外数据(Out-of-Band Data)
EPOLLERR 错误发生 连接错误(如 RST),自动返回,无需设置
EPOLLHUP 挂断 对端正常关闭,自动返回,无需设置
EPOLLET 边缘触发模式 设置后触发模式从 LT(默认)变为 ET
EPOLLONESHOT 一次性事件 事件触发一次后自动删除该 fd
EPOLLWAKEUP 防止系统休眠 事件处理期间阻止系统进入休眠
EPOLLEXCLUSIVE 避免惊群 多线程/多进程下,只唤醒一个等待者

注意事项:

  • fd 必须有效 :即 fd 必须是一个已经打开的文件描述符(不能是 -1 或已关闭的)。如果传入无效的 fd,epoll_ctl 会返回 -1errno 设置为 EBADF

  • epfd 必须有效 :即由 epoll_create 返回的 epoll 实例的 fd。

  • 操作必须合法 :比如不能对一个已经添加过的 fd 再次执行 EPOLL_CTL_ADD(会返回 EEXIST 错误),也不能对一个未添加的 fd 执行 EPOLL_CTL_MODEPOLL_CTL_DEL(会返回 ENOENT 错误)。也不能对一个已经添加过的但已关闭的(使用 close())fd 执行EPOLL_CTL_DEL也,即建议先把要关闭的 fd 从 epoll 模型中移除,再使用 close 关闭

epoll_wait() -- 等待事件发生

这是 epoll 的"工作接口",返回时只带回真正就绪的 fd。

cpp 复制代码
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
  • epfd:epoll_create 返回的 epoll fd

  • events:输出型参数,是一个用户态数组,由内核填充就绪的事件。

  • maxevents :**events的**数组大小,即每次最多捞出多少个就绪 fd,其余未被捞出的但已经就绪的 fd,要等到下一轮再被捞出。

  • timeout:-1 阻塞等待,0 立即返回,>0 等待毫秒超时。

  • 返回值:就绪的 fd 数量。

epoll 的优点

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

使用 epoll 的简易 TCP 服务器

先对上面的 epoll 相关的接口做一个简单的封装:

Epoller.hpp:

cpp 复制代码
#pragma once

#include "nocopy.hpp"
#include "Log.hpp"
#include <cerrno>
#include <cstring>
#include <sys/epoll.h>

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: %d", _epfd);
        }
    }
    
    int EpollerWait(struct epoll_event revents[], int num)
    {
        int n = epoll_wait(_epfd, revents, num, /*_timeout 0*/ -1);
        return n;
    }
    
    int EpllerUpdate(int oper, int sock, uint32_t event)
    {
        int n = 0;
        if (oper == EPOLL_CTL_DEL)
        {
            n = epoll_ctl(_epfd, oper, sock, nullptr);
            if (n != 0)
            {
                lg(Error, "epoll_ctl delete error!");
            }
        }
        else
        {
            // EPOLL_CTL_MOD || EPOLL_CTL_ADD
            struct epoll_event ev;
            ev.events = event;
            ev.data.fd = sock; // 目前,方便我们后期得知,是哪一个fd就绪了!

            n = epoll_ctl(_epfd, oper, sock, &ev);
            if (n != 0)
            {
                lg(Error, "epoll_ctl error!");
            }
        }
        return n;
    }
    
    ~Epoller()
    {
        if (_epfd >= 0)
            close(_epfd);
    }

private:
    int _epfd; // epoll 模型的文件描述符
    int _timeout{3000}; // 等待时间
};

Epoller_server.hpp:

cpp 复制代码
#pragma once

#include <iostream>
#include <memory>
#include <sys/epoll.h>
#include "Socket.hpp"
#include "Epoller.hpp"
#include "Log.hpp"
#include "nocopy.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())
    {}
    
    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()
    {
        // 获取了一个新连接
        std::string clientip;
        uint16_t clientport;
        int sock = _listsocket_ptr->Accept(&clientip, &clientport);
        if (sock > 0)
        {
            // 我们能直接读取吗?不能
            _epoller_ptr->EpllerUpdate(EPOLL_CTL_ADD, sock, EVENT_IN);
            lg(Info, "get a new link, client info@ %s:%d", clientip.c_str(), clientport);
        }
    }
    
    // for test
    void Recver(int fd)
    {
        // demo
        char buffer[1024];
        ssize_t n = read(fd, buffer, sizeof(buffer) - 1); // bug?
        if (n > 0)
        {
            buffer[n] = 0;
            std::cout << "get a messge: " << buffer << std::endl;
            // wrirte
            std::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);
            //细节3
            _epoller_ptr->EpllerUpdate(EPOLL_CTL_DEL, fd, 0);
            close(fd);
        }
        else
        {
            lg(Warning, "recv error: fd is : %d", fd);
            _epoller_ptr->EpllerUpdate(EPOLL_CTL_DEL, fd, 0);
            close(fd);
        }
    }
    
    // 事件派发器
    void Dispatcher(struct epoll_event revs[], int num)
    {
        for (int i = 0; i < num; i++)
        {
            uint32_t events = revs[i].events;
            int fd = revs[i].data.fd;
            if (events & EVENT_IN)
            {
                if (fd == _listsocket_ptr->Fd())
                {
                    Accepter(); // 链接管理器
                }
                else
                {
                    // 其他fd上面的普通读取事件就绪
                    Recver(fd); // 
                }
            }
            else if (events & EVENT_OUT)
            {}
            else
            {}
        }
    }
    void Start()
    {
        // 将listensock添加到epoll中 -> listensock和他关心的事件,
        // 添加到内核epoll模型中的rb_tree.
        _epoller_ptr->EpllerUpdate(EPOLL_CTL_ADD, _listsocket_ptr->Fd(), EVENT_IN);
        
        struct epoll_event revs[num]; // 待会从epoll模型捞出的就绪fd
        for (;;)
        {
            int n = _epoller_ptr->EpollerWait(revs, num);
            // 返回值 n 就是就绪的fd的数量
            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");
            }
        }
    }
    ~EpollServer()
    {
        _listsocket_ptr->Close();
    }

private:
    std::shared_ptr<Sock> _listsocket_ptr;
    std::shared_ptr<Epoller> _epoller_ptr;
    uint16_t _port;
};

epoll 的工作模式

epoll 具有两种工作模式:

  • 水平触发 (LT)只要条件满足,就会一直通知你 。这是 epoll默认模式 ,行为类似于 select/poll,安全且易于使用。

  • 边缘触发 (ET)只在状态发生变化的那一刻通知你一次 。这是 epoll 独有的高性能模式,效率极高,但编程也更复杂。

两种工作模式的区别:

  • 水平触发 (LT) :你的秘书每隔 5 分钟就去检查一次邮箱。只要邮箱里还有未读邮件,她就会通知你:"你有新邮件!"。哪怕这些邮件一小时前就到了,只要没读完,她就一直提醒。

  • 边缘触发 (ET) :你的秘书只在邮件到达的那一刻 通知你一次:"新邮件到了!"。然后她就走了。哪怕你只读了其中一封,剩下的没读,她也不会再提醒 。你必须养成习惯,一听到通知,就立刻去把邮箱里所有未读邮件全部处理完

对比维度 水平触发 (LT) - 默认 边缘触发 (ET) - 高性能
通知条件 只要 read 操作不会阻塞(即有数据可读),就会持续通知。 只在 fd 上有新数据到达(状态从无到有)的那一刻通知一次。
工作风格 主动检查:内核反复检查条件是否满足。 事件驱动:内核只在事件发生时通知。
编程复杂度 :可以一次只读一部分,下次再读剩下的,不用担心漏掉事件。 :必须一次性将数据读完,否则可能永远丢失事件。
fd 要求 可以是阻塞或非阻塞,通常使用阻塞 fd 也不会出大问题。 必须使用非阻塞 fd,否则可能阻塞整个进程。
性能 一般。可能因重复通知而产生一些不必要的系统调用。但是如果每次通知时都一次性将数据都取完,性能与 ET 模式相同。 极高 。大大减少了 epoll_wait 的调用次数和事件重复通知。
适用场景 绝大多数通用场景,特别是对编程效率要求高于极致性能时。 高并发、大流量场景,如 Web 服务器、网关、游戏服务器。

边缘触发 (ET) 为什么要求文件 fd 必须是非阻塞的?

在 ET 模式下,我们如何做到在有通知时将缓冲区的数据全部取走呢?答案是循环读取,一直到读取出错。使用 read/recv 时,如果我们一种循环读取接收缓冲区,一直到没有数据了,此时read/recv 会阻塞住,一直到接收缓冲区有数据,此时服务器就挂起了,这不是我们所期望的,所以我们必须将套接字设置为非阻塞模式

为什么边缘触发 (ET) 模式下,网络 IO 的效率更高?

因为在 ET 模式下,我们在在有通知时将缓冲区的数据全部取走,tcp 的流量控制机制会向对方通告一个更大的窗口,从而让对方更有可能一次性向我发送更多的数据。

边缘触发 (ET) 模式一定比水平触发 (LT) 模式高效吗?

如果我们在水平触发 (LT) 模式下,将所有的文件 fd 都设置成非阻塞模式,并且循环读取,其 IO 效率与边缘触发 (ET) 模式不相上下。

相关推荐
码云骑士1 小时前
为何VMware上云之路充满挑战?
运维·服务器·php
m0_738120721 小时前
渗透测试基础——一文详解JSONP跨域劫持漏洞原理与利用
服务器·安全·web安全·json
kebidaixu1 小时前
VSCode 安装和使用 Claude Code 完整指南
linux
朗晴1 小时前
Linux开机重置密码时做了什么?
linux·运维·服务器
某林2121 小时前
Isaac Lab (v2.3.2) Docker 本地化部署与底层排障全解析
运维·docker·容器·架构·iassc
烟雨江南aabb2 小时前
Docker第四弹:Dockerfile
linux·运维·docker
坤昱2 小时前
cfs调度类深入解刨——EAS科普篇
linux·cfs·linux内核调度·cfs调度类深入解刨·cfs调度类·eas·cfs调度器eas特性
itinymeng2 小时前
在Alibaba Cloud Linux 4 LTS 64位 中安装htop
linux·运维·服务器
白藏y2 小时前
【Linux】基础 IO(一)—— 文件操作及文件系统
linux