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

Go语言context包在并发编程中的应用

2021-11-195.5k 阅读

Go 语言并发编程基础回顾

在深入探讨 Go 语言 context 包之前,我们先来回顾一下 Go 语言并发编程的一些基础知识。Go 语言从设计之初就内置了对并发编程的支持,通过 goroutine 实现轻量级线程,通过 channel 实现不同 goroutine 之间的通信。

goroutine

goroutine 是 Go 语言中实现并发的核心概念,它类似于线程,但比线程更加轻量级。创建一个 goroutine 非常简单,只需要在函数调用前加上 go 关键字。例如:

package main

import (
    "fmt"
    "time"
)

func worker() {
    for i := 0; i < 5; i++ {
        fmt.Println("Worker:", i)
        time.Sleep(time.Second)
    }
}

func main() {
    go worker()
    time.Sleep(3 * time.Second)
    fmt.Println("Main function exiting")
}

在上述代码中,go worker() 启动了一个新的 goroutine 来执行 worker 函数。main 函数并不会等待 worker 函数执行完毕,而是继续向下执行。time.Sleep 函数在这里是为了让 main 函数等待一段时间,以便 worker 函数有机会执行部分代码。

channel

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

package main

import (
    "fmt"
)

func sender(ch chan int) {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch)
}

func receiver(ch chan int) {
    for num := range ch {
        fmt.Println("Received:", num)
    }
}

func main() {
    ch := make(chan int)
    go sender(ch)
    go receiver(ch)
    select {}
}

在这段代码中,sender 函数通过 ch <- i 将数据发送到 channel 中,receiver 函数通过 for num := range chchannel 中接收数据。close(ch) 用于关闭 channelfor... range 循环会在 channel 关闭时自动结束。select {} 语句用于阻塞 main 函数,防止程序过早退出。

context 包引入的背景

在实际的并发编程场景中,我们常常会遇到以下几个问题:

  1. 取消操作:当一个 goroutine 执行一个长时间运行的任务时,我们可能需要在某个时刻取消这个任务。例如,在一个网络请求的处理过程中,如果用户取消了请求,我们需要能够停止正在执行的相关 goroutine
  2. 设置超时:有些任务不能无限制地执行下去,需要设置一个超时时间。如果任务在规定的时间内没有完成,就应该自动取消。
  3. 传递请求范围的数据:在一个由多个 goroutine 组成的请求处理链中,我们可能需要传递一些与请求相关的数据,例如请求的唯一标识、用户认证信息等。

传统的通过 channel 来实现取消和超时控制虽然可行,但会使得代码变得复杂且难以维护。例如,为了实现取消功能,我们可能需要在每个需要取消的 goroutine 中添加对取消 channel 的监听逻辑,而且如果有多个 goroutine 嵌套调用,这种逻辑会变得更加复杂。

context 包的出现就是为了解决这些问题。它提供了一种简洁、优雅的方式来管理 goroutine 的生命周期,传递请求范围的数据,以及设置超时和取消操作。

context 包的核心类型

context 包主要包含以下几个核心类型:

  1. Context 接口:这是 context 包的核心接口,定义了几个关键方法:
    • Deadline:返回 context 的截止时间。如果没有设置截止时间,ok 返回 false
    • Done:返回一个 channel,当 context 被取消或超时时,这个 channel 会被关闭。
    • Err:返回 context 被取消或超时的原因。如果 context 还没有被取消或超时,返回 nil
    • Value:从 context 中获取键值对数据。
  2. emptyCtx:一个空的 Context 实现,作为所有 context 的根节点。
  3. cancelCtx:实现了取消功能的 Context。它可以通过调用 cancel 函数来取消 context,并关闭其 Done channel
  4. timerCtx:基于 cancelCtx,增加了超时功能。它会在指定的时间后自动取消 context
  5. valueCtx:用于在 context 中存储键值对数据。

context 包的使用场景

取消操作

在实际应用中,取消操作是非常常见的需求。例如,在一个网络爬虫程序中,如果用户手动停止爬虫任务,我们需要能够取消正在执行的 goroutine

package main

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

func task(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("Task cancelled")
            return
        default:
            fmt.Println("Task is running")
            time.Sleep(time.Second)
        }
    }
}

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

    time.Sleep(3 * time.Second)
    cancel()
    time.Sleep(time.Second)
    fmt.Println("Main function exiting")
}

在上述代码中,context.WithCancel(context.Background()) 创建了一个可取消的 context,并返回 ctxcancel 函数。task 函数通过监听 ctx.Done() 来判断是否需要取消任务。在 main 函数中,通过调用 cancel() 函数来取消 context,从而使得 task 函数中的 ctx.Done() channel 被关闭,任务得以取消。

设置超时

设置超时也是一个非常重要的场景。比如,在进行数据库查询时,如果查询时间过长,我们希望能够自动终止查询。

package main

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

func longRunningTask(ctx context.Context) {
    select {
    case <-ctx.Done():
        fmt.Println("Task timed out")
        return
    case <-time.After(5 * time.Second):
        fmt.Println("Task completed")
    }
}

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

    go longRunningTask(ctx)

    time.Sleep(5 * time.Second)
    fmt.Println("Main function exiting")
}

在这段代码中,context.WithTimeout(context.Background(), 3*time.Second) 创建了一个带有 3 秒超时的 contextlongRunningTask 函数通过监听 ctx.Done() 来判断任务是否超时。如果在 3 秒内任务没有完成,ctx.Done() channel 会被关闭,任务会被视为超时。

传递请求范围的数据

在一个由多个 goroutine 组成的请求处理链中,传递请求范围的数据是很有必要的。例如,在一个 Web 应用中,我们可能需要在不同的处理函数之间传递用户的认证信息。

package main

import (
    "context"
    "fmt"
)

type User struct {
    Name string
}

func processRequest(ctx context.Context) {
    user, ok := ctx.Value("user").(*User)
    if ok {
        fmt.Printf("Processing request for user: %s\n", user.Name)
    } else {
        fmt.Println("User not found in context")
    }
}

func main() {
    user := &User{Name: "John"}
    ctx := context.WithValue(context.Background(), "user", user)

    go processRequest(ctx)

    select {}
}

在上述代码中,context.WithValue(context.Background(), "user", user) 创建了一个带有用户信息的 contextprocessRequest 函数通过 ctx.Value("user") 获取 context 中的用户信息,并进行相应的处理。

context 包在复杂并发场景中的应用

多层嵌套的 goroutine 取消

在实际项目中,我们经常会遇到多层嵌套的 goroutine 调用。例如,一个主 goroutine 启动多个子 goroutine,每个子 goroutine 又可能启动更多的孙 goroutine。在这种情况下,如何实现统一的取消操作呢?

package main

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

func grandChild(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("GrandChild cancelled")
            return
        default:
            fmt.Println("GrandChild is running")
            time.Sleep(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 cancelled")
            return
        default:
            fmt.Println("Child is running")
            time.Sleep(time.Second)
        }
    }
}

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

    time.Sleep(3 * time.Second)
    cancel()
    time.Sleep(2 * time.Second)
    fmt.Println("Main function exiting")
}

在这段代码中,main 函数创建了一个可取消的 context 并传递给 child 函数。child 函数又创建了一个新的可取消 context 并传递给 grandChild 函数。当在 main 函数中调用 cancel() 时,child 函数和 grandChild 函数中的 ctx.Done() channel 都会被关闭,从而实现了多层嵌套 goroutine 的统一取消。

多个 goroutine 协作与取消

有时候,我们需要多个 goroutine 协作完成一个任务,并且能够在需要时统一取消。例如,一个数据分析任务可能由多个 goroutine 分别负责数据采集、数据清洗和数据分析,当用户取消任务时,所有这些 goroutine 都应该停止。

package main

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

func dataCollector(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("Data collection cancelled")
            return
        default:
            fmt.Println("Collecting data...")
            time.Sleep(time.Second)
        }
    }
}

func dataCleaner(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("Data cleaning cancelled")
            return
        default:
            fmt.Println("Cleaning data...")
            time.Sleep(time.Second)
        }
    }
}

func dataAnalyzer(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("Data analysis cancelled")
            return
        default:
            fmt.Println("Analyzing data...")
            time.Sleep(time.Second)
        }
    }
}

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

    go dataCollector(ctx)
    go dataCleaner(ctx)
    go dataAnalyzer(ctx)

    time.Sleep(3 * time.Second)
    cancel()
    time.Sleep(2 * time.Second)
    fmt.Println("Main function exiting")
}

在上述代码中,main 函数创建了一个可取消的 context,并启动了三个负责不同任务的 goroutine。当调用 cancel() 时,所有的 goroutine 都会收到取消信号并停止执行。

context 包使用的注意事项

  1. 正确传递 contextcontext 应该在 goroutine 创建时传递,并且贯穿整个调用链。如果在 goroutine 内部重新创建一个新的 context,可能会导致取消和超时控制失效。
  2. 避免在全局变量中使用 context:将 context 作为全局变量使用可能会导致难以追踪 context 的生命周期,增加代码的复杂性和调试难度。
  3. 及时取消 context:在使用完 context 后,应该及时调用取消函数,以释放相关资源。例如,timerCtx 会在超时后自动取消,但如果提前知道任务已经完成,手动调用取消函数可以避免不必要的资源占用。
  4. 注意 context 的值传递context 是值类型,在传递过程中会进行值拷贝。虽然这不会影响其功能,但要注意在传递大的结构体作为 context 的值时,可能会带来性能开销。

总结

context 包是 Go 语言并发编程中的一个重要工具,它为我们提供了简洁、高效的方式来管理 goroutine 的生命周期,处理超时和取消操作,以及传递请求范围的数据。通过合理使用 context 包,我们可以编写更加健壮、可维护的并发程序。在实际应用中,需要根据具体的场景选择合适的 context 创建方式,并注意遵循相关的使用注意事项,以充分发挥 context 包的优势。