Linux——高级IO

一、IO的基本概念

1.IO的概念

I/O (input/output)也就是输入和输出,在冯诺依曼体系结构当中,将数据从输入设备拷贝到内存就叫作输入,将数据从内存拷贝到输出设备就叫作输出。

  • 对文件进行的读写操作本质就是一种 IO,文件 IO 对应的外设就是磁盘。
  • 对网络进行的读写操作本质也是一种 IO,网络 IO 对应的外设就是网卡。

2.IO的低效率问题

效率问题

IO最主要的问题就是效率极为低下。

读取数据为例:

  • 当底层缓冲区中没有数据 时,read/recv 就会阻塞等待
  • 当底层缓冲区中有数据 时,read/recv 就会进行拷贝( read/recv 等一系列接口本质就是拷贝函数)

只要缓冲区中没有数据,read/recv 就会就会花费大量时间 进行阻塞等待,直到缓冲区中出现数据,然后进行拷贝,这就是一种低效的 IO 模式。

如何提升IO效率

**IO 的本质就是:等待(等待 IO 条件就绪) + 数据拷贝(当 IO 条件就绪后将数据拷贝到内存或外设)。**其中,"等待"时间通常远大于"拷贝"时间,尤其是在网络或磁盘IO中。

所以,高效IO的核心在于减少等待的时间

如何减少等待的时间呢?

这就与五种IO模型有关

二、五种IO模型

1.例子引入

下面我们来通过钓鱼的例子,将五种IO模型都串联起来介绍:

钓鱼 = 等 + 钓,我们要等🐟咬钩(等),再把🐟钓起来放进桶里(拷贝),如果等的时间短的话,那钓鱼的效率就高

核心比喻框架

  • 钓鱼者 :代表应用程序/进程

  • 钓鱼等待鱼上钩 ):代表I/O操作(特别是"等待数据就绪"阶段)。

  • :代表需要读取或写入的数据

  • :代表操作系统内核

  • :代表用户缓冲区(用于存放已拷贝的数据)。

五个人的不同钓鱼方式

1.张三 - 阻塞I/O

  • 行为:用 1 根鱼竿,将鱼钩抛入水中后,就一直盯着浮标一动不动,直到有鱼上钩,就挥动鱼竿将鱼钓上来。
  • 对应模型:发起I/O调用后,进程被**"阻塞"**,挂起等待,直到数据就绪并完成从内核到缓冲区的拷贝。

2.李四 - 非阻塞轮询I/O

  • 行为:用 1 根鱼竿,将鱼钩抛入水中后,就可以去做其它事情了 ,然后定期观察浮标的动静,如果有鱼上钩就将鱼钓上来,否则就继续做其它事情。(非阻塞轮询式)
  • 对应模型:发起I/O调用后,如果数据未就绪,内核立即返回一个错误,进程不会被挂起,可以继续执行 ,但需要不断轮询检查

3.王五 - 信号驱动I/O

  • 行为:用 1 根鱼竿,将鱼钩抛入水中后,在鱼竿顶部绑一个铃铛,就可以去做其它事情了 ,如果铃铛一响就知道有鱼上钩了,于是挥动鱼竿将鱼钓上来,否则就不管鱼竿。
  • 对应模型:先开启信号驱动,然后进程可以继续执行 。当数据就绪时,内核会主动发送一个信号通知进程,进程再发起I/O操作进行数据拷贝。

4.赵六 - 多路复用(I/O多路转接)

  • 行为:用 100 根鱼竿,将 100 个鱼钩抛入水中后,就定期观察这 100 个浮漂的动静,如果某个鱼竿有鱼上钩就挥动对应的鱼竿将鱼钓上来。
  • 对应模型:使用 select、poll、epoll等系统调用,单个进程可以同时监视多个文件描述符。当其中任何一个就绪时,进程再进行处理。它高效的关键在于能用一次等待管理多个I/O。

5.田七 - 异步I/O

  • 行为:田七是一个公司的领导,让自己的助理去钓鱼,当助理将鱼桶装满时再打电话告诉他。
  • 对应模型:进程发起一个I/O请求后立即返回,内核会负责完成整个I/O操作(等待+拷贝),完成后通知进程"一切都已就绪,数据已在你的缓冲区里"。

关键问题

阻塞IO VS 非阻塞IO

阻塞IO与非阻塞IO的核心区别在于等待方式

  • **阻塞IO:**当IO条件不满足时(如数据未到达),调用会一直等待,直到条件满足才返回。
  • **非阻塞IO:**当IO条件不满足时,立即返回错误或无数据状态,不等待。

因此,两者的主要区别是"是否主动等待",而非阻塞IO效率更高,因为它允许CPU执行其他任务。

钓鱼的效率

张三、李四、王五三个人的效率一样,赵六的效率最高

  • 张三、李四、王五三个人的钓鱼的效率是一样的,因为他们每个人都是拿的一根鱼竿,鱼咬钩的概率是相等的,等待的时间相同
  • 赵六拿了100根鱼竿,单位时间内鱼咬钩的概率更大,等待的时间更少

根据钓鱼的例子:

  • 阻塞 IO、非阻塞 IO 和信号驱动 IO 不能提高 IO 的效率,因为没有减少 IO 中的等待时间,但非阻塞 IO 和信号驱动 IO 能提高整体做事的效率。
  • 多路转接 IO 能够提高 IO 的效率,因为减少了 IO 中的等待时间

同步IO vs 异步IO

同步IO vs 异步IO 的核心区分标准是:线程/进程是否参与了"等"或"拷贝"阶段

  • **同步IO:**用户进程参与IO的"等待"或"数据拷贝"阶段。例如阻塞、非阻塞、信号驱动、多路复用都属于同步IO。
  • **异步IO:**用户进程只发起IO请求,后续的等待和数据拷贝由内核完成,完成后通知用户进程。

2.IO模型的工作流程

1.阻塞IO

阻塞 IO 是最常见的 IO 模型,所有的套接字,默认都是阻塞方式。

在内核将数据准备好之前,系统调用会一直等待。

2.非阻塞IO

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

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

3.信号驱动IO

内核将数据准备好的时候,操作系统 使用 SIGIO 信号通知应用程序进行 IO 操作。

信号驱动 IO 是同步 IO 的一种。 因为当底层数据就绪时,当前进程或线程需要停下正在做的事情,转而进行数据的拷贝操作,当前进程或线程仍然需要参与 IO 过程。

4.IO多路转接

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

5.异步IO

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

3.小结

任何 IO 过程中,都包含两个步骤:等待拷贝

在实际的应用场景中,等待消耗的时间往往都远远高于拷贝的时间。让 IO 更高效,最核心的办法就是在单位时间内让等待的时间尽量少

三、阻塞 IO

系统中大部分的接口都是阻塞式接口。

阻塞IO的例子:使用 read 函数从标准输入中读取数据

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <fcntl.h>
using namespace std;

int main()
{
    char buffer[1024];
    while (true)
    {
        size_t size = read(0, buffer, sizeof(buffer) - 1);//阻塞式从标准输入读取数据
        if (size < 0)
        {
            cerr << "read error" << endl;
            break;
        }
        else
        {
            buffer[size] = 0;
            cout << "echo#" << buffer << endl;
        }
    }
    return 0;
}

**运行结果:**程序运行后,若不进行输入操作,底层数据不就绪, read 函数就会进行阻塞等待,该进程就会阻塞。

四、非阻塞 IO

1.设置非阻塞IO的方法

1.打开文件时,设置文件描述符fd为非阻塞模式

**在使用 open 函数打开文件时,如果传入 O_NONBLOCK 标志,则可以将返回的文件描述符 fd 设置为非阻塞模式。**此后,通过该 fd 进行读取或写入操作时,如果操作无法立即完成,调用不会阻塞等待,而是立即返回(例如返回错误或部分数据)。(普通文件、设备文件等)

cpp 复制代码
int fd = open("/path/to/file", O_RDWR | O_NONBLOCK, 0644);

**在创建 socket 时,如果指定 SOCK_NONBLOCK 标志,则可以将返回的 socket 文件描述符 sockfd 设置为非阻塞模式。**此后,通过该 sockfd 进行读写操作时,如果操作无法立即完成,调用不会阻塞等待,而是立即返回。(网络通信)

cpp 复制代码
int sockfd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);

调用open返回的文件描述符 fd 和调用socket返回的网络套接字 sockfd 本质都是文件描述符

2.网络通信时,单次的为数据发送或接收操作启用非阻塞模式

**在网络编程中,对于 sendrecvsendtorecvmsg 等特定 I/O 调用,可以使用 MSG_DONTWAIT 标志,从而临时地、仅针对本次调用启用非阻塞行为。**这意味着,即使网络套接字sockfd本身处于阻塞模式,本次调用也不会阻塞等待。

cpp 复制代码
ssize_t n = recv(sockfd, buf, len, MSG_DONTWAIT);

在成功设置为非阻塞模式后,I/O 操作(如 readwrite)的行为将分为以下两种情况:

  • 如果操作无法立即完成,系统调用会立即返回 -1,并将错误码 errno 设置为 EAGAINEWOULDBLOCK。这并非真正的错误,而是预期的非阻塞行为,表示"资源暂时不可用,请稍后再试"。

  • 如果操作确实遇到真正的错误(如无效文件描述符、权限问题等),系统调用同样返回 -1,但此时 errno 会被设置为其他对应的错误码,以指示具体的错误原因。

3.打开文件后,设置文件描述符fd为非阻塞模式

这是最常用且通用的方法。对于已经打开的文件描述符fd,通过fcntl函数来设置文件描述符fd为非阻塞模式。

cpp 复制代码
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );

fcntl 函数根据传入的**cmd 参数值不同**,可执行多种不同的操作,并且其后续参数也随之变化。该函数主要提供以下五种功能:

  1. 复制文件描述符cmd = F_DUPFD

  2. 获取/设置文件描述符标记cmd = F_GETFDF_SETFD

  3. 获取/设置文件状态标志cmd = F_GETFLF_SETFL

  4. 获取/设置异步 I/O 所有权cmd = F_GETOWNF_SETOWN

  5. 获取/设置记录锁cmd = F_GETLKF_SETLKF_SETLKW

在实际应用中,第三种功能(获取/设置文件状态标志) 最为常用,它允许我们动态修改一个已打开文件的属性,而无需重新打开文件。通过这一功能,可以方便地将文件描述符fd设置为非阻塞模式。

文件状态标志的核心操作

  • F_GETFL获取 当前文件状态标志(例如 O_RDONLYO_NONBLOCKO_APPEND 等)。若要单独检查访问模式(只读、只写、读写),需要将返回值与掩码 O_ACCMODE 进行按位与操作。

  • F_SETFL设置 文件状态标志。需要注意的是,并非所有标志均可修改,通常只能设置 O_APPENDO_NONBLOCKO_ASYNC 等。

修改文件描述符fd状态时,一般采用"读-改-写"的步骤:先调用 F_GETFL 获取当前fd状态标志,再修改所需位,最后用 F_SETFL 设置回去,以避免覆盖其他标志。

2.利用fcntl函数设置非阻塞

返回值说明

在非阻塞 I/O 中,对 read 等函数的返回值 n 进行处理是核心环节。根据返回值 n 的大小和错误码,可以判断当前操作的状态并采取相应措施:

1. 返回值 n > 0

  • 含义 :成功读取到 n 字节数据。

2. 返回值 n == 0

  • 含义 :到达文件末尾(End-of-File)。对于标准输入,这通常表示用户按下了**Ctrl+D**(类 Unix 系统),输入流被关闭。

  • 处理:此时应跳出循环,结束读取操作。

3. 返回值 n < 0

  • 含义 :操作未成功,需要根据 errno 判断具体原因。

  • 错误码判断

    • EAGAINEWOULDBLOCK( 在绝大多数 Linux 系统上,这两个值等价****

      • 表示当前没有数据可读,但并非错误,而是非阻塞模式下的正常情况:资源暂时不可用。

      • 处理 :继续循环,尝试读取**(轮询)**。

    • EINTR

      • 表示系统调用被信号中断。

      • 处理立即重新读取,因为中断并不影响后续操作。

    • 其他错误

      • 例如无效文件描述符、权限问题等,属于真正的错误。

      • 处理 :应根据错误类型记录日志、清理资源并退出循环

代码示例

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

void SetNonBlock(int fd)// 将fd设置为非阻塞
{
    int fl = fcntl(fd, F_GETFL);// 获取fd的状态标志
    if (fl < 0)
    {
        perror("fcntl");
        return;
    }
    fcntl(fd, F_SETFL, fl | O_NONBLOCK); // 将O_NONBLOCK标志添加到fd的状态标志中
}

int main()
{
    SetNonBlock(0);//将标准输入设置为非阻塞(0:标准输入,默认为阻塞模式)

    char buffer[1024];
    while (true)
    {
        ssize_t n = read(0, buffer, sizeof(buffer)-1);
        // read的返回值n有三种情况:1. n>0 2. n<0 3. n=0
        if (n > 0)// 读到了数据
        {
            buffer[n] = 0;
            std::cout << buffer << std::endl;
        }
        else if (n < 0) // 读数据出错了/底层没有数据准备好
        {
            if(errno == EAGAIN || errno == EWOULDBLOCK) // 这两种错误码都表示底层没有数据准备好
            {
                std::cout << "数据没有准备好..." << std::endl;
                sleep(2); // 等待一段时间后继续尝试读取数据
                continue;
            }
            else if(errno == EINTR)// 读数据被信号中断了
            {
                continue;
            }
            else
            {
                // 其他错误
                perror("read");
                break;
            }
        }
        else// n=0 读到了EOF,说明输入流被关闭了
        {
            break;
        }
    }
}

运行结果

通过ctrl+d关闭了输入流

五、多路转接IO

在单进程/单线程环境下,传统的阻塞 I/O 模型存在以下主要局限:

  • 当进程阻塞在一个文件描述符上时,无法处理其他文件描述符的 I/O 事件。

  • 若同时打开多个文件描述符(如多个网络连接),进程可能因某个描述符长期无数据而陷入永久阻塞。

  • 若采用非阻塞轮询方式,则会导致 CPU 资源浪费。

I/O 多路复用技术正是为解决这些问题而设计的,其核心思想是:单个进程可以同时阻塞在多个文件描述符上,直到其中一个或多个描述符就绪(可读、可写或发生异常)

Linux 提供了三种主流的 I/O 多路复用方案:

  • select:经典实现,具有较好的跨平台支持性。

  • poll:对 select 的改进,解决了文件描述符数量限制等问题。

  • epoll:Linux 特有的高性能方案,适合处理大量并发连接。

1.select

1.1 select的核心定位

select 是一个 I/O 多路复用​ 的等待与通知机制。它的核心任务是 "等"​ ------ 让应用进程能同时监控多个文件描述符(fd),并在其中任意一个或多个 fd 的 I/O 事件就绪时,通知上层应用。此时上层再调用函数进行I/O,就不再需要等待,直接拷贝即可。

1.2 select函数原型

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

1.参数:

  • nfds: 需要监视的文件描述符中,最大的文件描述符值 +1
  • readfds:输入输出型参数 ,调用时用户告知内核需要监视哪些文件描述符的读事件是否就绪,返回时内核告知用户哪些文件描述符的读事件已就绪。(这个参数使用一次过后,需要进行重新设定)
  • writefds:输入输出型参数 ,调用时用户告知内核需要监视哪些文件描述符的写事件是否就绪,返回时内核告知用户哪些文件描述符的写事件已就绪。(这个参数使用一次过后,需要进行重新设定)
  • exceptfds:输入输出型参数 ,调用时用户告知内核需要监视哪些文件描述符的异常事件是否就绪,返回时内核告知用户哪些文件描述符的异常事件已就绪。(这个参数使用一次过后,需要进行重新设定)
  • timeout:输入输出型参数 ,调用时由用户设置 select 的等待时间,返回时表示 timeout 的剩余时间。

2.fd_set 结构:

fd_set 本质是一个固定大小的 位图,用位图中对应的比特位来表示要监视的文件描述符。

在调用 select 函数之前,需要先通过 fd_set 结构定义对应的文件描述符集 ,并将需要监视的文件描述符添加到该集合中 。虽然添加过程本质上是对位图进行位操作,但系统提供了一组专门的宏接口

cpp 复制代码
void FD_CLR(int fd, fd_set *set);    // 用来清除描述词组set中相关fd的位
int FD_ISSET(int fd, fd_set *set);   // 用来测试描述词组set中相关fd的位是否为真
void FD_SET(int fd, fd_set *set);    // 用来设置描述词组set中相关fd的位
void FD_ZERO(fd_set *set);           // 用来清除描述词组set的全部位

使用此参数(以readfds为例):

  1. 输入的readfds:用户告诉内核哪些fd读事件需要监视
  2. 输出的readfds:内核要告诉用户哪些被监视的fd读事件已经就绪了

示例:

3. timeval 结构:

select 函数的最后一个参数 timeout 是一个指向 struct timeval 结构体的指针,用于指定等待的时间长度。

struct timeval 结构体:

cpp 复制代码
struct timeval 
{
    time_t      tv_sec;     /* seconds 秒数*/
    suseconds_t tv_usec;    /* microseconds 微秒数*/
};

根据 timeout 参数的不同取值,select 可采用以下三种等待策略:

  • 阻塞等待timeout 设为 nullptr ,此时 select 会一直阻塞,直到有文件描述符就绪。

  • 非阻塞轮询timeout 设为 {0, 0} ,此时无论被监视的文件描述符上的事件是否就绪, select 会立即返回,实现非阻塞检查。

  • 限时等待设置一个具体的时间值 (如 {5, 0} 表示 5 秒),select 会在该时间内阻塞等待:如果在指定的 timeout 时间内有文件描述符就绪,select 函数会提前返回,此时 timeout 参数所指向的 timeval 结构体会被更新为能够等待的剩余时间;若超时则直接返回,返回值为0。(理解:timeout=timeout-(就绪时刻-开始等待时刻))

使用示例:

返回值说明
  • n>0:函数调用成功,有n个文件描述符fd就绪。
  • n=0:在当前等待时间内没有任何一个fd就绪,timeout 时间耗尽。
  • n=-1:函数调用失败,同时错误码被设置。

select 调用失败时,错误码可能被设置为:

  • EBADF:文件描述符为无效的或该文件已关闭。
  • EINTR:此调用被信号所中断。
  • EINVAL:参数 nfds 为负值。
  • ENOMEM:核心内存不足。

只要有一个 fd 数据就绪或空间就绪,就可以进行返回了。

1.3 select的工作流程

  1. 进程初始化三个文件描述符集合(读、写、异常),指定需要监视的文件描述符
  2. 调用 select,进程阻塞(除非设置了超时)
  3. 内核监视所有指定的文件描述符,直到:某个描述符就绪(可读、可写或异常)或 超时时间到达
  4. 内核修改文件描述符集合,只保留就绪的描述符
  5. select 返回,进程通过FD_ISSET检查哪些描述符就绪并进行处理

1.4 select 版 TCP 服务器实现

下面我们实现一个基于 select 的 TCP 服务器,支持同时处理多个客户端连接。

整体设计思路
  1. 创建监听套接字

    调用 socket() 创建 TCP 套接字。

  2. 绑定地址与端口

    调用 bind() 将套接字绑定到指定 IP 和端口。

  3. 开始监听

    调用 listen() 将套接字设置为监听状态,等待客户端连接。

  4. 定义并初始化文件描述符数组

    • 创建一个整型数组(如 _fd_array)用于保存所有需要监视的文件描述符。

    • 将监听套接字加入数组,并记录当前最大文件描述符值(maxfd)。

  5. 每次循环开始时重建读集合

    • 定义 fd_set readfds,调用 FD_ZERO 清空。

    • 遍历 _fd_array,将其中所有有效的文件描述符通过 FD_SET 添加到 readfds 中。

    • 同时更新 maxfd 为当前数组中最大描述符值。

  6. 调用 select 进行监视

    • 调用 select(maxfd + 1, &readfds, nullptr, nullptr, timeout)

    • timeoutnullptr,则阻塞直到有事件就绪;若设置为具体值,则限时等待;若为 {0,0} 则立即返回(非阻塞轮询)。

  7. 检查并处理就绪事件

    • 监听套接字就绪

      • 调用 accept 接受新连接,获取新的已连接套接字 connfd

      • connfd 添加到 _fd_array 中,并更新 maxfd(若需要)。

    • 已连接套接字就绪

      调用 read 读取数据。

      • 若返回值 < 0:需根据 errno 处理(如非阻塞模式下 EAGAIN/EWOULDBLOCK 可忽略,其他错误则关闭连接)。

      • 若返回值 == 0:客户端关闭连接,调用 close 关闭该套接字,并从 _fd_array 中移除(可将其值设为 -1 或重新整理数组)。

      • 若返回值 > 0:成功读取数据,打印或处理数据。

  8. 循环回到步骤 5,重复监视过程。

代码
  • Socket.hpp:封装网络套接字socket接口
  • SocketServer.hpp:封装select版服务器接口
  • main.cc:主函数

Socket.hpp

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <cassert>
#include <unistd.h>
#include <memory>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <ctype.h>

class Sock
{
private:
    const static int gbacklog = 10;//监听队列的长度

public:
    Sock() {}
    ~Sock() {}
    static int Socket()//创建套接字
    {
        int listensock = socket(AF_INET, SOCK_STREAM, 0);//socket函数创建一个套接字,返回一个文件描述符,如果出错则返回-1
        if (listensock < 0)
        {
            exit(2);
        }
        int opt = 1;
        setsockopt(listensock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));//打开端口复用
        return listensock;
    }
    static void Bind(int sock, uint16_t port, std::string ip = "0.0.0.0")//绑定套接字到指定的IP地址和端口号
    {
        struct sockaddr_in local;
        memset(&local, 0, sizeof local);
        local.sin_family = AF_INET;
        local.sin_port = htons(port);
        inet_pton(AF_INET, ip.c_str(), &local.sin_addr);
        if (bind(sock, (struct sockaddr *)&local, sizeof(local)) < 0)
        {
            exit(3);
        }
    }
    static void Listen(int sock)//监听套接字,等待客户端连接请求
    {
        if (listen(sock, gbacklog) < 0)
        {
            exit(4);
        }
    }
    static int Accept(int listensock, std::string *ip, uint16_t *port)//接受客户端连接请求
    {
        struct sockaddr_in src;
        socklen_t len = sizeof(src);
        int servicessock = accept(listensock, (struct sockaddr *)&src, &len);
        if (servicessock < 0)
        {
            return -1;
        }
        if (port)//获取客户端的端口号
            *port = ntohs(src.sin_port);
        if (ip)//获取客户端的IP地址
            *ip = inet_ntoa(src.sin_addr);
        return servicessock;
    }
};

SocketServer.hpp

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

const uint16_t default_port = 8080;        // 默认端口
const std::string default_ip = "0.0.0.0";  // 默认IP
const int fd_num_max = sizeof(fd_set) * 8; // 最大支持的文件描述符数
const int default_fd = -1;                 // 无效描述符标记

class SelectServer
{
private:
    uint16_t port_;          // 端口号
    int listensock_;         // 监听套接字
    int fd_arry[fd_num_max]; // 存储所有需要监视的文件描述符
public:
    SelectServer(const uint16_t port = default_port)
        : port_(port)
    {
        // 初始化辅助数组
        for (int i = 0; i < fd_num_max; ++i)
        {
            fd_arry[i] = default_fd;
        }
    }

    // 初始化服务器
    void Init()
    {
        listensock_ = Sock::Socket();
        Sock::Bind(listensock_, port_);
        Sock::Listen(listensock_);
        std::cout << "Server initialized, listening on port " << port_ << std::endl;
    }

    // 启动服务器
    void Start()
    {
        fd_arry[0] = listensock_; // 监听套接字放在数组首位

        while (true)
        {
            fd_set rfds;
            FD_ZERO(&rfds);

            // 将所有有效的文件描述符添加到监视集合中,并找到当前最大的文件描述符
            int maxfd = fd_arry[0];
            for (int i = 0; i < fd_num_max; ++i)
            {
                if (fd_arry[i] == default_fd)
                    continue;

                FD_SET(fd_arry[i], &rfds);
                if (fd_arry[i] > maxfd)
                {
                    maxfd = fd_arry[i];
                }
            }

            // 调用select等待事件发生
            int n = select(maxfd + 1, &rfds, nullptr, nullptr, nullptr);
            if (n < 0)//select出错
            {
                std::cerr << "select error: " << strerror(errno) << std::endl;
                continue;
            }
            else if (n == 0)//select超时
            {
                std::cout << "select timeout" << std::endl;
                continue;
            }
            else//有事件发生
            {
                // 处理就绪事件
                HandlerEvent(rfds);
            }
        }
    }

private:
    // 接受新连接
    void Accept()
    {
        std::string clientip;
        uint16_t clientport;
        int sockfd = Sock::Accept(listensock_, &clientip, &clientport);

        if (sockfd < 0)
            return;

        // 查找空位存放新连接
        int i;
        for (i = 1; i < fd_num_max; ++i)
        { // 从1开始,0号位是监听套接字
            if (fd_arry[i] == default_fd)
                break;
        }

        if (i < fd_num_max)//找到空位,存放新连接
        {
            fd_arry[i] = sockfd;
            std::cout << "New connection: " << clientip << ":" << clientport
                      << " (fd=" << sockfd << ")" << std::endl;
            PrintOnlineFds();
        }
        else//没有空位,拒绝新连接
        {
            std::cerr << "Too many connections, closing new one" << std::endl;
            close(sockfd);
        }
    }

    // 处理客户端数据
    void HandleClientData(int fd, int index)
    {
        char buffer[1024];
        ssize_t n = read(fd, buffer, sizeof(buffer) - 1);

        if (n > 0)
        {
            buffer[n] = 0;
            std::cout << "Received from fd " << fd << ": " << buffer << std::endl;
        }
        else if (n == 0)
        {
            std::cout << "Client fd " << fd << " disconnected" << std::endl;
            close(fd);
            fd_arry[index] = default_fd;
            PrintOnlineFds();
        }
        else
        {
            std::cerr << "Read error on fd " << fd << ": " << strerror(errno) << std::endl;
            close(fd);
            fd_arry[index] = default_fd;
            PrintOnlineFds();
        }
    }

    // 处理就绪事件
    void HandlerEvent(fd_set &rfds)
    {
        for (int i = 0; i < fd_num_max; ++i)
        {
            int fd = fd_arry[i];
            if (fd == default_fd)
                continue;

            if (FD_ISSET(fd, &rfds))//判断fd是否就绪
            {
                // 监听套接字就绪:新连接
                if (fd == listensock_)
                {
                    Accept();
                }
                // 客户端套接字就绪:数据到达
                else
                {
                    HandleClientData(fd, i);
                }
            }
        }
    }

    // 打印在线文件描述符
    void PrintOnlineFds()
    {
        std::cout << "Online fds: ";
        for (int i = 0; i < fd_num_max; ++i)
        {
            if (fd_arry[i] != default_fd)
            {
                std::cout << fd_arry[i] << " ";
            }
        }
        std::cout << std::endl;
    }
};

main.cc

cpp 复制代码
#include "SelectServer.hpp"
#include <memory>
 
int main(int argc, char *argv[]) {
    if(argc!=2){
        std::cerr<<"Usage: "<<argv[0]<<" <port>"<<std::endl;
        return 1;
    }
    // 创建并启动服务器
    std::unique_ptr<SelectServer> server(new SelectServer(std::stoi(argv[1])));
    server->Init();
    server->Start();
    return 0;
}
关键点

参数输入输出特性

select 函数的 readfdswritefdsexceptfds 以及 timeout 参数均为输入输出型 参数------内核在返回时会修改它们,因此每次调用 select 前都必须重新设置这些参数。

最大文件描述符值

select 的第一个参数需传入所有被监视描述符中的最大值加1 (即 maxfd + 1)。由于描述符集合会动态变化(如新增或移除连接),每次重建集合时需重新计算 maxfd

辅助数组的作用与维护

为了高效重建 fd_set,通常使用一个辅助数组 (如 fd_array)保存所有需要监视的描述符:

  • 数组的第一个元素可固定为监听套接字,便于区分新连接事件与普通数据事件。

  • 每次循环开始前,遍历该数组,将有效的描述符加入 readfds,并同步更新 maxfd

  • 当客户端断开连接时,关闭对应套接字,并将数组中的位置重置为无效值(如 FD_NONE),同时重新计算 maxfd

事件处理机制

  • 监听套接字就绪 :调用 accept 接受新连接,并将返回的已连接套接字加入辅助数组。

  • 客户端套接字就绪 :调用 read 读取数据:

    • 若返回值 > 0:正常处理数据。

    • 若返回值 == 0:表示对端关闭连接,需关闭该套接字并从辅助数组中移除。

    • 若返回值 < 0:根据 errno 处理错误(如 EAGAIN/EWOULDBLOCK 可忽略,其他错误则关闭连接)。

多事件监视

若需要同时监视写事件,需额外定义 writefds 及其对应的辅助数组,并在每次循环前分别设置读集合和写集合。

错误处理

务必处理 read 返回 0 的情况(对端关闭),及时释放资源并更新辅助数组与 maxfd,避免资源泄漏或无效监视。

运行结果

单个客户端连接

多个客户端连接

1.5 select 的优缺点分析

优点
  1. 多文件描述符并发等待

    select 允许单进程同时等待多个文件描述符,将"等"的时间重叠,显著提高 I/O 效率。它仅负责等待事件就绪,实际的 I/O 操作(如 acceptreadwrite)在就绪后执行,不会阻塞进程。

  2. 资源利用率高

    在面对大量连接但仅有少量活跃连接时,select 能够避免为每个连接分配独立线程/进程,节省系统资源。

  3. 事件类型丰富

    支持同时监视读、写和异常事件,满足多样化的 I/O 需求。

缺点
  1. 文件描述符数量受限

    select 能监控的描述符数量由**fd_set 的位图大小** 决定,通常上限为 1024(sizeof(fd_set)*8),但是实际上进程自身可打开更多文件描述符(通过 ulimit 调整)。这也意味着除去监听套接字,服务器最多只能处理 1023 个客户端连接,难以应对高并发场景。

  2. 用户态/内核态拷贝开销大

    每次调用 select 时,都需要将整个 fd_set 集合从用户空间拷贝到内核空间;返回时,内核又将修改后的集合拷回用户空间。当监控的描述符数量很大时,这种频繁的内存拷贝会消耗大量 CPU 时间,成为性能瓶颈。

  3. 内核线性扫描所有描述符

    select 在内核中采用线性遍历的方式检查每个描述符的状态,时间复杂度为 O(n)。即使只有少数描述符活跃,内核仍需扫描整个集合,导致效率随描述符数量增加而线性下降。

  4. 编程不便,维护复杂

    • 每次调用 select 前都必须手动重建描述符集合(使用 FD_ZEROFD_SET 等宏),因为内核会修改传入的集合。

    • 需要借助第三方数组(如 fd_array)保存所有待监控的描述符,并在每次循环中遍历该数组以重建集合、更新最大描述符值,增加了代码复杂度。

    • 处理就绪事件时仍需遍历整个集合或依赖辅助数组,逻辑较为繁琐。

  5. 参数输入输出特性导致重复设置
    readfdswritefdsexceptfdstimeout 均为输入输出参数,每次返回后内容被改写,因此必须在下次调用前重新初始化,进一步增加了编程负担。

2.poll

正是由于 select的上述缺点,我们就需要使用另一种多路转接的方案------poll

1.1 poll的核心定位

poll函数的功能与select完全一致监视并等待多个文件描述符的状态变化,仅关注 I/O 过程中的 "等待" 阶段,一旦某个 fd 上的事件发生,poll 就会返回并通知应用程序。

1.2 从 select 缺陷到 poll 的诞生

select作为早期 I/O 多路复用技术,存在两个核心缺陷,这直接推动了poll函数的出现:

  1. **文件描述符(fd)数量上限:**select依赖fd_set位图结构(内核固定大小),默认上限通常为 1024(需修改内核参数才能调整),超过则报错。
  2. **参数输入输出耦合:**select的fd_set既是输入参数(指定要监视的 fd),也是输出参数(标记就绪的 fd)。每次调用select前,需重新初始化fd_set,导致用户态频繁遍历和拷贝,增加额外开销。

poll函数针对这两个缺陷做了改进:

  1. 突破 fd 数量上限(理论无限制,仅受系统资源约束);
  2. 分离输入与输出参数(通过pollfd结构体的events和revents字段),无需每次调用前重新设置监视 fd。

但需注意:pollselect核心逻辑一致------ 均通过轮询检测 fd 状态,效率随 fd 数量增加而线性下降。

1.3 poll函数原型

cpp 复制代码
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

参数说明

1.参数

  • fds:一个 poll 函数监视的结构列表,每一个元素都包含三部分内容:文件描述符、监视的事件集合、就绪的事件集合。
  • nfds :表示 fds 数组的有效长度。(nfds_t通常是unsigned intunsigned long的别名)
  • timeout:表示 poll 函数的超时时间,单位是毫秒(ms)。

2. 参数 timeout 的取值

  • -1:poll 调用后进行阻塞等待,直到被监视的某个文件描述符上的某个事件就绪。
  • 0:poll 调用后进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,poll 检测后都会立即返回。
  • >0 (特定的时间值):poll 调用后在指定的时间内进行阻塞等待,若被监视的文件描述符上没有事件就绪,则在该时间后 poll 进行超时返回。

3. pollfd 结构

cpp 复制代码
struct pollfd {
    int fd;         // 待监视的文件描述符(如socket、管道、普通文件)
    short events;   // 输入参数:用户要监视的事件(位掩码)
    short revents;  // 输出参数:内核返回的就绪事件(位掩码,用户无需初始化)
};
  • fd :特定的文件描述符,若设置为负值则忽略 events 字段并且 revents 字段返回 0。
  • events:需要监视该文件描述符上的哪些事件。
  • revents:poll 函数返回时告知用户该文件描述符上的哪些事件已经就绪。

events(输入) 和 revents(输出) 的取值:

以宏的方式表示事件,支持多种事件的组合(用|运算符)。

pollfd 结构的使用方法:

  • 在调用 poll 函数之前,通过 |(或运算符) 将要监视的事件添加到events成员中
  • 在 poll 函数返回后,通过 &(与运算符)检测revents成员中是否包含特定事件,以得知对应文件描述符的特定事件是否就绪

poll将输入参数和输出参数进行了分离,在调用poll前不再需要重新设置pollfd。

返回值

poll的返回值直接反映调用结果,需根据返回值做不同处理:

返回值 含义 后续操作
-1 调用失败(如 fd 非法、内存不足) 检查errno(如 EBADF、EINTR),处理错误
0 超时(无任何 fd 就绪) 无需处理 fd,可重新调用poll
>0 就绪 fd 的数量(revents非 0 的结构体个数) 遍历pollfd数组,处理revents非 0 的 fd

1.4 poll的工作流程

1.调用前:用户填充 fds数组。

对每个关心的 fd,设置其 events字段(例如 POLLIN表示关心可读事件)。此时,fd和 events有效。这是"用户告诉内核":请帮我监视这个 fd上的这些事件。

2.调用 poll:内核开始监视,进程进入等待。

3.调用返回后:内核填充 revents字段。

检查每个 fd的 revents字段。如果某位被置位(如 revents & POLLIN为真),表示对应事件已就绪。此时,fd和 revents有效。这是"内核告诉用户":你让我监视的 fd上,这些事件已经就绪了,可以处理了!

1.5 poll 函数简单示例

何用poll监视一个文件是否可读,超时时间为 5 秒:

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <poll.h>
 
int main() {
    // 以只读模式打开文件(需确保test.txt存在)
    int fd = open("test.txt", O_RDONLY);
    if (fd < 0) {
        perror("Failed to open file");
        return EXIT_FAILURE;
    }
 
    // 初始化pollfd结构体(监视fd的可读事件)
    struct pollfd fds[1];
    fds[0].fd = fd;
    fds[0].events = POLLIN;  // 关注可读事件
    fds[0].revents = 0;      // 输出参数,可省略初始化(内核会覆盖)
 
    int timeout = 5000;  // 超时5秒
    int ret = poll(fds, 1, timeout);
 
    if (ret == -1) {
        perror("poll failed");
        close(fd);
        return EXIT_FAILURE;
    } else if (ret == 0) {
        printf("No data within 5 seconds\n");
    } else if (fds[0].revents & POLLIN) {
        // 读取文件内容并打印
        char buf[1024] = {0};
        ssize_t bytes_read = read(fd, buf, sizeof(buf) - 1);
        if (bytes_read > 0) {
            printf("Read %zd bytes: %s\n", bytes_read, buf);
        }
    }
 
    close(fd);
    return EXIT_SUCCESS;
}

对于普通文件 (如通过 open 打开的磁盘文件),文件描述符总是处于"可读"状态。因此,即使文件为空, poll 也会认为 POLLIN 事件就绪,并立即返回,而不会等待超时。这与管道、套接字等设备不同,无数据时POLLIN 事件不会就绪,poll会阻塞等待超时。

1.6 poll 版 TCP 服务器实现

poll版 TCP 服务器的逻辑与select版类似,核心差异在于用pollfd数组管理 fd,而非fd_set位图。

整体设计思路
  1. 初始化监听 socket:创建、绑定、监听端口;
  2. 管理 pollfd 数组:将监听 socket 加入数组,设置监视事件(POLLIN);
  3. 循环调用 poll:阻塞等待 fd 就绪,处理超时、错误、就绪三种情况;
  4. 事件处理:
  • 监听 socket 就绪:调用accept接受新连接,将新 socket 加入pollfd数组;
  • 通信 socket 就绪:调用read读取客户端数据,处理断开连接或错误。
代码
  • Sock.hpp:封装网络套接字socket接口
  • PollServer.hpp:封装poll版服务器接口
  • main.cc:主函数

Sock.hpp

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <cassert>
#include <unistd.h>
#include <memory>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <ctype.h>

class Sock
{
private:
    const static int gbacklog = 10;//监听队列的长度

public:
    Sock() {}
    ~Sock() {}
    static int Socket()//创建套接字
    {
        int listensock = socket(AF_INET, SOCK_STREAM, 0);//socket函数创建一个套接字,返回一个文件描述符,如果出错则返回-1
        if (listensock < 0)
        {
            exit(2);
        }
        int opt = 1;
        setsockopt(listensock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));//打开端口复用
        return listensock;
    }
    static void Bind(int sock, uint16_t port, std::string ip = "0.0.0.0")//绑定套接字到指定的IP地址和端口号
    {
        struct sockaddr_in local;
        memset(&local, 0, sizeof local);
        local.sin_family = AF_INET;
        local.sin_port = htons(port);
        inet_pton(AF_INET, ip.c_str(), &local.sin_addr);
        if (bind(sock, (struct sockaddr *)&local, sizeof(local)) < 0)
        {
            exit(3);
        }
    }
    static void Listen(int sock)//监听套接字,等待客户端连接请求
    {
        if (listen(sock, gbacklog) < 0)
        {
            exit(4);
        }
    }
    static int Accept(int listensock, std::string *ip, uint16_t *port)//接受客户端连接请求
    {
        struct sockaddr_in src;
        socklen_t len = sizeof(src);
        int servicessock = accept(listensock, (struct sockaddr *)&src, &len);
        if (servicessock < 0)
        {
            return -1;
        }
        if (port)//获取客户端的端口号
            *port = ntohs(src.sin_port);
        if (ip)//获取客户端的IP地址
            *ip = inet_ntoa(src.sin_addr);
        return servicessock;
    }
};

PollServer.hpp

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

const uint16_t default_port = 8080;       // 默认端口
const std::string default_ip = "0.0.0.0"; // 默认IP
const int fd_num_max = 64;                // 最大文件描述符数量(可自定义扩容)
const int default_fd = -1;                // 无效描述符标记

class PollServer
{
private:
    uint16_t port_;                    // 端口号
    int listensock_;                   // 监听套接字
    struct pollfd fd_arry[fd_num_max]; // 存储所有需要监视的文件描述符
public:
    PollServer(const uint16_t port = default_port)
        : port_(port)
    {
        // 初始化辅助数组
        for (int i = 0; i < fd_num_max; ++i)
        {
            fd_arry[i] = {default_fd, POLLIN, 0}; // 初始化为无效描述符,关注可读事件
        }
    }

    // 初始化服务器
    void Init()
    {
        listensock_ = Sock::Socket();
        Sock::Bind(listensock_, port_);
        Sock::Listen(listensock_);
        std::cout << "Server initialized, listening on port " << port_ << std::endl;
    }

    // 启动服务器
    void Start()
    {
        fd_arry[0].fd = listensock_; // 监听套接字放在数组首位
        fd_arry[0].events = POLLIN;  // 关注可读事件
        fd_arry[0].revents = 0;      // 初始化输出事件

        while (true)
        {
            // 调用poll等待事件发生
            int n = poll(fd_arry, fd_num_max, -1); // -1表示阻塞等待
            if (n < 0)
            {
                std::cerr << "poll error: " << strerror(errno) << std::endl;
                continue;
            }
            else if (n == 0)
            {
                std::cout << "poll timeout" << std::endl;
                continue;
            }
            else
            {
                // 处理就绪事件
                HandlerEvent(n);
            }
        }
    }

private:
    // 接受新连接
    void Accept()
    {
        std::string clientip;
        uint16_t clientport;
        int sockfd = Sock::Accept(listensock_, &clientip, &clientport);

        if (sockfd < 0)
            return;

        // 查找空位存放新连接
        int i;
        for (i = 1; i < fd_num_max; ++i)
        { // 从1开始,0号位是监听套接字
            if (fd_arry[i].fd == default_fd)
                break;
        }

        if (i < fd_num_max) // 找到空位,存放新连接
        {
            fd_arry[i].fd = sockfd;
            fd_arry[i].events = POLLIN; // 关注可读事件
            fd_arry[i].revents = 0;     // 初始化输出事件
            std::cout << "New connection: " << clientip << ":" << clientport
                      << " (fd=" << sockfd << ")" << std::endl;
            PrintOnlineFds();
        }
        else // 没有空位,拒绝新连接
        {
            std::cerr << "Too many connections, closing new one" << std::endl;
            close(sockfd);
        }
    }

    // 处理客户端数据
    void HandleClientData(int fd, int index)
    {
        char buffer[1024];
        ssize_t n = read(fd, buffer, sizeof(buffer) - 1);

        if (n > 0)
        {
            buffer[n] = 0;
            std::cout << "Received from fd " << fd << ": " << buffer << std::endl;
        }
        else if (n == 0)
        {
            std::cout << "Client fd " << fd << " disconnected" << std::endl;
            close(fd);
            fd_arry[index].fd = default_fd;
            PrintOnlineFds();
        }
        else
        {
            std::cerr << "Read error on fd " << fd << ": " << strerror(errno) << std::endl;
            close(fd);
            fd_arry[index].fd = default_fd;
            PrintOnlineFds();
        }
    }

    // 处理就绪事件
    void HandlerEvent(int n)
    {
        for (int i = 0; i < fd_num_max; ++i)
        {
            int fd = fd_arry[i].fd;
            if (fd == default_fd)
                break; // 后续都是无效描述符,直接跳出循环

            if (fd_arry[i].revents & POLLIN) // 判断fd是否就绪
            {
                // 监听套接字就绪:新连接
                if (fd == listensock_)
                {
                    Accept();
                }
                // 客户端套接字就绪:数据到达
                else
                {
                    HandleClientData(fd, i);
                }
            }
        }
    }

    // 打印在线文件描述符
    void PrintOnlineFds()
    {
        std::cout << "Online fds: ";
        for (int i = 0; i < fd_num_max; ++i)
        {
            if (fd_arry[i].fd != default_fd)
            {
                std::cout << fd_arry[i].fd << " ";
            }
        }
        std::cout << std::endl;
    }
};

main.cc

cpp 复制代码
#include "PollServer.hpp"
#include <memory>
 
int main(int argc, char *argv[]) {
    if(argc!=2){
        std::cerr<<"Usage: "<<argv[0]<<" <port>"<<std::endl;
        return 1;
    }
    // 创建并启动服务器
    std::unique_ptr<PollServer> server(new PollServer(std::stoi(argv[1])));
    server->Init();
    server->Start();
    return 0;
}
运行结果

1.7 poll 的优缺点

优点

1.无文件描述符数量限制

poll 使用 struct pollfd 数组管理监视的描述符,数组大小由用户自定义,理论上只受系统内存和进程可打开文件描述符上限的限制,彻底解决了 select 位图自身的大小限制。

2.输入输出参数分离,接口更易用
pollfd 结构中的 events(输入,用户设置关注的事件)和 revents(输出,内核返回实际发生的事件)相互独立,避免了 select 中每次调用前必须重新设置整个集合的繁琐操作,减少了编程错误。

3.效率相对 select 略有提升

poll 只需遍历数组中的有效元素,而非像 select 那样遍历到位图的最大描述符值,因此在描述符数值较大但数量不多时有一定优势。

4.异常事件的处理更加简洁

内核会自动在 revents 字段中标记异常状态(如 POLLERR 表示错误、POLLHUP 表示挂起等),无需像 select 那样单独设置异常集合进行监视。

缺点
  1. 用户态与内核态的数据拷贝开销大

    每次调用 poll,都需要将整个 pollfd 数组从用户空间拷贝到内核空间。当监控的描述符数量很大时,频繁的内存拷贝会显著影响性能。

  2. 内核仍需线性遍历所有描述符

    与 select 类似,poll 在内核中同样需要遍历所有传入的描述符,检查其状态,时间复杂度为 O(n)。即使只有少量描述符活跃,内核也要扫描全部,导致性能随描述符数量增加而线性下降。

  3. 编程复杂度依然存在

    尽管比 select 易用,但用户仍需手动维护 pollfd 数组(如添加、删除、标记无效描述符),代码复杂度高于 epoll 等更高级的接口。

总结

poll 作为 select 的改进版,解决了"文件描述符数量限制"和"参数耦合"问题,但其轮询本质未变,仍适用于中低并发场景(如描述符数量在几千以内)。理解 poll 的设计有助于掌握 I/O 多路复用的演进脉络,并为学习 epoll 奠定基础。

3.epoll

3.1 初识epoll

**核心定位:**epoll 是一种基于多个 fd 的就绪事件通知机制,通过监控这些 fd 上的事件(如可读、可写),在事件就绪时通知应用程序,从而避免阻塞等待。这与 select 和 poll 的目标一致,但设计更高效。

历史背景:epoll 是在 Linux 内核 2.5.44 版本中引入的,按照 man 手册的说法,它是为处理大批量句柄(fd)而改进的 poll,旨在解决传统方法在高并发场景下的局限性。

**实现与使用:**epoll 的实现原理、接口使用与 select 和 poll 差别非常大。它通过 epoll_create、epoll_ctl 和 epoll_wait 三个核心系统调用实现,拥有更灵活的事件注册和触发机制,减少了遍历所有 fd 的开销,从而提升了性能。

3.2 epoll相关的系统调用

epoll 的功能通过三个核心系统调用实现,三者分工明确、协作紧密,共同构成了 "创建环境 - 注册事件 - 等待触发" 的完整流程。

epoll_create:搭建事件监控舞台

epoll_create 的核心作用是向内核申请一块专用资源,创建一个 epoll 实例(本质是内核中的eventpoll结构体),用于后续管理待监控的文件描述符(fd)

epoll_create

cpp 复制代码
int epoll_create(int size);

参数:

size:已废弃,只需要传大于0的数即可

返回值:

成功返回 epoll 描述符 epfd

失败返回-1,并设置错误码:

  • EMFILE:用户打开的 fd 数量达到上限。

  • ENFILE:系统全局打开的 fd 数量达到上限。

  • ENOMEM:内存不足,无法创建 epoll 实例。

注意: 当不再使用时,须调用 close 函数关闭 epoll 模型对应的文件描述符,当所有引用 epoll 实例的文件描述符都已关闭时,内核将销毁该实例并释放相关资源。

epoll_ctl:管理事件监控名单

poll_ctl是 epoll 的 "事件管理器",负责向 epoll 实例添加、修改或删除待监控的 fd 及其事件,是连接用户需求与内核监控的桥梁。

epoll_ctl

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

参数

  • **epfd:**epoll_create返回的 epoll 实例 epfd,标识要操作的目标 epoll 环境
  • op: 操作类型:EPOLL_CTL_ADD: 添加 fd 到 epoll 监控;EPOLL_CTL_MOD: 修改已监控 fd 的事件;**EPOLL_CTL_DEL:**从 epoll 中删除 fd
  • **fd:**待操作的目标 fd(如 socket、管道等)
  • **event:**指向struct epoll_event的指针,描述 fd 的监控事件类型及关联数据

关键结构体 :epoll_event

struct epoll_event 结构中有两个成员:

  • 第一个成员 events 表示监视的事件类型(输入)或就绪的事件(输出)(读事件/写事件......)
  • 第二个成员 data 为联合体结构,一般选择使用该结构中的 fd,表示需要监听的文件描述符
cpp 复制代码
// 事件关联的数据(联合体,按需使用)
typedef union epoll_data {
    void    *ptr;    // 指向用户自定义数据(如连接上下文)
    int      fd;     // 关联的fd(最常用,直接标识触发事件的fd)
    uint32_t u32;    // 32位整数
    uint64_t u64;    // 64位整数
} epoll_data_t;
 
// 事件结构体
struct epoll_event {
    uint32_t     events;  // 监控的事件类型(位掩码)
    epoll_data_t data;    // 事件触发后返回的关联数据
};

常用事件类型(events 参数):

  • EPOLLIN:fd 可读(如 socket 收到数据)。
  • EPOLLOUT:fd 可写(如 socket 发送缓冲区空闲)。
  • EPOLLERR:fd 发生错误(无需主动设置,内核会自动通知)。
  • EPOLLHUP:fd 被挂断(如客户端关闭连接,无需主动设置)。
  • EPOLLET:边缘触发模式(ET,高效模式,需配合非阻塞 I/O)。
  • EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需继续监听该文件描述符 socket 的话,需重新将该文件描述符添加到 EPOLL 队列里。
  • EPOLLPRI:表示对应的文件描述符有紧急的数据可读(表示应该有带外数据到来)。

返回值:

成功0,失败-1

epoll_wait:等待并获取就绪事件

**epoll_wait**是 epoll 的 "事件收集器",负责阻塞等待已注册的事件触发,并将就绪事件返回给用户空间,是 epoll 高效性的直接体现。

epoll_wait

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

参数

  • epfd:目标 epoll 实例的 fd。
  • events:用户预先分配的struct epoll_event数组,用于存储内核返回的多个就绪事件(非空指针)。
  • maxevents:epoll_events数组的最大长度(即一次最多能处理的事件数,需与数组大小一致)。
  • timeout:超时时间(毫秒):-1:无限阻塞,直到有事件触发;0:立即返回,不阻塞;- >0:等待指定毫秒后超时返回。

返回值与错误码

成功:返回就绪事件的数量(0 表示超时,无事件就绪)。

失败:返回 - 1,并设置errno:

  • EBADF:epfd不是有效的 epoll 实例 fd。
  • EINTR:系统调用被信号中断(需处理,避免程序异常退出)。
  • EINVAL:maxevents≤0 或epfd无效。

3.3 epoll的底层原理

1.硬件基础:网络数据的接收流程

要理解 epoll,首先需要明确计算机如何接收网络数据,这是所有 I/O 操作的起点:

  1. **数据到达网卡:**网线传来的数据包被网卡接收,暂存到网卡缓冲区。
  2. **DMA 传输:**通过 DMA(直接内存访问)技术,数据从网卡缓冲区直接写入内存的内核缓冲区(无需 CPU 参与,减少 CPU 开销)。
  3. **触发中断:**数据写入完成后,网卡向 CPU 发送中断信号,告知 "有新数据待处理"。

2.中断机制:操作系统感知数据的关键

CPU 收到网卡中断信号后,会暂停当前任务,转而去处理中断(确保数据不丢失),流程如下:

  1. CPU 查找 "中断向量表",找到网卡中断对应的处理程序。
  2. 执行中断处理程序:将内存中的数据解析后,传递到对应协议栈(如 TCP/UDP)。
  3. 数据到达传输层后,被存入对应 socket 的接收缓冲区。
  4. 中断处理完成,CPU 恢复之前的任务。

这一机制确保了硬件事件能被及时响应,是 epoll "被动通知" 的基础。

3.回调机制:epoll 高效检测就绪事件的核心

epoll 最关键的优化是回调机制,彻底解决了 select/poll "遍历所有 fd 检测就绪" 的性能瓶颈:

  1. **注册回调:**当通过epoll_ctl添加 fd 时,内核会为该 fd 注册一个回调函数(ep_poll_callback)。
  2. **事件触发时自动回调:**当 fd 对应的事件就绪(如 socket 收到数据),内核会自动调用ep_poll_callback。
  3. **加入就绪链表:**回调函数将该 fd 对应的epitem(epoll 项)添加到就绪链表(rdllist)中。

整个过程无需遍历所有 fd,完全由内核自动完成,时间复杂度为 O (1)。

4.数据结构选择:红黑树与就绪链表的协同

epoll 使用 "红黑树 + 就绪链表" 的组合,兼顾了 "高效管理 fd" 和 "快速获取就绪事件" 的需求:

  • 红黑树(rbr):存储所有待监控的 fd,支持 O (log n) 的增删改查,解决了 "大量 fd 管理" 的效率问题。相比 AVL 树,红黑树的旋转操作更少,在频繁修改场景下性能更稳定。
  • 就绪链表(rdllist):暂存已就绪的 fd,epoll_wait直接从该链表获取结果,无需遍历红黑树,确保了 "获取就绪事件" 的 O (1) 时间复杂度。

当调用 epoll_create 函数时,Linux 内核会创建一个 eventpoll 结构体 ,即 epoll 模型,eventpoll 结构体中的成员rbr(红黑树)、rdlist(就绪链表) 也在这时被创建

cpp 复制代码
struct eventpoll{
	...
	//红黑树的根节点,这棵树中存储着所有添加到epoll中的需要监视的事件
	struct rb_root rbr;
	//就绪队列中则存放着将要通过epoll_wait返回给用户的满足条件的事件
	struct list_head rdlist;
	...
}

**在 epoll 的内部实现中,每个被监控的事件都对应一个 epitem 结构体。**该结构体包含以下关键成员,用于组织和管理事件:

  • rbn :作为红黑树的节点 ,用于将 epitem 挂载到内核维护的红黑树中,以便快速查找指定的文件描述符。

  • rdllink :作为双向链表的节点 ,用于将 epitem 链接到就绪队列(ready list)中,表示该描述符上有事件发生。

  • ffd:记录被监控的文件描述符值。

  • event:记录该文件描述符所关注的事件类型(如可读、可写等)。

cpp 复制代码
struct epitem{
	struct rb_node rbn;       //红黑树节点
	struct list_head rdllink; //双向链表节点
	struct epoll_filefd ffd;  //事件句柄信息
	struct eventpoll *ep;     //指向其所属的eventpoll对象
	struct epoll_event event; //期待发生的事件类型
}

这也说明就绪链表中的节点并不是单独开辟空间创建的。

注意红黑树 是一种二叉搜索树,必须有键值 key文件描述符fd就是红黑树 key 值,在增/删/改监控事件时被使用

三个系统调用在内核中的主要动作

调用epoll_create时,内核会完成三件关键事:

  1. 分配并初始化struct eventpoll结构体(epoll 实例的核心数据结构)。
  2. 创建红黑树(rbr):用于高效存储和管理所有待监控的 fd,支持 O (log n) 的增删改查。
  3. 创建就绪链表(rdllist):用于暂存已就绪的事件,epoll_wait直接从该链表获取结果,避免遍历全部 fd。

调用epoll_ctl 时,以EPOLL_CTL_ADD(添加 fd)为例,内核会执行:

  1. 检查 fd 是否已在红黑树中(避免重复添加)。
  2. 若不存在,分配struct epitem结构体(存储 fd、事件、关联的 epoll 实例等信息)。
  3. 将epitem插入红黑树,并为 fd 注册内核回调函数(ep_poll_callback,用于事件就绪时触发)。

调用epoll_wait时:

1.检查就绪链表(rdllist)是否有数据:

  • 若有数据:将就绪事件批量复制到用户空间的events数组(最多maxevents个)。
  • 若无数据:根据timeout参数决定是否阻塞(阻塞时将进程挂到等待队列wq)。

2.若进程被唤醒(有事件就绪或超时):返回就绪事件数量,用户程序遍历events数组处理即可。

与 select/poll 的性能对比

特性 select poll epoll
数据结构 固定大小 fd_set 动态数组 红黑树 + 就绪链表
事件检测方式 遍历所有 fd(O (n)) 遍历所有 fd(O (n)) 回调 + 就绪链表(O (1))
最大 fd 数量 受限(默认 1024) 理论无限制(受内存限制) 无限制(仅受系统资源限制)
用户 / 内核数据复制 每次调用复制所有 fd 每次调用复制所有 fd 仅添加 / 删除时复制一次
适用场景 少量 fd、低并发 中等 fd、中并发 大量 fd、高并发

3.4 epoll的工作方式

epoll 是 Linux 下高效的 I/O 多路复用机制,支持两种工作模式:水平触发(LT,Level Triggered)边缘触发(ET,Edge Triggered) 。这两种模式的核心区别在于事件通知的时机处理要求

LT模式
  • 默认模式:epoll 默认采用 LT 模式,与 select/poll 的行为一致。

  • 通知规则 :只要文件描述符上还有未处理的事件(如读缓冲区仍有数据),每次调用 epoll_wait 都会返回该事件,持续通知直到事件被完全处理。

  • 数据处理 :可以分多次读取或写入,不必一次性完成。例如,读缓冲区有 2KB 数据,第一次只读 1KB,剩余 1KB 会在下一次 epoll_wait 中再次触发通知。

  • 文件描述符要求支持阻塞和非阻塞模式,编程简单,容错性高。

理解:

水平触发(LT)模式下,只要底层事件保持就绪状态,epoll 就会持续通知用户,如同高电平持续触发一样。

ET模式
  • 需显式设置 :添加事件时使用 EPOLLET 标志启用

  • 通知规则 :仅当文件描述符的状态发生变化时才通知一次(如从无数据变为有数据,或数据量增加)。若一次未处理完所有数据,后续不再重复通知,直到下次状态变化。

  • 数据处理 :必须一次性 将本次就绪的数据全部读完(或写完)。通常需循环调用 read/write 直到返回 EAGAIN 错误,以确认数据已耗尽。

  • 文件描述符要求 :必须配合非阻塞模式使用,防止最后一次读取时因无数据而永久阻塞。

  • 性能优势 :减少了 epoll_wait 的返回次数,降低系统调用开销;同时强制及时清空缓冲区,使接收窗口更快更新,提升 TCP 吞吐量。

理解:

边缘触发(ET)模式下,epoll 仅在事件就绪状态发生变化的瞬间通知用户一次,类似于数字电路的上升沿触发。

LT VS ET
  • LT是epoll的默认模式 ,只要文件描述符上还有未处理的事件(如读缓冲区仍有数据),每次调用 epoll_wait 都会持续通知用户,直至事件被完全处理。所以,文件描述符可以是阻塞或非阻塞的
  • ET模式需要显式设置 EPOLLET 标志 。仅当事件状态发生变化时通知一次,之后即使数据未处理完也不再重复通知。这迫使程序员必须一次性将本轮数据全部读取或写入完毕。为避免阻塞,必须将文件描述符设置为非阻塞 ,并通过循环调用 read/write 直到返回 EAGAIN,确保数据已耗尽。

在 epoll 的边缘触发(ET)模式下,必须将文件描述符设置为非阻塞 ,这并非接口的强制要求,而是工程实践中的必要选择。原因如下:

  • ET 模式仅在事件状态变化时通知一次,若未一次性读完所有数据,剩余数据将滞留缓冲区且不再触发通知,导致后续数据无法被处理。

  • 使用阻塞式 read 无法保证一次读完所有数据(例如可能被信号打断或受限于缓冲区大小),若仅读取部分数据,剩余数据将永久留在缓冲区,直到有新事件到来才能再次触发读取。

  • 例如,服务器需读取完整的 10k 请求后才响应客户端,但若仅读取 1k 便阻塞等待,剩余 9k 数据不会被再次通知,服务器无法继续读取,也就无法发送响应。客户端因未收到响应而不会发送新请求,造成死锁。

  • 解决方法是采用非阻塞 I/O,循环调用 read 直至返回 EAGAIN,确保一次性将所有就绪数据读出。

  • (需要循环调用read/write=》若上一次刚好把数据读完,这一次就读不到数据=》此时若文件描述符为阻塞模式,进程就会阻塞=》应设置文件描述符为非阻塞

  • 相比之下,水平触发(LT)模式则无此问题,因为只要缓冲区还有数据,epoll_wait 就会持续通知,允许分多次读取。

总结:LT 是"持续通知直到处理完",ET 是"状态变化时通知一次,必须立即处理完"。

ET模式的高效性
  • 减少系统调用次数 :ET 仅在事件状态变化时通知一次,避免了水平触发(LT)模式下因未处理完数据而反复通知的开销 ,从而降低 epoll_wait 的调用频率,减少用户态与内核态的切换成本。

  • 提升网络吞吐量 :ET 强制程序尽快取走所有数据,使接收缓冲区迅速清空。这样一来,TCP 协议栈能及时向对端通告更大的接收窗口(接收窗口大小取决于缓冲区剩余空间),进而允许发送方扩大滑动窗口,一次发送更多数据,显著提高网络传输效率。(主要原因)

3.5 epoll 版 TCP 服务器实现(LT模式)

整体设计思路
  • 初始化监听 socket:socket 创建 → bind 绑定 IP/端口 → listen 开始监听(并设置为非阻塞)。
  • 初始化 epoll:创建 epoll 实例;将监听 fd 注册到 epoll,关注事件 EPOLLIN
  • 循环调用 epoll_wait:阻塞等待事件到来;处理三种结果:错误(如 EINTR 重试)、超时(本例 timeout=-1 基本不会)、就绪(返回 n 个事件)。
  • 事件分发处理(遍历就绪事件数组):
    • 监听 fd 就绪:循环 accept 接受新连接;新连接设置非阻塞;把新 fd 加入 epoll,关注 EPOLLIN
    • 通信 fd 就绪:循环 read 尽量读完;读到 0 表示对端关闭;读出错(非 EAGAIN)则关闭;关闭前先从 epoll DEL 再 close
    • 异常事件:若出现 EPOLLERR/EPOLLHUP,优先关闭并清理该连接 fd。
代码
  • main.cc

    • 作用:程序入口;解析端口参数,创建 EpollServer 并调用 Init() / Start()
    • 定位:只负责"启动流程",不承载网络细节。
  • EpollServer.hpp

    • 作用:TCP 服务器主逻辑(单线程、LT、非阻塞)。
    • 核心流程:
      • Init():创建监听 socket(Socket()Bind()Listen()),并设为非阻塞
      • Start():把监听 fd 加入 epoll,进入 epoll_wait 循环
      • HandlerEvent():分发事件(listen fd → Accept();client fd → HandleClientData()
      • CloseConnection():统一清理连接(从 epoll 删除 + close)
    • 定位:把"事件循环骨架"和"连接生命周期"串起来。
  • Epoll.hpp

    • 作用:对 epoll 的封装(创建 epfd、增/删/改关心事件AddEvent/DelEvent/ModEvent、等待事件就绪Wait)。
    • 定位:把"系统调用细节"隔离,给上层提供稳定接口。
  • Socket.hpp

    • 作用:对 socket 常用操作的封装:Socket/Bind/Listen/Accept,以及 SetNonBlock(设置fd为非阻塞)
    • 定位:把"建连与 fd 属性设置"从服务器逻辑里抽离出来,减少 EpollServer 的噪音。
  • Log.hpp

    • 作用:简单日志系统(日志等级 + 时间 + pid + printf 格式化输出)。
    • 定位:统一输出,方便观察事件循环行为。
  • Err.hpp

    • 作用:集中定义退出错误码(如 SOCKET_ERR/BIND_ERR/LISTEN_ERR/OPEN_ERR 等)。
    • 定位:让错误退出有明确语义。

main.cc

cpp 复制代码
#include <memory>
#include <cstdlib>
#include "EpollServer.hpp"

int main(int argc, char *argv[]) {
    if(argc!=2){
        std::cerr<<"Usage: "<<argv[0]<<" <port>"<<std::endl;
        return 1;
    }
    // 创建并启动服务器
    std::unique_ptr<EpollServer> server(new EpollServer(std::stoi(argv[1])));
    server->Init();
    server->Start();
    return 0;
}

EpollServer.hpp

cpp 复制代码
#pragma once
#include <iostream>
#include <unistd.h>
#include <cerrno>
#include <cstring>
#include "Socket.hpp"
#include "Epoll.hpp"
#include "Log.hpp"
#include "Err.hpp"

// 这是一个最小可读的 epoll TCP 服务器骨架(单线程、LT、非阻塞)。
// 阅读顺序建议:
// 1) Init(): 建立监听 socket(socket/bind/listen)并设置非阻塞
// 2) Start(): 把监听 fd 加入 epoll,并进入事件循环
// 3) HandlerEvent(): 事件分发:listen fd -> Accept;client fd -> HandleClientData
// 4) CloseConnection(): 统一清理连接资源
const uint16_t default_port = 8080; // 默认端口
const int fd_num_max = 64;          // 每次 epoll_wait 取回的最大事件数量(不是系统 fd 上限)

class EpollServer
{
private:
    uint16_t port_;                       // 端口号
    int listensock_;                      // 监听套接字
    Epoller epoller_;                     // epoll对象
    struct epoll_event revs_[fd_num_max]; // 就绪事件数组

public:
    EpollServer(const uint16_t port = default_port)
        : port_(port), listensock_(-1)
    {
        epoller_.Create();
    }

    ~EpollServer()
    {
        // epoller_ 在析构中会自动关闭 epoll fd;这里只需要收尾监听 fd。
        if (listensock_ >= 0)
        {
            close(listensock_);
            listensock_ = -1;
        }
    }

    // 初始化:准备好监听 socket(但还没开始事件循环)
    void Init()
    {
        listensock_ = Sock::Socket();
        Sock::Bind(listensock_, port_);
        Sock::Listen(listensock_);

        // 事件循环依赖"非阻塞 I/O + epoll 通知",否则某次 accept/read 可能卡住整个循环。
        Sock::SetNonBlock(listensock_);

        logMessage(Info, "Server initialized, listening on port %u", port_);
    }

    // 启动:注册监听 fd,进入 epoll_wait 循环(核心调度点)
    void Start()
    {
        // 对 listen fd 来说,EPOLLIN 表示"有新连接可 accept"。
        epoller_.AddEvent(listensock_, EPOLLIN);

        // timeout = -1:没有事件就睡眠,有事件就醒来处理。
        int timeout = -1;
        while (true)
        {
            // 等待事件发生,并把就绪事件填入 revs_
            int n = epoller_.Wait(revs_, fd_num_max, timeout);
            if (n < 0)
            {
                if (errno == EINTR)
                    continue;
                logMessage(Error, "epoll_wait error, code: %d, errstring: %s", errno, strerror(errno));
                continue;
            }
            else if (n == 0)
            {
                logMessage(Info, "epoll timeout");
                continue;
            }
            else
            {
                // n 个就绪事件:逐个分发处理
                Dispacher(n);
            }
        }
    }

private:
    // 统一关闭连接(并从 epoll 关注列表中移除)。
    void CloseConnection(int fd)
    {
        if (fd < 0)
            return;
        epoller_.DelEvent(fd);
        close(fd);
    }

    // 接受连接:把 listen fd 上的"新连接"变成可读写的 client fd,并注册到 epoll。
    void Accept()
    {
        // 非阻塞 + 循环 accept:一直取到"暂时没新连接"为止。
        while (true)
        {
            std::string clientip;
            uint16_t clientport = 0;
            int sockfd = Sock::Accept(listensock_, &clientip, &clientport);

            if (sockfd < 0)
            {
                if (errno == EAGAIN || errno == EWOULDBLOCK)
                    break;
                if (errno == EINTR)
                    continue;
                logMessage(Warning, "accept failed, errno=%d, err=%s", errno, strerror(errno));
                break;
            }

            // 新连接同样设为非阻塞,避免后续读写阻塞事件循环。
            Sock::SetNonBlock(sockfd);

            logMessage(Info, "Accepted connection %s:%u, fd=%d", clientip.c_str(), clientport, sockfd);
            epoller_.AddEvent(sockfd, EPOLLIN); // 添加客户端套接字到epoll
        }
    }

    // 处理可读事件:把当前能读到的数据尽量读出来;读到对端关闭/发生错误则清理连接。
    void HandleClientData(int fd)
    {
        char buffer[1024];
        ssize_t n = read(fd, buffer, sizeof(buffer) - 1);
        if (n > 0)
        {
            buffer[n] = 0;
            logMessage(Info, "Received from fd %d: %s", fd, buffer);
        }
        else if (n == 0)
        {
            logMessage(Info, "Client fd %d disconnected", fd);
            CloseConnection(fd);
        }
        else
        {
            logMessage(Warning, "Read error on fd %d, errno=%d, err=%s", fd, errno, strerror(errno));
            CloseConnection(fd);
        }
    }

    // 事件分发:listen fd 与 client fd 的处理逻辑不同,这里做一个集中分发。
    void Dispacher(int n)
    {
        for (int i = 0; i < n; ++i)
        {
            int fd = revs_[i].data.fd;         // 获取就绪事件的文件描述符
            uint32_t events = revs_[i].events; // 获取就绪事件的类型

            // 错误/挂断:优先处理,避免在异常 fd 上继续读写。
            if (events & (EPOLLERR | EPOLLHUP))
            {
                if (fd != listensock_)
                    CloseConnection(fd);
                continue;
            }

            if (events & EPOLLIN)
            {
                // 监听套接字就绪:新连接
                if (fd == listensock_)
                {
                    Accept();
                }
                else // 客户端套接字就绪:数据到达
                {
                    HandleClientData(fd);
                }
            }
        }
    }
};

Epoll.hpp

cpp 复制代码
#pragma once
#include <iostream>
#include <sys/epoll.h>
#include <unistd.h>
#include <string>
#include <cstring>
#include <cerrno>
#include "Log.hpp"
#include "Err.hpp"

static const int defaultepfd = -1;
static const int gsize = 128;

class Epoller
{
public:
    Epoller()
        : epfd_(defaultepfd)
    {
    }

    void Create()
    {
        epfd_ = epoll_create(gsize);
        if (epfd_ < 0)
        {
            logMessage(Fatal, "epoll_create error, code: %d, errstring: %s", errno, strerror(errno));
            exit(OPEN_ERR);
        }
    }

    // Add/Del/Mod:把"我关心什么事件"同步到内核(epoll 实例)里。
    // 添加事件
    bool AddEvent(int sockfd, uint32_t events)
    {
        struct epoll_event ev;
        ev.events = events;
        ev.data.fd = sockfd;
        int n = epoll_ctl(epfd_, EPOLL_CTL_ADD, sockfd, &ev);
        if (n < 0)
        {
            logMessage(Error, "epoll_ctl(ADD) error, fd=%d, events=0x%x, code=%d, errstring=%s",
                       sockfd, events, errno, strerror(errno));
            return false;
        }
        return true;
    }

    // 删除事件
    bool DelEvent(int sockfd)
    {
        if (epoll_ctl(epfd_, EPOLL_CTL_DEL, sockfd, nullptr) != 0)
        {
            logMessage(Warning, "epoll_ctl(DEL) failed, fd=%d, errno=%d, err=%s", sockfd, errno, strerror(errno));
            return false;
        }
        return true;
    }
    // 修改事件
    bool ModEvent(int sockfd, uint32_t events)
    {
        struct epoll_event ev;
        ev.events = events;
        ev.data.fd = sockfd; // 修改事件时需要重新设置fd
        if (epoll_ctl(epfd_, EPOLL_CTL_MOD, sockfd, &ev) != 0)
        {
            logMessage(Warning, "epoll_ctl(MOD) failed, fd=%d, events=0x%x, errno=%d, err=%s",
                       sockfd, events, errno, strerror(errno));
            return false;
        }
        return true;
    }
    // 等待事件就绪
    int Wait(struct epoll_event *revs, int num, int timeout)
    {
        return epoll_wait(epfd_, revs, num, timeout);
    }
    // 获取epoll文件描述符
    int epFd() const
    {
        return epfd_;
    }
    // 关闭epoll文件描述符
    void Close()
    {
        if (epfd_ != defaultepfd)
            close(epfd_);
        epfd_ = defaultepfd;
    }

    ~Epoller()
    {
        // RAII:Epoller 生命周期结束时自动释放内核资源(避免忘记 close)。
        Close();
    }

private:
    int epfd_;
};

Sock.hpp

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <cassert>
#include <unistd.h>
#include <memory>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <ctype.h>
#include "Log.hpp"
#include "Err.hpp"

class Sock
{
private:
    // 监听队列长度(用于 listen(backlog))
    const static int gbacklog = 10;

public:
    Sock() {}
    ~Sock() {}

    // 将 fd 设置为非阻塞。
    // 思路:事件循环线程不应该被某个 fd 的 I/O 阻塞;非阻塞 + epoll 才能让"有事就处理、没事就睡眠"成立。
    static bool SetNonBlock(int fd)
    {
        int flags = fcntl(fd, F_GETFL, 0);
        if (flags < 0)
        {
            logMessage(Error, "fcntl(F_GETFL) failed, fd=%d, errno=%d, err=%s", fd, errno, strerror(errno));
            return false;
        }
        if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) < 0)
        {
            logMessage(Error, "fcntl(F_SETFL) failed, fd=%d, errno=%d, err=%s", fd, errno, strerror(errno));
            return false;
        }
        return true;
    }

    // 创建监听/通信套接字。
    // 思路:把"更安全的默认项"尽量一次性设置好(CLOEXEC、端口复用),避免隐藏 bug。
    static int Socket() // 创建套接字
    {
        int listensock = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);
        if (listensock < 0)
        {
            logMessage(Fatal, "socket failed, errno=%d, err=%s", errno, strerror(errno));
            exit(SOCKET_ERR);
        }

        int opt = 1;
        setsockopt(listensock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));//打开端口复用
        return listensock;
    }

    // 绑定套接字到指定的 IP 地址和端口号。
    // 说明:ip 默认 0.0.0.0 代表监听所有网卡地址。
    static void Bind(int sock, uint16_t port, std::string ip = "0.0.0.0")
    {
        struct sockaddr_in local;
        memset(&local, 0, sizeof local);
        local.sin_family = AF_INET;
        local.sin_port = htons(port);
        inet_pton(AF_INET, ip.c_str(), &local.sin_addr);
        if (bind(sock, (struct sockaddr *)&local, sizeof(local)) < 0)
        {
            logMessage(Fatal, "bind failed, ip=%s, port=%u, errno=%d, err=%s", ip.c_str(), port, errno, strerror(errno));
            exit(BIND_ERR);
        }
    }

    // 监听套接字,进入被动监听状态(开始接收连接请求)。
    static void Listen(int sock)
    {
        if (listen(sock, gbacklog) < 0)
        {
            logMessage(Fatal, "listen failed, errno=%d, err=%s", errno, strerror(errno));
            exit(LISTEN_ERR);
        }
    }

    // 接受客户端连接请求
    static int Accept(int listensock, std::string *ip, uint16_t *port)
    {
        struct sockaddr_in src;
        socklen_t len = sizeof(src);
        int servicessock = accept(listensock, (struct sockaddr *)&src, &len);
        if (servicessock < 0)
        {
            return -1;
        }
        if (port)//获取客户端的端口号
            *port = ntohs(src.sin_port);
        if (ip)//获取客户端的IP地址
            *ip = inet_ntoa(src.sin_addr);
        return servicessock;
    }
};

Log.hpp

cpp 复制代码
#pragma once
#include <iostream>
#include <string>
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <cstdarg>
#include <time.h>

// 日志系统是有等级的

enum 
{
    Debug=0,
    Info,
    Warning,
    Error,
    Fatal,
    Uknown
};

static std::string toLevelString(int level)
{
    switch (level)
    {
    case Debug:
        return "Debug";
    case Info:
        return "Info";
    case Warning:
        return "Warning";
    case Error:
        return "Error";
    case Fatal:
        return "Fatal";
    default:
        return "Uknown";
    }
}

static std::string getTime()
{
    time_t curr=time(nullptr);
    struct tm*tmp=localtime(&curr);
    char buffer[128];
    snprintf(buffer,sizeof(buffer),"%d-%d-%d %d:%d:%d",tmp->tm_year+1900,tmp->tm_mon+1,tmp->tm_mday,
            tmp->tm_hour,tmp->tm_min,tmp->tm_sec);
    return buffer;
}

// 日志格式: 日志等级 时间 pid 消息体
// logLeft:  日志等级 时间 pid        logRight:  消息体
// logMessage(Debug, "hello: %d, %s",12,s.c_str())   Debug, hello: 12, world

void logMessage(int level, const char*format,...)
{
    char logLeft[1024];
    std::string level_string=toLevelString(level);
    std::string curr_time=getTime();
    snprintf(logLeft,sizeof(logLeft),"[%s] [%s] [%d] ",level_string.c_str(),curr_time.c_str(),getpid());

    char logRight[1024];
    va_list p;
    va_start(p,format);
    vsnprintf(logRight,sizeof(logRight),format,p);
    va_end(p);

    printf("%s%s\n",logLeft,logRight);
}

Err.hpp

cpp 复制代码
#pragma once
enum
{
    USAGE_ERR=1,
    SOCKET_ERR,
    BIND_ERR,
    LISTEN_ERR,
    CONNECT_ERR,
    SET_ERR,
    OPEN_ERR
};
运行结果

六、Reactor 模式

1.Reactor 模式定义

Reactor 反应器模式,也被称为分发者模式或通知者模式,是一种将就绪事件派发给对应服务处理程序的事件设计模式。

2.Reactor 模式的角色构成

3.Reactor 模式的工作流程

初始分发器(如事件循环框架)的工作流程如下:

  1. 注册事件处理器:具体事件处理器向初始分发器注册,声明自己关注哪些事件,并指定这些事件所关联的 Handle(如文件描述符或套接字)。

  2. 传递 Handle:初始分发器要求每个事件处理器提供其内部的 Handle,该 Handle 用于操作系统层面唯一标识该处理器所关心的 I/O 对象。

  3. 启动事件循环 :所有处理器注册完毕后,初始分发器启动事件循环。它将收集到的所有 Handle 合并,并交给底层的同步事件分离器(如 selectpollepoll),由后者统一监听这些 Handle 上的事件。

  4. 等待事件就绪:当某个 Handle 变为就绪状态(如可读、可写)时,同步事件分离器立即通知初始分发器。

  5. 查找对应处理器:初始分发器以便利的 Handle 为键,在内部映射中快速找到与该 Handle 关联的事件处理器。

  6. 分发回调:最后,初始分发器调用该事件处理器中预先注册的回调方法,完成对事件的响应处理。

4.epoll ET 服务器(reactor模式)

4.1 设计思路

基于 epoll 的 ET 模式,实现一个典型的 Reactor 模式服务器:底层统一管理所有 fd 的事件(连接、读、写、异常),上层通过回调处理业务逻辑。

4.2 epoll ET 服务器的工作流程

读事件

  • 监听套接字:
    • 当监听套接字读事件就绪时,TcpServer::handleNewConnection() 被调用:一次性取完所有就绪 fd ,为每个新 fd 创建 Connection ,注册监视事件并设置ET模式。
  • 普通连接套接字:
    • 当普通 fd 上读事件就绪时,TcpServer::handleRead(conn) 被调用:内部循环调用 read,把本轮所有数据追加到对应fd的接收缓冲区中,直到对端关闭或数据全部读完。
    • 读取完毕后:
    • 若接收缓冲区非空,则进行业务处理(如打印、回显),然后清空对应 fd 的接收缓冲区。
    • 若业务处理后有数据待发(对应fd的发送缓冲区非空),则打开对应 fd 的写事件关注。

写事件

  • 当 fd 上写事件就绪时,TcpServer::handleWrite(conn) 被调用:循环调用 write,尽量把对应 fd 的发送缓冲区中的数据拷贝到内核发送缓冲区:
    • 写成功则从发送缓冲区中删掉掉已发送部分;
    • 若发送缓冲区已全部写空,则关闭写事件关注,只保留读事件;
    • 若因 EAGAIN/EWOULDBLOCK 退出写循环,说明内核发送缓冲区暂时写满,保留写事件关注,等下一次可写再继续发送。

异常事件

  • 无论是 epoll 返回的 EPOLLERR/EPOLLHUP,还是读写中出现致命错误(非 EAGAIN/EWOULDBLOCK/EINTR),都会调用:
    • TcpServer::handleError(conn) 输出错误信息;
    • TcpServer::handleClose(conn)
      • reactor_->DelEvent(fd) 从 epoll 模型中移除;
      • connections_ 中删除该连接;
      • Connection 析构时关闭底层 fd 并打印关闭日志。

4.3 Reactor 模式五个角色的对应关系

句柄(Handle)

所有的文件描述符 fd:

  • 监听 fd:listenSocket_->fd()
  • 普通连接 fd:Connection::fd()

同步事件分离器(Synchronous Event Demultiplexer)

Epoller

  • AddEvent/ModEvent/DelEvent 封装 epoll_ctl
  • Wait 封装 epoll_wait,阻塞等待多个 fd 上的 I/O 事件发生。

初始分发器(Initiation Dispatcher)

TcpServer::start() 中等待事件就绪并处理每个就绪事件:

  • 若有 EPOLLERR/EPOLLHUP -> 调用 handleErrorhandleClose
  • 若有 EPOLLOUT -> 认为是写事件,调用 handleWrite(connections_[fd])
  • 若有 EPOLLIN/EPOLLPRI -> 认为是读事件,调用 handleRead(connections_[fd])
  • fd == listenSocket_->fd() -> 认为是连接事件,调用 handleNewConnection()

这样,TcpServer 负责"等待并根据 fd 和事件类型分发给具体处理函数",完整扮演了 Dispatcher 角色。

事件处理器(Event Handler)

TcpServer 中的一组成员函数:

  • handleNewConnection():处理连接到来;
  • handleRead(Connection::Ptr):处理读事件;
  • handleWrite(Connection::Ptr):处理写事件;
  • handleError(Connection::Ptr)handleClose(Connection::Ptr):处理错误和关闭。

具体事件处理器(Concrete Event Handler)

上述每个处理函数内部给出了具体的实现逻辑:

  • 接受连接、设置非阻塞、注册事件;
  • 读入数据、触发业务回调、决定是否打开写事件;
  • 发送数据、根据发送情况开启或关闭写事件;
  • 删除 epoll 事件、移除连接、关闭 fd 等。

4.4 Connection 结构说明

Connection 类的核心成员

  • int sockfd_:代表一个客户端连接的 fd。
  • std::string inBuffer_:接收缓冲区,用于累积当前 fd 未处理完的输入数据。
  • std::string outBuffer_:发送缓冲区,用于缓存待发送数据。

读缓冲区(_inBuffer)

Connection::Read() 中循环 read

  1. 每次读取到的数据追加到 inBuffer_
  2. 遇到 EAGAIN/EWOULDBLOCK 退出循环,说明本轮数据已全部读完;
  3. TcpServer::handleRead() 中在读完后检查 inBuffer_ 是否非空,若非空则交给 messageCallback_ 处理。
  4. 这样可以自然应对 TCP 中的半包/粘包:可以在 messageCallback_ 中实现协议切包逻辑(按分隔符或长度字段拆分 inBuffer_),切出完整报文进行处理,剩余部分继续保留在 inBuffer_ 中等待下次数据到来。

写缓冲区(_outBuffer)

应用层通过 Connection::Append(const std::string &data) 将待发送数据追加到 outBuffer_

Connection::Write() 循环 write

  • 写成功则从 outBuffer_ 头部删除已发送的字节;
  • outBuffer_ 清空,则通知服务器可以关闭写事件关注,只保留读事件;
  • 若因 EAGAIN/EWOULDBLOCK 返回,说明内核发送缓冲区暂时写满了,保留写事件,等下一次可写时继续发送。

4.5 TcpServer 结构说明

管理所有连接

std::unordered_map<int, Connection::Ptr> connections_

  • key:fd
  • value:对应的 Connection 智能指针

通过connections_来管理连接:

  • 当新连接到来时,创建 Connection 并插入
  • 当连接关闭或出错时,删除 Connection

注册与修改事件

在构造函数中:

  • 创建监听 socket,绑定端口、监听;
  • 将监听 fd 设为非阻塞;
  • 通过 epoller_->AddEvent(listenSocket_->fd(), EPOLLIN | EPOLLET) 注册监听套接字的读事件。

在读写处理函数中:

  • 读到有待发送数据时,通过 epoller_->ModEvent(fd, EPOLLIN | EPOLLOUT | EPOLLET) 打开发送事件
  • 发送完所有数据后,通过 epoller_->ModEvent(fd, EPOLLIN | EPOLLET) 关闭写事件,仅保留读事件

业务回调入口

  • setMessageCallback(MessageCallback cb) 用于注册高层业务处理函数。
  • handleRead 中调用该回调,将 Connection 对象和 读缓冲区inBuffer_ 传给上层,由业务层决定如何解析数据、如何生成响应,并写入 写缓冲区outBuffer_

4.6 运行流程总结

  1. main 中创建 TcpServer,注册业务回调(例如打印 + 回显),然后调用 server.start()
  2. TcpServer 构造时完成:创建监听 sock、绑定/监听、设为非阻塞并把注册监听 fd 。
  3. 调用 TcpServer::start(),进入统一事件循环:
    • 使用 epoll_wait 阻塞等待事件就绪;
    • 每有事件就绪,通过回调交给 TcpServer 分发;
    • TcpServer 根据 fd 与事件类型调用 handleNewConnection / handleRead / handleWrite / handleError / handleClose
    • 这些处理函数内部操作 ConnectioninBuffer_/outBuffer_、维护 connections_、并修改 epoll 关注的事件集合。
  4. 上层业务只需实现一个简洁的回调函数,通过 Connection::Append 写入响应数据,即可在整个 Reactor 框架下完成高并发、非阻塞的 I/O 处理。

4.7 ET 模式 Reactor 的关键技术点

  1. **非阻塞 I/O 配合:**ET 模式必须与非阻塞 I/O 结合使用,确保能一次性读取 / 写入所有可用数据
  2. **事件处理完整性:**在 ET 模式下,每次事件触发都需要循环处理直到操作返回 EAGAIN/EWOULDBLOCK,告知程序 "当前没有可用数据" 或 "操作暂时无法完成"
  3. **连接管理:**使用哈希表存储所有活跃连接,便于根据文件描述符快速查找对应的连接对象
  4. **缓冲区设计:**每个连接维护独立的输入 / 输出缓冲区,解决 TCP 粘包和发送阻塞问题
  5. **边缘触发的新连接处理:**对于监听套接字,需要在 ET 模式下一次性接受所有待处理的新连接
  6. 读事件和写事件的处理逻辑的区别:
  • 读事件 :通常需要持续关注(即始终注册 EPOLLIN 事件)。当可读事件触发时,应用程序必须循环调用 read 直到返回 EAGAIN,以确保一次性读取完所有到达的数据,避免漏读。
  • 写事件 :则采用按需关注策略。仅在发送缓冲区中有待发送数据时,才临时注册 EPOLLOUT 事件;当数据发送完毕或缓冲区满导致无法写入时,应立即取消写事件,防止因缓冲区一直可写而频繁触发无意义的空轮询,浪费 CPU 资源。

4.8 代码

  • main.cc: TCP服务器的主程序,启动服务器并设置消息处理回调。
  • TcpServer.hpp: TCP服务器类,管理监听套接字、连接和事件循环。
  • Epoll.hpp: Epoll事件管理类,用于添加、修改、删除和等待I/O事件。
  • Socket.hpp: 套接字类,处理套接字创建、绑定、监听和接受连接。
  • Connection.hpp: 连接类,管理单个客户端连接的读写缓冲区和数据传输。
  • nocopy.hpp: 禁止拷贝的基类,用于防止对象被拷贝。
main.cc
cpp 复制代码
#include "TcpServer.hpp"
#include <iostream>
#include <string>

int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        std::cerr << "Usage: " << argv[0] << " <port>" << std::endl;
        return 1;
    }
    
    // 创建服务器对象,监听指定端口
    TcpServer server(std::stoi(argv[1]));

    // 设置消息处理回调
    server.setMessageCallback([](Connection::Ptr conn, std::string &data)
                              {
        std::cout << "Received from " << conn->peerAddr() << ": " << data << std::endl;
        // 简单回显
        std::string response = "Server echo: " + data;
        conn->Append(response); });

    // 启动服务器
    server.start();

    return 0;
}
TcpServer.hpp
cpp 复制代码
#pragma once

#include <unordered_map>
#include <memory>
#include <functional>
#include <iostream>
#include <cerrno>
#include "Socket.hpp"
#include "Epoll.hpp"
#include "Connection.hpp"

class TcpServer : public nocopy
{
public:
    using ConnectionCallback = std::function<void(Connection::Ptr)>;
    using MessageCallback = std::function<void(Connection::Ptr, std::string &)>;

    // 构造函数,初始化服务器
    TcpServer(uint16_t port) : port_(port), epoller_(new Epoller()),
                               listenSocket_(new Socket())
    {
        listenSocket_->Bind(port);                                  // 绑定端口
        listenSocket_->Listen();                                    // 开始监听
        listenSocket_->SetNonBlock(listenSocket_->fd());            // 设置监听套接字为非阻塞
        epoller_->AddEvent(listenSocket_->fd(), EPOLLIN | EPOLLET); // 注册监听套接字的读事件,使用ET模式
        std::cout << "TcpServer started on port " << port << std::endl;
    }

    ~TcpServer() = default;

    // 启动服务器事件循环
    void start()
    {
        while (true)
        {

            epoller_->resizeEvents(1024);      // 调整事件数组大小,避免过小导致事件丢失
            int nready = epoller_->Wait(1000); // 等待事件,设置超时时间为1000ms

            for (int i = 0; i < nready; ++i)
            {
                epoll_event &ev = epoller_->getEvent(i);
                int fd = ev.data.fd;

                if (fd == listenSocket_->fd())
                {
                    // 处理新连接
                    handleNewConnection();
                }
                else if (ev.events & (EPOLLIN | EPOLLPRI))
                {
                    // 处理读事件
                    handleRead(connections_[fd]);
                }
                else if (ev.events & EPOLLOUT)
                {
                    // 处理写事件
                    handleWrite(connections_[fd]);
                }
                else if (ev.events & (EPOLLERR | EPOLLHUP))
                {
                    // 处理错误或断开事件
                    handleError(connections_[fd]);
                    handleClose(connections_[fd]);
                }
            }
        }
    }

    // 设置回调函数
    void setMessageCallback(MessageCallback cb) { messageCallback_ = cb; }

private:
    // 处理新连接
    void handleNewConnection()
    {
        std::string client_ip; // 获取客户端的IP地址
        uint16_t client_port;  // 获取客户端的端口号
        while (true)
        {
            // 接受新连接,获取客户端信息
            int clientfd = listenSocket_->Accept(&client_ip, &client_port);
            if (clientfd < 0)
            {
                // 非阻塞模式下,accept返回-1且errno为EAGAIN或EWOULDBLOCK表示没有更多连接可接受
                if (errno == EAGAIN || errno == EWOULDBLOCK)
                {
                    break;
                }
                perror("accept error");
                return;
            }

            std::cout << "New connection from " << client_ip
                      << ":" << client_port << std::endl;

            listenSocket_->SetNonBlock(clientfd);                                       // 设置新连接为非阻塞
            auto conn = std::make_shared<Connection>(clientfd, client_ip, client_port); // 创建连接对象
            connections_[conn->fd()] = conn;                                            // 将连接对象添加到连接管理器中
            epoller_->AddEvent(conn->fd(), EPOLLIN | EPOLLET);                          // 注册连接的读事件,使用ET模式

            std::cout << "Connection established: " << conn->peerAddr() << std::endl;
        }
    }

    // 处理读事件
    void handleRead(Connection::Ptr conn)
    {
        ssize_t n = conn->Read(); // 读取数据到连接对象的输入缓冲区

        // 调用消息回调函数,处理接收到的数据
        if (messageCallback_ && !conn->inBuffer().empty())
        {
            messageCallback_(conn, conn->inBuffer());
        }
        conn->clearInBuffer();

        // 如果有数据需要发送,注册写事件
        if (!conn->outBuffer().empty())
        {
            epoller_->ModEvent(conn->fd(), EPOLLIN | EPOLLOUT | EPOLLET);
        }

        // 处理连接关闭
        if (n == 0)
        {
            handleClose(conn);
        }
        // 处理读取错误
        else if (n < 0)
        {
            // 非阻塞模式下,读返回-1且errno为EAGAIN或EWOULDBLOCK表示数据已读完
            // 其他错误则表示连接异常,需要关闭连接
            if (errno != EAGAIN && errno != EWOULDBLOCK)
            {
                handleError(conn);
                handleClose(conn);
            }
        }
    }

    // 处理写事件
    void handleWrite(Connection::Ptr conn)
    {
        ssize_t n = conn->Write(); // 将连接对象的输出缓冲区数据发送到客户端
        // 如果输出缓冲区已空,说明数据发送完成,可以取消写事件监听
        if (conn->outBuffer().empty())
        {
            epoller_->ModEvent(conn->fd(), EPOLLIN | EPOLLET);
        }
        // 处理连接关闭
        if (n == 0)
        {
            handleClose(conn);
        }
        // 处理写入错误
        else if (n < 0)
        {
            // 非阻塞模式下,写返回-1且errno为EAGAIN或EWOULDBLOCK表示发送缓冲区已满,稍后再试
            // 其他错误则表示连接异常,需要关闭连接
            if (errno != EAGAIN && errno != EWOULDBLOCK)
            {
                handleError(conn);
                handleClose(conn);
            }
        }
    }

    // 处理连接关闭
    void handleClose(Connection::Ptr conn)
    {
        std::cout << "Connection closed: " << conn->peerAddr() << std::endl;
        epoller_->DelEvent(conn->fd());
        connections_.erase(conn->fd());
    }

    // 处理错误
    void handleError(Connection::Ptr conn)
    {
        std::cerr << "Error on connection: " << conn->peerAddr() << std::endl;
    }

private:
    uint16_t port_;                                        // 服务器监听的端口
    std::unique_ptr<Epoller> epoller_;                     // epoll对象,管理事件
    std::unique_ptr<Socket> listenSocket_;                 // 监听套接字对象
    std::unordered_map<int, Connection::Ptr> connections_; // 管理所有连接

    MessageCallback messageCallback_; // 消息到达回调
};
Epoll.hpp
cpp 复制代码
#pragma once

#include <vector>
#include <sys/epoll.h>
#include <unistd.h>
#include <cstring>
#include <iostream>
#include "nocopy.hpp"

class Epoller : public nocopy
{
public:
    Epoller() : epollfd_(epoll_create(1))
    {
        if (epollfd_ < 0)
        {
            perror("epoll create error");
            exit(1);
        }
    }

    ~Epoller()
    {
        close(epollfd_);
    }

    // 添加事件
    bool AddEvent(int fd, uint32_t events)
    {
        struct epoll_event ev;
        memset(&ev, 0, sizeof(ev));
        ev.data.fd = fd;
        ev.events = events; // 支持ET模式: events | EPOLLET

        return epoll_ctl(epollfd_, EPOLL_CTL_ADD, fd, &ev) == 0;
    }

    // 修改事件
    bool ModEvent(int fd, uint32_t events)
    {
        struct epoll_event ev;
        memset(&ev, 0, sizeof(ev));
        ev.data.fd = fd;
        ev.events = events;

        return epoll_ctl(epollfd_, EPOLL_CTL_MOD, fd, &ev) == 0;
    }

    // 删除事件
    bool DelEvent(int fd)
    {
        return epoll_ctl(epollfd_, EPOLL_CTL_DEL, fd, nullptr) == 0;
    }

    // 等待事件
    int Wait(int timeout = -1)
    {
        return epoll_wait(epollfd_, events_.data(), events_.size(), timeout);
    }

    int Fd()
    {
        return epollfd_;
    }

    struct epoll_event &getEvent(size_t i)
    {
        return events_[i];
    }

    // 调整事件数组大小
    void resizeEvents(size_t size)
    {
        if (events_.size() < size)
        {
            events_.resize(size);
        }
    }

private:
    int epollfd_;                            // epoll文件描述符
    std::vector<struct epoll_event> events_; // 事件数组,存储就绪事件
};
Socket.hpp
cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <unistd.h>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <fcntl.h>
#include "nocopy.hpp"

enum
{
    SocketErr = 2,
    BindErr,
    ListenErr
};

const int backlog = 10; // 监听队列长度

class Socket : public nocopy
{
public:
    // 构造函数,创建套接字并设置选项
    Socket()
    {
        listensock_ = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0); // 创建非阻塞套接字
        if (listensock_ < 0)
        {
            perror("socket create error");
            exit(SocketErr);
        }

        int opt = 1;
        setsockopt(listensock_, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt)); // 打开端口复用
    }
    ~Socket()
    {
        if (listensock_ != -1)
        {
            close(listensock_);
        }
    }
    
    // 绑定端口并开始监听
    void Bind(uint16_t port)
    {
        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(port);
        local.sin_addr.s_addr = INADDR_ANY;

        if (bind(listensock_, (struct sockaddr *)&local, sizeof(local)) < 0)
        {
            perror("bind error");
            exit(BindErr);
        }
    }

    // 开始监听
    void Listen()
    {
        if (listen(listensock_, backlog) < 0)
        {
            perror("listen error");
            exit(ListenErr);
        }
    }

    // 接受新连接,获取客户端信息
    int Accept(std::string *ip, uint16_t *port)
    {

        struct sockaddr_in client;
        socklen_t len = sizeof(client);
        int servicessock = accept(listensock_, (struct sockaddr *)&client, &len);
        if (servicessock < 0)
        {
            return -1;
        }
        if (port) // 获取客户端的端口号
            *port = ntohs(client.sin_port);
        if (ip) // 获取客户端的IP地址
            *ip = inet_ntoa(client.sin_addr);
        return servicessock;
    }

    int fd() const { return listensock_; }

    // 设置非阻塞
    void SetNonBlock(int fd)
    {
        int flag = fcntl(fd, F_GETFL, 0);
        fcntl(fd, F_SETFL, flag | O_NONBLOCK);
    }

private:
    int listensock_; // 监听套接字文件描述符
};
Connection.hpp
cpp 复制代码
#pragma once

#include <memory>
#include <string>
#include <sys/socket.h>
#include <netinet/in.h>
#include <functional>
#include <cstring>
#include <unistd.h>
#include <iostream>
#include <arpa/inet.h>
#include <cerrno>
#include "nocopy.hpp"

class Connection : public std::enable_shared_from_this<Connection>, public nocopy
{
public:
    typedef std::shared_ptr<Connection> Ptr;               // 定义智能指针类型
    using Callback = std::function<void(Connection::Ptr)>; // 定义回调函数类型

    // 构造函数,初始化连接对象
    Connection(int sockfd, std::string ip, uint16_t port)
        : sockfd_(sockfd), peerAddr_(ip + ":" + std::to_string(port))
    {
        std::cout << "New connection from " << peerAddr_ << std::endl;
    }

    ~Connection()
    {
        close(sockfd_);
        std::cout << "Connection closed: " << peerAddr_ << std::endl;
    }
    // 获取连接的文件描述符
    int fd() const { return sockfd_; }

    // 获取客户端的IP地址和端口号
    const std::string &peerAddr() const { return peerAddr_; }

    // 读取数据
    ssize_t Read()
    {
        char buf[1024];
        ssize_t n;
        while (true)
        {
            n = read(sockfd_, buf, sizeof(buf));

            if (n > 0) // 数据读取成功,追加到输入缓冲区
            {
                inBuffer_.append(buf, n);
            }
            else if (n == 0) // 对端关闭:可能已读到部分数据,交给上层处理后再关闭
            {
                break;
            }

            else if (n == -1)
            {
                if (errno == EINTR) // 被信号中断,继续读取
                {
                    continue;
                }
                else if (errno == EAGAIN || errno == EWOULDBLOCK) // 非阻塞模式下,读返回-1且errno为EAGAIN或EWOULDBLOCK表示数据已读完
                {
                    break;
                }
                else
                { // 其他错误则表示连接异常,需要关闭连接
                    break;
                }
            }
        }
        return n;
    }

    // 发送数据
    ssize_t Write()
    {
        ssize_t n;
        while (true)
        {
            n = write(sockfd_, outBuffer_.data(), outBuffer_.size()); // 尝试发送输出缓冲区中的数据
            if (n > 0)                                                // 数据发送成功,从输出缓冲区删除已发送的数据
            {
                outBuffer_.erase(0, n);
                if (outBuffer_.empty()) // 输出缓冲区已空,数据发送完成,可以取消写事件监听
                {
                    break;
                }
                else // 继续发送剩余数据
                {
                    continue;
                }
            }
            else if (n == 0) // 对端关闭:可能已发送部分数据,交给上层处理后再关闭
            {
                break;
            }
            else if (n == -1)
            {
                if (errno == EINTR) // 被信号中断,继续发送
                {
                    continue;
                }
                else if (errno == EAGAIN || errno == EWOULDBLOCK) // 非阻塞模式下,写返回-1且errno为EAGAIN或EWOULDBLOCK表示发送缓冲区已满,稍后再试
                {
                    break;
                }
                else // 其他错误则表示连接异常,需要关闭连接
                {
                    break;
                }
            }
        }
        return n;
    }

    // 缓冲区操作
    std::string &inBuffer() { return inBuffer_; }
    std::string &outBuffer() { return outBuffer_; }

    void Append(const std::string &data) { outBuffer_.append(data); }
    void clearInBuffer() { inBuffer_.clear(); }
    void clearOutBuffer() { outBuffer_.clear(); }

private:
    int sockfd_;            // 连接的文件描述符
    std::string peerAddr_;  // 客户端地址字符串
    std::string inBuffer_;  // 输入缓冲区
    std::string outBuffer_; // 输出缓冲区
};
nocopy.hpp
cpp 复制代码
#pragma once  
 
class nocopy  
{  
public:  
    nocopy() = default;   
    nocopy(const nocopy&) = delete;   
    nocopy& operator=(const nocopy&) = delete;   
};

4.9 运行结果

相关推荐
liux35282 小时前
使用DataX实现MySQL到MySQL的批量表同步(灵活配置方案)
数据库·mysql
程序员夏末2 小时前
【JchatMind智能体 | 第二天】为何选 PostgreSQL + pgvector 而非 MySQL?
数据库·mysql·postgresql·ai编程·ai agent
中科三方2 小时前
实操指南:网站更换服务器IP后,域名解析如何修改和验证?
运维·服务器·tcp/ip
数据知道2 小时前
详解MongoDB标签感知分片:基于区域的数据分布控制与优化策略
数据库·mongodb
xcLeigh2 小时前
复杂 SQL 过滤时机过晚?金仓基于代价的连接条件下推方案来了
java·数据库·sql语句·union·金仓·kingbasees
wanhengidc2 小时前
云手机有哪些辅助功能?
运维·服务器·网络·游戏·智能手机·生活
wwwwanggy2 小时前
【MySQL】表空间丢失处理(Tablespace is missing for table 错误处理)
数据库·mysql
Mr. Cao code2 小时前
快速部署MySQL 8.0:二进制安装全攻略
运维·数据库·mysql
herinspace2 小时前
管家婆iShop如何调整商品成本?
服务器·数据库·学习·电脑·excel