并发编程(零):并发问题与讨论范围

限定单机、单进程范围,从共享变量出发,建立共享内存、消息传递、语言并发语义与硬件实现之间的整体关系。

中文发布于 更新于 2026/10/04约 4 分钟读完
并发编程(零):并发问题与讨论范围

目录


1. 先明确讨论范围

这个系列只讨论:

单机、单进程内部的并发。

也就是说,我们关注的是同一个进程中的多个执行单元。

例如:

  • Java Thread;
  • Go Goroutine;
  • Python Thread / asyncio Task。

不讨论跨进程通信、分布式系统和网络通信。


2. 从一个共享变量开始

假设进程中有一个变量:

count = 0

现在有两个执行单元:

Thread A                    Thread B

count++                     count++

如果 A 和 B 各执行一次,最终结果是否一定是:

count = 2

答案是不一定。

因为 count++ 可以从逻辑上拆成三个步骤:

读取 count
计算 count + 1
写回 count

于是可能出现:

初始:count = 0

Thread A                    Thread B

读取 count -> 0
计算 count + 1 -> 1
                            读取 count -> 0
                            计算 count + 1 -> 1
写入 count -> 1
                            写入 count -> 1

最终:

count = 1

两个线程都完成了一次 +1,但因为它们读取到了相同的旧值 0,最终两次计算结果都只是 1,其中一次更新被覆盖了。

问题的关键在于两个 Thread 同时修改了 count,也就是:

多个执行单元同时访问并修改了同一份可变数据。

在单机、单进程范围内,为保证该场景下的并发安全,主要有两种模型:

  • Shared Memory:多个执行单元直接共享可变数据,通过锁、原子操作等同步机制协调并发访问;
  • Message Passing:多个执行单元之间尽量避免直接共享可变数据,通过消息交换数据。

下面仍然以 count++ 为例,看这两种模型如何处理前面的并发问题。


3. 两种主要的协作模型

3.1 Shared Memory

仍然以 count 为例。

Java 中可以通过同步保护这段共享状态,例如 synchronized:

class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }
}

两个线程仍然访问同一个 Counter:

Thread A                    Thread B

counter.increment()         counter.increment()

但是,由于 synchronized 的作用,两个线程不能同时进入 increment() 的临界区,也就是 count++。

假设 Thread A 先获得锁,完整的执行过程会变成:

初始:count = 0

Thread A                         Thread B

请求进入 increment()
获得锁
                                 请求进入 increment()
                                 无法获得同一把锁,等待
读取 count      -> 0
计算 count + 1  -> 1
写回 count      -> 1
退出临界区并释放锁
                                 获得锁
                                 读取 count      -> 1
                                 计算 count + 1  -> 2
                                 写回 count      -> 2
                                 退出临界区并释放锁

最终:count = 2

如果 Thread B 先获得锁,A 和 B 的顺序会互换,但结果仍然是 2。

所以,这里的关键不在于谁先执行,而在于一次一个线程退出临界区之前,另一个线程不能进入同一个临界区。因此,原来两个线程都读取到 0 的交错不会再出现。

Go 和 Python 中也有类似的方式,例如:

Go      -> Mutex
Python  -> Lock

它们的共同思路都是:

可变数据直接共享,但对该数据的访问需要通过锁、原子操作等同步机制进行协调。


3.2 Message Passing

仍然以 count 为例。

例如 Go:

increments := make(chan int)

go func() { // Counter Owner Goroutine
    count := 0

    for delta := range increments {
        count += delta
    }
}()

两个生产者 Goroutine 都不直接修改 count,而是把增量发送到同一个 Channel:

go func() { // Goroutine A
    increments <- 1
}()

go func() { // Goroutine B
    increments <- 1
}()

可以抽象成:

Goroutine A ── +1 ──┐
                     ├──> Channel ──> Counter Owner Goroutine ──> count++
Goroutine B ── +1 ──┘

两个 Goroutine 都只发送消息,真正的 count 由独立的 Counter Owner Goroutine 修改。

Java 和 Python 中也有类似的消息传递工具:

  • Java BlockingQueue
  • Python queue.Queue / asyncio.Queue

它们的共同思路是:

多个执行单元通过消息协作,而不是同时直接修改同一份状态。


4. 为什么这些代码能够正确工作?

到这里,我们已经用两种方式处理了最开始的 count++ 问题。

Shared Memory 中,我们使用了同步机制:

synchronized / Lock / Mutex

Message Passing 中,我们使用了:

Channel / Queue

但还有一个更基础的问题:

为什么这些代码能够正确工作?

要回答这个问题,我们需要从并发程序最基本的三个维度来讨论:

  • Atomicity(原子性):一个执行单元执行一个或多个操作时,这些操作对其他执行单元是否表现为一个不可分割的整体;其他执行单元看不到中间状态,只能看到操作前或操作后的状态。
  • Visibility(可见性):一个执行单元写入数据后,其他执行单元是否能够观察到该写入。
  • Ordering(有序性):一个执行单元按先后顺序执行多个操作时,其他执行单元观察到这些操作生效的顺序,是否符合原本的先后关系。

程序员需要知道,在这三个维度上究竟可以依赖哪些保证。

这就需要继续引出:

语言提供的并发语义。


5. 语言需要定义并发语义

编程语言需要定义:

多个执行单元同时访问并修改了同一份可变数据时,程序允许出现哪些行为,程序员又可以依赖哪些规则。

Java 有:

Java Memory Model

Go 有:

Go Memory Model

Python 的情况比较特殊,本系列主要结合:

Python / CPython Concurrency Semantics

来讨论。

这些规则需要回答三个问题:

问题 回到 count 例子意味着什么
Atomicity count++ 包含“读取 → 加一 → 写回”。不加锁时,A 和 B 的步骤可能交错;使用同一把锁后,这段临界区对其他使用同一把锁的执行单元表现为不可交错。
Visibility A 写回 count = 1 并释放锁后,B 随后获得同一把锁时必须能够看到这个写入,而不能继续使用旧值 0。
Ordering A 写入 count = 1 后释放锁,B 随后获得同一把锁并读取 count;同步规则需要保证 B 观察到的这些操作符合这个先后关系。

6. 为什么还要继续下钻到操作系统和硬件?

语言定义并发规则,这些规则最终落实到操作系统和真实机器上。

Operating System 负责管理程序真正运行时的执行单元,例如 Thread 的调度、等待和唤醒,以及 Semaphore、Futex、Condition Variable 等同步机制。

Hardware 则提供更底层的执行基础,例如 Atomic Instruction、Memory Ordering,以及 CPU Cache、Store Buffer 等实现机制。


7. 用一张图总结一下这个系列的讨论层次

先看正常的软件到硬件分层,可以简化为下面这条链:

Source Code
├──→ Java:Java / JDK Source
├──→ Go:Go / Standard Library Source
└──→ Python:Python / Standard Library Source
        ↓
Compiler / Translator
├──→ Java:javac
├──→ Go:Go Compiler
└──→ Python:CPython Compiler
        ↓
Runtime / Execution Environment
├──→ Java:HotSpot JVM(Interpreter / JIT / Runtime)
├──→ Go:Go Runtime
└──→ Python:CPython Interpreter / Runtime
        ↓
Operating System
└──→ Linux / Windows / macOS
        ↓
Hardware
        │
        ├── ISA ──→ x86-64 / ARM64
        │      ↓
        ├── Microarchitecture ──→ Cache / Store Buffer / Reorder Buffer / Out-of-Order Execution
        │      ↓
        └── Physical Hardware ──→ CPU / DRAM / Interconnect

Language Memory Model / Concurrency Semantics 是一组跨层约束:Compiler、Runtime、Operating System 和 Hardware 的实现,共同满足语言对程序员定义的并发规则。

这个系列聚焦并发。为了让后面的 Mutex、Atomic、Volatile 等文章都沿着同一套结构向下展开,这里把并发规则和上面的正常软硬件分层合并成一张讨论图:

Concurrency Tools
├──→ Java:synchronized / Lock / Atomic / BlockingQueue
├──→ Go:Mutex / Atomic / Channel
└──→ Python:Lock / Queue / asyncio
        ↓
Language Memory Model / Concurrency Semantics
├──→ Java:Java Memory Model
├──→ Go:Go Memory Model
└──→ Python:Python / CPython Concurrency Semantics
        ↓
Runtime / Language Implementation
├──→ Java:HotSpot JVM(Interpreter / JIT / Runtime)
├──→ Go:Go Runtime
└──→ Python:CPython Interpreter / Runtime
        ↓
Operating System
└──→ Thread / Scheduler / Semaphore / Futex / Condition Variable
        ↓
Hardware
├──→ ISA:x86-64 / ARM64
├──→ Microarchitecture:Cache / Store Buffer / Reorder Buffer / Out-of-Order Execution
└──→ Physical Hardware:CPU / DRAM / Interconnect

8. 下一篇:先谈硬件

下一篇正式进入硬件:

主要讨论:

  • CPU 为什么需要缓存;
  • 多核 CPU 如何协调缓存数据;
  • 写缓冲和乱序执行为什么会影响内存访问顺序;
  • 原子指令提供什么能力;

讨论

使用 GitHub 账号参与讨论,评论会保存在 GitHub Issues 中。在 GitHub 查看