Python 单线程阻塞式 TCP 服务器并发缺陷:Slowloris 式卡死与 selectors 修复
关键词:TCP服务器 / socket并发 / 阻塞recv / selectors多路复用 / Slowloris / 单线程服务器 / 慢连接 / 并发缺陷 / Python网络编程
问题背景
写 TCP 服务端时常见的一个坑:使用「单线程 + 阻塞 recv」结构,看起来能收能回,但一旦有客户端连上却不发送数据,整个服务器就被卡死在 recv 上,再也无法 accept 新的连接。
这类缺陷正是 Slowloris(慢连接攻击) 能轻松拖垮服务器的原理——攻击者只建立连接但不发送数据,让服务器的 recv() 永远阻塞,所有合法用户全部超时。
技术栈:Python 3, socket, selectors, TCP, 多线程, 非阻塞 I/O。
复现步骤
git checkout 2026-09-08-1600
cd scenario/2026-09-08-1600
docker compose up -d --build
docker compose exec app bash
# 容器内:
python3 server/single_thread_server.py --port 12345 &
python3 client/demo_client.py --port 12345 --hold-ms 1500 --timeout 2.0
复现现象:conn#2 发送 hello 后等待响应超时(2002ms),报 socket.timeout。
[demo] conn#2 等响应超时 (2002ms) → 服务器被 conn#1 卡死,无法并发
[demo] 结果: FAIL (bug reproduced)
根本原因
single_thread_server.py 的主循环中:
while True:
conn, addr = srv.accept() # 阻塞等新连接
data = conn.recv(4096) # ← 阻塞读,读完才回到 accept
conn.sendall(b'PONG:' + data)
conn.close()
recv() 是阻塞调用,若客户端不发数据,它会一直阻塞。于是:
- 服务器接受了 conn#1 后,就卡在 conn#1 的
recv()上; - conn#2 虽然能
connect成功(内核 backlog 先缓存),但永远等不到 accept 和响应; - 整个服务只有 1 个连接被服务,其余全部卡死/超时。
解决办法
三种方案任选其一,原理都是让服务器不要长时间阻塞在单个连接的 recv 上:
方案 B:selectors 多路复用(推荐,单线程高效)
import socket, selectors
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.bind(('127.0.0.1', 12347))
srv.listen(128)
srv.setblocking(False) # 非阻塞是关键
sel = selectors.DefaultSelector()
sel.register(srv, selectors.EVENT_READ, on_accept)
def on_accept(sel, srv):
conn, addr = srv.accept()
conn.setblocking(False)
sel.register(conn, selectors.EVENT_READ, on_read)
def on_read(sel, conn):
data = conn.recv(65536)
if data:
conn.sendall(b'PONG:' + data)
else:
sel.unregister(conn)
conn.close()
while True:
events = sel.select() # 只等有可读事件,绝不空转
for key, _ in events:
key.data(sel, key.fileobj)
方案 A:多线程
concurrent_server.py 中对每个连接 threading.Thread(target=handle, args=(conn, addr), daemon=True).start()。
方案 C:recv 超时
conn.settimeout(3) 给 recv 设超时,超时后关闭该连接或回到 accept。
验证
用 selectors 版服务器(或官方多线程版)替换后运行同样的 demo_client.py:
python3 server/selectors_server.py --port 12347 &
python3 client/demo_client.py --port 12347 --hold-ms 1500 --timeout 2.0
输出:
[demo] conn#2 收到响应: b'PONG:hello' 耗时 0ms → 服务器能并发处理
[demo] 结果: PASS (concurrent)
修复前后对比:
| 服务器 | conn#1 空占 | conn#2 表现 |
|---|---|---|
| 单线程阻塞 | 1500ms | 超时(2002ms) |
| 多线程/selectors | 1500ms | 0ms 秒回 |
经验与避坑
- "单线程 + 阻塞 recv"是新手写网络服务最常见的并发缺陷。只要代码里有
recv()紧跟accept()且中间没有非阻塞/超时/多路复用,就存在被慢连接拖死的风险。 - connect 成功不代表服务器在处理你。内核 backlog 会先缓存连接,客户端 connect 能成功但后续 recv 等不到响应,说明服务器已被卡死。
- selectors 是 Python 标准库推荐的 I/O 多路复用方案。相比
select,接口更简洁,自动选择 epoll/kqueue。注意非阻塞 socket 的 recv 可能抛BlockingIOError,需 try/except 捕获。 - Slowloris 攻击防护:在生产环境中应同时设置
SO_RCVTIMEO、最大连接数、最大 header/body 大小等限制,不依赖单线程阻塞模型。 - docker-compose 构建坑:
dockerfile路径是相对于context解析的,不要写相对仓库根的../..前缀。另外node:22-bookworm-slim的 pip 需要加--break-system-packages才能安装包。
