跳到主要内容
预计阅读 36 分钟

03|线程与并发

进程是资源分配的单位,线程是调度的单位。理解线程,是写好并发程序的第一步。本章将带你从进程内部的执行流出发,掌握线程模型、协程以及并发与并行的核心区别。

📋 开篇自测:你已经知道多少?

  1. 线程和进程的核心区别是什么?为什么说”线程是轻量级的进程”?
  2. 用户态线程和内核态线程各自的优缺点是什么?
  3. 并发和并行有什么区别?单核 CPU 上能实现并行吗?

一、为什么需要线程

1.1 进程的局限

上一章我们学了进程,进程很好,但它有一个本质性的”重”——每个进程都有独立的地址空间。

想象一个 Web 服务器,每来一个请求就 fork() 一个子进程来处理。如果同时有 1000 个请求,就需要 1000 个进程。每个进程都有自己的页表、文件描述符表、信号处理表…内存开销巨大,进程间切换也需要切换整个地址空间。

而且,同一个程序的多个进程之间共享数据非常麻烦——因为地址空间隔离,它们必须通过管道、共享内存、消息队列等 IPC 机制来通信(第 4 章详述)。

1.2 线程:共享地址空间的执行流

线程的出现就是为了解决这个问题。同一进程内的多个线程共享地址空间和大部分资源,每个线程只需要维护自己的:

进程的资源(所有线程共享)              每个线程私有的
+----------------------------+      +-------------------+
|  代码段 (Text)              |      |  线程 ID (TID)     |
|  数据段 (Data/BSS)          |      |  栈 (Stack)        |
|  堆 (Heap)                 |      |  寄存器组          |
|  打开的文件描述符            |      |  程序计数器 (PC)    |
|  信号处理设置               |      |  栈指针 (SP)       |
|  当前工作目录               |      |  线程局部存储 (TLS) |
|  用户 ID / 组 ID           |      |  errno 值          |
+----------------------------+      +-------------------+

用图来看更直观:

                    进程 P
    +----------------------------------------------+
    |                                              |
    |  共享地址空间                                  |
    |  +--------+--------+--------+---------+      |
    |  | 代码段  | 数据段  |   堆   | 文件表  |      |
    |  +--------+--------+--------+---------+      |
    |                                              |
    |  线程1        线程2        线程3              |
    |  +------+    +------+    +------+            |
    |  | 栈1  |    | 栈2  |    | 栈3  |            |
    |  | PC1  |    | PC2  |    | PC3  |            |
    |  | 寄存 |    | 寄存 |    | 寄存 |            |
    |  | 器1  |    | 器2  |    | 器3  |            |
    |  +------+    +------+    +------+            |
    |                                              |
    +----------------------------------------------+

1.3 进程 vs 线程:成本对比

维度进程线程
创建成本高(复制页表等结构)低(只需分配栈和少量元数据)
切换成本高(切换地址空间、刷新 TLB)低(共享地址空间,无需切换页表)
通信成本高(需 IPC 机制)低(直接读写共享内存)
隔离性强(一个崩溃不影响其他)弱(一个线程崩溃整个进程退出)
内存开销大(独立地址空间)小(共享地址空间)
编程难度较低较高(需处理同步问题)

线程的”轻”是有代价的——共享意味着需要同步。如果两个线程同时修改同一个全局变量而没有任何保护,就会产生数据竞争(Data Race),导致不可预测的结果。

🤔 想一想 既然线程比进程轻量,为什么 Chrome 浏览器选择用多进程而不是多线程来隔离标签页?


二、线程的实现模型

线程的实现方式决定了它的性能和灵活性。历史上出现了三种主要的线程模型。

2.1 用户态线程(多对一模型,N:1)

线程完全由用户空间的线程库管理,内核对线程的存在毫不知情,在内核看来只有一个进程。

       用户空间
  +------------------+
  |  线程库           |
  |  +----+ +----+   |
  |  | T1 | | T2 |   |
  |  +----+ +----+   |
  |  +----+ +----+   |
  |  | T3 | | T4 |   |
  |  +----+ +----+   |
  +--------+---------+
           |
           | N:1 映射
           |
  +--------+---------+
  |   内核线程 (1个)   |
  +------------------+
        内核空间

优势

  • 线程切换不需要进入内核,极快(通常只需几十纳秒)
  • 可以在不支持线程的操作系统上实现
  • 线程调度策略可以自定义

劣势

  • 一个线程阻塞(如 I/O),整个进程都会阻塞
  • 无法利用多核 CPU(因为内核只看到一个执行实体)

2.2 内核态线程(一对一模型,1:1)

每个用户线程对应一个内核线程,线程的创建、调度、同步全部由内核完成。

       用户空间
  +------------------+
  |  +----+ +----+   |
  |  | T1 | | T2 |   |
  |  +----+ +----+   |
  |  +----+ +----+   |
  |  | T3 | | T4 |   |
  |  +--+---+--+--+  |
       |   |  |  |
       | 1:1映射 |
       |   |  |  |
  +--+-+---+--+--+---+
  |  KT1 KT2 KT3 KT4 |
  +--------------------+
        内核空间

优势

  • 一个线程阻塞不影响其他线程
  • 可以在多核 CPU 上真正并行执行

劣势

  • 线程创建和切换需要系统调用,开销较大
  • 内核线程数量有限制(Linux 默认数万个)

Linux 采用的就是 1:1 模型。Linux 中的线程其实就是共享地址空间的”轻量级进程”——内核用 clone() 系统调用创建,和进程共享同一个 task_struct 数据结构。

2.3 混合模型(多对多模型,M:N)

将 N 个用户线程映射到 M 个内核线程(M <= N),兼顾两者的优势。

       用户空间
  +------------------------+
  |  +----+ +----+ +----+  |
  |  | T1 | | T2 | | T3 |  |
  |  +----+ +----+ +----+  |
  |  +----+ +----+ +----+  |
  |  | T4 | | T5 | | T6 |  |
  |  +--+--+-+--+-+--+---+ |
       |  \ |  |/ |  |
       | M:N 映射  |
       |  / |  |\ |  |
  +--+-+--+-+--+-+-+--+--+
  |  KT1    KT2    KT3    |
  +------------------------+
        内核空间

Go 语言的 goroutine 运行时就是 M:N 模型的典型实现——数千个 goroutine 被调度到少量的操作系统线程上执行。

2.4 Linux 中的 NPTL

Linux 现代的线程实现叫做 NPTL(Native POSIX Threads Library)。它是 1:1 模型,每个 pthread 对应一个内核 task_struct。

#include <pthread.h>
#include <stdio.h>

void* thread_func(void* arg) {
    int id = *(int*)arg;
    printf("Thread %d running, TID = %ld\n", id, pthread_self());
    return NULL;
}

int main() {
    pthread_t threads[4];
    int ids[4] = {0, 1, 2, 3};

    for (int i = 0; i < 4; i++) {
        pthread_create(&threads[i], NULL, thread_func, &ids[i]);
    }

    for (int i = 0; i < 4; i++) {
        pthread_join(threads[i], NULL);
    }

    printf("All threads finished.\n");
    return 0;
}

// 编译: gcc -pthread thread_demo.c -o thread_demo
# 查看一个进程的线程
ps -T -p <pid>

# 或者在 /proc 文件系统中查看
ls /proc/<pid>/task/
# 每个子目录代表一个线程,目录名就是线程 ID

三、线程的生命周期

3.1 线程状态

线程的状态转换和进程类似,但更加简单:

     pthread_create()
           |
           v
     +----------+
     |  就绪态   |<---------+
     +----+-----+          |
          |                 |
          | 获得CPU         | 时间片用完/被抢占
          v                 |
     +----------+          |
     |  运行态   |----------+
     +----+-----+
          |        \
          |         \  pthread_exit() / return
     等待事件        \
          |           v
          v      +-----------+
     +----------+|  终止态    |
     |  阻塞态   |+-----------+
     +----------+
          |
          | 事件完成
          v
       回到就绪态

3.2 线程的创建与终止

pthread_create()  创建线程
pthread_exit()    线程主动退出
pthread_cancel()  请求取消另一个线程
pthread_join()    等待指定线程结束 (类似进程的 wait)
pthread_detach()  将线程设为分离状态 (结束后自动回收资源)

关于 join 和 detach 的选择:

方式一: join (汇合)
  主线程                子线程
    |                     |
    |  create()           |
    |-------------------->|
    |                     | 执行任务...
    |  join() 阻塞等待     |
    |  ...                |
    |                     | exit()
    |<--------------------+
    |  获得返回值
    v

方式二: detach (分离)
  主线程                子线程
    |                     |
    |  create()           |
    |-------------------->|
    |  detach()           | 执行任务...
    |                     |
    |  继续做其他事        |
    |                     | exit()
    v                     | (资源自动回收)

🤔 想一想 如果一个线程既不 join 也不 detach,会发生什么?这和进程中的僵尸进程有什么相似之处?


四、协程:比线程更轻的执行流

4.1 线程的瓶颈

虽然线程比进程轻量,但内核态线程仍然有不小的开销:

  • 默认每个线程栈 8MB(Linux glibc 默认值,实际取决于 ulimit -s 配置)
  • 创建/销毁需要系统调用
  • 上下文切换需要进入内核(约几微秒)
  • 系统能支持的线程数有限(通常几万个)

当你的服务器需要同时处理十万甚至百万级别的连接时,一个连接一个线程的模型就不可行了——这就是经典的 C10K 问题(如何在单机上处理一万个并发连接)。

4.2 协程的概念

**协程(Coroutine)**是用户态的轻量级执行流,由程序自身(而非内核)调度。

线程 vs 协程:

内核线程:
+---------+     +---------+     +---------+
| Thread1 | --> | Thread2 | --> | Thread3 |   由内核调度
+---------+     +---------+     +---------+   每个栈 8MB (glibc 默认)
                                              系统调用切换

协程:
+---------+  +---------+  +---------+  +---------+
|Corout.1 |  |Corout.2 |  |Corout.3 |  |Corout.4 |  由运行时调度
+---------+  +---------+  +---------+  +---------+  每个栈 2-8KB
      \          |          /          /              用户态切换
       \         |         /          /
        v        v        v          v
     +---------------------------------+
     |    操作系统线程 (少量, 如4个)     |
     +---------------------------------+

4.3 协程的核心特征

特征说明
用户态调度不需要系统调用,切换开销极小(几十纳秒)
栈极小通常只有 KB 级别,可动态增长
协作式调度协程主动让出执行权(yield),而非被强制抢占
数量庞大一个进程中可以创建数百万个协程

4.4 主流语言的协程实现

语言         协程名称          调度模型       栈大小

Go           goroutine       M:N (GMP)     2KB (可增长)
Python       async/await     单线程事件循环  无栈协程 (受GIL限制,无法利用多核并行;Python 3.13+ 引入实验性 free-threaded 模式可选去除 GIL)
JavaScript   async/await     单线程事件循环  无栈协程
Rust         async/await     基于 Future    无栈协程
Java (21+)   虚拟线程         M:N           动态
Kotlin       coroutine       用户态调度     无栈协程

注意:Linux 的 io_uring 是内核异步 I/O 框架,不是协程实现。它可以与协程配合使用(如 C++20 coroutines + io_uring),但本身不属于协程机制。

4.5 Go 的 GMP 模型

Go 的 goroutine 是协程最成功的工业实现之一。它的调度模型叫做 GMP:

G = Goroutine (协程,可以有数百万个)
M = Machine  (操作系统线程,通常和 CPU 核数相当)
P = Processor (逻辑处理器,管理 G 的本地队列)

              全局队列 (Global Queue)
              [G7] [G8] [G9] ...
                    |
         +----------+-----------+
         |                      |
    +----+----+            +----+----+
    |    P0   |            |    P1   |
    | 本地队列 |            | 本地队列 |
    | [G1][G2]|            | [G4][G5]|
    +----+----+            +----+----+
         |                      |
    +----+----+            +----+----+
    |    M0   |            |    M1   |
    | (OS线程)|            | (OS线程)|
    | 正在执行 |            | 正在执行 |
    |   G0    |            |   G3    |
    +---------+            +---------+
         |                      |
    +----+----+            +----+----+
    | CPU核0  |            | CPU核1  |
    +---------+            +---------+

当一个 goroutine 发起系统调用(如文件 I/O)阻塞时,Go 运行时会将当前 M 与 P 解绑,把 P 交给一个空闲的 M 继续执行其他 goroutine。这样就避免了因为一个 goroutine 阻塞导致整个线程被占用的问题。


五、并发 vs 并行

5.1 概念辨析

这两个词经常被混用,但它们的含义完全不同:

并发(Concurrency):多个任务在时间上交替执行,看起来像是同时进行的。重点是”结构”——程序被组织成可以独立推进的多个部分。

并行(Parallelism):多个任务在同一时刻真正同时执行。需要多个 CPU 核心的物理支持。

并发(单核CPU):
  CPU: |--T1--|--T2--|--T1--|--T3--|--T2--|--T1--|-->
  时间轴: -------->
  多个任务快速切换,宏观上"同时"进行

并行(多核CPU):
  CPU0: |----T1----|----T1----|----T1----|-->
  CPU1: |----T2----|----T2----|----T2----|-->
  CPU2: |----T3----|----T3----|----T3----|-->
  时间轴: -------->
  多个任务物理上同一时刻执行

Rob Pike(Go 语言之父)有一句经典的话:

“Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once.” “并发是关于同时处理多件事。并行是关于同时做多件事。“

5.2 并发不一定需要并行

一个单核 CPU 上的 Web 服务器,可以通过并发模型同时处理数千个请求——当一个请求在等待数据库响应时,CPU 切换去处理另一个请求。虽然任何时刻只有一个请求在执行,但宏观上看所有请求都在推进。

而一个视频编码程序,把视频帧分到 8 个 CPU 核上同时编码,这才是并行。

5.3 四种并发/并行的组合

                    并行
              No          Yes
        +------------+------------+
  并    | 单线程同步  | 多核但      |
  发 No | 程序        | 无并发结构  |
        | (顺序执行)  | (少见)      |
        +------------+------------+
        | 单核多线程  | 多核多线程  |
     Yes| 或事件循环  | 真正并发    |
        | (并发无并行) | + 并行     |
        +------------+------------+

5.4 并发编程模型

当前主流的并发编程模型有几种:

共享内存模型:多个线程通过读写共享变量来通信,需要锁来保护(Java, C/C++, Python threading)。

  Thread1                   Thread2
     |                        |
     | lock(mutex)            |
     | counter++              | lock(mutex) -- 阻塞等待
     | unlock(mutex)          |
     |                        | counter++
     |                        | unlock(mutex)

消息传递模型:执行实体之间通过发送消息来通信,不共享内存(Go channels, Erlang, Actor model)。

  Goroutine1                Goroutine2
     |                        |
     | data := process()      |
     | ch <- data  发送       |  result := <-ch  接收
     |                        |  use(result)

事件驱动模型:单线程通过事件循环处理所有任务,遇到 I/O 就注册回调然后继续处理下一个事件(Node.js, nginx)。

事件循环:
  while true:
    event = poll_events()    # 获取下一个就绪事件
    handler = lookup(event)  # 查找对应的处理函数
    handler(event)           # 执行处理(必须是非阻塞的)

🤔 想一想 Node.js 是单线程的,但它能处理高并发。Nginx 也是少量 worker 进程就能处理数万连接。它们靠的是什么?


六、多线程编程的陷阱

6.1 数据竞争

当两个线程同时访问同一个变量,且至少有一个是写操作,如果没有同步机制,就会发生数据竞争:

// 危险的代码!
int counter = 0;

void* increment(void* arg) {
    for (int i = 0; i < 1000000; i++) {
        counter++;  // 不是原子操作!
    }
    return NULL;
}

// 两个线程同时运行 increment
// 预期结果: 2000000
// 实际结果: 可能是 1234567 或其他随机值

counter++ 看起来是一条语句,但在 CPU 层面它分为三步:

1. 从内存读取 counter 到寄存器    (LOAD)
2. 寄存器值加 1                  (ADD)
3. 将结果写回内存                (STORE)

线程A:  LOAD(0) -> ADD(1)  ->             STORE(1)
线程B:            LOAD(0)  -> ADD(1) -> STORE(1)

结果: counter = 1 (丢失了一次加法!)

6.2 死锁的四个必要条件

死锁是多线程编程中最棘手的问题之一:

线程A:                        线程B:
  lock(mutex1)                  lock(mutex2)
  ...                           ...
  lock(mutex2) -- 等待B释放      lock(mutex1) -- 等待A释放
  ...                           ...
  unlock(mutex2)                unlock(mutex1)
  unlock(mutex1)                unlock(mutex2)

  --> 永远等下去,死锁!

死锁发生的四个必要条件(第 4 章详细讨论):

  1. 互斥:资源不能被共享
  2. 持有并等待:持有资源的同时等待其他资源
  3. 非抢占:资源只能被持有者主动释放
  4. 循环等待:存在一个进程/线程的环形等待链

6.3 线程安全的思路

  • 不共享:每个线程使用自己的局部变量
  • 不可变:共享的数据设为只读
  • 同步保护:使用互斥锁、读写锁、原子操作等(第 4 章详细展开)
  • 消息传递:通过 channel 而非共享内存通信

七、动手实验

7.1 观察线程

# 查看一个 Java 程序的线程数
ps -T -p $(pgrep java) | wc -l

# 查看线程详情
top -H -p $(pgrep java)

# 查看系统允许的最大线程数
cat /proc/sys/kernel/threads-max

# 查看单个进程允许的最大线程数
ulimit -u

7.2 线程 vs 进程创建性能对比

# 用 time 命令测试创建 10000 个进程的耗时
time python3 -c "
import os
for i in range(10000):
    pid = os.fork()
    if pid == 0:
        os._exit(0)
    os.wait()
"

# 对比创建 10000 个线程的耗时
time python3 -c "
import threading
threads = []
for i in range(10000):
    t = threading.Thread(target=lambda: None)
    t.start()
    threads.append(t)
for t in threads:
    t.join()
"

你会发现线程的创建速度明显快于进程。


八、本章总结

+----------------------------------------------------+
|               线程与并发核心知识                      |
+----------------------------------------------------+
|                                                    |
|  线程 = 共享地址空间的执行流                          |
|  共享: 代码段、数据段、堆、文件表                      |
|  私有: 栈、寄存器、PC、TLS                           |
|                                                    |
|  实现模型:                                          |
|    N:1 用户态线程 (快但不能利用多核)                   |
|    1:1 内核线程   (Linux NPTL, 可多核)               |
|    M:N 混合模型   (Go goroutine)                    |
|                                                    |
|  协程 = 用户态调度 + 极小栈 + 协作式                  |
|                                                    |
|  并发 != 并行                                       |
|    并发: 结构上可交替执行                             |
|    并行: 物理上同时执行                              |
|                                                    |
|  并发模型: 共享内存 | 消息传递 | 事件驱动              |
|                                                    |
+----------------------------------------------------+

📝 结尾自测:检验你的收获

  1. 同一进程内的线程共享哪些资源?各自独有哪些资源?
  2. 用户态线程、内核态线程、混合线程模型各自的优缺点是什么?
  3. 协程和线程的核心区别是什么?为什么协程可以创建数百万个?
  4. 并发和并行的区别是什么?画图说明单核并发和多核并行的不同。
  5. 为什么 counter++ 在多线程环境下不安全?它在 CPU 层面分为哪几步?

购买课程解锁全部内容

系统底层入门:10 章掌握操作系统核心

¥29.90