Go语言 context 包


Go语言 context 上下文包 用来简化在多个 goroutine 传递上下文数据 (手动/超时)中止 routine 树等操作
Go语言 context 包原程序 $GOROOT\\Go\\src\\context
http 包使用 context 传递请求的上下文数据
gRpc 使用 context 来终止某个请求产生的 routine 树
Go服务器程序 每个请求由一个 goroutine 去处理 而处理程序需要创建额外的goroutine访问后端资源 比如数据库 RPC服务等
这些goroutine处理同一个请求 需要访问共享资源 如用户信息 认证token 请求截止时间等
如果请求超时或者被取消后 所有这些goroutine都应退出且释放资源

Goroutine概念

官方文档 goroutine 是一个轻量级的执行线程
多个 goroutine 比一个线程轻量所以管理它们消耗的资源更少
package main
import "fmt"
func printHello() {
        fmt.Println("Hello from printHello")
}
func main() {        
    go func(){fmt.Println("Hello inline")}()//inline goroutine. Define a function inline and then call it.
    go printHello()//call a function as goroutine
    fmt.Println("Hello from main")
}
运行程序  只看到 main 中打印Hello from main
main启动了两个 goroutine 在goroutine完成前退出了
为了让 main 等待这些 goroutine 执行完  需要让这些 goroutine 告诉 main 它们执行完了 需要用到通道

通道 channel概念

是 goroutine 间沟通渠道
要将结果或错误 或任何其他类型的信息从一个 goroutine 传递到另一个 goroutine 时使用通道
通道是有类型 可以是 int 类型的通道接收整数或错误类型的接收错误等
设个 int 类型的通道 ch  发一些信息到这个通道 语法是 ch <- 1
从这个通道接收信息 语法就是 var := <-ch 是从这个通道接收并存储值到 var 变量
通道的使用确保了 goroutine 执行完成并将值返回给 main  
package main
import "fmt"
func printHello(ch chan int) {//prints to stdout and puts an int on channel
    fmt.Println("Hello from printHello")    
    ch <- 2 //send a value on channel
}
func main() {
    //make a channel. You need to use the make function to create channels.
    //channels can also be buffered where you can specify size. eg: ch := make(chan int, 2) //that is out of the scope of this post.
    ch := make(chan int)    
    go func() {//inline goroutine. Define a function and then call it. //write on a channel when done
        fmt.Println("Hello inline")        
        ch <- 1 //send a value on channel
    }()    
    go printHello(ch) //call a function as goroutine
    fmt.Println("Hello from main")   
    i := <-ch //get first value from channel and assign to a variable to use this value later //here that is to print it
    fmt.Println("Recieved ", i)    
    <-ch //get the second value from channel //do not assign it to a variable because we dont want to use that
}
Go 语言 context 包允许 传递一个 context 到程序
Context 如超时或截止日期 deadline 或 通道 来指示停止运行和返回
例如 正在执行一个 web 请求或运行一个系统命令 定义一个超时对生产级系统通常是个好主意 如果依赖的API运行缓慢  不希望在系统上备份 backup 请求
因为它可能最终会增加负载并降低所有请求的执行效率 导致级联效应 这是超时或截止日期 context 派上用场的地方

创建 context

context 包 以下方式创建和获得 context
context.Background() Context
返回一个空 context 只能用于高等级(在 main 或顶级请求处理中) 这能用于派生
ctx := context.Background()
context.TODO() Context
也创建一个空 context 也只能用于高等级或不确定使用什么 context 或函数以后会更新以便接收一个 context
这意味 维护者 计划将来要添加 context 到函数
ctx := context.TODO()
有趣的是 $GOROOT/Go/src/context/context.go 它与 background 完全相同 不同的是 静态分析工具可以使用它来验证 context 是否正确传递 是个重要的细节 因为静态分析工具 帮助在早期发现潜在的错误 且可以连接到 CI/CD 管道
var (
    background = new(emptyCtx)
    todo = new(emptyCtx)
)
context.WithValue(parent Context, key, val interface{}) (ctx Context, cancel CancelFunc)
此函数接收 context 并返回派生 context 其中值 val 与 key 关联 并通过 context 树与 context 一起传递
这意味着一旦获得带有值的 context 从中派生的任何 context 都会获得此值
不建议使用 context 值传递关键参数 而是函数应接收签名中的那些值 使其显式化
ctx := context.WithValue(context.Background(), key, "test")
context.WithCancel(parent Context) (ctx Context, cancel CancelFunc)
这是它开始变得有趣的地方 此函数创建从传入的父 context 派生的新 context
父 context 可以是后台 context 或传递给函数的 context

返回派生 context 和取消函数
只有创建它的函数才能调用取消函数来取消此 context
如果您愿意 可以传递取消函数 但是 强烈建议不要这样做
这可能导致取消函数的调用者没有意识到取消 context 的下游影响
可能存在源自此的其他 context 这可能导致程序以意外的方式运行 简而言之 永远不要传递取消函数
ctx, cancel := context.WithCancel(context.Background())
context.WithDeadline(parent Context, d time.Time) (ctx Context, cancel CancelFunc)
此函数返回其父项的派生 context 当截止日期超过或取消函数被调用时 该 context 将被取消
例如 可以创建一个将在以后的某个时间自动取消的 context 并在子函数中传递它
当因为截止日期耗尽而取消该 context 时 获此 context 的所有函数都会收到通知去停止运行并返回
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(2 * time.Second))

context.WithTimeout(parent Context, timeout time.Duration) (ctx Context, cancel CancelFunc)
此函数类似于 context.WithDeadline 不同之处在于它将持续时间作为参数输入而不是时间对象
此函数返回派生 context 如果调用取消函数或超出超时持续时间 则会取消该派生 context
ctx, cancel := context.WithTimeout(context.Background(), 2 * time.Second)

函数接收和使用 Context
现在知道如何创建 context(Background 和 TODO)以及如何派生 context(WithValue WithCancel Deadline 和 Timeout)
如何使用 接受 context 的函数启动一个 goroutine 并等待 该 goroutine 返回或该 context 取消
select 语句帮助 选择先发生的任何情况并返回
<-ctx.Done() 一旦 Done 通道被关闭 这个 <-ctx.Done(): 被选择
一旦发生这种情况 此函数应该放弃运行并准备返回
这意味着应该关闭所有打开的管道 释放资源并从函数返回
有些情况下 释放资源可以阻止返回 比如做一些挂起的清理等
在处理 context 返回时 应该注意任何这样的可能性

完整的Context程序

说明了超时和取消功能
使用 context 可以设置截止日期 超时或调用取消函数来通知所有使用任何派生 context 的函数来停止运行并返回
如何工作的示例
main 函数
用 cancel 创建一个 context
随机超时后调用取消函数

doWorkContext 函数
派生一个超时 context
这个 context 将被取消当 main调用取消函数 或 超时到 或  doWorkContext 调用它的取消函数
启动 goroutine 传入派生上下文执行一些慢处理
等待 goroutine 完成或上下文被 main goroutine 取消 以优先发生者为准

sleepRandomContext 函数
开启一个 goroutine 去做些缓慢的处理
等待该 goroutine 完成或 等待 context 被 main goroutine 取消 操时或它自己的取消函数被调用

sleepRandom 函数
随机时间休眠
此示例使用休眠来模拟随机处理时间 在实际示例中 可以使用通道来通知此函数 以开始清理并在通道上等待它 以确认清理已完成
package main
import (
    "context"
    "fmt"
    "math/rand"
    "time"
)
func sleepRandom(fromFunction string, ch chan int) {//Slow function   
    defer func() { fmt.Println(fromFunction, "sleepRandom complete") }() //defer cleanup
    //Perform a slow task //For illustration purpose, //Sleep here for random ms
    seed := time.Now().UnixNano()
    r := rand.New(rand.NewSource(seed))
    randomNumber := r.Intn(100)
    sleeptime := randomNumber + 100
    fmt.Println(fromFunction, "Starting sleep for", sleeptime, "ms")
    time.Sleep(time.Duration(sleeptime) * time.Millisecond)
    fmt.Println(fromFunction, "Waking up, slept for ", sleeptime, "ms")    
    if ch != nil {//write on the channel if it was passed in
        ch <- sleeptime
    }
}
//Function that does slow processing with a context//Note that context is the first argument
func sleepRandomContext(ctx context.Context, ch chan bool) {
    //Cleanup tasks  //There are no contexts being created here //Hence, no canceling needed
    defer func() {
        fmt.Println("sleepRandomContext complete")
        ch <- true
    }()    
    sleeptimeChan := make(chan int)//Make a channel    
    go sleepRandom("sleepRandomContext", sleeptimeChan) //Start slow processing in a goroutine //Send a channel for communication
    select {//Use a select statement to exit out if context expires
    case <-ctx.Done():
        //If context is cancelled, this case is selected //This can happen if the timeout doWorkContext expires or
        //doWorkContext calls cancelFunction or main calls cancelFunction
        //Free up resources that may no longer be needed because of aborting the work
        //Signal all the goroutines that should stop work (use channels) //Usually, you would send something on channel,
        //wait for goroutines to exit and then return  //Or, use wait groups instead of channels for synchronization
        fmt.Println("sleepRandomContext: Time to return")
    case sleeptime := <-sleeptimeChan:        
        fmt.Println("Slept for ", sleeptime, "ms") //This case is selected when processing finishes before the context is cancelled
    }
}
//A helper function, this can, in the real world do various things.
//In this example, it is just calling one function //Here, this could have just lived in main
func doWorkContext(ctx context.Context) {
    //Derive a timeout context from context with cancel
    //Timeout in 150 ms
    //All the contexts derived from this will returns in 150 ms
    ctxWithTimeout, cancelFunction := context.WithTimeout(ctx, time.Duration(150)*time.Millisecond)    
    defer func() {//Cancel to release resources once the function is complete
        fmt.Println("doWorkContext complete")
        cancelFunction()
    }()
    //Make channel and call context function //Can use wait groups as well for this particular case
    //As we do not use the return value sent on channel
    ch := make(chan bool)
    go sleepRandomContext(ctxWithTimeout, ch)
    //Use a select statement to exit out if context expires
    select {
    case <-ctx.Done():
        //This case is selected when the passed in context notifies to stop work
        //In this example, it will be notified when main calls cancelFunction
        fmt.Println("doWorkContext: Time to return")
    case <-ch:        
        fmt.Println("sleepRandomContext returned")//This case is selected when processing finishes before the context is cancelled
    }
}
func main() {    
    ctx := context.Background()//Make a background context  
    ctxWithCancel, cancelFunction := context.WithCancel(ctx)//Derive a context with cancel
    //defer canceling so that all the resources are freed up
    //For this and the derived contexts
    defer func() {
        fmt.Println("Main Defer: canceling context")
        cancelFunction()
    }()
    //Cancel context after a random time
    //This cancels the request after a random timeout
    //If this happens, all the contexts derived from this should return
    go func() {
        sleepRandom("Main", nil)
        cancelFunction()
        fmt.Println("Main Sleep complete. canceling context")
    }()  
    doWorkContext(ctxWithCancel)  //Do work
}

缺陷 如果函数接收 context 参数 确保检查它是如何处理取消通知的 例如 exec.CommandContext 不会关闭读取管道 直到命令执行了进程创建的所有分支
Github 问题 https://github.com/golang/go/issues/23019
这意味着如果等待 cmd.Wait() 直到外部命令的所有分支都已完成 则 context 取消不会使该函数立即返回
如果使用超时或截止日期  可能会发现这不能按预期运行 如果遇到任何此类问题 可以使用 time.After 实现超时
最佳实践
context.Background 只应用在最高等级 作为所有派生 context 的根
context.TODO 应用在不确定要使用什么的地方 或者当前函数以后会更新以便使用 context
context 取消是建议性的 这些函数可能需要一些时间来清理和退出
context.Value 应该很少使用 它不应该被用来传递可选参数 这使得 API 隐式的并且可以引起错误 取而代之的是 这些值应该作为参数传递
不要将 context 存储在结构中 在函数中显式传递它们 最好是作为第一个参数
永远不要传递不存在的 context  相反 如果您不确定使用什么 使用一个 ToDo context
Context 结构没有取消方法 因为只有派生 context 的函数才应该取消 context


Go语言 context 包和context相关