手把手教你搞定golang的context库
  zlFBY6LVdunm 2023年12月23日 68 0
一 Context的设计目的

context库的设计目的就是跟踪goroutine调用树,并在这些goroutine调用树中传递通知和元数据。有两个目的

  1. 提出通知机制:通知可以传递给整个goroutine调用树上的每一个goroutine
  2. 传递数据: 数据可以传递给整个goroutine调用树上的每一个goroutine
二 Context基本数据结构
Context工作机制
  1. 第一个创建 Context 的 goroutine, 被称为 root 节点。
  2. root 节点负责创建一个实现 Context 接口的具体对象, 并将该对象作为参数传递到其新拉起的goroutine ,
  3. 下游的 goroutine 可以继续封装该对象,再传递到更下游的goroutine。Context 对象在传递的 过程中最终形成一个树状的数据结构
  4. 这样通过位于 root 节点的 Context 对象就能遍历整个 Context 对象树,通知和消息就可以通过 root 节点传递出去,实现了上游 goroutine 对下游goroutine 的消息传递。
Context接口

Context是一个接口类型,所有Context对象都要实现该接口。

type Context interface {
	Deadline() (dealine time.Time, ok bool)
	Done() <- chan struct{}
	Err() error
	Value (key interface{}) interface{}
}
  1. Deadline(): 如果context实现了超时控制,那么Deadline()方法返回的ok为true
  2. Done(): 被调的goroutine应该监听该方法返回的chan,以便及时释放资源。
  3. Err(): Done()返回的chan收到通知的时候,才可以访问Err()获取取消的原因
  4. Value: 可以访问上游传递给下游goroutine的值。
canceler接口
type canceler interface {
	cancel(removeFromParent bool , err error)
	Done() <-chan struct{}
}
  1. canceler接口是一个扩展接口。它的规定了这些接口:取消通知的接口。
  2. context包中有两个具体类型cancelCtx和timerCtx,它们都实现了这个接口。
  3. 如果一个context对象实现了canceler接口,则可以被取消。
  4. cancel: 调用cancel方法来通知后续创建的goroutine退出
  5. Done(): 方法返回通道chan, 需要后端的goroutine监听,并及时退出。
empty Context结构
type emptyCtx int
func (* emptyCtx) Deadline() (deadline time.Time, ok bool) {
	return
}

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

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

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

func (e *emptyCtx) String() string {
	switch e {
    case background :
    	return "context.Background"
    case todo:
    	return "context.TODO"
    }
    return "unknown empty Context"
}
  1. emptyCtx 实现了 Context 接口,但不具备任何功能,因为其所有的方法都是空实现
  2. 其存在的目的是作为 Context 对象树的根(root 节点)
  3. context包的使用思路就是不停地调用context 包提供的包装函数来创建具有特殊功能的 Context 实例 ,每一个Context实例的创建都以上一个 Context 对象为参数,最终形成一个树状的结构 。
  4. package 定义了两个全局变量和两个封装函数,返回两个 emptyCtx 实例对象,实际使用时通过调用这两个封装函数来构造 Context 的 root 节点。
var (
	background= new(emptyCtx)
	todo = new(emptyCtx)
)

func Background() Context {
	return background
}

func TODO() Context {
	return todo
}
cancelCtx
  1. cancelCtx是一个实现了 Context 接口的具体类型,同时实现了 conceler 接口。
  2. canceler 具有退出通知方法。注意退出通知机制不但能通知自己,也能逐层通知其 children 节点
timerCtx

timerCtx 是一个实现了Context接口的具体类型,内部封装了cancelCtx类型实例,同时有一个deadline变量,用来实现定时退出通知

valueCtx
type valueCtx struct {
	Context
	key, val interface{}
}

func (c * valueCtx) String() string {
	return fmt.Sprintf("%v.WithValue(%#v, %#v)", c.Context, c.key, c.val)
}

func (c * valueCtx) Value(key interface{}) interface{} {
	if c.key == key {
		return c.val
	}
	return c.Context.Value(key)
}

valueCtx 是一个实现了 Context 接口的具体类型,内部封装了 Context 接口类型,同时封装了一个k/v的存储变量。 valueCtx可用来传递通知信息

三 Context API函数

下面这两个函数是构造 Context 树的根节点对象,根节点对象用作后续 With 包装函数的实参。

func Background() Context
func TODO() Context

With 包装函数用来构建不同功能的 Context 具体对象

创建一个带有退出通知的 Context 具体对象,context包内部创建一个cancelCtx 的类型实例。

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

创建一个带有超时通知的 Context 具体对象,context包内部创建一个timerCtx的类型实例

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

创建一个带有超时通知的 Context 具体对象,内部创建一个timerCtx的类型实例

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

创建一个能够传递数据的 Context 具体对象,内部创建一个valueCtx 的类型实例

func WithValue(parent Context, key,val interface{}) Context

这些函数都有一个共同的特点:parent参数,其实这就是实现 Context 通知树的必备条件。
在goroutine 的调用链中,Context的实例被逐层地包装并传递 ,每层又可以对传进来的 Context实例再封装自己所需的功能,整个调用树需要一个数据结构来维护,这个维护逻辑在这些包装 函数内部实现。

四 Context辅助函数
  1. 前面描述的With开头的构造函数是给外部程序使用的 API 接口函数。Context 具体对象的链条关系是在With函数的内部维护的。现在分析一下With函数内部使用的通用函数 。
  2. propagateCancel
func propagateCancel(parent Context, child canceler)
  • propagate:传播
  • 判断 parent 的方法 Done()返回值是否是 nil ,如果是,则说明parent不是一个可取消的 Context 对象,也就无所谓取消构造树,说明child 就是取消构造树的根 。
  • 如果parent 的方法 Done()返回值不是nil,则向上回溯自己的祖先是否是 cancelCtx 类型实例,如果是,则将child的子节点注册维护到那棵关系树里面。
  • 如果向上回溯自己的祖先都不是 cancelCtx 类型实例,则说明整个链条的取消树是不连续的。此时只需监听 parent 和自己的取消信号即可 。

parentCancelCtx

func parentCancelCtx(parent Context )(* cancelCtx , bool)

判断parent中是否封装*cancelCtx的字段,或者接口里面存放的底层类型是否是 * cancelCtx 类型

removeChild

func removeChild(parent Context, child canceler)

则将其构造树上的child节点删除, 前提是parent封装 * cancelCtx类型字段,或者接口里面存放的底层类型是cancelCtx类型 ,

五 context的用法
context使用示例
  1. 使用context.Background()构建WithCancel类型的上下文。
  2. 使用WithDeadline包装前面的上下文ctx1
  3. 使用WithValue包装前面的上下文对象ctx2
  4. work程序在监视退出的通知,同时在do something
package main

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

type MyContext struct {
	context.Context
}

func work(ctx context.Context, name string) {
	for {
		select {
		case <- ctx.Done():
			fmt.Printf("%d: [%s] get msg to cancel: %s\n", time.Now().Unix(), name, ctx.Err())
			return
		default:
			fmt.Printf("%d: [%s] is running\n", time.Now().Unix(), name)
			time.Sleep(time.Second)
		}
	}
}

func workWithValue(ctx context.Context, name string) {
	for {
		select {
		case <- ctx.Done():
			fmt.Printf("%d: [%s] get msg to cancel: %s\n", time.Now().Unix(), name, ctx.Err())
			return
		default:
			value := ctx.Value("key").(string)
			fmt.Printf("%d: [%s] is running, value:%s\n", time.Now().Unix(), name, value)
			time.Sleep(time.Second)
		}
	}
}
func main() {
	ctxa,cancel := context.WithCancel(context.Background())
	go work(ctxa, "work1-cancel")

	tm := time.Now().Add(time.Second * 3)
	ctxb,_ := context.WithDeadline(ctxa, tm)
	go work(ctxb, "work2-deadline")

	myContext := MyContext{ctxb}
	ctxc := context.WithValue(myContext, "key", "this is msg from main")
	go workWithValue(ctxc, "work3-value")
	
	time.Sleep(time.Second * 5)
	cancel()
	time.Sleep(time.Second * 5)
	fmt.Println("main over")
}
/*
1702455293: [work3-value] is running, value:this is msg from main
1702455293: [work2-deadline] is running
1702455293: [work1-cancel] is running
1702455294: [work2-deadline] is running
1702455294: [work1-cancel] is running
1702455294: [work3-value] is running, value:this is msg from main
1702455295: [work1-cancel] is running
1702455295: [work2-deadline] is running
1702455295: [work3-value] is running, value:this is msg from main
1702455296: [work1-cancel] is running
1702455296: [work3-value] get msg to cancel: context deadline exceeded
1702455296: [work2-deadline] get msg to cancel: context deadline exceeded
1702455297: [work1-cancel] is running
1702455298: [work1-cancel] get msg to cancel: context canceled
main over
context使用分析

在使用context的过程中,程序在底层实际上维护了两条关系链

关系链1
从根到叶子的引用关系,这个关系在调用With函数的时候进行维护。(也就是上面讲的propagateCancel函数来维护),程序有这样一层树状结构

ctxa.children ---> ctxb
ctxb.children ---> ctxc

ctxa收到退出通知,会通知绑定的work1,同时会广播给ctxb和ctxc绑定的work2,work3

关系链2
在构造context的对象中不断包裹Context实例形成了一个引用关系链。这个关系链的方向是相反的,是从底向上的。

ctxc.Context --> myContext
ctxc.Context.Context --> ctxb
ctxc.Context.Context.cancelCtx --> ctxa
ctxc.Context.Context.cancelCtx.Context --> new(emptyCtx} //context.Background()

这个关系链主要用来切断当前Context实例和上层的Context实例之间的关系,比如ctxb调用了退出通知或定时器到期了,ctxb后续就没有必要在通知广播树上继续存在,它需要找到自己的parent,然后执行delete(parent.children,ctxb), 把自己从广播树上清理掉。

我们通过上文梳理出使用context包的一般流程

  • step1: 创建一个context根对象
func Background () Context
func TODO () Context
  • 包装上一步创建的context对象,使其具有特定的功能
    这些包装函数是 context package 的核心,几乎所有的封装都是从包装函数开始的。原因很简单,使用 context 包的核心就是使用其退出 通知广播功能
func WithCancel
func WithTimeout
func WithDDeadline
func WithValue
  • 将上一步创建的对象作为实参传给后续启动的并发函数(通常作为函数的第一个参数),每个并发函数内部可以 继续使用包装函数对传进来的Context对象进行包装, 添加自己需要的功能。
  • 顶端的goroutine 在超时后调用cancel退出通知函数,通知后端的所有 goroutine释放资源。
  • 后端的goroutine 通过select监昕 Context.Done()返回的 chan, 及时响应前端 goroutine的退出通知,一般停止本次处理,释放所占用的资源 。
六 使用context传递数据的争议
  1. 该不该使用context传递数据
  • 首先要清楚使用 context 包主要是解决 goroutine 的通知退出,传递数据是其一个额外功能。
  • 可以使用它传递一些元信息 ,总之使用 context 传递的信息不能影响正常的业务流程,程序不要 期待在 context 中传递一些必需的参数等,没有这些参数,程序也应该能正常工作。
  1. 在context中传递数据的坏处
  • 传递的都是 interface{} 类型的值,编译器不能进行严格的类型校验。
  • 从 interface{} 到具体类型需要使用类型断言和接口查闹,有一定的运行期开销和性能损失 。
  • 值在传递过程中有可能被后续的服务覆盖,且不易被发现 。
  • 传递信息不简明,较晦涩;不能通过代码或文档一眼看到传递的是什么,不利于后续维护。
  1. context应该传递什么数据
  • 日志信息
  • 调试信息
  • 不影响业务主逻辑的可选数据。
  • 优点:线程的优点是通过共享内存进行通信更快捷,切换代价小
  • 缺点:多个线程共享内存空间,极易导致数据访问混乱,某个线程误操作内存挂掉可能危及整个线程组,健壮性不高。
  1. 用户级多线程模型
    用户级多线程又分为两种:M:1, M:N
  • M个用户线程对应一个内核进程,这种情况很容易因为一个系统阻塞,其他用户线程都会被阻塞,不能利用机器多核的优势。
  • M个用户线程对应 N 个内核线程,这种模式一般需要语言运行时或库的支持,效率最高。
  1. 程序并发处理的要求越来越高,但是不能无限制地增加系统线程数。
  • 线程数过多会导致操作系统的调度开销变大,单个线程的单位时间内被分配的运行时间片减少,单个线程的运行速度降低,单靠增加系统线程数不能满足要求 。
  • 为了不让系统线程无限膨胀,于是就有了协程的概念。
  • 协程是一种用户态的轻量级线程,协程的调度完全由用户态程序控制,协程拥有自己的寄存器上下文和栈 。
  • 每个内核线程可以对应多个用户协程,当一个协程执行体阻塞了,调度器会调度另一个协程执行,最大效率地利用操作系统分给系统线程的时间片
  • 前面提到的用户级多线程模型就是一种协程模型,尤其以 M:N 模型最为高效。
  1. 协程是一种非常高效、理想的执行模型。 Go 的并发执行模型就是一种变种的协程模型。

end

to be continue...

【版权声明】本文内容来自摩杜云社区用户原创、第三方投稿、转载,内容版权归原作者所有。本网站的目的在于传递更多信息,不拥有版权,亦不承担相应法律责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@moduyun.com

  1. 分享:
最后一次编辑于 2023年12月23日 0

暂无评论

推荐阅读
  BnLyeqm7Fyq6   2023年12月22日   60   0   0 封装Java封装Java