mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1975 字
5 分钟
为什么使用通信来共享内存
2023-01-17

Go 语言有一句广为流传的设计哲学:「Don’t communicate by sharing memory; instead, share memory by communicating.」这句话看似矛盾,实则道出了并发编程中最核心的设计抉择。

为什么 Go 选择 CSP 模型而非传统的共享内存加锁模型?这个问题的答案藏在并发编程几十年的工程教训里。

一、共享内存模型的问题#

1.1 传统并发编程#

大多数主流语言(Java、C++、Python)采用共享内存模型:多个线程读写同一块内存区域,通过锁(Mutex)协调访问。

flowchart TB subgraph 共享内存模型 T1[线程 1] --> R[共享变量] T2[线程 2] --> R T3[线程 3] --> R R --> L[锁 Mutex] end style R fill:#ff9800,color:#fff style L fill:#f44336,color:#fff

这种模型在简单场景下没问题。但当并发规模增长,一系列棘手的 bug 会浮出水面。

1.2 竞态条件#

最经典的问题。两个线程同时读写一个变量,结果取决于执行顺序:

// 竞态条件示例
var counter int
func increment() {
counter++ // 三个操作:读取、加1、写回
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
increment()
}()
}
wg.Wait()
// counter 的值几乎不等于 1000
fmt.Println(counter)
}

counter++ 不是原子操作。它由「读取当前值」「加 1」「写回内存」三步组成。两个 goroutine 可能同时读到相同的值,各自加 1 后写回,最终只加了一次。

1.3 死锁#

加锁顺序不当,导致所有线程互相等待:

// 经典死锁
func worker(mutexA, mutexB *sync.Mutex) {
mutexA.Lock()
defer mutexA.Unlock()
mutexB.Lock() // 等待 mutexB
defer mutexB.Unlock()
}
func workerReverse(mutexA, mutexB *sync.Mutex) {
mutexB.Lock()
defer mutexB.Unlock()
mutexA.Lock() // 等待 mutexA → 死锁!
defer mutexA.Unlock()
}
flowchart LR subgraph 死锁场景 G1[Goroutine A] -->|"持有 lockA<br>等待 lockB"| G2[Goroutine B] G2 -->|"持有 lockB<br>等待 lockA"| G1 end style G1 fill:#f44336,color:#fff style G2 fill:#f44336,color:#fff

1.4 活锁与优先级反转#

活锁:线程都在工作,但整体没有进展。好比走廊里两个人面对面,同时向左让、又同时向右让,永远过不去。

优先级反转:低优先级线程持有锁,高优先级线程在等锁。但中优先级线程抢占 CPU,低优先级线程无法释放锁,高优先级线程反而被「反超」。1997 年火星探路者号就因此反复重启。

flowchart TB subgraph 优先级反转 H[高优先级 任务] -->|"等待锁"| L[低优先级 任务] M[中优先级 任务] -->|"抢占 CPU"| L L -->|"无法运行<br>无法释放锁"| H end

1.5 根本困境#

这些问题有共同根源:可见状态太多

flowchart TB A[共享内存的根本问题] --> B[所有线程都能看到所有状态] B --> C[必须手动协调访问] C --> D[锁的排列组合爆炸] D --> E[竞态条件] D --> F[死锁] D --> G[活锁] D --> H[优先级反转]

当程序有 N 个共享变量和 M 个锁,组合关系的复杂度是 O(2^N × M!)。人力很难在这种复杂度下保证正确性。

二、CSP 模型的核心思想#

2.1 从共享到通信#

CSP(Communicating Sequential Processes)由 Tony Hoare 在 1978 年提出。核心思想:不要让多个执行单元同时操作同一块内存,而是让它们各自持有自己的状态,通过消息传递协调

flowchart TB subgraph 共享内存模型 T1[Thread 1] -->|"读写"| S[共享状态] T2[Thread 2] -->|"读写"| S T3[Thread 3] -->|"读写"| S S --> L[" 锁"] end subgraph CSP 模型 G1[Goroutine 1] -->|"消息"| CH[Channel] CH -->|"消息"| G2[Goroutine 2] CH -->|"消息"| G3[Goroutine 3] G1 --> S1[私有状态] G2 --> S2[私有状态] G3 --> S3[私有状态] end style L fill:#f44336,color:#fff style CH fill:#4caf50,color:#fff

区别很明显:共享内存模型里所有人冲着一块白板写画,靠锁排队;CSP 模型里每个人有自己的笔记本,互相传纸条协调。

2.2 Channel 的语义#

Go 的 channel 提供两个核心保证:

同步保证:发送和接收会阻塞,直到对方准备好。无缓冲 channel 就是一个同步点。

ch := make(chan int) // 无缓冲 channel
ch <- 42 // 没人接收就阻塞
value := <-ch // 没人发送就阻塞

类型安全:channel 是有类型的管道,编译器保证只有正确类型的数据能通过。

2.3 所有权转移#

Go 的 channel 实现了隐式的「所有权转移」语义:把值发送到 channel 后,就不应该再使用它。

func producer(ch chan []byte) {
data := make([]byte, 1024)
// 填充 data...
ch <- data // 发送后,data 的「所有权」转移给接收方
}
func consumer(ch chan []byte) {
data := <-ch // 获得所有权,安全使用
}

这种语义和 Rust 的所有权系统在精神上是一致的:通过限制谁可以访问数据,来消除并发 bug 的根源。

三、Go 的 CSP 实现#

3.1 Goroutine#

Go 的 goroutine 是 CSP 模型的执行单元,比 OS 线程轻量得多:

特性OS ThreadGoroutine
栈大小1-8 MB2 KB(动态增长)
创建成本~1ms~0.3μs
上下文切换~1-10μs~0.2μs
调度OS 内核Go runtime

Go 的 M 调度模型(GMP 模型)用少量系统线程调度大量 goroutine,百万级并发成为可能。

3.2 Channel 实战#

用 CSP 模型实现生产者-消费者模式:

func producer(ch chan<- int) {
for i := 0; i < 100; i++ {
ch <- i
}
close(ch)
}
func consumer(ch <-chan int, done chan<- bool) {
for value := range ch { // range 自动处理 close
process(value)
}
done <- true
}
func main() {
ch := make(chan int, 10) // 带缓冲 channel
done := make(chan bool)
go producer(ch)
go consumer(ch, done)
<-done // 等待消费者完成
}

注意 channel 的方向声明:chan<- int 只写、<-chan int 只读。编译器在编译期检查方向约束。

3.3 Select 多路复用#

select 语句让一个 goroutine 同时等待多个 channel:

func fibonacci(c chan<- int, quit <-chan int) {
x, y := 0, 1
for {
select {
case c <- x:
x, y = y, x+y
case <-quit:
return
}
}
}

四、竞态检测#

Go 内置竞态检测器(Race Detector),这是 CSP 模型的工程保障。

# 测试时启用竞态检测
go test -race ./...
# 运行时启用
go run -race main.go

实际效果:

$ go run -race race_bug.go
==================
WARNING: DATA RACE
Write at 0x000001234567 by goroutine 7:
main.main.func1()
race_bug.go:10 +0x4e
==================
Found 1 data race(s)

竞态检测器基于 ThreadSanitizer 实现,能精确报告冲突的 goroutine、代码位置和调用栈。

五、两种模型的直接对比#

5.1 同一个问题的两种实现#

问题:多个 goroutine 并发累加计数器。

共享内存方案(Mutex)

type SafeCounter struct {
mu sync.Mutex
count int
}
func (c *SafeCounter) Inc() {
c.mu.Lock()
c.count++
c.mu.Unlock()
}

CSP 方案(Channel)

func counterService(ch <-chan func(int) int) int {
count := 0
for f := range ch {
count = f(count)
}
return count
}

CSP 方案中,计数器封装在 counterService 的局部变量里,外部无法直接访问。竞态条件从根本上不可能发生。

5.2 Fan-out/Fan-in 模式#

并行处理数据,然后汇总结果:

func fanOutFanIn(input <-chan int, workers int) <-chan int {
results := make(chan int)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for value := range input {
results <- process(value)
}
}()
}
go func() {
wg.Wait()
close(results)
}()
return results
}

用共享内存实现需要原子计数器、条件变量、结果切片的锁保护。用 channel 表达则非常自然。

六、性能对比#

6.1 Benchmark#

Mutex 和 Channel 在高并发下的典型对比:

方式ns/op适用场景
Mutex~50简单共享状态保护
Channel~200复杂协调、流式处理

Mutex 在原始性能上更快。Channel 需要额外的调度和拷贝开销。但性能不是唯一考量:Channel 提供更好的组合性、更少的 bug、更容易理解的代码。

6.2 什么时候用 Mutex#

Go 不排斥 Mutex。以下场景 Mutex 更合适:

  • 简单的计数器或标志位
  • 读多写少的缓存(sync.RWMutex
  • 性能敏感的热路径
  • 保护内部实现细节

原则:对外暴露的 API 用 channel 通信,内部实现可以用 Mutex 保护。

七、其他语言的并发模型对比#

flowchart TB subgraph 并发模型对比 A[共享内存 + 锁] --> A1["Java synchronized<br>C++ std::mutex"] B[CSP 模型] --> B1["Go goroutine + channel"] C[Actor 模型] --> C1["Erlang/Akka Actor"] D[所有权系统] --> D1["Rust Ownership"] end
特性CSP (Go)Actor (Erlang)Rust
通信方式Channel(匿名管道)直接发送到进程邮箱编译期检查所有权
耦合度低(只需持有 channel)高(需要知道对方 PID)低(类型系统保证)
安全保证运行时检测Supervisor 树容错编译期消除数据竞态
分布式不原生支持原生分布式不原生支持

Go 的 CSP 通过 channel 隐式转移数据的「所有权」,Rust 的 Send/Sync trait 则在编译期强制保证。两条路径殊途同归:限制数据的并发访问,从根本上消除竞态。

八、CSP 的局限与总结#

8.1 局限性#

Goroutine 泄漏:goroutine 在 channel 上阻塞而无人解除,就会永远泄漏。防范措施是用 context.Context 超时取消。

不适合细粒度并行:数值计算等需要极高吞吐量的场景,channel 的开销可能无法接受。

调试困难:goroutine 数量多时,追踪 channel 数据流比较困难。Go 提供了 pprof 和 trace 工具来缓解。

8.2 设计哲学#

flowchart TB subgraph CSP 的工程平衡 A[简单性] --> A1["go 关键字启动<br>channel 通信"] B[安全性] --> B1["竞态检测器<br>channel 隔离状态"] C[性能] --> C1["轻量 goroutine<br>M:N 调度"] D[组合性] --> D1["channel 一等公民<br>select 多路复用"] end
维度共享内存CSP (Go)
学习曲线陡峭(需理解锁语义)平缓(channel 比锁直观)
bug 类型竞态、死锁、活锁goroutine 泄漏
调试难度高(bug 难复现)中(有竞态检测器)
性能上限中高
代码可读性低(锁散落各处)高(数据流清晰)

共享内存和 CSP 不是非此即彼的对立关系。Go 的做法是:推荐 CSP 风格,但不排斥 Mutex。简单的场景用 Mutex,复杂的协调场景用 channel,根据问题选择合适的工具。

真正重要的不是你选了哪种模型,而是理解每种模型的取舍,并在工程中做出明智的选择。

参考引用#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

为什么使用通信来共享内存
https://blog.souloss.com/posts/why-the-design/why-share-memory-by-communicating/
作者
Souloss
发布于
2023-01-17
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时