【Linux高级全栈开发】2.1高性能网络-网络编程——2.1.1 网络IO与IO多路复用——select/poll/epoll

【Linux高级全栈开发】2.1高性能网络-网络编程

高性能网络学习目录

基础内容(两周完成):
  • 2.1网络编程

    • 2.1.1多路复用select/poll/epoll
    • 2.1.2事件驱动reactor
    • 2.1.3http服务器的实现
  • 2.2网络原理

    • 百万并发
    • PosixAPI
    • QUIC
  • 2.3协程库

    • NtyCo的实现
  • 2.4dpdk

    • 用户态协议栈的实现
  • 2.5高性能异步io机制

项目内容(两周完成):
  • 9.1 KV存储项目
  • 9.2 RPC项目
  • 9.3 DPDK项目

2.1.1 网络IO与IO多路复用------select/poll/epoll

1 基础知识
1.1 什么是网络IO

网络 I/O(Input/Output) 是指计算机通过网络与其他设备进行数据交换的过程。在编程中,网络 I/O 主要涉及:

  1. 建立连接 :通过socket()bind()listen()accept()等系统调用创建 TCP/UDP 连接。
  2. 数据传输 :使用send()recv()等函数发送和接收数据。
  3. 连接管理:处理连接建立、断开、超时等状态。

网络 I/O 的特点是耗时较长(相比 CPU 操作),因为数据需要在网络中传输,可能受到带宽、延迟等因素影响。

1.2 「一请求一线程」模型的缺点
  • 线程资源开销大:频繁创建 / 销毁线程会消耗大量 CPU 时间

  • 可扩展性差线程数量受限:操作系统对线程数量有限制(例如 Linux 默认最大线程数约为 32,000),无法支持海量连接(如 C10K 问题),在高并发(如 10 万 + 连接)下,线程开销会成为瓶颈

  • 某个线程崩溃可能导致整个进程退出,影响其他连接。

因此,「一请求一线程」模型存在严重的性能瓶颈,仅适合连接数少、I/O 耗时短的场景,性能不适合高并发场景,改进方式是:

  • 使用线程池(预先创建固定数量的线程,避免频繁创建 / 销毁线程);
  • 基于事件驱动的模型 (使用select()poll()epoll()(Linux)或kqueue()(BSD/macOS)等机制,让单个线程处理多个连接);
  • 异步 I/O(Asynchronous I/O) (使用aio_read()aio_write()等接口,让内核完成 I/O 后通知应用程序);
1.3 socket与文件描述符的关联
  • 在 Unix 及类 Unix 系统(如 Linux、macOS 等)中,"一切皆文件" 是一个重要的概念,这意味着包括网络套接字、设备、管道等在内的多种资源都可以像普通文件一样进行操作,而文件描述符(fd)就是用于标识这些资源的一个非负整数。

  • 文件描述符是一个非负整数,它是操作系统内核为了标识进程打开的文件或其他资源而分配的一个索引值。例如,当你打开一个普通文件时,open 函数会返回一个文件描述符,后续对该文件的读写操作(如 readwrite 等)都需要使用这个文件描述符作为参数。

  • 在网络编程中,使用 socket 函数创建一个套接字时,操作系统会在内核中为这个套接字分配相应的数据结构,并返回一个文件描述符。这个文件描述符可以用于后续的操作,如 connect(客户端连接服务器)、send(发送数据)、recv(接收数据)等,就如同对普通文件进行读写操作一样。

  • 综上所述,在 Unix 及类 Unix 系统中,当创建 TCP 连接时,操作系统通过分配文件描述符来标识这个连接,进程通过这个文件描述符来对 TCP 连接进行各种操作,从而实现网络通信。可以使用 ls /dev/fd查看已经使用的文件描述符

1.3+ 如何修改文件描述符相关参数
  • 调整进程级 FD 限制(ulimit

每个进程默认最多可打开 1024 个 FD,可通过以下方式临时修改:

bash 复制代码
# 查看当前限制
ulimit -n
# 临时提高限制(仅对当前 shell 及子进程有效)
ulimit -n 4096
# 永久修改:编辑 /etc/security/limits.conf,添加以下内容
your_username hard nofile 65535
your_username soft nofile 65535
  • 调整系统级 FD 限制(fs.file-max

系统全局最大 FD 数量由 fs.file-max 控制:

bash 复制代码
# 查看当前值
cat /proc/sys/fs/file-max
# 临时修改(重启失效)
sysctl -w fs.file-max=1000000
# 永久修改:编辑 /etc/sysctl.conf,添加或修改
fs.file-max = 1000000
# 使配置生效
sysctl -p
  • 调整网络套接字 TIME_WAIT 参数(可选)

网络套接字(socket)的 TIME_WAIT 状态是 TCP 协议层面的延迟关闭机制,默认持续时间是 2 倍最大段生存时间(MSL) ,通常为 60 秒,但这仅影响套接字资源,不影响 FD 本身的回收。

若需减少网络套接字占用的资源,可调整 TIME_WAIT 相关参数:

bash 复制代码
# 启用 TCP 快速回收(可能影响网络稳定性)
sysctl -w net.ipv4.tcp_tw_recycle=1
# 缩短 TIME_WAIT 超时时间(默认 60 秒)
sysctl -w net.ipv4.tcp_fin_timeout=30
# 允许重用处于 TIME_WAIT 的套接字
sysctl -w net.ipv4.tcp_tw_reuse=1
1.4 多路复用select/poll/epoll

多路复用(Multiplexing)是一种让单个进程同时监视多个文件描述符(如套接字、管道等)的技术,当其中任何一个或多个文件描述符变为可读或可写状态时,进程能够及时处理。

select函数
  • 概念select是最早出现的 I/O 多路复用函数,它允许进程监视多个文件描述符,一旦某个文件描述符就绪(一般是读就绪或者写就绪),能够通知进程进行相应的读写操作。

  • 函数原型

    c 复制代码
    int select(int nfds, fd_set *readfds, fd_set *writefds,
               fd_set *exceptfds, struct timeval *timeout);
    • nfds:需要监视的最大文件描述符值加 1。
    • readfdswritefdsexceptfds:分别是被监视的读、写和异常处理的文件描述符集合。
    • timeout:超时时间,控制select函数的阻塞行为。
  • 工作流程:

    1. 调用select函数前,需要先将要监视的文件描述符添加到对应的集合(readfdswritefds等)中。
    2. 调用select函数后,进程会被阻塞,直到有文件描述符就绪或超时。
    3. select返回后,需要遍历所有可能的文件描述符,检查哪个文件描述符在就绪集合中,然后进行相应的处理。
  • 缺点:

    • 单个进程能够监视的文件描述符的数量存在最大限制,在 Linux 系统上一般为 1024。
    • 每次调用select时都需要将文件描述符集合从用户空间拷贝到内核空间,开销较大。
    • select返回后,需要遍历所有文件描述符来找到就绪的那些,效率较低。
poll函数
  • 概念poll是为了克服select的一些缺点而设计的,它和select类似,也是用于监视多个文件描述符的状态变化。

  • 函数原型

    c 复制代码
    int poll(struct pollfd *fds, nfds_t nfds, int timeout);
    • fds:一个struct pollfd类型的数组,每个元素包含文件描述符、要监视的事件和发生的事件。
    • nfds:数组中元素的个数。
    • timeout:超时时间(毫秒)。
  • 工作流程:

    1. 准备一个struct pollfd数组,每个元素指定要监视的文件描述符和事件。
    2. 调用poll函数,进程会被阻塞直到有事件发生或超时。
    3. poll返回后,遍历pollfd数组,检查每个元素的revents字段,确定哪些事件发生了,然后进行相应处理。
  • 优点:

    • 没有最大文件描述符数量的限制(仅受限于系统资源)。
    • 使用pollfd结构而不是位掩码来表示文件描述符集合,更加直观和灵活。
  • 缺点:

    • 仍然需要遍历所有的文件描述符来找到就绪的那些,在文件描述符数量很多时效率依然不高。
    • 每次调用poll时,仍需要将pollfd数组从用户空间拷贝到内核空间。
epoll 函数
  • 事件驱动机制epoll 使用事件通知机制,当文件描述符就绪时,主动通知应用程序,无需遍历所有描述符。

  • 红黑树 + 链表:

    • 红黑树:存储所有被监视的文件描述符。
    • 链表:存储就绪的文件描述符,避免遍历所有描述符。
  • 三种系统调用

    c 复制代码
    int epoll_create(int size);     // 创建 epoll 实例(size 参数已废弃,填大于 0 的值即可)
    int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);  // 注册/修改/删除事件
    int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);  // 等待事件发生
    • EPOLL_CTL_ADD EPOLL_CTL_DEL EPOLL_CTL_MODIO管理的三种操作
    • epoll_event 就是处理数据的小车
    • maxevents是小车有多大
  • 工作模式

    • 水平触发(LT,Level Triggered,默认模式):

      • 只要文件描述符就绪(如可读),epoll_wait 就会一直返回该事件。
      • 若应用程序未处理完数据,下次调用 epoll_wait 仍会触发。
    • 边缘触发(ET,Edge Triggered):

      • 仅在文件描述符状态变化(如从无数据变为有数据)时触发一次。
      • 要求应用程序必须一次性处理完所有数据,否则剩余数据不会再次通知。
  • 优点

    • epoll 无最大连接数限制(仅受系统文件描述符上限约束)。
    • 时间复杂度为 O (1)(poll 为 O (n)),适合高并发场景。
三者对比
特性 select poll epoll
文件描述符上限 FD_SETSIZE 限制(通常为 1024) 无硬性限制,取决于系统资源 无上限,仅受系统资源限制
数据结构 位掩码(bitmap) 数组(struct pollfd 红黑树(存储监视对象) + 链表(就绪列表)
事件通知机制 轮询(遍历所有描述符) 轮询(遍历所有描述符) 事件驱动(回调函数 + 就绪链表)
内存拷贝 每次调用需从用户空间到内核空间拷贝 每次调用需从用户空间到内核空间拷贝 仅在注册事件时拷贝一次(epoll_ctl
时间复杂度 O(n) O(n) O (1)(仅遍历就绪链表)
工作模式 仅支持水平触发(LT) 仅支持水平触发(LT) 支持水平触发(LT)和边缘触发(ET)
适用场景 小规模连接(描述符少)Windows系统 小规模连接(描述符少)(连接数中等) 大规模高并发(如百万级连接)(如 Nginx、Redis 等高性能服务器)
  • select/poll 的瓶颈
  1. 用户态与内核态频繁拷贝 :每次调用 select/poll 时,需将全部描述符集合从用户空间拷贝到内核空间。
  2. 轮询遍历开销:返回后需遍历所有描述符以找到就绪者,时间复杂度为 O (n)。
  3. 描述符数量限制selectFD_SETSIZE 限制,难以处理大量连接。
  • epoll 的优化
  1. 事件回调机制:内核通过回调函数直接将就绪描述符放入链表,无需遍历所有描述符。(通俗来说,就是根据就绪事件一个一个创建,一个一个删除,而不是一开始就将整集全部创建完了)
  2. 内存映射 :使用 mmap 实现用户空间和内核空间的内存共享,避免频繁拷贝。
  3. 零拷贝设计 :仅在注册事件(epoll_ctl)时拷贝一次数据,后续 epoll_wait 无需重复拷贝。
  4. 总结epoll 通过内核级的事件表和回调机制,实现了从 "被动轮询""主动通知" 的质变,这使其成为现代高性能网络服务器的核心技术之一
1.5 IO事件触发------LT/ET
  • LT 和 ET 的核心区别

    • 水平触发(LT)

      • 触发条件:只要文件描述符(FD)处于就绪状态(如可读缓冲区有数据),就会持续触发事件。

      • 特性:

        • 事件会重复通知,直到应用程序处理完所有数据。
        • 编程简单,不易遗漏事件(但可能导致不必要的系统调用)。
    • 边缘触发(ET)

      • 触发条件:仅在 FD 状态变化时触发一次(如数据从无到有)。

      • 特性:

        • 事件仅触发一次,必须一次性处理完所有数据(否则剩余数据不会再通知)。
        • 要求应用程序使用非阻塞 I/O,并在事件触发后尽可能读 / 写完整数据。
  • select/poll仅支持 LT 模式;

  • epoll 同时支持 LT 和 ET:默认是 LT 模式,通过 EPOLLET 标志可启用 ET 模式。

  • 为什么 ET 模式要求非阻塞 I/O?

    • 阻塞 I/O 与 ET 的矛盾:

      • 若使用阻塞 I/O,当应用程序在 ET 模式下读取数据时,若数据未读完,线程会被阻塞在 read 调用中。
      • 此时内核认为应用程序正在处理数据,不会再次触发事件,导致剩余数据被 "饿死"。
    • 正确做法:

      • 设置 FD 为非阻塞模式(如 fcntl(fd, F_SETFL, O_NONBLOCK))。
        • 在事件触发后,循环读取 / 写入数据,直到返回 EAGAIN(表示缓冲区已空 / 满)。
场景 LT 模式 ET 模式
编程复杂度 低(无需循环处理) 高(必须循环处理 + 非阻塞 I/O)
性能 中等(可能有冗余通知) 高(减少系统调用次数)
适用场景 简单应用(如小规模连接) 高性能服务器(如 Nginx、Redis)
数据处理要求 可部分处理数据 必须一次性处理完所有数据
2 「代码实现」TCP连接(一请求一线程方式)
2.1 「一请求一线程」实现过程
复制代码
#include <stdio.h>
// #include <winsock2.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <errno.h>
#include <string.h>

int main() {
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);

    /*
    struct sockaddr_in {
        short            sin_family;   // 地址族,通常为 AF_INET
        unsigned short   sin_port;     // 端口号
        struct in_addr   sin_addr;     // IPv4 地址
        char             sin_zero[8];  // 填充字节,使 sockaddr_in 和 sockaddr 大小相同
    };
    
    struct in_addr {
        unsigned long s_addr;  // IPv4 地址,以网络字节序存储
    };
    *///sockaddr_in的结构体

    struct sockaddr_in servaddr;
    servaddr.sin_family = AF_INET;
    // htonl是 "Host to Network Long" 的缩写,
    // 其作用是将一个 32 位的无符号整数从主机字节序转换为网络字节序。
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY); 
    // htons是 "Host to Network Short" 的缩写,
    // 用于将一个 16 位的无符号整数从主机字节序转换为网络字节序
    servaddr.sin_port = htons(2000);

    // 绑定文件描述符和地址,并检错
    // 注意这里第三个参数是结构体的长度而不是指针的长度
    if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr))) {
        printf("bind failed: %s\n", strerror(errno));
    }
    // 开始监听
    listen(sockfd, 10);

    printf("listen finished\n");
    
    struct sockaddr_in clientaddr;
    socklen_t len = sizeof(clientaddr);

    printf("accept\n");
    int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
    printf("accept finished\n");

    char buffer[1024] = {0};
    int count = recv(clientfd, buffer, 1024, 0);
    printf("RECV: %s\n", buffer);

    count = send(clientfd, buffer, count, 0);
    // 把程序阻塞在这里不要往下走
    getchar();

    printf("exit\n");
    return 0;
}

TIPS:

  • 如果不知道某个函数的头文件在哪里,可以用 man 来查找该函数的手册,比如 man strerror发现该函数在string.h

  • 如果需要查看某个网络端口的服务有没有启动,可以用 netstat -anop | grep 2000(端口号)来查询该服务有没有启动

    • 3306 mysql端口
    • 6709 redis端口
复制代码
cv@ubuntu:~$ netstat -anop | grep 2000
(Not all processes could be identified, non-owned process info
 will not be shown, you would have to be root to see it all.)
tcp        0      0 0.0.0.0:2000            0.0.0.0:*               LISTEN      -                off (0.00/0/0)
tcp       80      0 192.168.21.129:2000     192.168.21.1:57037      ESTABLISHED -                off (0.00/0/0)

初代代码实现了一个简单的 TCP 服务器,它创建套接字、绑定到本地端口 2000 并监听连接,接受一个客户端连接后接收其发送的数据并原样返回,最后等待用户输入才退出程序。需要解决的问题:

  • 现象观察:如果此时再启动一个服务器段的,network程序连接网关,会发现端口占用:

    bind failed: Address already in use

    如果此时再用网络助手连接2000端口,会出现以下现象,端口没有被占用,连接成功:

    复制代码
    cv@ubuntu:~$ netstat -anop | grep 2000
    (Not all processes could be identified, non-owned process info
     will not be shown, you would have to be root to see it all.)
    tcp        0      0 0.0.0.0:2000            0.0.0.0:*               LISTEN      -                off (0.00/0/0)
    tcp       80      0 192.168.21.129:2000     192.168.21.1:57037      ESTABLISHED -                off (0.00/0/0)
    • 原因是一个端口在同一时刻只能被一个进程绑定,当服务器在某个端口上进行监听时,它可以同时接受多个客户端的连接。

    • 每当有一个客户端请求连接到服务器的指定端口时,服务器就会创建一个新的连接套接字(在代码中通常用新的文件描述符表示)来与该客户端进行通信,而服务器监听的端口仍然保持监听状态,继续接受其他客户端的连接请求。

  • 程序优化:端口被绑定以后,不能再次被绑定。(如何在一个端口建立多个连接)

    • 因此建立一个while循环,建立一次连接,就创建一个新的fd。

      复制代码
      while (1) {
      
      		printf("accept\n");
      		int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
      		printf("accept finshed\n");
      
      		char buffer[1024] = {0};
      		int count = recv(clientfd, buffer, 1024, 0);
      		printf("RECV: %s\n", buffer);
      
      		count = send(clientfd, buffer, count, 0);
      		printf("SEND: %d\n", count);
      
      	}
  • 程序优化:进入listen可以被连接,需要马上收发一次,然后再建立新的连接

    • 可以每次建立连接时新开一个线程,专门处理这个线程内的连接

      复制代码
      while (1) {
              printf("accept\n");
              int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
              printf("accept finished\n");
          
              
              pthread_t pthread_id;
              pthread_create(&pthread_id, NULL, client_thread, &clientfd);
          }
  • 程序优化:发送消息后只能收发一次

    • recv处加上一个while循环

      复制代码
      void *client_thread(void *arg) {
          int clientfd = *(int*)arg;
          while (1) {
              char buffer[1024] = {0};
              int count = recv(clientfd, buffer, 1024, 0);
              if (count == 0) { // disconnect
      			printf("client disconnect: %d\n", clientfd);
      			close(clientfd);
      			break;
      		}
      		// parser
              printf("RECV: %s\n", buffer);
      
              count = send(clientfd, buffer, count, 0);
              printf("SEND: %d\n", count);
          }
      }
  • 程序优化:客户端断开后,程序进入死循环

    • 加入处理断开 recv()返回0 的逻辑

      c++ 复制代码
      void *client_thread(void *arg) {
          int clientfd = *(int*)arg;
          while (1) {
              char buffer[1024] = {0};
              int count = recv(clientfd, buffer, 1024, 0);
              // 加入处理断开 `recv()返回0` 的逻辑
              if (count == 0) { // disconnect
      			printf("client disconnect: %d\n", clientfd);
      			close(clientfd);
      			break;
      		}
      		// parser
              printf("RECV: %s\n", buffer);
      
              count = send(clientfd, buffer, count, 0);
              printf("SEND: %d\n", count);
          }
      }
  • 现象观察:文件描述符fd依次递增

    复制代码
    cv@ubuntu:~/share/0voice/2.High_Performance_Network/2.1.1Network_Io$ sudo ./network
    listen finished: 3
    accept
    accept finished: 4
    accept
    accept finished: 5
    accept
    accept finished: 6
    accept
    RECV: Welcome to NetAssist
    SEND: 20
    • ls /dev/fd目录下的文件是文件描述符的符号链接,输出为 0 1 2,分别代表标准输入、标准输出和标准错误输出,它们是系统默认的文件描述符, 通过 ls /dec/stdin -l可以查看他们的信息
    • 因为文件描述符fd的数量是有限制的,所以实现百万并发的时候需要设置 open files的数量,用 ulimit -a查看
2.2 「select多路复用」实现过程

核心逻辑 : 通过 select 监听套接字 sockfd 的可读事件,当有数据可读时(如客户端连接或数据到达),select 返回并通知程序处理。

c++ 复制代码
// 定义主要\工作文件描述符集合
    fd_set rfds, rset;
    // 清空文件描述符集合 rfds
    FD_ZERO(&rfds);
    // 将套接字 sockfd 添加到 rfds 集合中,表示需要监听该套接字的可读事件。
    FD_SET(sockfd, &rfds);
    // select 的第一个参数需要传入最大文件描述符值 + 1。
    // 由于当前集合中只有 sockfd,因此 maxfd 初始化为 sockfd。
    int maxfd = sockfd;

    while (1) {
        // 每次循环开始时,将主集合 rfds 复制到工作集合 rset,因为 select 会修改工作集合。
        rset = rfds;

        int nready = select(maxfd+1, &rset, NULL, NULL, NULL);

        // accept部分,检查sockfd是否可读集合
        if (FD_ISSET(sockfd, &rset)) {
            // 接受新的客户端连接
            int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
            // 将新客户端的文件描述符添加到主监听集合中
            FD_SET(clientfd, &rfds);
            // 更新maxfd为所有监听描述符中的最大值
            if (clientfd > maxfd) maxfd = clientfd;
        }
        // recv部分
        int i = 0;
        for (i = sockfd + 1; i <= maxfd; i++) {
            if (FD_ISSET(i, &rset)) {
                char buffer[1024] = {0};
                int count = recv(i, buffer, 1024, 0);
                // 这里应该连接fd的值为i
                if (count == 0) { // disconnect
                    printf("client disconnect: %d\n", i);
                    close(i);
                    // 断开时在集合中应该把客户端的fd清空
                    FD_CLR(i, &rfds);
                    break;
                }
                // parser
                printf("RECV: %s\n", buffer);

                count = send(i, buffer, count, 0);
                printf("SEND: %d\n", count);
            }
        }
    }
  • 代码解释int nready = select(maxfd+1, &rset, NULL, NULL, NULL);

    • 调用 select 函数监听文件描述符集合 rset 中的可读事件。
    • 参数说明:
      • maxfd+1:指定监听的文件描述符范围(从 0 到 maxfd)。
      • &rset:监听可读事件的文件描述符集合。
      • NULL:不监听可写事件。
      • NULL:不监听异常事件。
      • NULL:阻塞模式,直到有文件描述符就绪。
    • 返回值 nready:就绪的文件描述符总数。
    • select 返回后,需要遍历文件描述符集合检查哪些就绪
  • FD_ZERO

    • 用法FD_ZERO(fd_set *set)
    • 作用 :将 fd_set 类型的集合 set 初始化为空集,即把集合中表示各个文件描述符的位都清零 ,确保集合中不包含任何文件描述符。
  • FD_SET

    • 用法FD_SET(int fd, fd_set *set)
    • 作用 :把指定的文件描述符 fd 添加到集合 set 中 ,也就是将集合中对应 fd 的位设置为 1 ,表示该文件描述符在集合内,后续可对其进行相关状态检测。
  • FD_CLR

    • 用法FD_CLR(int fd, fd_set *set)
    • 作用 :从集合 set 中移除指定的文件描述符 fd ,即将集合中对应 fd 的位设置为 0 ,表示该文件描述符不在集合内了。
  • FD_ISSET

    • 用法FD_ISSET(int fd, fd_set *set)
    • 作用 :用于检测文件描述符 fd 是否在集合 set 中。如果 fd 在集合 set 中,返回值为非零(表示真) ;如果不在集合中,返回值为 0(表示假) 。常配合 select 等函数使用,在 select 返回后,判断哪些文件描述符满足了相应条件。
  • fd_set 是一种用于在多路复用 I/O 操作中存储文件描述符集合的数据结构 ,常与 select 函数配合使用。

    • fd_set 是一个 bit 位集合,它采用类似位图(Bitmap)的方式,其中每一位对应一个文件描述符。若某一位被置为 1 ,代表对应的文件描述符在集合内;若为 0 ,则表示不在集合内。
    • 比如系统中文件描述符范围是 0 - 1023 ,fd_set 就有 1024 个位与之对应 ,某位为 1 代表对应文件描述符在集合内,为 0 则不在。
  • 缺点:

    • 单个进程能够监视的文件描述符的数量存在最大限制,在 Linux 系统上一般为 1024。
    • 每次调用select时都需要将文件描述符集合 fd_set 从用户空间拷贝到内核空间,开销较大。
    • select返回后,需要遍历 所有文件描述符 fd_set 来找到就绪的那些,效率较低。
2.3 「poll多路复用」实现过程

核心逻辑 : 使用 poll 函数实现了一个简单的 TCP 服务器,它持续监听客户端连接和数据收发,当有新连接或数据到来时进行相应处理。

c++ 复制代码
// 代码初始化了一个包含 1024 个 pollfd 结构的数组,并设置监听套接字 sockfd 关注可读事件(即新连接到来)。
    struct pollfd fds[1024] = {0};
    fds[sockfd].fd = sockfd;
    fds[sockfd].events = POLLIN;

    int maxfd = sockfd;

    while (1) {
        // poll 返回发生事件的文件描述符总数,存储在 nready 中
        int nready = poll(fds. maxfd+1, -1);
        // 当 sockfd 上有可读事件(POLLIN)发生时,表示有新的客户端连接
        if (fds[sockfd].revents & POLLIN) {
            // accept 函数接受连接并返回新的客户端套接字 clientfd
            int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
            fds[clientfd].fd = clientfd;
            fds[clientfd].events = POLLIN;
            // 更新 maxfd 为当前最大的文件描述符值
            if (clientfd > maxfd) maxfd = clientfd;
        }
        // 遍历所有可能有数据可读的客户端套接字(从 sockfd+1 到 maxfd)
        for (i = sockfd+1; i <= maxfd; i++) {
            // 当某个客户端套接字有可读事件时
            if (fds[i].revents & POLLIN) {
                // 使用 recv 接收数据
                int count = recv(i, buffer, 1024, 0);
                // 如果 recv 返回 0,表示客户端关闭连接,
                // 此时关闭套接字并从 pollfd 数组中移除(将 fd 设为 - 1,events 设为 0)
                if (count == 0) {
                    close(i);
                    fds[i].fd = -1;
                    fds[i].events = 0;
                } else {
                    send(i, buffer, count, 0);
                }
            }
        }
  • struct pollfd是poll函数使用的数据结构,包含三个成员:

    • fd:文件描述符
    • events:要监听的事件(如 POLLIN 表示可读事件)
    • revents:实际发生的事件(由 poll 函数填充)
复制代码
  poll(fds, maxfd+1, -1)
  • 第一个参数 fds 是要监听的文件描述符数组

  • 第二个参数 maxfd+1 表示数组中有效元素的数量(从 0 到 maxfd

  • 第三个参数 -1 表示无限等待,直到有事件发生

  • poll 返回发生事件的文件描述符总数,存储在 nready

  • 优点:

    • 没有最大文件描述符数量的限制(仅受限于系统资源)。
    • 使用pollfd结构而不是位掩码来表示文件描述符集合,更加直观和灵活。
  • 缺点:

    • 仍然需要遍历所有的文件描述符来找到就绪的那些,在文件描述符数量很多时效率依然不高。
    • 每次调用poll时,仍需要将pollfd数组从用户空间拷贝到内核空间。
2.4 「epoll多路复用」实现过程

核心逻辑epoll 实现了一个高效的 TCP 服务器,通过事件驱动方式同时处理多个客户端连接的读写操作,避免了轮询开销。

c++ 复制代码
// 创建一个 epoll 实例,返回文件描述符 epfd。
    // 传入参数必须为正数(历史上表示初始事件表大小)
    int epfd = epoll_create(1);
    
    // 注册监听事件
    struct epoll_event ev;
    ev.events = EPOLLIN;       // 监听读事件
    ev.data.fd = sockfd;       // 绑定监听套接字
    epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);

    while (1) {
        // 等待事件触发
        struct epoll_event events[1024] = {0};
        int nready = epoll_wait(epfd, events, 1024, -1);

        for (int i = 0; i < nready; i++) {
            int connfd = events[i].data.fd;
            // 当 sockfd 就绪时,表示有新连接
            if (connfd == sockfd) {
                int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
                ev.events = EPOLLIN;
                ev.data.fd = clientfd;
                epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &ev);
            } 
            // 当客户端套接字就绪时,读取数据
            else if (events[i].events & EPOLLIN) {
                char buffer[1024] = {0};
                int count = recv(connfd, buffer, 1024, 0);
                if (count == 0) {         // 客户端关闭连接
                    close(connfd);
                    epoll_ctl(epfd, EPOLL_CTL_DEL, connfd, NULL);
                } else {                  // 正常数据接收
                    send(connfd, buffer, count, 0);
                }
                // parser
                printf("RECV: %s\n", buffer);

                count = send(i, buffer, count, 0);
                printf("SEND: %d\n", count);
            }

        }
  • int epfd = epoll_create(1)创建一个 epoll 实例

  • epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev):注册监听事件参数:

    • epfdepoll_create 返回的句柄。
    • EPOLL_CTL_ADD:添加事件。
    • sockfd:要监听的套接字。
    • &ev:事件结构体,包含监听类型(EPOLLIN)和自定义数据(data.fd)。
  • epoll_wait(epfd, events, 1024, -1):等待事件触发参数

    • epfdepoll 实例句柄。
    • events:输出参数,存储就绪事件的数组。
    • 1024:数组最大容量。
    • -1:阻塞等待(直到有事件发生)。
  • 关键点总结

    • 高效事件通知epoll 使用事件表(而非轮询),仅返回就绪的文件描述符,适合处理大量连接。

    • 水平触发模式(LT) :默认模式下,只要数据未读完,EPOLLIN 会持续触发。

    • 数据结构:

      • struct epoll_event 包含 events(事件类型)和 data(自定义数据,通常存 fd)。
    • 对比 poll

      • epoll 无最大连接数限制(仅受系统文件描述符上限约束)。
      • 时间复杂度为 O (1)(poll 为 O (n)),适合高并发场景。
2.5 「TCP连接」完整代码
  • 为什么服务器多用linux,主要原因就是linux2.6版本以后引入了epoll,使得可以实现百万连接的服务器

    #include <stdio.h>
    // #include <winsock2.h>
    #include <sys/socket.h>
    #include <netinet/in.h>
    #include <errno.h>
    #include <string.h>
    #include <pthread.h>
    #include <unistd.h>
    #include <sys/select.h>
    #include <poll.h>
    #include <sys/epoll.h>

    void *client_thread(void *arg) {
    int clientfd = (int)arg;
    while (1) {
    char buffer[1024] = {0};
    int count = recv(clientfd, buffer, 1024, 0);
    if (count == 0) { // disconnect
    printf("client disconnect: %d\n", clientfd);
    close(clientfd);
    break;
    }
    // parser
    printf("RECV: %s\n", buffer);

    复制代码
          count = send(clientfd, buffer, count, 0);
          printf("SEND: %d\n", count);
      }

    }

    int main() {
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);

    复制代码
      /*
      struct sockaddr_in {
          short            sin_family;   // 地址族,通常为 AF_INET
          unsigned short   sin_port;     // 端口号
          struct in_addr   sin_addr;     // IPv4 地址
          char             sin_zero[8];  // 填充字节,使 sockaddr_in 和 sockaddr 大小相同
      };
      
      struct in_addr {
          unsigned long s_addr;  // IPv4 地址,以网络字节序存储
      };
      *///sockaddr_in的结构体
    
      struct sockaddr_in servaddr;
      servaddr.sin_family = AF_INET;
      // htonl是 "Host to Network Long" 的缩写,
      // 其作用是将一个 32 位的无符号整数从主机字节序转换为网络字节序。
      servaddr.sin_addr.s_addr = htonl(INADDR_ANY); 
      // htons是 "Host to Network Short" 的缩写,
      // 用于将一个 16 位的无符号整数从主机字节序转换为网络字节序
      servaddr.sin_port = htons(2000);
    
      // 绑定文件描述符和地址,并检错
      // 注意这里第三个参数是结构体的长度而不是指针的长度
      if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr))) {
          printf("bind failed: %s\n", strerror(errno));
      }
      // 开始监听
      listen(sockfd, 10);
    
      printf("listen finished: %d\n", sockfd);
      
      struct sockaddr_in clientaddr;
      socklen_t len = sizeof(clientaddr);

    #if 0
    printf("accept\n");
    int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
    printf("accept finished\n");

    复制代码
      char buffer[1024] = {0};
      int count = recv(clientfd, buffer, 1024, 0);
      printf("RECV: %s\n", buffer);
    
      count = send(clientfd, buffer, count, 0);
      printf("SEND: %s\n", count);

    #elif 0
    // 端口被绑定以后,不能再次被绑定。(如何在一个端口建立多个连接)
    // 因此建立一个循环,进行一次收发,建立一次连接
    while (1) {

    复制代码
      	printf("accept\n");
      	int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
      	printf("accept finshed\n");
    
      	char buffer[1024] = {0};
      	int count = recv(clientfd, buffer, 1024, 0);
      	printf("RECV: %s\n", buffer);
    
      	count = send(clientfd, buffer, count, 0);
      	printf("SEND: %d\n", count);
    
      }

    // 还有新的问题,和建立连接的顺序有关系,导致一些逻辑上的连接阻塞
    // 可以每次建立连接时新开一个线程,专门处理这个线程内的连接
    #elif 0 // 这就是一请求一线程的连接方式

    复制代码
      while (1) {
          printf("accept\n");
          int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
          printf("accept finished: %d\n", clientfd);
      
          
          pthread_t pthread_id;
          pthread_create(&pthread_id, NULL, client_thread, &clientfd);
      }

    #elif 0
    // 定义主要\工作文件描述符集合
    fd_set rfds, rset;
    // 清空文件描述符集合 rfds
    FD_ZERO(&rfds);
    // 将套接字 sockfd 添加到 rfds 集合中,表示需要监听该套接字的可读事件。
    FD_SET(sockfd, &rfds);
    // select 的第一个参数需要传入最大文件描述符值 + 1。
    // 由于当前集合中只有 sockfd,因此 maxfd 初始化为 sockfd。
    int maxfd = sockfd;

    复制代码
      while (1) {
          // 每次循环开始时,将主集合 rfds 复制到工作集合 rset,因为 select 会修改工作集合。
          rset = rfds;
    
          int nready = select(maxfd+1, &rset, NULL, NULL, NULL);
    
          // accept部分,检查sockfd是否可读集合
          if (FD_ISSET(sockfd, &rset)) {
              // 接受新的客户端连接
              int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
              // 将新客户端的文件描述符添加到主监听集合中
              FD_SET(clientfd, &rfds);
              // 更新maxfd为所有监听描述符中的最大值
              if (clientfd > maxfd) maxfd = clientfd;
          }
          // recv部分
          int i = 0;
          for (i = sockfd + 1; i <= maxfd; i++) {
              if (FD_ISSET(i, &rset)) {
                  char buffer[1024] = {0};
                  int count = recv(i, buffer, 1024, 0);
                  // 这里应该连接fd的值为i
                  if (count == 0) { // disconnect
                      printf("client disconnect: %d\n", i);
                      close(i);
                      // 断开时在集合中应该把客户端的fd清空
                      FD_CLR(i, &rfds);
                      break;
                  }
                  // parser
                  printf("RECV: %s\n", buffer);
    
                  count = send(i, buffer, count, 0);
                  printf("SEND: %d\n", count);
              }
          }
    
          
      }

    #elif 0
    // 代码初始化了一个包含 1024 个 pollfd 结构的数组,并设置监听套接字 sockfd 关注可读事件(即新连接到来)。
    struct pollfd fds[1024] = {0};
    fds[sockfd].fd = sockfd;
    fds[sockfd].events = POLLIN;

    复制代码
      int maxfd = sockfd;
    
      while (1) {
          // poll 返回发生事件的文件描述符总数,存储在 nready 中
          int nready = poll(fds, maxfd+1, -1);
          // 当 sockfd 上有可读事件(POLLIN)发生时,表示有新的客户端连接
          if (fds[sockfd].revents & POLLIN) {
              // accept 函数接受连接并返回新的客户端套接字 clientfd
              int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
              fds[clientfd].fd = clientfd;
              fds[clientfd].events = POLLIN;
              // 更新 maxfd 为当前最大的文件描述符值
              if (clientfd > maxfd) maxfd = clientfd;
          }
          // 遍历所有可能有数据可读的客户端套接字(从 sockfd+1 到 maxfd)
          for (int i = sockfd+1; i <= maxfd; i++) {
              // 当某个客户端套接字有可读事件时
              if (fds[i].revents & POLLIN) {
                  // 使用 recv 接收数据
                  char buffer[1024] = {0};
                  int count = recv(i, buffer, 1024, 0);
                  // 如果 recv 返回 0,表示客户端关闭连接,
                  // 此时关闭套接字并从 pollfd 数组中移除(将 fd 设为 - 1,events 设为 0)
                  if (count == 0) {
                      close(i);
                      fds[i].fd = -1;
                      fds[i].events = 0;
                  } else {
                      send(i, buffer, count, 0);
                  }
                  // parser
                  printf("RECV: %s\n", buffer);
    
                  count = send(i, buffer, count, 0);
                  printf("SEND: %d\n", count);
              }
          }
    
    
      }

    #else
    // 创建一个 epoll 实例,返回文件描述符 epfd。
    // 传入参数必须为正数(历史上表示初始事件表大小)
    int epfd = epoll_create(1);

    复制代码
      // 注册监听事件
      struct epoll_event ev;
      ev.events = EPOLLIN;       // 监听读事件
      ev.data.fd = sockfd;       // 绑定监听套接字
      epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
    
      while (1) {
          // 等待事件触发
          struct epoll_event events[1024] = {0};
          int nready = epoll_wait(epfd, events, 1024, -1);
    
          for (int i = 0; i < nready; i++) {
              int connfd = events[i].data.fd;
              // 当 sockfd 就绪时,表示有新连接
              if (connfd == sockfd) {
                  int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
                  ev.events = EPOLLIN;
                  ev.data.fd = clientfd;
                  epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &ev);
              } 
              // 当客户端套接字就绪时,读取数据
              else if (events[i].events & EPOLLIN) {
                  char buffer[1024] = {0};
                  int count = recv(connfd, buffer, 1024, 0);
                  if (count == 0) {         // 客户端关闭连接
                      close(connfd);
                      epoll_ctl(epfd, EPOLL_CTL_DEL, connfd, NULL);
                  } else {                  // 正常数据接收
                      send(connfd, buffer, count, 0);
                  }
                  // parser
                  printf("RECV: %s\n", buffer);
    
                  count = send(i, buffer, count, 0);
                  printf("SEND: %d\n", count);
              }
    
          }
    
    
      }

    #endif

    复制代码
      // 把程序阻塞在这里不要往下走
      getchar();
    
      printf("exit\n");
      return 0;

    }

下一章:2.1.2 事件驱动reactor的原理与实现

https://github.com/0voice

相关推荐
Demisse5 小时前
[华为eNSP] OSPF综合实验
网络·华为
工控小楠5 小时前
DeviceNet转Modbus TCP网关的远程遥控接收端连接研究
网络·网络协议·devicenet·profient
搬码临时工5 小时前
电脑同时连接内网和外网的方法,附外网连接局域网的操作设置
运维·服务器·网络
藥瓿亭5 小时前
K8S认证|CKS题库+答案| 3. 默认网络策略
运维·ubuntu·docker·云原生·容器·kubernetes·cks
Gaoithe5 小时前
ubuntu 端口复用
linux·运维·ubuntu
德先生&赛先生6 小时前
Linux编程:1、文件编程
linux
安全系统学习6 小时前
【网络安全】Qt免杀样本分析
java·网络·安全·web安全·系统安全
程序猿小D6 小时前
第16节 Node.js 文件系统
linux·服务器·前端·node.js·编辑器·vim
逃逸线LOF6 小时前
Spring Boot论文翻译防丢失 From船长&cap
网络
计算机毕设定制辅导-无忧学长6 小时前
从 AMQP 到 RabbitMQ:核心组件设计与工作原理(二)
网络·rabbitmq·ruby