第 17 章

线程和并发

线程和并发

餐厅里有一位厨师,所有菜都得排队等他做完一道再做下一道,效率极低。如果雇了五位厨师共用同一个厨房,他们共享灶台、餐具、食材仓库,但每人有自己的砧板和工作节奏——这就是多线程的比喻。

线程和进程的最大区别:进程之间是隔离的独立宇宙,线程之间共享同一个进程的地址空间。这既是优势(通信零开销)也是风险(共享意味着竞争)。

Level 1:建立直觉

进程 vs 线程

进程(Process):
  独立地址空间
  独立文件描述符
  独立信号处理
  创建开销大(fork + 内存映射)
  通信需要 IPC(管道、消息队列等)

线程(Thread):
  共享进程地址空间
  共享文件描述符
  共享信号处理(但每线程有独立的信号掩码)
  创建开销小(只需新建栈和线程描述符)
  通信直接共享内存(速度快,但需要同步)

每个线程独有的东西:

创建线程

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

void* worker(void* arg) {
    int id = *(int*)arg;
    printf("线程 %d 开始工作\n", id);
    // 做一些计算...
    return NULL;
}

int main() {
    pthread_t threads[4];
    int ids[4] = {1, 2, 3, 4};
    
    for (int i = 0; i < 4; i++) {
        pthread_create(&threads[i], NULL, worker, &ids[i]);
    }
    
    for (int i = 0; i < 4; i++) {
        pthread_join(threads[i], NULL);  // 等待线程结束
    }
    
    printf("所有线程完成\n");
    return 0;
}

编译:gcc -o program program.c -lpthread

并发和并行

两个容易混淆的概念:

并发(Concurrency):多个任务在同一时间段内交替进行。单核 CPU 通过快速切换实现并发。从人的视角,感觉是"同时",但实际上是轮流。

并行(Parallelism):多个任务真正同时执行。需要多个 CPU 核心。

单核并发(时间轴):
线程A: ████░░░████░░░████
线程B: ░░░████░░░████░░░
       (交替执行,共享 CPU)

四核并行:
核心1: ████████████████  ← 线程A
核心2: ████████████████  ← 线程B
核心3: ████████████████  ← 线程C
核心4: ████████████████  ← 线程D
       (真正同时执行)

现代系统通常两者都有:多核并行 + 每核上多线程并发。

为什么需要多线程

场景1:CPU 密集型——充分利用多核

# 图像处理:用 4 个线程同时处理 4 张图
from concurrent.futures import ThreadPoolExecutor

def process_image(path):
    # 耗时的图像处理
    return result

with ThreadPoolExecutor(max_workers=4) as executor:
    results = executor.map(process_image, image_paths)

场景2:I/O 密集型——避免等待时浪费 CPU

# 下载多个文件:等待网络时,CPU 可以处理其他请求
import requests
from threading import Thread

def download(url):
    r = requests.get(url)
    with open(url.split('/')[-1], 'wb') as f:
        f.write(r.content)

threads = [Thread(target=download, args=(url,)) for url in urls]
for t in threads: t.start()
for t in threads: t.join()

场景3:响应性——UI 不卡死

GUI 应用:
  主线程(UI 线程):处理用户交互、更新界面
  工作线程:执行耗时操作(网络请求、文件读写)
  
  如果在主线程执行耗时操作,UI 会"冻结"
  解决:在工作线程做,完成后通知主线程更新 UI

Level 2:原理剖析

Linux 线程的真相:轻量级进程

Linux 里没有独立的"线程"内核概念——线程是轻量级进程(LWP,Light Weight Process),也用 task_struct 表示,通过共享 mm_struct(内存映射)等来模拟线程语义。

// fork() 创建进程:不共享内存
// clone() 创建线程:可以精细控制共享什么
clone(child_fn, stack,
      CLONE_VM |        // 共享虚拟内存
      CLONE_FS |        // 共享文件系统
      CLONE_FILES |     // 共享文件描述符
      CLONE_SIGHAND,    // 共享信号处理器
      arg);

pthread_create 底层就是调用 clone(),带上这些共享标志。

这意味着在 Linux 上,ps aux 看到的有时是进程,有时是线程——它们本质上是一回事,只是共享程度不同。ps -eLf 可以看到所有线程(显示 LWP 列)。

线程栈的大小

每个线程有自己的栈。默认栈大小:

Linux 默认线程栈:8 MB
macOS 默认线程栈:8 MB(主线程 8 MB,其他线程 512 KB)
Windows 默认线程栈:1 MB

进程地址空间:
┌──────────────────────────────────┐ 高地址
│    线程1的栈(8MB)               │
├──────────────────────────────────┤
│    线程2的栈(8MB)               │
├──────────────────────────────────┤
│    线程3的栈(8MB)               │
├──────────────────────────────────┤
│    ...                           │
├──────────────────────────────────┤
│    共享堆(malloc)               │
├──────────────────────────────────┤
│    共享代码和全局数据             │
└──────────────────────────────────┘ 低地址

8 MB 默认栈 × 1000 线程 = 8 GB 虚拟内存。64 位系统虚拟地址空间巨大(128 TB),但物理内存受限。深递归会栈溢出(Stack Overflow)——Stack Overflow 这个网站名字就来源于此。

线程局部存储(TLS)

有些变量每个线程都应该有自己的副本,如 errno

#include <threads.h>

// C11 标准的线程局部存储
thread_local int my_counter = 0;

void* worker(void* arg) {
    // 每个线程有自己的 my_counter,互不影响
    my_counter++;
    printf("线程 %lu 的 counter = %d\n", pthread_self(), my_counter);
    return NULL;
}

GCC 实现:__thread int counter;

Python 中:threading.local()

用户态线程 vs 内核态线程

内核态线程(1:1 模型):
  每个用户线程对应一个内核线程
  调度由内核管理,能真正并行(多核)
  上下文切换需要系统调用(~1-10µs)
  Linux pthreads 就是这个模型

用户态线程(N:1 模型):
  多个用户线程复用一个内核线程
  完全在用户空间调度(更快,~0.1µs)
  无法利用多核(整个进程只有一个内核线程)
  阻塞 I/O 会卡住所有用户线程
  Go 语言早期协程模型类似此

混合模型(M:N):
  M 个用户线程映射到 N 个内核线程
  Go goroutine 当前实现(GOMAXPROCS=N 个内核线程,调度 goroutines)
  兼顾性能和多核利用

Go 语言的 goroutine 是现代协程的典范:创建一个 goroutine 只需约 2 KB 栈(可自动增长),可以同时存在数百万个,而传统 1:1 线程每个至少 2 MB,几千个就会耗尽内存。

Level 3 · 规范怎么定义的(资深)

线程模型的标准定义

线程的 API 由 POSIX Threads(IEEE Std 1003.1c-1995,简称 pthreads)标准定义。pthreads 规范了线程创建(pthread_create)、终止(pthread_exit/pthread_join/pthread_cancel)、同步原语(互斥锁 pthread_mutex_*、条件变量 pthread_cond_*、读写锁 pthread_rwlock_*)以及线程属性(栈大小、调度策略、分离状态等)。pthreads 是最广泛实现的线程标准,Linux(NPTL 实现)、macOS、FreeBSD 都支持。

C11 标准(ISO/IEC 9899:2011)首次在 C 语言层面引入了线程支持(<threads.h>),提供了 thrd_createmtx_lockcnd_wait 等函数,语义与 pthreads 类似但更简洁。C++11(ISO/IEC 14882:2011)引入了 <thread><mutex><condition_variable><atomic> 头文件,并定义了 C++ 内存模型——这是第一次在主流语言标准中形式化定义多线程程序的内存可见性语义。

Linux 的 NPTL(Native POSIX Threads Library)实现基于 clone() 系统调用,每个 pthread 对应一个内核调度实体(task_struct)。Go 语言的 goroutine 使用 M:N 调度模型——M 个 goroutine 映射到 N 个 OS 线程上,由 Go 运行时的调度器(基于 GMP 模型:Goroutine、Machine、Processor)管理。这种用户态调度避免了内核线程切换的开销(goroutine 切换约 200ns,OS 线程切换约 1-5μs)。

Level 4 · 边界与陷阱(所有人)

陷阱 1:线程创建和销毁的开销不可忽视

在 Linux 上,创建一个线程需要:分配栈空间(默认 8MB 虚拟内存,实际物理页按需分配)、调用 clone() 进入内核、在调度器中注册——总共约 10-30μs。如果你在请求处理的热路径上为每个请求创建和销毁线程,这个开销会成为瓶颈。线程池(Thread Pool)模式通过预创建固定数量的线程并复用来消除这一开销,是所有高性能服务器的标准实践(Java 的 ThreadPoolExecutor、Go 的 goroutine 池、Nginx 的 worker 进程)。

陷阱 2:线程数量不是越多越好

直觉上似乎"更多线程 = 更快",但实际上线程数超过 CPU 核心数后,额外的线程只会增加上下文切换开销。每次上下文切换需要保存/恢复寄存器、刷新部分 TLB 和 Cache,约 1-5μs。当线程数达到数千时,CPU 可能将大量时间花在切换而非实际计算上。对于 CPU 密集型任务,最优线程数通常等于 CPU 核心数;对于 I/O 密集型任务,可以适当多一些(典型的经验公式是:线程数 = 核心数 × (1 + I/O等待时间/计算时间))。Go 的 GOMAXPROCS 默认设为 CPU 核心数就是这个道理。

陷阱 3:数据竞争(Data Race)可能不会立即崩溃,但结果不可预测

两个线程同时读写同一个非原子变量,在 C/C++ 中是未定义行为。这意味着编译器和 CPU 都可以自由重排这些操作。最隐蔽的后果是"撕裂读"(torn read):一个线程写入一个 64 位值的过程中,另一个线程可能读到一半新值一半旧值。在 32 位 x86 上,一次 64 位写入实际上是两次 32 位写入,另一个线程可能读到新的高 32 位和旧的低 32 位。ThreadSanitizer(TSan)是检测数据竞争的最佳工具,可以通过 GCC/Clang 的 -fsanitize=thread 启用。Google 在 Chrome 和 Android 的开发中强制使用 TSan 扫描,发现了数千个数据竞争 bug。

本章评分
4.9  / 5  (13 评分)

💬 留言讨论