Skip to content

Latest commit

 

History

History
189 lines (139 loc) · 8.97 KB

w13.md

File metadata and controls

189 lines (139 loc) · 8.97 KB

在 Go 程序中使用 context 提供的取消功能

很多使用 Go 的用户将会在工作中碰到 context 这个库。使用 context 最多的场景是在处理下游数据,例如发起一个 HTTP 的请求或者从数据库中获取数据,又或者使用 goroutine 执行异步操作。 context 最常见的用途是传递可供所有下游操作使用的通用数据。或许很少人知道, context 能够取消或者终止正在运行中的操作这个非常有用的特性。

本将给大家讲解如何使用 context 这个库的取消功能,通过取消功能的几个模式和最佳实践让你的应用变得更快和更健壮。

我们为什么需要一个取消功能

简而言之,我们需要(取消功能来)防止我们的系统做一些不必要的工作。

让我们来思考这样一个场景,一个 HTTP 服务去请求数据库并且返回查询结果给用户(客户端)。如下图所示:

如果一切运行顺利,时序图应该如下所示:

但是如果客户端突然中止了请求会发生什么样的情况?这种情况是可能出现的,例如:在数据请求过程中用户突然关闭了浏览器。对应的后端响应如果没有取消,那么服务器和数据库将继续一系列的操作,即使有结果返回其实也是一个资源上的浪费。

理想状态下,如果我们发现处理流程(在本例中是 HTTP 请求)已停止,那么我们希望在这之后的所有下游操作都被取消:

在 Go 中如何用 context 的取消功能

现在我们已经知道为什么我们的程序需要取消功能,下面让我们看下如何Go 中实现该功能。因为这个取消事件与正在执行的事务或操作高度相关,所以很自然,它与 context 捆绑在一起。

这里你需要实现两个操作以实现取消效果:

  • 监听需触发取消操作的事件
  • 发出取消命令

监听取消事件

Context 类型提供了一个 Done() 的方法,该方法返回一个 channel。每当上下文接收到一个取消事件时,该 channel 会接收到空结构体,即 struct{}{}。所以监听一个取消事件就是阻塞等待 <-ctx.Done() 这么简单。

假设我们有一个 HTTP 服务器,它需要花费两秒钟来处理事件。如果请求在这两秒内被取消,我们想立即响应这个取消操作:

func main() {
    // Create an HTTP server that listens on port 8000
    http.ListenAndServe(":8000", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        // This prints to STDOUT to show that processing has started
        fmt.Fprint(os.Stdout, "processing request\n")
        // We use `select` to execute a peice of code depending on which
        // channel receives a message first
        select {
        case <-time.After(2 * time.Second):
            // If we receive a message after 2 seconds
            // that means the request has been processed
            // We then write this as the response
            w.Write([]byte("request processed"))
        case <-ctx.Done():
            // If the request gets cancelled, log it
            // to STDERR
            fmt.Fprint(os.Stderr, "request cancelled\n")
        }
    }))
}

所有代码的示例可以在 这里 找到

现在我们可以启动这个服务,并且在浏览器访问 localhost:8000。如果你在 2 秒内关闭浏览器,我们会在终端看到 "request cancelled" 打印的结果。

发起一个取消的命令

如果你正在操作某些资源,需要发起取消命令的时候,你可以通过 context 来发起这个取消操作。我们可以使用 context 包 中的 WithCancel 函数,它会返回一个 context 对象和一个函数。这个函数不接受任何参数,也不返回任何内容,当你要取消 context 的时候调用此函数即可。

让我们考虑这样一个应用场景,该操作需要 2 个「依赖」关系。「依赖」的意思是说如果一个失败了,那么其他的即使完成也是没有意义的。如果我们知道其中一个失败了,我们想要取消所有的相关操作。

func operation1(ctx context.Context) error {
    // Let's assume that this operation failed for some reason
    // We use time.Sleep to simulate a resource intensive operation
    time.Sleep(100 * time.Millisecond)
    return errors.New("failed")
}

func operation2(ctx context.Context) {
    // We use a similar pattern to the HTTP server
    // that we saw in the earlier example
    select {
    case <-time.After(500 * time.Millisecond):
        fmt.Println("done")
    case <-ctx.Done():
        fmt.Println("halted operation2")
    }
}

func main() {
    // Create a new context
    ctx := context.Background()
    // Create a new context, with its cancellation function
    // from the original context
    ctx, cancel := context.WithCancel(ctx)

    // Run two operations: one in a different go routine
    go func() {
        err := operation1(ctx)
        // If this operation returns an error
        // cancel all operations using this context
        if err != nil {
            cancel()
        }
    }()

    // Run operation2 with the same context we use for operation1
    operation2(ctx)
}

基于时间的取消

任何需要在请求的最长持续时间内维护 SLA (service level agreement) 的应用程序都应使用基于时间的取消。下面这段 API 和前面的 API 是一样的,只是加了一些额外的代码

// The context will be cancelled after 3 seconds
// If it needs to be cancelled earlier, the `cancel` function can
// be used, like before
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)

// The context will be cancelled on 2009-11-10 23:00:00
ctx, cancel := context.WithDeadline(ctx, time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC))

例如我们的服务中有依赖于第三方的请求,如果第三方的服务花费的的时间过长,最好提前去取消这个依赖请求。

例如,如果我们通过 HTTP API 调用第三方服务时。如果响应时间过长,我们应把它当作调用失败处理,并及时终止这个调用:

func main() {
    // Create a new context
    // With a deadline of 100 milliseconds
    ctx := context.Background()
    ctx, _ = context.WithTimeout(ctx, 100*time.Millisecond)

    // Make a request, that will call the google homepage
    req, _ := http.NewRequest(http.MethodGet, "http://google.com", nil)
    // Associate the cancellable context we just created to the request
    req = req.WithContext(ctx)

    // Create a new HTTP client and execute the request
    client := &http.Client{}
    res, err := client.Do(req)
    // If the request failed, log to STDOUT
    if err != nil {
        fmt.Println("Request failed:", err)
        return
    }
    // Print the statuscode if the request succeeds
    fmt.Println("Response received, status code:", res.StatusCode)
}

根据 Google 首页对你的请求的响应速度,你可能得到如下结果:

plain Response received,status code: 200

或者是

plain Request failed: Get http://google.com: context deadline exceeded

你可以调整 timeout 并观察得到的结果。

陷阱和注意事项

尽管取消命令是 context 一个通用的功能,但是在使用之前,有一些情况你需要特别注意。

最重要的是取消操作在 context 中只可以使用一次。如果你希望在同一操作中抛出多个错误,使用上下文取消可能不是最佳选择。在使用之前你应该很清楚的知道你要取消的是什么操作,而不是简单地告知下游数据这里有一个错误出现了。

另一个情况是你应该把相同的上下文传递给所有使用该上下文的函数和 goroutine 。如果你想使用 WithTimeoutWithCancel 封装一个可被取消的上下文时,会存在很多未知情形,这个是需要避免的。

所有代码的示例可以在这里找到。