一个计算机技术爱好者与学习者

0%

好好学Golang:上下文Context

1. Golang Context简介

在Go语言中,context 是一个在Go语言程序中传递截止时间、取消信号以及其它请求作用域的值的包。
context 包为并发操作提供了一种统一的方式来传递取消信号和元数据。经常用于控制不同Go协程之间的超时和取消操作,管理函数或方法的生命周期。

参考文档:上下文Context

2. Context用途

context 的主要用途有以下几点:

1、超时控制:通过在 context 中设置超时来控制任务的执行时长,例如 HTTP 请求或数据库查询,以防止一些操作耗时过长影响系统性能。
2、取消操作:在多个协程之间同步取消信号,如一个用户请求的取消操作需要传递给所有相关的处理协程。
3、传递请求相关的数据:虽然不是 context 的主要用途,但它也可用于在API的调用链中传递请求特定的数据,如身份验证令牌等。

3. Context关键概念

3.1. Context类型

Context 类型: context 包中定义了 Context 接口,它包括以下方法:

  • Deadline():返回 context 截止时间。
  • Done():返回一个 channelcontext 取消或者达截止时间时,channel 会被关闭。
  • Err():返回 context 结束的错误,如果 context 被取消就返回 Canceled 错误,如果超时则返回 DeadlineExceeded 错误。
  • Value(key interface{}):获取与 key 对应的值,用于传递请求特定的数据。

3.2. Context对象

创建 Context 对象:

  • context.Background():返回一个空的 Context,通常用于整个 Context 树的根节点。它不可以取消,也没有截止时间。我们并不需要,也不能主动去关闭它。它通常是用在main函数或顶层的初始化函数中,提供了一个基础的 Context,该Context通常被扩展为更复杂的 Context。
  • context.TODO():返回一个空的 Context,用于占位。通常在代码的某个部分还没有决定应该使用哪个 Context 时使用,或者在当前没有可用的 Context,但我们预计将来某个时间点需要向下传递一个实际的 Context 时使用。
  • context.WithCancel(parent Context):基于父 Context 创建新的可以主动取消的 Context
  • context.WithDeadline(parent Context, deadline time.Time):基于父 Context 创建新的带有截止时间的 Context,当达到截止时间时,Context 会自动取消,也可以主动取消。
  • context.WithTimeout(parent Context, timeout time.Duration):基于父 Context 和超时时间创建新的 Context

3.3. Context树

Context 树是指在Go的context包中通过Context对象的派生形成的层次结构。在这个层次结构中,每个Context对象都可能有父Context,同时也可以有一个或多个子Context。这种结构类似于一颗树,其中最顶层的Context通常是context.Background()context.TODO(),它们通常作为所有Context层次结构的根节点。

创建子Context的过程一般通过context.WithCancelcontext.WithDeadlinecontext.WithTimeoutcontext.WithValue四个函数实现。每个函数在创建子Context时都会复制父Context的行为(诸如超时、取消信号等),同时可以增加额外的行为或信息。

例如,如果我们有一个HTTP请求的处理逻辑,我们可能会在这个请求的开始处创建一个根Context(通过context.WithTimeout来设置整个请求的超时),然后随着我们的代码调用更深层的函数,或者启动新的goroutine来处理请求的不同部分,我们会从根Context派生子Context

具体来说,如果一个数据库查询依赖于这个请求,我们可以再从这个根Context派生一个子Context(可能还会设置一个特定于数据库操作的超时时间),并将其传递给执行数据库操作的函数。如果需要取消整个请求处理,只需要取消最顶层的Context,整个Context树下所有的Context都会收到取消信号。

以下展示了Context树的简单结构示意:

1
2
3
4
5
6
7
8
9
Root Context (Background or TODO)

├── Child Context A (WithCancel or WithTimeout)
│ ├── Grandchild Context A.1 (WithCancel or WithValue)
│ └── Grandchild Context A.2 (WithCancel)

└── Child Context B (WithDeadline)
    └── Grandchild Context B.1 (WithCancel or WithDeadline)
        └── Great-Grandchild Context B.1.1 (WithCancel or WithValue)

在这个结构中:

  • 取消Root Context会导致整个树的Context都被取消。
  • 取消Child Context A只会影响A的子树,即Grandchild Context A.1Grandchild Context A.2,不会影响Child Context B子树。
  • Context会继承父Context的截止时间和取消信号,并可以拓展自己的值与超时设置。

在并发编程和复杂的应用逻辑中,管理Context树可以帮助开发者更有效地处理超时、取消操作以及传递关键的请求信息。

4. Context最佳实践

  • 不要将 Context 存放在结构体中,而应明确地传递。
  • 务必传递而非忽略 Context,即使代码还不需要它。
  • 即使函数允许,也不要传递 nil Context,如果不确定使用哪个 Context,请使用 context.TODO()
  • ContextValue 应该用得极其谨慎,它不是传递可选参数的手段。

一个典型的使用场景是在服务器处理请求时,使用 context.WithCancelcontext.WithTimeout 在入口处产生一个 Context,然后将这个 Context 传递给每个涉及到的协程,让这些并发执行的操作都可以响应取消或超时事件。这样、单一请求的依赖操作可以被统一管理和控制。

5. Context使用示例

5.1. 创建可取消的Context

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package main

import (
"context"
"fmt"
"time"
)

func operation(ctx context.Context, duration time.Duration) {
select {
case <-time.After(duration):
fmt.Println("Operation finished successfully")
case <-ctx.Done():
fmt.Println("Operation canceled:", ctx.Err())
}
}

func main() {
// 创建一个可取消的context
ctx, cancel := context.WithCancel(context.Background())

// 启动一个协程执行任务
go operation(ctx, 5*time.Second)

// 假设1秒后我们想取消操作
time.Sleep(1 * time.Second)
cancel() // 触发取消操作

// 等待足够长的时间以确保打印信息
time.Sleep(3 * time.Second)
}

在这个例子中,我们通过context.WithCancel创建了一个可取消的Context。在operation函数中,我们使用select监听Context的Done通道是否关闭。一秒后,执行cancel()函数,此时Done通道关闭,程序输出”Operation canceled”,而不会等到5秒的超时时间。

5.2. 创建带超时的Context

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package main

import (
"context"
"fmt"
"time"
)

func operation(ctx context.Context) {
select {
case <-time.After(5 * time.Second): // 假设操作需要5秒才能完成
fmt.Println("Operation finished successfully")
case <-ctx.Done():
fmt.Println("Operation failed:", ctx.Err())
}
}

func main() {
// 创建一个超时未5秒的context
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() // 确保所有路径下都会调用cancel

go operation(ctx)

// 等待operation执行或超时
<-ctx.Done()

if ctx.Err() == context.DeadlineExceeded {
fmt.Println("Operation timeout with deadline exceeded")
}
}

在这个例子中,我们通过context.WithTimeout创建了一个带有2秒超时的Context。由于operation函数中的模拟操作需要5秒才能完成,Context会在2秒后因超时而取消。<-ctx.Done()等待Context的取消信号,打印出超时信息。

5.3. Context传递值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main

import (
    "context"
    "fmt"
)

func doSomething(ctx context.Context) {
// 从Context中提取值
value := ctx.Value("key").(string)
fmt.Println("Value from context:", value)
}

func main() {
// 设置一个包含值的context
ctx := context.WithValue(context.Background(), "key", "go-12345")

// 调用函数并传递context
doSomething(ctx)
}

在这个例子中,我们使用context.WithValuecontext添加了一个键值对。然后在doSomething函数中,我们从context中检索值并打印它。需要注意的是,如前所述,WithValue应该非常谨慎地使用,因为它不适合传递很多值,并且可能会导致依赖于context的代码变得难以理解和维护。

在实际应用中,context常常用于API调用链、数据库操作、HTTP请求处理等多级协程控制场景中。使用context能够优雅地管理多个协程间的超时、取消和信号传递。

5.4. Context传递停止信号

ctx.Done() 返回只读chan类型的通道关闭信号。
<-ctx.Done() 用于等待ctx.Done方法返回的通道关闭信号。
context被取消或超时的情况下,Done通道会被关闭,这表示相关的操作应该停止执行。

我们可以通过监听这个Done通道来优雅地处理取消信号和超时事件。我们通常会在长时间运行或涉及到并发的操作中使用这种方法。

Done()用法的基本示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package main

import (
    "context"
    "fmt"
    "time"
)

func performTask(ctx context.Context) {
    for {
        select {
        case <-ctx.Done(): // 监听context是否已被取消或超时
            fmt.Println("Task was cancelled or has timed out:", ctx.Err())
            return
        default:
            // 模拟正在进行的任务
            fmt.Println("Task is running...")
            time.Sleep(1 * time.Second)
        }
    }
}

func main() {
    // 创建一个将在3秒后超时的context
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel() // 确保resources被释放

    // 在新的goroutine中启动任务
    go performTask(ctx)

    // 等待ctx.Done()的信号,这表示context已被取消或者超时
    <-ctx.Done()

    // 执行善后工作
    fmt.Println("Main function: Context has been cancelled, performing cleanup...")

    // 此时performTask的协程已退出,因为它监听到了ctx.Done()的关闭信号
}

在这个例子中,我们通过ctx.Done()来监控一个长时间运行的任务是否应该停止。
当Context的超时时间到了(在本例中是3秒后),Done通道会关闭,同时select语句的case <-ctx.Done()分支被选中,程序打印出取消或超时的信息,并且退出for循环,结束任务。
在主函数(main)中,我们也监听了<-ctx.Done(),以便等待Context的取消信号,进行必要的清理工作。

这种做法使得并发程序在需要响应停止指令(如取消或超时)时能够及时安全地停止正在执行的操作。

5.5. 多个子任务并发执行

下面是多个子函数并发执行的示例,每个子函数都通过监听<-ctx.Done()来响应取消信号:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package main

import (
"context"
"fmt"
"sync"
"time"
)

// task 模拟一个简单的耗时任务
func task(ctx context.Context, wg *sync.WaitGroup, id int) {
defer wg.Done() // 等待组计数-1

// 模拟任务周期性执行某些操作
for {
select {
case <-ctx.Done():
// 如果收到取消信号,打印取消信息并退出
fmt.Printf("Task %d is canceled: %v\n", id, ctx.Err())
return
default:
// 模拟周期性的工作
fmt.Printf("Task %d is running...\n", id)
time.Sleep(1 * time.Second)
}
}
}

func main() {
// 创建一个带有取消功能的Context
ctx, cancel := context.WithCancel(context.Background())
// 使用sync.WaitGroup等待所有子goroutine完成
var wg sync.WaitGroup

// 启动多个并发的子函数(任务)
for i := 1; i <= 3; i++ {
wg.Add(1) // 等待组计数+1
go task(ctx, &wg, i)
}

// 定时3秒后,取消所有任务
time.AfterFunc(3*time.Second, func() {
fmt.Println("Cancelling all tasks...")
cancel()
})

// 等待所有任务结束
wg.Wait()
fmt.Println("All tasks have been cancelled.")
}

在这个程序中:

1、我们首先使用 context.WithCancel(context.Background()) 创建了一个可取消的父 Context,这个 Context 会被作为参数传给所有的任务(子函数)。

2、我们使用 sync.WaitGroup 确保主函数等待三个并发任务全部完成。每个任务的goroutine在开始时调用 wg.Add(1) 增加等待计数,当任务完成时调用 wg.Done() 递减计数。

3、task 函数表示需要执行的任务。它通过 select 语句监听两个事件:ctx.Done() 信号的关闭,表示任务应该取消;default 分支模拟正在进行的工作(这里简化为打印信息和等待1秒)。

4、在 main 函数中,我们使用 time.AfterFunc 设置了一个3秒后执行的定时器,在定时器触发时调用 cancel() 函数取消所有任务。

5、最后,wg.Wait() 阻塞主函数,直到所有goroutine执行 wg.Done(),这也就意味着所有任务都接收到取消信号并正常退出。

在上述示例中,当主函数中的定时器触发,所有任务都会因 ctx.Done() 的关闭而接收到取消信号并优雅地停止正在进行的工作。程序将输出”Task x is canceled”信息并退出。所有任务取消后,程序打印 “All tasks have been cancelled.”,然后结束。

6. 总结

Context的本质作用,其实是控制函数或方法的生命周期。
Context生命周期控制机制有两个关键概念:一个是ctx对象,一个是ctx对象的channel。

Context生命周期控制的流程可以概括为:
1、父函数中,创建一个ctx对象,并且把该对象传递给子函数
2、父函数中,可以通过定时或者手动取消,关闭ctx对象的channel
3、父函数中关闭ctx对象的channel时,子函数就收到了关闭信号,子函数可以选择结束自己的生命周期
4、如果子函数长时间运行,或者多个子函数并发,父函数中可以使用 <-ctx.Done() 使主函数进入等待子函数执行

PS:多个子函数并发,还可以使用 sync.WaitGroup 等待子函数执行

  • 本文作者: 好好学习的郝
  • 原文链接: https://www.voidking.com/dev-golang-context/
  • 版权声明: 本文采用 BY-NC-SA 许可协议,转载请注明出处!源站会即时更新知识点并修正错误,欢迎访问~
  • 微信公众号同步更新,欢迎关注~