一个线程怎么管几百个连接?答案不是轮询,而是向内核问「这一批 socket 里,现在谁有事」。这篇用真实的 5 个连接、4 类场景,把 selectors 每一轮 select() 的返回都记下来。
实验脚本(节选) 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。
第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 并关闭,否则它会一直就绪。
不读, 第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)。
新连接刚建立, 可写? [True] 对端一直不读, 一共写进内核 740856 字节后 send 抛 BlockingIOError 此时 select 写事件: [] (空 = 不可写) 对端读走数据后, 可写? [True]
关注写事件通常是为了做背压:先尝试 send,写不完再注册 EVENT_WRITE 等缓冲区腾出空间。本次对端一直不读,累计写进去 740856 字节后 send 抛出 BlockingIOError,这时 select 的写事件为空;对端读走数据后,写事件马上回来。这个数字是本机、本次的内核缓冲区大小(发送缓冲加上对端接收缓冲的合计),换系统或改 SO_SNDBUF 会变。
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 密集或阻塞调用,单线程事件循环反而会被它拖住。
selectors、socket、threading