专业的编程技术博客社区

网站首页 > 博客文章 正文

Go 语言 Context知识详解(go中的context)

baijin 2024-08-17 11:10:50 博客文章 10 ℃ 0 评论

context包的起源

context是Go中用来进程通信的一种方式,其底层是借助channl与snyc.Mutex实现的。

context包是在go1.7版本中引入到标准库中的:

context可以用来在goroutine之间传递上下文信息,相同的context可以传递给运行在不同goroutine中的函数,上下文对于多个goroutine同时使用是安全的,context包定义了上下文类型,可以使用background、TODO创建一个上下文,在函数调用链之间传播context,也可以使用WithDeadline、WithTimeout、WithCancel 或 WithValue 创建的修改副本替换它,听起来有点绕,其实总结起就是一句话:context的作用就是在不同的goroutine之间同步请求特定的数据、取消信号以及处理请求的截止日期。

目前我们常用的一些库都是支持context的,例如gin、database/sql等库都是支持context的,这样更方便我们做并发控制了,只要在服务器入口创建一个context上下文,不断透传下去即可。

基本介绍

context的底层设计,我们可以概括为2种接口,4种实现与6个方法。

2个接口 :Context 接口和canceler 接口

4 种实现 :emptyCtx 实现了一个空的context,可以用作根节点 cancelCtx 实现一个带cancel功能的context,可以主动取消 timerCtx 实现一个通过定时器timer和截止时间deadline定时取消的context valueCtx 实现一个可以通过 key、val 两个字段来存数据的context

6 个方法 :Background 返回一个emptyCtx作为根节点 TODO 返回一个emptyCtx作为未知节点 WithCancel 返回一个cancelCtx WithDeadline 返回一个timerCtx WithTimeout 返回一个timerCtx WithValue 返回一个valueCtx

结构和主要方法

主要函数、结构体和变量说明:

名称

类型

可否导出

说明

Context

接口

可以

Context 基本接口,定义了 4 个方法

canceler

接口

不可以

Context 取消接口,定义了 2 个方法

CancelFunc

函数

可以

取消函数签名

Background

函数

可以

返回一个空的 Context,常用来作为根 Context

Todo

函数

可以

返回一个空的 context,常用于初期写的时候,没有合适的 context 可用

emptyCtx

结构体

不可以

实现了 Context 接口,默认都是空实现,emptyCtx 是 int 类型别名

cancelCtx

结构体

不可以

可以被取消

valueCtx

结构体

不可以

可以存储 k-v 信息

timerCtx

结构体

不可以

可被取消,也可超时取消

WithCancel

函数

可以

基于父 context,创建可取消 Context

WithDeadline

函数

可以

创建一个有 deadline 的 context

WithTimeout

函数

可以

创建一个有 timeout 的 context

WithValue

函数

可以

创建一个存储 k-v 的 context

newCancelCtx

函数

不可以

创建一个可取消的 context

propagateCancel

函数

不可以

向下传递 context 节点间的取消关系

parentCancelCtx

函数

不可以

找到最先出现的一个可取消 Context

removeChild

函数

不可以

将当前的 canceler 从父 Context 中的 children map 中移除

background

变量

不可以

包级 Context,默认的 Context,常作为顶级 Context

todo

变量

不可以

包级 Context,默认的 Context 实现,也作为顶级 Context,与 background 同类型

closedchan

变量

不可以

channel struct{}类型,用于信息通知

Canceled

变量

可以

取消 error

DeadlineExceeded

变量

可以

超时 error

cancelCtxKey

变量

不可以

int 类型别名,做标记用的

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和进程间跟请求域的数据;

Context 的四种实现

emptyCtx

从源码可以看出,emptyCtx 实际上就是个 int,其对 Context 接口的实现不是直接返回,就是返回 nil,是一个空实现。它通常用于创建 root Context,标准库中 context.Background() 和 context.TODO() 返回的就是这个 emptyCtx。emptyCtx 不能取消、不能传值且没有 deadline。

cancelCtx

Context 包的核心实现就是 cancelCtx,包括里面的构造树形结构、级联取消等。

Dono()方法

c.done 是“懒汉式”初始化,只有调用了 Done() 方法的时候才会被创建。Done() 方法用于通知该 Context 是否被取消,通过监听 channel 关闭达到被取消通知目的,c.done 没有被关闭的时候,调用 Done() 方法会 block,被关闭之后,调用 Done() 方法返回 struct{},一般通过搭配 select 使用。

Value()方法

这个方法的实现比较有意思,cancelCtxKey 是一个 Context 包内部变量,将 key 与 &cancelCtxKey 比较,相等的话就返回 *cancelCtx,即 cancelCtx 的自身地址;否则继续递归。

canceler 接口

type canceler interface {
 cancel(removeFromParent bool, err error)
 Done() <-chan struct{}
}

如果一个 Context 类型实现了上面定义的两个方法,该 Context 就是一个可取消的 Context。Context 包中 *cancelCtx 和 *timerCtx 实现了 canceler 接口,注意这里是指针类型。

第一次看到这两个接口时,我就在想为什么不把 canneler 和 Context 合并呢?况且他们定义的方法中都有 Done 方法,可以解释得通的说法是,源码作者认为 cancel 方法并不是 Context 必须的,根据最小接口设计原则,将两者分开。像 emptyCtx 和 valueCtx 不是可取消的,所以他们只要实现 Context 接口即可。cancelCtx 和 timerCtx 是可取消的 Context,他们要实现 2 个接口中的所有方法。

Background()和TODO()

Go内置两个函数:Background()和TODO(),这两个函数分别返回一个实现了Context接口的background和todo。我们代码中最开始都是以这两个内置的上下文对象作为最顶层的partent context,衍生出更多的子上下文对象。

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

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

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

With系列函数

context包中还定义了四个With系列函数。

WithCancel

WithCancel返回带有新Done通道的父节点的副本。当调用返回的cancel函数或当关闭父上下文的Done通道时,将关闭返回上下文的Done通道,无论先发生什么情况。

WithDeadline

返回父上下文的副本,并将deadline调整为不迟于d。如果父上下文的deadline已经早于d,则WithDeadline(parent, d)在语义上等同于父上下文。当截止日过期时,当调用返回的cancel函数时,或者当父上下文的Done通道关闭时,返回上下文的Done通道将被关闭,以最先发生的情况为准。

WithTimeout

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

WithValue

WithValue返回父节点的副本,其中与key关联的值为val。

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

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

客户端超时取消示例

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

client端

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)
}

server端

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)
  }
}

总结及注意事项

context 包的代码非常短,去掉注释的话也就 200+ 行,但却是并发控制的标准做法,比如实现 goroutine 之间传递取消信号、截止时间及传递一些 k-v 值等。

注意事项

  • 以Context作为参数的函数方法,应该把Context作为第一个参数。
  • 给一个函数方法传递Context的时候,不要传递nil,如果不知道传递什么,就使用context.TODO()
  • Context的Value相关方法应该传递请求域的必要数据,不应该用于传递可选参数
  • 推荐以参数的方式显示传递Context
  • 对第三方调用要传入 context,用于控制远程调用。
  • 不要将上下文存储在结构类型中,尽可能的作为函数第一位形参传入。
  • 函数调用链必须传播上下文,实现完整链路上的控制。
  • context 的继承和派生,保证父、子级 context 的联动。
  • 不传递 nil context,不确定的 context 应当使用 TODO。
  • context 仅传递必要的值,不要让可选参数揉在一起。

优点

  • 使用context可以更好的做并发控制,能更好的管理goroutine滥用。
  • context的携带者功能没有任何限制,这样我我们传递任何的数据,可以说这是一把双刃剑
  • 网上都说context包解决了goroutine的cancelation问题,你觉得呢?

缺点

  • context可以携带值,但是没有任何限制,类型和大小都没有限制,也就是没有任何约束,这样很容易导致滥用,程序的健壮很难保证;还有一个问题就是通过context携带值不如显式传值舒服,可读性变差了。
  • 可以自定义context,这样风险不可控,更加会导致滥用。
  • 创建衍生节点实际是创建一个个链表节点,其时间复杂度为O(n),节点多了会掉支效率变低。


本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表