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的工作流程,其实挺朴素的:

  1. 你把关心的文件描述符(socket)放到三个集合里:读集合、写集合、异常集合
  2. 调用select,内核帮你检查这些描述符的状态
  3. 如果有描述符就绪了,select返回,你遍历所有描述符,找到就绪的那个
  4. 处理完事件,再重新把描述符加进去,继续下一轮

我画了一张图,帮你理解这个过程:

用户空间 内核空间 fd_set readfds {3, 5, 7, 9, ...} fd_set writefds {4, 6, 8, ...} fd_set exceptfds {} 拷贝 遍历检查所有fd O(n) 复杂度 就绪fd标记 fd[3]=可读, fd[7]=可读 返回 遍历所有fd,找到就绪的 再次 O(n) 遍历

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。记住,技术选型要看场景,没有银弹。

好了,今天就到这里。代码示例你可以直接拿去跑,试试看不同连接数下的表现。有什么问题,欢迎交流。

可可精品资料库