Go 的并发哲学:不要共享内存通信

大多数语言做并发,思路是"多个线程共享同一块内存,再用锁护住它"。Go 反其道而行:它的口号是"不要通过共享内存来通信,而要通过通信来共享内存"。goroutine 是轻量任务、channel 是它们之间的管道——数据在管道里流动,谁拿到谁负责,自然就少了很多锁的麻烦。

这个设计让 Go 在网络服务、后台任务、爬虫这类"同时做很多件小事"的场景里特别顺手:一个 goroutine 只占几 KB 栈,开几万个也毫无压力。

goroutine 由 Go 运行时自己调度,不占操作系统线程。你开十万个 goroutine,它们会被多路复用到少量 OS 线程上跑。
本文核心知识点
  • goroutine在函数调用前加 go,就并行跑起来。
  • channel是带类型的管道,发送方塞数据、接收方取数据。
  • select同时等多个 channel,谁先到用谁。
  • sync.WaitGroup等一批 goroutine 都干完再退出。
  • context把取消信号沿着调用链传下去。
Go 并发模式配图:蓝调渐变背景,中央一个发光的圆形调度核心,向四周放射出数十条纤细的并发任务流线,每条流线带多个小光点流动,两侧有汇合的栅状屏障结构
中央调度核心向四周分发任务——这正是 goroutine 扇出(fan-out)的样子。

goroutine:几行代码起一个轻量线程

起一个 goroutine 只要在函数调用前加 go 关键字。下面这段代码里,主函数不等另外两个 goroutine,自己继续往下跑。

package main

import (
    "fmt"
    "time"
)

func say(s string) {
    for i := 0; i < 3; i++ {
        fmt.Println(s, i)
        time.Sleep(100 * time.Millisecond)
    }
}

func main() {
    go say("world")   // 并行跑
    say("hello")      // 主 goroutine 也跑
}

如果 main 先结束,整个程序就退出了,子 goroutine 也跟着没了——所以生产代码里一定要用后面讲的 WaitGroup 等它们干完,不能放任不管。

channel:在 goroutine 之间传数据

channel 是带类型的管道。make(chan int) 造一个能传整数的管道;ch <- 1 塞进去,v := <-ch 取出来。不带缓冲的管道默认会卡住,直到有人来收——这反而成了一种天然的同步。

func sum(nums []int, ch chan<- int) {
    total := 0
    for _, n := range nums {
        total += n
    }
    ch <- total        // 把结果送进管道
}

func main() {
    nums := []int{1, 2, 3, 4, 5, 6}
    ch := make(chan int)
    go sum(nums[:3], ch)
    go sum(nums[3:], ch)
    a, b := <-ch, <-ch   // 收两个结果
    fmt.Println(a + b)   // 21
}

两个 goroutine 各算一半,再通过 channel 把结果交回来。整个过程没有一把锁,数据靠管道"传"过去——这就是 Go 推崇的并发写法。

select:同时盯多个通道

当一个 goroutine 要同时等多个 channel,用 select:它像 switch,但每个 case 都是一次 channel 收发,哪个先就绪就跑哪个。

select {
case msg := <-ch1:
    fmt.Println("来自 ch1:", msg)
case msg := <-ch2:
    fmt.Println("来自 ch2:", msg)
case <-time.After(2 * time.Second):
    fmt.Println("超时了")
}

配上 time.After,select 顺手就实现了超时控制——这在网络编程里几乎是日常操作:等到了就处理,等不到就放弃。

等待组与互斥:sync 包三件套

有些场景确实需要等一批 goroutine 一起干完,或者共享一块内存。这时用标准库 sync:WaitGroup 等一批人干完,Mutex 给共享数据上把锁。

var wg sync.WaitGroup
var counter int
var mu sync.Mutex

for i := 0; i < 10; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        mu.Lock()
        counter++
        mu.Unlock()
    }()
}
wg.Wait()          // 等 10 个都干完
fmt.Println(counter) // 10

原则还是那个:能 channel 就 channel,必须共享内存再上 Mutex。锁用多了,Go 并发的优势就被自己磨掉了。

context:给并发任务一个取消信号

一个 HTTP 请求往往会起好几个 goroutine 去查数据库、调下游接口。用户中途关了页面,这一串都得停——手动去关太麻烦,Go 用 context.Context 把取消信号沿着调用链一路传下去。

ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()

go doWork(ctx)

select {
case <-ctx.Done():
    fmt.Println("任务被取消或超时:", ctx.Err())
}

在 doWork 里只要监听 ctx.Done(),一旦到点或被取消,就能干净地收尾退出。这是 Go 服务端代码的标配姿势。