golang context 原理及使用

1. golang context 原理及使用

1.1. Context 使用原则

  • 不要把 Context 放在结构体中, 要以参数的方式传递
  • 以 Context 作为参数的函数方法, 应该把 Context 作为第一个参数, 放在第一位。
  • 给一个函数方法传递 Context 的时候, 不要传递 nil, 如果不知道传递什么, 就使用 context.TODO
  • Context 的 Value 相关方法应该传递必须的数据, 不要什么数据都使用这个传递
  • Context 是县城安全的, 可以放心的在多个 goroutine 中传递

1.2. 使用 select+chan 方式控制一个 goroutine

func main() {stop := make(chan bool)

    go func() {
        for {
            select {
            case <-stop:
                fmt.Println("监控退出, 停止了。..")
                return
            default:
                fmt.Println("goroutine 监控中。..")
                time.Sleep(2 * time.Second)
            }
        }
    }()

    time.Sleep(10 * time.Second)
    fmt.Println("可以了, 通知监控停止")
    stop<- true
    // 为了检测监控过是否停止, 如果没有监控输出, 就表示停止了
    time.Sleep(5 * time.Second)
}

1.3. 初识 Context

一个网络请求 Request, 每个 Request 都需要开启一个 goroutine 做一些事情, 这些 goroutine 又可能会开启其他的 goroutine。所以我们需要一种可以跟踪 goroutine 的方案, 才可以达到控制他们的目的, 这就是 Go 语言为我们提供的 Context, 称之为上下文非常贴切, 它就是 goroutine 的上下文。

下面我们就使用 Go Context 重写上面的示例。

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    go func(ctx context.Context) {
        for {
            select {
            case <-ctx.Done():
                fmt.Println("监控退出, 停止了。..")
                return
            default:
                fmt.Println("goroutine 监控中。..")
                time.Sleep(2 * time.Second)
            }
        }
    }(ctx)

    time.Sleep(10 * time.Second)
    fmt.Println("可以了, 通知监控停止")
    cancel()
    // 为了检测监控过是否停止, 如果没有监控输出, 就表示停止了
    time.Sleep(5 * time.Second)
}

重写比较简单, 就是把原来的 chan stop 换成 Context, 使用 Context 跟踪 goroutine, 以便进行控制, 比如结束等。

context.Background() 返回一个空的 Context, 这个空的 Context 一般用于整个 Context 树的根节点。然后我们使用 context.WithCancel(parent) 函数, 创建一个可取消的子 Context, 然后当作参数传给 goroutine 使用, 这样就可以使用这个子 Context 跟踪这个 goroutine。

在 goroutine 中, 使用 select 调用 <-ctx.Done() 判断是否要结束, 如果接受到值的话, 就可以返回结束 goroutine 了; 如果接收不到, 就会继续进行监控。

那么是如何发送结束指令的呢? 这就是示例中的 cancel 函数啦, 它是我们调用 context.WithCancel(parent) 函数生成子 Context 的时候返回的, 第二个返回值就是这个取消函数, 它是 CancelFunc 类型的。我们调用它就可以发出取消指令, 然后我们的监控 goroutine 就会收到信号, 就会返回结束。

1.4. Context 控制多个 goroutine

使用 Context 控制一个 goroutine 的例子如上, 非常简单, 下面我们看看控制多个 goroutine 的例子, 其实也比较简单。

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    go watch(ctx,"【监控 1】")
    go watch(ctx,"【监控 2】")
    go watch(ctx,"【监控 3】")

    time.Sleep(10 * time.Second)
    fmt.Println("可以了, 通知监控停止")
    cancel()
    // 为了检测监控过是否停止, 如果没有监控输出, 就表示停止了
    time.Sleep(5 * time.Second)
}

func watch(ctx context.Context, name string) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println(name,"监控退出, 停止了。..")
            return
        default:
            fmt.Println(name,"goroutine 监控中。..")
            time.Sleep(2 * time.Second)
        }
    }
}

示例中启动了 3 个监控 goroutine 进行不断的监控, 每一个都使用了 Context 进行跟踪, 当我们使用 cancel 函数通知取消时, 这 3 个 goroutine 都会被结束。这就是 Context 的控制能力, 它就像一个控制器一样, 按下开关后, 所有基于这个 Context 或者衍生的子 Context 都会收到通知, 这时就可以进行清理操作了, 最终释放 goroutine, 这就优雅的解决了 goroutine 启动后不可控的问题。

1.5. Context 接口

Context 的接口定义的比较简洁, 我们看下这个接口的方法。

type Context interface {
    Deadline() (deadline time.Time, ok bool)

    Done() <-chan struct{}

    Err() error

    Value(key interface{}) interface{}
}

这个接口共有 4 个方法, 了解这些方法的意思非常重要, 这样我们才可以更好的使用他们。

  • Deadline 方法是获取设置的截止时间的意思, 第一个返回式是截止时间, 到了这个时间点, Context 会自动发起取消请求; 第二个返回值 ok==false 时表示没有设置截止时间, 如果需要取消的话, 需要调用取消函数进行取消。

  • Done 方法返回一个只读的 chan, 类型为 struct{}, 我们在 goroutine 中, 如果该方法返回的 chan 可以读取, 则意味着 parent context 已经发起了取消请求, 我们通过 Done 方法收到这个信号后, 就应该做清理操作, 然后退出 goroutine, 释放资源。

  • Err 方法返回取消的错误原因, 因为什么 Context 被取消。

  • Value 方法获取该 Context 上绑定的值, 是一个键值对, 所以要通过一个 Key 才可以获取对应的值, 这个值一般是线程安全的。

以上四个方法中常用的就是 Done 了, 如果 Context 取消的时候, 我们就可以得到一个关闭的 chan, 关闭的 chan 是可以读取的, 所以只要可以读取的时候, 就意味着收到 Context 取消的信号了, 以下是这个方法的经典用法。

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 接口并不需要我们实现, Go 内置已经帮我们实现了 2 个, 我们代码中最开始都是以这两个内置的作为最顶层的 partent context, 衍生出更多的子 Context。

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

func Background() Context {
    return background
}

func TODO() Context {
    return todo
}

一个是 Background, 主要用于 main 函数、初始化以及测试代码中, 作为 Context 这个树结构的最顶层的 Context, 也就是根 Context。

一个是 TODO, 它目前还不知道具体的使用场景, 如果我们不知道该使用什么 Context 的时候, 可以使用这个。

他们两个本质上都是 emptyCtx 结构体类型, 是一个不可取消, 没有设置截止时间, 没有携带任何值的 Context。

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
}

这就是 emptyCtx 实现 Context 接口的方法, 可以看到, 这些方法什么都没做, 返回的都是 nil 或者零值。

1.6. Context 的继承衍生

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

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

这四个 With 函数, 接收的都有一个 partent 参数, 就是父 Context, 我们要基于这个父 Context 创建出子 Context 的意思, 这种方式可以理解为子 Context 对父 Context 的继承, 也可以理解为基于父 Context 的衍生。

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

WithCancel 函数, 传递一个父 Context 作为参数, 返回子 Context, 以及一个取消函数用来取消 Context。WithDeadline 函数, 和 WithCancel 差不多, 它会多传递一个截止时间参数, 意味着到了这个时间点, 会自动取消 Context, 当然我们也可以不等到这个时候, 可以提前通过取消函数进行取消。

WithTimeout 和 WithDeadline 基本上一样, 这个表示是超时自动取消, 是多少时间后自动取消 Context 的意思。

WithValue 函数和取消 Context 无关, 它是为了生成一个绑定了一个键值对数据的 Context, 这个绑定的数据可以通过 Context.Value 方法访问到, 后面我们会专门讲。

大家可能留意到, 前三个函数都返回一个取消函数 CancelFunc, 这是一个函数类型, 它的定义非常简单。

type CancelFunc func()

这就是取消函数的类型, 该函数可以取消一个 Context, 以及这个节点 Context 下所有的所有的 Context, 不管有多少层级。

1.7. WithValue 传递元数据

通过 Context 我们也可以传递一些必须的元数据, 这些数据会附加在 Context 上以供使用。

var key string="name"

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    // 附加值
    valueCtx:=context.WithValue(ctx,key,"【监控 1】")
    go watch(valueCtx)
    time.Sleep(10 * time.Second)
    fmt.Println("可以了, 通知监控停止")
    cancel()
    // 为了检测监控过是否停止, 如果没有监控输出, 就表示停止了
    time.Sleep(5 * time.Second)
}

func watch(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            // 取出值
            fmt.Println(ctx.Value(key),"监控退出, 停止了。..")
            return
        default:
            // 取出值
            fmt.Println(ctx.Value(key),"goroutine 监控中。..")
            time.Sleep(2 * time.Second)
        }
    }
}

在前面的例子, 我们通过传递参数的方式, 把 name 的值传递给监控函数。在这个例子里, 我们实现一样的效果, 但是通过的是 Context 的 Value 的方式。

我们可以使用 context.WithValue 方法附加一对 K-V 的键值对, 这里 Key 必须是等价性的, 也就是具有可比性; Value 值要是线程安全的

这样我们就生成了一个新的 Context, 这个新的 Context 带有这个键值对, 在使用的时候, 可以通过 Value 方法读取 ctx.Value(key)

记住, 使用 WithValue 传值, 一般是必须的值, 不要什么值都传递。

1.7.1. 关于 WithValue

WithValue returns a copy of parent in which the value associated with key is val.

Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.

The provided key must be comparable and should not be of type string or any other built-in type to avoid collisions between packages using context. Users of WithValue should define their own types for keys. To avoid allocating when assigning to an interface{}, context keys often have concrete type struct{}. Alternatively, exported context key variables' static type should be a pointer or interface.

1.7.2. WithValue Example

This example demonstrates how a value can be passed to the context and also how to retrieve it if it exists.

import (
	"context"
	"fmt"
)

func main() {
	type favContextKey string

	f := func(ctx context.Context, k favContextKey) {
		if v := ctx.Value(k); v != nil {
			fmt.Println("found value:", v)
			return
		}
		fmt.Println("key not found:", k)
	}

	k := favContextKey("language")
	ctx := context.WithValue(context.Background(), k, "Go")

	f(ctx, k)
	f(ctx, favContextKey("color"))
}

1.8. context cancel

Context cancel simply closes a channel. Your goroutines should check if the context is canceled, and return.

go func(ctx context.Context, i int) {
    for {
        select {
            case <-ctx.Done():
                return
            default:
            }
        fmt.Println("go routine#", i)
    }

}(ctx, i)

After you cancel, you should wait for the goroutines to terminate. They will not all receive the cancellation immediately.

1.8.1. 简介

context 是一个在 go 中时常用到的程序包, google 官方开发。特别常见的一个应用场景是由一个请求衍生出的各个 goroutine 之间需要满足一定的约束关系, 以实现一些诸如有效期, 中止 routine 树, 传递请求全局变量之类的功能。使用 context 实现上下文功能约定需要在你的方法的传入参数的第一个传入一个 context.Context 类型的变量。

比如:

  • 上层需要指定超时的情况: ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
  • 上层需要主动取消的情况: ctx, cancel := context.WithCancel(ctx); 需要的地方调用 cancel()

1.8.2. 问题

对于 context 包中提供的 WithTimeout(本质上调用的是 WithDeadline) 方法; 官方有这样的说明

// Canceling this context releases resources associated with it, so code should
// call cancel as soon as the operations running in this Context complete:
//
// func slowOperationWithTimeout(ctx context.Context) (Result, error) {
// ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
// defer cancel() // releases resources if slowOperation completes before timeout elapses
// return slowOperation(ctx)
// }

文中说到要尽快调用 cancel() 用来释放关联的资源, 那到底这个 cancel 做了什么, 如果不调用呢?

1.8.3. 解释

通过阅读代码可以发现, 主要做了四件事)

  1. close(c.done)
    这里 c.done, 即使没有 close, 也不会影响 GC; 猜测是为了防止 slowOperation 里面又创建的 goroutine 里面等待 c.done, 这样可能会阻塞, 防止 goroutine 泄露

  2. 所有的 child 调用 cancel
    这个就是递归了

  3. delete(p.children, child) 删除自己在上层 context 的记录
    这个有利于 GC, 如果不删除掉, 这个无用的 context 对象会一直留着, 直到上层对象被 GC 了

  4. c.timer.Stop() 关闭定时器
    如果在超时发生前, slowOperation 结束了, 这个时候提前 关闭

但是对于 WithTimeout(或者 WithDeadline) 有两种情况

  1. 一种是发生超时了, 这个时候 cancel 会自动调用, 资源被释放
  2. 另一种没有发生超时, 也就是 slowOperation 结束的时候, 这个时候需要咱们主动调用 cancel; 但是即使没有调用, 在过期时间到了的时候还是会调用 cancel, 释放资源

所以: cancel 即使不主动调用, 也不影响资源的最终释放, 但是提前主动调用, 可以尽快的释放, 避免等待过期时间之间的浪费;
建议还是按照官方的说明使用, 养成良好的习惯, 在调用 WithTimeout 之后 defer cancel()

1.9. 背景

golang 中并发编程的三种实现方式: chan 管道、waitGroup 和 Context。本篇将重点介绍 context 的使用, 告诉大家基本的使用方式, 做到会用。

1.9.1. Context 概念介绍

context 译为上下文, golang 在 1.6.2 的时候还没有自己的 context, 在 1.7 的版本中就把 golang.org/x/net/context 包被加入到了官方的库中。golang 的 Context 包, 是专门用来处理多个 goroutine 之间与请求域的数据、取消信号、截止时间等相关操作。使用场景: 当请求来临时, 你需要使用多个子协程去处理数据, 但是此时业务报错, 你需要去取消子协程并对请求做返回, 直接返回不管子协程就有可能造成脏数据, 而且子协程可能占用系统资源, 所以你是需要关闭子协程的。而 context 就可以提供当子协程正在运行时, 父协程可以关闭子协程这个功能。context 的呈现的形式像二叉树结构, 有父子关系, 父协程管理子协程。context 的使用场景就是主协程管理多个子协程, 这边的管理就是简单粗暴的关闭子协程。这种粗暴的关闭协程的方式分为了三种: context.WithCancel 方法、context.WithTimeout 方法和 context.WithDeadline 方法, 包含主动关闭和被动关闭方式。下面我就这些方法的使用给个示例。

1.9.2. 常用方法示例

1.9.2.1. context.WithCancel 方法

在使用 Context 前, 先介绍下 Context 包中的取消函数 CancelFunc, 这是一个函数类型, 它的定义非常简单如下:

type CancelFunc func()

下面是使用了 context.WithCancel 方法生成了当前协程的 context ctx 和 cancel 函数, 然后把 ctx 传递到当前协程管理的子协程中, 然后通过 cancel 函数对子协程进行管理(主要的管理方式就是强制关闭子协程), 如下程序:

func CancelTest() {
	ctx, cancel := context.WithCancel(context.Background())
	go func(ctx context.Context) {
		for {
			select {
			case <-ctx.Done():
				fmt.Println("协程退出, 停止了。..")
				return
			default:
				fmt.Println("协程运行中。..")
				time.Sleep(2 * time.Second)
			}
		}
	}(ctx)
	time.Sleep(time.Second * 30)
	fmt.Println("两分钟时间到了, 关闭子协程")
	cancel()
	time.Sleep(time.Second * 10)
	fmt.Println("演示结束")
}

上面的程序在子协程运行了 30s 后就会被主协程关闭。

1.9.2.2. context.WithTimeout 方法

context.WithTimeout 方法返回 context ctx 和 cancel 函数, 这个 WithTimeout 方法的参数和 WithCancel 相比多了时间参数, 就是可以设定一个时间, 超过该时间就会主动触发 cancel 函数, 触发原理是定时器 Timer, 当然你也可以主动使用 cancel 函数触发:

func TimeOutTest() {
	ctx, _ := context.WithTimeout(context.Background(), time.Minute*1)
	go func(ctx context.Context) {
		for {
			select {
			case <-ctx.Done():
				fmt.Println("协程退出, 停止了。..")
				return
			default:
				fmt.Println("协程运行中。..")
				time.Sleep(2 * time.Second)
			}
		}
	}(ctx)
	time.Sleep(time.Second * 70)
	fmt.Println("演示结束")
}

上面的函数, 子协程会在运行 1 分钟后主动退出, 然后接着 10 秒后主协程也会运行完退出。你也可以使用 cancel 函数在 1 分钟时间未到时主动调用使得子协程退出。

1.9.2.3. context.WithDeadline 方法

context.WithDeadline 和 context.WithTimeout 这两个方法的功能是相似的, 区别是 WithDeadline 可以在指定任意时刻调用 cancel 函数, 而 WithTimeout 只能基于调用时的时间然后加上一段时间调用 cancel 函数。

func DeadLineTest() {
	ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(time.Minute*1))
	defer cancel()
	go func(ctx context.Context) {
		for {
			select {
			case <-ctx.Done():
				fmt.Println("协程退出, 停止了。..")
				return
			default:
				fmt.Println("协程运行中。..")
				time.Sleep(2 * time.Second)
			}
		}
	}(ctx)
	time.Sleep(time.Second * 70)
	fmt.Println("演示结束")
}

上面函数中 context.WithDeadline 需要传入的是个时间点, 我写的时间点时 time.Now().Add(time.Minute * 1), 当前时间后的一分钟触发 cancel 函数(你可以选择任意时间点), 所以子协程会在运行一分钟后结束运行, 当然未到一分钟时, 你可以主动调用 withDeadline 方法所返回的 cancel 函数对子协程提前管理。

1.9.2.4. context.WithValue 方法

WithValue 函数的返回只有上下文 context, 传入值多了一个 key-value 键值对, 这个键值对可以在子协程中使用 Context.Value 方法访问到, 如下:

func WithValueTest() {
	ctx, cancel := context.WithCancel(context.Background()) // 附加值
	valueCtx := context.WithValue(ctx, "test", "子协程 1")     // should not use built-in type string as key for value; define your own type to avoid collisions (SA1029)
	go func(ctx context.Context) {
		for {
			select {
			case <-ctx.Done(): //取出值
				fmt.Println(ctx.Value("test"), "监控退出, 停止了。..")
				return
			default: //取出值
				fmt.Println(ctx.Value("test"), "goroutine 监控中。..")
				time.Sleep(2 * time.Second)
			}
		}
	}(valueCtx)
	time.Sleep(10 * time.Second)
	fmt.Println("可以了, 通知监控停止")
	cancel() //为了检测监控过是否停止, 如果没有监控输出, 就表示停止了
	time.Sleep(5 * time.Second)
}

将配置好的 key-value 的 context 传入子协程, 然后子协程可以使用 context.value() 方法访问到值, 这在各个父子关资的协程中传值比较方便。

1.9.2.5. context 的使用规则

简单介绍下 context 的使用规则, 以免误用:

  • context 应该作为函数参数传递, 而不是 struct 的一个 field
  • context 应该是函数的第一个参数
  • 不要传递 nil context, 即使是不使用, 如果不知道用啥, 用 context.TODO 吧
  • 只使用 context 传递请求上下文, 而不是为了传递可选参数
  • context 可能会被同一个函数在不同的 goroutine 中使用, 他是并发安全的
1.9.2.6. context 的缺陷

context 虽然实现父协程对子协程的管理, 但是这种管理方式是比较粗暴的, 直接关闭, 而且关闭时根本不知道子协程的执行结果。总之, 对子协程的管理不够细致化, 必要时需要在字协程退出时用 defer 做下退出处理, 或者你可以使用 waitGroup 这种, 对协程的执行结果有个明确的了解。

1.9.2.7. 总结

本篇主要讲解了 golang context 在协程管理方面的使用, 只是基础, 大家只要记住当遇到父协程管理子协程这个场景时可以想到 context 即可。对于里面的 4 个基本方法: context.WithCancel 方法、context.WithTimeout 方法、context.WithDeadline 方法和 context.WithValue 方法的功能有个了解就行。

  • 4
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

云满笔记

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值