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

Go语言中的Context包在并发中的应用

2021-06-213.9k 阅读

Go语言并发编程基础

在深入探讨 Context 包之前,我们先来回顾一下Go语言的并发编程基础。Go语言在设计之初就将并发编程作为其核心特性之一,通过 goroutinechannel 来实现高效的并发编程。

goroutine

goroutine 是Go语言中实现并发的轻量级线程。与传统线程相比,goroutine 的创建和销毁开销极小,一个程序中可以轻松创建数以万计的 goroutine

package main

import (
    "fmt"
    "time"
)

func say(s string) {
    for i := 0; i < 5; i++ {
        time.Sleep(100 * time.Millisecond)
        fmt.Println(s)
    }
}

func main() {
    go say("world")
    say("hello")
}

在上述代码中,通过 go 关键字启动了一个新的 goroutine 来执行 say("world") 函数,同时主线程继续执行 say("hello") 函数。这两个函数并发执行,goroutine 的调度由Go语言的运行时系统负责。

channel

channel 是Go语言中用于在 goroutine 之间进行通信和同步的机制。它可以看作是一个管道,数据可以从一端发送,从另一端接收。

package main

import (
    "fmt"
)

func sum(s []int, c chan int) {
    sum := 0
    for _, v := range s {
        sum += v
    }
    c <- sum
}

func main() {
    s := []int{7, 2, 8, -9, 4, 0}

    c := make(chan int)
    go sum(s[:len(s)/2], c)
    go sum(s[len(s)/2:], c)
    x, y := <-c, <-c

    fmt.Println(x, y, x+y)
}

在这段代码中,我们创建了一个 channel c,然后启动两个 goroutine 分别计算切片的前半部分和后半部分的和,并将结果通过 channel 发送回来。主线程通过从 channel 接收数据来获取计算结果。

Context包的基本概念

在复杂的并发程序中,我们常常需要对 goroutine 的生命周期进行控制,比如在某个操作完成后取消所有相关的 goroutine,或者设置操作的超时时间。Context 包就是为了解决这些问题而设计的。

Context接口

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

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}
  • Deadline 方法返回 Context 的截止时间。如果截止时间已过,ok 返回 false
  • Done 方法返回一个只读的 channel,当 Context 被取消或者超时时,这个 channel 会被关闭。
  • Err 方法返回 Context 被取消或超时的原因。
  • Value 方法用于从 Context 中获取与给定键关联的值。

常用的Context实现

  • background.Context:这是所有 Context 的根 Context,通常作为整个 Context 树的起点。
  • todo.Context:目前用途与 background.Context 类似,主要用于占位,在不确定使用哪种 Context 时可以使用。
  • WithCancel:创建一个可取消的 Context。返回的 cancel 函数用于取消这个 Context,所有基于这个 Context 创建的子 Context 也会被取消。
  • WithDeadline:创建一个带有截止时间的 Context。当截止时间到达时,Context 会自动取消。
  • WithTimeout:创建一个带有超时时间的 Context,它是 WithDeadline 的便捷封装,通过当前时间加上超时时间来计算截止时间。
  • WithValue:创建一个携带值的 Context,可以通过 Value 方法获取这个值。

Context包在并发中的应用场景

控制goroutine的生命周期

在实际应用中,我们常常需要在某个条件满足时取消一组相关的 goroutine。例如,在一个Web服务器中,当客户端关闭连接时,我们需要取消所有正在处理该客户端请求的 goroutine

package main

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

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

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

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

在上述代码中,我们创建了一个可取消的 Context ctx 和对应的取消函数 cancelworker 函数通过监听 ctx.Done() 通道来判断是否需要停止工作。在 main 函数中,我们先启动 worker goroutine,然后在500毫秒后调用 cancel 函数取消 Context,从而终止 worker goroutine

设置操作超时

在进行网络请求、数据库查询等操作时,我们通常需要设置一个超时时间,以防止操作无限期阻塞。

package main

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

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

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

    go longOperation(ctx)

    time.Sleep(3 * time.Second)
}

在这段代码中,我们使用 context.WithTimeout 创建了一个超时时间为1秒的 ContextlongOperation 函数通过监听 ctx.Done() 通道来判断操作是否超时。如果在2秒内操作没有完成,且 Context 超时(1秒后),则会打印 “operation timed out”。

传递请求范围的数据

在处理一个请求时,我们可能需要在多个 goroutine 之间传递一些与请求相关的数据,比如用户认证信息、请求ID等。ContextWithValue 方法可以方便地实现这一点。

package main

import (
    "context"
    "fmt"
)

type requestIDKey struct{}

func processRequest(ctx context.Context) {
    reqID := ctx.Value(requestIDKey{}).(string)
    fmt.Printf("Processing request with ID: %s\n", reqID)
}

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

    time.Sleep(100 * time.Millisecond)
}

在上述代码中,我们定义了一个 requestIDKey 结构体作为 Context 中值的键。通过 context.WithValue 将请求ID “12345” 放入 Context 中,在 processRequest 函数中通过 ctx.Value 方法获取这个请求ID。

Context的层级结构

Context 可以形成一个层级结构,子 Context 继承父 Context 的截止时间、取消信号等属性。这种层级结构使得在复杂的并发场景中可以方便地管理和控制一组相关的 goroutine

package main

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

func childWorker(ctx context.Context, name string) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("%s stopped\n", name)
            return
        default:
            fmt.Printf("%s working...\n", name)
            time.Sleep(100 * time.Millisecond)
        }
    }
}

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

    go childWorker(ctx, "child1")
    go childWorker(ctx, "child2")

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

在这段代码中,child1child2goroutine 都基于同一个可取消的 Context ctx 创建。当调用 cancel 函数取消 ctx 时,两个子 goroutine 都会收到取消信号并停止工作。

嵌套的Context

我们可以基于一个已有的 Context 创建新的子 Context,从而形成更复杂的层级结构。

package main

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

func grandChildWorker(ctx context.Context, name string) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("%s stopped\n", name)
            return
        default:
            fmt.Printf("%s working...\n", name)
            time.Sleep(100 * time.Millisecond)
        }
    }
}

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

    childCtx, childCancel := context.WithCancel(ctx)

    go grandChildWorker(childCtx, "grandChild1")
    go grandChildWorker(childCtx, "grandChild2")

    time.Sleep(300 * time.Millisecond)
    childCancel()

    time.Sleep(200 * time.Millisecond)
    cancel()

    time.Sleep(100 * time.Millisecond)
}

在上述代码中,我们首先创建了一个根 Context ctx 和对应的取消函数 cancel。然后基于 ctx 创建了一个子 Context childCtx 和子取消函数 childCancelgrandChild1grandChild2goroutine 基于 childCtx 创建。当调用 childCancel 时,只会取消 grandChild1grandChild2goroutine,而调用 cancel 时会取消所有相关的 goroutine

Context在Web开发中的应用

在Go语言的Web开发框架中,如 net/httpContext 被广泛应用于处理HTTP请求。

在http.Handler中使用Context

package main

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

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    // 设置一个子Context,用于特定的操作
    subCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
    defer cancel()

    // 模拟一个可能超时的操作
    select {
    case <-time.After(3 * time.Second):
        fmt.Fprintf(w, "Operation completed")
    case <-subCtx.Done():
        fmt.Fprintf(w, "Operation timed out")
    }
}

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

在上述代码中,通过 r.Context() 获取请求的 Context,然后基于这个 Context 创建一个带有超时时间的子 Context subCtx。在处理请求时,通过监听 subCtx.Done() 来判断操作是否超时。

传递请求相关数据

package main

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

type userKey struct{}

func middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // 假设这里从请求头中获取用户信息
        user := "John Doe"
        ctx := context.WithValue(r.Context(), userKey{}, user)
        r = r.WithContext(ctx)
        next.ServeHTTP(w, r)
    })
}

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    user := ctx.Value(userKey{}).(string)
    fmt.Fprintf(w, "Hello, %s!", user)
}

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

在这段代码中,我们通过中间件将用户信息放入 Context 中,然后在 handler 函数中通过 ctx.Value 方法获取用户信息并返回给客户端。

注意事项

避免滥用Context

虽然 Context 非常强大,但不应滥用。例如,不应该在不需要取消或传递数据的简单 goroutine 中使用 Context,以免增加不必要的复杂性。

正确处理取消信号

在使用 Context 取消 goroutine 时,应确保 goroutine 能够及时响应取消信号并进行清理工作。例如,关闭打开的文件、释放数据库连接等。

注意Context的传递

在传递 Context 时,应确保正确传递,避免在传递过程中丢失重要信息。特别是在多层函数调用中,要确保 Context 能正确地从上层传递到下层。

小心内存泄漏

如果 goroutine 没有正确处理 Context 的取消信号,可能会导致内存泄漏。例如,一个 goroutine 持续占用资源而不释放,即使 Context 已被取消。因此,在编写 goroutine 时,要养成良好的习惯,及时响应取消信号并进行清理。

通过深入理解和正确应用 Context 包,我们可以更好地控制Go语言并发程序中的 goroutine 生命周期、设置操作超时以及在不同 goroutine 之间传递数据,从而编写出更健壮、高效的并发程序。无论是在小型工具还是大型分布式系统中,Context 都发挥着至关重要的作用。