Blog

Go 的 context 与并发模式

Go 的 context 告诉处理同一项任务的每个 goroutine 什么时候该停。本文先讲取消、超时和请求作用域的值,再用它们搭建不会泄漏的工作池、管道、扇出扇入、errgroup 和信号量。

启动 goroutine 很容易,难的是在合适的时候让它停下。用户关掉了浏览器标签页,数据库调用太慢,或者五个并行请求里有一个失败了。每种情况下,都有一些 goroutine 应该放弃,而且得有东西通知它们。

在 Go 里,这个东西就是 context.Context。本文先讲取消、超时和请求作用域的值,再讲建立在它们之上的五种并发模式。前面三部分已经介绍过 goroutine、通道(channel)、selectsync.WaitGroup,本文默认你已经了解。下面每个程序都在 Go 1.26 上跑过,输出直接从运行结果粘贴而来。

context 是一个叫你停下的信号

context 是你传给函数的一个值,函数靠它得知什么时候该停止工作。最简单实用的 context 来自 context.WithCancel

package main

import (
	"context"
	"fmt"
)

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	fmt.Println("before:", ctx.Err())

	cancel()
	<-ctx.Done() // closed now, so this returns at once
	fmt.Println("after:", ctx.Err())

	cancel() // calling it again does nothing
	fmt.Println("again:", ctx.Err())
}

输出:

before: <nil>
after: context canceled
again: context canceled

context.Background() 是空的根 context。它永远不会结束,也不携带任何东西。WithCancel 把它包装起来,返回两样东西:一个新的 context,以及结束这个 context 的 cancel 函数。

有两个方法能告诉你 context 是否已经结束。ctx.Done() 返回一个通道,context 结束时这个通道会关闭。context 还有效时,ctx.Err() 返回 nil;结束之后,它返回结束的原因。调用两次 cancel 是安全的,第二次什么也不做。

用十岁孩子能懂的话说

想象一位经理要开始一项大工程。她给这项工程的每个工人发一台对讲机。有些工人会找帮手,他们也给帮手发对讲机,调到同一个频道。

经理对着对讲机喊“停”,这条链上每个拿着对讲机的人都能听到。工人们就放下工具。

超时就像绑在对讲机上的闹钟。闹钟一响,它就替你喊“停”,哪怕没人按下按钮。

准确的说法

context 组成一棵树。每个 With… 函数都接收一个父 context,返回一个子 context。context 被取消时,Go 关闭它的 Done 通道,然后取消它所有的子 context,以及子 context 的子 context,一直到底。

取消只会向下传播。取消子 context 永远不会影响它的父 context,也不会影响它的兄弟 context。拿到的 cancel 函数还会解除子 context 和父 context 之间的关联,所以一定要调用它,通常写成 defer cancel()

这个比喻的局限:拿着对讲机的工人不可能听不到,goroutine 却可以。关闭 Done 本身不会停止任何东西。你的代码必须检查 ctx.Done()ctx.Err(),然后返回。从来不看的 goroutine 会一直跑下去。

超时和截止时间

超时是 context 结束最常见的原因,context.WithTimeout 一次调用就能设好。下面的函数会响应取消:它等待工作完成或 ctx.Done(),哪个先到就听哪个:

package main

import (
	"context"
	"errors"
	"fmt"
	"time"
)

// work pretends to do a job that takes d, but gives up if ctx ends first.
func work(ctx context.Context, d time.Duration) error {
	select {
	case <-time.After(d):
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
	defer cancel()
	err := work(ctx, 2*time.Second)
	fmt.Println("slow job:", err)
	fmt.Println(errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled))

	ctx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel2()
	fmt.Println("quick job:", work(ctx2, time.Millisecond))

	past := time.Now().Add(-time.Minute)
	ctx3, cancel3 := context.WithDeadline(context.Background(), past)
	defer cancel3()
	fmt.Println("deadline already gone:", work(ctx3, time.Millisecond))
}

输出:

slow job: context deadline exceeded
true false
quick job: <nil>
deadline already gone: context deadline exceeded

第一个任务需要 2 秒,却只给了 20 毫秒,所以 context 先结束。ctx.Err() 返回 context.DeadlineExceeded。这里故意把差距拉得很大。相差 100 倍,结果就不会取决于机器有多忙。

比较错误要用 errors.Is,讲错误的那一部分介绍过。context.Canceled 表示有人调用了 cancelcontext.DeadlineExceeded 表示时间用完了。做重试的代码常常区别对待这两者:超时也许值得再试一次,取消则说明调用方已经走了。

WithDeadline 接收的是一个时间点,而不是一段时长。WithTimeout(ctx, d) 完全等于 WithDeadline(ctx, time.Now().Add(d))。如果截止时间已经过去,你拿到的 context 在函数开始之前就已经结束了。

select 里用 time.After,以前是个小小的内存陷阱,因为定时器在触发之前一直占着内存。从 Go 1.23 起,没有被引用的定时器可以被垃圾回收,所以现在这么写没问题。

取消沿着树向下传播

几个 goroutine 共同完成一项任务时,这棵树最重要。在下面的程序里,root 有两个子 context。A 设了 20ms 超时,下面有两个 worker:A1A2B 是另一个子 context,有自己的 worker。注意看 A 的超时影响到了什么,又没有影响到什么:

root A(20ms 超时) B (WithCancel) A1 worker A2 worker 运行中 Canceled 运行中 DeadlineExceeded 运行中 Canceled 运行中 DeadlineExceeded 运行中 DeadlineExceeded 定时器触发 不向上,也不横向 五个 context 都有效,每个 worker 在 select 循环里等待 A 的 20ms 超时触发:A 结束,A.Err() 为 DeadlineExceeded 信号向下传递:A1 和 A2 看到 Done 关闭,随即停止 B 和 root 不受影响:它们的 Err() 仍是 nil main 调用 cancelRoot():信号到达 B,B 以 Canceled 停止

一棵在某个分支上设了超时的 context 树。A 的 20ms 定时器触发时,A 结束,信号向下传到它的 worker A1 和 A2,它们以 DeadlineExceeded 停止。兄弟 B 和父节点 root 继续运行。直到 main 取消 root,信号才到达 B。

如果动画没有播放,下面用文字把这几步再说一遍:

  1. rootAA1A2B 都有效。每个 worker 都待在一个 select 循环里,一小份一小份地做工作。
  2. A 的 20ms 定时器触发。A 结束,A.Err() 返回 context deadline exceeded
  3. 信号向下传递。A1A2 看到各自的 Done 通道关闭,都以同样的错误停止。
  4. Broot 不受影响。取消从 A 向下传播,不会向上传到 root,也不会横向传到 B
  5. main 调用 cancelRoot()。这时信号从 root 向下传到 BBcontext canceled 停止。

下面是动画演示的程序:

package main

import (
	"context"
	"fmt"
	"time"
)

// worker does small units of work until its context ends.
func worker(ctx context.Context, name string, report chan<- string) {
	tick := time.NewTicker(time.Millisecond)
	defer tick.Stop()
	for {
		select {
		case <-ctx.Done():
			report <- name + " stopped: " + ctx.Err().Error()
			return
		case <-tick.C:
			// one small unit of work
		}
	}
}

func main() {
	root, cancelRoot := context.WithCancel(context.Background())
	defer cancelRoot()

	a, cancelA := context.WithTimeout(root, 20*time.Millisecond)
	defer cancelA()
	a1, cancelA1 := context.WithCancel(a)
	defer cancelA1()
	a2, cancelA2 := context.WithCancel(a)
	defer cancelA2()
	b, cancelB := context.WithCancel(root)
	defer cancelB()

	repA1 := make(chan string)
	repA2 := make(chan string)
	repB := make(chan string)
	go worker(a1, "A1", repA1)
	go worker(a2, "A2", repA2)
	go worker(b, "B", repB)

	fmt.Println(<-repA1)
	fmt.Println(<-repA2)
	fmt.Println("A:", a.Err())
	fmt.Println("B:", b.Err())
	fmt.Println("root:", root.Err())

	cancelRoot()
	fmt.Println(<-repB)
}

输出:

A1 stopped: context deadline exceeded
A2 stopped: context deadline exceeded
A: context deadline exceeded
B: <nil>
root: <nil>
B stopped: context canceled

main 里事件的顺序。它先等两个 A worker 报告,再检查 Broot。两者都还是 nil,尽管旁边一整条分支已经结束了。只有 cancelRoot() 能影响到 B

worker 的写法值得照抄:for 循环包着一个 select,一个 case 做工作,一个 case 等 ctx.Done()。只要每个阻塞步骤都放在这样的 select 里,goroutine 就总能听到“停”。

ctx 放在哪里:第一个参数,绝不放进结构体字段

标准库和几乎所有 Go 代码都遵守同一个约定。可以被取消的函数,把 context.Context 作为第一个参数,命名为 ctx

func (s *Store) LoadUser(ctx context.Context, id int) (User, error)

不要把 context 存进结构体。context 属于一次调用,比如一次 HTTP 请求,而结构体通常比许多次调用活得更久。存起来的 context 最后总会是错的那个:对下一个请求来说取消得太早,或者根本不会被取消。显式传递还能让人一眼看出哪些函数可以被停下。

还有两条规则。永远不要传 nil context。如果手头还没有 context,就用 context.TODO(),它的行为和 Background() 一样,但标出了以后要补的地方。另外,一定要调用 cancel 函数。第二条 go vet 会替你检查。对于 ctx, _ := context.WithTimeout(context.Background(), time.Second),它报告:

$ go vet .
main.go:10:7: the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak

为什么停下:Cause 和 AfterFunc

ctx.Err() 只会说“canceled”或“deadline exceeded”,调试时往往信息太少。Go 1.20 加入了 WithCancelCause,Go 1.21 加入了 WithTimeoutCauseAfterFunc

package main

import (
	"context"
	"errors"
	"fmt"
	"time"
)

func main() {
	ctx, cancel := context.WithCancelCause(context.Background())
	cleaned := make(chan struct{})
	context.AfterFunc(ctx, func() {
		close(cleaned) // runs in its own goroutine once ctx ends
	})

	cancel(errors.New("server shutting down"))
	<-cleaned
	fmt.Println("cleanup ran")
	fmt.Println("Err:  ", ctx.Err())
	fmt.Println("Cause:", context.Cause(ctx))

	slow := errors.New("payment provider took too long")
	ctx2, cancel2 := context.WithTimeoutCause(context.Background(), 20*time.Millisecond, slow)
	defer cancel2()
	<-ctx2.Done()
	fmt.Println("Err:  ", ctx2.Err())
	fmt.Println("Cause:", context.Cause(ctx2))
}

输出:

cleanup ran
Err:   context canceled
Cause: server shutting down
Err:   context deadline exceeded
Cause: payment provider took too long

WithCancelCause 返回的 cancel 接收一个错误。ctx.Err() 仍然返回 context.Canceled,所以已有的检查照样能用,而 context.Cause(ctx) 返回你传入的错误。WithTimeoutCause 对超时做同样的事。写进日志时,“payment provider took too long”比“context deadline exceeded”有用得多。

context.AfterFunc 注册一个函数,在 context 结束后运行。它在自己的 goroutine 里运行,所以 main 要先等 cleaned 通道再打印。在回调里直接打印,会和 main 自己的输出产生竞争。AfterFunc 返回一个 stop 函数,回调还没运行时,调用它可以取消注册。

请求作用域的值

context 还能携带值。像请求 ID 这样的数据,靠 context.WithValue 沿着调用链传递,不必加进每个函数签名:

package main

import (
	"context"
	"fmt"
)

// An unexported key type: no other package can make a key equal to this one.
type requestIDKey struct{}

func withRequestID(ctx context.Context, id string) context.Context {
	return context.WithValue(ctx, requestIDKey{}, id)
}

func requestID(ctx context.Context) string {
	id, ok := ctx.Value(requestIDKey{}).(string)
	if !ok {
		return "no-request-id"
	}
	return id
}

func loadUser(ctx context.Context, userID int) {
	fmt.Printf("[%s] loading user %d\n", requestID(ctx), userID)
}

func main() {
	ctx := withRequestID(context.Background(), "req-7f3a")
	loadUser(ctx, 42)
	loadUser(context.Background(), 43)
}

输出:

[req-7f3a] loading user 42
[no-request-id] loading user 43

键是一个私有的空结构体类型。如果两个包都用字符串 "id" 作键,就会互相覆盖。未导出类型的键不可能和别人的键冲突。ctx.Value 返回 any,所以要用 comma-ok 写法检查类型,并处理值不存在的情况。

只用于请求作用域的数据,也就是描述这次请求、会跨越 API 边界的东西:请求 ID、trace ID、已认证的用户。不要用它传数据库句柄、日志记录器或配置开关。这些是真正的依赖,应该放在参数或结构体字段里,让编译器看得见。Value 没有类型,而且要沿着树一层一层往父节点找,所以藏在里面的依赖会在运行时出错,而不是在构建时。

工作池

工作池运行固定数量的 goroutine,它们都从同一个通道取任务。不管来多少任务,同时进行的工作量都有上限:

package main

import (
	"cmp"
	"fmt"
	"slices"
	"sync"
)

type result struct {
	job, square int
}

func worker(jobs <-chan int, results chan<- result) {
	for j := range jobs {
		results <- result{job: j, square: j * j}
	}
}

func main() {
	jobs := make(chan int)
	results := make(chan result)

	var wg sync.WaitGroup
	for range 3 {
		wg.Go(func() { worker(jobs, results) })
	}

	go func() {
		for j := range 9 {
			jobs <- j + 1
		}
		close(jobs) // workers' range loops end
	}()

	go func() {
		wg.Wait()
		close(results) // main's range loop ends
	}()

	var all []result
	for r := range results {
		all = append(all, r)
	}
	slices.SortFunc(all, func(x, y result) int { return cmp.Compare(x.job, y.job) })
	fmt.Println(len(all), "results")
	fmt.Println(all)
}

输出:

9 results
[{1 1} {2 4} {3 9} {4 16} {5 25} {6 36} {7 49} {8 64} {9 81}]

三个 worker 共用 jobs 通道。每个任务只会交给其中一个。两次关闭让整个程序收尾。关闭 jobs 会结束每个 worker 的 range 循环。另一个 goroutine 等所有 worker 结束后关闭 results,这会结束 main 里的循环。

worker 完成任务的顺序由调度器决定,所以每次运行,结果到达的顺序都不同。每个结果都带着自己的任务编号,按编号排序后,输出每次都一样。结果一到就打印,就做不到这一点。

管道

管道是一串用通道连接起来的阶段,每个阶段都是一个 goroutine,从一个通道读,往下一个通道写。这里有三个阶段:生成器、平方器和求和器。生成器自己永远不会停,所以 context 是关掉它的唯一办法:

package main

import (
	"context"
	"fmt"
)

// naturals sends 1, 2, 3, ... until ctx ends. It never stops on its own.
func naturals(ctx context.Context) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for n := 1; ; n++ {
			select {
			case out <- n:
			case <-ctx.Done():
				return
			}
		}
	}()
	return out
}

// square sends the square of every value it receives, until in closes or ctx ends.
func square(ctx context.Context, in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for n := range in {
			select {
			case out <- n * n:
			case <-ctx.Done():
				return
			}
		}
	}()
	return out
}

// sumFirst adds up the first k values from in.
func sumFirst(in <-chan int, k int) int {
	total := 0
	for range k {
		total += <-in
	}
	return total
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	squares := square(ctx, naturals(ctx))

	fmt.Println("sum of first 5 squares:", sumFirst(squares, 5))

	cancel()
	for range squares {
		// drain until square closes its channel
	}
	fmt.Println("both stages stopped")
}

输出:

sum of first 5 squares: 55
both stages stopped

sumFirst 取了五个值就返回,留下两个 goroutine,它们的输出再也没人读。没有 ctx 的话,两者都会永远阻塞在 out <- … 上。有了它,cancel() 给每个阶段的 select 多了一条出路。它们返回,defer close(out) 随之运行。

那个空的 for range squares 循环就是证明。它只有在 square 关闭通道时才结束,而 square 只在停下之后才关闭通道。如果取消不起作用,程序会卡在那里,打印不出最后一行。

扇出与扇入

扇出是指多个 goroutine 从同一个通道读取,分摊工作。扇入是指把它们各自的输出通道合并回一个:

package main

import (
	"context"
	"fmt"
	"slices"
	"sync"
)

func square(ctx context.Context, in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for n := range in {
			select {
			case out <- n * n:
			case <-ctx.Done():
				return
			}
		}
	}()
	return out
}

// merge copies every value from every input onto one channel.
func merge(ctx context.Context, inputs ...<-chan int) <-chan int {
	out := make(chan int)
	var wg sync.WaitGroup
	for _, in := range inputs {
		wg.Go(func() {
			for v := range in {
				select {
				case out <- v:
				case <-ctx.Done():
					return
				}
			}
		})
	}
	go func() {
		wg.Wait()
		close(out)
	}()
	return out
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	src := make(chan int)
	go func() {
		defer close(src)
		for n := range 10 {
			src <- n + 1
		}
	}()

	// fan out: three workers read the same channel
	w1 := square(ctx, src)
	w2 := square(ctx, src)
	w3 := square(ctx, src)

	// fan in: merge their outputs
	var got []int
	for v := range merge(ctx, w1, w2, w3) {
		got = append(got, v)
	}
	slices.Sort(got)
	fmt.Println(got)
}

输出:

[1 4 9 16 25 36 49 64 81 100]

三个 square 阶段都从 src 读,所以十个数字被分给了它们。merge 为每个输入启动一个 goroutine,把值复制到 out 上,所有输入都读完后关闭 out。这和工作池一样,是先 WaitGroupclose 的结构。

合并后的值交错到达,没有固定顺序,所以 main 先排序再打印。每个阶段也都接收 ctx。这个程序会一直跑到结束,什么都没被取消;但如果 main 提前停止读取,延迟调用的 cancel 会释放所有 goroutine。

用 WaitGroup 自己实现 errgroup

有个常见需求:同时运行几个任务,其中一个失败就全部停下,并返回第一个错误。errgroup 包能做到,但它在 golang.org/x/sync 里,不在标准库中。这个思路很简单,用 sync.WaitGroupsync.Once 和一个 context 就能搭出来:

package main

import (
	"context"
	"fmt"
	"sync"
	"time"
)

// Group runs tasks in goroutines, keeps the first error,
// and cancels the shared context when that error happens.
type Group struct {
	wg     sync.WaitGroup
	once   sync.Once
	err    error
	cancel context.CancelCauseFunc
}

func WithContext(parent context.Context) (*Group, context.Context) {
	ctx, cancel := context.WithCancelCause(parent)
	return &Group{cancel: cancel}, ctx
}

func (g *Group) Go(task func() error) {
	g.wg.Go(func() {
		if err := task(); err != nil {
			g.once.Do(func() {
				g.err = err
				g.cancel(err)
			})
		}
	})
}

func (g *Group) Wait() error {
	g.wg.Wait()
	g.cancel(g.err) // release the context even if nothing failed
	return g.err
}

func main() {
	g, ctx := WithContext(context.Background())
	status := make([]string, 3)

	for i := range 3 {
		g.Go(func() error {
			if i == 1 {
				status[i] = "failed"
				return fmt.Errorf("fetch %d: connection refused", i)
			}
			select {
			case <-time.After(2 * time.Second):
				status[i] = "finished"
				return nil
			case <-ctx.Done():
				status[i] = "stopped early"
				return ctx.Err()
			}
		})
	}

	err := g.Wait()
	fmt.Printf("%q\n", status)
	fmt.Println("first error:", err)
	fmt.Println("cause:", context.Cause(ctx))
}

输出:

["stopped early" "failed" "stopped early"]
first error: fetch 1: connection refused
cause: fetch 1: connection refused

任务 1 立刻失败。once.Do 记下它的错误,并以这个错误为原因取消共享的 context。任务 0 和 2 正在等一个 2 秒的工作,看到 ctx.Done() 关闭,就提前停下。

这两个任务也会返回一个错误:context.Canceled。它永远不会替换真正的错误。sync.Once 只运行它的函数一次,其他调用会等到第一次运行结束。被取消的任务走到 once.Do 时,第一个错误已经存好了。所以这里的“first error”每次都是导致取消的那个错误。

每个任务写的是 status 里属于自己的那个元素,所以不需要互斥锁。mainWait 返回之后才读这个切片。

用信号量限制并发数

有时任务很多,却只能同时跑几个,因为 API 有速率限制,或者数据库只有十个连接。带缓冲的通道就能当简单的信号量用,容量有多少,就有多少个槽位:

package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	const limit = 2
	sem := make(chan struct{}, limit)

	var (
		mu      sync.Mutex
		running int
		peak    int
		wg      sync.WaitGroup
	)
	results := make([]int, 6)

	for i := range 6 {
		sem <- struct{}{} // take a slot; blocks while two are busy
		wg.Go(func() {
			defer func() { <-sem }() // give the slot back

			mu.Lock()
			running++
			peak = max(peak, running)
			mu.Unlock()

			time.Sleep(5 * time.Millisecond) // pretend to call a slow service
			results[i] = i * 10

			mu.Lock()
			running--
			mu.Unlock()
		})
	}
	wg.Wait()

	fmt.Println(results)
	fmt.Println("never more than", limit, "at once:", peak <= limit)
}

输出:

[0 10 20 30 40 50]
never more than 2 at once: true

sem 发送就是占一个槽位。容量为 2 时,第三次发送会阻塞,直到某个正在运行的任务结束,从 sem 接收,腾出槽位。在 wg.Go 之前占槽位,意味着任何时候最多只有两个 goroutine,而不是六个 goroutine 里有四个在等。

程序打印的是 peak <= limit,而不是 peak。两个任务是否真的重叠,取决于调度。重叠的可能性很大,但没有保证。上限却每次运行都成立。想让等待可以取消,就在 select 里占槽位,并加上 case <-ctx.Done():

每个 goroutine 都需要一种停下的方式

永远阻塞在通道上的 goroutine 就是泄漏。它占着自己的栈和它指向的一切,而且没有任何东西会释放它。下面是泄漏的写法,旁边是修复后的写法:

package main

import (
	"context"
	"fmt"
	"time"
)

// leaky sends three values, with no way to give up.
func leaky(out chan<- int, exited chan<- struct{}) {
	defer close(exited)
	for i := range 3 {
		out <- i
	}
}

// fixed sends three values, but stops as soon as ctx ends.
func fixed(ctx context.Context, out chan<- int, exited chan<- struct{}) {
	defer close(exited)
	for i := range 3 {
		select {
		case out <- i:
		case <-ctx.Done():
			return
		}
	}
}

func report(name string, exited <-chan struct{}) {
	select {
	case <-exited:
		fmt.Println(name, "goroutine exited")
	case <-time.After(100 * time.Millisecond):
		fmt.Println(name, "goroutine still stuck on its send")
	}
}

func main() {
	out := make(chan int)
	exited := make(chan struct{})
	go leaky(out, exited)
	fmt.Println("leaky got", <-out) // take one value, then walk away
	report("leaky", exited)

	ctx, cancel := context.WithCancel(context.Background())
	out2 := make(chan int)
	exited2 := make(chan struct{})
	go fixed(ctx, out2, exited2)
	fmt.Println("fixed got", <-out2)
	cancel() // walk away, but say so
	report("fixed", exited2)
}

输出:

leaky got 0
leaky goroutine still stuck on its send
fixed got 0
fixed goroutine exited

两个生产者都想发送三个值,而 main 从每个里只取一个。leaky 没办法放弃,所以在程序剩下的时间里一直卡在 out <- 1 上。report 等了 100ms,远超过 goroutine 退出所需的时间,它还卡在那里。fixed 把发送放进带 ctx.Done()select,所以 cancel() 能让它返回。

写下 go 之前,先回答一个问题:什么会让这个 goroutine 返回?好的答案是“它的输入通道关闭了”“它的 context 被取消了”或者“它做完了一份有限的工作”。“有人读它的输出”只有在那个人不会提前停下时才算好答案。

Go 1.26 在 runtime/pprof 里加入了实验性的 goroutineleak profile,它会报告阻塞在某个其他代码都够不着的东西上的 goroutine。只有用 GOEXPERIMENT=goroutineleakprofile 构建时才有这个 profile。否则 pprof.Lookup("goroutineleak") 返回 nil,对它调用 WriteTo 会 panic,所以要先检查是不是 nil

要点

  • context 沿着一棵树向下传递停止信号,有时还带着截止时间。取消只传给子 context,永远不会向上传给父 context,也不会横向传给兄弟 context。
  • 取消本身不会让 goroutine 停下。把每个阻塞步骤放进带 case <-ctx.Done():select,并返回 ctx.Err()
  • context.Canceled 表示有人调用了 cancelcontext.DeadlineExceeded 表示时间用完了。用 context.Cause 说明原因。
  • ctx 作为第一个参数传递,绝不存进结构体,并且一定要调用 cancelgo vet 能发现被丢弃的 cancel
  • context 的值只用于请求作用域的数据,键用未导出的类型。
  • 工作池、管道、扇入和 errgroup 都以同样的方式收尾:WaitGroup 等待,然后一次 close 告诉读取方已经结束。到达顺序不固定的结果要排序。
  • 每写一条 go 语句之前,先想清楚什么会让那个 goroutine 返回。

你启动的每个 goroutine 都需要一种停下的方式,而 context 通常就是那种方式。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。