go之Context基本使用

Context 入门

概要

  • 什么是Context
  • 为什么要使用context
  • Context基本的使用
  • Context接口实现
  • 使用场景
  • 小结

1. 什么是Context

  • 它用一句话来说就是: 控制goroutine的生命周期, 在 Goroutine 构成的树形结构中对信号进行同步以减少计算资源的浪费。

  • Context 通常被称之为上下文, 我们可以理解为: 一般理解为goroutine的一个运行状态、现场、快照。


1.1 为什么要使用context

在并发程序中,由于超时、取消操作或者一些异常情况,往往需要进行抢占操作或者中断后续操作。

熟悉channel的朋友应该都见过使用done channel来处理此类问题。

比如以下这个例子,一旦主协程关闭done channel,那么子协程就可以退出了,这样就实现了主协程通知子协程的需求。

func main() {
    // 数据通道
    messages := make(chan int, 10)
    // 信号通道
    done := make(chan bool)

    defer close(messages)
    // consumer
    go func() {
        // 每隔一秒执行一次,定时器
        ticker := time.NewTicker(1 * time.Second)
        for _ = range ticker.C {
            select {
            // 若关闭了通道则 执行下面的代码
            case <-done:
                fmt.Println("child process interrupt...")
                return
            default:
                fmt.Printf("send message: %d\n", <-messages)
            }
        }
    }()

    // producer
    for i := 0; i < 10; i++ {
        messages <- i
    }
    time.Sleep(5 * time.Second)
    // 关闭通道, 退出上面的匿名函数
    close(done)
    time.Sleep(1 * time.Second)
    fmt.Println("main process exit!")
}

如果我们可以在简单的通知上附加传递额外的信息来控制取消。

考虑下面这种情况:假如主协程中有多个任务1, 2, …m,主协程对这些任务有超时控制。

而其中任务1又有多个子任务1, 2, …n,任务1对这些子任务也有自己的超时控制,那么这些子任务既要感知主协程的取消信号,也需要感知任务1的取消信号。

如果还是使用done channel的用法 ,那么使用done channel的方式将会变得非常繁琐且混乱

我们需要一种优雅的方案来实现这样一种机制:

  • 上层任务取消后,所有的下层任务都会被取消;
  • 中间某一层的任务取消后,只会将当前任务的下层任务取消,而不会影响上层的任务以及同级任务。

这个时候context就派上用场了。


2. context的使用

2.1 创建context两种方式

注意: 这两种方式是创建根context,不具备任何功能,需要用到with系列函数来实现具体功能

  • context.Backgroud()
  • context.TODO()

2.2 两种方式比较

  • context.Background 是上下文的默认值,所有其他的上下文都应该从它衍生(Derived)出来, ,一般情况下我们用这个
  • context.TODO 应该只在不确定应该使用哪种上下文时使用;

2.3 With系列函数

context具体的用法 通过下面的几个具体demo来演示一下

2.3.1 context.withCancel() 取消控制

日常业务开发中我们往往为了完成一个复杂的需求会开多个gouroutine去做一些事情,这就导致我们会在一次请求中开了多个goroutine确无法控制他们,这时我们就可以使用withCancel来衍生一个context传递到不同的goroutine中,当我想让这些goroutine停止运行,就可以调用cancel来进行取消。

// 案例一

/*  
	代码逻辑:
    我们使用withCancel创建一个基于Background的ctx,然后启动一个讲话程序,
    每隔1s说一话,main函数在10s后执行cancel,那么speak检测到取消信号就会退出。
*/

package main

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

// context.Background()函数创建根上下文,返回父context和cancel函数
func NewWithCancel() (context.Context, context.CancelFunc) {
	return context.WithCancel(context.Background())

}

//  业务逻辑
func Speak(ctx context.Context) {
	for range time.Tick(time.Second) {
		select {
		case <-ctx.Done():
			fmt.Println("关闭线程...")
			fmt.Println(ctx.Err())
		default:
			fmt.Println("hahahhaa")
		}
	}
}

func main() {
        // 创建父context和cancel函数
	ctx, cancel := NewWithCancel()

        // 使用协程来启动业务逻辑
	go Speak(ctx)

	time.Sleep(10 * time.Second)

        // 取消的信号,结束Speak函数的运行
	cancel()

	time.Sleep(1 * time.Second)

}

// 案例二

/*
代码逻辑: 
1. 利用根Context创建一个父Context,使用父Context创建一个协程,
2. 利用上面的父Context再创建一个子Context,使用该子Context创建一个协程
3. 一段时间后,调用父Context的cancel函数,会发现父Context的协程和子Context的协程都收到了信号,被结束了

*/
package main
 
import (
	"context"
	"fmt"
	"time"
)
 
func main() {
	// 父context(利用根context得到)
	ctx, cancel := context.WithCancel(context.Background())
 
	// 父context的子协程
	go watch1(ctx)
 
	// 子context,注意:这里虽然也返回了cancel的函数对象,但是未使用
	valueCtx, _ := context.WithCancel(ctx)
	// 子context的子协程
	go watch2(valueCtx)
 
	fmt.Println("现在开始等待3秒,time=", time.Now().Unix())
	time.Sleep(3 * time.Second)
 
	// 调用cancel()
	fmt.Println("等待3秒结束,调用cancel()函数")
	cancel()
 
	// 再等待5秒看输出,可以发现父context的子协程和子context的子协程都会被结束掉
	time.Sleep(5 * time.Second)
	fmt.Println("最终结束,time=", time.Now().Unix())
}
 
// 父context的协程
func watch1(ctx context.Context) {
	for {
		select {
		case <-ctx.Done(): //取出值即说明是结束信号
			fmt.Println("收到信号,父context的协程退出,time=", time.Now().Unix())
			return
		default:
			fmt.Println("父context的协程监控中,time=", time.Now().Unix())
			time.Sleep(1 * time.Second)
		}
	}
}
 
// 子context的协程
func watch2(ctx context.Context) {
	for {
		select {
		case <-ctx.Done(): //取出值即说明是结束信号
			fmt.Println("收到信号,子context的协程退出,time=", time.Now().Unix())
			return
		default:
			fmt.Println("子context的协程监控中,time=", time.Now().Unix())
			time.Sleep(1 * time.Second)
		}
	}
}

2.3.2 context.WithTimeout 超时控制

通常健壮的程序都是要设置超时时间的,避免因为服务端长时间响应消耗资源,所以一些web框架或rpc框架都会采用withTimeout或者withDeadline来做超时控制,当一次请求到达我们设置的超时时间,就会及时取消,不在往下执行。withTimeoutwithDeadline作用是一样的,就是传递的时间参数不同而已,他们都会通过传入的时间来自动取消Context,这里要注意的是他们都会返回一个cancelFunc方法,通过调用这个方法可以达到提前进行取消,不过在使用的过程还是建议在自动取消后也调用cancelFunc去停止定时减少不必要的资源浪费。

withTimeout WithDeadline不同在于WithTimeout将持续时间作为参数输入而不是时间对象,这两个方法使用哪个都是一样的,看业务场景和个人习惯了,因为本质withTimout内部也是调用的`WithDeadline

// 案例一

package main

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

// 创建一个带超时context, 三秒后退出执行
func NewContextWithTimeout() (context.Context, context.CancelFunc) {
	return context.WithTimeout(context.Background(), 3*time.Second)
}

// 处理程序
func HttpHandler() {
	ctx, cancel := NewContextWithTimeout()
	defer cancel()
	deal(ctx)

}

// 业务逻辑代码
func deal(ctx context.Context) {
	for i := 0; i < 10; i++ {
		time.Sleep(1 * time.Second)
		select {
		case <-ctx.Done():
			fmt.Println(ctx.Err())
			return
		default:
			fmt.Printf("deal time is %d\n", i)
		}
	}
}
func main() {
	HttpHandler()
}
// 案例二

package main
 
import (
	"context"
	"fmt"
	"time"
)
 
func main() {
	// 创建一个子节点的context,3秒后自动超时
	ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
 
	go watch(ctx, "监控1")
	go watch(ctx, "监控2")
 
	fmt.Println("现在开始等待8秒,time=", time.Now().Unix())
	time.Sleep(8 * time.Second)
 
	fmt.Println("等待8秒结束,准备调用cancel()函数,发现两个子协程已经结束了,time=", time.Now().Unix())
	cancel()
}
 
// 单独的监控协程
func watch(ctx context.Context, name string) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println(name, "收到信号,监控退出,time=", time.Now().Unix())
			return
		default:
			fmt.Println(name, "goroutine监控中,time=", time.Now().Unix())
			time.Sleep(1 * time.Second)
		}
	}
}

2.3.3 context.WithValue() 携带数据[谨慎使用]

我们日常在业务开发中都希望能有一个trace_id能串联所有的日志,这就需要我们打印日志时能够获取到这个trace_id,在python中我们可以用gevent.local来传递,在java中我们可以用ThreadLocal来传递,在Go语言中我们就可以使用Context来传递,通过使用WithValue来创建一个携带trace_idcontext,然后不断透传下去,打印日志时输出即可,来看使用例子

/*
我们基于context.Background创建一个携带trace_id的ctx,然后通过context树一起传递,
从中派生的任何context都会获取此值,我们最后打印日志的时候就可以从ctx中取值输出到日志中。
目前一些RPC框架都是支持了Context,所以trace_id的向下传递就更方便了
*/
package main

import (
	"context"
	"fmt"
	"strings"
	"time"

	"github.com/google/uuid"
)

type MyKEY string

const (
	KEY MyKEY = "trace_id"
)



// 返回一个UUID
func NewRequestID1() MyKEY {
	return MyKEY(strings.Replace(uuid.New().String(), "-", "", -1))

}

// 创建一个携带trace_id 的ctx
func NewContextWithTraceID() context.Context {
	ctx := context.WithValue(context.Background(), KEY, NewRequestID1())
	return ctx
}

// 打印值
func PrintLog(ctx context.Context, message string) {
	fmt.Printf("%s|info|trace_id=%s|%s", time.Now().Format("2006-01-02 15:04:05"), GetContextValue1(ctx, KEY), message)
}

// 获取设置的key对应的值,并断言
func GetContextValue1(ctx context.Context, k MyKEY) MyKEY {
	v, ok := ctx.Value(k).(MyKEY)
	fmt.Println("打印k:" + k)
	fmt.Printf("打印v: %v\n", v)
	if !ok {
		return ""
	}
	return v
}

func ProcessEnter(ctx context.Context) {
	PrintLog(ctx, "Golang")
}

func main() {
	ProcessEnter(NewContextWithTraceID())
}

几点建议:

  • 不建议使用context值传递关键参数,关键参数应该显示的声明出来
  • 因为携带value也是key value,避免context多个包使用带来的冲突,建议使用内置类型
  • context传递的数据key value都是interface, 所以类型断言时别忘了保证程序的健壮性

3. context的结构设计

type Context interface{
    // 返回 context.Context 被取消的时间,也就是完成工作的截止日期;如果没有设定期限,将返回ok == false
    Deadline()(deadline time.Time, ok bool)
    
    // 当绑定当前context的任务被取消时,将返回一个关闭的channel;如果当前context不会被取消,将返回nil
    Done() <-chan struct{}
    
    // 当Context被取消或者关闭后,返回context取消的原因
    // 如果Done返回的channel没有关闭,将返回nil;如果Done返回的channel已经关闭,将返回非空的值表示任务结束的原因
    // 如果是context被取消,Err将返回Canceled;如果是context超时,Err将返回DeadlineExceeded
    Err() error
    
    // 获取设置的key对应的值
    // 同时用于获取特定于当前任务树的额外信息
    Value(key interface{}) interface{}
}

4. 应用场景

  • RPC调用

  • PipeLine

  • 超时请求

  • HTTP服务器的request互相传递数据


5. 小结

Go 语言中的 context.Context 的主要作用还是在多个 Goroutine 组成的树中同步取消信号以减少对资源的消耗和占用,虽然它也有传值的功能,但是这个功能我们还是很少用到。

在真正使用传值的功能时我们也应该非常谨慎,使用context.Context传递请求的所有参数一种非常差的设计,比较常见的使用场景是传递请求对应用户的认证令牌以及用于进行分布式追踪的请求 ID


6. 参考链接

posted @ 2022-04-08 23:09  レモン  阅读(337)  评论(0编辑  收藏  举报
Title