Golang之旅
首发于Golang之旅
GO译文之并发模型二 基于Context编程

GO译文之并发模型二 基于Context编程

原文:Context-Based Programming in Go
作者:Gigi Sayfan

在GO中,我们需要有能力管理并发运行中的goroutine,主要是指它的生命周期。那些失去控制的goroutine可能会进入某个死循环,从而导致其它等待中的goroutine死锁或运行太久。理想情况是,可以终止这些goroutine或使它们不太好的超时退出。

可以基于context编程。Go 1.7 引入了context包。它为我们提供了这些能力,同时我们也可以将某些变量与context关联实现信息的跨界交流与传递。

在本教程中,你将会了解到context的输入输出以及何时和如何使用它,以避免滥用。

什么情况下使用

context是一种非常好的抽象。它让你可以封装一些与核心逻辑无关的信息,比如 请求ID、认证Token和超时时间。这可以为我们带来如下的一些好处:

  • 它有效地帮助我们把核心逻辑参数与运行参数中分离开来。
  • 它为我们制定了通用的操作规则和在边界交流数据的方法。
  • 它为我们提供了一套标准的机制,在不修改函数签名的情况下传递额外信息。

Context接口

如下是Context的所有接口信息:

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done <-chan struct{}
    Err() error
    Value(key interface{})  interface{}
}

下面介绍各个方法的作用。

Deadline()

当执行完成,context就应被取消,此时Deadline()会返回相应的时间。当没有设置最后期限,Deadline返回ok == false。多次调用Deadline返回结果相同。

Done()

Done()方法返回的是一个channel,它将在工作执行完成即context应该被取消的时候被关闭。连续调用Done()返回的结果相同。

  • context.WithCancel()返回cancel函数,当调用它时,Done会被关闭;
  • context.WithDeadline()设置过期时间,当过期后,Done会被关闭;
  • context.WithTimeout()设置超时时间,当超时后,Done会被关闭;

可以在select语句中使用Done:

func Stream(ctx context.Context, out chan<- Value) error {
    for {
        v, err := DoSomething(ctx)
        if err != nil {
            return err
        }

        select {
        case <-ctx.Done():
            return ctx.Err()
        case out <- v:
        }
    }
}

可以读下这篇文章Go并发模型:Pipeline和Cancellation,介绍了很多如何使用Done取消context的例子。

Err()

只要Done是打开状态,Err()返回nil。如果context被取消,它返回Canceled error。如果context到期或超时,它返回DeadlineExceeded error。Done被关闭后,多次调用Err()返回结果相同。下面是一些定义:

// Canceled is the error returned by Context.Err when the context is canceled.
var Canceled = errors.New("context canceled")

// DeadlineExceeded is the error returned by Context.Err when the context's deadline passes.
var DeadlineExceeded error = deadlineExceededError{}

Value()

Value()通过key去调用与context关联的value,如果context中与指定key对应的值,返回nil。多次以相同key调用Value()返回结果相同。

Context中的Value仅仅用于在请求范围内不同程序和接口的数据转化,不可用于其他的参数传递。

在Context,一个key代表一个具体的值。那些希望用Context存储数据值的函数通常会在全局分配一个变量key,并用这个key作为参数调用context.WithValue和context.Value()。key支持任何类型。

Context的作用域

Contexts有作用范围。你可以从已有的context作用域延伸出新的作用域。父级不能访问衍生的作用域的数据,不过下级是可以访问父级作用域数据的。

Contexts是层级结构。你可以通过context.Background()或context.TODO()创建contexts。无论何时你调用WithCancel、WithDeadline或WithTimeout,都会得到出新的context,同时会返回一个cancel函数。最重要的是当父级的context被取消,所有的子级也将取消。

你应该在main、init和tests中使用context.Background()。如果不知道该使用什么context,可以通过context.TODO()产生context。

注意,Background和TODO生成的context是不可取消的。

过期、超时和取消

如你所知,WithDeadline() 和 WithTimeout() 创建的contexts将会自动取消,而WithCancel() 创建的context必须通过cancel()明确指定何时取消。其实,它们都会返回一个cancel函数,所以既没有超时/过期,你依然可以通过cancel取消衍生的context。

让我们看个例子。首先,contextDemo函数有两个参数,分别是name和context。它在一个无限循环中运行,不停的在控制台打印name和deadline(如果有的话)。然后sleep一秒。

package main

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

func contextDemo(name string, ctx context.Context) {
    for {
        if ok {
            fmt.Println(name, "will expire at:", deadline)
        } else {
            fmt.Println(name, "has no deadline")
        }
        time.Sleep(time.Second)
    }
}

主函数创建了三个contexts:

  • 三秒超时的timeoutContext;
  • 没有过期时间的cancelContext;
  • 由cancelContext产生的从现在开始4小时过期的deadlineContext;

然后,启动三个contextDemo的goroutine。它们并发执行且每秒打印一次message。

主函数通过读取timeoutContext的Done()来实现等待goroutine超时退出。一但三秒超时,main函数就调用cancelFunc取消cancelContext中的goroutine,同时cancelContext衍生出来的4小时过期的deadlineContext的goroutine也将退出。

func main() {
    timeout := 3 * time.Second
    deadline := time.Now().Add(4 * time.Hour)
    timeOutContext, _ := context.WithTimeout(
        context.Background(), timeout)
    cancelContext, cancelFunc := context.withCancel(
        context.Background())
    deadlineContext, _ := context.WithDeadline(
        cancelContext, deadline)
        
    go contextDemo("[timeoutContext]", timeOutContext)
    go contextDemo("[cancelContext]", cancelContext)
    go contextDemo("[deadlineContext]", deadlineContext)
 
    // Wait for the timeout to expire
    <- timeOutContext.Done()
 
    // This will cancel the deadline context as well as its
    // child - the cancelContext
    fmt.Println("Cancelling the cancel context...")
    cancelFunc()
 
    <- cancelContext.Done()
    fmt.Println("The cancel context has been cancelled...")
 
    // Wait for both contexts to be cancelled
    <- deadlineContext.Done()
    fmt.Println("The deadline context has been cancelled...")       
}

下面是输出结果:

[cancelContext] has no deadline
[deadlineContext] will expire at: 2017-07-29 09:06:02.34260363
[timeoutContext] will expire at: 2017-07-29 05:06:05.342603759
[cancelContext] has no deadline
[timeoutContext] will expire at: 2017-07-29 05:06:05.342603759
[deadlineContext] will expire at: 2017-07-29 09:06:02.34260363
[cancelContext] has no deadline
[timeoutContext] will expire at: 2017-07-29 05:06:05.342603759
[deadlineContext] will expire at: 2017-07-29 09:06:02.34260363
Cancelling the cancel context...
The cancel context has been cancelled...
The deadline context has been cancelled...

输出结果不变。接下来是最佳实践章节,将介绍一些指导原则,以便于我们恰当地使用context数据传递。

最佳实践

围绕context数据传递的几个最佳实践:

  • 避免在context中传递函数参数;
  • 在全局变量中为context中的数据分配一个对应key;
  • 包中应该为key定义一个不可导出的类型,以防止发生冲突;
  • 包中定义的key应该为其在context存储的数据提供类型安全访问方法;

HTTP请求的Context

context的常用场景之一就是在HTTP请求间传递信息。这些信息可能包含请求ID、认证证书等。在GO1.7,标准库net/http利用了context的优势,并且已经标准化,直接在request中加入了对context的支持。

func (r *Request) Context() context.Context
func (r *Request) WithContext(ctx context.Context) *Request

现在,我们可以使用一种标准方式把从headers中获取到的requestId传递到最终的处理函数。WithRequestID() 处理函数从"X-Request-ID"头部导出requestID并从正在使用的context中衍生出一个带有requestID的context。然后把它传递给调用链的下一个处理函数。公共函数GetRequestID()为处理函数提供了访问RequestID的途径,包括定义在其他包的处理函数。

const requestIDKey int = 0
 
func WithRequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(
        func(rw http.ResponseWriter, req *http.Request) {
            // Extract request ID from request header
            reqID := req.Header.Get("X-Request-ID")
            // Create new context from request context with 
            // the request ID
            ctx := context.WithValue(
                req.Context(), requestIDKey, reqID)
            // Create new request with the new context
            req = req.WithContext(ctx)
            // Let the next handler in the chain take over.
            next.ServeHTTP(rw, req)
        }
    )
}
 
func GetRequestID(ctx context.Context) string {
    ctx.Value(requestIDKey).(string)
}

func Handle(rw http.ResponseWriter, req *http.Request) {
    reqID := GetRequestID(req.Context())
    ...
}

func main() {
    handler := WithRequestID(http.HandlerFunc(Handle))
    http.ListenAndServe("/", handler)
}

总结

基于Context的编程为我们提供了一套标准和良好支持的方法,它解决了两个常见的问题:goroutine的生命周期管理和信息传递。

以最佳实践为准,在合适的场景下使用contexts,你的编码能力将会大幅提升。

编辑于 2019-05-26

文章被以下专栏收录