bigywater.tech
Python · 72

selectors:一个线程如何监听多个 socket

一个线程怎么管几百个连接?答案不是轮询,而是向内核问「这一批 socket 里,现在谁有事」。这篇用真实的 5 个连接、4 类场景,把 selectors 每一轮 select() 的返回都记下来。

KqueueSelector
本机 DefaultSelector 自动选的实现(macOS)
626168 字节
对端不读时,send 写满内核发送缓冲区抛 BlockingIOError 的位置
41 vs 67 ms
200 连接×20 轮 echo:1 个线程 selectors / 201 个线程

做的事:5 个连接,1 个线程

所有服务端 socket 设为非阻塞,一起注册到 DefaultSelector
实验脚本(节选) sel = selectors.DefaultSelector() for i in range(5): c = socket.create_connection(("127.0.0.1", port)); s, _ = lsn.accept() s.setblocking(False) sel.register(s, selectors.EVENT_READ, data=f"c{i}") for key, mask in sel.select(timeout=1): # 返回「此刻就绪」的那几个 data = key.fileobj.recv(100)
实测 · 本机执行输出
DefaultSelector = KqueueSelector | 可用实现: ['SelectSelector', 'PollSelector', 'KqueueSelector']

DefaultSelector 是个别名,由标准库按平台挑最合适的实现:这台 Mac 上是 kqueue,Linux 上会是 epoll。三种实现(select/poll/kqueue)在本机都能用,但接口一致——业务代码只面对 register / select。

验证一:select() 只返回「此刻有事」的连接

一次调用同时知道多个连接的状态
实测 · 本机执行输出
第1轮 c1,c3 发了数据: select() 用时    0.0 ms, 返回 2 个就绪: ["c1 → b'one'", "c3 → b'three'"]
第2轮 谁都没发: select() 用时  301.0 ms, 返回 0 个就绪: []
第3轮 c0 发数据, c4 关闭: select() 用时    0.0 ms, 返回 2 个就绪: ["c0 → b'zero'", "c4 → b'' (对端已关闭, unregister)"]

第 1 轮 c1、c3 有数据,select() 一次返回这两个,几乎不花时间;第 2 轮没人发数据,它阻塞了整整 301.0 ms(超时设的是 0.3 s),期间线程不占 CPU;第 3 轮 c0 有数据、c4 关闭,返回的两个里 c4 读到的是 b''——对端关闭也表现为「可读」,读到空字节才知道对方走了,这时必须 unregister 并关闭,否则它会一直就绪。

验证二:水平触发,不读就一直通知

忘了 recv 的后果
实测 · 本机执行输出
  不读, 第1次 select: select() 用时    0.0 ms, 返回 1 个就绪: ['c2']
  不读, 第2次 select: select() 用时    0.0 ms, 返回 1 个就绪: ['c2']
  不读, 第3次 select: select() 用时    0.0 ms, 返回 1 个就绪: ['c2']
  第4次 select 并读走: select() 用时    0.0 ms, 返回 1 个就绪: ["c2 → b'unread'"]
  读走后再 select: select() 用时  201.0 ms, 返回 0 个就绪: []

c2 发了数据、服务端故意不读:连续三次 select() 都立刻返回 c2。selectors 的语义是水平触发——只要内核缓冲区里还有数据,就一直报就绪。好处是不容易漏事件(读一半也不会丢通知);代价是「某个连接就绪却没处理」会让事件循环空转,CPU 空转在监控里很像死循环。读走 b'unread' 之后,再 select 才回到阻塞到超时(200.4 ms)。

验证三:EVENT_WRITE 看的是发送缓冲区

刚建立的连接可写;写满之后不可写
实测 · 本机执行输出
新连接刚建立, 可写? [True]
对端一直不读, 一共写进内核 740856 字节后 send 抛 BlockingIOError
此时 select 写事件: [] (空 = 不可写)
对端读走数据后, 可写? [True]

关注写事件通常是为了做背压:先尝试 send,写不完再注册 EVENT_WRITE 等缓冲区腾出空间。本次对端一直不读,累计写进去 740856 字节后 send 抛出 BlockingIOError,这时 select 的写事件为空;对端读走数据后,写事件马上回来。这个数字是本机、本次的内核缓冲区大小(发送缓冲加上对端接收缓冲的合计),换系统或改 SO_SNDBUF 会变。

验证四:一个线程 vs 一个连接一个线程

200 个连接,每个 echo 20 次
实测 · 本机执行输出
selectors:    40.7 ms, 峰值线程数 1
线程/连接:    66.6 ms, 峰值线程数 201

连续跑了三次:selectors 版 40.7–46.6 ms,线程版 66.6–67.4 ms,峰值线程数 1 对 201。同样是回环地址上的 echo,单线程 selectors 更快,线程数从 201 降到 1。要注意这个测试的连接始终活跃、每次只传 4 字节,是最有利于事件循环的情形;它证明的是「连接数上去之后线程开销很实在」,并不是说 selectors 在任何负载下都更快——处理函数如果里面有 CPU 密集或阻塞调用,单线程事件循环反而会被它拖住。

分步演示:同一个线程,八次 select() 的返回

每一步的返回值都是上面实验的原始记录
一个线程监听 5 个连接
这一轮被 select() 返回的连接

    本文用到的工具与边界

    • 本机 macOS 27 / Python 3.14.7,标准库 selectors、socket、threading
    • 只测了回环地址和 kqueue;epoll 的边沿触发(EPOLLET)、Windows 上的 select 上限、连接数上万后的表现这里都没有验证
    京ICP备2026067502号-1京公网安备11010502063487号