select最老的前台
select——最老的”前台”,能用但不聪明
select 怎么工作
select 是 Linux 最早提供的 IO 多路复用函数。它的工作方式是:
-
你把所有要盯的 fd(文件描述符)写在一个集合里
-
调用 select——内核开始帮你盯
-
select 返回——告诉你”有几个 fd 就绪了”
-
你遍历整个集合,挨个问:“是你好了吗?是你好了吗?“
用”签到表”比喻
你是老师(线程),全班 500 个学生(500 个 fd)。每节课你想知道”谁举手了”。
select 方法:
-
你把全班 500 人的名字抄在一张纸上(fd_set)
-
把纸交给班长(内核),说”帮我看看谁举手了”
-
班长扫完全班 500 人 → 告诉你”有 3 个人举手了”
-
你把纸拿回来,从第 1 个人到第 500 个人,挨个问:“是你举手吗?""是你举手吗?”…(遍历整个集合)
-
找到第 47, 128, 399 号举手了 → 处理他们
-
下次上课 → 回到第 ① 步,重新抄一份名单(fd_set 每次都要重新填!)
关键缺陷:
-
每次都要把 500 人的名单从你手上抄给班长(数据从用户空间复制到内核空间)
-
班长每次要扫 500 人(内核遍历整个 fd_set)
-
班长只告诉你”有 3 个人举手了”但不说是谁——你还要挨个问(O(n))
-
名单最多写 1024 个人(select 默认上限 FD_SETSIZE)
select 的三个致命缺陷
缺陷①:fd 数量上限 1024(FD_SETSIZE)
select 用 fd_set 存文件描述符,它是一个固定大小的 bitmap,默认 1024 位。你的服务器如果要同时处理 10000 个连接 → select 直接不行。
为什么是 1024?→ 不是 Linux 内核的限制,是 C 语言宏定义写死了。可以修改内核重新编译——但改完还是 bitmap,大 bitmap 遍历效率照样低。
缺陷②:每次调用都要把整个 fd_set 从用户空间复制到内核空间
select 的参数是 fd_set,这个数据在用户空间(你的程序里)。内核要监视 fd,需要把这整个集合复制到内核空间。
10000 个 fd 的 fd_set ≈ 1.25KB,看起来不大?但这是每次 select 调用都要复制一次!高并发服务器每秒可能调用 select 成千上万次 → 拷贝开销可观。
缺陷③:返回后你需要遍历整个 fd_set 找”是谁就绪了”
select 返回只说”有 N 个 fd 就绪了”,但不告诉你具体是哪几个。你得从 fd 0 扫到 fd 1023,用 FD_ISSET 逐个检查。即使只有 1 个 fd 就绪,你也要扫完整个集合 → O(n)。n 越大越浪费。
select 的代码演示——看一次就知道它为什么”笨”
"""
select 模式演示(Python 封装了底层 select 调用)
注意 Python 的 select.select() 帮我们做了很多脏活,
但底层原理就是上面说的那些。
"""
import select
import socket
server = socket.socket()
server.bind(('0.0.0.0', 8080))
server.listen(1000)
server.setblocking(False) # 设为非阻塞模式
# 要监视的集合
inputs = [server] # 当前正在被 select 盯着的 fd 列表
outputs = [] # 等着写数据的 fd 列表
while True:
# 🔴 select 的核心调用:
# readable = 哪些 fd 可以读了(有数据到了 / 有新连接)
# writable = 哪些 fd 可以写了(缓冲区有空位)
# (最后一个参数是超时时间)
readable, writable, _ = select.select(inputs, outputs, inputs)
# ⚠️ 返回后——你得自己遍历所有可读的 fd
for fd in readable:
if fd == server:
# 这是服务端 socket——有新的客户端连接
client, addr = fd.accept()
client.setblocking(False)
inputs.append(client) # 把新客户端也加入监视列表
else:
# 这是客户端 socket——有数据到了
data = fd.recv(1024)
if data:
# 处理数据
handle(data)
# 准备回复——把 fd 加入 writable 列表
outputs.append(fd)
else:
# 对方关闭了连接
inputs.remove(fd)
if fd in outputs:
outputs.remove(fd)
fd.close()
for fd in writable:
# fd 可以写数据了
fd.send(b"ok")
outputs.remove(fd)
一图流(Mermaid)
flowchart TD A[把要盯的 fd 写进 fd_set] --> B[每次调用复制整个集合到内核] B --> C[内核遍历全部 fd] C --> D["返回:有 N 个就绪(不说是谁)"] D --> E["用户从 0 扫到 1023 找就绪(FD_ISSET)"] E --> F[处理就绪的 fd] F --> A style D fill:#ffe0b3 style E fill:#ffe0b3
三大痛点都画出来了:复制全量、内核全扫、用户再全扫。即便只有 1 个 fd 就绪,也要把整张表走一遍。
速记卡(面试闪卡)
Q1:select 是干什么的?
A:最早的 IO 多路复用函数。一个线程把多个 fd 交给内核盯着,内核告诉你”有 N 个就绪了”,让你不用一个连接开一个线程。
Q2:它的三个致命缺陷是哪三个?
A:① fd 数量上限 1024(FD_SETSIZE,C 宏写死);② 每次调用都要把整个 fd_set 从用户空间复制到内核空间;③ 返回后只给数量不给具体,你得 O(n) 遍历整个集合找就绪。
Q3:为什么返回后必须遍历?
A:内核只报”有几个就绪”,不告诉你”哪几个就绪”。你只能用 FD_ISSET 从 fd 0 挨个问到 fd 1023,哪怕实际只有 1 个就绪也要扫完。
Q4:fd_set 是什么?
A:一个固定大小的 bitmap(默认 1024 位),每次 select 调用都要重新把要监视的 fd 填进去,不能复用上次的。
Q5:poll / epoll 怎么治这仨毛病?
A:poll 用动态数组去掉 1024 上限;epoll 用红黑树 + 就绪链表,注册一次、内核只返回就绪的 fd,免复制免全扫(详见 14 / 15 篇)。
口诀:select 三宗罪——上限 1024、每次全拷贝、就绪还得遍历。