避免使用 context.Background(),让你的 Goroutine 更可靠
当我们同时管理多个任务时,我们使用 goroutine,对吧? 每个都专用于特定的功能,例如发出 HTTP 请求或查询数据库。 当这些任务需要暂停时就会出现问题,这就是棘手的地方,因为我们不希望我们的 goroutine 无限期地停止或阻塞而无法退出。
"为什么我们应该避免直接使用 context.Background() ?"
我们避免直接使用 context.Background() 的主要原因是,如果出现问题,它无法提供停止或取消操作的方法。 这是我们可以使用的最简单的上下文形式,没有值、没有截止日期、没有取消信号。 当操作陷入困境或需要顺利结束时,这可能会成为问题。
为了解决这个问题,我们通常依靠两种策略:取消和超时。
go
context.WithTimeout(ctx, duration)
context.WithTimeoutCause(ctx, duration, errors.New("custom message"))
context.WithCancel(ctx)
context.WithCancelCause(ctx)
context.WithDeadline(ctx)
context.WithDeadlineCause(ctx, deadline, errors.New("custom message"))
有了这些工具,我们启动的每个 goroutine 都带有明确的期望:"我会及时完成我的任务,或者我会解释为什么我不能及时完成,并且如果有必要,你有权取消我的任务。"
请记住以下几点:
- 在这一切之下,WithTimeout 实际上只是 WithDeadline,但名称不同。
- 最近的 Go 更新(版本 1.20 和 1.21)中添加的 XXXCause 函数提供了更好的错误报告。
- 如果 XXXCause 发生超时,它会提供更详细的错误消息:"超出上下文截止时间:自定义消息"。
"channel怎么样?我不想在channel上永远等待。"
在处理channel时,我们希望确保不会无限期地等待,更好的管理方法是使用 select 语句,它允许我们设置超时选项:
go
select {
case result := <-ch
fmt.Println("Received result:", result)
case <-time.After(5 * time.Second):
fmt.Println("Timed out")
}
但是,重要的是要注意使用 time.After 可能会导致短期内存泄漏。 在某些情况下,使用 time.Timer 或 time.Ticker 可能更有效,它们可以让我们更好地控制时间。
我们将在接下来的提示中深入探讨这些替代方案及其价值。
不幸的是,context.Value 不是我们的朋友
让我们来谈谈如何使用 context.Value,它似乎是一个方便的工具,因为我们可以将一些数据放入上下文中并在需要的地方取出它的值。
这使我们的函数签名保持干净和简单,对吧? 它通常是这样使用的:
go
func A(ctx context.Context, transactionID string) {
payment := db.GetPayment(ctx, transactionID)
ctx = context.WithValue(ctx, "payment", payment)
B(ctx)
}
func B(ctx context.Context) {
...
C(ctx)
}
func C(ctx context.Context) {
payment, ok := ctx.Value("payment").(Payment)
...
}
在此设置中,函数 A 获取付款记录并将其添加到上下文中,在 B 中调用的函数 C 检索该付款。 此策略避免直接通过功能 B 传递付款,因为功能 B 不需要了解付款。
这种方法可能看起来不错,因为:
- 它允许我们省略通过像 B 这样不使用它的函数传递特定数据。
- 它使我们能够将所有必要的数据保存在上下文中的一个位置。
- 它避免了函数签名中的额外参数。
- 为什么不直接从函数A调用函数C呢? 通常,C 深深地集成到 B 的逻辑中,并且可能依赖于它的一些计算、参数。
所以有什么问题? 这是我们遇到的一些问题:
- 我们正在放弃 Go 在编译期间提供的类型检查安全性。
- 我们将数据放入黑匣子中,并希望稍后能再次找到它,一周后可能就像盲目搜索一样。
- 由于隐式传递,支付数据看似可选,但实际上很重要。
- 从个人角度来看,使用 ctx.Value 的主要问题是它如何隐藏数据。 这就像把东西放在没有清晰标签的保险箱里一样。 当然,它被存储起来,但检索它就变成了一个猜谜游戏。
明确我们正在传递的内容通常会减少以后的麻烦。
"那么,什么时候适合使用 context.Value() 呢?"
最好限制它的使用,但 Go 文档建议它适合跨 API 边界和进程之间传递请求范围的值。 以下是一些很好的用途:
您可以考虑使用它来跟踪某些与请求相关的数据,例如:
- 跟踪请求的开始时间
- 记录呼叫者的 IP 地址
- 管理跟踪和跨度 ID
- 识别正在访问的HTTP路由 ... "我的'付款'数据与请求无关吗?"
如果支付信息在多个函数中都很重要,那么通过函数参数显式传递它会更清晰、更安全,这有助于任何阅读代码的人立即理解该函数直接与支付数据交互。
一般来说,最好避免在上下文中嵌入关键业务数据,这种策略可以使我们的代码保持清晰和可维护。
使用 context.WithoutCancel() 保持上下文
因此,当您在 Go 中使用上下文时,非常简单的一件事就是取消的工作方式。 如果取消父上下文,所有子上下文也会被取消。
这是一个简单的例子来看看它是如何发挥作用的:
go
parentCtx, cancel := context.WithCancel(context.Background())
childCtx, _ := context.WithCancel(parentCtx)
go func(ctx context.Context) {
<-ctx.Done()
fmt.Println("Child context done")
}(childCtx)
cancel()
在这段代码中,一旦我们取消了parentCtx,childCtx也会被取消,这通常是我们想要的,但有时你可能会发现自己需要一个子上下文来继续运行,即使它的父上下文被取消了。
在 Go 中处理 HTTP 请求时,我们经常遇到一个常见场景。 在处理主请求后启动 goroutine 来处理任务,如果处理不仔细,有时可能会导致错误:
go
func handleRequest(req *http.Request) {
ctx := req.Context()
go hookAfterRequest(ctx)
}
或者我们可以考虑处理 HTTP 请求,即使客户端断开连接,我们仍然需要记录详细信息并收集指标,但没有取消行为。
"好吧,我将为这些任务创建一个新的上下文"
这是一种方法,但新上下文不会继承原始上下文中的任何信息或值,而通常需要这些信息或值来执行日志记录和指标等任务。
以下是保留这些值的方法:
go
parentCtx := context.WithValue(context.Background(), "requestID", "12345")
childCtx, _ := context.WithCancel(parentCtx)
fmt.Println("Propagated value": childCtx.Value("requestID")) // 12345
在像我们的 HTTP 请求示例这样的场景中,为了确保即使父上下文被取消某些操作也能继续,我们可以尝试以下操作:
go
func handleRequest(req * http.Request) {
ctx := req.Context()
// Create a child context that doesn't cancel when the parent does
uncancellableCtx := context.WithoutCancel(ctx)
go func(ctx context.Context) {
// This logging operation won't be interrupted if
// the parent context (ctx) is cancelled
getMetrics(uncancelableCtx, req)
}(ctxWithoutCancel)
}
基本上,Go 1.21 中引入的 context.WithoutCancel() 函数允许某些操作继续进行,而不会受到其父上下文取消的影响。
使用 context.AfterFunc 在上下文取消后回调函数
我们讨论了如何在父上下文停止后仍保持上下文活动。 现在,我们将看看 Go 1.21 中引入的另一个方便的功能,即 context.AfterFunc。 此函数允许您安排回调函数 f 在 ctx 结束后在单独的 goroutine 中运行,无论是由于取消还是超时。
使用方法如下:
go
ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second)
defer cancel()
stop := context.AfterFunc(ctx, func() {
fmt.Println("Cleanup operations after context is done")
})
这段代码设置了一个清理任务,以便在上下文完成后运行,它对于清理、日志记录或取消后需要发生的其他操作等任务非常有用。
"回调什么时候运行?"
一旦父上下文的 ctx.done 通道发送信号,它就会在新的 goroutine 中启动。
然后回调会立即运行,当然是在一个新的 goroutine 中。
- AfterFunc 可以在相同的上下文中多次使用,并且您设置的每个任务都独立运行。
- 如果调用 AfterFunc 时上下文已经完成,它会立即在新的 goroutine 中触发该函数。
- 它提供了停止功能,可以让您取消计划的功能。
- 使用 stop 函数是非阻塞的,它不会等待函数完成,而是立即停止它。 如果您需要该功能和您的主要工作同步,您必须自己管理。
让我们深入研究一下 AfterFunc 返回的函数 stop():
go
stop := context.AfterFunc(ctx, func() {
...
})
if stopped := stop(); stopped {
fmt.Println("Remove the callback before context is done")
}
如果你在上下文结束之前调用 stop() 并且回调还没有运行(意味着 goroutine 还没有被触发),stopped 将返回 true,表明你成功停止了回调的运行。
但是,如果 stop() 返回 false,则可能意味着函数 f 已经开始在新的 goroutine 中运行,或者已经停止。
使用未导出的空结构作为上下文键
在延迟调用中很容易忽视错误处理:
go
func doSomething() error {
file, err := os.Open("file.txt")
if err != nil {
return err
}
defer file.Close()
...
}
在此代码片段中,如果关闭文件失败,可能是因为某些内容未正确写入或文件系统中存在故障,并且我们不检查该错误,那么我们就错过了捕获和处理潜在问题的机会 关键问题。
现在,如果我们坚持使用 defer,我们基本上有三种方法来处理它:
- 我们可以将错误作为函数返回的一部分进行处理。
- 我们可以让程序出现panic,这可能有点严重,除非它是一个真正严重的错误,足以证明程序崩溃是合理的。
- 或者我们可能只是记录错误并继续,这更简单,但意味着您没有主动处理错误 - 只是注意到它发生了。
但是如何将其作为函数错误处理呢? 这有点微妙。 我们可以使用匿名函数来巧妙地管理它:
go
func DoSomething(path string) (err error) {
file, err := os.Open(path)
if err != nil {
return err
}
defer func() {
if cerr := file.Close(); cerr != nil {
err = errors.Join(err, cerr)
}
}()
...
}
在此版本中,我们使用命名的返回变量 err 作为函数的错误返回。
在延迟函数内,我们检查 file.Close() 是否返回错误(捕获为 cerr)。 如果确实如此,我们使用errors.Join 将其与可能已经存在的任何现有错误合并(而不是包装)。 这样,该函数可以返回反映文件打开和文件关闭操作问题的错误。
或者,您可以稍微简化一下:
go
defer func() {
err = errors.Join(err, file.Close())
}()
这个较短的版本本质上做同样的事情,但将其压缩为 defer 内的一行。 现在,由于匿名函数的存在,即使是这种较短的方法也会增加一些复杂性,这会增加嵌套并使代码更难以理解。
实际上还有另一种方法可以使用简洁的辅助函数来处理延迟调用中的错误:
go
defer closeWithError(&err, file)
func closeWithError(err *error, f func() error) {
*err = errors.Join(*err, f())
}
"等等,这不会因为在 err 可能为零时取消引用 err 而导致恐慌吗?"
这是一个合理的担忧,但问题在于:它实际上工作得很好。
原因如下:在 Go 中,error是一个 interface{},并且 nil error的行为方式与 nil 指针对于其他类型(例如 *int)的行为不同。
nil error在内部表示为 {type = nil, value = nil},但准确地说,它仍然是一个有效的、可用的值(接口的零值)。
因此,当我们在 defer closeWithError(&err, file) 调用中使用 &err 时,我们并不是在处理 nil 指针场景。 我们实际上得到的是一个指向接口变量的指针,该变量本身保存着 {type = nil, value = nil}。
这意味着在 closeWithError 函数中,当我们使用 *err 取消引用错误指针以分配新值时,我们不会取消引用 nil 指针(这确实会导致panic)。 相反,我们正在做的是通过接口变量的指针修改其值。 这是一个安全的操作,可以避免您可能预期的panic
始终跟踪 Goroutine 的生命周期
Goroutines 是堆栈式的,这意味着它们比其他语言中的类似结构占用更多的内存,每个至少 2KB,虽然很小但不可忽略。
每次我们使用 go doSomething() 启动 goroutine 时,我们都会立即保留 2KB 内存(在 Go 1.2 中为 4KB,在 Go 1.4 中增加到 8KB)。
因此,缺点是,当你的 Go 程序同时处理很多事情时,与没有这种堆栈分配的语言相比,它的内存使用量会增长得更快。
这个初始大小是一个起点,Go 运行时会在执行过程中自动调整 Goroutine 的堆栈大小,以适应每个 Goroutine 工作负载的内存需求。
这个过程是这样的:当 goroutine 的堆栈达到其当前限制时,Go 运行时会检测到这种情况并分配更大的堆栈空间。 然后,它将现有堆栈的内容复制到新的、更大的堆栈,并使用扩展的堆栈空间继续 goroutine 的执行。
我个人遇到过使用一堆带有 for 循环和 time.Sleep 的 goroutine 的情况,如下所示:
go
func Job(d time.Duration) {
for ;; time.Sleep(d) {
...
}
}
go
func Job(d time.Duration){
for {
...
time.Sleep(d)
}
}
这样写看起来很方便,但它肯定有它的缺点。
当我们谈论优雅地关闭您的应用程序时,正如我们关于优雅地关闭您的应用程序主题的部分所述,我们遇到了某些函数的棘手问题,例如 time.Sleep,它本质上不能很好地支持优雅关闭。
- Sleep -> SIGTERM -> Running -> Interrupted.
因此,对于不会自然结束的任务,例如提供网络连接或监视配置文件,最好使用取消信号或条件来明确定义这些任务何时应停止。
go
func Job(ctx context.Context, d time.Duration) {
for {
select {
case <-ctx.Done():
return
default:
...
time.Sleep(d)
}
}
}
在此设置中,上下文应从基本上下文派生,当收到 SIGTERM 时,该上下文将被取消。 这样,至少我们知道任务不会被意外中断,即使是在程序终止时。
但这并不能完全解决问题,如果我们需要随时停止 goroutine 怎么办? 这在避免 time.Sleep(),它不是上下文感知且不能被中断中进行了讨论)
现在,考虑另一种情况,其中 goroutine 可能会永远卡住:
go
func worker(jobs <-chan int) {
for i := range jobs {
...
}
}
jobs := make(chan int)
go worker(jobs)
您可能认为确定协程何时完成很简单,只需关闭channel即可。
但channel到底什么时候关闭呢? 如果出现错误并且我们没有关闭通道并从函数返回,则 goroutine 会无限期挂起,从而导致内存泄漏。
因此,重要的是要让 goroutine 何时启动和停止变得优雅,并将上下文传递给长时间运行的进程。
避免使用 time.Sleep(),它不是上下文感知的并且不能被中断
当您想要在 Go 应用程序中暂停执行时,使用 time.Sleep() 似乎是一个简单的解决方案,但它有一个显着的缺点:它不具有上下文感知能力并且无法被中断。
假设您有一个正在关闭的应用程序。
如果有一个函数由于 time.Sleep() 而当前处于休眠状态,我们不能只是唤醒它并告诉它停止正在执行的操作。 它会自行醒来,开始执行下一行代码,只有那时它才会意识到,"哦,我应该停止",因为应用程序的其余部分正在关闭。
这是一个个人的"轶事",我自己实际上也犯过这个错误,并且它变成了一个非常好的学习经验:
go
func doJob(d time.Duration) {
for ;; time.Sleep(d) {
...
}
}
func doJob() {
for {
...
time.Sleep(d)
}
}
在这些循环中,该函数执行一些工作,使用 time.Sleep() 暂停 5 秒,然后继续。 问题是,这些循环无法通过上下文取消来停止。
更好的方法是让你的函数尊重上下文:
go
func doWork(ctx context.Context, d time.Duration) {
for {
select {
case <-ctx.Done():
return
default:
time.Sleep(d)
}
...
}
}
这个版本有点冗长,但现在我们的工作尊重上下文。 例如,如果发送关闭信号,该函数会检查上下文,如果完成则可以立即停止:
- -> doWork -> sleep -> shutdown -> ctx.Done() -> out
- -> doWork -> shutdown -> sleep -> ctx.Done() -> out
还有一个问题。
我们必须等待睡眠持续时间结束,该持续时间可能长达 5 秒甚至更长,具体取决于作业中延迟的设置方式。 如果您需要该功能能够立即响应关闭命令,那么这并不理想。
因此,虽然这种方法更好,但并不完美。
更好的方法
我们一直在使用时间包来管理暂停,但是我们可以考虑进行一些调整来改进这一切的工作方式。
go
func doWork(ctx context.Context, d time.Duration) {
for {
select {
case <-ctx.Done():
return
case <-time.After(d):
}
...
}
}
这个策略很简单并且可以完成工作,但它并非没有缺陷。
- 它每次运行循环时都会创建一个新的计时器通道。 这可能会导致不必要的分配。
- 此外,Go 社区经常指出,短期内存泄漏还存在潜在问题。 如果函数在计时器耗尽之前由于 ctx.Done() 而退出,则 time.After 计时器仍在后台滴答作响,直到完成,这并不理想。
现在,我们可能会考虑一个稍微复杂的解决方案,可以更有效地处理计时器:
go
func doWork(ctx context.Context, d time.Duration) {
delay := time.NewTimer(d)
for {
select {
case <-ctx.Done():
if !delay.Stop() {
<-delay.C
}
return
case <-delay.C:
_ = delay.Reset(d)
}
...
}
}
在这里,我们使用单个计时器并在每个周期重置它,这样效率更高。 当上下文信号完成时,停止计时器以防止任何泄漏非常重要。
如果计时器已经停止,我们确保通过接收delay.C 来清除通道。
虽然计时器通常用于一次性事件,但ticker旨在处理重复事件。 然而,使用滚动条的细微差别之一是,它不会停下来检查前一个任务是否已完成,而是会根据设置的时间间隔继续滚动。
例如,如果我们将 Ticker 设置为 1 分钟,但我们的任务需要 2 分钟,则无论如何,Ticker 仍会在一分钟后发送到channel。 这意味着一旦我们的较长任务完成,Ticker 就会立即触发该任务立即重新开始。
go
func doWork(ctx context.Context, d time.Duration) {
now := time.Now()
delay := time.NewTicker(d)
for {
workIn2Minute()
delay.Reset(d)
select {
case <-ctx.Done():
delay.Stop()
case <-delay.C:
}
}
}
在此设置中,即使我们在 workIn2Minute() 完成后重置了ticker,ticker可能已经在任务运行时发送到channel。 这可能会导致下一个任务立即开始,这不是我们想要的。
现在,考虑改变工作的安排:
go
func doWork(ctx context.Context, d time.Duration) {
now := time.Now()
delay := time.NewTicker(d)
for {
select {
case <-ctx.Done():
delay.Stop()
return
case <-delay.C:
}
workIn2Minute()
delay.Reset(d)
}
}
现在,workIn2Minute 函数在延迟后执行,但同样,这不起作用,因为在执行 workIn2Minute 时,tick 已经发送到channel。 因此,我们需要在执行任务之前延迟.Stop() ticker代码,然后在任务完成后重置它。 然而,这让我们回到了计时器解决方案。
实现上下文感知睡眠功能
正如我们在上一篇技巧中指出的那样,常规 time.Sleep() 并不关心上下文。
我们之前已经讨论过一个解决方案,但是每次需要时都需要编写它,这可能会很麻烦。 那么,我们如何制作一个更加用户友好的版本,其行为类似于睡眠功能,但也尊重上下文被取消的情况?
我们可以创建一种"假"睡眠函数,如果上下文发出信号,该函数就会停止:
go
func Sleep(ctx context.Context, d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
这个设置允许我们暂停 Go 代码,但是如果有东西告诉上下文停止,睡眠就会提前结束,如下所示:
go
func Job(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
...
_ = Sleep(ctx, 10 * time.Second);
}
}
}
"等等,你为什么不处理 sleep() 的错误?"
嗯,我们可以,但没有必要。
大多数时候,当上下文被取消时,并不是睡眠函数出了问题。 这通常是由于程序中存在更大的问题,其中包括但不限于睡眠部分。
如果代码中的 sleep() 之后还有其他步骤,并且它们被设计为监听上下文,那么如果上下文被取消,它们也会停止。
因此,让我们简化我们的 sleep 函数,使其更短、更直接:
go
- func Sleep(ctx context.Context, d time.Duration) error {
+ func Sleep(ctx context.Context, d time.Duration) {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
- return ctx.Err()
+ return
case <-timer.C:
- return nil
+ return
}
}
这样,该函数就更容易使用并集成到代码的各个部分中,您可能需要上下文感知暂停。