2. 线程与进程模型深入

在并发编程中,进程与线程是最基础的两个执行单元。理解它们的差异、线程的生命周期、上下文切换的代价,以及多线程 API 的正确用法与常见陷阱,是写出稳定并发程序的前提。本节从模型对比出发,逐步深入到线程状态迁移、切换开销的量化估算,最后给出可运行的 Java 示例与陷阱清单。

一、进程与线程的本质区别

进程是操作系统进行资源分配的基本单位,拥有独立的虚拟地址空间、文件描述符表、信号处理表等;线程是 CPU 调度的基本单位,同一进程内的多个线程共享地址空间和大部分资源,但各自拥有独立的程序计数器、栈和寄存器上下文。

这种“资源共享、执行独立”的设计带来两个直接后果:一是线程间通信成本远低于进程间通信(IPC),因为不需要经过内核拷贝或序列化;二是线程间隔离性弱,一个线程的越界写可能破坏同进程其他线程的数据,而进程崩溃通常不会影响其他进程。

对比维度进程线程
资源分配独立地址空间、文件表共享进程资源
调度单位是是(更轻量)
创建开销较大(复制页表等)较小(约 1/10 或更低)
通信方式管道、共享内存、socket共享变量、锁、条件变量
隔离性强弱
崩溃影响通常不影响其他进程可能导致整个进程退出

二、线程生命周期与状态迁移

以 Java 的 Thread.State 为例,线程共有六种状态:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。它们之间的迁移关系是理解并发行为的关键。

Java 线程状态迁移图 NEW RUNNABLE TERMINATED BLOCKED WAITING TIMED_WAITING start() run() 结束 竞争锁失败 获得锁 wait()/join() notify()/唤醒 sleep(t)/wait(t) 超时/唤醒

需要特别注意:RUNNABLE 在 JVM 层面包含了“就绪”和“运行中”两种子状态,是否真正占用 CPU 由操作系统调度决定。BLOCKED 仅指等待进入 synchronized 临界区,而 WAITING/TIMED_WAITING 是调用了 Object.wait、Thread.join、LockSupport.park 等方法后的等待状态。

三、上下文切换开销的量化理解

上下文切换是内核保存当前线程的寄存器、程序计数器、栈指针等状态,并恢复另一个线程状态的过程。它本身不执行用户业务逻辑,因此是纯开销。一次切换的耗时通常在 1~10 微秒量级,具体取决于 CPU 架构、缓存命中情况和内核实现。

可以用一个简单模型估算切换带来的吞吐损失。假设单次切换耗时 t_switch = 3μs,线程每次执行 t_work = 50μs 后因锁竞争或时间片耗尽发生切换,则有效 CPU 利用率为:

利用率 = t_work / (t_work + t_switch)
       = 50 / (50 + 3)
       ≈ 94.3%

若把 t_work 降到 5μs,利用率变为 5 / 8 ≈ 62.5%,超过三分之一的 CPU 时间花在切换上。这说明:锁粒度越细、临界区越短,并不总是越好;当临界区短到与切换开销可比时,反而应合并操作或使用无锁结构。

经验参考:在普通 x86 服务器上,一次线程上下文切换约 2~5μs;若涉及跨 NUMA 节点或缓存大量失效,可能达到 10μs 以上。压测时可用 vmstat 1 观察 cs 列(每秒上下文切换次数)。

四、多线程基本 API 与典型陷阱

下面给出一个可运行的 Java 示例,演示创建线程、共享计数器以及用 synchronized 保护临界区。示例参数为教学假设,非实测数据。

public class CounterDemo {
    private int count = 0;

    public synchronized void increment() {
        count++;          // 非原子:读-改-写三步
    }

    public synchronized int get() {
        return count;
    }

    public static void main(String[] args) throws InterruptedException {
        CounterDemo demo = new CounterDemo();
        Thread[] threads = new Thread[4];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 100_000; j++) {
                    demo.increment();
                }
            });
            threads[i].start();
        }
        for (Thread t : threads) {
            t.join();     // 主线程等待所有子线程结束
        }
        System.out.println("count = " + demo.get());
    }
}

若去掉 synchronized,最终结果通常小于 400000,因为 count++ 不是原子操作,多个线程可能读到相同的旧值。这是最经典的竞态条件。

常见陷阱还包括:

  • 死锁:两个线程以相反顺序获取两把锁。避免方式是统一加锁顺序,或使用 tryLock 超时。
  • 活锁:线程不断重试却无法推进,例如两个线程互相礼让。
  • 线程泄漏:线程池未关闭或任务阻塞导致线程无法回收。
  • 可见性问题:未使用 volatile 或锁时,一个线程的写可能对另一线程不可见。
  • 误用 Thread.stop/suspend:已废弃,会破坏对象一致性,应使用中断标志位协作停止。
不要用 Thread.stop() 强制终止线程。正确做法是设置 volatile boolean running 标志,线程循环检查该标志并自行退出;阻塞在 wait/sleep 时通过 interrupt() 唤醒并捕获 InterruptedException。

五、实践判断:何时用进程,何时用线程

如果任务之间需要强隔离、可能崩溃且不能互相影响,或者需要独立部署与资源限额,优先用进程;如果任务需要频繁共享大量数据、追求低通信延迟和高吞吐,优先用线程。对于 CPU 密集型任务,线程数接近 CPU 核心数即可;对于 I/O 密集型任务,可适当放大线程数,但需结合上下文切换开销与内存占用综合评估。

一个可操作的判断流程是:先估算任务的 CPU 时间占比 p,再根据目标利用率 U 估算所需线程数 N ≈ U / (1 - p)(该公式为简化模型,仅作教学参考)。例如 p = 0.8、目标 U = 0.9,则 N ≈ 4.5,取 5 左右。实际部署时还应通过压测观察 cs、load average 和响应时间,逐步调整。

掌握线程状态迁移、切换开销的量级以及 API 的正确用法,才能在后续的任务调度与并发框架设计中做出合理取舍。