Java/Go双修 - Go-Context与Java-ThreadLocal的区别

422 阅读5分钟

Context是什么?

context其实是一个接口,提供了四种方法,在官方go语言中对context接口提供了四种基本类型的实现

再简单一点,对于Javaer来说,Go的Context就是Java的ThreadLocal

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}
  • Deadline:设置context.Context被取消的时间,即截止时间
  • Done:返回一个 只读的Channel,当Context被取消或者到达截止时间,这个Channel就会被关闭,表示Context的链路结束
  • Err:返回context.Context结束的原因,会在Done返回的Channel被关闭时才会返回非空的值
    • 如果是context.Context被取消,返回Canceled
    • 如果是context.Context超时,返回DeadlineExceeded
  • Value:从context.Context中获取键对应的值,类似于map的get方法,对于同一个context,多次调用Value并传入相同的Key,会返回相同的结果,如果没有对应的key,则返回nil,键值对是通过WithValue方法写入的

如何创建Context及Context的派生函数With系列

根context创建,源码上看没有太多的区别,在一般情况下,当前函数没有上下文作为入惨,我们都会用Background()创建

func Background() Context {
	return background
}
func TODO() Context {
	return todo
}
func main() {
	ctx := context.TODO()
	ctx1 := context.Background()
}

context派生函数,为了让context在我们的程序中发挥作用,我们要依靠context包提供的With系列函数来进行派生

主要是以下几个派生函数

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithValue(parent Context, key, val any) Context

Context使用场景和用途

在明确context接口提供了哪些方法后,看似联想出这些实现是为了解决什么问题

主要是两点作用:上下文信息传递和写成的取消控制

比如Context可以用来在goroutine之间传递上下文信息,比如传递请求的trace_id,以便追踪全局唯一请求

还可以用来做取消控制,通过取消信号和超时时间来控制子goroutine的退出,防止goroutine泄漏

派生函数使用举例:

  • WithValue(),一般项目中用这个方法用于上下文信息的传递,比如请求唯一id,trace_id等
func func1(ctx context.Context) {
    fmt.Printf("name is: %s", ctx.Value("name").(string))
}

func main() {
    ctx := context.WithValue(context.Background(), "name", "zhangsan")
    go func1(ctx)
    time.Sleep(time.Second)
}

输出:

name is: zhangsan
  • WithCancel() 取消控制函数,需要一个父context作为参数,从context.Context中衍生出一个新的子context和取消函数CancelFunc()

    通过将这子context传递到新的goroutine中来控制这些goroutine的关闭,一旦我们执行返回的取消函数CancelFunc

    当前的上下文以及它的子上下文都会被取消,所有的Goroutine都会同步的收到取消信号

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    go Watch(ctx, "goroutine1")
    go Watch(ctx, "goroutine2")

    time.Sleep(6 * time.Second) // 让goroutine1和goroutine2执行6s
    fmt.Println("end watching!!!")
    cancel() // 通知goroutine1和goroutine2关闭
    time.Sleep(1 * time.Second)
}

func Watch(ctx context.Context, name string) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("%s exit!\n", name) // 主goroutine调用cancel后,会发送一个信号到ctx.Don
            return
        default:
            fmt.Printf("%s watching...\n", name)
            time.Sleep(time.Second)
        }
    }
}

输出:

goroutine2 watching...
goroutine1 watching...
goroutine1 watching...
goroutine2 watching...
goroutine2 watching...
goroutine1 watching...
goroutine1 watching...
goroutine2 watching...
goroutine2 watching...
goroutine1 watching...
goroutine1 watching...
goroutine2 watching...
end watching!!!
goroutine1 exit!
goroutine2 exit!
  • WithDeadline() 和 WithTimeout() 一起看,其实作用是差不多的,在传参数上有所区别

    WithDeadline(parent Context, d time.Time) (Context, CancelFunc) 这个就是截止时间

    WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) 这个是时间长度

    具体的区别细节,大家看下面这段代码即可

func main() {
    ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(4*time.Second))
    defer cancel()
    go Watch(ctx, "goroutine1")
    go Watch(ctx, "goroutine2")

    time.Sleep(6 * time.Second) // 让goroutine1和goroutine2执行6s
    fmt.Println("end watching!!!")
}

func Watch(ctx context.Context, name string) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("%s exit!\n", name) // 4s之后收到信号
            return
        default:
            fmt.Printf("%s watching...\n", name)
            time.Sleep(time.Second)
        }
    }
}

/** ---------------------------------------------------------------------------------------- **/

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
    defer cancel()
    go Watch(ctx, "goroutine1")
    go Watch(ctx, "goroutine2")

    time.Sleep(6 * time.Second) // 让goroutine1和goroutine2执行6s
    fmt.Println("end watching!!!")
}

func Watch(ctx context.Context, name string) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("%s exit!\n", name) // 主goroutine调用cancel后,会发送一个信号到ctx.Done
            return
        default:
            fmt.Printf("%s watching...\n", name)
            time.Sleep(time.Second)
        }
    }
}

Context思考

  • 为什么 WithCancel 可以控制所有子Goroutine停止?从源码角度分析一下
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
	if parent == nil {
		panic("cannot create context from nil parent")
	}
	c := newCancelCtx(parent)
	propagateCancel(parent, &c)
	return &c, func() { c.cancel(true, Canceled) }
}

这里我们可以看到 c := newCancelCtx(parent) 新指向了CancelCtx,我们来看这个结构体

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
}

children map[canceler]struct{} 所有子协程都会放入这个children的map里面,取消cancel的时候,递归的取消所有子协程

具体源码在GoLand上看吧,这里只取出一部分逻辑

for child := range c.children {
// 递归取消所有子节点
child.cancel(false, err)
}

那么Goroutine如何感知呢?换句话说父协程怎么操控的子协程,cancel的内部细节是什么?

Goroutine 通过 select监听ctx.Done() 来感知取消cancel的操作

func Watch(ctx context.Context, name string) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("%s exit!\n", name)
            return
        default:
            // 业务逻辑...
        }
    }
}

ctx.Done() 返回的 channel 被关闭(即 cancel 被调用),case <-ctx.Done() 会被触发,Goroutine 执行相应的退出逻辑。

Context注意点

  • 注意WithValue()返回值是一个新的context。context.WithValue 均不会修改父上下文,而是返回全新的子上下文
func WithValue(parent Context, key, val any) Context {
	if parent == nil {
		panic("cannot create context from nil parent")
	}
	if key == nil {
		panic("nil key")
	}
	if !reflectlite.TypeOf(key).Comparable() {
		panic("key is not comparable")
	}
	return &valueCtx{parent, key, val}
}

来看注意点 ,初学的时候,Java那一套没有完全转变过来,以为context放进去WithValue()就可以了,其实不是

ctx := context.TODO()
context.WithValue(ctx,"key","value")

正确的写法:

ctx1 := context.TODO()
ctx1 = context.WithValue(ctx,"key","value")

ctx里面是没有这个key的,ctx1才有这个key,从源码中我们也是能看得出的,&valueCtx{parent, key, val} 返回了新的context