Go语言中关于上下文取消函数的最佳实践方法

huangapple go评论75阅读模式
英文:

Best practices on go context cancelation functions

问题

我已经阅读了关于使用golang中的context包的一些文章。最近我在一个博客上看到了以下文章:http://p.agnihotry.com/post/understanding_the_context_package_in_golang/

这篇文章中提到了关于go中取消上下文函数的以下内容:

> "如果你想的话,可以传递取消函数,但是这是非常不推荐的。这可能导致取消上下文的调用者没有意识到取消上下文的下游影响。可能会有其他从这个上下文派生出来的上下文,这可能导致程序以意外的方式运行。简而言之,永远不要传递取消函数。"

然而,如果我想要激活父上下文的Done()通道,似乎将取消函数作为参数传递是唯一的选择(请参见下面的代码片段)。例如,下面的代码片段中,只有在执行function2时,代码中的Done通道才会被激活。

package main

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

func function1(ctx context.Context) {
	_, cancelFunction := context.WithCancel(ctx)
	fmt.Println("cancel called from function1")
	cancelFunction()
}

func function2(ctx context.Context, cancelFunction context.CancelFunc) {
	fmt.Println("cancel called from function2")
	cancelFunction()
}

func main() {
	//Make a background context
	ctx := context.Background()
	//Derive a context with cancel
	ctxWithCancel, cancelFunction := context.WithCancel(ctx)

	go function1(ctxWithCancel)
	time.Sleep(5 * time.Second)

	go function2(ctxWithCancel, cancelFunction)

	time.Sleep(5 * time.Second)

	// Done signal is only received when function2 is called
	<-ctxWithCancel.Done()
	fmt.Println("Done")
}

那么,传递这个取消函数实际上是一个问题吗?在使用context包和取消函数方面有哪些最佳实践?

英文:

I have been reading some articles on the use of the context package from golang. I recently came across the following article in a blog: http://p.agnihotry.com/post/understanding_the_context_package_in_golang/

The article states the following regarding context cancelation functions in go:

> "You can pass around the cancel function if you wanted to, but, that
> is highly not recommended. This can lead to the invoker of cancel not
> realizing what the downstream impact of canceling the context may be.
> There may be other contexts that are derived from this which may cause
> the program to behave in an unexpected fashion. In short, NEVER pass
> around the cancel function."

However, passing the cancelation function as a parameter seems to be the only option in case I want the parent context.Done() channel to be activated (see the code snippet below). For instance, the code Done channel in the code snippet below is activated only when function2 is executed.

package main

import (
	&quot;context&quot;
	&quot;fmt&quot;
	&quot;time&quot;
)

func function1(ctx context.Context) {
	_, cancelFunction := context.WithCancel(ctx)
	fmt.Println(&quot;cancel called from function1&quot;)
	cancelFunction()
}

func function2(ctx context.Context, cancelFunction context.CancelFunc) {
	fmt.Println(&quot;cancel called from function2&quot;)
	cancelFunction()
}

func main() {
	//Make a background context
	ctx := context.Background()
	//Derive a context with cancel
	ctxWithCancel, cancelFunction := context.WithCancel(ctx)

	go function1(ctxWithCancel)
	time.Sleep(5 * time.Second)

	go function2(ctxWithCancel, cancelFunction)

	time.Sleep(5 * time.Second)

	// Done signal is only received when function2 is called
	&lt;-ctxWithCancel.Done()
	fmt.Println(&quot;Done&quot;)
}

So, is passing this cancellation function actually an issue? Are there any best practices related to the use of the context package and their cancel function?

答案1

得分: 5

在你的具体示例中,代码量很小,可能没有问题理解它是如何工作的。当你用更复杂的内容替换function1function2时,问题就开始出现了。你提供的文章给出了一个具体的原因,解释了为什么传递取消上下文可能会导致难以理解的问题,但更一般的原则是,尽可能将协调工作(取消、启动goroutine等)与要执行的基本工作(function1function2所做的任何事情)分离开来。这有助于更容易独立地理解代码的子部分,并且可以帮助简化测试。"function2执行<某些操作>"比" function2执行<某些操作>并且与function1协调"更容易理解。

与其将取消函数传递给function2,不如在你生成的goroutine中调用它来运行function2

func main() {
  //...
  go func() {
    function2(ctxWithCancel)
    cancelFunction()
  }()
  //...
}

这样做很好,因为取消的协调工作完全包含在调用函数中,而不是分散在多个函数中。


如果你想让function2有条件地取消上下文,可以让它显式返回某种指示是否发生了可取消条件的值:

func function2(ctx context.Context) bool {
  //...
  if workShouldBecanceled() {
    return true
  }
  //...
  return false
}

func main() {
  //...
  go func() {
    if function2(ctxWithCancel) {
      cancelFunction()
    }
  }()
  //...
}

这里我使用了一个布尔值,但通常这种模式与error一起使用 - 如果function2返回非nil的error,则取消其余的工作。

根据你的需求,类似errgroup.WithContext这样的东西可能对你有用。它可以协调多个可能失败的并发操作,并在第一个操作失败时取消其他操作。


我在处理上下文取消时尝试遵循的另一个最佳实践是:始终确保取消函数在某个时刻被调用。根据文档,多次调用取消函数是安全的,所以我经常会做这样的事情:

func main() {
  ctx, cancel := context.WithCancel(context.Background())
  defer cancel()
  //...
  if shouldCancel() {
    cancel()
  }
  //...
}

根据评论进行编辑:

如果你有多个长时间运行的操作(例如服务器、连接等),并且希望在第一个操作停止时立即关闭所有操作,上下文取消是一种合理的方法。然而,我仍然建议你在一个函数中处理所有上下文交互。类似下面的代码可以工作:

func operation1(ctx context.Context) {
   for {
     select {
     case <-ctx.Done():
       return
     default:
     }
     //...
   }
}

func operation2(ctx context.Context) {
  // 类似的代码
}

func main() {
  ctx, cancel := context.WithCancel(context.Background())
  var wg sync.WaitGroup
  wg.Add(2)
  go func() {
    defer wg.Done()
    defer cancel()
    operation1(ctx)
  }()
  go func() {
    defer wg.Done()
    defer cancel()
    operation2(ctx)
  }()
  wg.Wait()
}

一旦其中一个操作终止,另一个操作就会被取消,但main仍然等待两个操作都完成。这样,操作无需担心管理这个过程。

英文:

In your specific example, there's a small enough amount of code that there's probably no issue understanding how it all works. The problems start when you replace function1 and function2 with something more complicated. The article you link to gives a specific reason why passing around cancellation contexts can do things that are hard to reason about, but the more general principle is that you should try to separate coordination work (cancellation, spinning up goroutines) from the underlying work to be done (whatever function1 and function2 are doing) as much as possible. This just helps make it easier to reason about sub-sections of your code independently and can help make testing easier. "function2 does &lt;something&gt;" is a lot easier to understand than "function2 does &lt;something&gt; and also coordinates with function1".

Rather than pass the cancellation function into function2, you can just invoke it inside the goroutune you spawn to run function2:

func main() {
  //...
  go func() {
    function2(ctxWithCancel)
    cancelFunction()
  }()
  //...
}

This is niece because the coordination work of figuring out when to cancel is all contained within the calling function rather than be split across multiple functions.


If you want to have function2 cancel the context conditionally, have it explicitly return some kind of value indicating whether some cancellable condition happened:

func function2(ctx context.Context) bool {
  //...
  if workShouldBecanceled() {
    return true
  }
  //...
  return false
}

func main() {
  //...
  go func() {
    if function2(ctxWithCancel) {
      cancelFunction()
    }
  }()
  //...
}

Here I used a boolean, but commonly this pattern is used with errors - if function2 returns a non-nil error, cancel the rest of the work.

Depending on what you're doing, something like errgroup.WithContext might be useful to you. This can coordinate several concurrent actions all of which might fail and cancels the others as soon as the first one fails.


One other best practice that I try to follow with context cancellation: always make sure that the cancel function gets called at some point. From the docs, it is safe to call a cancel function twice, so frequently I end up doing something like this:

func main() {
  ctx, cancel := context.WithCancel(context.Background())
  defer cancel()
  //...
  if shouldCancel() {
    cancel()
  }
  //...
}

Edit in response to comment:

If you have a situation where you have multiple long-running operations (e.g., servers, connections, etc.) and you want to shut all of them down as soon as the first one stops, context cancellation is a reasonable way to do that. However, I'd still recommend that you handle all the context interaction in a single function. Something like this would work:

func operation1(ctx context.Context) {
   for {
     select {
     case &lt;-ctx.Done():
       return
     default:
     }
     //...
   }
}

func operation2(ctx context.Context) {
  // Similar code to operatoin1()
}

func main() {
  ctx, cancel := context.WithCancel(context.Background())
  var wg sync.WaitGroup
  wg.Add(2)
  go func() {
    defer wg.Done()
    defer cancel()
    operation1(ctx)
  }()
  go func() {
    defer wg.Done()
    defer cancel()
    operation2(ctx)
  }()
  wg.Wait()
}

As soon as one of the operations terminates, the other is canceled, but main still waits for both to finish. Neither operation needs to worry about managing this at all.

huangapple
  • 本文由 发表于 2021年11月20日 09:45:05
  • 转载请务必保留本文链接:https://go.coder-hub.com/70042646.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定