走进Golang之Context的使用
我们为什么需要 Context
的呢?我们来看看看一个 HTTP 请求的处理:
请求示意
例子大概意思是说,有一个获取订单详情的请求,会单独起一个 goroutine 去处理该请求。在该请求内部又有三个分支 goroutine 分别处理订单详情、推荐商品、物流信息;每个分支可能又需要单独调用DB、Redis等存储组件。那么面对这个场景我们需要哪些额外的事情呢?
- 三个分支 goroutine 可能是对应的三个不同服务,我们想要携带一些基础信息过去,比如:LogID、UserID、IP等;
- 每个分支我们需要设置过期时间,如果某个超时不影响整个流程;
- 如果主 goroutine 发生错误,取消了请求,对应的三个分支应该也都取消,避免资源浪费;
简单归纳就是传值、同步信号(取消、超时)。
看到这里可能有人要叫了,完全可以用 channel 来搞啊!那么我们看看 channel 是否可以满足。想一个问题,如果是微服务架构,channel 怎么实现跨进程的边界呢?另外一个问题,就算不跨进程,如果嵌套很多个分支,想一想这个消息传递的复杂度。
如果是你,要实现上面的这个需求,你会怎么做?
Context 出场
幸好,我们不用自己每次写代码都要去实现这个很基础的能力。Golang 为我们准备好了一切,就是 context.Context
这个包,这个包的源代码非常简单,源码部分本文会略过,下期单独一篇文章来讲,本篇我们重点谈正确的使用。
Context 的结构非常简单,它是一个接口。
// Context 提供跨越API的截止时间获取,取消信号,以及请求范围值的功能。
// 它的这些方案在多个 goroutine 中使用是安全的
type Context interface {
// 如果设置了截止时间,这个方法ok会是true,并返回设置的截止时间
Deadline() (deadline time.Time, ok bool)
// 如果 Context 超时或者主动取消返回一个关闭的channel,如果返回的是nil,表示这个
// context 永远不会关闭,比如:Background()
Done() <-chan struct{}
// 返回发生的错误
Err() error
// 它的作用就是传值
Value(key interface{}) interface{}
}
写到这里,我们打住想一想,如果你来实现这样一个能力的 package,你抽象的接口是否也是具备这样四个能力?
- 获取截止时间
- 获取信号
- 获取信号产生的对应错误信息
- 传值专用
net/http 中是怎么用 context的?
在我们开始自己鼓捣前,我们先看看 net/http 这个包是怎么使用的。
func main() {
req, _ := http.NewRequest("GET", "https://api.github.com/users/helei112g", nil)
// 这里设置了超时时间
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*1)
defer cancel()
req = req.WithContext(ctx)
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatalln("request Err", err.Error())
}
defer resp.Body.Close()
body, _ := ioutil.ReadAll(resp.Body)
fmt.Println(string(body))
}
上面这段程序就是请求 github 获取用户信息的接口,通过 context 包设置了请求超时时间是 1ms (肯定无法访问到)。执行时我们看到控制台做如下输出:
2020/xx/xx xx:xx:xx request Err Get https://api.github.com/users/helei112g: context deadline exceeded
exit status 1
我们继续做实验,将上面的代码稍作修改。
func main() {
req, _ := http.NewRequest("GET", "https://api.github.com/users/helei112g", nil)
// 这里超时改成了 10s,怎么都够了吧
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
// 但是这里移出了 defer 关键字
cancel()
req = req.WithContext(ctx)
// 没有改动的部分,省略
... ...
}
大家猜猜看能否获取到请求结果?肯定是不能的,因为 context 取消的信号,在 net/http 包内部通过 ctx.Done()
是能够拿到的,一旦获取到就会进行取消。上面的代码,控制台会输出:
2020/xx/xx xx:xx:xx request Err Get https://api.github.com/users/helei112g: context canceled
exit status 1
注意两次控制台输出的错误信息是不一样的。
- context deadline exceeded 表示执行超时被取消了
- context canceled 表示主动取消
net/http 中 context 获取取消信号
接下来,我们去看看 net/http 包内部是怎么捕捉信号的,我们只关注 context 的部分,其它的直接忽略,源码路径如下;
net/http/transport.go (go 1.13.7)
// req 就是我们上面传进来的 req,它有个 context 字段
func (t *Transport) roundTrip(req *Request) (*Response, error) {
t.nextProtoOnce.Do(t.onceSetNextProtoDefaults)
ctx := req.Context() // 获取了 context
trace := httptrace.ContextClientTrace(ctx) // 这里内部实际用到了 context.Value() 方法
// 各种处理,无关代码删除了
// 处理请求
for {
// 检查是否关闭了,如果关闭了就直接返回
select {
case <-ctx.Done():
req.closeBody()
return nil, ctx.Err()
default:
}
// 发送请求出去
}
}
来总结下上面这段代码,实际上关于 context 的精髓就在 for 循环中的 select,它通过 ctx.Done() 来获取信号,因为不管是自动超时,还是主动取消,ctx.Done() 都会收到一个关闭的 channel 的信号。
这里隐藏了一个细节,那就是如果按照上面的逻辑只能处理到发起请求前的超时,但是如果请求已经被发出去了,等待这段时间的超时该如何控制呢?感兴趣的小伙伴可以去看源码的这里:
net/http/transport.go:1234 (go 1.13.7)
其实就是在内部等待返回的时候不断的检查 ctx.Done() 信号,如果发现了就立即返回。
好了,官方的技巧我们已经学完了,现在轮到我们把开头的例子写个代码来实现下。
多个 goroutine 控制超时及传值
由于服务内部不方便模拟,我们简化成函数调用,假设图中所有的逻辑都可以并发调用。现在我们的要求是:
- 整个函数的超时时间为1s;
- 需要从最外层传递 LogID/UserID/IP 信息到其它函数;
- 获取订单接口超时为 500ms,由于 DB/Redis 是其内部支持的,这里不进行模拟;
- 获取推荐超时是 400ms;
- 获取物流超时是 700ms。
为了清晰,我这里所有接口都返回一个字符串,实际中会根据需要返回不同的结果;请求参数也都只使用了 context。代码如下:
type key int
const (
userIP = iota
userID
logID
)
type Result struct {
order string
logistics string
recommend string
}
// timeout: 1s
// 入口函数
func api() (result *Result, err error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*1)
defer cancel()
// 设置值
ctx = context.WithValue(ctx, userIP, "127.0.0.1")
ctx = context.WithValue(ctx, userID, 666888)
ctx = context.WithValue(ctx, logID, "123456")
result = &Result{}
// 业务逻辑处理放到协程中
go func() {
result.order, err = getOrderDetail(ctx)
}()
go func() {
result.logistics, err = getLogisticsDetail(ctx)
}()
go func() {
result.recommend, err = getRecommend(ctx)
}()
for {
select {
case <-ctx.Done():
return result, ctx.Err() // 取消或者超时,把现有已经拿到的结果返回
default:
}
// 有错误直接返回
if err != nil {
return result, err
}
// 全部处理完成,直接返回
if result.order != "" && result.logistics != "" && result.recommend != "" {
return result, nil
}
}
}
// timeout: 500ms
func getOrderDetail(ctx context.Context) (string, error) {
ctx, cancel := context.WithTimeout(ctx, time.Millisecond*500)
defer cancel()
// 模拟超时
time.Sleep(time.Millisecond * 700)
// 获取 user id
uip := ctx.Value(userIP).(string)
fmt.Println("userIP", uip)
return handleTimeout(ctx, func() string {
return "order"
})
}
// timeout: 700ms
func getLogisticsDetail(ctx context.Context) (string, error) {
ctx, cancel := context.WithTimeout(ctx, time.Millisecond*700)
defer cancel()
// 获取 user id
uid := ctx.Value(userID).(int)
fmt.Println("userID", uid)
return handleTimeout(ctx, func() string {
return "logistics"
})
}
// timeout: 400ms
func getRecommend(ctx context.Context) (string, error) {
ctx, cancel := context.WithTimeout(ctx, time.Millisecond*400)
defer cancel()
// 获取 log id
lid := ctx.Value(logID).(string)
fmt.Println("logID", lid)
return handleTimeout(ctx, func() string {
return "recommend"
})
}
// 超时的统一处理代码
func handleTimeout(ctx context.Context, f func() string) (string, error) {
// 请求之前先去检查下是否超时
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}
str := make(chan string)
go func() {
// 业务逻辑
str <- f()
}()
select {
case <-ctx.Done():
return "", ctx.Err()
case ret := <-str:
return ret, nil
}
}
不知道你是否看明白了整个使用,我们这个例子看起来很复杂,实际上与我给你介绍的 net/http 包控制超时是一样的,只不过 net/http 的控制超时代码不需要我们写,而且我们这里一次性把三个调用的整合到了一起。
还有一点说明一下,对于 select
,如果没有写 defalut
分支,是不需要放在 for
循环中的,因为它本身就会阻塞(网络上有很多例子放在for循环中)。
参考资料
- [1] Package context
- [2] Go Concurrency Patterns: Context
- Javascript跨域后台设置拦截
- 奇怪的登录问题及解决 (75天)
- Optional乱用Empty之No value present
- Java中ArrayList remove会遇到的坑
- Dagger2 入门解析
- Git 工作流的正确打开方式
- 如何从两个List中筛选出相同的值
- 【Windows编程】系列第八篇:创建通用对话框
- 使用dropwizard(3)-加入DI-dagger2
- 巧用shell生成数据库检查脚本 (74天)
- 【专业技术】OPENGL与EGL
- 在dropwizard中使用feign,使用hystrix
- 用python抓取淘宝评论
- 使用Dropwizard(2)-配置分类ConfiguredBundle
- JavaScript 教程
- JavaScript 编辑工具
- JavaScript 与HTML
- JavaScript 与Java
- JavaScript 数据结构
- JavaScript 基本数据类型
- JavaScript 特殊数据类型
- JavaScript 运算符
- JavaScript typeof 运算符
- JavaScript 表达式
- JavaScript 类型转换
- JavaScript 基本语法
- JavaScript 注释
- Javascript 基本处理流程
- Javascript 选择结构
- Javascript if 语句
- Javascript if 语句的嵌套
- Javascript switch 语句
- Javascript 循环结构
- Javascript 循环结构实例
- Javascript 跳转语句
- Javascript 控制语句总结
- Javascript 函数介绍
- Javascript 函数的定义
- Javascript 函数调用
- Javascript 几种特殊的函数
- JavaScript 内置函数简介
- Javascript eval() 函数
- Javascript isFinite() 函数
- Javascript isNaN() 函数
- parseInt() 与 parseFloat()
- escape() 与 unescape()
- Javascript 字符串介绍
- Javascript length属性
- javascript 字符串函数
- Javascript 日期对象简介
- Javascript 日期对象用途
- Date 对象属性和方法
- Javascript 数组是什么
- Javascript 创建数组
- Javascript 数组赋值与取值
- Javascript 数组属性和方法
- 解决CentOS7虚拟机无法上网并设置CentOS7虚拟机使用静态IP上网
- Android编程实现自定义Dialog的大小自动控制方法示例
- Linux中如何查看文件的创建时间详解
- Android 图片添加水印的实现方法
- Linux系统清除缓存的方法总结
- 详解Android使用@hide的API的方法
- Android 实现按两次返回键退出程序(两种方法)
- 使用 bash 倒计时日期的方法
- Android 实现页面跳转
- Android EditText密码的隐藏和显示功能
- linux系统下的时间配置综述
- Android TextView 去掉自适应默认的fontpadding的实现方法
- Linux文件/目录的权限及归属管理使用
- Android自定义环形LoadingView效果
- Android隐藏标题栏及解决启动闪过标题的实例详解