Go 的并发哲学:不要共享内存通信
大多数语言做并发,思路是"多个线程共享同一块内存,再用锁护住它"。Go 反其道而行:它的口号是"不要通过共享内存来通信,而要通过通信来共享内存"。goroutine 是轻量任务、channel 是它们之间的管道——数据在管道里流动,谁拿到谁负责,自然就少了很多锁的麻烦。
这个设计让 Go 在网络服务、后台任务、爬虫这类"同时做很多件小事"的场景里特别顺手:一个 goroutine 只占几 KB 栈,开几万个也毫无压力。
goroutine 由 Go 运行时自己调度,不占操作系统线程。你开十万个 goroutine,它们会被多路复用到少量 OS 线程上跑。
- goroutine在函数调用前加 go,就并行跑起来。
- channel是带类型的管道,发送方塞数据、接收方取数据。
- select同时等多个 channel,谁先到用谁。
- sync.WaitGroup等一批 goroutine 都干完再退出。
- context把取消信号沿着调用链传下去。
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 服务端代码的标配姿势。
实战:并发抓取一组网页
把上面几样串起来:给定一串网址,用 goroutine 并发抓取,用 WaitGroup 等全部结束,再把每个页面的字节数打印出来。
urls := []string{
"https://golang.org",
"https://example.com",
"https://news.ycombinator.com",
}
var wg sync.WaitGroup
for _, u := range urls {
wg.Add(1)
go func(url string) {
defer wg.Done()
resp, err := http.Get(url)
if err != nil {
fmt.Println(url, "出错:", err)
return
}
defer resp.Body.Close()
n, _ := io.Copy(io.Discard, resp.Body)
fmt.Println(url, n, "字节")
}(u)
}
wg.Wait()
fmt.Println("全部抓完")
三个请求几乎同时发出,总耗时是最慢那个,而不是三个加起来。再往上叠一层就是常见的"并发池":用一个带缓冲 channel 当信号量,控制最多同时跑多少个 goroutine,避免把对方服务器打挂。
Go 把并发从"底层细节"做成了"语言级能力":go、chan、select、context 四个关键字,就够写干净的并发程序。