操作系统学习笔记(二) 进程、线程与协程
一、三种抽象处在不同层级
进程、线程、协程不是互相替代的三个方案——一个服务可以启动多个进程,每个进程拥有若干线程,其中某个线程再运行事件循环,承载成千上万个协程。它们可以组成一棵执行树:
操作系统 └─ 进程:资源与隔离容器 ├─ 线程:CPU 调度单元 │ ├─ 协程 A:等待网络 │ ├─ 协程 B:等待数据库 │ └─ 协程 C:处理定时任务 └─ 线程:处理同步阻塞调用这三层关注的问题分别是:
| 层级 | 解决了什么问题 |
|---|---|
| 进程 | 如何划分资源、权限与故障边界 |
| 线程 | 如何在一个进程内建立多个可被操作系统独立调度的执行流,使多个任务能够并发推进 |
| 协程 | 如何让一个线程承载多个可暂停的逻辑任务,并在它们之间协作切换 |
二、进程:资源和故障的边界
进程是程序的一次运行实例。进程拥有独立的虚拟地址空间,以及文件描述符、环境变量、安全身份等运行资源。
两个进程默认不能直接读写彼此的内存。它们需要通过管道、Socket、消息队列或共享内存通信。这个限制增加了通信成本,却换来了清晰的隔离:一个进程内存损坏或崩溃,通常不会直接破坏另一个进程的调用栈和堆。
进程重要价值在与:它是可靠的终止边界。操作系统可以终止一个具体进程,并回收它占用的大部分资源(强制终止线程通常不安全,可能破坏仍被其他线程使用的共享状态)。因此,不可信、可能死锁或者必须支持强制回收的工作,适合放进独立进程中,而不是直接塞进主服务线程。
进程的代价也很明确。创建和切换进程比协程更重,进程间数据需要序列化、复制或同步。它适合换取隔离,不适合替代所有轻量任务。
三、线程:进程里的执行单元
现代操作系统通常实际调度线程。进程提供资源容器,线程带着寄存器、栈和执行位置去使用 CPU。
从 Python 代码创建一个线程
Python 标准库通过threading.Thread创建线程:
importthreadingimporttimedefdownload(filename):print(f"{threading.current_thread().name}开始下载{filename}")time.sleep(2)print(f"{filename}下载完成")worker=threading.Thread(target=download,args=("report.pdf",),name="download-worker",)print(worker.is_alive())# False:此时还没有启动新线程worker.start()print(worker.is_alive())# 通常为 True:新线程正在运行print("主线程继续处理其他事情")worker.join()# 等待 worker 执行结束print("所有工作完成")这里有三个关键动作:
Thread(...)创建一个 Python 层的线程对象,并保存目标函数、参数和名称,此时还没有创建操作系统线程;start()真正申请并启动一个新线程,新线程随后执行run(),再由默认的run()调用target;join()让当前线程等待目标线程结束,它不会启动或终止目标线程。
如果直接调用worker.run(),download()会像普通函数一样在当前线程中执行,不会产生并发。一个Thread对象也只能成功调用一次start()。
进程启动时已经有线程吗
有。一个正在运行的普通进程至少需要一个线程来执行指令。操作系统创建 Python 进程时,也会创建它的初始线程;Python 把它称为主线程,即MainThread。模块顶层代码、程序入口和没有显式创建线程的普通 Python 代码,默认都由这个线程执行。
importthreadingprint(threading.current_thread().name)# MainThreadprint(threading.active_count())# 简单程序中通常是 1调用start()后发生了什么
以常见的 CPython 实现为例,新线程的启动过程可以概括为:
主线程创建 Thread 对象 ↓ 主线程调用 start() ↓ Python 运行时请求操作系统创建原生线程 ↓ 操作系统分配线程 ID、调度状态、寄存器现场和专用栈 ↓ 新线程进入 CPython 的启动代码,建立自己的线程状态 ↓ 新线程执行 Thread.run(),进而调用 target ↓ target 返回,线程退出;join() 的等待者被唤醒新线程不是把主线程完整复制一份。操作系统不会复制整个进程地址空间,也不会复制主线程当前的调用栈。新线程直接加入同一个进程,因此可以访问同一份代码、全局变量、堆对象、文件描述符和网络连接;与此同时,它获得自己的寄存器状态、指令位置、线程局部存储和栈区域。
在常见的 CPython 构建中,新线程执行 Python 字节码前还需要获得 GIL。线程遇到阻塞 I/O 或运行时切换时,其他线程才有机会继续执行 Python 代码。GIL 不等于“只有一个线程”,它限制的是多个线程同时执行 Python 字节码。
新线程的栈和局部存储从哪里来
简短答案是:操作系统与线程运行库负责分配,但这些内存仍然位于当前进程的虚拟地址空间中。线程不会因此获得一套独立于进程之外的地址空间。
进程虚拟地址空间 ├─ 代码区 ├─ 全局数据 ├─ 共享堆 ├─ 主线程的栈 ├─ 线程 A 的栈 ├─ 线程 B 的栈 ├─ 线程 A 的 TLS(线程局部存储) └─ 线程 B 的 TLS创建新线程时,线程运行库会请求操作系统在当前进程的虚拟地址空间中为它预留栈区域。操作系统还会在内核中建立线程 ID、调度状态、寄存器现场和优先级等管理信息。两类资源处在不同位置:
| 位置 | 典型内容 |
|---|---|
| 进程虚拟地址空间 | 线程栈、线程局部存储、运行时控制结构 |
| 操作系统内核 | 线程 ID、调度状态、保存的寄存器、优先级 |
线程栈通常先预留一段虚拟地址,真正访问相应页面时才逐步占用物理内存。栈边界附近一般还会设置保护页,线程越界访问时便会触发栈溢出错误。
“线程专用栈”描述的是默认归属和栈指针关系,并不表示存在内存隔离。如果线程 A 得到了线程 B 栈中某个对象的地址,它在底层仍可能访问这块内存,因为两个线程共享同一个进程地址空间。
线程局部存储 TLS 也是类似的。程序看起来访问同一个变量名,运行时会根据当前线程找到各自的数据副本:
importthreading request_context=threading.local()request_context.user_id=123线程 A 和线程 B 可以拥有不同的user_id,但这些数据仍然存放在进程地址空间中,只是由运行时按线程身份进行索引。
还要区分线程栈与 Python 局部变量。Python 对象大多分配在进程堆中,函数执行现场保存的是对这些对象的引用。不能简单地认为一个 Python 函数里的所有局部数据都直接存放在原生线程栈上。
为什么线程不能共用一个栈
调用函数时,需要记录参数、局部变量、返回地址和调用链。例如两个线程同时处理订单:
defcharge(order_id):amount=100pay(order_id,amount)它们的调用现场不同:
线程 A 的栈 线程 B 的栈 ┌────────────────┐ ┌────────────────┐ │ pay(order_id=1)│ │ pay(order_id=2)│ ├────────────────┤ ├────────────────┤ │ charge │ │ charge │ │ amount = 100 │ │ amount = 100 │ │ order_id = 1 │ │ order_id = 2 │ └────────────────┘ └────────────────┘如果两个线程使用同一个栈,线程 B 压入的参数和返回地址可能覆盖线程 A 的现场,函数将无法正确恢复。
CPU 使用栈指针寄存器记录当前栈顶。操作系统切换线程时,会保存旧线程的寄存器和栈指针,再加载新线程的状态:
运行线程 A:SP → A 的栈 运行线程 B:SP → B 的栈 切回线程 A:SP → A 原来的栈位置抢占式调度
线程由操作系统抢占式调度。一个线程即使不主动让出 CPU,时间片耗尽后也可能被暂停,操作系统随后运行另一个线程。
这种模型不依赖任务主动合作,却带来了共享内存竞争。多个线程可能同时修改同一个对象,因此需要锁、条件变量和信号量,并谨慎处理竞态、死锁与内存可见性。
在 CPython 中,GIL 会限制多个线程同时执行 Python 字节码。Python 线程仍适合 I/O 和同步阻塞库;纯 Python 的 CPU 密集计算通常更适合多进程。
四、协程:一个线程里的协作式调度
协程是可以暂停和恢复的函数。Python 使用async def定义协程,在await处保存执行现场并主动让出当前线程。
asyncdefnoodles():print("面条下锅")awaitasyncio.sleep(3)print("面条好了")asyncdefegg():print("鸡蛋下锅")awaitasyncio.sleep(1)print("鸡蛋好了")awaitasyncio.gather(noodles(),egg())面条等待三秒时,线程不必站在原地。事件循环可以运行鸡蛋协程。鸡蛋的定时器到期后,它进入就绪队列,等待事件循环重新调度。
同一个线程在同一时刻只能执行一个协程。协程带来的并发来自交替推进,而不是同时占用一个 CPU 核心。
ACM 比赛类比
可以把协程调度想成几个 ACM 队员共享一台电脑:
一台电脑 = 一个事件循环线程 队员 = 协程 在纸上推理 = 网络、数据库或远端服务在外部处理 上机写代码 = 获得线程执行权 提交后下机 = 在 await 处主动让出线程 保存代码和思路 = 保留局部变量与执行位置 队长排上机顺序 = 事件循环调度队员 A 提交代码等待判题后下机,队员 B 随即使用电脑。A 的判题结果返回时,它只会进入上机队列,不会把正在运行的 B 强行赶走。
这就是协作式调度。协程需要在合适的位置主动await,事件循环才有机会运行其他任务。
不合作的协程会发生什么
asyncdeftroublemaker():whileTrue:calculate()这个协程始终不执行await,便会独占事件循环线程。其他协程、网络回调、定时器和 watchdog 都无法运行。
因此,长时间同步阻塞或 CPU 计算不能直接放进事件循环。普通阻塞函数可以交给线程池:
result=awaitasyncio.to_thread(blocking_function)需要多核并行、故障隔离或强制终止的任务,则应交给独立进程。
五、并发不等于并行
并发描述多个任务在一段时间内共同推进;并行描述多个任务在同一时刻使用不同 CPU 核心。
单线程协程能够并发处理大量 I/O,却不能让两个 CPU 密集任务在同一个核心上同时执行。它提升的通常是吞吐量和资源利用率,不会缩短远端模型本身的推理时间。
一次模型调用可能耗时 30 秒,但 Worker 线程实际只做两小段工作:发送请求和处理响应。中间的推理发生在模型服务器的 CPU/GPU 上。协程在这段时间让出线程,Worker 便能继续处理续约、监控和其他请求。
按照工作性质选择执行模型:
| 工作类型 | 合适的方式 |
|---|---|
| 大量网络、数据库和定时等待 | 协程 |
| 只能同步调用的阻塞库 | 线程池 |
| CPU 密集并且需要多核并行 | 多进程 |
| 不可信、可能卡死、需要强制终止 | 子进程、Sandbox 或 Pod |
六、进程、线程和协程的取舍
| 维度 | 进程 | 线程 | 协程 |
|---|---|---|---|
| 调度者 | 操作系统 | 操作系统 | 事件循环/运行时 |
| 调度方式 | 抢占式 | 抢占式 | 通常是协作式 |
| 内存关系 | 独立地址空间 | 共享进程地址空间 | 共享所在线程和进程资源 |
| 执行现场 | 至少一个线程 | 独立栈和寄存器 | coroutine/frame 对象 |
| 创建和切换成本 | 高 | 中等 | 低 |
| 多核并行 | 可以 | 可以;CPython 受 GIL 影响 | 单线程内不可以 |
| 故障隔离 | 强 | 弱 | 弱 |
| 强制终止 | 可以终止进程 | 通常不能安全强杀 | 取消需要代码配合 |
| 典型用途 | 隔离、并行、不可信任务 | 阻塞调用、共享内存并发 | 大量 I/O 并发 |
实际系统很少只选一种。更常见的组合是:进程提供隔离,线程承载运行时,协程编排 I/O。
Python相关QA
Q:为什么说使用Python批量处理计算密集时任务时,最好使用多进程而不是多线程?
核心原因是CPython 的 GIL(全局解释器锁)——在默认的 CPython 中,同一个 Python 进程内,同一时刻只有一个线程能执行 Python 字节码。
如果处理I/O密集型任务,那么在等待IO的过程中,线程会释放GIL和CPU核心,每个线程占用GIL的时间很短,线程虽然不能并行,但是可以并发执行,效率瓶颈在外部系统,整体效率依旧很高
如果是计算密集型任务,效率瓶颈在于线程内部的计算,计算过程中线程必须占据GIL和CPU核心,其他线程此时只能挂起,整体其实更加偏向于单线程的模型,因此不适合Python不适合使用多线程来批量处理计算密集型的任务
Q:什么是GIL,既然GIL引入了进程内线程不可并行的问题,为什么还要用ta
GIL全称Global Interpreter Lock,全局解释器锁,是CPython 解释器里的一个互斥锁。
- 同一个进程内的线程是会共享一部分空间的,CPython为了解决其解释器内部的变量并发修改问题,引入了GIL这样的大颗粒度的锁,好处是实现简单。
- 历史的局限性——早期大部分机器都是单核的
Q:什么是Cpython?为什么要有这个东西?
CPython是Python的默认解释器,其主要做两件事
- 把Python语言编译为字节码
- 用虚拟机解释执行字节码
编译和解释有什么区别?
编译器和解释器最终都是让程序变成 CPU 能执行的机器指令;但它们在中间可能生成字节码、IR 等非机器语言形式,由运行时系统代为执行。
这里的“计算机能执行的形式”,不一定是机器语言,但是最终一定是机器语言。
对于CPU而言,其能理解的确实只有机器语言,但是现代计算器不止有CPU,还可以通过其他手段——比如OS、虚拟机、运行时环境等,从而让计算机能理解更加高级的语言
C 语言
hello.c -> 编译器 -> 机器码 -> CPU 直接执行这里可以说:高级代码直接变成了机器语言。
Python
hello.py -> CPython 编译成字节码 -> CPython 虚拟机解释字节码 -> 执行对应机器指令Python 源码并没有直接变成机器语言。
是 CPython 解释器这个机器码程序,根据字节码去执行相应的机器指令。
Java
Hello.java -> 字节码 -> JVM 解释/JIT -> 机器码 -> CPU 执行字节码也不是 CPU 直接执行的,但 JVM 能执行它
Q:Python为什么不需要将字节码编译为机器码?
类似于命令模型的原理——命令模式的核心在与:
- 调用者不需要了解每个命令后的具体原理与过程,只需要负责下发命令,具体操作交给接收者
这里字节码就像是命令,而机器码就是命令背后的具体操作;解释器只需要读到机器码,就会执行对与的C语言函数,这些函数以及被提前编译为了机器码;因此字节码并不需要全部编译为机器码再整个运行,这个转化的过程在解释的过程中间接完成
Q:把字节码整个编译为机器码不是更好吗?这样一次编译就可以到处运行,省去了每次解释的开销
因为Python不确定的东西太多:
Python本身是动态类型的语言,变量的类型要等到运行时才知道
字节码可以跨平台运行(通过不同平台的解释器解释成不同平台的机器码),但是机器码本身不行;