英文:
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 (
"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")
}
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
在你的具体示例中,代码量很小,可能没有问题理解它是如何工作的。当你用更复杂的内容替换function1
和function2
时,问题就开始出现了。你提供的文章给出了一个具体的原因,解释了为什么传递取消上下文可能会导致难以理解的问题,但更一般的原则是,尽可能将协调工作(取消、启动goroutine等)与要执行的基本工作(function1
和function2
所做的任何事情)分离开来。这有助于更容易独立地理解代码的子部分,并且可以帮助简化测试。"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 <something>" is a lot easier to understand than "function2
does <something> 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 error
s - 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 <-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.
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论