Go并发编程-context包
作用
context 主要用来在 goroutine 之间传递上下文信息,包括:
- 取消信号
- 超时时间
- 截止时间
- 传值
原理:
contex接口
Go 里并没有直接为我们提供一个统一的 context 对象,而是设计了一个接口类型的 Context。然后在接口上来实现了几种具体类型的context
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
接口核心API四个方法:
- Deadline() :返回过期时间,如果ok为false,说明没有设置过期时间。不常用
- Done():返回一个channel,而且它的数据类型是 struct{},一个空结构体,因此在 Go 里都是直接通过 close channel 来进行通知的,不会涉及具体数据传输。一般用于监听Context实例的信号,比如说过期,或者正常关闭。常用
- Err():返回一个错误用于表达Context发生了什么。如果上面的 Done() 的 channel 没被 close,则 error 为 nil;如果 channel 已被 close,则 error 将会返回 close 的原因,比如超时或手动取消。Canceled=>正常关闭,DeadlineExceeded=>过期超时。比较常用。
- Value():是用来存储具体数据的方法,取值。非常常用
context类型
-
emptyCtx:表示什么都没有的 context,一般用作最初始的 context,作为父 context 使用;是一个空的 context,永远不会被 cancel,没有存储值,也没有 deadline。通过Background()和 TODO()方法创建。background 通常用在 main 函数中,作为所有 context 的根节点。todo 通常用在并不知道传递什么 context 的情形。
-
cancelCtx:核心是可以主动取消的上下文,它取消的时候还会将所有由该上下文派生的的子上下文一并取消,主要是通过 mu,done,children,err 实现,WithCancel 方法创建。mu:锁,用于保护并发 ,首先根据 cancelCtx 的核心需求,可以取消派生的所有上下文,也就意味着我们需要存储这个 context 派生的所有子context,那我们推测这个锁的作用应该就是保护存储子上下文或者删除子上下文的结构体。(总结会含有真正作用)done:推测用于标识此结构体是否结束。children:是一个 canceler 的 map,可以发现就是用于存储上下文的结构题。err:错误信息,用于判断是否已经取消。children 的作用:因为 cancelContext 是父子相关联的,一个 cancelContext 取消的时候既需要干掉自己所有的子,也需要告诉自己的父。Mu 的作用:context 被多个协程互相传递使用,这就要保证它一定要是并发安全的,实现过程中各种修改操作,如取消,删除子,增加子,都需要用锁保证并发安全。
-
timerCtx:核心是在到达指定时间后自动 cancel,所以相对于 cancelCtx,它只新增了两个结构体,WithDeadline 方法创建。timer:计时器。deadline:截止时间
-
valueCtx:用来传值的 context,WithValue 方法创建。valueCtx 通过 key-value形式来存储数据,当找不到 key 时,就会到父 context 里查找,直到没有父 context。和链表有点像,只是它的方向相反:Context 指向它的父节点,链表则指向下一个节点。通过 WithValue 函数,可以创建层层的 valueCtx,存储goroutine 间可以共享的变量。因为查找方向是往上走的,所以,父节点没法获取子节点存储的值,子节点却可以获取父节点的值。
-
只能存储一个key,val:为什么不用map?
- map要求key是comparable的,而我们可能用不是comparable的key
- context包的设计理念就是将 Context 设计成不可变
-
缺点:在一个处理过程中,有若干子函数、子协程。各种不同的地方会向context 里塞入各种不同的 k-v 对,最后在某个地方使用,不清楚这些值会不会被覆 盖,很难进行排查和项目的梳理
cancelCtx 和 timerCtx 区别:
相同点:
- cancelCtx 、timerCtx(用来通知用的 context)
不同点:
- cancelCtx 是手动调用 cancel 方法来触发取消通知;
- timerCtxt 则通过 AfterFunc 超时时间来自动触发 cancel 方法。
核心APl
context包的核心APl有四个,分为两类:
- 安全传递数据:指在请求执行上下文中线程安全地传递数据,依赖于WithValue方法。因为Go本身没有thread-local机制,所以大部分类似的功能都是借助于 context来实现的。
- context.WithValue:设置键值对,并且返回一个新的context实例
例如:
-
- 链路追踪的trace id
- AB测试的标记位
- 压力测试的标记位
- 分库分表中间件中传递sharding hint
- ORM中间件传递SQL hint
- Web框架传递上下文
- 控制链路,返回一个可取消的context实例和取消函数
- context.WithCancel:没有过期时间,但是又需要在必要的时候取消
- context.WithDeadline:在固定时间点过期
- context.WithTimeout:在一段时间后过期
注意:context实例是不可变的,每一次都是新创建的
而后便是监听Done()返回的channel,不管是主动调用cancel()还是超时,都能从这个channel里面取出来数据。后面可以用Err()方法来判断究竟是哪种情况。
父亲可以控制儿子,但儿子控制不了父亲
特点
context的实例之间存在父子关系:
- 当父亲取消或者超时,所有派生的子context 都被取消或者超时
- 当找key的时候,子context先看自己有没有,没有则去祖先里面找
控制是从上至下的,查找是从下至上的
使用注意事项
- 一般只用做方法参数,而且是作为第一个参数;
- 所有公共方法,除非是util,helper之类的方法,否则都加上context参数;
- 不要用作结构体字段,除非结构体本身也是表达一个上下文的概念。
面试要点
context.Context使用场景:上下文传递和超时控制
context.Context原理:
- 父亲如何控制儿子:通过儿子主动加入到父亲的children里面,父亲只需要遍历就可以
- valueCtx和timeCtx的原理
例子
父控制子
子context试图重新设置超时时间,然而并没有成功,它依旧受到了父亲的控制
func TestContext_timeout(t *testing.T) {
bg := context.Background()
timeoutCtx, cancel1 := context.WithTimeout(bg, time.Second)
subCtx, cancel2 := context.WithTimeout(timeoutCtx, 3*time.Second)
go func() {
// 一秒钟之后就会过期,然后输出 timeout
<-subCtx.Done()
fmt.Printf("timout")
}()
time.Sleep(2 * time.Second)
cancel2()
cancel1()
}
控制超时
利用context控制超时,控制超时,相当于同时监听两个channel,一个是正常业务结束的channel,Done()返回的。
func TestTimeoutExample(t *testing.T) {
ctx := context.Background()
timeoutCtx, cancel := context.WithTimeout(ctx, time.Second*3)
defer cancel()
end := make(chan struct{}, 1)
go func() {
slowBusiness()
end <- struct{}{}
}()
ch := timeoutCtx.Done()
select {
case <-ch:
fmt.Println("timeout")
case <-end:
fmt.Println("business end")
}
}
另外一种超时控制是采用time.AfterFunc:一般这种用法我们会认为是定时任务,而不是超时控制。
这种超时控制有两个弊端:
- 如果不主动取消,那么AfterFunc是必然会执行的
- 如果主动取消,那么在业务正常结束到主动取消之间,有一个短时间的时间差
DB.conn控制超时
首先直接检查一次context.Context有没有超时。
这种提前检测一下的用法还是比较常见的。比如说RPC链路超时控制就可以先看看 context有没有超时。
如果超时则可以不发送请求,直接返回超时响应。
代码在Go\src\database\sql\sql.go文件的conn方法中
// Check if the context is expired.
select {
default:
case <-ctx.Done():
db.mu.Unlock()
return nil, ctx.Err()
}
超时控制至少两个分支:
- 超时分支
- 正常业务分支
所以普遍来说context.Context会和select-case一起使用。
// Timeout the connection request with the context.
select {
case <-ctx.Done():
// Remove the connection request and ensure no value has been sent
// on it after removing.
db.mu.Lock()
delete(db.connRequests, reqKey)
db.mu.Unlock()
atomic.AddInt64(&db.waitDuration, int64(time.Since(waitStart)))
select {
default:
case ret, ok := <-req:
if ok && ret.conn != nil {
db.putConn(ret.conn, ret.err, false)
}
}
return nil, ctx.Err()
case ret, ok := <-req:
atomic.AddInt64(&db.waitDuration, int64(time.Since(waitStart)))
if !ok {
return nil, errDBClosed
}
// Only check if the connection is expired if the strategy is cachedOrNewConns.
// If we require a new connection, just re-use the connection without looking
// at the expiry time. If it is expired, it will be checked when it is placed
// back into the connection pool.
// This prioritizes giving a valid connection to a client over the exact connection
// lifetime, which could expire exactly after this point anyway.
if strategy == cachedOrNewConn && ret.err == nil && ret.conn.expired(lifetime) {
db.mu.Lock()
db.maxLifetimeClosed++
db.mu.Unlock()
ret.conn.Close()
return nil, driver.ErrBadConn
}
if ret.conn == nil {
return nil, ret.err
}
// Reset the session if required.
if err := ret.conn.resetSession(ctx); errors.Is(err, driver.ErrBadConn) {
ret.conn.Close()
return nil, err
}
return ret.conn, ret.err
}
http.Request使用context作为字段类型
context不建议作为字段类型,因为context是线程安全的,但是http.Request是有生命周期的,所以可以
http.Request本身就是request-scope的。
http.Request里面的ctx依旧设计为不可变的,我们只能创建一个新的http.Request。
所以实际上我们没有办法修改一个已有的http.Request里面的ctx。即便我们要把 context.Context做成字段,也要遵循类似的用法。
// A Request represents an HTTP request received by a server
// or to be sent by a client.
//
// The field semantics differ slightly between client and server
// usage. In addition to the notes on the fields below, see the
// documentation for Request.Write and RoundTripper.
type Request struct {
Method string
URL *url.URL
Proto string // "HTTP/1.0"
ProtoMajor int // 1
ProtoMinor int // 0
Header Header
Body io.ReadCloser
GetBody func() (io.ReadCloser, error)
ContentLength int64
TransferEncoding []string
Close bool
Host string
Form url.Values
PostForm url.Values
MultipartForm *multipart.Form
Trailer Header
RemoteAddr string
RequestURI string
TLS *tls.ConnectionState
Cancel <-chan struct{}
Response *Response
// ctx is either the client or server context. It should only
// be modified via copying the whole Request using WithContext.
// It is unexported to prevent people from using Context wrong
// and mutating the contexts held by callers of the same request.
ctx context.Context
}
errgroup.WithContext利用context来传递信号
WithContext会返回一个context.Context实例
如果errgroup.Group的Wait返回,或者任何一个Group执行的函数返回 error,context.Context 实例都会被取消(一损俱损)
所以用户可以通过监听 context.Context来判断errgroup.Group的执行情况
这是典型的将 context.Context作为信号载体的用法,本质是依赖于channel的特性。
func TestErrgroup(t *testing.T) {
// eg := errgroup.Group{}
eg, ctx := errgroup.WithContext(context.Background())
var result int64 = 0
for i := 0; i < 10; i++ {
delta := i
eg.Go(func() error {
atomic.AddInt64(&result, int64(delta))
return nil
})
}
if err := eg.Wait(); err != nil {
t.Fatal(err)
}
// 可以根据错误判定是超时结束,还是正常执行结束
ctx.Err()
fmt.Println(result)
}
Kratos中也有利用这个特性来优雅启动服务实例,并且监听服务实例启动情况的代码片段。
注意:所有的Server调用Stop都是创建了一个新的context,这是因为关闭的时候需要摆脱启动时候的 context的控制。
eg, ctx := errgroup.WithContext(sctx)
wg := sync.WaitGroup{}
for _, srv := range a.opts.servers {
srv := srv
eg.Go(func() error {
<-ctx.Done() // wait for stop signal
stopCtx, cancel := context.WithTimeout(NewContext(a.opts.ctx, a), a.opts.stopTimeout)
defer cancel()
return srv.Stop(stopCtx)
})
wg.Add(1)
// 返回,说明是启动有问题,那么其它启动没有问题的Server也会退出,确保要么全部成功,要么全部失败
eg.Go(func() error {
wg.Done() // here is to ensure server start has begun running before register, so defer is not needed
return srv.Start(sctx)
})
}
wg.Wait()
c := make(chan os.Signal, 1)
signal.Notify(c, a.opts.sigs...)
eg.Go(func() error {
select {
case <-ctx.Done():
return nil
// 说明监听到了退出信号,比如说ctrl+C,Server都会退出
case <-c:
return a.Stop()
}
})
cancelCtx实现
cancelCtx也是典型的装饰器模式:在已有Context的基础上,加上取消的功能。
// A cancelCtx can be canceled. When canceled, it also cancels any children
// that implement canceler.
type cancelCtx struct {
Context
mu sync.Mutex // protects following fields
done atomic.Value // of chan struct{}, created lazily, closed by first cancel call
children map[canceler]struct{} // set to nil by the first cancel call
err error // set to non-nil by the first cancel call
}
func (c *cancelCtx) Done() <-chan struct{} {
d := c.done.Load()
if d != nil {
return d.(chan struct{})
}
c.mu.Lock()
defer c.mu.Unlock()
d = c.done.Load()
if d == nil {
d = make(chan struct{})
c.done.Store(d)
}
return d.(chan struct{})
}
核心实现:
- Done方法是通过类似于double-check的机制写的。这种原子操作和锁结合的用法比较罕见。
- 利用children来维护了所有的衍生节点,难点就在于它是如何维护这个衍生节点。children:核心是儿子把自己加进去父亲的children 字段里面。但是因为Context里面存在非常多的层级,所以父亲不一定是cancelCtx,因此本质上是找最近属于cancelCtx类型的祖先,然后儿子把自己加进去。cancel就是遍历children,挨个调用cancel。然后儿子调用孙子的 cancel,子子孙孙无穷匮也。
// propagateCancel arranges for child to be canceled when parent is.
func propagateCancel(parent Context, child canceler) {
done := parent.Done()
if done == nil {
return // parent is never canceled
}
select {
case <-done:
// parent is already canceled
child.cancel(false, parent.Err())
return
default:
}
if p, ok := parentCancelCtx(parent); ok {
p.mu.Lock()
// 找到最近的是cancelCtx类型的祖先,然后将child加进去祖先的children里面
if p.err != nil {
// parent has already been canceled
child.cancel(false, p.err)
} else {
if p.children == nil {
p.children = make(map[canceler]struct{})
}
p.children[child] = struct{}{}
}
p.mu.Unlock()
} else {
// 找不到就只需要监听到parent的信号,或者自己的信号。这些信号源自cancel或者超时
atomic.AddInt32(&goroutines, +1)
go func() {
select {
case <-parent.Done():
child.cancel(false, parent.Err())
case <-child.Done():
}
}()
}
}
cancel方法
// cancel closes c.done, cancels each of c's children, and, if
// removeFromParent is true, removes c from its parent's children.
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
if err == nil {
panic("context: internal error: missing cancel error")
}
c.mu.Lock()
if c.err != nil {
c.mu.Unlock()
return // already canceled
}
c.err = err
d, _ := c.done.Load().(chan struct{})
if d == nil {
c.done.Store(closedchan)
} else {
close(d)
}
for child := range c.children {
// NOTE: acquiring the child's lock while holding parent's lock.
child.cancel(false, err)
}
c.children = nil
c.mu.Unlock()
if removeFromParent {
removeChild(c.Context, c)
}
}
核心的cancel方法,做了两件事:
- 遍历所有的 children
- 关闭 done这个channel:这个符合谁创建谁关闭的原则
timerCtx实现
timerCtx也是装饰器模式:在已有cancelCtx的基础上增加了超时的功能。
// A timerCtx carries a timer and a deadline. It embeds a cancelCtx to
// implement Done and Err. It implements cancel by stopping its timer then
// delegating to cancelCtx.cancel.
type timerCtx struct {
cancelCtx
timer *time.Timer // Under cancelCtx.mu.
deadline time.Time
}
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
if parent == nil {
panic("cannot create context from nil parent")
}
if cur, ok := parent.Deadline(); ok && cur.Before(d) {
// The current deadline is already sooner than the new one.
return WithCancel(parent)
}
c := &timerCtx{
cancelCtx: newCancelCtx(parent),
deadline: d,
}
propagateCancel(parent, c)
dur := time.Until(d)
if dur <= 0 {
c.cancel(true, DeadlineExceeded) // deadline has already passed
return c, func() { c.cancel(false, Canceled) }
}
c.mu.Lock()
defer c.mu.Unlock()
if c.err == nil {
c.timer = time.AfterFunc(dur, func() {
c.cancel(true, DeadlineExceeded)
})
}
return c, func() { c.cancel(true, Canceled) }
}
实现要点:
- WithTimeout 和WithDeadline本质一样
- WithDeadline里面,在创建timerCtx的时候利用time.AfterFunc来实现超时