【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) 模式不相上下。

相关推荐
戴为沐20 小时前
Linux内存扩容指南
linux
zylyehuo1 天前
Linux 彻底且安全地删除文件
linux
用户805533698032 天前
主线 U-Boot 上 RK3506:和闭源 rkbin 拔河的三个隐性契约
linux·嵌入式
用户034095297912 天前
linux fcitx 5 雾凇拼音 设置在中文输入法下仍然输入英文标点
linux
乘云数字DATABUFF2 天前
5分钟部署开源APM Databuff:OpenTelemetry全链路追踪入门实战
运维·后端
Web3探索者4 天前
可视化服务器管理和传统命令行区别是什么?新手教程:Linux 运维到底该用图形界面还是 SSH 命令行?
linux·ssh
zylyehuo4 天前
Linux系统中网线与USB网络共享冲突
linux
荣--4 天前
一键部署不是为了省时间 —— 它是把"买来的 PaaS"变成"自己的平台"的拐点
运维·zabbix·工程化·一键部署·平台化·边界设计
江华森4 天前
动手实战学 Docker — 从零到集群编排完全指南
运维
Avan_菜菜4 天前
FRP 内网穿透完整实战:从 HTTP 映射到 HTTPS 自签代理
运维·nginx·https