多线程业务
没有操作系统,就无法实现多线程
严格意义上的多线程(抢占式、由独立调度器管理、具备完整同步机制)确实依赖操作系统。
多线程(Multithreading)的本质是CPU时间分片 + 执行上下文切换 + 资源调度,这三者都依赖操作系统提供的基础设施:
| 功能 | 操作系统的作用 |
|---|---|
| 线程创建/销毁 | 需要内核提供系统调用(如clone()、CreateThread) |
| 调度算法 | OS 调度器决定哪个线程在何时运行(时间片轮转、优先级抢占等) |
| 上下文切换 | 保存/恢复寄存器、程序计数器、栈指针等状态 |
| 内存隔离 | 线程栈空间的分配与管理 |
| 同步机制 | 互斥锁、信号量等依赖内核原语实现 |
例外情况(裸机/嵌入式)
在没有操作系统的裸机环境中,可以通过以下方式模拟"类多线程"行为:
协作式调度(Cooperative Multitasking)
手动在代码中切换任务状态机
没有真正的并行,只是逻辑上的并发
硬件中断 + 状态机
利用定时器中断触发任务切换
本质上是一个极简的"微内核"雏形
多核 MCU 的裸机并行
如 ARM Cortex-M 的 SMP 模式
但仍需要极简的调度逻辑,严格来说已接近微内核
CPU核心与线程
单个 CPU 核心 = 一个指令指针 (PC) = 同一时刻只能推进一个线程的执行流。
进程不是执行的单位,线程才是 CPU 调度的基本单位。一个CPU核心在任意时刻:
执行的是某个线程的指令
这个线程必然属于某个进程(线程不能脱离进程存在)
但核心不关心这是哪个进程的线程——调度器只关心线程的优先级、状态等
时间线 → ├─ 核心执行 Thread-A(属于进程 P1) ├─ 上下文切换 ─────────────────────────┐ ├─ 核心执行 Thread-B(属于进程 P2)←───┘ 不同进程,无缝切换 ├─ 上下文切换 ─────────────────────────┐ ├─ 核心执行 Thread-C(属于进程 P1)←───┘ 又切回 P1进程切换 vs 线程切换的区别:
同进程线程切换:只需换栈、寄存器,页表不变(轻量)
跨进程线程切换:还要切换地址空间(页表)、刷新 TLB(稍重)
但无论哪种,同一时刻只有一个线程在跑。
线程切换
线程切换确实极其频繁,频繁到超出常人想象。但这正是操作系统设计的精妙之处——它让这种高频切换"便宜"到几乎无感知。你的电脑不是"同时"跑 3000 个线程,而是以每秒数万次的频率,在 几个核心上快速"接力"跑其中最需要 CPU 的那几十个线程。
| 场景 | 时间尺度 | 类比 |
|---|---|---|
| 现代 CPU 主频 | ~3-5 GHz | 每秒 30-50 亿个时钟周期 |
| 一次线程上下文切换 | ~1000-2000 个时钟周期 | 约0.3-0.6 微秒(百万分之一秒) |
| Linux 默认时间片 | 100 毫秒(CFS 调度) | 但线程可能几毫秒就主动让出 |
| 实际切换频率 | 每秒数千到数万次 | 甚至更高 |
1. 绝大多数线程在"睡觉"
系统状态示例(你的电脑此刻): ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 总线程数: ~3000个 ├─ 运行中 (Running): 4个 ← 等于你的 4 核 CPU ├─ 可运行 (Runnable): 12个 ← 等着被调度 ├─ 睡眠等待 (Sleeping): 2980个 ← 等I/O、等定时器、等用户点击... └─ 僵尸/停止: 4个 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━真相:>95% 的线程处于阻塞/睡眠状态,根本不消耗 CPU。它们可能在:
等硬盘读完文件(几十毫秒)
等网络响应(几百毫秒)
等用户按键盘(可能几分钟)
等定时器到期(如每秒刷新一次的时钟)
2. 切换成本被设计得极低
现代 CPU 和 OS 的优化:
硬件支持:专用寄存器快速保存/恢复上下文
TLB 缓存:减少地址切换开销
内核优化:Linux 的
vDSO让用户态快速获取时间,减少进内核次数避免不必要切换:线程连续运行几毫秒才切,不是微秒级乱切
3. 人类感知 vs 机器时间
| 人类感知 | CPU 视角 |
|---|---|
| "同时"打开 10 个网页 | 每个网页的渲染线程分片执行,每片几毫秒 |
| 音乐流畅播放 | 音频线程每 5-10 毫秒被唤醒填充一次缓冲区 |
| 鼠标立刻响应 | 中断瞬间抢占,延迟 < 1 毫秒 |
三态模型:进程/线程?
现代操作系统中,CPU 调度的基本单位是线程,不是进程。
但"三态模型"确实常被用来讲进程,如网上可以搜到的很多资料和教材,这是历史遗留 + 概念简化造成的混淆。
| 年代 | 代表系统 | 特点 |
|---|---|---|
| 1960s-1980s | Unix 早期、DOS | 只有进程,没有线程概念 |
| 1990s+ | Solaris、Windows NT、Linux 2.6+ | 引入内核级线程 |
在早期系统中:
进程 = 执行单位 + 资源单位
调度器选"哪个进程"运行
现代 OS 的演变
早期模型 现代模型 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 进程 = 执行流 进程 = 资源容器(地址空间、文件描述符等) ↓ ↓ 调度器调度"进程" 调度器调度"线程" ↓ ↓ 进程切换 = 换执行+换资源 线程切换 = 换执行(同进程内资源不变) 进程切换 = 换线程 + 换资源(更重)三态模型到底属于谁?
三态模型描述的是"执行实体"的生命周期,这个实体可以是:
| 语境 | 实际指代 | 正确性 |
|---|---|---|
| 早期教材/简单OS | 进程(因为当时没有线程) | ✅ 历史正确 |
| 现代OS内核实现 | 线程(内核调度实体) | ✅ 技术正确 |
| 用户态库线程(如Python threading) | 用户态线程,由运行时管理 | ⚠️ 模拟的"伪并行" |
内核层(真正发生 CPU 调度)
内核看到的: ┌─────────────────────────────────────────┐ │ 进程P1 进程P2 进程P3 │ │ ├─ 线程T1 ├─ 线程T3 ├─ 线程T5 │ │ └─ 线程T2 └─ 线程T4 ... │ │ │ │ 调度器从 {T1, T2, T3, T4, T5...} 中选择 │ │ ▲ 无视进程边界,只看线程优先级、状态 │ └─────────────────────────────────────────┘内核调度的是线程(Linux 中叫task_struct,代表一个可调度的任务)。
Linux 的哲学:线程只是共享地址空间的进程。
// Linux 内核代码(简化) struct task_struct { // 这个结构体既代表进程,也代表线程! // 区别只在 flags 和 mm(内存描述符)的共享方式 volatile long state; // 状态:TASK_RUNNING, TASK_INTERRUPTIBLE... int prio, static_prio; // 动态/静态优先级 struct sched_entity se; // CFS 调度实体 struct mm_struct *mm; // 地址空间(线程共享,进程独占) pid_t pid; // 线程ID pid_t tgid; // 进程组ID(线程组leader的PID) };fork()= 创建新进程(新地址空间)clone(CLONE_VM)= 创建线程(共享地址空间)
调度器一视同仁,都按task_struct调度。
内核态/用户态线程
内核态线程(Kernel-Level Thread / OS Thread)
本质:由操作系统内核直接管理和调度的线程,每个线程对应一个内核调度实体。
特点:
线程的创建、销毁、调度都由操作系统内核完成
线程切换需要陷入内核态,涉及上下文切换开销较大
可以利用多核CPU的并行能力(真正的并行执行)
线程阻塞(如I/O操作)不会阻塞整个进程
资源占用相对较大(每个线程需要独立的内核栈等)
用户态线程(User-Level Thread / Green Thread / 协程)
本质:完全在用户空间实现的线程机制,操作系统"看不见"这些线程,内核只感知到一个普通的进程或单个线程。
特点:
线程管理(创建、调度、切换)完全由用户空间的运行时库/虚拟机完成
切换不需要进入内核态,开销极小(类似函数调用)
无法直接利用多核并行(除非绑定到多个内核线程上,即M:N模型)
一个用户态线程阻塞会导致整个内核线程阻塞(早期实现的问题,现代实现已改善)
可以创建海量线程(内存占用极小,通常几KB)
需要运行时支持协作式调度或抢占式调度
| 维度 | 内核态线程 | 用户态线程 |
|---|---|---|
| 管理者 | 操作系统内核 | 用户空间运行时/虚拟机 |
| 切换成本 | 高(需陷入内核,保存大量寄存器状态) | 低(纯用户空间操作) |
| 创建数量 | 有限(通常几千个,受内存限制) | 海量(可创建数百万个) |
| 多核利用 | 原生支持,真正并行 | 需映射到多个内核线程才能并行 |
| 阻塞影响 | 单个线程阻塞不影响其他线程 | 早期模型会阻塞整个进程,现代实现已优化 |
| 调度策略 | 内核决定,抢占式 | 运行时决定,协作式或轻量级抢占 |
主流语言的多线程实现
| 语言 | 语法/API | 线程类型 | 说明 |
|---|---|---|---|
| C/C++ | pthread_create()/std::thread | 内核态 | POSIX线程和C++11线程都是1:1映射到OS线程 |
| Java | new Thread()/ExecutorService | 内核态 | 传统Java线程是内核线程(1:1模型) |
| Java | VirtualThread(Java 21+) | 用户态 | Project Loom引入的虚拟线程,M:N模型 |
| Go | go func() | 用户态 | Goroutine是经典用户态线程,Go运行时调度,M:N模型 |
| Python | threading.Thread | 内核态 | 受GIL限制,同一时刻只有一个线程执行Python字节码 |
| Python | asyncio/asyncawait | 用户态 | 协程实现,单线程事件循环,非抢占式 |
| Rust | std::thread::spawn | 内核态 | 原生OS线程 |
| Rust | tokio::spawn(async) | 用户态 | 基于work-stealing的异步运行时,用户态调度 |
| JavaScript | new Worker() | 内核态 | Web Worker是独立OS线程(浏览器/Node.js环境) |
| JavaScript | async/await/ Promise | 用户态 | 事件循环中的协程,单线程非阻塞 |
| C# | new Thread()/Task.Run | 内核态 | .NET线程池线程映射到OS线程 |
| C# | async/await | 混合 | Task异步模型,可能运行在池化线程或当前上下文 |
| Kotlin | launch/async(协程) | 用户态 | Kotlin Coroutines,编译为状态机,可挂起恢复 |
| Erlang/Elixir | spawn | 用户态 | BEAM虚拟机的轻量级进程(非OS进程),M:N模型 |
现代趋势:混合模型(M:N)
现代高性能运行时普遍采用M:N 模型(混合线程模型):
用户态线程(M个) → 调度器 → 内核线程(N个,通常N=CPU核心数)代表实现:
Go: Goroutine + Go Scheduler + OS Threads
Java Virtual Threads: Virtual Thread → Carrier Thread → OS Thread
Rust Tokio: Async Task → Worker Threads → OS Threads
这种模型结合了用户态线程的轻量级和内核态线程的多核并行能力,是当前并发编程的主流方向。
竞态
多个线程完全可以在同一时间段内(甚至真正的同一时刻)执行同一个函数。但这正是多线程设计的正常行为,线程安全不依赖于"是否同时执行",而依赖于"如何访问数据"。
┌─────────────────────────────────────┐ │ 代码段(只读共享) │ │ ┌─────────────────────────┐ │ │ │ void worker(int x) { │ │ │ │ int a = x + 1; │ │ │ │ return a * 2; │ │ │ │ } │ │ │ └─────────────────────────┘ │ └─────────────────────────────────────┘ ▲ │ 所有线程共享这份机器码 ┌───────┴───────┐ ▼ ▼ ┌─────────┐ ┌─────────┐ │ 线程 A │ │ 线程 B │ │ 的执行上下文│ │ 的执行上下文│ ├─────────┤ ├─────────┤ │ PC=0x100│ │ PC=0x104│ ← 程序计数器(执行位置不同!) │ x=5 │ │ x=10 │ ← 参数(寄存器或栈中) │ a=6 │ │ a=11 │ ← 局部变量(各自栈上) │ RAX=12 │ │ RAX=22 │ ← 返回值寄存器 └─────────┘ └─────────┘ │ │ ▼ ▼ ┌─────────┐ ┌─────────┐ │ 线程A的栈│ │ 线程B的栈│ │ [独立] │ │ [独立] │ └─────────┘ └─────────┘单核 CPU(时间片轮转)
时间轴 ──────────────────────────────────────────────► CPU 核心1: [线程A:worker()] [线程B:worker()] [线程A:worker()] ... └───── 10ms ─────┘└───── 10ms ─────┘ 实际上: 快速切换,看起来"同时",实际是交替执行多核 CPU(真正并行)
时间轴 ──────────────────────────────────────────────► CPU 核心1: [线程A:worker()]──────────────────────────► └──────────────────────────────────────────┘ CPU 核心2: [线程B:worker()]──────────────────────────► └──────────────────────────────────────────┘ 同时刻: 两个核心真的在执行同一份代码的不同位置!区分"代码共享" vs "数据共享"
void safe_function(int x) { // x 在寄存器或栈上,每个线程独立 int local = x + 1; // local 在栈上,每个线程独立 int *ptr = malloc(4); // ptr 在栈上(独立),指向堆(看情况) *ptr = local; // 如果 ptr 指向线程私有内存,安全 // 线程安全的原因:所有"状态"都是独立的 } void unsafe_function(int x) { static int shared = 0; // ❌ 静态区,所有线程共享! shared += x; // ❌ 竞态条件! global_var++; // ❌ 全局变量,共享! }线程不安全的情况
1. 静态/全局变量(数据共享)
static int counter = 0; // 在数据段,共享! void unsafe_increment() { counter++; // 读-改-写三步,非原子操作 // 实际汇编: // MOV EAX, [counter] ; 读取(可能读到旧值) // INC EAX ; 修改 // MOV [counter], EAX ; 写回(可能覆盖其他线程的修改) } // 线程A和B同时执行: // A读取0,B读取0,A写回1,B写回1 → 结果应该是2,实际是1!(丢失更新)2. 共享堆内存
int *shared_ptr; // 全局指针 void unsafe_heap() { if (shared_ptr != NULL) { // 检查和使用之间可能有其他线程修改! *shared_ptr = 100; // 可能访问已释放内存 } }线程安全的本质
线程安全的关键不是"代码是否共享",而是"可变数据是否被并发访问"。函数代码天然是只读的,所以共享无妨;但数据一旦共享且可写,就必须同步。
| 组件 | 是否共享 | 是否安全 | 原因 |
|---|---|---|---|
| 代码(函数体) | 共享 | ✅ 安全 | 只读,不可修改 |
| 程序计数器 PC | 独立 | ✅ 安全 | 每个线程有自己的执行位置 |
| 寄存器 | 独立 | ✅ 安全 | 上下文切换时保存/恢复 |
| 栈(局部变量) | 独立 | ✅ 安全 | 每个线程独立栈空间 |
| 堆(动态内存) | 看情况 | ⚠️ 看情况 | 取决于指针是否被共享 |
| 全局/静态变量 | 共享 | ❌ 危险 | 需要互斥锁等同步机制 |
