0%

Context In Go

在Golang中,控制并发有两种经典的方式,一种是WaitGroup, 另外一种就是Context

什么是WaitGroup

WaitGroup是一种控制并发的方式,通过控制多个GoRoutine同时完成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func main() {
var wg sync.WaitGroup

wg.Add(2)
go func() {
time.Sleep(time.Second * 2)
fmt.Println("routine 1 done")
wg.Done()
}()
go func() {
time.Sleep(time.Second * 2)
fmt.Println("routine 2 done")
wg.Done()
wg.Wait()
fmt.Println("all routine done")
}

使用WaitGroup的情景在于:多个goroutine协同做同一件事情,只有当所有的goroutine都完成时,这件事情才算完成。

但是实际的业务里面,可能还会碰到这么一种场景:需要我们主动的通知某一个goroutine结束

比如我们开启一个后台goroutine一直做一件事情,比如监控,现在不需要了,就需要通知这个监控goroutine结束,不然它会一直跑,产生泄露

chan通知

一个goroutine启动之后,我们是无法控制它的,大部分情况下是等待自己结束。为了在通知这个goroutine结束,经常采用的方式是chan+select。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func main() {
stop := make(chan bool)

go func() {
for {
select {
case <- stop:
fmt.Println("monitor exited...")
return
default:
fmt.Println("monitoring...")
time.Sleep(time.Second * 2)
}
}
}()

time.Sleep(time.Second * 10)
fmt.Println("It's time to stop monitoring")
stop <- true
time.Sleep(time.Second * 5)
}

在这个例子中,我们定义了一个stop的channel,在后台的goroutine中,我们使用select判断stop是否可以接收到值,如果可以接收到,就表示可以退出停止了;如果没有接收到,就会执行default里面的逻辑,继续监控。

这样依赖,我们可以在其他的goroutine中给stop发送值了,比如这里是在main goroutine中发送的控制这个监控的goroutine结束。

这种chan+select的方式,是一种比较优雅地结束goroutine的方式。不过他也有自己的局限性,如果有很多个goroutine都需要控制结束怎么办?入宫这些goroutine中又衍生了更多的goroutine怎么办呢?如果一层一层的无穷尽的goroutine呢?

这种场景下仅仅用context是不够的,所以我们引入了context

初识Context

上面的场景其实也很常见,比如一个网络请求Request,每个Request都需要开启一个goroutine做一些事情,这些goroutine又可能会开启其他的goroutine。所以我们需要一种可以跟踪goroutine的方案,才能达到控制他们的目的,这就是Golang的Context做的事情,称之为goroutine的上下文

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func main() {
ctx, cancel := context.WithCancel(context.Background())
go func(ctx context.Context) {
for {
select {
case <- ctx.Done():
fmt.Println("monitor exited...")
return
default:
fmt.Println("monitoring...")
time.Sleep(time.Second * 2)
}
}
}(ctx)

time.Sleep(time.Second * 10)
fmt.Println("It's time to stop monitor")
cancel()
time.Sleep(time.Second * 2)
}

Context控制多个goroutine

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func main() {
ctx, cancel := context.WithCancel(context.Background())
go watch(ctx, "monitor1")
go watch(ctx, "monitor2")
go watch(ctx, "monitor3")

time.Sleep(time.Second * 10)
fmt.Println("It's time to stop monitor")
cancel()
time.Sleep(time.Second * 5)
}

func watch() {
for {
select {
case <- ctx.Done():
fmt.Println(name, "monitor exited")
return
default:
fmt.Println(name, "goroutine monitoring")
time.Sleep(time.Second * 2)
}
}
}

Context接口

标准库中Context定义如下:

1
2
3
4
5
6
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <- struct{}
Err() error
Value(key interface{}) interface{}
}

可以看到Context是一个interface,在golang里面,interface是一个使用非常广泛的结构,它可以接纳任何类型。Context定义很简单,一共有4个方法

  • Deadline方法是获取设置的截止时间的意思,第一个返回值是截止时间,到了这个时间点,Context会自动发起取消请求。第二个返回值ok == false表示没有设置截止时间,如果需要取消的话,需要调用取消函数取消
  • Done方法返回一个只读的channel,类型是struct{}。在goroutine中,如果该方法返回的chan可以读取,则意味着parent context已经发起了取消请求,我们通过Done方法收到这个信号后,就应该做清理操作,然后退出goroutine,释放资源。之后Err方法会返回一个错误,告知为什么Context被取消
  • Err方法返回取消的原因
  • Value方法获取该Context上绑定的值,是一个键值对,所以要通过一个Key才可以获取对应的值,这个值一般是线程安全的。

Context的实现方法

Context虽然是一个接口,但是并不需要使用方实现。golang内置的context包,已经包我们实现了2个方法,一般在代码中都是以这两个(Background和TODO)作为最顶层的parent context,然后在衍生出子context。这些Context对象形成一棵树:当一个Context对象被取消时,继承自它所有的Context都会被取消。

下面是golang的标准库中的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// An emptyCtx is never canceled, has no values, and has no deadline. It is not
// struct{}, since vars of this type must have distinct addresses.
type emptyCtx int

func (*emptyCtx) Deadline() (deadline time.Time, ok bool) {
return
}

func (*emptyCtx) Done() <-chan struct{} {
return nil
}

func (*emptyCtx) Err() error {
return nil
}

func (*emptyCtx) Value(key interface{}) interface{} {
return nil
}

func (e *emptyCtx) String() string {
switch e {
case background:
return "context.Background"
case todo:
return "context.TODO"
}
return "unknown empty Context"
}

var (
background = new(emptyCtx)
todo = new(emptyCtx)
)

// Background returns a non-nil, empty Context. It is never canceled, has no
// values, and has no deadline. It is typically used by the main function,
// initialization, and tests, and as the top-level Context for incoming
// requests.
func Background() Context {
return background
}

// TODO returns a non-nil, empty Context. Code should use context.TODO when
// it's unclear which Context to use or it is not yet available (because the
// surrounding function has not yet been extended to accept a Context
// parameter). TODO is recognized by static analysis tools that determine
// whether Contexts are propagated correctly in a program.
func TODO() Context {
return todo
}

Context的继承

有了以上的根Context,那么如何衍生更多的子Context呢?这就要靠context包为我们提供的With系列函数了

1
2
3
4
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithValue(parent Context, key, val interface{}) Context

通过这些函数,就创建了一棵Context,树的每个节点都可以有更多的子节点,节点层级可以有任意多个

  • WithCancel函数,传递一个父Context作为参数,返回子Context,以及一个取消函数用来取消Context
  • WithDeadline函数,和WithCancel差不多,他会传递一个截至时间参数,意味着到了这个时间点,会自动取消Context。当然我们也可以不等到这个时候,可以提前通过取消函数进行取消
  • WithTimeoutWithDeadline差不多,只是表示的是多长时间后取消Context
  • WithValue函数和取消Context无关,它是为了生成一个绑定了一个键值对数据的Context,这个绑定的数据可以通过Context.Value方法访问到。这是我们实际中经常要用到的技巧,一般我们想要通过上下文传递数据时,可以通过这个方法,比如我们需要trace追踪系统调用栈的时候。

Context使用原则和技巧

  • 不要把Context放在结构体中,要以参数的方式传递,parent Context一般是Background
  • 应该要把Context作为第一个参数传递给入口请求和出口请求链路上的每一个函数,放在第一位,变量名都统一,如ctx
  • 给一个函数方法传递Context的时候,不要传递nil,否则在trace追踪的时候,就会断了连接
  • Context的Value相关方法应该传递必须的数据,不要什么数据都是用这个传递
  • Context是线程安全的,可以放心的在多个goroutine中传递
  • 可以把一个Context对象传递给任意个数的goroutine,对他执行取消操作时,所有的goroutine都会接收到取消信号

Context常用方法实例

调用Context Done方法取消

1
2
3
4
5
6
7
8
9
10
11
12
13
func Stream(ctx context.Context, out chan<- Value) error {
for {
v, err := DoSomething(ctx)
if err != nil {
return err
}
select {
case <- ctx.Done():
return ctx.Err()
case out <- v:
}
}
}

通过context.WithValue来传值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func main() {
ctx, cancel := context.WithCancel(context.Background)
valueCtx := context.WithValue(ctx, key, "add value")

go watch(valueCtx)
time.Sleep(time.Second * 10)
cancel()

time.Sleep(time.Second * 5)
}

func watch(ctx context.Context) {
for {
select {
case <- ctx.Done():
fmt.Println(ctx.Value(key), "is canceled")
return
default:
fmt.Println(ctx.Value(key), "int goroutine")
time.Sleep(time.Second * 2)
}
}
}

超时取消context.WithTimeout

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package main

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

var (
wg sync.WaitGroup
)

func work(ctx context.Context) error {
defer wg.Done()

for i := 0; i < 1000; i++ {
select {
case <- time.After(time.Second * 2):
fmt.Println("Doing some work", i)
case <- ctx.Done():
fmt.Println("Cancel the context", i)
return ctx.Err()
}
}
return nil
}

func main() {
ctx, cancel := context.WithTimeout(context.Background, time.Second * 4)
defer cancel()

fmt.Println("Hey, I'm going to do some work")

wg.Add(1)
go work(ctx)
wg.Wait()

fmt.Println("Finished, I'm going home")
}

截至时间取消context.WithDeadline

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

package main

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

func main() {
d := time.Now().Add(time.Second * 1)
ctx, cancel := context.WithDeadline(context.Backend(), d)

// Even though ctx will be expired, it is good practice to call its
// cancelation function in any case. Failure to do so may keep the
// context and its parent alive longer than necessary.
defer cancel()

select {
case <- time.After(time.Second * 2):
fmt.Println("OverSleep")
case <- ctx.Done():
fmt.Println(ctx.Err())
}
}

Reference

最后的彩蛋

其实在看这个的时候,想到这是Context的With系列函数的设计是一个特别有意思的算法题,题目描述如下:

  1. 我们有一个Context树,根Context可以是Backgroud的Context或者是TODO的Context
  2. 可以从根Context出发,每个parent Context可以有自己的child Context
  3. 我们想实现这个一个函数WithCancel(parent Context) (ctx Context, cancel CancelFunc),输入参数是parent,输出是子context和cancel函数
  4. 当调用cancel函数时,继承自它的所有Context都会被取消
  5. 补充的一个背景是,所有的context都有上面说的4个函数

那么要实现这么一个功能,我们就需要考虑其对应的数据结构和算法了。

具体的做法是什么呢?晚上回去好好想想