Skip to content

第 3 章 · select 详解

学完本章你将知道:select 是怎么用一张"位图"同时盯多个 fd 的、为什么它有 1024 这个魔咒、它的三大致命缺陷是什么——这三大缺陷恰好是 poll 和 epoll 出现的原因。


1. 一句话开场

select 是 1983 年 4.2BSD 引入的第一个 I/O 多路复用系统调用。 它给你 3 张"位图",分别表示「我关心哪些 fd 的读 / 写 / 错误事件」,传给内核——内核帮你盯着,任何一个 fd 准备好了就回报

生活类比:你给前台桌子上摆了 3 个本子——

  • 读本子:写下你关心的 fd 编号("3、5、8 来电话叫我")
  • 写本子:写下你想发消息的 fd 编号
  • 错本子:写下你担心出问题的 fd 编号

然后你跟前台说"任一个有动静叫我",人就睡了。前台某个本子上的某个 fd 出事了,就在本子上画个圈,喊醒你——你回来挨个本子看哪儿被画圈了。

一个细节:前台每次"扫一遍本子看谁出事"是 O(n) 的——这就是 select 的痛点。


2. 函数签名一览

c
#include <sys/select.h>

int select(int nfds,
           fd_set *readfds,    // 关心可读的 fd 集合(位图)
           fd_set *writefds,   // 关心可写的 fd 集合
           fd_set *exceptfds,  // 关心异常的 fd 集合
           struct timeval *timeout);

// 操作 fd_set 的辅助宏
void FD_ZERO(fd_set *set);              // 清空集合
void FD_SET(int fd, fd_set *set);       // 加入一个 fd
void FD_CLR(int fd, fd_set *set);       // 移除一个 fd
int  FD_ISSET(int fd, fd_set *set);     // 判断 fd 是否在集合中
参数含义关键说明
nfds最大 fd + 1是上界,告诉内核"扫到这里就行"
readfds关心可读的 fd 集合输入是"想监听的",输出是"已就绪的"——会被改写
writefds关心可写的 fd 集合同上
exceptfds关心异常事件的 fd 集合同上
timeout超时时间NULL=永久阻塞,{0,0}=立即返回,其他=最多等多久

返回值

  • > 0:就绪 fd 总数
  • 0:超时
  • -1:出错(看 errno)

3. fd_set:位图的真面目

3.1 它究竟是什么?

fd_set 在 Linux 上定义为:

c
typedef struct {
    unsigned long fds_bits[1024 / (8 * sizeof(unsigned long))];
} fd_set;
// 在 64 位机器上 = unsigned long fds_bits[16]
// 16 * 64 = 1024 bit,正好可以表示 0~1023 共 1024 个 fd

本质一个 1024 比特的位图(bitmap)——下标 i 处的 bit 是 1,表示"关心 fd=i"。

3.2 为什么叫"位图"

text
fd_set readfds 的内部结构(128 字节 = 1024 bit)

  fd 编号:   0  1  2  3  4  5  6  7  ...  62 63 | 64 65 ... 127 | ... | 1023
            ─────────────────────────────       ─────────────         ─────
  bit 值:   0  0  0  1  0  1  0  0  ...   0  0 |  0  0 ...   0 | ... |   0
                     ↑     ↑
                     │     └── FD_SET(5, &readfds) 之后这一位变成 1
                     └──────── FD_SET(3, &readfds) 之后这一位变成 1

  这表示:现在我关心 fd=3 和 fd=5 的可读事件

关键洞察:因为是位图每个 fd 只占 1 bit。所以 1024 个 fd 才用 128 字节——非常省空间。但代价是位图大小是固定的——这就是 1024 限制的根源。

3.3 为什么是 1024?能改吗?

FD_SETSIZE 是个编译期常量(不是运行时配置),在 /usr/include/sys/select.h 中:

c
#define __FD_SETSIZE 1024

理论上可以重新编译 glibc 改大它——但你不应该这么干

  • 改大后所有依赖 glibc 的程序都要重新编译
  • 即使大了,select 性能还是 O(n),没意义
  • 正确做法:要监听更多 fd,用 poll 或 epoll

4. 标准用法模板

4.1 单 fd 等待(简化场景)

c
fd_set rfds;
struct timeval tv = {.tv_sec = 5, .tv_usec = 0};   // 超时 5 秒

FD_ZERO(&rfds);
FD_SET(STDIN_FILENO, &rfds);

int ret = select(STDIN_FILENO + 1, &rfds, NULL, NULL, &tv);

if (ret == -1) perror("select");
else if (ret == 0) printf("5 秒超时,啥也没来\n");
else {
    if (FD_ISSET(STDIN_FILENO, &rfds))
        printf("stdin 有数据可读了\n");
}

4.2 多 fd 服务器骨架(核心模板)

c
fd_set master, working;       // master 是"想监听集合",working 是给 select 改写的副本
FD_ZERO(&master);
FD_SET(listen_fd, &master);
int max_fd = listen_fd;

while (1) {
    working = master;          // ⚠️ 必须每次循环都拷贝!select 会改写
    int n = select(max_fd + 1, &working, NULL, NULL, NULL);
    if (n < 0) { perror("select"); break; }

    for (int fd = 0; fd <= max_fd; fd++) {     // ⚠️ 必须遍历所有 fd
        if (!FD_ISSET(fd, &working)) continue;
        if (fd == listen_fd) {
            int conn = accept(listen_fd, NULL, NULL);
            FD_SET(conn, &master);              // 加入监听
            if (conn > max_fd) max_fd = conn;
        } else {
            char buf[1024];
            ssize_t r = read(fd, buf, sizeof(buf));
            if (r <= 0) {
                close(fd);
                FD_CLR(fd, &master);            // 移除
            } else {
                write(fd, buf, r);              // echo
            }
        }
    }
}

三个易错点(强烈记住):

  1. 每次循环必须 working = master:select 会修改你传入的 fd_set,把"我关心的"改成"已就绪的"
  2. 必须遍历 0 ~ max_fd:因为 fd_set 是位图,没法"只看就绪的"
  3. fd 数字会被 accept 一直推大:要维护 max_fd,否则 select 会漏看新 fd

5. 核心机制深挖

5.1 内核里 select 究竟做了什么?

图解说明:select 一次调用涉及:

  1. 2 次 O(n) 拷贝(用户↔内核)
  2. 1 次 O(n) 加等待队列
  3. 1 次 O(n) 遍历检查就绪状态
  4. 1 次 O(n) 用户态遍历找就绪 fd

——所以 1000 个 fd 时,select 一次至少做 4000+ 次操作。这就是它慢的根本原因。

5.2 等待队列:进程是怎么被唤醒的?

每个 fd 在内核里都有一个等待队列(wait queue)——所有"在等这个 fd 的进程"都挂在上面。

text
fd=4 (socket) 的内核数据结构
┌────────────────────────────┐
│ struct file                │
│ ├─ private_data → sock     │
│ │                          │
│ └─ wait_queue:             │
│     ├─→ Process A (在 select)
│     ├─→ Process B (在 epoll_wait)
│     └─→ Process C (在 read)
└────────────────────────────┘

当数据到达 fd=4 时:
    内核遍历 wait_queue → 唤醒 A、B、C
    (这就是惊群问题的根源)

6. select 的三大致命缺陷

缺陷 1:fd 数量上限 1024

c
FD_SET(2000, &rfds);   // ⚠️ 编译能过,运行时未定义行为(写到位图外内存了)

影响:写不了高并发服务器。

缺陷 2:每次 O(n) 拷贝 + O(n) 遍历

监听 10000 个 fd 但只有 1 个就绪?select 还是会做 10000 次 bit 检查。

影响:fd 越多 select 越慢,这是它根本性的瓶颈。

缺陷 3:fd_set 被改写,必须每次重置

c
fd_set rfds;
FD_ZERO(&rfds);
FD_SET(3, &rfds);

select(4, &rfds, NULL, NULL, NULL);     // 调用后 rfds 被改写
// 现在 rfds 里只有"就绪"的 fd,"原本想监听"的信息丢了

影响:必须用两个 fd_set(master + working),增加心智负担。


7. 动手实践

实践 1:编译运行 echo 服务器

请直接看本页底部 [💻 示例代码] 区,包含 4 个由浅入深的样例:

文件内容
01_simple_select.c入门:监听 stdin 5 秒超时演示
02_echo_server.c实战:select 版多客户端 echo 服务器
03_timer_loop.c进阶:用 select 的 timeout 当定时器
04_fd_set_internals.c内幕:手动操作 fd_set 看位图原理

实践 2:用 telnet 测试 echo 服务器

bash
# 终端 1
gcc 02_echo_server.c -o echo_srv && ./echo_srv 9999

# 终端 2
telnet localhost 9999
# 输入啥都会被原样返回

# 终端 3
telnet localhost 9999
# 同时第二个客户端,select 也能处理

实践 3:观察 select 系统调用

bash
strace -e trace=select ./echo_srv 9999 &
SRV=$!

# 在另一个终端 telnet 进去
echo "hello" | nc localhost 9999

# 看 strace 输出,会看到类似:
# select(5, [3 4], NULL, NULL, NULL) = 1 (in [4])
# select(5, [3 4], NULL, NULL, NULL) = 1 (in [4])
# ...

kill $SRV

解读:每次 select 返回时,[3 4] 这个数组就是 select 改写后的"就绪 fd 集合"——可以直接看到 select 是怎么和内核打交道的。


8. 常见陷阱

🪤 陷阱 1:忘记每次重置 fd_set

c
// ❌ 错误:fd_set 调用一次就被改写了
fd_set rfds;
FD_ZERO(&rfds);
FD_SET(fd, &rfds);
while (1) {
    select(fd + 1, &rfds, NULL, NULL, NULL);   // 第二次开始 fd_set 是空的!
    // ...
}

// ✅ 正确:用 master + working 两份
fd_set master, working;
FD_ZERO(&master);
FD_SET(fd, &master);
while (1) {
    working = master;
    select(fd + 1, &working, NULL, NULL, NULL);
}

🪤 陷阱 2:nfds 写小了

c
FD_SET(10, &rfds);
select(5, &rfds, ...);   // ❌ nfds=5,但你监听的 fd 是 10,永远不会触发!

正确:nfds 必须是「最大 fd 数字 + 1」。

🪤 陷阱 3:对 fd > 1024 的使用

c
int fd = open(...);   // 假设拿到 fd = 2000
FD_SET(fd, &rfds);   // ❌ 越界写入,可能踩坏内存

解决:要么用 poll/epoll,要么自己确保 fd 不超 1024(关掉一些不用的)。

🪤 陷阱 4:超时时间被改写

部分 Linux 实现下,select 返回后会修改 timeout 为剩余时间。所以循环里要重置 timeout

c
struct timeval tv = {5, 0};
while (1) {
    tv.tv_sec = 5; tv.tv_usec = 0;   // 必须重置
    select(...);
}

9. 本章小结

text
select 的关键事实清单
─────────────────────────────────
✅ 第一个多路复用 syscall,跨平台(POSIX)
✅ 用位图传递 fd 集合,1024 个 fd 上限
✅ 一次调用:2 次 O(n) 拷贝 + 2 次 O(n) 遍历
✅ fd_set 会被内核改写,必须 master + working
✅ 三大缺陷 → poll 解决"1024 限制",epoll 解决"O(n)"

10. 真实面试题

Q1:select 为什么有 1024 这个限制?能改吗?

A:因为 fd_set 的底层是 1024 bit 的固定大小位图(__FD_SETSIZE 编译期常量)。理论上可以重新编译 glibc 改大,但实践不可行——影响太大且不解决性能问题,应直接用 poll/epoll。

Q2:select 调用后为什么 fd_set 不能直接复用?

A:select 内核里会修改传入的 fd_set——把"想监听"的 bit 改成"已就绪"的 bit。所以下次循环必须重新设置(标准做法是 master/working 两份)。

Q3:select 性能为什么慢?请定量说明。

A:每次调用涉及——① 用户→内核拷贝整个 fd_set(O(n));② 内核遍历所有 fd 检查就绪(O(n));③ 内核→用户拷贝改写后的 fd_set(O(n));④ 用户态用 FD_ISSET 找就绪 fd(O(n))。4 倍 O(n) + 系统调用开销,万级 fd 时性能崩溃。

Q4:select 和 poll 的本质区别是什么?

A:① 数据结构:select 用位图(固定 1024 大小),poll 用结构体数组(无上限);② 接口:select 有 3 张表(读/写/错),poll 用同一个 pollfd 通过 events 字段区分;③ 复用:select 的 fd_set 会被改写,poll 的 reventsevents 分离不会污染。性能本质都是 O(n),没有质的飞跃

Q5:用 select 写 echo 服务器,每次循环必须做什么?为什么?

A:① 重新拷贝 master fd_set 给 working——因为 select 会改写;② 重新设置超时时间——某些实现下 timeout 会被改写为剩余时间;③ 遍历 0~max_fd 的所有位——因为位图没法只看就绪的。


下一站第 4 章:poll 详解,看 1997 年 System V 的 poll 是怎么用结构体数组优雅地解决 1024 上限的,以及为什么它仍然不够快。

🎬 可视化演示

演示加载缓慢或样式异常?点此在新标签页打开 ↗

💻 示例代码

c
/*
 * 01_simple_select.c —— 入门:select 监听 stdin 5 秒超时
 *
 * 编译: gcc 01_simple_select.c -o simple_select
 * 运行: ./simple_select
 * 玩法: 5 秒内输入任意文字回车,或啥都不做等超时
 */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/select.h>
#include <errno.h>

int main(void) {
    fd_set rfds;
    struct timeval tv;
    int retval;

    FD_ZERO(&rfds);
    FD_SET(STDIN_FILENO, &rfds);

    tv.tv_sec  = 5;
    tv.tv_usec = 0;

    printf("⏰ 5 秒内输入任意文字...\n");
    fflush(stdout);

    /*
     * select 的第一个参数是「最大 fd + 1」
     * STDIN_FILENO == 0,所以这里是 1
     */
    retval = select(STDIN_FILENO + 1, &rfds, NULL, NULL, &tv);

    if (retval == -1) {
        perror("select error");
        return EXIT_FAILURE;
    } else if (retval == 0) {
        printf("😴 5 秒到了,啥也没等到\n");
    } else {
        if (FD_ISSET(STDIN_FILENO, &rfds)) {
            char buf[256];
            ssize_t n = read(STDIN_FILENO, buf, sizeof(buf) - 1);
            if (n > 0) {
                buf[n] = '\0';
                printf("✅ 收到 %ld 字节: %s", n, buf);
            }
        }
    }
    return EXIT_SUCCESS;
}
c
/*
 * 02_echo_server.c —— select 版多客户端 echo 服务器
 *
 * 编译: gcc 02_echo_server.c -o echo_srv
 * 运行: ./echo_srv 9999
 * 测试: 开多个终端 `telnet localhost 9999` 或 `nc localhost 9999`
 *
 * 这个例子完整展示了 select 写服务器的标准套路:
 *   1) master / working 两份 fd_set
 *   2) 每次循环 working = master
 *   3) 遍历 0 ~ max_fd 检查就绪
 *   4) accept 新连接时维护 max_fd
 */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/select.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <errno.h>

#define BUF_SIZE 1024

static int make_listen_socket(int port) {
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd < 0) { perror("socket"); exit(1); }

    int yes = 1;
    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));

    struct sockaddr_in addr = {0};
    addr.sin_family      = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    addr.sin_port        = htons(port);

    if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        perror("bind"); exit(1);
    }
    if (listen(fd, 64) < 0) {
        perror("listen"); exit(1);
    }
    printf("🚀 echo 服务器已启动,端口 %d\n", port);
    return fd;
}

int main(int argc, char *argv[]) {
    int port = (argc > 1) ? atoi(argv[1]) : 9999;
    int listen_fd = make_listen_socket(port);

    fd_set master, working;
    FD_ZERO(&master);
    FD_SET(listen_fd, &master);
    int max_fd = listen_fd;

    while (1) {
        working = master;       /* select 会改写 working,必须每轮拷贝 */

        int n = select(max_fd + 1, &working, NULL, NULL, NULL);
        if (n < 0) {
            if (errno == EINTR) continue;
            perror("select");
            break;
        }

        for (int fd = 0; fd <= max_fd && n > 0; fd++) {
            if (!FD_ISSET(fd, &working)) continue;
            n--;

            if (fd == listen_fd) {
                /* 新连接到达 */
                struct sockaddr_in cli;
                socklen_t cli_len = sizeof(cli);
                int conn = accept(listen_fd, (struct sockaddr *)&cli, &cli_len);
                if (conn < 0) { perror("accept"); continue; }

                FD_SET(conn, &master);
                if (conn > max_fd) max_fd = conn;

                printf("✅ 新客户端 fd=%d, 来自 %s:%d (当前 max_fd=%d)\n",
                       conn, inet_ntoa(cli.sin_addr), ntohs(cli.sin_port), max_fd);
            } else {
                /* 已连接客户端有数据 */
                char buf[BUF_SIZE];
                ssize_t r = read(fd, buf, sizeof(buf));
                if (r <= 0) {
                    if (r == 0) printf("👋 客户端 fd=%d 断开\n", fd);
                    else perror("read");
                    close(fd);
                    FD_CLR(fd, &master);
                } else {
                    /* echo 回去 */
                    write(fd, buf, r);
                    printf("📨 fd=%d 收到 %ld 字节,已回 echo\n", fd, r);
                }
            }
        }
    }

    close(listen_fd);
    return 0;
}
c
/*
 * 03_timer_loop.c —— 用 select 的 timeout 当定时器
 *
 * 编译: gcc 03_timer_loop.c -o timer_loop
 * 运行: ./timer_loop
 *
 * 思路: 把 timeout 设成 1 秒,每次返回都打印一次 tick;
 *       同时还能监听 stdin,输入 'q' 退出。
 *       这是没有专用 timerfd 的年代,select 兼任定时器的常用技巧。
 */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/select.h>

int main(void) {
    fd_set rfds;
    struct timeval tv;
    int ticks = 0;

    printf("⏱  每秒打印一次 tick;输入 q + 回车退出\n");
    fflush(stdout);

    while (1) {
        FD_ZERO(&rfds);
        FD_SET(STDIN_FILENO, &rfds);
        tv.tv_sec  = 1;
        tv.tv_usec = 0;

        int n = select(STDIN_FILENO + 1, &rfds, NULL, NULL, &tv);
        if (n < 0) { perror("select"); break; }

        if (n == 0) {
            /* 超时 = 一个 tick */
            printf("🔔 tick #%d\n", ++ticks);
            fflush(stdout);
        } else {
            /* stdin 有数据 */
            char buf[64];
            ssize_t r = read(STDIN_FILENO, buf, sizeof(buf));
            if (r > 0 && buf[0] == 'q') {
                printf("👋 收到 q,退出\n");
                break;
            }
            printf("📨 你输入了: %.*s", (int)r, buf);
        }
    }
    return 0;
}
c
/*
 * 04_fd_set_internals.c —— fd_set 内部位图原理探秘
 *
 * 编译: gcc 04_fd_set_internals.c -o fd_internals
 * 运行: ./fd_internals
 *
 * 这个示例不调用 select,只是把 fd_set 的内部布局打印出来,
 * 帮助你直观理解「为什么 fd_set 是位图」、「为什么有 1024 限制」。
 */

#include <stdio.h>
#include <string.h>
#include <sys/select.h>

static void dump_fd_set(const char *tag, fd_set *s) {
    unsigned char *bytes = (unsigned char *)s;
    size_t bits = sizeof(*s) * 8;

    printf("[%s] 总大小 = %zu 字节 = %zu bit (理论最多支持 %zu 个 fd)\n",
           tag, sizeof(*s), bits, bits);

    /* 找出所有为 1 的 bit */
    printf("    bit 为 1 的 fd: ");
    int found = 0;
    for (size_t i = 0; i < bits; i++) {
        size_t byte = i / 8, bit = i % 8;
        if (bytes[byte] & (1u << bit)) {
            printf("%zu ", i);
            found = 1;
        }
    }
    if (!found) printf("(无)");
    printf("\n");

    /* 打印前 16 字节的二进制布局 */
    printf("    前 16 字节十六进制: ");
    for (size_t i = 0; i < 16 && i < sizeof(*s); i++) {
        printf("%02x ", bytes[i]);
    }
    printf("\n\n");
}

int main(void) {
    fd_set s;

    FD_ZERO(&s);
    dump_fd_set("初始 (FD_ZERO 后)", &s);

    FD_SET(0, &s);     /* fd=0 */
    FD_SET(3, &s);     /* fd=3 */
    FD_SET(7, &s);     /* fd=7 */
    FD_SET(63, &s);    /* fd=63 (位于第一个 long 的最高位) */
    FD_SET(64, &s);    /* fd=64 (跨到第二个 long) */
    dump_fd_set("FD_SET(0,3,7,63,64) 后", &s);

    FD_CLR(3, &s);
    dump_fd_set("FD_CLR(3) 后", &s);

    printf("FD_ISSET(7, &s) = %d\n", FD_ISSET(7, &s));
    printf("FD_ISSET(3, &s) = %d (已清除)\n", FD_ISSET(3, &s));

    printf("\n💡 关键启示:\n");
    printf("   1. 每个 fd 只占 1 bit -> 极度省空间\n");
    printf("   2. 位图大小 = FD_SETSIZE = 1024 -> 这是编译期常量\n");
    printf("   3. FD_SET(2000, &s) 会越界写入 -> 未定义行为,可能踩坏内存\n");
    return 0;
}

01_simple_select.c ↗ · 02_echo_server.c ↗ · 03_timer_loop.c ↗ · 04_fd_set_internals.c ↗