标准库之context(很重要)

1|0标准库之context

  • 在 Go的http包的Server端中,每一个请求在都有一个对应的 goroutine 去处理。请求处理函数通常会启动额外的 goroutine 用来访问后端服务,比如数据库和RPC服务。用来处理一个请求的 goroutine 通常需要访问一些与请求特定的数据,比如终端用户的身份认证信息、验证相关的token、请求的截止时间。 当一个请求被取消或超时时,所有用来处理该请求的 goroutine 都应该迅速退出,然后系统才能释放这些 goroutine 占用的资源。

2|0一、为什么需要context

  • 可以先挨个看看下面的几个并发示例的不足之处

2|11. 基本并发的示例

  • 本例中,我们只能老老实实的等待子 goroutine 完成后,才能继续主 goroutine 的执行。无法手动去结束子 goroutine
package main import ( "fmt" "sync" "time" ) var wg sync.WaitGroup // 初始的例子 func worker() { for { fmt.Println("worker") time.Sleep(time.Second) } // 如何接收外部命令实现退出 wg.Done() } func main() { wg.Add(1) go worker() // 如何优雅的实现结束子goroutine wg.Wait() fmt.Println("over") }

2|22. 全局变量方式的并发

  • 使用一个全局变量,当该全局变量作为参数进入多个 goroutine的任务中时,或者跨包时就变得不好控制了。
  • 同样,还存在上面基本并发中无法手动控制子 goroutine的退出
package main import ( "fmt" "sync" "time" ) var wg sync.WaitGroup var exit bool // 全局变量方式存在的问题: // 1. 使用全局变量在跨包调用时不容易统一 // 2. 如果worker中再启动goroutine,就不太好控制了。 func worker() { for { fmt.Println("worker") time.Sleep(time.Second) if exit { break } } wg.Done() } func main() { wg.Add(1) go worker() time.Sleep(time.Second * 3) // sleep3秒以免程序过快退出 exit = true // 修改全局变量实现子goroutine的退出 wg.Wait() fmt.Println("over") }

2|33. 通道方式的并发

  • 采用channel作为多个 goroutine之间的数据交互的媒介,是实现了数据安全的交互,但道理同上面的全局变量一样,当跨包时,或者子 goroutine中继续启动 goroutine时,需要维护一个共用的channel,也不太友好。
  • 同样,还存在上面基本并发中无法手动控制子 goroutine的退出
package main import ( "fmt" "sync" "time" ) var wg sync.WaitGroup // 管道方式存在的问题: // 1. 使用全局变量在跨包调用时不容易实现规范和统一,需要维护一个共用的channel func worker(exitChan chan struct{}) { LOOP: for { fmt.Println("worker") time.Sleep(time.Second) select { case <-exitChan: // 等待接收上级通知 break LOOP default: } } wg.Done() } func main() { var exitChan = make(chan struct{}) wg.Add(1) go worker(exitChan) time.Sleep(time.Second * 3) // sleep3秒以免程序过快退出 exitChan <- struct{}{} // 给子goroutine发送退出信号 close(exitChan) wg.Wait() fmt.Println("over") }

2|44. 官方版的方案

  • 下面是官方给出的解决方案的参考案例:使用context包进行上下文的管理
package main import ( "context" "fmt" "sync" "time" ) var wg sync.WaitGroup func worker(ctx context.Context) { LOOP: // 定义一个待结束的标签 for { fmt.Println("worker") time.Sleep(time.Second) select { case <-ctx.Done(): // 等待上级通知 break LOOP default: } } wg.Done() } func main() { ctx, cancel := context.WithCancel(context.Background()) wg.Add(1) go worker(ctx) time.Sleep(time.Second * 3) cancel() // 通知子goroutine结束 wg.Wait() fmt.Println("over") }
  • 当子goroutine又开启另外一个goroutine时,只需要将ctx传入即可:
package main import ( "context" "fmt" "sync" "time" ) var wg sync.WaitGroup func worker(ctx context.Context) { go worker2(ctx) LOOP: // 定义一个待结束的标签 for { fmt.Println("worker") time.Sleep(time.Second) select { case <-ctx.Done(): // 等待上级通知 break LOOP default: } } wg.Done() } func worker2(ctx context.Context) { LOOP: for { fmt.Println("worker2") time.Sleep(time.Second) select { case <-ctx.Done(): // 等待上级通知 break LOOP default: } } } func main() { ctx, cancel := context.WithCancel(context.Background()) wg.Add(1) go worker(ctx) time.Sleep(time.Second * 3) cancel() // 通知子goroutine结束 wg.Wait() fmt.Println("over") }

3|0二、Context初识

  • Go1.7版本加入了一个新的标准库context,它定义了Context类型,专门用来简化 对于处理单个请求的多个 goroutine 之间与请求域的数据、取消信号、截止时间等相关操作,这些操作可能涉及多个 API 调用。

  • 对服务器传入的请求应该创建上下文,而对服务器的传出调用应该接受上下文。它们之间的函数调用链必须传递上下文,或者可以使用WithCancelWithDeadlineWithTimeoutWithValue创建的派生上下文。当一个上下文被取消时,它派生的所有上下文也被取消。

3|11. Context接口

  • context.Context是一个接口,该接口定义了四个需要实现的方法。具体签名如下:
type Context interface { Deadline() (deadline time.Time, ok bool) Done() <-chan struct{} Err() error Value(key interface{}) interface{} }

其中:

  • Deadline 方法需要返回当前Context被取消的时间,也就是完成工作的截止时间(deadline)
  • Done 方法需要返回一个 Channel,这个Channel会在当前工作完成或者上下文被取消之后关闭,多次调用Done 方法会返回同一个 Channel
  • Err 方法会返回当前 Context 结束的原因,它只会在 Done 返回的Channel被关闭时才会返回非空的值
    • 如果当前Context被取消就会返回Canceled错误
    • 如果当前Context超时就会返回DeadlineExceeded错误
  • Value方法会从Context中返回键对应的值,对于同一个上下文来说,多次调用Value 并传入相同的Key会返回相同的结果,该方法仅用于传递跨API和进程间跟请求域的数据

3|22. 两个顶级Context

  • context包提供两种顶级的上下文类型 Background()TODO(),这两个内置的上下文对象作为最顶层的partent context,衍生出更多的子上下文对象

1|0(1)Background()和TODO()

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

  • context.TODO() 返回非零的空上下文。当不清楚要使用哪个上下文或者它还不可用时,应该使用context.TODO()

1|0(2)区别

  • 本质上都是emptyCtx结构体类型,其源码实现是一样的,是一个不可取消,没有设置截止时间,没有携带任何值的Context。只不过使用场景不同,context.Background()通常由主函数、初始化和测试使用,是顶级Context;context.TODO()通常用于主协程外的其他协程向下传递,分析工具可识别它在调用栈中传播

3|33. 派生Context(With系列函数)

  • 此外,context包中还定义了四个With系列函数。

1|0(1)WithCancel(可取消context)

  • 用于创建一个具有取消功能的上下文(context)。它的主要目的是允许你在需要的时候取消一个长时间运行的任务或多个任务,以确保程序可以优雅地退出或处理超时情况

  • WithCancel的函数定义如下:

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) - context.WithCancel 接受一个父上下文(parent),并返回一个新的上下文 ctx 和一个 cancel 函数。 - ctx 是一个新创建的上下文,它会继承父上下文的截止时间(deadline)、值(values)、取消函数等属性。 - cancel 是一个函数,当调用 cancel 函数时,它会取消 ctx 及其派生的所有子上下文,导致所有与这些上下文相关的操作都会尽早退出
  • WithCancel返回带有新Done通道的父节点的副本。当调用返回的cancel函数或当关闭父上下文的Done通道时,将关闭返回上下文的Done通道,无论先发生哪种情况

  • 取消此上下文将释放与其关联的资源,因此代码应该在此上下文中运行的操作完成后立即调用cancel。

1|0i. context控制goroutine内部的goroutine的停止

// 示例: 通过context控制goroutine内部的goroutine的停止 package main import ( "context" "fmt" "time" ) func gen(ctx context.Context) <-chan int { dst := make(chan int) n := 1 go func() { for { select { case <-ctx.Done(): return // return结束该goroutine,防止泄露 case dst <- n: n++ } } }() return dst } func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() // 当我们取完需要的整数后调用cancel for n := range gen(ctx) { fmt.Println(n) if n == 5 { break } } } /* 上面的示例代码中,`gen`函数在单独的`goroutine`中生成整数并将它们发送到返回的通道。 `gen`的调用者在使用生成的整数之后需要取消上下文,以免在`gen`内部启动的`goroutine`发生泄漏。 */

1|0ii. context控制多个goroutine的停止

package main import ( "context" "fmt" "time" ) func task(ctx context.Context, s string) { hsw: for { select { case <-ctx.Done(): fmt.Println("task:我收到取消指令,我结束了") break hsw // 结束掉 label位置的循环 default: fmt.Println("打印一次传入的值:", s) time.Sleep(1 * time.Second) } } } func main() { parent := context.Background() ctx, cancle := context.WithCancel(parent) go task(ctx, "hsw is Nb") go task(ctx, "hsw is handsome") time.Sleep(5 * time.Second) // 睡个5s钟,发现上面两句话不停打印 cancle() // 通过ctx控制,上面两个go协程关闭 time.Sleep(5 * time.Second) // 睡个5s钟,发现确实被停止了,不打印了 }

1|0(2)WithDeadline(超时取消context)

  • WithDeadline 用于创建一个到达指定时间后能被自动取消的上下文

  • WithDeadline的函数签名如下:

func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
  • WithDeadline函数返回父上下文的副本,其截止时间调整为不迟于d。如果父上下文的截止时间早于d,则WithDeadline(Parent,d)在语义上等同于父上下文。

  • 当截止时间到期、调用返回的cancel函数或关闭父上下文的done通道(以先发生者为准)时,返回的上下文的done通道将关闭(即当设定的延迟时间到了之后,或者未到延迟时间时主动调用cancel函数,或关闭父上下文的done通道,就关闭上下文,以最先发生的情况为准)。

  • 取消此上下文将释放与其关联的资源,因此代码应该在此上下文中运行的操作完成后立即调用cancel

1. 示例1 package main import ( "context" "fmt" "time" ) func main() { d := time.Now().Add(50 * time.Millisecond) ctx, cancel := context.WithDeadline(context.Background(), d) // 尽管ctx会过期,但在任何情况下调用它的cancel函数都是很好的实践。 // 如果不这样做,可能会使上下文及其父类存活的时间超过必要的时间。 defer cancel() select { case <-time.After(1 * time.Second): fmt.Println(111, "overslept") case <-ctx.Done(): fmt.Println(222, ctx.Err()) } } /* 分析:上面的代码中,定义了一个50毫秒之后过期的deadline,然后我们调用`context.WithDeadline(context.Background(), d)`得到一个上下文(ctx)和一个取消函数(cancel),然后使用一个select让主程序陷入等待:等待1秒后打印`overslept`退出或者等待ctx过期后退出。 因为ctx50秒后就过期,所以`ctx.Done()`会先接收到值,上面的代码会打印ctx.Err()取消原因。 */ 2. 示例2(官方使用示例) // 这个例子传递一个具有任意截止时间的上下文,告诉一个阻塞函数一旦到达它就应该放弃它的工作 package main import ( "context" "fmt" "time" ) func task(ctx context.Context, s string) { lqz: for { select { case <-ctx.Done(): fmt.Println("task:我收到取消指令,我结束了") fmt.Println(ctx.Err()) // 正常到时间:context deadline exceeded // 手动调用cancel :context canceled break lqz // 结束掉 label位置的循环 default: fmt.Println("打印一次传入的值:", s) time.Sleep(1 * time.Second) } } } func task2(ctx context.Context, s string) { lqz: for { select { case <-ctx.Done(): fmt.Println("task:我收到取消指令,我结束了") fmt.Println(ctx.Err()) // 正常到时间:context deadline exceeded // 手动调用cancel :context canceled break lqz // 结束掉 label位置的循环 case <-time.After(1 * time.Second): fmt.Println("1s时间到了,打印:",s) fmt.Println(ctx.Err()) // 执行到此,如果还没到结束时间,Err为nil } } } func main() { // 1 正常到时间 //parent := context.Background() //t:=time.Now().Add(5*time.Second) // 5s后的时间 //ctx, _ := context.WithDeadline(parent,t) //go task(ctx, "lqz is Nb") //time.Sleep(10 * time.Second) // 睡个10s钟,由于5s结束,后5s没有输出 // 2 手动调用cancle取消 //parent := context.Background() //t := time.Now().Add(5 * time.Second) // 5s后的时间 //ctx, cancel := context.WithDeadline(parent, t) //go task(ctx, "lqz is Nb") //time.Sleep(3 * time.Second) // 睡个3s钟,由于5s还没到,手动结束 //cancel() //time.Sleep(7 * time.Second) // 再睡7s看输出 //3 1s后输出一次内容的另一种写法 parent := context.Background() t := time.Now().Add(5 * time.Second) // 5s后的时间 ctx, cancel := context.WithDeadline(parent, t) go task2(ctx, "lqz is Nb") time.Sleep(3 * time.Second) // 睡个3s钟,由于5s还没到,手动结束 cancel() time.Sleep(7 * time.Second) // 再睡7s看输出 }

1|0(3)WithTimeout(超时取消context)

  • WithTimeout 方法用于创建一个经过一段时间后能被自动取消的上下文,他也是调用 WithDeadline 这个方法

  • WithTimeout的函数签名如下:

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
  • WithTimeout返回WithDeadline(parent, time.Now().Add(timeout))

  • 取消此上下文将释放与其相关的资源,因此代码应该在此上下文中运行的操作完成后立即调用cancel,通常用于数据库或者网络连接的超时控制。具体示例如下:

1. 示例1 package main import ( "context" "fmt" "sync" "time" ) var wg sync.WaitGroup func worker(ctx context.Context) { LOOP: for { fmt.Println("db connecting ...") time.Sleep(time.Millisecond * 10) // 假设正常连接数据库耗时10毫秒 select { case <-ctx.Done(): // 50毫秒后自动调用 break LOOP default: } } fmt.Println("worker done!") wg.Done() } func main() { // 设置一个50毫秒的超时 ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*50) wg.Add(1) go worker(ctx) time.Sleep(time.Second * 5) cancel() // 通知子goroutine结束 wg.Wait() fmt.Println("over") } 2. 示例2 // 这个例子传递一个带有超时的上下文,告诉一个阻塞函数它应该在超时结束后放弃它的工作 package main import ( "context" "fmt" "time" ) func task(ctx context.Context) { select { case <-ctx.Done(): fmt.Println("task:我结束了") // cancle函数取消会打印context canceled // 到时间取消会打印:context deadline exceeded fmt.Println(ctx.Err()) case <-time.After(1 * time.Second): fmt.Println("1s时间到了") fmt.Println(ctx.Err()) // 执行到此,如果还没到结束时间,Err为nil } } func main() { //ctx, cancle := context.WithTimeout(context.Background(), 1*time.Second) // 打印 ctx, cancle := context.WithTimeout(context.Background(), 2*time.Second) go task(ctx) time.Sleep(3*time.Second) cancle() time.Sleep(3*time.Second) }

1|0(4)WithValue(向context添加值)

  • WithValue函数能够将请求作用域的数据与 Context 对象建立关系。声明如下:
func WithValue(parent Context, key, val interface{}) Context
  • WithValue返回父节点的副本,其中与key关联的值为val。

  • 仅对API和进程间传递请求域的数据使用上下文值,而不是使用它来传递可选参数给函数。

  • 所提供的键必须是可比较的,并且不应该是string类型或任何其他内置类型,以避免使用上下文在包之间发生冲突。WithValue的用户应该为键定义自己的类型。为了避免在分配给interface{}时进行分配,上下文键通常具有具体类型struct{}。或者,导出的上下文关键变量的静态类型应该是指针或接口。

package main import ( "context" "fmt" "sync" "time" ) type TraceCode string var wg sync.WaitGroup func worker(ctx context.Context) { key := TraceCode("TRACE_CODE") traceCode, ok := ctx.Value(key).(string) // 在子goroutine中获取trace code if !ok { fmt.Println("invalid trace code") } LOOP: for { fmt.Printf("worker, trace code:%s\n", traceCode) time.Sleep(time.Millisecond * 10) // 假设正常连接数据库耗时10毫秒 select { case <-ctx.Done(): // 50毫秒后自动调用 break LOOP default: } } fmt.Println("worker done!") wg.Done() } func main() { // 设置一个50毫秒的超时 ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*50) // 在系统的入口中设置trace code传递给后续启动的goroutine实现日志数据聚合 ctx = context.WithValue(ctx, TraceCode("TRACE_CODE"), "12512312234") wg.Add(1) go worker(ctx) time.Sleep(time.Second * 5) cancel() // 通知子goroutine结束 wg.Wait() fmt.Println("over") }

4|0三、使用Context的注意事项

  • 推荐以参数的方式显示传递Context
  • 以Context作为参数的函数方法,应该把Context作为第一个参数。
  • 给一个函数方法传递Context的时候,不要传递nil,如果不知道传递什么,就使用context.TODO()
  • Context的Value相关方法应该传递请求域的必要数据,不应该用于传递可选参数
  • Context是线程安全的,可以放心的在多个goroutine中传递

5|0四、Context常见示例

5|11. 控制10s后,所有协程退出

  • 使用context包来实现线程安全退出或超时的控制:控制10s后,所有协程退出
package main import ( "context" "fmt" "strconv" "sync" "time" ) func task(ctx context.Context, s string, wg *sync.WaitGroup) { defer wg.Done() for { select { case <-ctx.Done(): fmt.Println(s, "--->我结束了") //fmt.Println(ctx.Err()) return default: fmt.Println(s) time.Sleep(1 * time.Second) } } } func main() { var wg sync.WaitGroup ctx, _ := context.WithTimeout(context.Background(), 10*time.Second) for i := 0; i < 10; i++ { wg.Add(1) s := fmt.Sprintf("我是第:%v 个任务", strconv.Itoa(i)) go task(ctx, s, &wg) } wg.Wait() } // 当并发体超时或`main`主动停止工作者Goroutine时,每个工作者都可以安全退出

5|22. 控制某个go协程执行5次就结束

// 控制goroutine 执行5次结束 func main() { // 定义一个运行次数变量 runCount := 0 //定义一个waitgroup,等待goroutine执行完成 var wg sync.WaitGroup // 初始化context parent := context.Background() // 传入初始化的ctx,返回ctx和cancle函数 ctx, cancle := context.WithCancel(parent) wg.Add(1) // 增加一个任务 go func() { for { select { case <-ctx.Done(): fmt.Println("任务结束") return default: fmt.Printf("任务执行了%d次\n", runCount) runCount++ } // 执行了5次,使用ctx的取消函数将任务取消 if runCount >= 5 { cancle() wg.Done() // goroutine执行完成 } } }() wg.Wait() //等待所有任务完成 }

5|33. 打印100个素数

  • Go语言是带内存自动回收特性的,因此内存一般不会泄漏。当main函数不再使用管道时后台Goroutine有泄漏的风险。我们可以通过context包来避免这个问题,下面是防止内存泄露的素数筛实现:
// 返回生成自然数序列的管道: 2, 3, 4, ... func GenerateNatural(ctx context.Context) chan int { ch := make(chan int) go func() { for i := 2; ; i++ { select { //父协程cancel()时安全退出该子协程 case <- ctx.Done(): return //生成的素数发送到管道 case ch <- i: } } }() return ch } // 管道过滤器: 删除能被素数整除的数 func PrimeFilter(ctx context.Context, in <-chan int, prime int) chan int { out := make(chan int) go func() { for { if i := <-in; i%prime != 0 { select { //父协程cancel()时安全退出该子协程 case <- ctx.Done(): return case out <- i: } } } }() return out } func main() { // 使用一个可由父协程控制子协程安全退出的Context。 ctx, cancel := context.WithCancel(context.Background()) ch := GenerateNatural(ctx) // 自然数序列: 2, 3, 4, ... for i := 0; i < 100; i++ { // 新出现的素数打印出来 prime := <-ch fmt.Printf("%v: %v\n", i+1, prime) // 基于新素数构造的过滤器 ch = PrimeFilter(ctx, ch, prime) } //输出100以内符合要求的素数后安全退出所有子协程 cancel() } // 当main函数完成工作前,通过调用`cancel()`来通知后台Goroutine退出,这样就避免了Goroutine的泄漏

6|0五、客户端超时取消示例

  • 调用服务端API时如何在客户端实现超时控制?

6|11. server端

// context_timeout/server/main.go package main import ( "fmt" "math/rand" "net/http" "time" ) // server端,随机出现慢响应 func indexHandler(w http.ResponseWriter, r *http.Request) { number := rand.Intn(2) if number == 0 { time.Sleep(time.Second * 10) // 耗时10秒的慢响应 fmt.Fprintf(w, "slow response") return } fmt.Fprint(w, "quick response") } func main() { http.HandleFunc("/", indexHandler) err := http.ListenAndServe(":8000", nil) if err != nil { panic(err) } }

6|22. client端

// context_timeout/client/main.go package main import ( "context" "fmt" "io/ioutil" "net/http" "sync" "time" ) // 客户端 type respData struct { resp *http.Response err error } func doCall(ctx context.Context) { transport := http.Transport{ // 请求频繁可定义全局的client对象并启用长链接 // 请求不频繁使用短链接 DisableKeepAlives: true} client := http.Client{ Transport: &transport, } respChan := make(chan *respData, 1) req, err := http.NewRequest("GET", "http://127.0.0.1:8000/", nil) if err != nil { fmt.Printf("new requestg failed, err:%v\n", err) return } req = req.WithContext(ctx) // 使用带超时的ctx创建一个新的client request var wg sync.WaitGroup wg.Add(1) defer wg.Wait() go func() { resp, err := client.Do(req) fmt.Printf("client.do resp:%v, err:%v\n", resp, err) rd := &respData{ resp: resp, err: err, } respChan <- rd wg.Done() }() select { case <-ctx.Done(): //transport.CancelRequest(req) fmt.Println("call api timeout") case result := <-respChan: fmt.Println("call server api success") if result.err != nil { fmt.Printf("call server api failed, err:%v\n", result.err) return } defer result.resp.Body.Close() data, _ := ioutil.ReadAll(result.resp.Body) fmt.Printf("resp:%v\n", string(data)) } } func main() { // 定义一个100毫秒的超时 ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*100) defer cancel() // 调用cancel释放子goroutine资源 doCall(ctx) }

__EOF__

本文作者BigSun丶
本文链接https://www.cnblogs.com/Mcoming/p/18073098.html
关于博主:评论和私信会在第一时间回复。或者直接私信我。
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角推荐一下。您的鼓励是博主的最大动力!
posted @   BigSun丶  阅读(94)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构
点击右上角即可分享
微信分享提示