跳到主要内容

Python 与操作系统

Python 程序不是跑在真空里:它向操作系统要地址空间、线程、文件描述符和信号。理解这些,才能解释 GIL 为什么存在、async 为什么不阻塞、multiprocessing 为什么能绕开 GIL、文件描述符为什么会泄漏。

1. 一张总表​

操作系统概念Python 接口应用开发中的表现
进程os.fork、multiprocessing、subprocessWeb 服务的多 worker、CPU 密集任务隔离
线程threading、concurrent.futures.ThreadPoolExecutorI/O 并发、GIL 竞争
协程asyncio高并发网络服务、WebSocket/SSE
虚拟内存 / 堆 / 栈mmap、resource、tracemalloc内存占用、OOM、缓存
文件描述符os.open、socket.fileno、selectors连接泄漏、Too many open files
I/O 多路复用select/poll/epoll、selectors、asyncioC10K、反向代理、数据库连接池
信号signal优雅停机、SIGTERM、Ctrl+C
进程间通信multiprocessing.Queue/Pipe/SharedMemoryworker 池、任务分发、共享缓存
调度与时间time、sched、asyncio 事件循环超时、重试、定时任务

2. 进程:隔离与并行​

2.1 fork 与写时复制​

在 Unix 上,os.fork() 创建一个子进程:父进程返回子进程 PID,子进程返回 0。

import os

pid = os.fork()
if pid == 0:
print("child", os.getpid())
os._exit(0)
else:
print("parent", os.getpid(), "child", pid)
os.waitpid(pid, 0)

fork 之后父子进程拥有各自独立的地址空间,但采用写时复制:刚 fork 完并不复制物理内存,谁写谁复制。这让「fork 一个进程」比「重新加载整个解释器」便宜。

2.2 multiprocessing 的启动方式​

平台默认 start method特点
Linuxfork快,但 fork 后线程状态可能不一致
macOS(3.8+)spawn重新启动解释器,安全但慢
Windowsspawn只支持 spawn,必须保护入口
import multiprocessing as mp

def work(x):
return x * x

if __name__ == "__main__": # spawn 模式必须有这行
with mp.Pool(4) as pool:
print(pool.map(work, range(8)))

spawn 会重新导入主模块,所以入口必须放在 if __name__ == "__main__": 下;否则子进程会递归创建进程。

2.3 subprocess:调用外部程序​

import subprocess

# 推荐:参数列表 + 不经过 shell
result = subprocess.run(
["ls", "-l"],
capture_output=True,
text=True,
timeout=5,
check=True,
)
print(result.stdout)

shell=True 会把字符串交给 shell 解释,用户可控的输入容易变成命令注入。除非确实需要管道、通配符等 shell 特性,否则保持默认的 shell=False。

3. 线程与 GIL​

3.1 Python 线程就是操作系统线程​

threading.Thread 最终调用平台的线程 API(Linux 的 pthread、Windows 的线程)。所以线程的创建、调度、上下文切换都由操作系统负责;Python 额外加了一把 GIL。

3.2 GIL 到底限制了什么​

CPython 的传统构建里,同一时刻只有一个线程能执行 Python 字节码。GIL 的保护对象是解释器内部状态(引用计数、对象分配、GC 等),不是你的业务数据。

一个常见的过时说法是「每执行 100 条字节码切换一次」。从 Python 3.2 起,GIL 的切换已经改成按时间片:

import sys
print(sys.getswitchinterval()) # 默认 0.005 秒
sys.setswitchinterval(0.001) # 调小,切换更频繁

GIL 的关键行为:

  • CPU 密集型:多线程基本跑不满多核,因为同一时刻只有一个线程在执行字节码。
  • I/O 密集型:线程在 read、recv、sleep 等系统调用阻塞时会释放 GIL,其他线程可以继续跑,所以多线程仍然有效。
  • C 扩展:NumPy 等库在执行耗时计算时可能主动释放 GIL,这时多线程能真正并行。
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

# 片段:fetch_url / urls / heavy_compute / data 省略
# I/O 密集:线程池
with ThreadPoolExecutor(max_workers=8) as ex:
results = list(ex.map(fetch_url, urls))

# CPU 密集:进程池
with ProcessPoolExecutor(max_workers=4) as ex:
results = list(ex.map(heavy_compute, data))

3.3 GIL 不等于线程安全​

GIL 只保证「字节码之间」不并行,不保证复合操作原子。count += 1 会编译成「读、加、写」多条字节码,线程切换可能发生在中间:

import threading

count = 0
lock = threading.Lock()

def inc_no_lock():
global count
for _ in range(100_000):
count += 1 # 读-加-写,可能丢失更新

def inc_locked():
global count
for _ in range(100_000):
with lock:
count += 1

def run(fn):
global count
count = 0
threads = [threading.Thread(target=fn) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
return count

print("no lock:", run(inc_no_lock)) # 通常小于 200000
print("locked :", run(inc_locked)) # 200000

需要共享可变状态时,仍然要用 Lock、RLock、queue.Queue 或不可变数据。

3.4 自由线程构建​

Python 3.13 提供实验性的 free-threaded build(PEP 703),3.14 起转为官方支持,可以关闭 GIL。它不是「自动变快」:没有 GIL 之后,引用计数和容器操作需要更细的锁,业务代码里的竞态反而更容易暴露;同时 C 扩展需要重新编译并声明线程安全。生产环境是否采用,要看依赖生态。

4. 协程:单线程里的并发​

asyncio 是协作式并发:一个线程跑事件循环,协程在 await 处主动让出控制权。它适合「大量连接、每个连接大部分时间在等 I/O」的场景。

import asyncio

async def fetch(name, delay):
await asyncio.sleep(delay)
return f"{name} done"

async def main():
async with asyncio.TaskGroup() as tg: # 3.11+
t1 = tg.create_task(fetch("a", 1))
t2 = tg.create_task(fetch("b", 2))
print(t1.result(), t2.result())

asyncio.run(main())

4.1 阻塞调用会卡住整个循环​

事件循环是单线程的。在协程里调用 time.sleep()、requests.get()、同步数据库驱动,会阻塞整个 loop,所有协程一起停摆。

import asyncio
import time
import requests

async def bad():
time.sleep(5) # 整个事件循环停 5 秒
requests.get("https://example.com") # 同样会阻塞

async def good():
await asyncio.sleep(5) # 让出控制权
await asyncio.to_thread(requests.get, "https://example.com") # 3.9+

CPU 密集任务也不应该直接放在协程里;用 asyncio.to_thread、run_in_executor 或进程池。

4.2 超时与取消​

async def main():
try:
async with asyncio.timeout(3): # 3.11+
await slow_operation() # 片段:slow_operation 省略
except TimeoutError:
print("timed out")

取消是协作式的:协程需要在 await 点响应 CancelledError,并做好资源清理(关闭连接、释放锁)。

5. 内存:虚拟地址空间里的 Python 堆​

5.1 进程的内存分区​

高地址
┌──────────────┐
│ 栈 stack │ C 函数调用帧、局部变量(不是 Python 对象)
├──────────────┤
│ 堆 heap │ malloc / pymalloc / 大块 mmap
├──────────────┤
│ .bss/.data │ 全局变量、静态数据
├──────────────┤
│ .text │ 机器码
└──────────────┘
低地址

Python 对象全部在堆上,不在 C 栈上。C 栈只保存 PyObject* 这类临时引用;Python 层的帧和求值栈由解释器自己管理,里面放的也是引用。

5.2 分配器与回收:只留一张地图​

CPython 的小对象走 pymalloc:arena → pool → block,≤ 512 字节按 size class 切块,释放回 free list,不保证立刻还给 OS。64 位 3.11+ 实测 arena 1 MiB、pool 16 KiB;更早版本或 32 位可能不同,不要当常量。

对象头里有 ob_refcnt,引用计数归零就调用 tp_dealloc;循环引用交给分代 GC。GC 只跟踪容器,int/str 不参与;3.12+ 有不朽对象。GC 默认阈值随版本变:3.11/3.12 常见 (700, 10, 10),3.13 常见 (2000, 10, 10)。

sys._debugmallocstats()、tracemalloc、RSS 的完整用法见 Python 与内存管理 §3、§7;这里只需要记住:Python 对象在私有堆上,释放回分配器不等于还给操作系统。

5.3 mmap 与共享内存​

mmap 把文件或匿名内存映射进地址空间,适合大文件随机读写和进程间共享:

import mmap

with open("big.bin", "rb") as f:
with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mm:
print(mm[:4])

多进程共享大块数据时,multiprocessing.shared_memory.SharedMemory 可以避免 pickle 整个数组;NumPy 数组可以直接挂到共享内存上。

6. 文件描述符与 I/O 多路复用​

6.1 fd 是什么​

在 Unix 里,「一切皆文件」:普通文件、socket、管道、终端都有文件描述符(一个非负整数)。进程有上限:

import resource
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
print(soft, hard)

泄漏的典型形态是「打开了文件/socket/数据库连接,异常路径没有关闭」。用 with 或 try/finally:

with open("data.txt") as f:
data = f.read()

# socket 也一样
with socket.create_connection(("example.com", 80)) as s:
...

6.2 select / poll / epoll​

阻塞 I/O 的问题是一个线程只能等一个 fd。I/O 多路复用让一个线程同时等多个 fd:

  • select:跨平台,fd 数量有限;
  • poll:没有 fd 数量上限,但仍要线性扫描;
  • epoll(Linux):事件驱动,适合大量连接;macOS 对应 kqueue。

Python 的 selectors 会自动选择当前平台最优的实现:

import selectors
import socket

sel = selectors.DefaultSelector()
server = socket.socket()
server.bind(("127.0.0.1", 9000))
server.listen()
server.setblocking(False)

sel.register(server, selectors.EVENT_READ, data="accept")

while True:
for key, mask in sel.select():
if key.data == "accept":
conn, addr = key.fileobj.accept()
conn.setblocking(False)
sel.register(conn, selectors.EVENT_READ, data="echo")
else:
conn = key.fileobj
data = conn.recv(4096)
if data:
conn.sendall(data)
else:
sel.unregister(conn)
conn.close()

asyncio 在底层就是「事件循环 + epoll/kqueue + 非阻塞 socket」。所以它不是魔法,只是把「注册 fd、等待事件、回调协程」这套流程封装了起来。

7. 信号:优雅停机​

信号是 OS 发给进程的异步通知。Python 里用 signal 注册处理函数:

import signal
import sys

def handle_term(signum, frame):
print("shutting down...")
# 停止接收新请求、关闭连接池、刷新日志
sys.exit(0)

signal.signal(signal.SIGTERM, handle_term)

几个约束:

  • 处理函数在主线程执行;工作线程里注册无效。
  • SIGINT 默认转成 KeyboardInterrupt;SIGTERM 默认直接终止进程。
  • 在 Kubernetes/容器里,删除 Pod 会先发 SIGTERM,等待一段时间再 SIGKILL;所以优雅停机逻辑要在这段时间内完成。
  • uvicorn、gunicorn 已经内置了信号处理,自己再注册时要避免覆盖。

8. 进程间通信​

方式接口特点
队列multiprocessing.Queue基于 pickle,适合传任务/结果;有后台 feeder 线程
管道multiprocessing.Pipe双向/单向,轻量;send/recv 按 pickle 对象分帧,传原始字节流才需要自己定界
共享内存multiprocessing.shared_memory零拷贝,适合大数组;要自己同步
socketpairsocket.socketpair()本机双向字节流,可配合 selectors/asyncio
import multiprocessing as mp

def worker(q):
while (item := q.get()) is not None:
print("got", item)

if __name__ == "__main__":
q = mp.Queue()
p = mp.Process(target=worker, args=(q,))
p.start()
for i in range(3):
q.put(i)
q.put(None)
p.join()
q.close()
q.join_thread()

坑:

  • Queue 用 pickle 序列化,lambda、打开的文件、数据库连接都传不过去。
  • multiprocessing.Queue 没有 task_done() / join();那是 JoinableQueue 的 API。生产者写完用 close() + join_thread() 确保 feeder 线程把缓冲写完。
  • 父进程不要在子进程还可能 put 时先 join(),否则管道缓冲满时可能死锁。
  • SharedMemory 不会自动回收,结束时需要 close() + unlink()。

9. 一个 Web 服务的 OS 视角​

一次请求大致经历:

  1. Nginx 接受连接,按 worker 分发;
  2. 每个 uvicorn worker 是一个进程,进程里跑一个事件循环;
  3. async def 端点直接在循环上执行;def 端点被 AnyIO 丢进线程池;
  4. 同步数据库驱动会占用线程池,异步驱动才真正让出循环;
  5. 请求结束,连接回到 keep-alive 或关闭;fd 回到空闲池;
  6. 收到 SIGTERM 后停止 accept,等待在途请求,关闭连接池。

几个容量参数要对齐:

  • --workers 通常按 CPU 核数设置,但容器里 os.cpu_count() 可能看到宿主机核数,实际受 cgroup quota 限制;
  • ulimit -n 限制 fd 数,keep-alive 连接越多,需要越多 fd;
  • 数据库连接池不是越大越好,池子超过数据库的 max_connections 会把数据库拖死;
  • 线程池大小要覆盖同步阻塞调用的并发,不能按 CPU 核数设。

10. 常见坑​

  • 先有多线程、再 fork:子进程只继承调用 fork 的线程,其他线程持有的锁(如 malloc 锁、日志锁)在子进程中可能永远不释放,容易死锁。多线程程序用 spawn 或 forkserver;Python 3.12+ 对多线程进程 os.fork() 会发 DeprecationWarning。
  • 在 async 里调用同步库:requests、time.sleep、同步 ORM 都会阻塞事件循环。
  • multiprocessing 传不可 pickle 的对象:lambda、连接、锁、生成器。
  • fd 泄漏:异常路径没有 close,最终 Too many open files。
  • shell=True 拼接用户输入:命令注入。
  • 把 os.cpu_count() 当成容器可用核数:cgroup 限制下会高估。

11. 动手实验​

import os
import time
import threading
import asyncio

# 1. 进程与线程
print("pid", os.getpid(), "threads", threading.active_count())

# 2. GIL:CPU 密集 vs I/O 密集
def cpu():
n = 0
for _ in range(10_000_000):
n += 1
return n

def io():
time.sleep(0.1)

t0 = time.perf_counter()
threads = [threading.Thread(target=cpu) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print("4 threads cpu:", time.perf_counter() - t0) # 接近串行

t0 = time.perf_counter()
threads = [threading.Thread(target=io) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print("4 threads io:", time.perf_counter() - t0) # 接近 0.1s

# 3. asyncio:同样等 0.1s,4 个协程并发
async def main():
t0 = time.perf_counter()
await asyncio.gather(*(asyncio.sleep(0.1) for _ in range(4)))
print("4 coroutines:", time.perf_counter() - t0)

asyncio.run(main())

观察命令​

ps -o pid,ppid,nlwp,rss,cmd -p <pid>   # 线程数、RSS
ls /proc/<pid>/fd | wc -l # fd 数量
ss -s # 套接字统计
strace -f -p <pid> # 跟踪系统调用(需权限)

12. 小结​

  • 进程给隔离和真正的并行,线程给 I/O 并发但受 GIL 限制,协程在单线程里用 await 做协作式切换。
  • GIL 是 CPython 解释器的锁,不是业务锁;CPU 密集用多进程,I/O 密集用线程或协程。
  • Python 对象在私有堆上,C 栈和解释器帧只保存引用;pymalloc 管小块,大块走 malloc/mmap。
  • fd 是有限资源,I/O 多路复用让一个线程等多个 fd;asyncio 底层就是 epoll/kqueue。
  • 信号决定进程怎么优雅退出;容器里的 SIGTERM 处理不好,就会丢请求。
加载评论中...