- 文档
- 教程
- 开发工具
【免费下载链接】pysheeet
Python Cheat Sheet
在 pysheeet 的 Async Socket I/O 章节 中,完整呈现了 Python 网络编程中最关键的性能主题:如何用单线程同时处理成千上万个并发连接。本文以该文档为主体,结合仓库 socket 测试源码 与 网络章节索引,系统讲解select、poll、epoll、kqueue四种底层 I/O 多路复用机制,以及selectors这个官方推荐的高层封装,并给出可直接复制运行的回显服务器实现。读完本文,你将掌握事件驱动服务器从「原理 → 手写 → 工程化」的完整进阶路径,即使最终使用asyncio等高层框架,也能理解其底层依赖的同一套概念。
一、为什么需要异步 I/O:从「每连接一线程」到「单线程多路复用」
构建高性能网络服务器的第一道坎是并发模型。传统的阻塞式 I/O 采用「每连接一线程」模式:每个客户端连接占用一个操作系统线程,线程在recv()/send()上阻塞等待数据。这种方式存在两个致命问题:
- 内存开销:每个线程默认需要独立的栈空间(通常 MB 级),数千连接即消耗数 GB 内存;
- 上下文切换成本:线程频繁在可运行与阻塞状态间切换,CPU 大量时间消耗在调度而非业务处理上。
异步 I/O 的解法是I/O 多路复用(I/O Multiplexing):程序用一个线程同时监控多个 socket,内核告诉程序「哪些 socket 已经可读、哪些已经可写」,程序只处理就绪的那些。事件循环与线程模型的差异可参考仓库 asyncio 指南 中引用的对比示意图(即上图)。
Python 的多路复用 API 按演进顺序分为三代:
| 层次 | 机制 | 定位 |
|---|---|---|
| 第一代 | select | 最古老、最通用,跨平台但上限低 |
| 第二代 | poll | Unix 下的改进,无 FD 数量硬限制 |
| 第三代 | epoll/kqueue | Linux / BSD·macOS 各自的高性能事件通知机制 |
| 高层封装 | selectors | 自动选择最优机制的平台无关接口 |
下文五个服务器示例均监听localhost:5566,实现同样的功能:接受连接、读取客户端数据、原样写回(echo)。它们的差异只在事件监控手段,这也是理解各 API 精髓的最好方式。
二、select:最古老也最通用的多路复用
select()是最古老、可移植性最好的 I/O 多路复用系统调用,在 Windows、Linux、macOS 上几乎都可用。它同时监控三类文件描述符条件:
- 可读(readability):有数据可读、有新连接待 accept、对端关闭;
- 可写(writability):发送缓冲区有空间可写;
- 异常条件(exceptional):带外数据或错误。
它通过修改传入的 fd 集合来报告就绪情况,Python 的select.select(read_list, write_list, exceptional_list)直接对应这三类集合。但它有两个显著局限:
- FD 数量上限:典型的 1024 个文件描述符上限;
- O(n) 轮询复杂度:每次调用都要线性扫描全部被监控的描述符,连接越多效率越差。
from select import select import socket host = ('localhost', 5566) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind(host) sock.listen(5) read_list = [sock] write_list = [] messages = {} try: while True: readable, writable, _ = select(read_list, write_list, []) for s in readable: if s == sock: conn, addr = sock.accept() read_list.append(conn) else: msg = s.recv(1024) if msg: messages[s.fileno()] = msg write_list.append(s) else: read_list.remove(s) s.close() for s in writable: msg = messages.pop(s.fileno(), None) if msg: s.send(msg) write_list.remove(s) except KeyboardInterrupt: sock.close()该示例的关键设计要点:
- 监听 socket 与连接 socket 共用同一个
read_list:通过s == sock区分「新连接到达」与「已有连接可读」,这是单线程服务器的经典判别手法; - 可读与可写分开调度:
recv()到的数据暂存于messages字典(以fileno()为键),等 socket 可写时再send(),避免对未就绪 socket 的阻塞写; - 对端关闭检测:
recv()返回空字节串即代表对端 FIN,此时从read_list移除并关闭; SO_REUSEADDR:允许服务在 TIME_WAIT 状态未过期时立即重启,避免 "Address already in use" 错误;listen(5):指定内核 accept 队列长度(半连接 + 已完成连接),溢出时新连接会被拒绝。
三、poll:突破 FD 数量限制的 Unix 改进
poll()与select()逻辑相似,但通过事件位掩码(POLLIN/POLLOUT等)而非集合修改来传递就绪状态,消除了 1024 个 FD 的硬限制,在描述符数量较大时更高效。它仅在 Unix 系系统上可用。
import socket import select import contextlib host = 'localhost' port = 5566 connections = {} requests = {} responses = {} @contextlib.contextmanager def create_server(host, port): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.setblocking(False) s.bind((host, port)) s.listen(10) try: yield s finally: s.close() def accept(server, poll): conn, addr = server.accept() conn.setblocking(False) fd = conn.fileno() poll.register(fd, select.POLLIN) requests[fd] = conn connections[fd] = conn def recv(fd, poll): conn = requests.pop(fd, None) if not conn: return msg = conn.recv(1024) if msg: responses[fd] = msg poll.modify(fd, select.POLLOUT) else: poll.unregister(fd) conn.close() connections.pop(fd, None) def send(fd, poll): conn = connections.get(fd) msg = responses.pop(fd, None) if conn and msg: conn.send(msg) requests[fd] = conn poll.modify(fd, select.POLLIN) with create_server(host, port) as server: poll = select.poll() poll.register(server.fileno(), select.POLLIN) try: while True: events = poll.poll(1000) for fd, event in events: if fd == server.fileno(): accept(server, poll) elif event & (select.POLLIN | select.POLLPRI): recv(fd, poll) elif event & select.POLLOUT: send(fd, poll) except KeyboardInterrupt: pass相比 select 版本,poll 示例引入了三个工程化改进:
setblocking(False):服务器与每个连接 socket 都设为非阻塞,即使事件通知有竞态,recv()/send()也不会阻塞整个事件循环;poll.register(fd, event_mask)/poll.modify(fd, mask)/poll.unregister(fd):注册、修改、注销监控事件,用POLLIN/POLLOUT在「读」与「写」两种兴趣之间切换,实现读-写状态机;poll.poll(1000):超时参数 1000 毫秒,即使没有任何事件,事件循环也会周期性返回,便于插入定时任务或处理信号。
四、epoll:Linux 上的性能之王
epoll是 Linux 特有的、为海量连接而生的机制。它的核心优势体现在:
- O(1) 复杂度:内核维护就绪链表,只返回真正就绪的 FD,无需每次扫描全部描述符;
- 两种触发模式:
- 水平触发(Level-Triggered):只要缓冲区仍有数据/仍有空间,就会持续通知,类似
select/poll,不易漏事件、编码简单; - 边缘触发(Edge-Triggered):仅在状态变化瞬间通知一次,效率更高但要求程序一次性把数据读完/写尽,否则可能漏事件,编码更谨慎;
- 水平触发(Level-Triggered):只要缓冲区仍有数据/仍有空间,就会持续通知,类似
- 内核级维护:FD 的注册、删除都发生在内核事件表上,不随每次调用复制集合。
import socket import select import contextlib host = 'localhost' port = 5566 connections = {} requests = {} responses = {} @contextlib.contextmanager def create_server(host, port): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.setblocking(False) s.bind((host, port)) s.listen(10) try: yield s finally: s.close() def accept(server, epoll): conn, addr = server.accept() conn.setblocking(False) fd = conn.fileno() epoll.register(fd, select.EPOLLIN) requests[fd] = conn connections[fd] = conn def recv(fd, epoll): conn = requests.pop(fd, None) if not conn: return msg = conn.recv(1024) if msg: responses[fd] = msg epoll.modify(fd, select.EPOLLOUT) else: epoll.unregister(fd) conn.close() connections.pop(fd, None) def send(fd, epoll): conn = connections.get(fd) msg = responses.pop(fd, None) if conn and msg: conn.send(msg) requests[fd] = conn epoll.modify(fd, select.EPOLLIN) with create_server(host, port) as server: epoll = select.epoll() epoll.register(server.fileno(), select.EPOLLIN) try: while True: events = epoll.poll(1) for fd, event in events: if fd == server.fileno(): accept(server, epoll) elif event & select.EPOLLIN: recv(fd, epoll) elif event & select.EPOLLOUT: send(fd, epoll) except KeyboardInterrupt: pass finally: epoll.close()代码结构与 poll 版本几乎一一对应,只是把POLLIN/POLLOUT换成EPOLLIN/EPOLLOUT、poll()换成epoll.poll(1)(1 秒超时),并在finally中显式epoll.close()释放内核事件表。这也是理解不同多路复用 API 的诀窍:它们解决的是同一个问题,只是内核实现与 API 形式不同。
epoll的事件位掩码还可组合EPOLLET(边缘触发)、EPOLLONESHOT(一次性通知后需重新武装)等选项;Linux 上单个 epoll 实例可监控的描述符数量仅受系统资源限制,这正是它能支撑「数万连接」的根源。
五、kqueue:BSD/macOS 上的对应物
kqueue是 BSD 与 macOS 平台上与 epoll 对等的机制,同样以 O(1) 复杂度处理大量描述符。与 epoll 的「fd + 事件掩码」模型不同,kqueue 以kevent事件结构体为核心:每个事件由过滤器(filter)、标志(flags)与数据构成,通过kq.control()批量提交增删改操作。
import socket import select import contextlib if not hasattr(select, 'kqueue'): print("kqueue not supported on this platform") exit(1) host = 'localhost' port = 5566 connections = {} requests = {} responses = {} @contextlib.contextmanager def create_server(host, port): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.setblocking(False) s.bind((host, port)) s.listen(10) try: yield s finally: s.close() def accept(server, kq): conn, addr = server.accept() conn.setblocking(False) fd = conn.fileno() ke = select.kevent(fd, select.KQ_FILTER_READ, select.KQ_EV_ADD) kq.control([ke], 0) requests[fd] = conn connections[fd] = conn def recv(fd, kq): conn = requests.pop(fd, None) if not conn: return msg = conn.recv(1024) if msg: responses[fd] = msg # Switch from read to write ke_del = select.kevent(fd, select.KQ_FILTER_READ, select.KQ_EV_DELETE) ke_add = select.kevent(fd, select.KQ_FILTER_WRITE, select.KQ_EV_ADD) kq.control([ke_del, ke_add], 0) requests[fd] = conn else: ke = select.kevent(fd, select.KQ_FILTER_READ, select.KQ_EV_DELETE) kq.control([ke], 0) conn.close() connections.pop(fd, None) def send(fd, kq): conn = connections.get(fd) msg = responses.pop(fd, None) if conn and msg: conn.send(msg) # Switch from write to read ke_del = select.kevent(fd, select.KQ_FILTER_WRITE, select.KQ_EV_DELETE) ke_add = select.kevent(fd, select.KQ_FILTER_READ, select.KQ_EV_ADD) kq.control([ke_del, ke_add], 0) requests[fd] = conn with create_server(host, port) as server: kq = select.kqueue() ke = select.kevent(server.fileno(), select.KQ_FILTER_READ, select.KQ_EV_ADD) kq.control([ke], 0) try: while True: events = kq.control(None, 1024, 1) for e in events: fd = e.ident if fd == server.fileno(): accept(server, kq) elif e.filter == select.KQ_FILTER_READ: recv(fd, kq) elif e.filter == select.KQ_FILTER_WRITE: send(fd, kq) except KeyboardInterrupt: pass finally: kq.close()kqueue 示例独有的关键点:
- 平台探测:
hasattr(select, 'kqueue')先行检查,因为该 API 在 Linux/Windows 上不存在; kevent(ident, filter, flags)三元组:ident是被监控的 fd,filter为KQ_FILTER_READ或KQ_FILTER_WRITE,flags为KQ_EV_ADD/KQ_EV_DELETE;- 读/写切换 = 删除一个过滤器 + 添加另一个:
recv()后把KQ_FILTER_READ换成KQ_FILTER_WRITE,send()后换回,两个kevent一次control()批量提交; kq.control(None, 1024, 1):第一个参数为None表示只取事件不修改;1024 为一次最多取回的事件数;1 为超时秒数;返回事件的e.ident即 fd,e.filter即事件类型。
六、selectors:官方推荐的高层封装
selectors模块(Python 3.4+)把上述全部机制封装成统一接口,核心是DefaultSelector()——它在当前平台自动选择最优实现(Linux 用EpollSelector,BSD/macOS 用KqueueSelector,其他平台退回SelectSelector/PollSelector)。开发者无需关心平台差异,同时获得最高的可移植性与性能。
import selectors import socket import contextlib @contextlib.contextmanager def create_server(host, port): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((host, port)) s.listen(10) sel = selectors.DefaultSelector() try: yield s, sel finally: s.close() sel.close() def accept_handler(sock, sel): conn, addr = sock.accept() sel.register(conn, selectors.EVENT_READ, read_handler) def read_handler(conn, sel): msg = conn.recv(1024) if msg: conn.send(msg) else: sel.unregister(conn) conn.close() host = 'localhost' port = 5566 with create_server(host, port) as (sock, sel): sel.register(sock, selectors.EVENT_READ, accept_handler) try: while True: events = sel.select() for key, mask in events: handler = key.data handler(key.fileobj, sel) except KeyboardInterrupt: passselectors 版将「事件监控」与「业务处理」彻底解耦,代码量大幅缩减:
register(fileobj, EVENT_READ, data):第三个参数data可以是任意对象(这里直接存放回调函数),事件到来时随key.data一并返回——这是典型的「回调注册」模式;- 事件分发:
sel.select()返回(key, mask)列表,key.fileobj是就绪的 socket,key.data是注册时挂载的回调,一行handler(key.fileobj, sel)完成分发; - 无状态字典:读-写状态机不再需要
requests/responses字典,回调闭包天然携带上下文; - 资源管理:
sel.close()在create_server的finally中与s.close()一同释放。
该模式的正确性在仓库测试中得到了直接验证:源码 src/basic/socket_.py 的TestSelectorsEcho(L241-L298)用selectors.DefaultSelector()实现了与本文相同的回显逻辑——服务器线程在 15569 端口上运行事件循环,客户端发送b"Async Hello"并断言收到相同响应。测试中还使用了sel.select(timeout=0.1)超时轮询与stop_server事件实现优雅退出,可作为生产代码中「可控关闭事件循环」的参考模板。
七、五种方案横向对比
| 方法 | 平台 | 扩展性 | 说明 |
|---|---|---|---|
| select | 全部 | O(n) - 有限 | 约 1024 个 FD 上限 |
| poll | Unix | O(n) - 更好 | 无 FD 数量限制 |
| epoll | Linux | O(1) - 极佳 | 支持边缘/水平触发 |
| kqueue | BSD/macOS | O(1) - 极佳 | 与 epoll 类似 |
| selectors | 全部 | 自动取最优 | 新代码推荐使用 |
选型建议(来自原文档):新代码一律使用selectors模块或asyncio做异步 I/O;底层 API(select、poll、epoll、kqueue)主要用于理解异步 I/O 的工作原理,或在需要细粒度控制(如EPOLLET边缘触发、KQ_EV_ONESHOT等)的场景下使用。
八、从多路复用到 asyncio:进阶路径
理解本文的五个示例后,向上层的自然进阶是asyncio——它的事件循环本质上就是围绕本文这些多路复用原语构建的,只是把「手动维护 fd 与状态字典」的苦力活交给了框架。仓库的 Asyncio 基础、Asyncio 网络编程(TCP/UDP 服务器、HTTP、SSL/TLS)与 Asyncio 进阶(同步原语、队列、子进程)三篇文档提供了从本文直接衔接的完整课程,其配套测试见 src/basic/asyncio_.py。
本文所属的 Network 系列在仓库中构成完整的网络编程知识链,建议按序阅读:
- Socket Basics:DNS 解析、IP/字节序转换、超时与优雅关闭;
- Socket Servers:TCP/UDP 服务器、IPv6 双栈、Unix 域套接字、sendfile 零拷贝;
- Async Socket I/O(本文):I/O 多路复用与事件驱动服务器;
- SSL/TLS Sockets:加密通信;
- Packet Sniffing:网络分析;
- SSH and Tunnels:安全远程访问与隧道。
整个系列从 Network 索引 进入,也可通过仓库 README 的 Network 章节直达;上述所有代码示例与测试均可直接在本地python3环境运行验证(示例端口 5566/15569 均可自行调整)。
- 文档
- 教程
- 开发工具
【免费下载链接】pysheeet
Python Cheat Sheet
相关推荐
Datacore:3步快速上手Obsidian最强数据可视化插件
Datacore:3步快速上手Obsidian最强数据可视化插件 想要在Obsidian中实现像Notion一样强大的数据可视化功能吗?Datacore正是你需
nanomsg I/O多路复用机制:select、poll与epoll终极性能对比指南
nanomsg I/O多路复用机制:select、poll与epoll终极性能对比指南 nanomsg作为一款高性能的 网络通信库 ,其底层I/O多路复用机制的
消息队列通信nanomsg异步I/O模型终极指南:epoll与kqueue性能对比分析
nanomsg异步I/O模型终极指南:epoll与kqueue性能对比分析 在现代网络编程中, 异步I/O模型 是实现高性能应用的关键技术。nanomsg作为一
消息队列通信
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考