top/strace/perf,周六啃了 Linux 权限与提权。今天我们补上整周最大的一块拼图——并发协作机制:当多个执行流(线程、进程)想共用一份数据时,它们怎么"不打架"?同步原语解决线程内部的争抢,IPC 解决进程之间的通信,信号则是异步事件的传递通道。学完今天,你应该能回答四个问题:(1)为什么 Python 多线程读字典也可能炸?(2)vLLM 的多 GPU 进程通过什么把 KV cache 共享?(3)一个死锁需要哪四个必要条件同时成立?(4)Agent 沙箱用什么机制把子进程"按下去"?此外,最后一段把整个 Week 01 拉成一张图,并给出 Week 02(虚拟内存)的 preview。
来看一段你以为"显然没问题"的 Python 代码:
import threading
counter = 0
def add():
global counter
for _ in range(1_000_000):
counter += 1
t1 = threading.Thread(target=add)
t2 = threading.Thread(target=add)
t1.start(); t2.start()
t1.join(); t2.join()
print(counter) # 你期望 2_000_000,但很多次跑只会得到 1_374_812、1_902_447……
问题出在 counter += 1。这一行在 CPython 字节码里其实是三步:
线程 A 读到 counter=100,准备写回 101;恰好此刻线程 B 也读到 100,也准备写回 101。最终 counter=101——这次更新丢了一次。这就是竞争条件(race condition):执行结果依赖于两个或更多线程的相对调度顺序。
很多人会问:"Python 不是有 GIL 吗?"GIL 只保证一个字节码指令内部不被打断,不保证多个字节码组成的复合操作是原子的。+= 是 4 条字节码,所以可以被打断。这也是为什么 list.append()(C 实现里只有一次原子操作)在 GIL 下安全,但 list[i] += 1(要 LOAD_SUBSCR + BINARY_ADD + STORE_SUBSCR)不安全。
所有同步原语的目标只有一个:把一段「不希望被打断」的代码——临界区(critical section)——保护起来,让它在任何时刻最多被一个线程执行。这种性质叫互斥(mutual exclusion)。
最朴素的工具。同一时刻只有一个线程能"持有"锁,其余线程阻塞等待。在 Linux 里,pthread mutex 由 futex(fast userspace mutex)实现:没人争抢时整个加锁解锁在用户态完成,零 syscall;一旦有人需要排队,才陷入内核挂起。这个"快路径在用户态、慢路径在内核态"的设计是现代锁性能的核心。
import threading
lock = threading.Lock()
counter = 0
def add():
global counter
for _ in range(1_000_000):
with lock: # 进入临界区
counter += 1 # 现在是原子的
不阻塞,原地"自旋"忙等:
while (!atomic_compare_exchange(&lock, 0, 1)) {
/* 什么都不干,CPU 空转 */
}
看起来很傻,但极短的临界区(比如十几条指令)下,自旋反而比 mutex 快——因为 mutex 的休眠/唤醒至少要两次上下文切换(几微秒),而 spinlock 可能只浪费几十纳秒。Linux 内核里中断处理、调度器自身用的全是 spinlock,不能在中断上下文里"睡觉"。用户空间几乎不需要 spinlock,除非你在写 lock-free 数据结构。
读操作可以并发,写操作必须独占。适合"读多写少"场景。注意陷阱:
本质是一个非负计数器,提供两个原子操作:P/wait(计数 -1,若 <0 则阻塞)和 V/post(计数 +1,若有人等就唤醒)。Mutex 是 semaphore 的特例(初始值 1)。Semaphore 真正的杀手锏是限流——比如限制最多 8 个线程并发访问数据库:
sema = threading.Semaphore(8)
def query():
with sema:
run_db_query() # 最多 8 个并发
解决"等待某个条件成立"的问题。必须配合 mutex 使用。经典生产者-消费者模板:
cond = threading.Condition()
queue = []
def producer():
with cond:
queue.append(item)
cond.notify() # 唤醒一个 consumer
def consumer():
with cond:
while not queue: # 注意必须是 while 不是 if(防止虚假唤醒)
cond.wait() # 自动释放锁并睡,被唤醒后重新拿锁
item = queue.pop(0)
为什么是 while 不是 if?POSIX 允许 cond.wait() 出现"虚假唤醒"(spurious wakeup):没人 notify,它也可能自己醒。即使没有虚假唤醒,被唤醒到拿到锁之间,状态也可能被其他 consumer 改掉。所以醒来必须重新检查条件。
| 原语 | 语义 | 等待方式 | 典型场景 |
|---|---|---|---|
| Mutex | 1 个槽位 | 阻塞/睡 | 保护共享变量 |
| Spinlock | 1 个槽位 | 忙等 | 内核短临界区 |
| RWLock | 多读单写 | 阻塞 | 缓存、配置表 |
| Semaphore | N 个槽位 | 阻塞 | 限流、资源池 |
| CondVar | 等待状态变化 | 阻塞 | 队列、事件通知 |
所有上层同步原语的地基都是原子操作(atomic operation)——一条 CPU 指令完成读+改+写,过程中不会被其他核心看到中间状态。最重要的原子原语是 CAS(Compare-And-Swap):
// 伪代码:原子地执行下面整块
bool CAS(int *ptr, int expected, int new_val) {
if (*ptr == expected) {
*ptr = new_val;
return true;
}
return false;
}
在 x86 上对应 LOCK CMPXCHG 指令;ARM 上对应 LDREX/STREX 对(Load-Exclusive / Store-Exclusive)。所有 mutex/spinlock 都是用 CAS 实现的最外层入口。
CAS 还撑起了无锁数据结构(lock-free data structure)。例子:原子计数器
do {
old = counter;
new = old + 1;
} while (!CAS(&counter, old, new));
不需要锁,多线程竞争激烈时第二次循环就可能成功。代价是 ABA 问题:在你 CAS 之间,值从 A 变 B 又变回 A,CAS 会"误判"。一般用版本号或 tagged pointer 解决。
在 Python 里,标准库的 queue.Queue 用的是 Lock + Condition;高性能场景可以用 multiprocessing.Value(共享内存 + ctypes 原子操作)或第三方 atomics 库。
1971 年 Coffman 证明:死锁发生当且仅当下面四个条件同时成立——
破解任意一条即可。工程上最常用的是破解条件 4:所有线程按全局固定顺序加锁。比如转账:
def transfer(a, b, amount):
# 不管 a 转 b 还是 b 转 a,都按 id 小的先加锁
first, second = (a, b) if id(a) < id(b) else (b, a)
with first.lock:
with second.lock:
a.balance -= amount
b.balance += amount
另一个常用招是给锁加超时(破解条件 3 的变种):拿不到就回滚释放已持有的锁,回退一段随机时间重试,类似以太网 CSMA/CD 的 backoff。Python 的 lock.acquire(timeout=...) 就是干这个。
D 状态(不可中断睡眠)或长时间 S。cat /proc/<pid>/wchan 看它在内核哪里阻塞,cat /proc/<pid>/stack(需要 root)看完整调用栈。Python 程序用 py-spy dump --pid <pid> 直接打印所有线程的 Python 栈。线程同步解决的是"同一进程内部的协作",IPC(Inter-Process Communication)解决的是"跨进程的协作"。Linux 提供六大件,理解它们的差异比记住 API 重要:
| 机制 | 方向 | 容量 | 速度 | 典型场景 |
|---|---|---|---|---|
| 匿名管道 pipe | 单向 | ~64KB | ★★★ | 父子进程,ls | wc |
| 命名管道 FIFO | 单向 | ~64KB | ★★★ | 无亲缘进程,文件系统中可见 |
| 消息队列 mq | 双向有结构 | 内核可配 | ★★ | 需要消息边界与优先级 |
| 共享内存 shm | 双向 | 受限于 RAM | ★★★★★ | 大数据零拷贝,需要外加同步 |
| 信号量 sem | 同步原语 | — | ★★★ | 给共享内存配套 |
| socket(UDS) | 双向 | 大 | ★★★ | 跨主机/容器都通用 |
你每天用的 cat foo | grep bar | wc -l 背后就是匿名管道。shell 先 pipe() 拿到一对 fd(读端 + 写端),fork() 三个子进程,每个子进程用 dup2() 把对应 fd 重定向到 stdin/stdout,再 exec()。
# 用 Python 演示
import os
r, w = os.pipe()
if os.fork() == 0:
os.close(r)
os.write(w, b"hello from child")
os._exit(0)
os.close(w)
print(os.read(r, 1024)) # b'hello from child'
两个进程 mmap 同一块物理内存,从此对它的读写就是普通内存访问,不走内核、不复制。这是大数据 IPC 的唯一可选项。代价:没有任何同步保证,你必须自己用 semaphore/mutex 配套保护,否则就是 race condition。
from multiprocessing import shared_memory
import numpy as np
# 进程 A 创建
shm = shared_memory.SharedMemory(create=True, size=4*1024*1024, name="kv_cache")
arr = np.ndarray((1024, 1024), dtype=np.float32, buffer=shm.buf)
# 进程 B 直接打开同一块
shm2 = shared_memory.SharedMemory(name="kv_cache")
arr2 = np.ndarray((1024, 1024), dtype=np.float32, buffer=shm2.buf)
# arr2 修改,arr 立即看到
语法和网络 socket 一模一样,但在同一台机器上走的是内核内存拷贝,比 TCP loopback 还快(因为省了协议栈)。Docker daemon (/var/run/docker.sock)、systemd、Kubernetes kubelet 都用 UDS 暴露 API。UDS 还可以传文件描述符——这是 nginx master-worker、container runtime 移交 socket 的核心技巧。
信号是 OS 给进程的异步通知。它打断进程当前执行,跳到信号处理函数,完事再回到原代码。你按 Ctrl-C 触发的就是 SIGINT。
| 信号 | 编号 | 默认行为 | 含义 | 能否捕获 |
|---|---|---|---|---|
| SIGHUP | 1 | 终止 | 终端挂断,常被复用为"重读配置" | 能 |
| SIGINT | 2 | 终止 | Ctrl-C | 能 |
| SIGKILL | 9 | 立即杀死 | 不可拦截、不可忽略 | 不能 |
| SIGTERM | 15 | 终止 | kill 默认信号,礼貌地请求退出 | 能 |
| SIGSTOP | 19 | 暂停 | 不可拦截,配合 SIGCONT 恢复 | 不能 |
| SIGCHLD | 17 | 忽略 | 子进程退出,父进程要 wait 回收 | 能 |
| SIGSEGV | 11 | core dump | 段错误,访问非法地址 | 能(但很少做) |
| SIGPIPE | 13 | 终止 | 写一个没读端的 pipe/socket | 能 |
信号的最大陷阱:信号处理函数是 async-signal-safe 的。你不能在里面调 printf、malloc、获取任何锁——会死锁。安全的做法是只写一个 flag,主循环检查 flag 后再做事,或者用 signalfd/self-pipe trick 把信号转成可读事件,融入主 event loop。
import signal, sys
stop = False
def handler(signum, frame):
global stop
stop = True # 只设标志,不做事
signal.signal(signal.SIGTERM, handler)
signal.signal(signal.SIGINT, handler)
while not stop:
do_one_iteration()
graceful_shutdown()
K8s 滚动更新就靠这个:kubelet 先发 SIGTERM 给容器进程,等 terminationGracePeriodSeconds(默认 30s)让你优雅退出,超时不退再发 SIGKILL。这也是为什么生产服务必须捕获 SIGTERM 做收尾——否则正在处理的请求会被硬切断。
把今天学的东西串起来。vLLM 在 tensor-parallel 模式下,要把一个 LLM 模型切到 N 张 GPU 上,每张 GPU 由一个独立 Python 进程驱动(受 GIL 限制,多进程而非多线程)。这些进程之间有大量协调需求:
具体用到的机制:
如果你能看懂 vLLM 的 vllm/executor/multiproc_worker_utils.py,你就已经把今天的内容打通到工业级实践了。
LangChain/AutoGPT 把 LLM 输出的 tool call 并发执行(比如同时查 5 个搜索 API),如果几个工具共享一个数据库连接池或一个文件句柄,竞争条件就来了。最近一类 bug 是共享 Python dict 作为 memory,多个 tool 同时写导致状态丢失——表现成"Agent 偶尔忘记上一步说过什么",难以复现。修复方法:每个工具调用独立 worker,或加 asyncio.Lock。
vLLM 的 prefix caching 把不同用户的 prompt 前缀缓存在共享显存中。如果实现不当(比如 cache key 没绑定 user_id),用户 A 可以构造 prompt 故意撞缓存命中用户 B 的内容前缀——侧信道泄露。设计多租户推理服务时,prefix cache 必须按租户分桶,或者关掉。
给 LLM 跑代码的沙箱(OpenInterpreter、e2b、Daytona、Modal)都基于信号 + 资源限制构建:SIGALRM 超时杀死、SIGXCPU 限制 CPU 时间、setrlimit 限制内存/fd 数。但攻击者可以让 Python 子进程注册自己的 SIGALRM handler 吞掉信号,或者 fork 出孙进程让 alarm 不生效。所以生产沙箱必须用 cgroup(不可绕过的硬限制,Week 09 会讲)+ 不可捕获的 SIGKILL 兜底。
Triton Inference Server、vLLM 在多 worker 之间用共享内存放模型权重,避免每个 worker 都加载一份 70GB 的 Llama-70B。安全上要注意:共享内存段在 /dev/shm 下默认对同 UID 用户可读,多租户机器上别人能直接 dump 你的权重。生产环境要么单租户独占节点,要么用带访问控制的 hugepage + IPC namespace 隔离。
昨天讲的 SUID 程序如果在"检查权限"和"打开文件"之间被攻击者偷偷把文件 symlink 改向 /etc/shadow,就构成 TOCTOU(Time-of-check to time-of-use) 漏洞——本质就是竞争条件被利用。Linux 提供 openat(..., O_NOFOLLOW) + fstat(拿到 fd 后再检查)来消除窗口。AI 工具读 prompt 引用的本地文件时也常踩这个坑。
fork() 复制一份完整地址空间却几乎不耗时间?(答:COW)# 进程
ps -ef # 所有进程全格式
ps -eLf # 看线程
pstree -p # 树状关系
top / htop # 实时
pidstat -t -p <pid> 1 # 单进程明细
# 调度与性能
uptime # load average
mpstat -P ALL 1 # 每核 CPU
perf stat -e cache-misses,branches ./prog
perf top # 热点函数
taskset -c 0-3 ./prog # CPU 亲和性
# 跟踪
strace -f -e openat python a.py
ltrace -e malloc ./prog
py-spy dump --pid <pid>
# 权限
ls -l / stat /etc/shadow
getcap -r /usr/bin 2>/dev/null
sudo -l
find / -perm -4000 2>/dev/null # 全 SUID 文件
# IPC
ipcs -m / -s / -q # System V IPC
ls -la /dev/shm/ # POSIX shm
lsof -U # Unix sockets
# 信号
kill -l # 信号列表
kill -SIGTERM <pid>
cat /proc/<pid>/status | grep Sig # 看进程信号 mask
1动手:写一个"安全"的多线程计数器
用 Python threading 模拟 10 个线程,各自给 counter 加 100000 次。先不加锁跑一遍,记下错误结果与"丢失更新数";再加 Lock、再换 multiprocessing.Value('i') + get_lock() 各跑一遍。对比三种方法的耗时,思考为什么 multiprocessing 共享内存 + 进程锁会比纯多线程慢一个数量级。
2观察:抓 nginx 是怎么 graceful reload 的
用 docker 跑一个 nginx:docker run -d --name nx -p 8080:80 nginx。在另一个终端 docker exec -it nx sh -c 'while true; do echo "config_v1"; sleep 1; done'。给 nginx 主进程发 SIGHUP(docker exec nx kill -HUP 1)。再用 docker exec nx ps -ef 看进程变化——你会看到新 worker 起来、旧 worker 在等连接结束后退出。理解 SIGHUP 是怎么被复用成"重载配置"的。
3思考:Agent 安全设计题
设想你给公司做一个 Code-Interpreter 类产品:让用户 prompt 触发 LLM 写 Python 代码并运行。请只用今天学的概念,列出至少 5 个安全风险并对应给出缓解。提示:考虑(a)多用户共享进程的 race condition;(b)子进程信号绕过;(c)shared_memory 跨用户泄露;(d)IPC socket 权限;(e)僵尸进程堆积耗尽 PID 池。
Day 08 (Mon) 虚拟内存与页表:内存层级延迟金字塔、物理 vs 虚拟、MMU、多级页表、TLB、大页、/proc/meminfo。
Day 09 (Tue) 内存分配器:buddy / slab / brk vs mmap / ptmalloc / jemalloc / tcmalloc、内存碎片、OOM killer。
Day 10 (Wed) 虚拟内存进阶:page fault 三类、COW 原理细节、mmap 完全解析、swap、page cache、madvise、mlock。
Day 11 (Thu) I/O 模型谱系:5 种 IO 模型、select/poll、epoll 内部(LT vs ET)、kqueue、Reactor vs Proactor、io_uring、aio。
Day 12 (Fri 🔧) 内存与 I/O 动手:Python selectors 写 epoll 回声、mmap 加速大文件、/proc/maps & smaps、vmstat、free、valgrind。
Day 13 (Sat 🔐) 系统调用追踪与 eBPF 入门:strace 深入、ltrace、ftrace、eBPF 概念、bpftrace、bcc 工具、监控可疑 exec。
Day 14 (Sun 🌐) 文件系统 + 零拷贝 + 周复盘:VFS、inode/dentry、ext4 xfs btrfs、IO scheduler、sendfile/splice、fsync、O_DIRECT、大模型 checkpoint 加速。
下周的核心收获预期:你将真正搞懂"内存"两个字——为什么 top 里 RSS 和 VIRT 差那么远、为什么 LLM 推理是 memory-bound、epoll 为什么比 select 快几个数量级、sendfile 为什么能让文件下载快一倍。这是 Phase 1 的另一座大山,啃完之后你看 vLLM / PyTorch 源码会有完全不同的视角。周末好好休息,周一见。