参考链接
- https://www.liwenzhou.com/posts/Go/context/
- https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-context/
- 走进Golang之Context的使用-大愚
- https://geektutu.com/post/quick-go-context.html#2-context-WithCancel
情节需求
有一个获取订单详情的请求,会单独起一个 goroutine 去处理该请求。在该请求内部又有三个分支 goroutine 分别处理订单详情、推荐商品、物流信息;每个分支可能又需要单独调用DB、Redis等存储组件。那么面对这个场景我们需要哪些额外的事情呢?
- 三个分支 goroutine 可能是对应的三个不同服务,我们想要携带一些基础信息过去,比如:LogID、UserID、IP等;
- 每个分支我们需要设置过期时间,如果某个超时不影响整个流程;
- 如果主 goroutine 发生错误,取消了请求,对应的三个分支应该也都取消,避免资源浪费;
简单归纳就是传值、同步信号(取消、超时)。
channel方式
其实可以用 channel 来搞啊!那么我们看看 channel 是否可以满足。
想一个问题,如果是微服务架构,channel 怎么实现跨进程的边界呢?另外一个问题,就算不跨进程,如果嵌套很多个分支,想一想这个消息传递的复杂度。
比如下面这个例子,大致流程:
- 首先一个 go 程去执行
getOrder
查询订单的信息- 然后这个go程
getOrder
的内部分别又开启两个 go 程getOrderDetails
&getTransportationInfo
去分别查询物流和订单详情信息- 内部所有go程之间的通信都需要channel去维护
- 当物流和订单详情分别查询完了,然后向上逐层通知
- 最后程序结束!
package main
import (
"fmt"
"sync"
"time"
)
var wg sync.WaitGroup
// 获取订单信息!
func getOrder(exitChan chan struct{}) {
fmt.Println("开始查询订单")
var exitChan2order = make(chan struct{})
var exitChan2Trans = make(chan struct{})
defer close(exitChan2order)
defer close(exitChan2Trans)
go getOrderDetails(exitChan2order)
go getTransportationInfo(exitChan2Trans)
<-exitChan2order
<-exitChan2Trans
exitChan <- struct{}{}
fmt.Println("查询订单结束")
wg.Done()
}
// getOrderDetails 获取订单详细信息!
func getOrderDetails(exitChan chan<- struct{}) {
fmt.Println("== 查询订单详细信息")
time.Sleep(time.Second * 1)
fmt.Println("== 查询订单详细信息结束")
exitChan <- struct{}{}
wg.Done()
}
// getTransportationInfo 获取物流详细信息
func getTransportationInfo(exitChan chan<- struct{}) {
fmt.Println("== 查询物流信息")
time.Sleep(time.Second * 1)
fmt.Println("== 查询物流信息结束")
exitChan <- struct{}{}
wg.Done()
}
func main() {
var exitChan = make(chan struct{})
wg.Add(3)
go getOrder(exitChan)
<-exitChan
close(exitChan)
wg.Wait()
fmt.Println("over")
}
执行!
可以看到上面单纯是通知流程顺利完成的情况下(不包括异常处理,数据传递,取消请求)等这些操作!
使用CHANEL去做就已经挺复杂的了!这就是树形多嵌套 goroutine 通信太复杂的问题!
( 一个 goroutine 里面又开启新的多个 goroutine,像树枝分叉一样!)
(这是以我目前水平举出的例子了!可能会有更好的处理方式哈哈哈!)
context
上面的处理,如果使用context就会很简单了!先学习一下context!
context.Context
是 Go 语言在 1.7 版本中引入标准库的接口1,该接口定义了四个需要实现的方法,其中包括:
-
Deadline
— 返回context.Context
被取消的时间,也就是完成工作的截止日期; -
Done
— 返回一个 Channel,这个 Channel 会在当前工作完成或者上下文被取消后关闭,多次调用Done
方法会返回同一个 Channel; -
Err
返回context.Context
结束的原因,它只会在Done
方法对应的Channel
关闭时返回非空的值;- 如果
context.Context
被取消,会返回Canceled
错误; - 如果
context.Context
超时,会返回DeadlineExceeded
错误;
- 如果
-
Value
— 从context.Context
中获取键对应的值,对于同一个上下文来说,多次调用Value
并传入相同的Key
会返回相同的结果,该方法可以用来传递请求特定的数据;
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
在 Goroutine 构成的树形结构中对信号进行同步以减少计算资源的浪费是
context.Context
的最大作用。
Go 服务的每一个请求都是通过单独的 Goroutine 处理的LINK,HTTP/RPC 请求的处理器会启动新的 Goroutine 访问数据库和其他服务。
我们可能会创建多个 Goroutine 来处理一次请求,而 context.Context
的作用是在不同 Goroutine
之间同步请求特定数据、取消信号以及处理请求的截止日期。
Context 与 Goroutine 树
每一个
context.Context
都会从最顶层的 Goroutine 一层一层传递到最下层。context.Context
可以在上层 Goroutine 执行出现错误时,将信号及时同步给下层。
如果不使用 Context 同步信号:当最上层的 Goroutine 因为某些原因执行失败时,下层的 Goroutine 由于没有接收到这个信号所以会继续工作;但是当我们正确地使用 context.Context
时,就可以在下层及时停掉无用的工作以减少额外资源的消耗:
默认上下文
context
包中最常用的方法还是 context.Background
、context.TODO
这两个方法都会返回预先初始化好的私有变量 background
和 todo
,它们会在同一个 Go 程序中被复用:
// Background returns a non-nil, empty Context. It is never canceled, has no
// values, and has no deadline. It is typically used by the main function,
// initialization, and tests, and as the top-level Context for incoming
// requests.
func Background() Context {
return background
}
// TODO returns a non-nil, empty Context. Code should use context.TODO when
// it's unclear which Context to use or it is not yet available (because the
// surrounding function has not yet been extended to accept a Context
// parameter).
func TODO() Context {
return todo
}
其实从代码从面上来看这两个没有什么区别,只是在使用和语义上稍有不同:
context.Background
是上下文的默认值,所有其他的上下文都应该从它衍生出来;context.TODO
应该仅在不确定应该使用哪种上下文时使用;
这两个私有变量都是通过 new(emptyCtx)
语句初始化的,它们是指向私有结构体 context.emptyCtx
的指针
var (
background = new(emptyCtx)
todo = new(emptyCtx)
)
// An emptyCtx is never canceled, has no values, and has no deadline. It is not
// struct{}, since vars of this type must have distinct addresses.
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 any) any {
return nil
}
从上述代码中,我们不难发现 context.emptyCtx
通过空方法实现了 context.Context
接口中的所有方法,它没有任何功能。
在多数情况下,如果当前函数没有上下文作为入参,我们都会使用 context.Background
作为起始的上下文向下传递。
目前对 context 的理解
Goroutine 就像并行流水线的工人~ context 负责在工人之间传递一些信息~控制一些指令!
Background && TODO 是一个全局的ctx!包含上面功能的最基本的context,对context接口最基本的实现!
通常在 main 函数、初始化和测试代码中创建,作为顶层 Context。
下面提到的一些 延伸的 context 都是基于context基础功能标准的扩展!
WithCancel
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
context.WithCancel()
创建可取消的 Context 对象,即可以主动通知子协程退出。
package main
import (
"context"
"fmt"
"sync"
"time"
)
var wg sync.WaitGroup
func main() {
wg.Add(1)
ctx, cancel := context.WithCancel(context.Background())
go handle(ctx)
// 3s 后通知 handle 停止执行!
time.Sleep(time.Second * 2)
cancel()
wg.Wait()
}
func handle(ctx context.Context) {
LABEL:
for {
select {
case <-ctx.Done():
fmt.Println("handle done", ctx.Err())
wg.Done()
break LABEL
default:
}
}
}
context.Backgroud()
创建根 Context,通常在 main 函数、初始化和测试代码中创建,作为顶层 Context。context.WithCancel(parent)
创建可取消的子 Context,同时返回函数cancel
。- 在子协程中,使用 select 调用
<-ctx.Done()
判断是否需要退出。 - 主协程中,调用
cancel()
函数通知子协程退出。cancel调用时 ,Done函数返回一个 输出的空结构体!
同时 cancel 所有 goroutine
package main
import (
"context"
"fmt"
"sync"
"time"
)
var wg sync.WaitGroup
func main() {
wg.Add(3)
ctx, cancel := context.WithCancel(context.Background())
go handle(ctx, "work1")
go handle(ctx, "work2")
go handle(ctx, "work3")
// 3s 后通知 handle 停止执行!
time.Sleep(time.Second * 2)
cancel()
wg.Wait()
}
func handle(ctx context.Context, workName string) {
LABEL:
for {
select {
case <-ctx.Done():
fmt.Println("handle done"+workName, ctx.Err())
break LABEL
default:
time.Sleep(time.Second)
fmt.Println(workName)
}
}
wg.Done()
}
为每个子协程传递相同的上下文 ctx
即可,调用 cancel()
函数后该 Context 控制的所有子协程都会退出。
WithValue
func WithValue(parent Context, key, val interface{}) Context
传入的数据与 Context 对象建立关系:
通俗的说就是,用这个可以根据传入的parent context ,额外放点 key value形式的值进parent context,返回父节点parent的副本,其中与key关联的值为val。
package main
import (
"context"
"fmt"
"sync"
"time"
)
var wg sync.WaitGroup
func main() {
wg.Add(1)
ctx, cancel := context.WithCancel(context.Background())
ctxV := context.WithValue(ctx, "production_id", 111)
go handle(ctxV, "work1")
// 3s 后通知 handle 停止执行!
time.Sleep(time.Second * 2)
cancel()
wg.Wait()
}
func handle(ctx context.Context, workName string) {
LABEL:
for {
select {
case <-ctx.Done():
fmt.Println("handle done"+workName, ctx.Err())
break LABEL
default:
fmt.Println(fmt.Sprintf("production_id==%v", ctx.Value("production_id")))
time.Sleep(time.Second)
}
}
wg.Done()
}
WithDeadline
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
WithDeadline 指定一个时间!到了指定时间会自动执行超时函数!
还是之前的代码,handle函数不用变,我们不去手动调用 cancel 了,时间节点设为当前时间 + 5s !
func main() {
wg.Add(1)
ctx, _ := context.WithDeadline(context.Background(), time.Now().Add(time.Second*5))
ctxV := context.WithValue(ctx, "production_id", 111)
go handle(ctxV, "work1")
// 3s 后通知 handle 停止执行!
wg.Wait()
}
可以看到5s后自己就帮我们执行了 cancel
WithTimeout
WithTimeout和WithDeadLine差不多,只不过传入的是一个时间段 duration
ctx, _ := context.WithTimeout(context.Background(), time.Second*5)
使用Context的注意事项
- 推荐以参数的方式显示传递Context
- 以Context作为参数的函数方法,应该把Context作为第一个参数。
- 给一个函数方法传递Context的时候,不要传递nil,如果不知道传递什么,就使用context.TODO()
- Context的Value相关方法应该传递请求域的必要数据,不应该用于传递可选参数
- Context是线程安全的,可以放心的在多个goroutine中传递
评论区