【Socket 进阶之路】第 7 章 IO 多路复用 select & poll 完整实战|多 fd 监听、超时等待、优缺点横向对比

💖 点赞 + 收藏 + 关注,Socket 工业级网络编程全套实战持续更新!

✨ 本专栏 全程手写源码、无阉割、纯工程

0. 前言

上一章我们掌握了阻塞 IO 与非阻塞 IO,同时发现纯轮询非阻塞套接字会造成 CPU 满载,无法直接用于正式项目。想要单线程管理多个文件描述符(TCP 连接、UDP、管道),就需要 IO 多路复用技术。

Linux 下 IO 多路复用存在三套经典 API:selectpollepoll。学习顺序建议先吃透 select、poll,理解多路复用核心思想,再学习 epoll,否则很容易对就绪通知、水平触发、边缘触发理解不透彻。

本章将完成:

  1. select 函数原型、fd_set 文件描述符集合原理
  2. select 完整 TCP 多客户端服务端代码实现
  3. poll 接口讲解 + 同功能完整 Demo
  4. select /poll 底层原理、性能瓶颈对比
  5. 工业项目选型建议,常见 bug 与调试方案

核心思想:IO 多路复用,是把多个 fd 交给内核监控,内核阻塞等待,直到任意 fd 发生可读、可写、异常事件之后,再唤醒应用程序处理,避免用户态空轮询,大幅降低 CPU 占用。

1. select 函数详解

1.1 函数原型

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

参数说明:

  1. nfds:所有 fd 中最大值 + 1,内核用来循环遍历 fd 集合;
  2. readfds:监听可读事件 fd 集合(有数据、连接断开、新客户端 accept 都属于可读);
  3. writefds:监听可写事件 fd 集合(内核发送缓冲区有空,可以发送数据);
  4. exceptfds:监听异常事件 fd 集合(带外数据等,常规网络开发几乎不用,传 NULL);
  5. timeout:阻塞超时时间。
    • NULL:永久阻塞,直到事件触发;
    • tv_sec=0, tv_usec=0:非阻塞轮询;
    • 设置时间:最长等待指定时长,超时后 select 返回 0。

1.2 fd_set 配套操作宏

fd_set 本质是一个位图,每一位代表一个 fd 是否被监听,提供 4 个标准宏操作:

复制代码
FD_ZERO(fd_set *fdset);    // 清空整个集合,初始化必须调用
FD_SET(int fd, fd_set *fdset);  // 将fd加入监听集合
FD_CLR(int fd, fd_set *fdset);  // 将fd从监听集合移除
FD_ISSET(int fd, fd_set *fdset); // 判断fd是否就绪(事件触发)

⚠️ 重大坑点:select 会修改传入的 fd_set!每次循环调用 select 前,都必须重新 FD_ZERO、FD_SET 组装 fd 集合,不能复用上次的集合。
经典限制:默认 fd_set 最大支持 1024 个 fd(FD_SETSIZE=1024),无法直接支持上万并发连接,这也是 select 最大短板。

2. 实战:select 实现单线程多客户端 TCP 服务端

业务逻辑说明:

  1. 创建监听 listen_fd,设置非阻塞,加入 read 监听集合;
  2. select 阻塞等待事件;
  3. listen_fd 可读 → accept 新连接,新 connfd 设置非阻塞,加入监听集合;
  4. 普通 connfd 可读 → read 读取数据,使用环形缓冲区 + 上一章协议解析处理;
  5. read 返回 0 / 读写异常 → close 关闭 fd,从 fd 集合移除;
  6. 简易回声服务,收到数据原路回复客户端。

select_server.c

复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/select.h>

#define LISTEN_PORT 8888
#define BUF_MAX 256
#define MAX_CLIENTS 64

int SetSocketNonBlock(int fd)
{
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags < 0)
        return -1;
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

int main(void)
{
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0)
    {
        perror("socket fail");
        exit(EXIT_FAILURE);
    }

    // 端口复用
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in serv_addr;
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(LISTEN_PORT);
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);

    if (bind(listen_fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0)
    {
        perror("bind fail");
        close(listen_fd);
        exit(EXIT_FAILURE);
    }
    if (listen(listen_fd, 5) < 0)
    {
        perror("listen fail");
        close(listen_fd);
        exit(EXIT_FAILURE);
    }
    SetSocketNonBlock(listen_fd);
    printf("select tcp server run on port %d\n", LISTEN_PORT);

    // 保存所有客户端fd,初始化为-1代表空闲
    int client_fds[MAX_CLIENTS] = {0};
    memset(client_fds, -1, sizeof(client_fds));

    fd_set read_fds;
    int max_fd = listen_fd;

    while (1)
    {
        // 每次循环必须重置fd集合
        FD_ZERO(&read_fds);
        FD_SET(listen_fd, &read_fds);
        max_fd = listen_fd;

        // 将有效客户端fd加入监听集合
        for (int i = 0; i < MAX_CLIENTS; i++)
        {
            if (client_fds[i] > 0)
            {
                FD_SET(client_fds[i], &read_fds);
                if (client_fds[i] > max_fd)
                    max_fd = client_fds[i];
            }
        }

        // 设置select超时 1s
        struct timeval timeout = {1, 0};
        int ret = select(max_fd + 1, &read_fds, NULL, NULL, &timeout);
        if (ret < 0)
        {
            perror("select fail");
            continue;
        }
        else if (ret == 0)
        {
            // 超时,无事件,继续循环
            continue;
        }

        // 事件就绪,判断是否是监听fd(新连接)
        if (FD_ISSET(listen_fd, &read_fds))
        {
            struct sockaddr_in cli_addr;
            socklen_t cli_len = sizeof(cli_addr);
            int connfd = accept(listen_fd, (struct sockaddr *)&cli_addr, &cli_len);
            if (connfd < 0)
            {
                perror("accept fail");
                continue;
            }
            SetSocketNonBlock(connfd);
            printf("new client connect: %s:%d, fd=%d\n",
                   inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port), connfd);

            // 存入客户端数组
            int idx = -1;
            for (int i = 0; i < MAX_CLIENTS; i++)
            {
                if (client_fds[i] == -1)
                {
                    idx = i;
                    break;
                }
            }
            if (idx == -1)
            {
                printf("max client limit!\n");
                close(connfd);
            }
            else
            {
                client_fds[idx] = connfd;
            }
        }

        // 遍历处理各个客户端fd可读事件
        for (int i = 0; i < MAX_CLIENTS; i++)
        {
            int fd = client_fds[i];
            if (fd == -1)
                continue;
            if (FD_ISSET(fd, &read_fds))
            {
                uint8_t buf[BUF_MAX] = {0};
                int rlen = read(fd, buf, BUF_MAX - 1);
                if (rlen > 0)
                {
                    printf("fd:%d recv:%s\n", fd, buf);
                    // 回声:原路发回
                    write(fd, buf, rlen);
                }
                else if (rlen == 0)
                {
                    printf("client fd:%d disconnect\n", fd);
                    close(fd);
                    client_fds[i] = -1;
                }
                else
                {
                    if (errno != EAGAIN && errno != EWOULDBLOCK)
                    {
                        perror("read error");
                        close(fd);
                        client_fds[i] = -1;
                    }
                }
            }
        }
    }
    close(listen_fd);
    return 0;
}

编译运行:

复制代码
gcc select_server.c -o select_server
./select_server

可以使用多个 telnet 或者 nc 工具同时连接 8888 端口,验证单线程多客户端并发能力。

3. poll 接口详解,解决 select 部分缺陷

3.1 poll 函数原型

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

参数说明:

  1. fds:pollfd 结构体数组,存放需要监听的 fd、监听事件、返回就绪事件;
  2. nfds:数组元素个数;
  3. timeout:超时时间,单位 ms。-1 永久阻塞,0 非阻塞轮询。

pollfd 结构体定义:

复制代码
struct pollfd
{
    int fd;         // 需要监听的文件描述符
    short events;   // 我们想要监听的事件(POLLIN 可读 / POLLOUT 可写)
    short revents;  // 内核返回:实际触发的就绪事件
};

常用事件宏:

  • POLLIN:可读事件(有数据、连接关闭、新连接)
  • POLLOUT:可写事件(发送缓冲区空闲)
  • POLLERR:fd 发生错误,自动监听,无需手动设置 events
  • POLLHUP:对端挂断连接

poll 对比 select 的改进点:

  1. 不再有 1024 fd 上限限制(受系统进程最大 fd 限制);
  2. 不会修改原始监听数组,不用每次循环重建 fd 集合;
  3. 不需要计算 max_fd,接口使用更加友好;
    共同短板:select/poll 都需要遍历全部 fd 集合查找就绪 fd,时间复杂度 O (n),上万连接场景性能急剧下降。

4. poll 简易 TCP 回声服务端 Demo

poll_server.c

复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <errno.h>
#include <poll.h>

#define LISTEN_PORT 8888
#define BUF_MAX 256
#define MAX_FD 64

int SetSocketNonBlock(int fd)
{
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags < 0)
        return -1;
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

int main(void)
{
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0)
    {
        perror("socket fail");
        exit(EXIT_FAILURE);
    }
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in serv_addr;
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(LISTEN_PORT);
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);

    bind(listen_fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
    listen(listen_fd, 5);
    SetSocketNonBlock(listen_fd);
    printf("poll tcp server run on port %d\n", LISTEN_PORT);

    struct pollfd fds[MAX_FD];
    memset(fds, 0, sizeof(fds));
    fds[0].fd = listen_fd;
    fds[0].events = POLLIN;

    while (1)
    {
        int ret = poll(fds, MAX_FD, 1000);
        if (ret < 0)
        {
            perror("poll error");
            continue;
        }
        else if (ret == 0)
        {
            continue;
        }

        // 监听fd就绪,新连接
        if (fds[0].revents & POLLIN)
        {
            struct sockaddr_in cli_addr;
            socklen_t cli_len = sizeof(cli_addr);
            int connfd = accept(listen_fd, (struct sockaddr *)&cli_addr, &cli_len);
            if (connfd < 0)
            {
                perror("accept");
                continue;
            }
            SetSocketNonBlock(connfd);
            printf("new client %s:%d fd=%d\n", inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port), connfd);

            // 寻找空闲位置存入新fd
            for (int i = 1; i < MAX_FD; i++)
            {
                if (fds[i].fd == 0)
                {
                    fds[i].fd = connfd;
                    fds[i].events = POLLIN;
                    break;
                }
            }
        }

        // 遍历所有客户端fd
        for (int i = 1; i < MAX_FD; i++)
        {
            int fd = fds[i].fd;
            if (fd <= 0)
                continue;
            if (fds[i].revents & POLLIN)
            {
                uint8_t buf[BUF_MAX] = {0};
                int rlen = read(fd, buf, BUF_MAX - 1);
                if (rlen > 0)
                {
                    printf("fd:%d recv: %s\n", fd, buf);
                    write(fd, buf, rlen);
                }
                else if (rlen == 0)
                {
                    printf("client fd:%d close\n", fd);
                    close(fd);
                    fds[i].fd = 0;
                    fds[i].events = 0;
                }
                else
                {
                    if (errno != EAGAIN && errno != EWOULDBLOCK)
                    {
                        close(fd);
                        fds[i].fd = 0;
                        fds[i].events = 0;
                    }
                }
            }
            // 异常挂断
            if (fds[i].revents & (POLLERR | POLLHUP))
            {
                close(fd);
                fds[i].fd = 0;
                fds[i].events = 0;
            }
        }
    }
    close(listen_fd);
    return 0;
}

5. select VS poll 横向对比

特性 select poll
fd 上限 默认 1024,修改内核编译才能扩大 无硬性上限,受系统最大进程 fd 限制
参数处理 每次调用都会改写 fd_set,每次循环需要重建集合 不会修改原始 events,仅修改 revents,复用数组
时间精度 timeval 微秒级 timeout 毫秒级
性能 O (n),全部 fd 轮询 O (n),全部 fd 轮询
跨平台 Windows/Linux 都支持,兼容性最好 Linux 原生,Windows 支持差

✅ 适用场景:

  1. 嵌入式网关、小型服务,并发连接几十~几百:select /poll 完全够用,代码简单易维护;
  2. 高并发上万长连接:两者都不推荐,使用 epoll。

6. 工程踩坑汇总

  1. select 每次循环必须 FD_ZERO 重建 fd 集合,忘记重建会导致 fd 监听丢失;
  2. 监听 fd 和业务 connfd 建议全部设置为非阻塞,防止 accept/read 阻塞卡死主线程;
  3. 就绪事件判断,必须使用 revents & POLLIN,不要直接判断相等;
  4. 连接关闭(POLLHUP)一定要 close fd,并且将 pollfd 数组 fd 置 0,避免野 fd 持续监听;
  5. select/poll 返回就绪后,循环 read 直到返回 EAGAIN,防止缓冲区残留半包数据;
  6. 大量连接场景,每次就绪都要遍历整个 fd 数组,CPU 开销随连接数量线性上涨;
  7. 注意系统限制:ulimit -n 修改进程最大打开文件描述符数量,防止 fd 耗尽。

7. 本章小结

本章讲解 IO 多路复用基础模型 select 和 poll,掌握 fd_set、pollfd 用法,实现单线程多客户端 TCP 服务端,横向对比两套 API 优缺点。 核心要点:

  1. IO 多路复用由内核阻塞监听多个 fd,事件就绪后唤醒应用处理,降低 CPU 占用;
  2. select 存在 1024 默认 fd 上限,poll 解除该限制;
  3. select/poll 就绪后都需要遍历全部 fd 查找就绪连接,复杂度 O (n),不适合超高并发;
  4. 所有 connfd 建议统一非阻塞,配合环形缓冲区处理半包、粘包。

遗留思考: 如何解决 select/poll O (n) 遍历的性能瓶颈?epoll 是 Linux 专为高并发设计的 IO 多路复用方案,支持就绪回调通知,时间复杂度 O (1)。 下一章:epoll 完整实战,水平触发 LT、边缘触发 ET,Reactor 模型,高并发服务架构。

下一章:epoll 高并发 IO 多路复用完整实战|LT 水平触发 & ET 边缘触发、Reactor 单线程模型

相关推荐
船厂电气自动化ai大模型1 小时前
AI大模型与数学|第81天 课程:正交向量、正交基、格拉姆‑施密特(Gram‑Schmidt)正交化
开发语言·数据结构·人工智能·线性代数·机器学习
人工智能培训1 小时前
大语言模型:从语言理解到通用智能的跃迁
linux·服务器·前端·人工智能
YCOSA20251 小时前
迎双节 雨晨 Windows 11 IoT 企业版 LTSC 26H1 轻装 28000.2956
windows·物联网
文人sec1 小时前
grant 之后要跟着 flush privileges 吗?要不要使用分区表?
数据库·mysql·adb·数据分析
东方护航数据恢复(深圳)1 小时前
本地小算力设备数据恢复经典案例实操全解_东方护航数据恢复深圳店
大数据·服务器·算法·ai·gpu算力
Omics Pro2 小时前
上海AI Lab孙思琦×高张阳:虚拟细胞代码库智能体
数据库·人工智能·算法·机器学习·自然语言处理
阿明62 小时前
Linux进程【Linux】
linux·运维·服务器
lv__pf2 小时前
seata【实战 msb】
java·开发语言·后端
点纭2 小时前
C 语言 第七章 常用函数(3)
c语言·开发语言·算法·oracle·c#