Skip to content

理解 Go 并发哲学:通过通信来共享内存

引言

在 Go 语言的并发语境中,有一句广为流传、却常被误解的话:

不要通过共享内存来通信,而应该通过通信来共享内存。

这句话并不是对 mutex、atomic 等同步原语的否定,而是一种并发设计哲学。它关注的核心不是“能不能共享内存”,而是如何管理状态、表达所有权以及降低并发系统的复杂度

本文将从设计动机出发,结合具体代码示例,系统性地解释这句话在工程实践中的真实含义。


一、传统并发模型:共享内存 + 锁

在多数语言中,并发的默认模型是:

  • 多个线程 / goroutine
  • 访问同一块共享内存
  • 通过锁保证一致性

示例:使用 mutex 保护共享状态

go
var (
    counter int
    mu      sync.Mutex
)

func inc() {
    mu.Lock()
    counter++
    mu.Unlock()
}

func main() {
    for i := 0; i < 1000; i++ {
        go inc()
    }
    time.Sleep(time.Second)
    fmt.Println(counter)
}

这段代码是正确的。但它有一个明显的特征:

  • 正确性依赖于“所有访问 counter 的地方都必须记得加锁”
  • 并发约束存在于程序员的认知中,而不是结构中

在系统规模变大后,这种隐式约束会成为主要风险来源。


二、Go 的主张:状态应当被拥有,而不是被围观

Go 的并发哲学试图回答一个更根本的问题:

这份数据,究竟属于谁

在“通过通信来共享内存”的模型中:

  • 数据在 goroutine 之间移动
  • 所有权随通信发生转移
  • 同一时刻,数据只被一个 goroutine 持有

示例:通过 channel 串行化状态访问

go
func counterServer(in <-chan int, out chan<- int) {
    counter := 0
    for {
        select {
        case v := <-in:
            counter += v
        case out <- counter:
        }
    }
}

func main() {
    in := make(chan int)
    out := make(chan int)

    go counterServer(in, out)

    for i := 0; i < 1000; i++ {
        in <- 1
    }

    fmt.Println(<-out)
}

这里的关键变化是:

  • counter 不再是共享变量
  • 它被封装在一个 goroutine 内部
  • 外界只能通过 channel 与之交互

这使得并发安全性从“人为约定”变成了“结构保证”。


三、channel 的本质:并发协议,而非简单队列

在工程上,channel 的价值远不止“传值”。

一个 channel 隐含地定义了:

  • 数据流向
  • 同步关系(阻塞 / 缓冲)
  • 背压机制
  • 生命周期边界

示例:使用 channel 表达工作流

go
type Job struct {
    ID int
}

type Result struct {
    ID int
}

func worker(jobs <-chan Job, results chan<- Result) {
    for job := range jobs {
        results <- Result{ID: job.ID}
    }
}

func main() {
    jobs := make(chan Job)
    results := make(chan Result)

    go worker(jobs, results)

    jobs <- Job{ID: 1}
    close(jobs)

    fmt.Println(<-results)
}

在这个模型中:

  • Job 的所有权通过 channel 明确转移
  • worker 不需要关心锁
  • 调度顺序天然由 channel 决定

并发控制被提升为架构层面的设计


四、这句话并不排斥共享内存

需要明确的是:

Go 并没有禁止共享内存。

以下场景中,直接共享内存是合理的:

  • 原子计数器(sync/atomic
  • 高性能缓存
  • 极端性能敏感的热路径

示例:合理使用 atomic

go
var total int64

func inc() {
    atomic.AddInt64(&total, 1)
}

这里的选择是基于性能与复杂度的权衡,而不是哲学立场。

Go 的那句名言,更像是一条设计优先级提醒:

如果你已经在为锁的边界和顺序焦虑,说明可以考虑用通信模型重新审视设计。


五、工程化理解

在成熟的 Go 并发系统中,常见模式是:

  • goroutine 持有私有状态
  • channel 作为唯一交互入口
  • 少量、可控的共享内存用于性能关键点

这种结构带来的收益包括:

  • 更强的可读性
  • 更低的心智负担
  • 更容易推导正确性

结语

“不要通过共享内存来通信,而应该通过通信来共享内存”并不是一条强制规则,而是一种并发设计的价值取向

它鼓励开发者:

  • 用数据流替代状态争夺
  • 用结构约束代替人为约定
  • 用架构设计降低并发复杂度

在这个意义上,它不是关于 channel,而是关于如何构建可长期维护的并发系统

最近更新