用的最多的就是超时控制,参数传递,以及级联取消。
redis,mysql等操作,在封装提供func的时候,第一个参数无脑context.Context就行,懂的人都说好。。。
从gin那边传过来某个请求,当你调用某个函数进行处理的时候,把ctx也传递过去,参数传参和方便log库打印requestId.
面试的时候,当面试官问你对Context的理解和有什么作用时,你侃侃而谈,说可以用来控制超时、级联取消、上下文传递,但是,在实际的开发中,如果你是从php,java,c++等语言转型go,你大概率会继续用其他语言的思路来写功能实现。
假设实现登录认证功能,你刚转型go时,大概率会写出如下代码:
func auth(userName, userPwd string) (bool,error){
// ...
}
提供一个auth() 函数,入参是用户名和密码,如果校验成功,返回true,否则返回false和具体的错误原因。
如果是一个老鸟,刚接到这个需求,需要查数据库,那么可能就会遇到超时,因此,他会毫不犹豫改一下你的代码,加了一个Context的参数:
func auth(ctx context.Context, userName, userPwd string) (bool,error){
// ...
}
默认的 http.Get() 外部无法控制超时时间,此时,我们可以自己封装一下:
// PostFormUrlEncode 请求,数据类型为:application/x-www-form-urlencoded
func (r *CloudRequest) PostFormUrlEncode(ctx context.Context, path string, data H) ([]byte, error) {
client := &http.Client{}
// post要提交的数据
formData := url.Values{}
for key, val := range data {
formData.Add(key, i2s(val))
}
req, err := http.NewRequestWithContext(ctx, "POST", r.host+path, strings.NewReader(formData.Encode()))
if err != nil {
return nil, err
}
// access token
if r.accessToken != "" {
req.Header.Add("authorization", "Bearer "+r.accessToken)
}
// 伪装头部
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
result, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != 200 {
appErr := protos.AppError{}
err = protojson.Unmarshal(result, &appErr)
if err != nil {
return nil, err
}
return nil, errors.New(fmt.Sprintf("http.statusCode=%d, error message=%s", resp.StatusCode, appErr.Message))
}
return result, nil
}
你可以直接调用:
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
r := NewRequest(c.apiAddr, registerAccessToken)
// 如果5秒都没有响应(阻塞的),
resData, err := r.PostFormUrlEncode(ctx, kAuthLoginUrl, form)
但是实际场景中,通常会被其他业务逻辑调用,比如你封装了一个SDK,提供一个登录函数:
func (c *cloudClient) Login(ctx context.Context, registerAccessToken, phone, code string) (*protos.AccessToken, error) {
form := H{
"type": "mobile",
"phone": phone,
"code": code,
}
r := NewRequest(c.apiAddr, registerAccessToken)
resData, err := r.PostFormUrlEncode(ctx, kAuthLoginUrl, form)
if err != nil {
return nil, err
}
token := &protos.AccessToken{}
err = protojson.Unmarshal(resData, token)
if err != nil {
return nil, err
}
return token, nil
}
别人调用你的登录函数,也就可以控制超时时间了:
func TestCloudClient_LoginReq(t *testing.T) {
c := NewCloudClient(kCloudUrl)
// register device
accessToken, err := c.DeviceRegister(context.Background())
if err != nil {
t.Fatal(err.Error())
}
t.Log("DeviceRegisterReq success, accessToken:", accessToken)
// login
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
token, err := c.Login(ctx, accessToken, kDefaultPhone, kDefaultCode)
if err != nil {
t.Fatal(err.Error())
}
}
假设提供了一个 auth 函数,基于你们的系统设计,这个函数中你需要发送 2个http请求:
func NewCloudClientWithAuthed(apiAddr, phone, code string) (CloudClient, error) {
client := NewCloudClient(apiAddr)
// 第一个HTTP请求,注册设备
accessToken, err := client.DeviceRegister()
if err != nil {
return nil, err
}
// 第二个HTTP请求,登录认证
token, err := client.Login(accessToken, phone, code)
if err != nil {
return nil, err
}
client.UpdateAccessToken(token.AccessToken)
return client, nil
}
如果你没传递 context,那么可能的结果就是:第1个http请求超时,花了5秒。第2个也超时,同样花了5秒,那调用这个函数的人,总超时时间就是 5+5=10秒。ok,这个时候你灵机一动,我改成2.5秒,2个加起来不就是5秒了吗?好的,间隔300天后,你又加了一个http调用。。那么这时总超时是多久?
所以,这个时候,context的好处就体验出来了,它的超时剩余时间是传递的,也就是说,如果第一个HTTP请求花了2秒,第2个请求就只有3秒时间来执行了。
func NewCloudClientWithAuthed(ctx context.Context, apiAddr, phone, code string) (CloudClient, error) {
client := NewCloudClient(apiAddr)
// 第一个HTTP请求,增加 ctx 传递
accessToken, err := client.DeviceRegister(cox)
// ...
// 第二个HTTP请求,增加 ctx 传递
token, err := client.Login(ctx, accessToken, phone, code)
// ...
}
那么,调用方也不用管你到底会有几次HTTP请求,他只需要管使用 WithTimeout() 设置一个总的超时时间即可!
比如查询数据库、查询Redis等,通常go-redis,ent或者gorm等框架都提供了统一的超时时间设置,以redis初始化举例:
// NewRedis new redis client
func NewRedis(opt *Options) *redis.Client {
return redis.NewClient(&redis.Options{
Addr: opt.Addr,
Password: opt.Password,
DB: opt.DB,
DialTimeout: opt.DialTimeout,
ReadTimeout: opt.ReadTimeout,
WriteTimeout: opt.WriteTimeout,
// use go-redis default value
PoolSize: 10 * runtime.NumCPU(),
MinIdleConns: runtime.NumCPU(),
PoolTimeout: time.Second * (3 + 1), // ReadTimeout + 1
IdleTimeout: time.Minute * 5,
IdleCheckFrequency: time.Minute,
})
}
配置文件中:
redis:
addr: 192.168.0.164:6379
dial_timeout: 5s
read_timeout: 3s
write_timeout: 3s
password:
db: 0
我们看到,读超时是3秒(默认值),那假设你有一个redis 查询(比如token校验),因为接口调用频率非常高,你希望超时控制在1秒,要怎么办?
封装一个函数,提供一个ctx 参数:
// UpdateUserOnlineTime update user online time
func (u *userCacheV1) UpdateUserOnlineTime(ctx context.Context, client *redis.Client, userId int64) error {
ctx, span := trace.NewSpan(ctx, "UpdateUserOnlineTime(old)")
defer span.End()
key := u.buildUserOnlineKey(userId)
return client.Set(ctx, key, time.Now().Unix(), ExpOnlineKey).Err()
}
调用时,调用方使用 WithTimeout() 来创建一个新的Context对象来定制超时时间(下面展示的是单元测试):
func TestUserRoomStateCache_SetRoomUserOnline(t *testing.T) {
client := newDefault(t)
// 1秒超时,别忘记下面的 cancel() ,主要是用来释放资源,如果没有超时时
ctx, cancel := context.WithTimeout(context.Background(), time.Second*1)
defer cancel() // releases resources if slowOperation completes before timeout elapses
UserStateV1.UpdateUserOnlineTime(ctx, client, 123)
}
当然,他也可以直接使用默认的全局超时时间:
UserStateV1.UpdateUserOnlineTime(context.Background(), client, 123)
还是结合登录场景,假设你使用了gin框架,注册了一个路由:
func RegisteRouter(engine *gin.Engine) {
engine.GET("/auth/login", onAuthLogin)
}
在你的登录逻辑里,你主要干2件事:
func onAuthLogin(ginCtx *gin.Context) {
// 1:校验密码
login(userName,userPwd)
// 2:记录登录日志
insertLoginLog(userId)
}
此时,假设你遇到了一个BUG,有一个用户(用户ID是9527)登录了,但是登录日志中却查不到。但是你打印的日志太多了(一天几十万),因为是线上,你也没办法打断点,那怎么办?
为了调试代码,你分别给这2个函数加上了日志,打印userId,通过日志来简接判断程序调用到哪里出错了:
func login(userName, userPwd string) (bool, error) {
log.HS.Info("login", zap.String("userName", userName))
// ...
return true, nil
}
func insertLoginLog(userId int64) {
log.HS.Info("insertLoginLog", zap.Int64("userId", userId),
zap.Int64("loginTime", time.Now().Unix()))
}
但是,足够了吗?你发布到线上,你发现userId=9527的记录太多了,你在客户端操作了一下,但是却不知道那次操作到底是对应到那一条日志。
通用的解决办法是,在每一次HTTP请求中,我们打印一个唯一的请求ID:
func onAuthLogin(ginCtx *gin.Context) {
// 在 HTTP 入口,我们为这一次请求生产一个RequestId
requestId = uuid.NewString()
// 然后,2个函数调用,传参进去,以方便打印日志的时候带上 请求ID
login(requestId, userName,userPwd)
insertLoginLog(requestId, userId)
}
这样,用户在浏览器中的每一次操作,你就能精确对应到服务器中的某几行日志了!
不过,通过字段的形式,如果将来我们又加了TraceId,还得增加参数一顿改。这是其他语言的思路,go里面我们应该怎么传递这种参数呢?
没错,用 Context!看下面的代码,我们通过 context.WithValue() 把 请求ID,填进去了:
func onAuthLogin(ginCtx *gin.Context) {
ctx := context.WithValue(context.Background(), "requestId", uuid.NewString())
// 然后,2个函数调用,传参进去,以方便打印日志的时候带上 请求ID
login(ctx, userName,userPwd)
insertLoginLog(ctx, userId)
}
其他函数要用的时候,直接取就行:
requestId := ctx.Value("requestId")
可以结合 logger 使用:
type AuthLoginReq struct {
UserName string `json:"user_name,omitempty"`
UserPwd string `json:"user_pwd"`
}
func onAuthLogin(ginCtx *gin.Context) {
ctx := context.WithValue(context.Background(), "requestId", uuid.NewString())
req := AuthLoginReq{}
if err := ginCtx.BindJSON(&req); err != nil {
ginCtx.JSON(http.StatusOK, gin.H{"code": 10, "msg": "invalid param"})
return
}
login(ctx, req.UserName, req.UserPwd)
// ...
}
func lc(ctx context.Context) op.Logger {
span := trace.SpanContextFromContext(ctx)
requestId := ctx.Value("requestId")
v, ok := requestId.(string)
if !ok {
v = uuid.NewString()
}
return op.WithLogger(log.HS,
op.WithTraceField(span.TraceID().String(), span.SpanID().String()),
zap.Field{Key: "requestId", Type: zapcore.StringType, String: v})
}
// BLL 层
func login(ctx context.Context, userName, userPwd string) (bool, error) {
lc(ctx).Info("login", zap.String("userName", userName))
// query from db
userId := int64(133)
insertLoginLog(ctx, userId)
return true, nil
}
// 记录登录日志
func insertLoginLog(ctx context.Context, userId int64) {
lc(ctx).Info("insertLoginLog", zap.Int64("userId", userId),
zap.Int64("loginTime", time.Now().Unix()))
}
执行后输出:
2022-07-22T11:47:26.149+0800 INFO router/routers.go:82 login {"traceId": "00000000000000000000000000000000", "requestId": "6ad9096f-d9b5-4054-9c2d-e0d51e087e59", "userName": "heel"}
2022-07-22T11:47:26.149+0800 INFO router/routers.go:93 insertLoginLog {"traceId": "00000000000000000000000000000000", "requestId": "6ad9096f-d9b5-4054-9c2d-e0d51e087e59", "userId": 133, "loginTime": 1658461646}
发现了吗?这个时候,直接搜 6ad9096f-d9b5-4054-9c2d-e0d51e087e59 就能精确找到某一次HTTP请求,服务器报的某几行日志了!
以下出自: Go 上下文 context 底层原理
Go 上下文 context 底层原理
很多时候,我们会遇到这样的情况,上层与下层的goroutine需要同时取消,这样就涉及到了goroutine间的通信。在Go中,推荐我们以通信的方式共享内存,而不是以共享内存的方式通信。
所以,就需要用到channl,但是,在上述场景中,如果需要自己去处理channl的业务逻辑,就会有很多费时费力的重复工作,因此,context出现了。
context是Go中用来进程通信的一种方式,其底层是借助channl与snyc.Mutex实现的。
context的底层设计,我们可以概括为1个接口,4种实现与6个方法。
1 个接口
4 种实现
6 个方法
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
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 interface{}) interface{} {
return nil
}
emptyCtx实现了空的Context接口,其主要作用是为Background和TODO这两个方法都会返回预先初始化好的私有变量 background 和 todo,它们会在同一个 Go 程序中被复用:
var (
background = new(emptyCtx)
todo = new(emptyCtx)
)
func Background() Context {
return background
}
func TODO() Context {
return todo
}
Background和TODO在实现上没有区别,只是在使用语义上有所差异:
cancelCtx实现了canceler接口与Context接口:
type canceler interface {
cancel(removeFromParent bool, err error)
Done() <-chan struct{}
}
其结构体如下:
type cancelCtx struct {
// 直接嵌入了一个 Context,那么可以把 cancelCtx 看做是一个 Context
Context
mu sync.Mutex // protects following fields
done atomic.Value // of chan struct{}, created lazily, closed by first cancel call
children map[canceler]struct{} // set to nil by the first cancel call
err error // set to non-nil by the first cancel call
}
我们可以使用WithCancel的方法来创建一个cancelCtx:
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
if parent == nil {
panic("cannot create context from nil parent")
}
c := newCancelCtx(parent)
propagateCancel(parent, &c)
return &c, func() { c.cancel(true, Canceled) }
}
func newCancelCtx(parent Context) cancelCtx {
return cancelCtx{Context: parent}
}
上面的方法,我们传入一个父 Context(这通常是一个 background,作为根节点),返回新建的 context,并通过闭包的形式,返回了一个 cancel 方法。
newCancelCtx将传入的上下文包装成私有结构体context.cancelCtx。
propagateCancel则会构建父子上下文之间的关联,形成树结构,当父上下文被取消时,子上下文也会被取消:
func propagateCancel(parent Context, child canceler) {
// 1.如果 parent ctx 是不可取消的 ctx,则直接返回 不进行关联
done := parent.Done()
if done == nil {
return // parent is never canceled
}
// 2.接着判断一下 父ctx 是否已经被取消
select {
case <-done:
// 2.1 如果 父ctx 已经被取消了,那就没必要关联了
// 然后这里也要顺便把子ctx给取消了,因为父ctx取消了 子ctx就应该被取消
// 这里是因为还没有关联上,所以需要手动触发取消
// parent is already canceled
child.cancel(false, parent.Err())
return
default:
}
// 3. 从父 ctx 中提取出 cancelCtx 并将子ctx加入到父ctx 的 children 里面
if p, ok := parentCancelCtx(parent); ok {
p.mu.Lock()
// double check 一下,确认父 ctx 是否被取消
if p.err != nil {
// 取消了就直接把当前这个子ctx给取消了
// parent has already been canceled
child.cancel(false, p.err)
} else {
// 否则就添加到 children 里面
if p.children == nil {
p.children = make(map[canceler]struct{})
}
p.children[child] = struct{}{}
}
p.mu.Unlock()
} else {
// 如果没有找到可取消的父 context。新启动一个协程监控父节点或子节点取消信号
atomic.AddInt32(&goroutines, +1)
go func() {
select {
case <-parent.Done():
child.cancel(false, parent.Err())
case <-child.Done():
}
}()
}
}
上面的方法可能遇到以下几种情况:
当 parent.Done() == nil,也就是 parent 不会触发取消事件时,当前函数会直接返回;
当 child 的继承链包含可以取消的上下文时,会判断 parent 是否已经触发了取消信号;
当父上下文是开发者自定义的类型、实现了 context.Context 接口并在 Done() 方法中返回了非空的管道时;
propagateCancel 的作用是在 parent 和 child 之间同步取消和结束的信号,保证在 parent 被取消时,child 也会收到对应的信号,不会出现状态不一致的情况。
func parentCancelCtx(parent Context) (*cancelCtx, bool) {
done := parent.Done()
// 如果 done 为 nil 说明这个ctx是不可取消的
// 如果 done == closedchan 说明这个ctx不是标准的 cancelCtx,可能是自定义的
if done == closedchan || done == nil {
return nil, false
}
// 然后调用 value 方法从ctx中提取出 cancelCtx
p, ok := parent.Value(&cancelCtxKey).(*cancelCtx)
if !ok {
return nil, false
}
// 最后再判断一下cancelCtx 里存的 done 和 父ctx里的done是否一致
// 如果不一致说明parent不是一个 cancelCtx
pdone, _ := p.done.Load().(chan struct{})
if pdone != done {
return nil, false
}
return p, true
}
ancelCtx 的 done 方法会返回一个 chan struct{}:
func (c *cancelCtx) Done() <-chan struct{} {
d := c.done.Load()
if d != nil {
return d.(chan struct{})
}
c.mu.Lock()
defer c.mu.Unlock()
d = c.done.Load()
if d == nil {
d = make(chan struct{})
c.done.Store(d)
}
return d.(chan struct{})
}
var closedchan = make(chan struct{})
parentCancelCtx 其实就是判断 parent context 里面有没有一个 cancelCtx,有就返回,让子context可以“挂靠”到parent context 上,如果不是就返回false,不进行挂靠,自己新开一个 goroutine 来监听。
timerCtx 内部不仅通过嵌入 cancelCtx 的方式承了相关的变量和方法,还通过持有的定时器 timer 和截止时间 deadline 实现了定时取消的功能:
type timerCtx struct {
cancelCtx
timer *time.Timer // Under cancelCtx.mu.
deadline time.Time
}
func (c *timerCtx) Deadline() (deadline time.Time, ok bool) {
return c.deadline, true
}
func (c *timerCtx) cancel(removeFromParent bool, err error) {
c.cancelCtx.cancel(false, err)
if removeFromParent {
removeChild(c.cancelCtx.Context, c)
}
c.mu.Lock()
if c.timer != nil {
c.timer.Stop()
c.timer = nil
}
c.mu.Unlock()
}
valueCtx 是多了 key、val 两个字段来存数据:
type valueCtx struct {
Context
key, val interface{}
}
取值查找的过程,实际上是一个递归查找的过程:
func (c *valueCtx) Value(key interface{}) interface{} {
if c.key == key {
return c.val
}
return c.Context.Value(key)
}
如果 key 和当前 ctx 中存的 value 一致就直接返回,没有就去 parent 中找。最终找到根节点(一般是 emptyCtx),直接返回一个 nil。所以用 Value 方法的时候要判断结果是否为 nil,类似于一个链表,效率是很低的,不建议用来传参数。
在官方博客里,对于使用 context 提出了几点建议: