首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

Go 语言入门很简单:上下文

Go Context 的概念

Golang 的 Context 应用开发常用的并发控制工具,用于在程序中的 API 层或进程之间共享请求范围的数据、取消信号以及超时或截止日期。

Context 又被称为上下文,与 WaitGroup 不同的是,context 对于派生 goroutine 有更强的控制力,可以管理多级的 goroutine。

Context Tree

在实际实现中,我们通常使用派生上下文。我们创建一个父上下文并将其传递到一个层,我们派生一个新的上下文,它添加一些额外的信息并将其再次传递到下一层,依此类推。通过这种方式,我们创建了一个从作为父级的根上下文开始的上下文树。这种结构的优点是我们可以一次性控制所有上下文的取消。如果根信号关闭了上下文,它将在所有派生的上下文中传播,这些上下文可用于终止所有进程,立即释放所有内容。这使得上下文成为并发编程中非常强大的工具。

Context 接口定义:

代码语言:javascript
复制
type Context interface {
  Deadline() (deadline time.Time, ok bool)
  
  Done <-chan struct{}
  Err() error
  Value(key interface{}) interface{}
}

Context 接口定义了 4 个方法:

  • Deadline(): 返回取消此上下文的时间 deadline(如果有)。如果未设置 deadline 时,则返回 ok==false,此时 deadline 为一个初始值的 time.Time 值。
  • Done() : 返回一个用于探测 Context 是否取消的 channel,当 Context 取消会自动将该 channel 关闭,如果该 Context 不能被永久取消,该函数返回 nil。例如 context.Background()
  • Err(): 该方法会返回 context 被关闭的原因,关闭原因由 context 实现控制,不需要用户设置;如果 Done() 尚未关闭,则 Err() 返回 nil
  • Value() : 在树状分布的 goroutine 之间共享数据,用 map 键值的工作方法,通过 key 值查询 value

创建上下文

我们可以从现有的上下文中创建或派生上下文。根上下文是使用 Background 或 TODO 方法创建的,而派生上下文是使用 WithCancel、WithDeadline、WithTimeout 或 WithValue 方法创建的。所有派生的上下文方法都返回一个取消函数 CancelFunc,但 WithValue 除外,因为它与取消无关。调用 CancelFunc 会取消子项及其子项,删除父项对子项的引用,并停止任何关联的计时器。调用 CancelFunc 失败会泄漏子项及其子项,直到父项被取消或计时器触发。

空 context

context.Background() ctx Context

此函数返回一个空上下文。这通常只应在主请求处理程序或顶级请求处理程序中使用。这可用于为后续层或 goroutine 派生其他上下文。

ctx, cancel := context.Background()

context.TODO() ctx Context

此函数还创建一个空上下文。但是,这也应该仅在您不确定要使用什么上下文或者该函数还不能用于接收上下文并且将在将来添加时使用。

ctx, cancel := context.TODO()

context.WithValue(parent Context, key, val interface{}) Context

这个函数接受一个上下文并返回一个派生的上下文,其中值 val 与 key 相关联,并与上下文一起经过上下文树。这意味着一旦你得到一个带有值的上下文,任何从它派生的上下文都会得到这个值。该值是不可变的,因此是线程安全的。

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

代码语言:javascript
复制
package main

import (
  "context"
  "fmt"
)

type contextKey string

func main() {

  var authToken contextKey = "auth_token"

  ctx := context.WithValue(context.Background(), authToken, "Hello123456")

  fmt.Println(ctx.Value(authToken))
}

运行该代码:

代码语言:javascript
复制
$ go run .           
Hello123456

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

此函数接受父上下文并返回派生上下文以及 CancelFunc 类型的取消函数。在这个派生上下文中,添加了一个新的 Done channel,该 channel 在调用 cancel 函数或父上下文的 Done 通道关闭时关闭。

要记住的一件事是,我们永远不应该在不同的函数或层之间传递这个 cancel ,因为它可能会导致意想不到的结果。创建派生上下文的函数应该只调用取消函数。

下面是一个使用 Done 通道演示 goroutine 泄漏的示例:

代码语言:javascript
复制
package main

import (
  "context"
  "fmt"
  "math/rand"
  "time"
)

func main() {

  rand.Seed(time.Now().UnixNano())

  ctx, cancel := context.WithCancel(context.Background())
  defer cancel()

  for char := range randomCharGenerator(ctx) {
    generatedChar := string(char)
    fmt.Printf("%v\n", generatedChar)

    if generatedChar == "o" {
      break
    }
  }
}

func randomCharGenerator(ctx context.Context) <-chan int {

  char := make(chan int)

  seedChar := int('a')

  go func() {
    for {
      select {
      case <-ctx.Done():
        fmt.Printf("found %v", seedChar)
        return

      case char <- seedChar:
        seedChar = 'a' + rand.Intn(26)
      }
    }
  }()

  return char
}

运行结果:

代码语言:javascript
复制
$ go run .           
a
m
q
c
l
t
o

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)

此函数从其父级返回派生上下文,当期限超过或调用取消函数时,该派生上下文将被取消。例如,您可以创建一个在未来某个时间自动取消的上下文,并将其传递给子函数。当该上下文由于截止日期用完而被取消时,所有获得该上下文的函数都会收到通知停止工作并返回。如果 parent 的截止日期已经早于 d,则上下文的 Done 通道已经关闭。

下面是我们正在读取一个大文件的示例,该文件的截止时间为当前时间 2 毫秒。我们将获得 2 毫秒的输出,然后将关闭上下文并退出程序。

代码语言:javascript
复制
package main

import (
    "bufio"
    "context"
    "fmt"
    "log"
    "os"
    "time"
)

func main() {
    // context with deadline after 2 millisecond
    ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(2*time.Millisecond))
    defer cancel()

    lineRead := make(chan string)

    var fileName = "sample-file.txt"
    file, err := os.Open(fileName)
    if err != nil {
        log.Fatalf("failed opening file: %s", err)
    }

    scanner := bufio.NewScanner(file)
    scanner.Split(bufio.ScanLines)

    // goroutine to read file line by line and passing to channel to print
    go func() {
        for scanner.Scan() {
            lineRead <- scanner.Text()
        }

        close(lineRead)
        file.Close()
    }()

outer:
    for {
        // printing file line by line until deadline is reached
        select {
        case <-ctx.Done():
            fmt.Println("process stopped. reason: ", ctx.Err())
            break outer
        case line := <-lineRead:
            fmt.Println(line)
        }
    }
}

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

这个函数类似于 context.WithDeadline。不同之处在于它将持续时间作为输入而不是时间对象。此函数返回一个派生上下文,如果调用取消函数或超过超时持续时间,该上下文将被取消。

WithTimeout 返回 WithDeadline(parent, time.Now().Add(timeout))

代码语言:javascript
复制
package main

import (
    "bufio"
    "context"
    "fmt"
    "log"
    "os"
    "time"
)

func main() {
    // context with deadline after 2 millisecond
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Millisecond)
    defer cancel()

    lineRead := make(chan string)

    var fileName = "sample-file.txt"
    file, err := os.Open(fileName)
    if err != nil {
        log.Fatalf("failed opening file: %s", err)
    }

    scanner := bufio.NewScanner(file)
    scanner.Split(bufio.ScanLines)

    // goroutine to read file line by line and passing to channel to print
    go func() {
        for scanner.Scan() {
            lineRead <- scanner.Text()
        }

        close(lineRead)
        file.Close()
    }()

outer:
    for {
        // printing file line by line until deadline is reached
        select {
        case <-ctx.Done():
            fmt.Println("process stopped. reason: ", ctx.Err())
            break outer
        case line := <-lineRead:
            fmt.Println(line)
        }
    }
}

如果父上下文的 Done 通道关闭,它最终将关闭所有派生的 Done 通道(所有后代),如:

代码语言:javascript
复制
package main

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

func main() {
    c := make(chan string)
    go func() {
        time.Sleep(1 * time.Second)
        c <- "one"
    }()

    ctx1 := context.Context(context.Background())

    ctx2, cancel2 := context.WithTimeout(ctx1, 2*time.Second)
    ctx3, cancel3 := context.WithTimeout(ctx2, 10*time.Second) // derives from ctx2
    ctx4, cancel4 := context.WithTimeout(ctx2, 3*time.Second)  // derives from ctx2
    ctx5, cancel5 := context.WithTimeout(ctx4, 5*time.Second)  // derives from ctx4

    cancel2()
    defer cancel3()
    defer cancel4()
    defer cancel5()

    select {
    case <-ctx3.Done():
        fmt.Println("ctx3 closed! error: ", ctx3.Err())
    case <-ctx4.Done():
        fmt.Println("ctx4 closed! error: ", ctx4.Err())
    case <-ctx5.Done():
        fmt.Println("ctx5 closed! error: ", ctx5.Err())
    case msg := <-c:
        fmt.Println("received", msg)
    }
}

在这里,由于我们在创建其他派生上下文后立即关闭 ctx2,因此所有其他上下文也会立即关闭,随机打印 ctx3、ctx4 和 ctx5 关闭消息。 ctx5 是从 ctx4 派生的,由于 ctx2 关闭的级联效应,它正在关闭。尝试多次运行,您会看到不同的结果。

使用 Background 或 TODO 方法创建的上下文没有取消、值或截止日期。

代码语言:javascript
复制
package main

import (
    "context"
    "fmt"
)

func main() {
    ctx := context.Background()

    _, ok := ctx.Deadline()

    if !ok {
        fmt.Println("no dealine is set")
    }

    done := ctx.Done()

    if done == nil {
        fmt.Println("channel is nil")
    }
}

总结

Context 是在 Go 中进行并发编程时最重要的工具之一。

  • 不要将上下文存储在结构类型中;相反,将 Context 显式传递给需要它的每个函数。 Context 应该是第一个参数,通常命名为 ctx。
代码语言:javascript
复制
func DoSomething(ctx context.Context, arg Arg) error {
    // ... use ctx ...
}

  • 不要不传递 nil 上下文,即使函数允许。如果不确定要使用哪个 Context,请传递 context.TODO。
  • 仅使用上下文传递请求范围的数据。不要传递应该使用函数参数传递的数据。
  • 始终寻找 goroutine 泄漏并有效地使用上下文来避免这种情况。
  • 如果父上下文的 Done 通道关闭,它最终将关闭所有派生的 Done 通道(所有后代
  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/da333dfaedc56ac5cdabd48a3
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券