作者: andylin02
关键词: poll、I/O多路复用、Python网络编程、select.poll、非阻塞I/O、并发服务器


一、Python 中的 poll 对象

Python 的 select 模块提供了 poll() 方法,返回一个 poll 对象,用于实现 I/O 多路复用。poll 对象提供了与 C 语言 poll 系统调用类似的功能,但接口更加 Pythonic。

创建 poll 对象

import select
poll_obj = select.poll()

poll 对象的核心方法

方法说明
register(fd[, eventmask])注册一个文件描述符,eventmask 是事件掩码(默认 POLLIN | POLLPRI
unregister(fd)注销一个文件描述符
modify(fd, eventmask)修改已注册文件描述符的事件掩码
poll([timeout])等待事件发生,返回 (fd, event) 元组列表;timeout 单位为毫秒,-1 或 None 表示阻塞

常用事件常量(select 模块)

常量含义
select.POLLIN0x001有数据可读
select.POLLPRI0x002有紧急数据可读
select.POLLOUT0x004可写
select.POLLERR0x008错误(返回时自动检测)
select.POLLHUP0x010挂起(对端关闭,返回时自动检测)
select.POLLNVAL0x020无效请求(fd 未打开,返回时自动检测)

通常,对于普通 TCP 服务,我们只需要关心 POLLIN(新连接或数据到达)和 POLLOUT(可写)。错误和挂起事件无需显式注册,poll() 返回时会自动包含。

二、Python poll 的优点

优点说明
无文件描述符数量限制相比 select.select 受限于 FD_SETSIZEpoll 可以处理数千个连接
效率更高每次调用只需传入需要监视的 fd,内核返回时就绪列表,但 Python 层面仍需遍历,底层实现比 select 好
数据结构简单使用 register/unregister 动态管理 fd,不需要像 select 那样每次都重新构建列表
跨平台性较好在 Linux、BSD、macOS 上均支持,Windows 不支持 poll(需使用 select 或 asyncio
支持修改事件通过 modify 方法可以动态更改 fd 的监听事件,无需先注销再注册

三、Python poll 的缺点

缺点说明
Windows 不支持这是最大的局限,Windows 上没有原生 poll,程序无法跨平台(除非回退到 select
仍需遍历就绪列表poll 返回的是所有就绪的 fd 列表,仍需用户代码遍历处理,但比 select 的全量遍历要好一些
大并发下不如 asyncioPython 的 asyncio 使用更现代的事件循环(如 epoll 的封装),性能更好且代码更简洁

四、使用 poll 的完整示例:TCP Echo 服务器

以下代码实现了一个 Echo 服务器,支持多个客户端并发连接,使用 select.poll 进行 I/O 多路复用。

服务器代码(poll_echo_server.py)

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import select
import socket
import sys

HOST = '0.0.0.0'
PORT = 8888
MAX_CLIENTS = 100

def main():
    # 1. 创建监听 socket
    listen_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    listen_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    listen_sock.bind((HOST, PORT))
    listen_sock.listen(10)
    listen_sock.setblocking(False)   # 非阻塞模式,配合 poll
    print(f"Echo server listening on {HOST}:{PORT}")

    # 2. 创建 poll 对象并注册监听 socket
    poller = select.poll()
    # POLLIN:可读事件(新连接)
    poller.register(listen_sock, select.POLLIN)

    # 存储每个连接的待发送数据队列
    message_queues = {}
    # 存储 fd -> socket 对象的映射(因为 poll 返回只给 fd,需要根据 fd 获取 socket)
    fd_to_sock = {listen_sock.fileno(): listen_sock}

    try:
        while True:
            # 3. 调用 poll,阻塞等待事件,超时 None 表示永久阻塞
            events = poller.poll()
            for fd, event in events:
                sock = fd_to_sock.get(fd)
                if sock is None:
                    continue

                # 处理错误或挂起事件
                if event & (select.POLLERR | select.POLLHUP | select.POLLNVAL):
                    print(f"Error/hangup on fd {fd}, closing")
                    _close_connection(sock, poller, fd_to_sock, message_queues)
                    continue

                # 处理可读事件
                if event & select.POLLIN:
                    if sock is listen_sock:
                        # 新连接
                        try:
                            client_sock, client_addr = sock.accept()
                        except socket.error:
                            continue
                        client_sock.setblocking(False)
                        client_fd = client_sock.fileno()
                        print(f"New connection from {client_addr[0]}:{client_addr[1]}, fd={client_fd}")
                        # 注册到 poll,只监听读事件
                        poller.register(client_sock, select.POLLIN)
                        fd_to_sock[client_fd] = client_sock
                        message_queues[client_fd] = []
                    else:
                        # 客户端数据到达
                        try:
                            data = sock.recv(1024)
                        except (socket.error, ConnectionResetError):
                            data = None

                        if data:
                            print(f"Received from fd {fd}: {data.decode().strip()}")
                            # 将数据存入发送队列,并修改为监听可写事件
                            message_queues[fd].append(data)
                            # 修改事件,添加 POLLOUT
                            poller.modify(sock, select.POLLIN | select.POLLOUT)
                        else:
                            # 对端关闭连接
                            print(f"Client fd {fd} disconnected")
                            _close_connection(sock, poller, fd_to_sock, message_queues)

                # 处理可写事件
                if event & select.POLLOUT:
                    queue = message_queues.get(fd)
                    if queue:
                        # 发送所有待发数据
                        while queue:
                            data = queue.pop(0)
                            try:
                                sent = sock.send(data)
                                # 注意:如果一次没发完,实际应该处理部分发送,本例简化
                            except socket.error:
                                _close_connection(sock, poller, fd_to_sock, message_queues)
                                break
                    if not queue:
                        # 没有待发数据,改回只监听读事件
                        poller.modify(sock, select.POLLIN)

    except KeyboardInterrupt:
        print("\nServer shutting down...")
    finally:
        for sock in fd_to_sock.values():
            sock.close()
        print("All sockets closed.")

def _close_connection(sock, poller, fd_to_sock, message_queues):
    """关闭连接并清理相关资源"""
    fd = sock.fileno()
    poller.unregister(sock)
    sock.close()
    if fd in fd_to_sock:
        del fd_to_sock[fd]
    if fd in message_queues:
        del message_queues[fd]

if __name__ == '__main__':
    main()

五、代码关键点解析

1. 非阻塞模式

所有 socket 都必须设置为非阻塞模式:

listen_sock.setblocking(False)
client_sock.setblocking(False)

这是因为 poll 返回时只表示 I/O 操作不会阻塞,但实际的 acceptrecvsend 仍可能阻塞(例如在多线程环境下),非阻塞模式保证了事件循环的响应性。

2. 映射 fd → socket 对象

poll.poll() 返回的是 (fd, event) 元组,而不是 socket 对象本身。因此需要维护一个字典 fd_to_sock 来根据 fd 找到对应的 socket。

3. 事件修改

当有数据需要发送时,使用 poller.modify(sock, select.POLLIN | select.POLLOUT) 添加可写事件监听。数据发送完毕后,再改回只监听 POLLIN

4. 错误处理

  • POLLERR:发生错误(如连接重置)

  • POLLHUP:对端关闭连接(挂起)

  • POLLNVAL:文件描述符无效

这些事件无需显式注册,poll() 返回时会自动包含在 event 中。

六、测试方法

方式1:使用 telnet

telnet 127.0.0.1 8888

方式2:使用 nc

nc 127.0.0.1 8888

方式3:多客户端并发测试

可以同时打开多个终端运行 telnet,观察服务器输出。

七、poll 与 select 在 Python 中的性能对比

场景selectpoll
连接数 < 100性能相当性能相当
连接数 100-1000select 受限于 FD_SETSIZE(通常 1024)可以支持,遍历开销尚可
连接数 > 1000不适用(上限限制)性能开始下降,但比 select 好
海量连接(万级)无法支持仍然可用,但建议使用 epoll

在 Python 中,如果连接数超过几百,建议使用 select.poll 而不是 select.select。如果追求极致性能且仅在 Linux 上运行,可以使用 select.epoll

八、跨平台注意事项

  • Linux/BSD/macOSselect.poll 可用,性能良好

  • Windowsselect.poll 不可用,需要使用 select.select 或 asyncio(它内部会根据平台选择合适的事件循环)

因此,如果代码需要跨平台,通常需要条件判断或回退到 select

九、总结

Python 的 select.poll 是 select.select 的升级版,它突破了文件描述符数量限制,使用更灵活的事件管理接口,适合中高并发场景。掌握 poll 是理解更高级事件循环(如 epollasyncio)的必经之路。

对于新项目,建议直接使用 asyncio 或 selectors 模块,它们封装了底层多路复用的差异,提供更简洁的 API。


本文为个人学习笔记,仅用于知识分享。如有错误,欢迎指正。

👍🏻 点赞 + 收藏 + 分享,让更多开发者看到这篇深度解析!❤️ 如果觉得有用,请给个赞支持一下作者!

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐