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

Go Context在并发编程的应用

2021-10-296.9k 阅读

Go Context 基础概念

什么是 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就是截止时间。这个截止日期用于告知各个goroutine什么时候应该停止执行。
  2. Done 方法:返回一个只读的channel,类型为<-chan struct{}。当这个channel接收到数据(实际并不会关心接收到的数据具体是什么,只关心有数据到来这个信号),就意味着Context被取消了。所有依赖这个Contextgoroutine应该尽快停止正在执行的任务。
  3. Err 方法:返回Context被取消的原因。如果Context还没有被取消,返回nil;如果是因为超时而取消,返回context.DeadlineExceeded;如果是被手动取消,返回context.Canceled
  4. Value 方法:用于在goroutine之间传递一些键值对数据。这个方法通常用于传递一些请求范围内的全局数据,比如当前请求的用户认证信息等。

为什么需要 Context

在复杂的并发程序中,我们经常会面临以下几个问题:

  1. 取消goroutine:当某个外部条件发生变化时,需要能够及时通知到正在运行的goroutine,让它停止执行。例如,在一个 Web 服务器中,当客户端断开连接时,服务器端正在处理这个客户端请求的goroutine应该能够及时停止,避免资源浪费。
  2. 设置截止时间:为某个goroutine的执行设置一个时间限制。如果goroutine在规定时间内没有完成任务,就应该被取消。比如,在进行数据库查询时,设置一个 5 秒的超时时间,如果 5 秒内没有查询到结果,就取消查询操作。
  3. 传递请求范围的数据:在处理一个请求时,可能需要在多个goroutine之间传递一些与这个请求相关的数据,比如用户认证信息、请求的唯一标识等。

Context的出现正是为了解决这些问题。它提供了一种简洁而强大的方式来管理goroutine的生命周期,并在goroutine之间传递必要的信息。

Context 的类型及创建方式

context.Background

context.Background 是所有Context的根节点,通常作为Context树的最顶层Context。它是一个空的Context,没有截止日期、不会被取消,也没有携带任何值。一般在程序的主函数或者初始化阶段作为起始Context使用。例如:

package main

import (
    "context"
    "fmt"
)

func main() {
    ctx := context.Background()
    fmt.Println(ctx)
}

context.TODO

context.TODO 也是一个空的Context,它的作用主要是在代码暂时不知道该使用哪个Context时作为占位符。比如,在函数的前期设计阶段,可能还不确定需要传递什么样的Context,就可以先用context.TODO,后续再替换为合适的Context。示例如下:

package main

import (
    "context"
    "fmt"
)

func someFunction(ctx context.Context) {
    // 这里暂时不知道用什么ctx,先用TODO占位
    if ctx == nil {
        ctx = context.TODO()
    }
    fmt.Println(ctx)
}

func main() {
    someFunction(nil)
}

context.WithCancel

context.WithCancel 用于创建一个可取消的Context。它接受一个父Context作为参数,并返回一个新的Context和一个取消函数(CancelFunc)。调用取消函数时,会取消这个新创建的Context,同时也会取消所有基于这个Context派生出来的子Context。示例代码如下:

package main

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

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

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

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

在上述代码中,worker函数在一个无限循环中检查Context是否被取消。main函数启动一个worker goroutine,3 秒后调用cancel函数取消Contextworker goroutine检测到Context被取消后停止工作。

context.WithDeadline

context.WithDeadline 用于创建一个带有截止日期的Context。它接受一个父Context、截止时间(time.Time类型)作为参数,返回一个新的Context和一个取消函数。当到达截止时间时,Context会自动被取消,当然也可以提前调用取消函数手动取消。示例如下:

package main

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

func task(ctx context.Context) {
    select {
    case <-ctx.Done():
        fmt.Println("task cancelled:", ctx.Err())
    case <-time.After(5 * time.Second):
        fmt.Println("task completed")
    }
}

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

    go task(ctx)

    time.Sleep(5 * time.Second)
}

在这个例子中,task函数要么在 5 秒内正常完成,要么在 3 秒截止时间到达时被取消。main函数设置了一个 3 秒后的截止时间,并启动task goroutine,3 秒后Context被取消,task goroutine收到取消信号并输出取消原因。

context.WithTimeout

context.WithTimeout 本质上是context.WithDeadline的一种便捷形式。它接受一个父Context和一个超时时间(time.Duration类型)作为参数,内部会根据当前时间加上超时时间来计算截止日期。同样返回一个新的Context和一个取消函数。示例如下:

package main

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

func anotherTask(ctx context.Context) {
    select {
    case <-ctx.Done():
        fmt.Println("another task cancelled:", ctx.Err())
    case <-time.After(4 * time.Second):
        fmt.Println("another task completed")
    }
}

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

    go anotherTask(ctx)

    time.Sleep(5 * time.Second)
}

这里anotherTask函数在 4 秒内完成任务或者在 2 秒超时时间到达时被取消。main函数通过context.WithTimeout创建一个 2 秒超时的Context并启动anotherTask goroutine

context.WithValue

context.WithValue 用于创建一个携带值的Context。它接受一个父Context、键和值作为参数,返回一个新的Context。这个新的Context会携带传入的键值对,并且可以通过ContextValue方法在goroutine之间获取这个值。需要注意的是,键应该是可比较的类型(如字符串、整数等),并且在整个应用程序中应该是唯一的,以避免冲突。示例如下:

package main

import (
    "context"
    "fmt"
)

func process(ctx context.Context) {
    value := ctx.Value("key")
    if value != nil {
        fmt.Println("value from context:", value)
    }
}

func main() {
    ctx := context.WithValue(context.Background(), "key", "value")
    go process(ctx)

    time.Sleep(1 * time.Second)
}

在上述代码中,main函数创建了一个携带键值对("key", "value")Context,并传递给process goroutineprocess goroutine通过ctx.Value("key")获取到对应的值并输出。

Context 在并发编程中的应用场景

Web 服务器中的应用

在 Web 服务器开发中,Context起着至关重要的作用。当服务器接收到一个请求时,会为这个请求创建一个Context,这个Context会在处理这个请求的各个goroutine之间传递。例如,在处理一个 HTTP 请求时,可能会涉及到数据库查询、调用其他微服务等操作,这些操作可能会在不同的goroutine中执行。通过Context,可以在这些goroutine之间传递请求的截止时间(如设置整个请求的处理超时时间)、取消信号(当客户端断开连接时取消相关操作)以及请求范围的数据(如用户认证信息)。

以下是一个简单的 HTTP 服务器示例,展示了如何在处理请求时使用Context

package main

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

func handler(w http.ResponseWriter, r *http.Request) {
    // 获取请求的Context
    ctx := r.Context()

    // 设置一个5秒的超时时间
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()

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

    select {
    case <-ctx.Done():
        http.Error(w, "request timed out", http.StatusGatewayTimeout)
    case <-done:
        fmt.Fprintf(w, "request completed")
    }
}

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

在这个例子中,handler函数从 HTTP 请求中获取Context,并设置了一个 5 秒的超时时间。然后模拟一个 3 秒的耗时操作,通过select语句监听Context的取消信号和操作完成信号。如果 5 秒内操作没有完成,返回请求超时的错误。

分布式系统中的应用

在分布式系统中,一个请求可能会涉及到多个服务之间的调用。Context可以在这些服务调用之间传递,确保整个分布式事务能够根据统一的截止日期、取消信号等进行管理。例如,在一个微服务架构中,一个用户请求可能会依次调用用户服务、订单服务、支付服务等。通过在每个服务调用中传递Context,可以实现当某个服务调用超时时,整个请求链路上的其他服务调用也能及时取消,避免资源浪费和不一致的状态。

假设我们有三个微服务:用户服务(UserService)、订单服务(OrderService)和支付服务(PaymentService),它们之间通过Context进行交互。示例代码如下:

package main

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

// UserService 模拟用户服务
func UserService(ctx context.Context) error {
    select {
    case <-ctx.Done():
        return ctx.Err()
    case <-time.After(2 * time.Second):
        fmt.Println("UserService completed")
        return nil
    }
}

// OrderService 模拟订单服务
func OrderService(ctx context.Context) error {
    select {
    case <-ctx.Done():
        return ctx.Err()
    case <-time.After(3 * time.Second):
        fmt.Println("OrderService completed")
        return nil
    }
}

// PaymentService 模拟支付服务
func PaymentService(ctx context.Context) error {
    select {
    case <-ctx.Done():
        return ctx.Err()
    case <-time.After(4 * time.Second):
        fmt.Println("PaymentService completed")
        return nil
    }
}

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

    err := UserService(ctx)
    if err != nil {
        fmt.Println("UserService failed:", err)
        return
    }

    err = OrderService(ctx)
    if err != nil {
        fmt.Println("OrderService failed:", err)
        return
    }

    err = PaymentService(ctx)
    if err != nil {
        fmt.Println("PaymentService failed:", err)
        return
    }

    fmt.Println("All services completed successfully")
}

在这个例子中,main函数创建了一个 5 秒超时的Context,并依次调用三个微服务。如果某个服务在 5 秒内没有完成,Context会被取消,后续的服务调用也会收到取消信号并返回错误。

数据库操作中的应用

在进行数据库操作时,Context可以用于设置操作的超时时间和取消操作。例如,在执行一个复杂的数据库查询时,可能需要设置一个合理的超时时间,以避免查询长时间阻塞。同时,如果在查询过程中,应用程序接收到了取消信号(如用户手动取消查询),可以通过Context及时取消数据库查询操作。

以下是一个使用database/sql包进行数据库查询并结合Context的示例:

package main

import (
    "context"
    "database/sql"
    "fmt"
    _ "github.com/lib/pq" // 假设使用PostgreSQL
    "time"
)

func queryDB(ctx context.Context, db *sql.DB) {
    rows, err := db.QueryContext(ctx, "SELECT * FROM some_table WHERE some_condition")
    if err != nil {
        if ctx.Err() == context.Canceled {
            fmt.Println("query cancelled")
        } else if ctx.Err() == context.DeadlineExceeded {
            fmt.Println("query timed out")
        } else {
            fmt.Println("query error:", err)
        }
        return
    }
    defer rows.Close()

    for rows.Next() {
        // 处理查询结果
    }

    if err := rows.Err(); err != nil {
        fmt.Println("row iteration error:", err)
    }
}

func main() {
    db, err := sql.Open("postgres", "user=postgres dbname=mydb sslmode=disable")
    if err != nil {
        fmt.Println("failed to open database:", err)
        return
    }
    defer db.Close()

    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    go queryDB(ctx, db)

    time.Sleep(5 * time.Second)
}

在这个示例中,queryDB函数使用db.QueryContext方法执行数据库查询,并通过Context来处理查询过程中的取消和超时情况。main函数创建了一个 3 秒超时的Context并启动queryDB goroutine

Context 使用的注意事项

正确传递 Context

在使用Context时,确保Context在整个goroutine调用链中正确传递是非常重要的。如果某个goroutine没有接收到正确的Context,可能会导致无法正确处理取消信号或截止日期。通常,建议将Context作为函数的第一个参数传递,并且在创建新的goroutine时,将当前的Context传递给新的goroutine。例如:

package main

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

func subTask(ctx context.Context) {
    select {
    case <-ctx.Done():
        fmt.Println("subTask cancelled")
    case <-time.After(2 * time.Second):
        fmt.Println("subTask completed")
    }
}

func mainTask(ctx context.Context) {
    go subTask(ctx)

    time.Sleep(1 * time.Second)
    // 取消Context
    cancel := func() {}
    if cancelCtx, ok := ctx.(interface{ Cancel() }); ok {
        cancel = cancelCtx.Cancel
    }
    cancel()

    time.Sleep(1 * time.Second)
}

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

    time.Sleep(2 * time.Second)
}

在这个例子中,mainTask函数将Context传递给subTask goroutine,确保subTask能够接收到取消信号。

避免 Context 泄露

如果在goroutine中使用了Context,但没有正确处理取消信号,可能会导致goroutine无法停止,从而造成资源泄露。特别是在一些长时间运行的goroutine中,一定要在select语句中监听ContextDone通道。例如:

package main

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

func badWorker(ctx context.Context) {
    for {
        // 没有监听Context的取消信号
        fmt.Println("bad worker working")
        time.Sleep(1 * time.Second)
    }
}

func goodWorker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("good worker stopped")
            return
        default:
            fmt.Println("good worker working")
            time.Sleep(1 * time.Second)
        }
    }
}

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

    go badWorker(ctx)
    go goodWorker(ctx)

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

    time.Sleep(2 * time.Second)
}

在上述代码中,badWorker没有监听Context的取消信号,即使Context被取消,它也会继续运行;而goodWorker正确监听了Context的取消信号,能够在Context被取消时停止工作。

注意 Context 的生命周期

不同类型的Context有不同的生命周期管理方式。例如,context.WithCancel创建的Context需要手动调用取消函数来取消;context.WithDeadlinecontext.WithTimeout创建的Context会在截止时间到达时自动取消。在使用这些Context时,要清楚它们的生命周期特点,避免出现意外情况。比如,在使用context.WithTimeout时,如果没有正确设置超时时间,可能会导致某些操作执行时间过长,影响系统性能。

谨慎使用 context.WithValue

虽然context.WithValue提供了一种在goroutine之间传递数据的便捷方式,但要谨慎使用。因为通过Context传递的值可能会在整个goroutine调用链中传播,使得代码的依赖关系变得不那么清晰。尽量只在必要时使用context.WithValue传递一些与请求范围紧密相关的全局数据,并且要确保键的唯一性,避免与其他地方使用的键冲突。

总结 Context 在并发编程中的优势

  1. 简洁的取消机制:通过Context,可以非常方便地在多个goroutine之间传递取消信号,使得goroutine的生命周期管理变得更加容易。无论是在 Web 服务器、分布式系统还是数据库操作中,都能够及时停止不必要的goroutine,释放资源。
  2. 统一的超时管理Context提供了设置截止日期和超时时间的功能,使得在不同的并发操作中可以统一管理超时。这有助于提高系统的稳定性和性能,避免因为某些操作长时间阻塞而导致整个系统响应变慢。
  3. 方便的数据传递context.WithValue方法使得在goroutine之间传递请求范围的数据变得很方便,并且这种传递方式与goroutine的调用链紧密结合,不会像全局变量那样带来线程安全等问题。

总之,Context是 Go 语言并发编程中一个非常强大且重要的工具,深入理解并正确使用它对于编写高效、健壮的并发程序至关重要。通过合理运用Context,可以更好地管理goroutine的生命周期,提高系统的稳定性和性能。