【Golang】源码学习:contex包——从功能到源码理解Go Context机制(一)

一、设计需求

GoLang通过开启多个Goruntine来实现并发,典型的是在网络编程中,无论是RPC调用还是web服务,当Sever端收到一个网络请求request后,都将开启一个对应的Goruntine进行处理,由于服务需求的不同,有可能需要多个不同的Goroutine,出于并发效率和异步的考虑,这些Goroutine又有可能开启多个子routine进行工作,因此往往一个Request请求都将在Server端开启一个routine树,这时,对于routine树的管理工作变得至关重要:

  • 多个Goroutine之间需要安全地共享Request中包含的请求元数据
  • 父routine对于子routine应该有充分的控制权,可以进行统一的消息传递和关闭操作
  • 如果对应的request被取消或者timeout,就需要所有为这个request服务的gorountine被快速回收。

上述工作需要设计并发控制来完成,在GoLang中,能够实现并发控制的主要方法有:

1、开启专用的通道进行消息传递(chan 通知)

package main

import (
	"fmt"
	"time"
)

func main(){
	ch1:=make(chan bool)
	ch2:=make(chan bool)
	go Worker1(ch1)
	go Worker2(ch2)
	time.Sleep(3*1e9)
	ch1<-true
	time.Sleep(3*1e9)
	ch2<-true
}

func Worker1(ch chan bool){
	for ;;{
		select{
		case <-ch:{
			fmt.Println("Worker1: I was cancled by parent goruntinue")
			return
		}
		default:
			time.Sleep(1e9)
			fmt.Println("Worker1: I am doing my work")
		}
	}
}
func Worker2(ch chan bool){
	for ;;{
		select{
		case <-ch:{
			fmt.Println("Worker2: I was cancled by parent goruntinue")
			return
		}
		default:
			time.Sleep(1e9)
			fmt.Println("Worker2: I am doing my work")
		}
	}
}

可以看到,父routine(main)为子routine(worker1\worker2)分配了专用的通道(ch1\ch2)专用于并发控制,在子routine中,使用select语句判断父线程是否通过通道发送了关闭通知,否则将继续进行自己的工作,而父routine通过向chan发送一个值关闭子routine,这种方式虽然有效实现了控制操作,但存在很多问题:

  • 通过向某chan发送一值的方式关闭子routine,逻辑上不够直观,造成代码晦涩难懂。
  • 对于一个父routine开启多个子routine的情况,子routine可能需要同步或异步的进行回收,需要维护多个chan值。
  • 对于复杂的嵌套开启routine的场合,这种方法显得更加捉襟见肘,比如A开启B,向B设置了一个控制用通道ch1,B开启C,此时如果B想对C进行控制,需要建立一个新的通道ch2,但若只将ch2交给C,A将失去对C的控制,势必需要添加额外的逻辑段,可以想象,对于一个复杂的routine树,这种方式将变得复杂和晦涩。

而通过chan进行消息共享往往也是行不通的:

package main

import (
	"fmt"
	"time"
)

func main(){
	ch:=make(chan int)
	go func()(){
		for ;;{
			fmt.Println("Goroutine1:",<-ch)
		}
	}()
	go func()(){
		for ;;{
			fmt.Println("Goroutine2:",<-ch)
		}
	}()
	ch<-1
	ch<-1
	time.Sleep(5*1e9)
}

在以上的代码中,main同时开启两个routine工作,通同一个通道ch尝试向两个通道发送消息,运行可以看到:

  • 向同一个chan发送的消息,每次仅有一个routine可以收到该消息。
  • 由于Goroutine运行时的不确定性,每次发送我们都将无法确定是哪个routine得到了该消息。

虽然可以用更复杂的逻辑解决这些问题,但大大增加了编码的工作量,单单使用通道无法胜任复杂的并发控制工作。

2、使用sync包进行的并发控制

package main

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

type request struct{
	message string
	m sync.Mutex
}
func main(){
	req:=new(request)
	req.message="helloworld"

	go func(req *request)(){
		req.m.Lock()
		fmt.Println(req.message)
		req.m.Unlock()
	}(req)
	go func(req *request)(){
		req.m.Lock()
		req.message="It's changed!"
		req.m.Unlock()
	}(req)
	time.Sleep(3*1e9)
}

上述代码通过sync包提供的Mutex互斥锁操作,实现了多routine之间安全的共享同一消息,但当工作协程数量很大,任务量也很多时,处理效率将会因为频繁的加锁/解锁开销而降低,不适合直接用于在复杂的routine树中实现消息共享。

于此同时,sync包提供了WaitGroup机制用于并发控制,但只适用于多个子协程平级并发处理一个任务的情况:

package main

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

func main(){
	var wg sync.WaitGroup
	wg.Add(2)
	go func()(){
		time.Sleep(2*1e9)
		fmt.Println("Routine1 is done")
		wg.Done()
	}()
	go func()(){
		time.Sleep(2*1e9)
		fmt.Println("Routine2 is done")
		wg.Done()
	}()
	wg.Wait()
	fmt.Println("It's all over")
}

综上两种方法可以看到,虽然通道机制和sync包在一定程度上都完成了多routine间消息共享和并发控制的目的,但都是很底层的实现,无法满足对于复杂routine树进行管理的需求。

context包正是基于通道机制和sync包进行设计,简化了在多个Goroutine传递上下文数据、中止routine树的等操作,使开发者能够更容易地完成并发控制。通过context的译名”上下文“,可以更好地理解这个概念,上下文,即"当前程序的执行状态",Context包通过构建树型关系的Context,来达到上一层Goroutine能对传递给下一层Goroutine的控制,一个Goroutine从其父进程中获得父进程给与的上下文状态信息,对这个上下文添加上自己的状态,交付给自己的子routine,最终形成了一棵context树,在上下文中包含的状态主要有:控制信息(运行剩余时间、截至日期、结束状态)、共享消息。

本部分分析了Context包设计的目标需求,列举了使用通道机制和sync包完成并发控制的方式和不足,总结了Context包的设计使命,下一部分将对Context包的结构和使用方法进行分析。

二、Context包和Context的使用

关于Context包的介绍,参照:

Go1.7(当前是RC2版本)已将原来的golang.org/x/net/context包挪入了标准库中,放在$GOROOT/src/context下面。标准库中netnet/httpos/exec都用到了context。同时为了考虑兼容,在原golang.org/x/net/context包下存在两个文件,go17.go是调用标准库的context包,而pre_go17.go则是之前的默认实现,其介绍请参考go程序包源码解读。

Context包是一个接口设计的范例,在对整个包功能进行高度提炼之后,最终只对外暴露了一个接口:

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}
  • Deadline():获得这个Context的设定结束时间,对于没有被设置结束时间的Context,ok将被返回为false
  • Done():返回一个chan类型的通道,用于判断该Context是否已经被结束了。
  • Err():当本Context被结束时,该函数将返回结束原因,没有被结束时,该函数返回nil。
  • Value():通过Key得到该Context中存储的Value值,因为key和value接收类型均为空接口,因此可以使用任意可判等的类型。

具体使用方法为:

1、在Gorountine树的根部,创捷一个根Context(空context)

使用context.Backgroud()函数创建一个初始的context值,正如这个函数名一样,可以理解为以当前程序为上下文(背景)建立一个根context,该context没有deadline,没有value,没有err,不可被取消,被用来作为一个初始值,等待程序附加更多的上下文控制信息。

	ctx:=context.Background()
	fmt.Println(ctx.Deadline())//0001-01-01 00:00:00 +0000 UTC false
	fmt.Println(ctx.Err())//nil

在实际使用中,不需要命名一个空context对象,而是将context.Backgroud()作为函数参数,如下面步骤所示。

2、在routine树的衍生过程中,根据需求对根Context添加更多的控制信息

由如下四个函数完成:

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

可以看到,上述四个函数均接收一个Context对象,返回一个Context对象,得益于Context包的高度封装,我们不需要关注其内部是否涉及到多种Context的派生类型(实际上是的),可以把这四个函数一视同仁的看作:作为包装函数,为一个父context节点附加更多的控制信息,得到该Context的子节点,在任意一层Goroutine程序中,均可使用这四个函数生成子节点,再向下传递,为routine树生成一个对应的context树,从而实现Goroutine树实现消息共享和控制。

这四个函数的具体使用实例如下:

1)WithCancel

该函数将父节点复制到子节点,返回一个额外的CancelFunc函数类型变量,这一操作使Context变为可主动取消的,一旦调用生成的CancelFunc函数,该Context和其衍生的所有子孙Context都将被取消,context.Done()函数返回的通道将被闭,context.err()包含了context被取消的原因,实例如下:

package main

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

func main(){
	ctxWithCancel,cancelFunc:=context.WithCancel(context.Background())
	go Worker1(ctxWithCancel,"hello")
	go Worker2(ctxWithCancel,"hello")
	time.Sleep(3*1e9)
	cancelFunc()  //调用函数取消ctxWithCancel

	time.Sleep(3*1e9)
}


func Worker1(ctx context.Context,message string){
	for ;;{
		select{
		case <-ctx.Done():{
			fmt.Println("worker1 is cancled")
			fmt.Println(ctx.Err())   //context canceled
			return
		}
		default:
			time.Sleep(2*1e9)
			fmt.Println("worker1: ",message)
		}
	}
}
func Worker2(ctx context.Context,message string){
	for ;;{
		select{
		case <-ctx.Done():{
			fmt.Println("worker2 is cancled")
			fmt.Println(ctx.Err())   //context canceled
			return
		}
		default:
			time.Sleep(2*1e9)
			fmt.Println("worker2: ",message)
		}
	}
}

子Goroutine判断当前父传来的context是否是被取消的,做出对应的操作(最常见的退出routine,当触发某些条件时,可以调用CancelFunc对象来终止子结点树的所有routine。可以看到,对于被主动取消的context,Err()将简单的返回"context canceled"消息。

需要注意的是,context树的取消规则为:使用withCaneel函数生成的canelFunc,可以主动地取消其所有子孙context,在其后代中再次调用withCancel函数,这个性质不会被影响,将形成一个树形管理体系,如下图:

【Golang】源码学习:contex包——从功能到源码理解Go Context机制(一)_第1张图片

【Golang】源码学习:contex包——从功能到源码理解Go Context机制(一)_第2张图片

【Golang】源码学习:contex包——从功能到源码理解Go Context机制(一)_第3张图片

2)WIthDeadline

该函数同样父节点复制到子节点,返回一个额外的CancelFunc函数类型变量,不同的是,该函数将指示context存活的最长时间。如果超过了过期时间,会自动撤销它的子context。所以context的生命期是由父context的routine和deadline共同决定的。如果父context同样具备一个deadline,那么实际的deadline将是两者的最小值,使用如下:

package main

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

func main(){
	ctxWithDeadline,_:=context.WithDeadline(context.Background(),time.Now().Add(2*1e9))
	go Worker1(ctxWithDeadline,"hello")
	go Worker2(ctxWithDeadline,"hello")
	time.Sleep(3*1e9)
	fmt.Println()
}


func Worker1(ctx context.Context,message string){
	for ;;{
		select{
		case <-ctx.Done():{
			fmt.Println("worker1 is cancled")
			fmt.Println(ctx.Err())   //context canceled
			return
		}
		default:
			time.Sleep(2*1e9)
			fmt.Println("worker1: ",message)
		}
	}
}
func Worker2(ctx context.Context,message string){
	for ;;{
		select{
		case <-ctx.Done():{
			fmt.Println("worker2 is cancled")
			fmt.Println(ctx.Err())   //context canceled
			return
		}
		default:
			time.Sleep(2*1e9)
			fmt.Println("worker2: ",message)
		}
	}
}

当到达设置的deadline后,该context和其子孙context都将主动的被取消。

3)WithTimeout

该函数的功能与WithDeadline基本相同,都使子context具备了一个自动取消时间,只不过接收的不是一个结束时间点,而是从当前函数调用开始,在取消之前的运行时间,因此接收的time.Duration类型,实际上,在底层实现上该函数就是直接调用了WithDeadline函数。

4)WIthValue

该函数使得生成子Context具备包含了一组key-value信息,该信息可以安全的被多个子Goroutine同步访问,虽然一个WithValue只能添加一组key-value信息,但其后续的子孙context再次使用WithValue添加的消息可以都共存,都可以通过函数value(key)查到,但如果存在相同key值的情况,老的k-v值将被覆盖(因为在底层实现上,父context将被包含在子context中)。

在源码中,设计者指出,为了避免与外部其他类型冲突,key的类型应该被定义成一个非外部的(开头字母小写)的类型。

使用实例如下:

package main

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

type user struct{
	name string
}
type key int

func main(){
	u:=&user{"helloworld"}
	var k key=1
	ctx:=context.WithValue(context.Background(),k,u)
	go middleContext(ctx)
	time.Sleep(9*1e9)
}
func middleContext(ctx context.Context){
	u:=&user{"withValueTest"}
	var k key=2
	newCtx:=context.WithValue(ctx,k,u)
	go terminalContext(newCtx)
}
func terminalContext(ctx context.Context){
	fmt.Println(ctx.Value(key(1)).(*user).name)//helloworld
	fmt.Println(ctx.Value(key(2)).(*user).name)//withValueTest
}

WithValue并没有为context附加被主动取消的权力,得益于Context包良好的设计,我们可以灵活的使用这四个函数,达到根据需求对上下文状态的修改:

	ctx,cancelFunc:=context.WithCancel(context.WithValue(context.Background(),k,u))

综上,结合源码作者注释的使用规则,总结Context包的使用方法如下:

【Golang】源码学习:contex包——从功能到源码理解Go Context机制(一)_第4张图片

第二部分链接:https://blog.csdn.net/qq_38093301/article/details/104378837

你可能感兴趣的:(Golang)