详解Golang中Context的原理和使用技巧

目录
  • Context 背景 和 适用场景
    • Context 的背景
    • Context 的功能和目的
    • Context 的基本使用
    • Context 的同步控制设计
  • Context 的定义和实现
    • Context interface 接口定义
    • parent Context 的具体实现
    • Context 的继承和各种 With 系列函数
  • Context 的常用方法实例
    • 1. 调用 Context Done方法取消
    • 2. 通过 context.WithValue 来传值
    • 3. 超时取消 context.WithTimeout
    • 4. 截止时间取消 context.WithDeadline
  • Context 使用原则 和 技巧

Context 背景 和 适用场景

Context 的背景

Golang 在 1.6.2 的时候还没有自己的 context,在1.7的版本中就把 https://pkg.go.dev/golang.org/x/net/context包被加入到了官方的库中。Golang 的 Context 包,中文可以称之为“上下文”,是用来在 goroutine 协程之间进行上下文信息传递的,这些上下文信息包括 kv 数据、取消信号、超时时间、截止时间等。

Context 的功能和目的

虽然我们知道了 context 上下文的基本信息,但是想想,为何 Go 里面把 Context 单独拧出来设计呢?这就和 Go 的并发有比较大的关系,因为 Go 里面创建并发协程非常容易,但是,如果没有相关的机制去控制这些这些协程的生命周期,那么可能导致协程泛滥,也可能导致请求大量超时,协程无法退出导致协程泄漏、协程泄漏导致协程占用的资源无法释放,从而导致资源被占满等各种问题。所以,context 出现的目的就是为了解决并发协程之间父子进程的退出控制。

一个常见例子,有一个 web 服务器,来一个请求,开多个协程去处理这个请求的业务逻辑,比如,查询登录状态、获取用户信息、获取业务信息等,那么如果请求的下游协程的生命周期无法控制,那么我们的业务请求就可能会一直超时,业务服务可能会因为协程没有释放导致协程泄漏。因此,协程之间能够进行事件通知并且能控制协程的生命周期非常重要,怎么实现呢? context 就是来干这些事的。

另外,既然有大量并发协程,那么各个协程之间的一些基础数据如果想要共享,比如把每个请求链路的 tarceID 都进行传递,这样把整个链路串起来,要怎么做呢? 还是要依靠 context。

总体来说,context 的目的主要包括两个:

  • 协程之间的事件通知(超时、取消)
  • 协程之间的数据传递键值对的数据(kv 数据)

Context 的基本使用

Go 语言中的 Context 直接使用官方的 "context" 包就可以开始使用了,一般是在我们所有要传递的地方(函数的第一个参数)把 context.Context 类型的变量传递,并对其进行相关 API 的使用。context 常用的使用姿势包括但不限于:

  • 通过 context 进行数据传递,但是这里只能传递一些通用或者基础的元数据,不要传递业务层面的数据,不是说不可以传递,是在 Go 的编码规范或者惯用法中不提倡
  • 通过 context 进行协程的超时控制
  • 通过 context 进行并发控制

Context 的同步控制设计

Go 里面控制并发有两种经典的方式,一种是 WaitGroup,另外一种就是 Context。

在 Go 里面,当需要进行多批次的计算任务同步,或者需要一对多的协作流程的时候;通过 Context 的关联关系(go 的 context 被设计为包含了父子关系),我们就可以控制子协程的生命周期,而其他的同步方式是无法控制其生命周期的,只能是被动阻塞等待完成或者结束。context 控制子协程的生命周期,是通过 context 的 context.WithTimeout 机制来实现的,这个是一般系统中或者底层各种框架、库的普适用法。context 对并发做一些控制包括 Context Done 取消、截止时间取消 context.WithDeadline、超时取消 context.WithTimeout 等。

比如有一个网络请求 Request,每个 Request 都需要开启一个 goroutine 做一些业务逻辑,这些 goroutine 又可能会开启其他的 goroutine。那么这样的话,我们就可以通过 Context 来跟踪并控制这些 goroutine。

另外一个实际例子是,在 Go 实现的 web server 中,每个请求都会开一个 goroutine 去处理。但是我们的这个 goroutine 请求逻辑里面, 还需继续创建goroutine 去访问后端其他资源,比如数据库、RPC 服务等。由于这些 goroutine 都是在处理同一个请求,因此,如果请求超时或者被取消后,所有的 goroutine 都应该马上退出并且释放相关的资源,这种情况也需要用 Context 来为我们取消掉所有 goroutine。

Context 的定义和实现

Context interface 接口定义

在 golang 里面,interface 是一个使用非常广泛的结构,它可以接纳任何类型。而 context 就是通过 interface 来定义的,定义很简单,一共4个方法,这也是 Go 的设计理念,接口尽量简单、小巧,通过组合来实现丰富的功能。

定义如下:

type Context interface {
    //  返回 context 是否会被取消以及自动取消的截止时间(即 deadline)
    Deadline() (deadline time.Time, ok bool)

    // 当 context 被取消或者到了 deadline,返回一个被关闭的 channel
    Done() <-chan struct{}

    // 返回取消的错误原因,因为什么 Context 被取消
    Err() error

    // 获取 key 对应的 value
    Value(key interface{}) interface{}
}
  • Deadline 返回 context 是否会被取消以及自动取消的截止时间,第一个返回值是截止时间,到了这个时间点,Context 会自动发起取消请求;第二个返回值 ok==false 时表示没有设置截止时间,如果需要取消的话,需要调用取消函数进行取消。
  • Done 方法返回一个只读的 chan,类型为 struct{},如果该方法返回的 chan 可以读取,那么就说明 parent context 已经发起了取消请求,当我们通过 Done 方法收到这个信号后,就应该做清理操作,然后退出 goroutine,释放资源。之后,Err 方法会返回一个错误,告知为什么 Context 被取消。
  • Err 方法返回取消的错误原因,因为什么 Context 被取消。
  • Value 方法获取该 Context 上保存的键值对,所以要通过一个 Key 才可以获取对应的值,这个值一般是线程安全(并发安全)的。虽然 context 是一个并发安全的类型,但是如果 context 中保存着 value,则这些 value 通常不是并发安全的,并发读写这些 value 可能会造成数据错乱,严重的情况下可能发生 panic,所以在并发时,如果我们的业务代码需要读写 context 中的 value,那么最好建议我们 clone 一份原来的 context 中的 value,并塞到新的 ctx 传递给各个gorouinte。当然, 如果已经明确不会有并发读取,那么可以直接使用,或者使用的时候加锁。

parent Context 的具体实现

Context 虽然是个接口,但是并不需要使用方实现,golang 内置的 context 包,已经帮我们实现了,查看 Go 的源码可以看到如下定义:

var (
	background = new(emptyCtx)
	todo = new(emptyCtx)
)

func Background() Context {
	return background
}

func TODO() Context {
	return todo
}

Background 和 TODO 两个其实都是基于 emptyCtx 来实现的,emptyCtx 类型实现了 context 接口定义的 4 个方法,它本身是一个不可取消,没有设置截止时间,没有携带任何值的 Context,查看官方源码如下:

type emptyCtx int

func (*emptyCtx) Deadline() (deadline time.Time, ok bool) {
	return
}

func (*emptyCtx) Done() <-chan struct{} {
	return nil
}

func (*emptyCtx) Err() error {
	return nil
}

func (*emptyCtx) Value(key interface{}) interface{} {
	return nil
}

Background 方法,一般是在 main 函数的入口处(或者请求最初的根 context)就定义并使用,然后一直往下传递,接下来所有的子协程里面都是基于 main 的 context 来衍生的。TODO 这个一般不建议业务上使用,一般没有实际意义,在单元测试里面可以使用。

Context 的继承和各种 With 系列函数

查看官方文档 https://pkg.go.dev/golang.org/x/net/context

// 最基础的实现,也可以叫做父 context
func Background() Context
func TODO() Context

// 在 Background() 根 context 基础上派生的各种  With 系列函数
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithValue(parent Context, key interface{}, val interface{}) Context
  • WithCancel 函数,传递一个 parent Context 作为参数,返回子 Context,以及一个取消函数用来取消 Context。我们前面说到控制父子协程的生命周期,那么就可以通过这个函数来实现
  • WithDeadline 函数,和 WithCancel 差不多,但是它会多传递一个截止时间参数,这样的话,当到了截止的时间点,就会自动取消 Context,当然我们也可以不等到这个时候,然后可以通过取消函数提前进行取消。
  • WithTimeout 函数,和 WithDeadline 基本上一样,会传入一个 timeout 超时时间,也就是是从现在开始,直到过来 timeout 时间后,就进行超时取消,注意,这个是超时取消,不是截止时间取消。
  • WithValue 函数,这个和 WithCancel 就没有关系了,它不是用来控制父子协程生命周期的,这个是我们说到的,在 context 中传递基础元数据用的,这个可以在 context 中存储键值对的数据,然后这个键值对的数据可以通过 Context.Value 方法获取到,这是我们实际用经常要用到的技巧,一般我们想要通过上下文来传递数据时,可以通过这个方法,如我们需要 tarceID 追踪系统调用栈的时候。

Context 的常用方法实例

1. 调用 Context Done方法取消

func ContextDone(ctx context.Context, out chan<- Value) error {

	for {
		v, err := AllenHandler(ctx)

		if err != nil {
			return err
		}
		select {
		case <-ctx.Done():
			log.Infof("context has done")
			return ctx.Err()
		case out <- v:
		}
	}
}

2. 通过 context.WithValue 来传值

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	valueCtx := context.WithValue(ctx, key, "add value from allen")

	go watchAndGetValue(valueCtx)

	time.Sleep(10 * time.Second)

	cancel()

	time.Sleep(5 * time.Second)
}

func watchAndGetValue(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			//get value
			log.Infof(ctx.Value(key), "is cancel")

			return
		default:
			//get value
			log.Infof(ctx.Value(key), "int goroutine")

			time.Sleep(2 * time.Second)
		}
	}
}

3. 超时取消 context.WithTimeout

	package main

	import (
		"fmt"
		"sync"
		"time"

		"golang.org/x/net/context"
	)

	var (
		wg sync.WaitGroup
	)

	func work(ctx context.Context) error {
		defer wg.Done()

		for i := 0; i < 1000; i++ {
			select {
			case <-time.After(2 * time.Second):
				fmt.Println("Doing some work ", i)

			// we received the signal of cancelation in this channel
			case <-ctx.Done():
				fmt.Println("Cancel the context ", i)
				return ctx.Err()
			}
		}
		return nil
	}

	func main() {
		ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
		defer cancel()

		fmt.Println("Hey, I'm going to do some work")

		wg.Add(1)
		go work(ctx)
		wg.Wait()

		fmt.Println("Finished. I'm going home")
	}

4. 截止时间取消 context.WithDeadline

	package main

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

	func main() {
		d := time.Now().Add(1 * time.Second)
		ctx, cancel := context.WithDeadline(context.Background(), d)

		// Even though ctx will be expired, it is good practice to call its
		// cancelation function in any case. Failure to do so may keep the
		// context and its parent alive longer than necessary.
		defer cancel()

		select {
		case <-time.After(2 * time.Second):
			fmt.Println("oversleep")
		case <-ctx.Done():
			fmt.Println(ctx.Err())
		}
	}

Context 使用原则 和 技巧

  • Context 是线程安全的,可以放心的在多个 goroutine 协程中传递
  • 可以把一个 Context 对象传递给任意个数的 gorotuine,对它执行 取消 操作时,所有 goroutine 都会接收到取消信号。
  • 不要把 Context 放在结构体中,要以参数的方式传递,parent Context 一般为Background,并且一般要在 main 函数的入口处创建然后传递下去
  • Context 的变量名建议都统一为 ctx,并且要把 Context 作为第一个参数传递给入口请求和出口请求链路上的每一个函数
  • 往下游给一个函数方法传递 Context 的时候,千万不要传递 nil,否则在 tarce 追踪的时候,就会中断链路,并且如果函数里面有获取值的逻辑,可能导致 panic。
  • Context 的 Value 只能传递一些通用或者基础的元数据,不要传递业务层面的数据,不是说不可以传递,是在 Go 的编码规范或者惯用法中不提倡不要什么数据都使用这个传递。由于 context 存储 key-value 是链式的,因此查询复杂度为O(n),所以,尽量不要随意存储不必要的数据

到此这篇关于详解Golang中Context的原理和使用技巧的文章就介绍到这了,更多相关Golang Context内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • 使用Golang的Context管理上下文的方法

    golang 1.7版本中context库被很多标准库的模块所使用,比如net/http和os的一些模块中,利用这些原生模块,我们就不需要自己再写上下文的管理器了,直接调用函数接口即可实现,利用context我们可以实现一些比如请求的声明周期内的变量管理,执行一些操作的超时等等. 保存上下文对象 这里我们通过一个简单的例子来看一下如何使用context的特性来实现上下文的对象保存,这里我们写了一个简单的http server,具有登录和退出,状态检查路由(检查用户是否登录) func main(

  • golang中context的作用详解

    当一个goroutine可以启动其他goroutine,而这些goroutine可以启动其他goroutine,依此类推,则第一个goroutine应该能够向所有其它goroutine发送取消信号. 上下文包的唯一目的是在goroutine之间执行取消信号,而不管它们如何生成.上下文的接口定义为: type Context interface { Deadline() (deadline time.Time, ok bool) Done() <- chan struct{} Err() erro

  • GOLANG使用Context管理关联goroutine的方法

    一般一个业务很少不用到goroutine的,因为很多方法是需要等待的,例如http.Server.ListenAndServe这个就是等待的,除非关闭了Server或Listener,否则是不会返回的.除非是一个API服务器,否则肯定需要另外起goroutine发起其他的服务,而且对于API服务器来说,在http.Handler的处理函数中一般也需要起goroutine,如何管理这些goroutine,在GOLANG1.7提供context.Context. 先看一个简单的,如果启动两个goro

  • GOLANG使用Context实现传值、超时和取消的方法

    GO1.7之后,新增了context.Context这个package,实现goroutine的管理. Context基本的用法参考GOLANG使用Context管理关联goroutine. 实际上,Context还有个非常重要的作用,就是设置超时.比如,如果我们有个API是这样设计的: type Packet interface { encoding.BinaryMarshaler encoding.BinaryUnmarshaler } type Stack struct { } func

  • 深入Golang之context的用法详解

    context在Golang的1.7版本之前,是在包golang.org/x/net/context中的,但是后来发现其在很多地方都是需要用到的,所有在1.7开始被列入了Golang的标准库.Context包专门用来简化处理单个请求的多个goroutine之间与请求域的数据.取消信号.截止时间等相关操作,那么这篇文章就来看看其用法和实现原理. 源码分析 首先我们来看一下Context里面核心的几个数据结构: Context interface type Context interface { D

  • GoLang之使用Context控制请求超时的实现

    起因   之前接触了一个需求:提供一个接口,这个接口有一个超时时间,如果超时了返回超时异常:这个接口中调用其他的接口,如果调用超时了,所有请求全部结束.   在这个接口中,我使用了go协程去调用其他接口,所以不仅涉及到请求的超时控制,而且还涉及到父协程对子协程的控制问题.在翻阅了一些资料之后,了解到了Context的基本知识. Context   Context是golang.org.pkg下的一个包,类型是接口类型.主要功能有 父协程控制所有的子协程   Context可以通过context.

  • 详解Golang中Context的原理和使用技巧

    目录 Context 背景 和 适用场景 Context 的背景 Context 的功能和目的 Context 的基本使用 Context 的同步控制设计 Context 的定义和实现 Context interface 接口定义 parent Context 的具体实现 Context 的继承和各种 With 系列函数 Context 的常用方法实例 1. 调用 Context Done方法取消 2. 通过 context.WithValue 来传值 3. 超时取消 context.WithT

  • 一文详解Golang中net/http包的实现原理

    目录 前言 http包执行流程 http包源码分析 端口监听 请求解析 路由分配 响应处理 前言 Go语言自带的net/http包提供了HTTP客户端和服务端的实现,实现一个简单的http服务非常容易,其自带了一些列结构和方法来帮助开发者简化HTTP服务开发的相关流程,因此我们不需要依赖任何第三方组件就能构建并启动一个高并发的HTTP服务器,net/http包在编写web应用中有很重要的作用,这篇文章会学习如何用 net/http 自己编写实现一个 HTTP Server 并探究其实现原理,具体

  • 详解Golang中字符串的使用

    目录 1.字符串编码 2.字符串遍历 3.字符串中的字符数 4.字符串trim 5.字符串连接 6.字节切片转字符串 1.字符串编码 在go中rune是一个unicode编码点. 我们都知道UTF-8将字符编码为1-4个字节,比如我们常用的汉字,UTF-8编码为3个字节.所以rune也是int32的别名. type rune = int32 当我们打印一个英文字符hello的时候,我们可以得到s的长度为5,因为英文字母代表1个字节: package main import "fmt"

  • 详解JSP 中Spring工作原理及其作用

    详解JSP 中Spring工作原理及其作用 1.springmvc请所有的请求都提交给DispatcherServlet,它会委托应用系统的其他模块负责负责对请求进行真正的处理工作. 2.DispatcherServlet查询一个或多个HandlerMapping,找到处理请求的Controller. 3.DispatcherServlet请请求提交到目标Controller 4.Controller进行业务逻辑处理后,会返回一个ModelAndView 5.Dispathcher查询一个或多个

  • 详解Vue中的MVVM原理和实现方法

    下面由我阿巴阿巴的详细走一遍Vue中MVVM原理的实现,这篇文章大家可以学习到: 1.Vue数据双向绑定核心代码模块以及实现原理 2.订阅者-发布者模式是如何做到让数据驱动视图.视图驱动数据再驱动视图 3.如何对元素节点上的指令进行解析并且关联订阅者实现视图更新 一.思路整理 实现的流程图: 我们要实现一个类MVVM简单版本的Vue框架,就需要实现一下几点: 1.实现一个数据监听Observer,对数据对象的所有属性进行监听,数据发生变化可以获取到最新值通知订阅者. 2.实现一个解析器Compi

  • 详解Golang中的各种时间操作

    需求 时间格式的转换比较麻烦,自己写了个工具,可以通过工具中的这些方法相互调用转成自己想要的格式,代码如下,后续有新的函数再添加 实现代码 package utils import "time" const ( TIMEFORMAT = "20060102150405" NORMALTIMEFORMAT = "2006-01-02 15:04:05" ) // 当前时间 func GetTime() time.Time{ return time.

  • 详解Golang中Channel的用法

    如果说goroutine是Go语言程序的并发体的话,那么channels则是它们之间的通信机制.一个channel是一个通信机制,它可以让一个goroutine通过它给另一个goroutine发送值信息. 1 创建channel 每个channel都有一个特殊的类型,也就是channels可发送数据的类型.一个可以发送int类型数据 的channel一般写为chan int.使用内置的make函数,如果第二个参数大于0,则表示创建一个带缓存的channel. ch := make(chan in

  • 详解golang中的method

    什么是method(方法)?method是函数的另外一种形态,隶属于某个类型的方法. method的语法: func (r Receiver) funcName (parameters) (result) receiver可以看作是method的第一个参数,method并且支持继承和重写. Go中虽没有class,但依旧有method 通过显示说明receiver来实现与某个类型的结合 只能为同一个包中的类型定义方法 receiver可以是类型的值或者指针 不存在方法重载 可以使用值或指针来调用

  • 一文详解Python中生成器的原理与使用

    目录 什么是生成器 迭代器和生成器的区别 创建方式 生成器表达式 基本语法 生成器函数 yield关键字 yield和return yield的使用方法 生成器函数的基本使用 send的使用 可迭代对象的优化 总结 我们学习完推导式之后发现,推导式就是在容器中使用一个for循环而已,为什么没有元组推导式? 原因就是“元组推导式”的名字不是这样的,而是叫做生成器表达式. 什么是生成器 生成器表达式本质上就是一个迭代器,是定义迭代器的一种方式,是允许自定义逻辑的迭代器.生成器使用generator表

  • 详解Python中递归函数的原理与使用

    目录 什么是递归函数 递归函数的条件 定义一个简单的递归函数 代码解析 内存栈区堆区 死递归 尾递归 实例 什么是递归函数 如果一个函数,可以自己调用自己,那么这个函数就是一个递归函数. 递归,递就是去,归就是回,递归就是一去一回的过程. 递归函数的条件 一般来说,递归需要边界条件,整个递归的结构中要有递归前进段和递归返回段.当边界条件不满足,递归前进,反之递归返回.就是说递归函数一定需要有边界条件来控制递归函数的前进和返回. 定义一个简单的递归函数 # 定义一个函数 def recursion

随机推荐