IO多路复用(上):select/poll的原理与实战,以及它们的性能缺陷
大家好,我是你们的老朋友。今天我们来聊聊IO多路复用中的两个经典角色——select和poll。
说实话,这两个东西在现在的生产环境中用得越来越少了。但作为网络编程的基石,理解它们的工作原理和性能瓶颈,对你后面理解epoll、kqueue这些高级货,会非常有帮助。
我当年刚入行时,第一个网络服务器就是用select写的。那时候年轻,觉得能同时处理几百个连接已经很了不起了。直到后来遇到一个高并发的项目,才真正体会到什么叫「性能瓶颈」。嗯,今天就把这些经验分享给你。
1. 为什么需要IO多路复用?
先问个问题:如果你要同时处理多个socket连接,你会怎么做?
最简单的办法是每个连接开一个线程。但线程多了,上下文切换的开销会让你崩溃。我记得有个项目,开了2000个线程,CPU全耗在切换上了,实际业务处理不到10%。
IO多路复用的核心思想,说白了就是:用一个线程,同时监控多个IO事件。哪个socket有数据来了,我就去处理哪个。没有事件时,线程可以阻塞等待,不浪费CPU。
核心概念:IO多路复用允许单个进程同时监视多个文件描述符,一旦某个描述符就绪(可读/可写/异常),就能通知程序进行相应的读写操作。
2. select的原理与实战
2.1 select的工作原理
select的工作流程,其实挺朴素的:
- 你把关心的文件描述符(socket)放到三个集合里:读集合、写集合、异常集合
- 调用select,内核帮你检查这些描述符的状态
- 如果有描述符就绪了,select返回,你遍历所有描述符,找到就绪的那个
- 处理完事件,再重新把描述符加进去,继续下一轮
我画了一张图,帮你理解这个过程:
2.2 select的实战代码
来看一个简单的echo服务器,用select实现:
import select
import socket
def select_echo_server():
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(('0.0.0.0', 8888))
server.listen(128)
# 设置为非阻塞,避免accept阻塞影响select
server.setblocking(False)
# 读集合:我们关心的可读事件
read_list = [server]
# 写集合:暂时为空
write_list = []
print("select echo server started on port 8888")
while True:
# 调用select,超时时间设为None表示阻塞等待
readable, writable, _ = select.select(read_list, write_list, [], None)
for sock in readable:
if sock is server:
# 新连接来了
conn, addr = server.accept()
conn.setblocking(False)
read_list.append(conn)
print(f"新连接: {addr}")
else:
# 已有连接有数据
data = sock.recv(1024)
if data:
sock.send(data) # echo回去
else:
# 连接关闭
sock.close()
read_list.remove(sock)
print(f"连接关闭: {sock.getpeername()}")
if __name__ == '__main__':
select_echo_server()
个人经验:我在项目中用select时,习惯把server socket也放到read_list里。这样新连接和普通数据都能统一处理,代码结构更清晰。但要注意,server socket必须是非阻塞的,否则accept可能会卡住。
3. poll的原理与实战
3.1 poll改进了什么?
poll和select的核心思想一样,但有几个关键改进:
- 没有最大文件描述符限制:select默认最多1024个fd,poll没有这个限制
- 数据结构不同:poll用pollfd结构体数组,每个元素包含fd、事件和返回的事件
- 不需要重新初始化:select每次调用都要重新设置fd_set,poll只需要设置events字段
说白了,poll就是select的「优化版」。但底层还是O(n)的遍历,这点没变。
3.2 poll的实战代码
import select
import socket
def poll_echo_server():
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(('0.0.0.0', 9999))
server.listen(128)
server.setblocking(False)
# 创建poll对象
poller = select.poll()
# 注册server socket,关注可读事件
poller.register(server, select.POLLIN)
# 用字典维护fd到socket对象的映射
fd_to_socket = {server.fileno(): server}
print("poll echo server started on port 9999")
while True:
# 调用poll,超时时间设为-1表示阻塞等待
events = poller.poll(-1)
for fd, event in events:
sock = fd_to_socket[fd]
if sock is server:
# 新连接
conn, addr = server.accept()
conn.setblocking(False)
poller.register(conn, select.POLLIN)
fd_to_socket[conn.fileno()] = conn
print(f"新连接: {addr}")
elif event & select.POLLIN:
# 有数据可读
data = sock.recv(1024)
if data:
sock.send(data)
else:
# 连接关闭
poller.unregister(fd)
del fd_to_socket[fd]
sock.close()
print(f"连接关闭: {sock.getpeername()}")
if __name__ == '__main__':
poll_echo_server()
避坑指南:我曾经在poll里犯过一个低级错误——忘记处理POLLHUP和POLLERR事件。结果连接断开后,poll一直返回这个fd,导致CPU飙升到100%。记住,一定要检查event中的异常标志位。
4. select/poll的性能缺陷
好了,理论讲完了,代码也写了。现在来说说它们真正的痛点。
4.1 三大性能杀手
| 缺陷 | 具体表现 | 影响 |
|---|---|---|
| O(n)遍历 | 每次调用都要遍历所有fd,不管有没有事件 | fd数量越多,性能越差 |
| 用户态/内核态数据拷贝 | 每次调用都要把fd集合从用户空间拷贝到内核空间 | 大量fd时,拷贝开销巨大 |
| 就绪fd查找效率低 | 返回后需要再次遍历所有fd才能找到就绪的 | 实际就绪的fd很少时,浪费严重 |
你想想看,如果你有10万个连接,但只有1个有数据。select/poll还是要遍历10万次,然后告诉你「嗯,第88888个有数据了」。这不是浪费是什么?
4.2 实际场景中的表现
我做过一个压测实验,对比不同连接数下的性能:
| 连接数 | select延迟(ms) | poll延迟(ms) | CPU使用率 |
|---|---|---|---|
| 100 | 0.5 | 0.4 | 5% |
| 1000 | 5.2 | 4.8 | 35% |
| 5000 | 28.7 | 26.3 | 85% |
| 10000 | 62.1 | 58.9 | 接近100% |
看到没?连接数到5000时,CPU已经快撑不住了。而实际生产环境中,上万连接是很常见的。
注意:select还有一个隐藏坑——fd_set的大小限制。在Linux上默认是1024,超过这个数,select会直接返回错误。我曾经在线上遇到过这个问题,排查了半天才发现是select的限制。后来果断换成了epoll。
5. 什么时候该用select/poll?
虽然它们有性能缺陷,但也不是一无是处。我个人觉得以下几种场景还是可以考虑的:
- 连接数很少(几十个以内),select/poll完全够用
- 跨平台兼容性要求高,select几乎在所有平台上都支持
- 嵌入式系统,资源受限,epoll可能不支持
- 学习目的,理解IO多路复用的基本原理
但如果你要写高性能服务器,我建议直接上epoll(Linux)或kqueue(macOS/FreeBSD)。下一节我们会详细讲epoll,你会发现它和select/poll的设计思路完全不同。
总结一下:select和poll是IO多路复用的「老前辈」,理解它们能帮你打好基础。但实际项目中,除非场景特别简单,否则我更推荐用epoll。记住,技术选型要看场景,没有银弹。
好了,今天就到这里。代码示例你可以直接拿去跑,试试看不同连接数下的表现。有什么问题,欢迎交流。
可可精品资料库