Hacker News 中文摘要

RSS订阅

Go 并发编程精要 -- Go Concurrency Distilled

这本迷你书提供了Go语言(Go)中许多并发主题的简要概述。每个主题都配有交互式示例,读者可以自由修改代码并点击Run按钮进行试验。此外还提供了一个带有静态示例的PDF版本。该书声明不含人工智能生成内容。

正文

这是本小书,简要梳理 Go 并发的众多主题。每个主题都配有可交互示例,可以改代码、点运行试验;另有带静态示例的 PDF 版本。它是 Go 并发的快速复习,不是入门指南。本书不含 AI 生成内容。

Goroutines

Go 并发的基础是 goroutine——用 go 关键字启动的函数。示例里 main 声明一个 sync.WaitGroup,Add(2),再用 go 启动两个匿名函数,各自 defer wg.Done() 并打印 worker 1 / worker 2,最后 wg.Wait()。输出顺序不定。

Go 运行时调度这些 goroutine,把它们分配到跑在 CPU 核心上的操作系统线程。相比 OS 线程,goroutine 很轻量,可以创建成百上千个。

goroutine 彼此完全独立。main 函数本身也是一个 goroutine,只是随程序启动隐式开始。main 结束时,其他 goroutine 也一并关闭。

上面用等待组(sync.WaitGroup)等 goroutine 结束。等待组内部有一个计数器:Add(n) 加 n,Done() 减一,Wait() 阻塞调用方(这里是 main)直到计数器归零,于是 main 会等两个 worker 都完成再退出。

WaitGroup.Go 会自动递增计数器、在 goroutine 里运行函数、完成后再递减,把 Add + go + Done 三步合成一步:wg.Go(func(){...})。

Channels

goroutine 之间通过 channel 传值。channel 像一扇窗,一个 goroutine 往里扔东西,另一个接住。make(chan string) 之后,一个 goroutine 发送 "ping",主 goroutine 接收并打印。

通过 channel 发送是同步操作:发送方执行 ch <- val 时阻塞,直到有人接收 <-ch,然后才继续。

输出 channel

函数返回一个输出 channel、并在内部 goroutine 里填充它,是 Go 里常见的模式。这样调用方通过 channel 取值,而函数本身保留对 channel 的控制权。比如 generate(start, stop) 返回 chan int,内部 goroutine 把区间内的数一个个发进去。

关闭 channel

写入方用 close() 通知读取方数据已发完。读取方用第二个返回值("comma ok")检查状态:num, ok := <-in,channel 还开着就拿到值和 true,已关闭就拿到零值和 false,据此跳出循环。

channel 只能关闭一次。再次关闭或向已关闭的 channel 写入都会 panic。

关闭 channel 的唯一理由是通知读取方数据发完了。如果读取方不在意,就不必关闭。channel 不再使用时,Go 的垃圾回收器会释放它的资源,关不关都一样。

迭代 channel

range 会自动读取 channel 的下一个值并检查是否关闭,关闭就退出循环。对 channel 做 range 只返回一个值,不像对切片 range 那样返回一对。

定向 channel

设置 channel 方向可以避免误写误关。channel 有三种:

  • chan(双向):可读可写,默认;
  • chan<-(只写):只能写;
  • <-chan(只读):只能读。

不能从只写 channel 读,也不能向只读 channel 写,还不能关闭只读 channel。

channel 通常在初始化时是双向的,在函数参数里指定方向,Go 会自动把普通 channel 转成定向 channel。

缓冲 channel

带缓冲的 channel 像一个有固定大小缓冲区的 FIFO 队列。只要缓冲区还有空位,写入就不会阻塞;只要缓冲区还有值,读取也不会阻塞。不指定缓冲区大小时,channel 是无缓冲的(大小为零)。

缓冲 channel 可以用内置的 len() 和 cap()。

从已关闭的缓冲 channel 读取,会先返回缓冲区里的值加 true;值取完后,返回零值加 false,和普通 channel 一样。

nil channel

和 Go 里任何类型一样,channel 的零值是 nil。

向 nil channel 写入或读取会永久阻塞 goroutine。关闭 nil channel 会 panic。

Select

select 有点像 switch,但专为 channel 设计,它会:

  • 检查哪些 case 没有被阻塞。
  • 如果有多个 case 就绪,随机挑一个执行。
  • 如果全部阻塞且有 default 分支,就执行 default。
  • 如果全部阻塞且没有 default,就等到某一个就绪。

select 用来管理管道里的数据流:

  • 合并:merge(in1, in2) 从两个输入 channel 合并到输出 channel,循环条件是 in1 != nil || in2 != nil;某个 case 读到 channel 关闭时,把对应变量设为 nil,这个 case 之后就不再被选中。
  • 取消 goroutine:process(cancel, in) 在 select 里同时尝试发送处理结果和监听 cancel,收到取消就打印 "canceled" 并返回。
  • 非阻塞操作:multiplier 返回的函数用 select 尝试把 n*10 发进 channel,成功返回 nil,走 default 则返回 "busy" 错误。容量为 1 的 channel 下,前两次调用成功,第三次、第四次因缓冲已满返回 busy。

以及更多用途。

Pipelines

管道是一连串操作,每一步接收输入、以特定方式处理、再输出,每一步的输入输出都是 channel。

典型管道长这样:

  • Reader:从文件、数据库或网络读取输入数据。
  • N 个处理器:借助外部数据源做转换、过滤、聚合或充实。
  • Writer:把处理后的数据写入文件、数据库或网络。

代码里 read[T]、process[T]、write[T] 都是泛型函数,各自返回 channel。

输出 channel

goroutine 可以用输出 channel 告诉其他 goroutine 自己干完了:generate 返回 <-chan int,调用方用 range 读。

完成 channel

如果 goroutine 不需要返回结果,可以用 done channel 通知完成:work 返回 <-chan struct{},内部 defer close(done),调用方 <-done 等待。

取消 channel

要提前终止 goroutine,调用方可以用 cancel channel。generate(cancel, n) 在 select 里发送值或监听 cancel,收到取消就直接 return;main 里 defer close(cancel)。

错误处理

并发管道里处理错误有三种方式。

➊ 遇到第一个错误就返回:函数返回 (<-chan int, <-chan error),出错时向 errc 发送错误并 return,正常结束时发送 nil。errc 用容量 1,避免泄漏。

➋ 用结果类型:定义 Result{answer, err},函数返回 <-chan Result,每个值同时带上答案和错误。

➌ 单独收集错误:函数额外接收一个 errc chan<- error,成功的结果发到 out,错误发到 errc。

Time

time 包除处理日期时间外,还提供在并发程序里管理时间敏感操作的工具。

After

time.After() 返回一个 channel,初始为空,超时后收到一个值,适合给操作加超时。withTimeout(timeout, fn) 在 goroutine 里跑 fn,用 select 同时等待 done 和 time.After(timeout):先完成返回 nil,超时返回错误。示例中 50ms 超时配立即完成的函数得到 err = <nil>;配睡 100ms 的函数则得到 err = timeout。

Timer

定时器(time.Timer)是一个带 C channel 的结构,触发(到期)时会向该 channel 发送当前时间,适合安排未来的执行。NewTimer(50ms) 后,goroutine 从 timer.C 取值会阻塞 50ms,再打印完成时间。

Stop() 停止定时器:还没到期返回 true,否则返回 false。示例中 50ms 的定时器在 10ms 后 Stop(),返回 true,打印 "execution canceled"。

更方便的往往是包装函数 time.AfterFunc():等待时长 d 后执行函数 f,返回的定时器可以在执行前取消。

如果定时器用在循环里,最好创建一个定时器反复 Reset,而不是每轮迭代都新建。示例中 consumer 从输入 channel 读 token,一小时没有值就告警,用 NewTimer 每轮 Reset;发 10000 个值,内存只用 4 KB,分配 6 次。

Ticker

ticker 类似定时器,但会持续触发直到你停掉它,适合周期性任务。NewTicker(50ms) 每隔 50ms 向 C 发送当前时间,必须最终用 Stop() 释放资源。示例中睡 160ms 后停止,打印了三个时间点。

如果 channel 的读取方跟不上 ticker,ticker 会跳过若干次。

Context

context 的主要用途是取消操作,可以手动取消,也可以按超时或截止时间取消。

函数接收 context,通过它的 Done() channel 监听取消。work(ctx) 在内部 goroutine 里做 50ms 的活,用 select 等 done 或 ctx.Done(),被取消时返回 ctx.Err()。

手动取消:context.Background() 是空 context,context.WithCancel 得到可取消的 context(记得 defer cancel)。主 goroutine 睡 10ms 后调用 cancel(),work 返回 context canceled。

按超时取消:context.WithTimeout(ctx, 10ms),错误是 context deadline exceeded。

按截止时间取消:deadline := time.Now().Add(10ms),再用 context.WithDeadline,错误同样是 context deadline exceeded。

context 是分层的。context 对象本身不可变,要加新属性就在父 context 基础上创建子 context。父与子之间总是较短的那个超时生效,子 context 只能缩短父的超时,不能延长。示例中父 100ms、子 10ms,最终 work 被取消。

多次取消是安全的:cancel() 想调多少次都行,第一次生效,其余忽略。

可以用 context.WithCancelCause()、WithTimeoutCause()、WithDeadlineCause() 指定自定义取消原因,通过 context.Cause() 读取。示例中 cancel(errors.New("the night is dark")),Cause 就返回这条错误。

可以用 context.AfterFunc() 注册 context 被取消时执行的函数,示例里的 cleanup 会打印 "cleanup"。

context 还能用 context.WithValue() 为特定键携带值,但一般应避免用 context 传值,最好改用显式参数或自定义结构体。

Wait groups

sync.WaitGroup 用来等一个或多个 goroutine 结束。示例:n 为 10,Add(n),循环启动 goroutine 各打印一个 ".",最后 Wait。

WaitGroup 并不了解它管理的 goroutine,只操作一个内部计数器:Add(1) 加一,Done() 减一,Wait() 阻塞调用方直到计数器归零。

Go 方法把 Add、启动 goroutine 和 Done 合在一起。

所有方法都可以从多个 goroutine 安全调用。通常所有 Add 都在 Wait 之前,但技术上不禁止一部分 Add 在 Wait 之后(由另一个 goroutine 执行)。可以从多个 goroutine 调用 Wait,它们都会阻塞到计数器归零。

Data races

当多个 goroutine 访问共享数据,且至少有一个在修改它时,就发生数据竞争。需要保护数据免受这类并发访问。

数据竞争不一定会导致运行时 panic,所以 Go 提供了专门的竞态检测器,用 race 标志开启,适用于 test、run、build、install 命令。示例中两个 goroutine 各自执行 total++,程序可能打印 total: 2,但加上 -race 运行后会报出 WARNING: DATA RACE,并显示 Found 1 data race(s)。

channel 的并发读写是安全的,不会导致数据竞争。

防止数据竞争的办法:

  • 避免并发修改数据(通常用 channel);
  • 用互斥锁同步访问;
  • 只使用原子操作。

Race conditions

竞态条件是指多个 goroutine 的操作顺序不可预测,导致系统状态出错。

示例:withdraw 先检查余额是否足够,睡 1ms,再扣款。初始余额 50,两个 goroutine 各取 40,最终余额变成 -30。由于单个操作本身是并发安全的,竞态检测器查不出任何问题——它抓不到竞态条件。

并发环境中的不确定性无法彻底消除,事件顺序本就不可预测。但可以防止竞态条件,通常办法是用互斥锁保护复合操作:把检查加扣款整个过程放进 mu.Lock() / mu.Unlock() 之间,最终余额就是 10。

Compare-and-set

有时不用互斥锁也能防止竞态条件,办法是原子的比较并设置操作或其变体:

  • CompareAndSet(old, new):当前值等于 old 时改为 new,返回是否修改成功。
  • CompareAndSwap(old, new):当前值等于 old 时改为 new,返回旧值。
  • CompareAndDelete(old):当前值等于 old 时删除,返回是否删除成功。

思路总是一样:检查假设的旧状态是否与现实相符;相符就把状态改成新值;不相符就什么也不做。

Mutexes

sync.Mutex 保护共享数据和代码段不被并发访问。示例中 100 个 goroutine 各自加锁、睡 1ms、total++、解锁,最终 total: 100。互斥锁保证同一时刻只有一个 goroutine 能执行 Lock() 与 Unlock() 之间的代码。

这些情况需要互斥锁:

  • 多个 goroutine 修改同一份数据;
  • 一个 goroutine 修改数据,其他 goroutine 读取它。

如果所有 goroutine 都只是读数据,就不需要互斥锁。

TryLock

TryLock 和普通 Lock 一样尝试加锁,但失败时立即返回 false,不阻塞 goroutine。示例中 100 个 goroutine 同时尝试,只有第一个成功,最终 total: 1。

RWMutex

sync.RWMutex 区分读者和写者,提供两组方法:

  • Lock / Unlock:为读写加锁和解锁。
  • RLock / RUnlock:只为读加锁和解锁。

规则是:

  • goroutine 用 Lock() 加锁后,其他尝试 Lock() 或 RLock() 的 goroutine 都会被阻塞。
  • goroutine 用 RLock() 加锁后,其他 goroutine 也可以用 RLock() 加锁而不被阻塞。
  • 只要至少一个 goroutine 用 RLock() 持有锁,尝试 Lock() 的 goroutine 就会被阻塞。

这样就形成"单写多读"的局面。示例中 10 个写者加 10 个读者,耗时 10ms。

Locker

sync.Mutex 和 sync.RWMutex 都实现同一个 sync.Locker 接口,只有 Lock() 和 Unlock() 两个方法。用 Locker 而不是具体的互斥锁类型,可以构建不依赖特定锁实现的组件,让调用方自己决定用哪种锁。

用 channel 当互斥锁

也可以用 channel 代替互斥锁保护共享数据:容量为 1 的缓冲 channel,进入临界区前 lock <- struct{}{},defer 时 <-lock。

Semaphores

信号量像一个有 N 个槽位的容器,有两个操作:acquire 取一个槽,release 释放一个槽。规则是:

  • 调用 acquire 会取走一个空闲槽。
  • 如果没有空闲槽,acquire 会阻塞调用它的 goroutine。
  • 调用 release 会释放之前取走的槽。
  • 调用 release 时如果有 goroutine 阻塞在 acquire 上,其中一个会立刻拿到释放的槽并解除阻塞。

可以用缓冲 channel 实现简单信号量,N 就是 channel 的大小:acquire 就是往 channel 发送值,release 就是从 channel 取值。示例中并发数 4、调用数 100、每次调用睡 1ms,总耗时 25ms。

更复杂的情况用 golang.org/x/sync/semaphore 包。

Rendezvous

会合(rendezvous)让两个 goroutine 互相等待:

  • 有两个 goroutine G1 和 G2,各自都能发出就绪信号。
  • 如果 G1 发了信号而 G2 还没发,G1 阻塞等待。
  • 如果 G2 发了信号而 G1 还没发,G2 阻塞等待。
  • 两者都发出信号后,一起解除阻塞继续运行。

可以用等待组实现:rend.Add(2),两个 goroutine 各自打印 "before rendezvous"、调用 rend.Done()、rend.Wait(),再打印 "after rendezvous"。结果是两行 before 在前、两行 after 在后。

Barrier

屏障(barrier)是会合的推广,让 N 个 goroutine 互相等待:

  • 屏障有一个从 0 开始的计数器和一个阈值 N。
  • 每个到达屏障的 goroutine 把计数器加 1。
  • 屏障阻塞所有到达它的 goroutine。
  • 计数器达到 N 后,屏障解除所有等待 goroutine 的阻塞。

可以用等待组实现:bar.Add(4),4 个 goroutine 各自打印 "before the barrier"、bar.Done()、bar.Wait(),再打印 "after the barrier"。

Signaling

sync.Cond(条件变量)让一个 goroutine 通知另一个它准备好了,另一个则等待这个信号。Cond 内含一个互斥锁,有两个方法 Wait 和 Signal:

  • Wait 解锁互斥锁并挂起 goroutine,直到收到信号。
  • Signal 唤醒正在 Wait 的 goroutine。
  • Wait 被唤醒后会重新加锁。

示例中 done 为 false,G1 加锁、打印 "G1 is ready to signal"、把 done 设为 true、Signal、解锁;G2 加锁,for !done { cond.Wait() },收到信号后打印 "G2 received the signal" 并解锁。

如果 Signal 时有多个 goroutine 在等待,只唤醒其中一个;如果没有等待者,Signal 什么也不做。

还可以用 Broadcast:Signal 只唤醒一个等待 Cond.Wait 的 goroutine,Broadcast 唤醒所有。

用 channel 也能发信号:容量为 1 的 channel 发送 struct{}{}。

广播则用 close(broadcast),所有从这个 channel 接收的 goroutine 都会被唤醒。

条件变量的广播有局限:它只发信号、不带实际数据,而且只能生效一次。用 channel 可以构建没有这些限制的发布/订阅系统:Publisher 用切片保存订阅 channel,用互斥锁保护状态;Subscribe() 创建容量 1 的 channel、加入切片并返回;Broadcast(v) 遍历所有订阅 channel,用 select 加 default 非阻塞地发送值。

Run once

sync.Once 保证给定函数只运行一次。多个 goroutine 同时调用 Once.Do 时,只有其中一个会执行函数,其余等待它返回。示例中两个 goroutine 都调用 once.Do(initState),total 最终为 1。它非常适合并发环境下的一次性初始化或清理。

除 Once 类型外,sync 包还提供几个便利函数:

  • Once.Do(f):只调用 f 一次。
  • OnceFunc(f):返回一个只调用 f 一次的函数。
  • OnceValue[T](f):返回一个只调用 f 一次、并返回第一次调用结果的函数。
  • OnceValues[T1, T2](f):返回一个只调用 f 一次、并返回那对值的函数。

Object pool

sync.Pool 帮助复用内存而不是每次重新分配,从而减轻垃圾回收器的负担。示例中 New 返回 1024 字节切片的指针;4 个 goroutine 各循环 1000 次 Get/Put,尽管循环 4000 次,只分配了 4×1024 字节。

Get 从池中取一项,没有可用项就用 New 创建(必须自己定义,因为池不知道它创建的是什么)。Put 把项放回池中。

需要注意:

  • New 应返回指针而不是值,以减少内存拷贝、避免额外分配。
  • 池没有大小限制。如果再有 1000 个 goroutine 同时调用 Get,就会再分配 1000 个缓冲区。
  • 项用 Put 放回池后就不应再使用(可能已被别的 goroutine 取走使用了)。

Atomics

一个不带同步的操作,只有能编译成单条处理器指令时才是真正原子的。这类操作不需要锁,并发调用也不会出问题(连写操作也是)。

原子操作很少,都在 sync/atomic 包里:Int32、Int64、Uint32、Uint64、Bool、Value、Pointer。

每种原子类型提供这些方法:

  • Load 读取变量的值。
  • Store 设置新值。
  • Swap 设置新值(像 Store)并返回旧值。
  • CompareAndSwap 只在当前值仍是预期值时才设置新值。

示例中 n.Store(10),n.CompareAndSwap(10, 42) 返回 true,n.Load() 得到 42。数值类型还提供 Add 方法,把值增加指定数量。

所有方法要么编译成单条 CPU 指令,要么以其他方式保证原子性,因此可以从多个 goroutine 安全使用。

原子操作的组合永远不是原子的。示例中的 increment 先 delta.Add(1)、睡 10ms、再 counter.Add(delta.Load()),这不是原子操作,会导致竞态条件:100 次并发递增后,counter 的最终值没有保证(比如 9386)。

让复合操作可靠地原子化、避免竞态条件的稳妥办法是互斥锁:加锁、delta += 1、睡 10ms、counter += delta、解锁,这样 100 次并发递增后 counter 保证是 1+2+…+100 = 5050。

有时可以用原子类型代替互斥锁来提前退出:Gate 结构里有 closed atomic.Bool,Close() 用 CompareAndSwap(false, true) 判断是否重复调用,是就返回,否则关闭闸门、释放资源。

Testing

如果并发程序使用 channel 或带 Wait 之类同步方法的自定义类型,可以在测试里直接使用它们,测试就不会比同步代码复杂多少。示例中 Calc() 返回 <-chan int,内部 goroutine 发送 42;测试里 got := <-Calc() 等待并检查。

如果被测代码没有合适的同步"把手",可以用 synctest 包。它导出两个函数:synctest.Test(t, f) 和 synctest.Wait()。

synctest.Test 运行一个隔离的气泡(bubble),气泡使用假时钟,可以用 synctest.Wait 手动控制 goroutine 同步。

synctest.Wait 会阻塞,直到气泡内除调用者以外的所有 goroutine 要么结束、要么持久阻塞。这样可以等某个 goroutine 结束或阻塞,从而检查程序状态。示例中 NewProc 启动计算并在 p.done 上阻塞;测试在 synctest.Test 内创建 p,defer p.Stop(),调用 synctest.Wait() 等 goroutine 在阻塞点停下,再检查 p.Res() 是否为 42。

synctest.Test 的假时钟只在三个条件同时满足时前进:➊ 气泡内所有 goroutine 都持久阻塞;➋ 存在某个未来时刻至少一个 goroutine 会解除阻塞;➌ synctest.Wait 没有在运行。因此依赖时间的测试可以瞬间跑完。示例中 Calc 要么从输入 channel 取值,要么 3 秒超时;测试里没有输入,于是立刻返回 ErrTimeout,不必真等 3 秒。

以下操作会持久阻塞 goroutine:

  • 在气泡内创建的 channel 上阻塞地发送或接收。
  • 阻塞的 select 语句,且每个 case 都是气泡内创建的 channel。
  • 调用 Cond.Wait。
  • 调用 WaitGroup.Wait,前提是所有 WaitGroup.Add 调用都在气泡内完成。
  • 调用 time.Sleep。

在互斥锁、I/O 或系统调用上阻塞不算持久阻塞,synctest 气泡处理不了。

Scheduling

硬件层面,CPU 核心负责运行并行任务。

操作系统层面,线程是基本的执行单位。线程通常远多于 CPU 核心,所以操作系统的调度器决定哪些线程运行、哪些暂停。

Go 运行时层面,goroutine 是基本的执行单位。运行时调度器运行固定数量的 OS 线程,通常是每个 CPU 核心一个。goroutine 数量可能远多于线程,所以调度器决定哪些 goroutine 在可用线程上运行、哪些暂停。调度器不断在 goroutine 之间切换,保证每一个都有机会在某条线程上运行,而不是永远排队。

层级关系就是:CPU 核心 ← 运行于 —— OS 线程 ← 运行于 —— Go goroutine。Go 的并发就是这样处理的。

goroutine 调度器

调度器的任务是在 N 条操作系统线程上运行 M 个 goroutine,M 可能远大于 N。算法的简化版本如下:

  • 如果有空闲线程,从队列里分配一个 goroutine 给它。
  • 如果运行中的 goroutine 被阻塞(比如从 channel 读取),把它放回队列,给线程分配另一个 goroutine。
  • 如果运行中的 goroutine 卡在系统调用里,就启动新线程运行其他 goroutine,直到被阻塞的 goroutine 完成系统调用。
  • 每 10 ms 检查一次运行中的 goroutine,抢占运行过久的,放回队列,防止饥饿。

运行 Go 代码的线程数量由 GOMAXPROCS 环境变量或 runtime.GOMAXPROCS 函数控制。

goroutine 是一个结构体,初始约占 2 KB 内存,主要用于栈;栈可以按需增长。正因为 goroutine 如此轻量,可以在小机器上运行数万甚至数十万个。

Diagnostics

要在生产环境排查并发程序,我们会用指标、性能分析和追踪。

指标展示 Go 运行时的表现,比如堆内存用量或垃圾回收暂停时长。每个指标有唯一的名字和值,值可以是数字或直方图。可以用 runtime/metrics 包拿到完整指标列表或读取特定指标的值。示例读取 /sched/gomaxprocs:threads 和 /sched/goroutines:goroutines,得到 8 和 1。实践中人们很少手动这么做,而是通过 Prometheus 或 OpenTelemetry 库自动导出全部指标。

性能分析帮助弄清程序到底在做什么、用了哪些资源、发生在代码的哪一处。Go 使用适合生产环境的采样分析器。最常用的 profile 是 CPU(各函数占用的处理器时间)和堆(各函数占用的堆内存)。goroutine、block 和 mutex profile 有助于发现并发相关的问题。

给应用加分析器最简单的方式是用 net/http/pprof 包。要采集指定名字的 profile,调用 /debug/pprof/{name} 端点;查看采集结果用 go tool pprof 工具:

go tool pprof -proto \ "http://localhost:6060/debug/pprof/profile?seconds=N" > cpu.pprof go tool pprof -http=localhost:8080 cpu.pprof

也可以手动分析:CPU profile 用 os.Create 建文件、pprof.StartCPUProfile(file)、defer pprof.StopCPUProfile();其他 profile 用 pprof.Lookup(name).WriteTo(file, 0)。

追踪记录程序运行期间的某些事件,主要与并发和内存相关。net/http/pprof 的分析服务器运行时,调用 /debug/pprof/trace 端点采集追踪,用 go tool trace 查看结果。也可以手动采集:trace.Start(file)、defer trace.Stop()。

还可以设置带滑动窗口的自动追踪,用大小或时长限制,这叫"飞行记录"(flight recording),让你始终保留最近一段追踪,以备出错时取用:配置 trace.FlightRecorderConfig{MinAge: 5s, MaxBytes: 3MB},再 trace.NewFlightRecorder(cfg)、rec.Start()、defer rec.Stop()。

Final thoughts

这里覆盖了编写并发程序会用到的多种 Go 工具:

  • goroutine,用于运行并发任务。
  • channel 和 select,灵活的通信工具。
  • timer 和 ticker,处理时间。
  • context,用于取消操作。
  • 等待组,用于同步 goroutine。
  • 互斥锁,用于防止竞态条件。
  • 条件变量,用于发出事件信号。
  • Once,用于安全的一次性初始化。
  • 池,用于减轻垃圾回收负担。
  • 原子操作。

评论

讨论整体在赞美与困惑之间摇摆,分歧主轴是:Go 的 goroutine 与 channel 到底意味着并发编程被真正简化了,还是只是把复杂度藏得更深、需要更长的学习曲线。

认为 Go 的并发模型独一无二、近乎魔法

  • goroutine 让任务可以出现在任意线程上,这种形态在其他语言里没见过,长期使用者自认已经上瘾且不打算戒断(少数人) > 相比之下 Go 的并发和线程就像魔法,多年来我没见过第二个语言能让事情这样在任意线程上发生(@SamInTheShell)

认为并发代码远比语法看起来难写

  • select 和错误处理的正确用法要靠反复实践,不是读完文档就会;有人写了十多年 Go 仍觉得没真正「掌握」channel,每次用都要回去翻手册,模式也不直观(较多人) > 写了十多年 Go,我始终觉得自己没真正搞懂 channel,每次用都得查手册,而且没有哪个模式是显而易见的(@voidfunc)

把这种不适应归因于旧语言的思维惯性

  • 有人半开玩笑地认为,多年 Java 里管理 Thread 和 Runnable 的经验反而拖累了自己理解 Go 的并发(少数人) > 大概是太多年 Java 线程和 Runnable 的经历把我的脑子泡坏了(@voidfunc)

有价值的补充

  • 与其只学「应该怎么写」,不如系统地读并发反模式;有人推荐 Uber 那篇关于 Go 数据竞争模式的文章,认为它是理解「不该怎么写」的最佳材料。