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

Go Context常见用法的深入剖析

2022-02-057.4k 阅读

Go Context 概述

在 Go 语言的并发编程中,context 包扮演着至关重要的角色。context 主要用于在多个 goroutine 之间传递截止时间、取消信号和其他请求范围的值。它为处理与请求生命周期相关的操作提供了一种优雅且高效的方式,特别是在需要控制 goroutine 的执行、管理资源以及处理超时的场景下。

context.Context 是一个接口,定义如下:

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}
  • Deadline:该方法返回一个截止时间 deadline 和一个布尔值 ok。如果 oktrue,则表示有截止时间,deadline 是截止时间点;如果 okfalse,则表示没有设置截止时间。
  • Done:返回一个只读的通道 <-chan struct{}。当 context 被取消或超时的时候,这个通道会被关闭。goroutine 可以通过监听这个通道来判断是否需要停止工作。
  • Err:返回 context 被取消或超时的原因。如果 Done 通道还没有关闭,Err 会返回 nil;如果 Done 通道已关闭,Err 会返回一个非 nil 的错误,表明取消的原因,常见的错误有 context.Canceledcontext.DeadlineExceeded
  • Value:该方法用于从 context 中获取与特定键关联的值。它主要用于在不同的 goroutine 之间传递请求范围的数据,比如用户认证信息、请求 ID 等。

几种常见的 Context 类型

  1. Backgroundcontext.Background 是所有 context 的根,通常用于 main 函数、初始化以及测试代码中。它不会被取消,没有截止时间,也没有与之关联的值。
func main() {
    ctx := context.Background()
    // 使用 ctx 启动其他 goroutine
}
  1. TODOcontext.TODO 用于暂时不清楚该使用哪种 context 的场景。例如,在代码的前期设计阶段,可能还不确定是否需要设置截止时间或取消功能,此时可以使用 TODO。它同样不会被取消,没有截止时间,也没有与之关联的值。
func someFunction() {
    ctx := context.TODO()
    // 后续根据需求替换为合适的 context
}
  1. WithCancelcontext.WithCancel 用于创建一个可取消的 context。它接受一个父 context 作为参数,并返回一个新的 context 和一个取消函数 cancel。调用取消函数 cancel 时,会关闭新 contextDone 通道,从而通知所有监听该通道的 goroutine 停止工作。
func main() {
    ctx := context.Background()
    ctx, cancel := context.WithCancel(ctx)

    go func(ctx context.Context) {
        for {
            select {
            case <-ctx.Done():
                fmt.Println("goroutine stopped due to cancellation")
                return
            default:
                fmt.Println("goroutine is working")
                time.Sleep(100 * time.Millisecond)
            }
        }
    }(ctx)

    time.Sleep(500 * time.Millisecond)
    cancel()
    time.Sleep(100 * time.Millisecond)
}

在上述代码中,我们创建了一个可取消的 context,并在一个 goroutine 中监听其 Done 通道。主函数在运行一段时间后调用取消函数 cancel,goroutine 监听到 Done 通道关闭后停止工作。 4. WithDeadlinecontext.WithDeadline 用于创建一个有截止时间的 context。它接受一个父 context、截止时间 deadline 作为参数,并返回一个新的 context 和一个取消函数 cancel。当到达截止时间或者调用取消函数 cancel 时,新 contextDone 通道会被关闭。

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

    go func(ctx context.Context) {
        for {
            select {
            case <-ctx.Done():
                if ctx.Err() == context.DeadlineExceeded {
                    fmt.Println("goroutine stopped due to deadline exceeded")
                } else {
                    fmt.Println("goroutine stopped due to cancellation")
                }
                return
            default:
                fmt.Println("goroutine is working")
                time.Sleep(100 * time.Millisecond)
            }
        }
    }(ctx)

    time.Sleep(1000 * time.Millisecond)
}

在这段代码中,我们设置了一个 500 毫秒后的截止时间。如果 goroutine 在截止时间到达时还未完成工作,它会监听到 Done 通道关闭,并根据 ctx.Err() 判断是因为截止时间超时而停止工作。 5. WithTimeoutcontext.WithTimeoutcontext.WithDeadline 的便捷版本,它接受一个父 context 和一个超时时间 timeout 作为参数,内部通过计算当前时间加上超时时间得到截止时间,然后调用 context.WithDeadline 创建 context。同样返回一个新的 context 和一个取消函数 cancel

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

    go func(ctx context.Context) {
        for {
            select {
            case <-ctx.Done():
                if ctx.Err() == context.DeadlineExceeded {
                    fmt.Println("goroutine stopped due to deadline exceeded")
                } else {
                    fmt.Println("goroutine stopped due to cancellation")
                }
                return
            default:
                fmt.Println("goroutine is working")
                time.Sleep(100 * time.Millisecond)
            }
        }
    }(ctx)

    time.Sleep(1000 * time.Millisecond)
}

此代码与 context.WithDeadline 的示例类似,只是使用 context.WithTimeout 更简洁地设置了超时时间。

Context 在 HTTP 服务器中的应用

在 HTTP 服务器编程中,context 用于管理每个请求的生命周期。当客户端发起一个 HTTP 请求时,服务器会为该请求创建一个 context。这个 context 可以携带请求的截止时间、取消信号等信息,在处理请求的各个阶段传递,确保在请求结束或超时时,相关的资源能够被正确释放,goroutine 能够被合理终止。

package main

import (
    "context"
    "fmt"
    "net/http"
    "time"
)

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    // 设置一个 2 秒的截止时间
    ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
    defer cancel()

    done := make(chan struct{})
    go func() {
        // 模拟一个可能耗时较长的操作
        time.Sleep(3 * time.Second)
        close(done)
    }()

    select {
    case <-ctx.Done():
        fmt.Println("operation cancelled due to timeout or request cancellation")
        http.Error(w, "operation cancelled", http.StatusRequestTimeout)
    case <-done:
        fmt.Println("operation completed successfully")
        fmt.Fprintf(w, "operation completed successfully")
    }
}

func main() {
    http.HandleFunc("/", handler)
    fmt.Println("Server listening on :8080")
    http.ListenAndServe(":8080", nil)
}

在上述代码中,我们在 HTTP 处理函数 handler 中从请求 r 中获取 context,然后设置了一个 2 秒的超时时间。接着,我们启动一个 goroutine 模拟一个可能耗时较长的操作。通过 select 语句监听 ctx.Done() 通道和 done 通道,当 ctx.Done() 通道关闭时,说明请求超时或被取消,返回相应的错误信息;当 done 通道关闭时,说明操作成功完成,返回成功信息。

Context 在数据库操作中的应用

在与数据库交互时,context 可以用于控制数据库操作的生命周期,特别是在处理连接、查询等操作时。通过传递合适的 context,可以确保在请求取消或超时时,数据库操作能够及时终止,避免资源浪费。

package main

import (
    "context"
    "fmt"
    "go.mongodb.org/mongo-driver/bson"
    "go.mongodb.org/mongo-driver/mongo"
    "go.mongodb.org/mongo-driver/mongo/options"
    "time"
)

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

    client, err := mongo.Connect(ctx, options.Client().ApplyURI("mongodb://localhost:27017"))
    if err != nil {
        fmt.Println("Failed to connect to MongoDB:", err)
        return
    }
    defer func() {
        if err = client.Disconnect(ctx); err != nil {
            fmt.Println("Failed to disconnect from MongoDB:", err)
        }
    }()

    collection := client.Database("test").Collection("users")

    ctx, cancel = context.WithTimeout(ctx, 5*time.Second)
    defer cancel()

    var result bson.M
    err = collection.FindOne(ctx, bson.M{"name": "John"}).Decode(&result)
    if err != nil {
        if err == context.DeadlineExceeded {
            fmt.Println("Query timed out")
        } else {
            fmt.Println("Failed to query:", err)
        }
        return
    }
    fmt.Println("Query result:", result)
}

在这段代码中,我们首先使用 context.WithTimeout 创建一个 10 秒的 context 用于连接 MongoDB 数据库。连接成功后,在进行查询操作时,又创建了一个 5 秒的 context 来控制查询的超时时间。如果查询超时,会捕获到 context.DeadlineExceeded 错误并进行相应处理。

Context 在微服务间传递

在微服务架构中,context 可以在不同微服务之间传递,确保整个请求链路中的操作都能受到统一的截止时间和取消信号控制。例如,一个前端请求触发多个微服务的调用,通过传递 context,可以在前端取消请求时,级联取消所有相关微服务的操作。

假设我们有两个微服务 ServiceAServiceBServiceA 调用 ServiceB,代码示例如下:

// ServiceA
package main

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

func callServiceB(ctx context.Context) {
    // 模拟 ServiceB 的调用
    ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
    defer cancel()

    select {
    case <-ctx.Done():
        fmt.Println("ServiceB call cancelled due to context cancellation or timeout")
    case <-time.After(3 * time.Second):
        fmt.Println("ServiceB call completed successfully")
    }
}

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

    go callServiceB(ctx)

    time.Sleep(10 * time.Second)
}
// ServiceB
package main

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

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

    select {
    case <-ctx.Done():
        fmt.Println("ServiceB operation cancelled due to context cancellation or timeout")
    case <-time.After(3 * time.Second):
        fmt.Println("ServiceB operation completed successfully")
    }
}

ServiceA 中,我们创建一个带超时的 context 并传递给 callServiceB 函数,callServiceB 函数模拟调用 ServiceB,在 ServiceB 中同样使用 context 来控制操作的超时和取消。如果在 ServiceA 中的 context 被取消或超时,ServiceB 中的操作也会相应地被取消。

传递请求范围的值

contextValue 方法可以用于在不同的 goroutine 之间传递请求范围的值,比如用户认证信息、请求 ID 等。这些值可以在请求的处理过程中方便地获取和使用。

package main

import (
    "context"
    "fmt"
)

type requestIDKey struct{}

func main() {
    ctx := context.Background()
    ctx = context.WithValue(ctx, requestIDKey{}, "12345")

    go func(ctx context.Context) {
        if id, ok := ctx.Value(requestIDKey{}).(string); ok {
            fmt.Println("Request ID in goroutine:", id)
        }
    }(ctx)

    time.Sleep(100 * time.Millisecond)
}

在上述代码中,我们定义了一个 requestIDKey 结构体类型作为 context.Value 的键,然后使用 context.WithValue 方法将请求 ID "12345"ctx 关联。在 goroutine 中,通过 ctx.Value 获取请求 ID 并进行处理。

注意事项

  1. 不要将 context.Context 类型作为结构体的字段:因为 context 应该是随函数调用传递的,而不是作为结构体的一部分持久保存。如果将 context 作为结构体字段,可能会导致在不需要的时候结构体仍然持有 context,从而影响资源的释放和取消逻辑。
  2. 在函数调用链中尽早传递 context:这样可以确保所有相关的 goroutine 都能及时接收到取消信号或截止时间信息。如果传递过晚,可能会导致部分 goroutine 无法正确处理取消或超时。
  3. 避免在 context 中传递敏感信息:因为 context.Value 方法获取值时没有类型检查,可能会导致敏感信息泄露。如果需要传递敏感信息,应该使用更安全的方式,比如加密或者通过特定的安全通道传递。
  4. 正确处理取消和超时:在监听到 contextDone 通道关闭时,应该尽快清理资源并退出 goroutine,避免造成资源泄漏。同时,要根据 ctx.Err() 判断是取消还是超时,进行不同的处理。

通过深入理解和正确使用 Go 的 context,我们可以更好地管理并发编程中的资源和控制流,提高程序的健壮性和性能。无论是在简单的 HTTP 服务器,还是复杂的微服务架构中,context 都为我们提供了强大的工具来处理请求的生命周期和并发操作。