MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Go使用context管理子协程的退出逻辑

2022-05-022.9k 阅读

Go 中 context 的基本概念

在 Go 语言的并发编程模型里,context(上下文)是一个至关重要的概念。它主要用于在多个goroutine(协程)之间传递截止时间、取消信号以及其他请求相关的值。简单来说,context就像是一个携带各种信息的“包裹”,在不同的goroutine之间传递,这些信息可以控制goroutine的生命周期以及传递一些关键数据。

从本质上讲,context是一个接口类型,其定义如下:

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}
  1. Deadline 方法:该方法返回一个time.Time类型的截止时间以及一个布尔值。布尔值oktrue时,表示设置了截止时间;deadline就是这个截止时间。当到达截止时间后,context会自动取消相关的操作。例如,在一个网络请求的goroutine中,如果设置了 5 秒的截止时间,5 秒后context会自动触发取消,防止请求无限期等待。
  2. Done 方法:返回一个只读的通道<-chan struct{}。当context被取消(无论是手动取消还是到达截止时间)时,这个通道会被关闭。goroutine可以监听这个通道,一旦通道关闭,就知道应该停止当前执行的任务。
  3. Err 方法:当Done通道被关闭后,可以通过调用Err方法获取context取消的原因。如果context是被手动取消的,Err方法返回context.Canceled;如果是因为超过截止时间取消的,返回context.DeadlineExceeded
  4. Value 方法:用于从context中获取键值对数据。它可以在不同的goroutine之间传递一些请求相关的数据,比如用户认证信息、请求 ID 等。

为什么要使用 context 管理子协程退出逻辑

在 Go 语言的并发编程场景中,经常会创建大量的goroutine来处理各种任务。然而,管理这些goroutine的生命周期,特别是在合适的时机优雅地退出它们,是一个具有挑战性的问题。

  1. 资源管理:如果goroutine在执行过程中占用了一些资源,比如文件句柄、网络连接等。当不再需要这个goroutine时,如果没有正确地关闭资源就直接退出,会导致资源泄漏。例如,一个goroutine打开了一个数据库连接进行数据查询,如果没有在退出时关闭连接,随着时间的推移,数据库可能会因为连接耗尽而无法提供服务。
  2. 防止内存泄漏goroutine本身也占用内存资源。如果大量goroutine没有在合适的时机退出,会导致内存不断增加,最终可能耗尽系统内存,导致程序崩溃。
  3. 提高程序健壮性:当外部条件发生变化(比如用户取消操作、系统资源不足等)时,程序需要能够及时响应并取消正在执行的goroutine,以保证整个系统的稳定性和可靠性。

context为解决这些问题提供了一种优雅且高效的方式。通过context,可以在不同层级的goroutine之间传递取消信号,使得所有相关的goroutine能够在合适的时机安全地退出,释放所占用的资源。

context 常用类型及使用场景

context.Background

context.Background是所有context的根节点,通常用于主函数、初始化以及测试代码中。它是一个空的context,没有截止时间、取消功能和携带值。例如:

func main() {
    ctx := context.Background()
    // 基于 ctx 创建子 context 并启动 goroutine
    go doWork(ctx)
    // 主函数执行其他逻辑
    time.Sleep(2 * time.Second)
}

在上述代码中,main函数创建了一个context.Background,并将其作为参数传递给doWork函数开启的goroutinecontext.Background就像是一个“起点”,后续可以基于它创建具有各种功能(如取消、设置截止时间等)的子context

context.TODO

context.TODO也是一个空的context,与context.Background类似。它主要用于暂时不知道应该使用哪种context的场景,通常是在代码开发过程中作为占位符使用。例如:

func someFunction() {
    ctx := context.TODO()
    // 后续根据实际需求替换 ctx
    // ...
}

在这个例子中,someFunction函数一开始使用context.TODO作为占位,随着功能的完善和需求的明确,可以将其替换为合适的context类型,如带有截止时间或取消功能的context

context.WithCancel

context.WithCancel用于创建一个可取消的context。它接受一个父context作为参数,并返回一个新的context和一个取消函数cancel。调用取消函数cancel时,会关闭新contextDone通道,从而通知所有依赖这个contextgoroutine应该停止工作。例如:

func main() {
    ctx := context.Background()
    ctx, cancel := context.WithCancel(ctx)

    go func(ctx context.Context) {
        for {
            select {
            case <-ctx.Done():
                fmt.Println("goroutine is canceled")
                return
            default:
                fmt.Println("goroutine is working")
                time.Sleep(1 * time.Second)
            }
        }
    }(ctx)

    time.Sleep(3 * time.Second)
    cancel()
    time.Sleep(1 * time.Second)
}

在上述代码中,首先通过context.WithCancel创建了一个可取消的context和对应的取消函数cancel。然后开启一个goroutine,在goroutine内部通过select语句监听ctx.Done()通道。当主函数在 3 秒后调用cancel函数时,ctx.Done()通道被关闭,goroutine接收到取消信号并安全退出。

context.WithDeadline

context.WithDeadline用于创建一个带有截止时间的context。它接受一个父context、截止时间deadline作为参数,并返回一个新的context和一个取消函数cancel。当到达截止时间时,context会自动取消,同时也可以通过调用取消函数cancel提前取消。例如:

func main() {
    ctx := context.Background()
    deadline := time.Now().Add(2 * time.Second)
    ctx, cancel := context.WithDeadline(ctx, deadline)
    defer cancel()

    go func(ctx context.Context) {
        for {
            select {
            case <-ctx.Done():
                fmt.Println("goroutine is canceled due to deadline or manual cancel")
                return
            default:
                fmt.Println("goroutine is working")
                time.Sleep(1 * time.Second)
            }
        }
    }(ctx)

    time.Sleep(3 * time.Second)
}

在这段代码中,设置了一个 2 秒后的截止时间。goroutine会在 2 秒后因为截止时间到达而自动取消。如果在 2 秒内调用cancel函数,也可以提前取消goroutine。通过defer cancel()语句,确保无论函数如何结束,都能正确调用取消函数,避免资源泄漏。

context.WithTimeout

context.WithTimeoutcontext.WithDeadline的一种便捷形式,它接受一个父context和超时时间timeout作为参数,会自动计算截止时间。例如:

func main() {
    ctx := context.Background()
    ctx, cancel := context.WithTimeout(ctx, 2 * time.Second)
    defer cancel()

    go func(ctx context.Context) {
        for {
            select {
            case <-ctx.Done():
                fmt.Println("goroutine is canceled due to timeout or manual cancel")
                return
            default:
                fmt.Println("goroutine is working")
                time.Sleep(1 * time.Second)
            }
        }
    }(ctx)

    time.Sleep(3 * time.Second)
}

上述代码与使用context.WithDeadline类似,只是这里通过context.WithTimeout更简洁地设置了 2 秒的超时时间。context.WithTimeout会根据当前时间加上指定的超时时间来计算截止时间,使用起来更加方便。

使用 context 管理多层级子协程的退出逻辑

在实际应用中,goroutine之间往往存在多层嵌套的关系。例如,一个主goroutine启动多个子goroutine,每个子goroutine又可能启动更多的孙子goroutine。使用context可以有效地管理这种多层级goroutine的退出逻辑。

示例代码

package main

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

func grandChild(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("grandChild goroutine is canceled")
            return
        default:
            fmt.Println("grandChild goroutine is working")
            time.Sleep(1 * time.Second)
        }
    }
}

func child(ctx context.Context) {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()

    go grandChild(ctx)

    for {
        select {
        case <-ctx.Done():
            fmt.Println("child goroutine is canceled")
            return
        default:
            fmt.Println("child goroutine is working")
            time.Sleep(1 * time.Second)
        }
    }
}

func main() {
    ctx := context.Background()
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()

    go child(ctx)

    time.Sleep(3 * time.Second)
    cancel()
    time.Sleep(1 * time.Second)
}

代码分析

  1. grandChild函数:它是最内层的goroutine,通过监听传入的contextDone通道来判断是否应该取消。当Done通道关闭时,打印取消信息并返回,停止工作。
  2. child函数:它创建了一个新的可取消的context,并启动了grandChild goroutinechild函数自身也通过监听contextDone通道来判断是否取消。当它接收到取消信号时,会先调用自己创建的context的取消函数,从而关闭grandChild goroutine监听的Done通道,确保grandChild goroutine也能正确取消。
  3. main函数:作为程序的入口,创建了一个可取消的context,并启动了child goroutine。3 秒后调用取消函数,取消整个goroutine树。通过这种方式,从根context开始,取消信号会一层一层传递下去,确保所有相关的goroutine都能安全退出。

context 与资源管理

文件资源管理

goroutine中操作文件时,需要确保在goroutine结束时正确关闭文件,以避免资源泄漏。例如:

func readFile(ctx context.Context, filePath string) {
    file, err := os.Open(filePath)
    if err != nil {
        fmt.Printf("Failed to open file: %v\n", err)
        return
    }
    defer file.Close()

    for {
        select {
        case <-ctx.Done():
            fmt.Println("goroutine is canceled, closing file")
            return
        default:
            // 读取文件逻辑
            buffer := make([]byte, 1024)
            n, err := file.Read(buffer)
            if err != nil && err != io.EOF {
                fmt.Printf("Failed to read file: %v\n", err)
                return
            }
            if n > 0 {
                fmt.Printf("Read %d bytes from file\n", n)
            }
            if err == io.EOF {
                fmt.Println("End of file reached")
                return
            }
            time.Sleep(1 * time.Second)
        }
    }
}

在上述代码中,readFile函数打开一个文件进行读取。在goroutine的执行过程中,通过监听contextDone通道。当接收到取消信号时,先打印提示信息,然后正常返回,确保defer语句能够关闭文件,避免文件句柄泄漏。

网络连接资源管理

在处理网络请求时,context同样可以用于管理网络连接资源。例如,使用net/http包发送 HTTP 请求:

func sendHttpRequest(ctx context.Context, url string) {
    client := &http.Client{}
    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    if err != nil {
        fmt.Printf("Failed to create request: %v\n", err)
        return
    }

    resp, err := client.Do(req)
    if err != nil {
        if ctx.Err() == context.Canceled {
            fmt.Println("Request canceled")
        } else {
            fmt.Printf("Failed to send request: %v\n", err)
        }
        return
    }
    defer resp.Body.Close()

    // 处理响应逻辑
    body, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        fmt.Printf("Failed to read response body: %v\n", err)
        return
    }
    fmt.Printf("Response body: %s\n", body)
}

在这个例子中,http.NewRequestWithContext函数将context关联到 HTTP 请求中。如果在请求过程中context被取消,client.Do方法会返回错误,通过判断ctx.Err()是否为context.Canceled,可以确定是请求被取消,从而进行相应的处理。同时,通过defer resp.Body.Close()确保在请求完成后关闭响应体,释放网络连接资源。

context 与错误处理

处理取消导致的错误

context因为取消而导致相关操作失败时,需要正确处理错误信息,以便程序能够做出合适的响应。例如:

func doWork(ctx context.Context) error {
    for {
        select {
        case <-ctx.Done():
            if ctx.Err() == context.Canceled {
                return fmt.Errorf("operation canceled")
            } else if ctx.Err() == context.DeadlineExceeded {
                return fmt.Errorf("operation timed out")
            }
            return fmt.Errorf("unknown context error")
        default:
            // 模拟工作
            fmt.Println("working...")
            time.Sleep(1 * time.Second)
        }
    }
}

在上述代码中,doWork函数在接收到context取消信号时,根据ctx.Err()返回的错误类型返回不同的错误信息。调用者可以根据这些错误信息进行相应的处理,比如提示用户操作被取消或超时。

传递错误信息

context不仅可以用于传递取消信号,还可以在goroutine之间传递错误信息。例如:

type ErrorContextKey struct{}

func worker(ctx context.Context) {
    err := someOperation()
    if err != nil {
        ctx = context.WithValue(ctx, ErrorContextKey{}, err)
    }
    // 其他逻辑
}

func main() {
    ctx := context.Background()
    go worker(ctx)

    time.Sleep(2 * time.Second)

    if err, ok := ctx.Value(ErrorContextKey{}).(error); ok {
        fmt.Printf("Error in worker: %v\n", err)
    }
}

func someOperation() error {
    // 模拟可能出错的操作
    return fmt.Errorf("operation failed")
}

在这个例子中,worker函数在执行someOperation时如果发生错误,通过context.WithValue将错误信息存入context。在main函数中,可以通过ctx.Value获取错误信息并进行处理。这样就实现了在不同goroutine之间传递错误信息,方便统一的错误处理。

context 的注意事项

不要将 context 放入结构体

虽然context是一个接口类型,可以像其他类型一样传递,但不应该将其放入结构体中。因为context主要用于在函数调用链中传递,而不是作为结构体的成员变量。如果将context放入结构体,会使得结构体的复用性降低,并且在context发生变化时,很难确保所有相关的结构体实例都能及时更新。例如:

// 不推荐的做法
type MyStruct struct {
    ctx context.Context
}

func (ms *MyStruct) doWork() {
    // 使用 ms.ctx
}

在上述代码中,MyStruct结构体包含了一个context成员变量。如果context需要改变,比如需要传递一个新的带有不同截止时间的context,就需要修改MyStruct的实例,这会带来很多麻烦。

正确传递 context

在函数调用过程中,应该将context作为第一个参数传递,并且尽量在函数的开头就进行检查。例如:

func processRequest(ctx context.Context, request Request) {
    if ctx.Err() != nil {
        fmt.Println("context is already canceled or has an error")
        return
    }
    // 处理请求逻辑
}

这样可以确保在函数执行任何复杂操作之前,先检查context的状态,避免在context已经取消的情况下继续执行不必要的操作,浪费资源。

避免滥用 context.Value

虽然context.Value可以在goroutine之间传递数据,但不应该滥用。因为context.Value传递的数据不容易被追踪和调试,并且会增加代码的复杂性。应该尽量通过函数参数传递数据,只有在一些特殊情况下,比如传递请求相关的全局数据(如用户认证信息、请求 ID 等)时,才使用context.Value

注意 context 的生命周期

在使用context时,要注意其生命周期。例如,在使用context.WithCancel创建的可取消context,要确保在合适的时机调用取消函数,避免资源泄漏。同时,在使用context.WithDeadlinecontext.WithTimeout时,要合理设置截止时间和超时时间,避免设置过短导致任务无法完成,或设置过长导致资源长时间占用。

通过正确理解和使用context,可以有效地管理 Go 语言中goroutine的退出逻辑,提高程序的健壮性和资源利用率。在实际开发中,要根据具体的需求选择合适的context类型,并遵循相关的使用规范和注意事项。