Golang_13: Go语言 并发编程:协程(goroutine)、通道(chan)、同步锁(Mutex)、WaitGroup、上下文(context)

原文链接:https://xiets.blog.csdn.net/article/details/130866291

版权声明:原创文章禁止转载

专栏目录:Golang 专栏(总目录)

1. 协程(goroutine)

Go 语言的并发编程使用 协程(goroutine)实现。Go 语言的每一个并发执行的任务称为 goroutine,也包括执行 main() 函数的任务。

1.1 启动协程: go func()

Go 启动一个 goroutine 非常简单,在任何函数或方法调用前面加上 go 关键字,该函数或方法的调用即异步执行。

调用示例:

f()         // 普通调用函数 f(), 等待它返回 (在当前 goroutine 中调用)
go f()      // 异步调用函数 f(), 不等待返回 (新建一个 goroutine 来调用)

goroutine 代码示例:

package main

import "fmt"

func main() {
    // main() 函数也是在一个 goroutine 中运行
    
    n := 45
    result := make(chan int)

    // 异步计算 斐波那契数列, 把结果发送到 result通道,
    // 新建一个 goroutine 异步调用 calcFib() 函数 (不阻塞), 
    // 函数异步调用完成后, 此 goroutine 任务结束。
    go calcFib(n, result)

    // 等待其他 goroutine 中发送结果过来
    f := <-result
    fmt.Printf("fib(%d) = %d\n", n, f)
}

func calcFib(n int, result chan<- int) {
    f := fib(n)
    result <- f
    close(result)
}

func fib(n int) int {
    if n < 2 {
        return n
    }
    return fib(n-1) + fib(n-2)
}

特别注意:当 main() 函数返回时,如果还有正在运行的 goroutine,将被暴力地直接终止,然后进程退出。除了从 main() 函数返回或进程退出外,没有程序化的方法可以在一个 goroutine 中停止另一个 goroutine。大部分编程语言中的线程都有一个唯一标识(线程ID),Go 语言的 goroutine 没有这样的唯一标识。

1.2 协程(goroutine) 与 线程(thread)

Go 语言没有显式的线程概念,每一个异步任务都是一个 goroutine,所有 goroutine 可以并发运行。实际上 goroutine 也是运行在 OS 线程中。OS 线程由操作系统内核来调度,CPU 执行权在线程之间的切换需要一个完整的上下文切换,这些操作对 CPU 来说其实是“很慢”的。

Go 运行时包含了一个自己的调度器,该调度器使用一个被称为 m:n 的调度技术,即调度 m 个 goroutine 到 n 个线程中运行。Go 调度器由 Go 语言结构触发,比如当一个 goroutine 调用time.Sleep()、通道通信阻塞 或 互斥量阻塞时,调度器会将当前 goroutine 设为休眠状态,并运行其他 goroutine,这一组操作可以在同一个线程中执行,省去了在线程之间切换的时间。

Go 调度器使用 GOMAXPROCS 参数来确定使用多少个 OS 线程 来同时执行 Go 程序。GOMAXPROCS 的默认值为当前运行时机器上的 CPU 数量。Go 语言结构(time.Sleep、通道通信阻塞 或 互斥量阻塞 等)导致阻塞的 goroutine 不需要占用线程。当 goroutine 阻塞在I/O操作、其他系统调用 或 正在调用非 Go 语言写的函数时,需要占用一个独立的 OS 线程,但这个线程不计算在 GOMAXPROCS 内。

可以设置环境变量 GOMAXPROCS 或 运行时调用 runtime.GOMAXPROCS(n int) 函数来显式控制该参数的值:

$ GOMAXPROCS=1 go run main.go

runtime 包中可以动态获取当前正在运行的 goroutine 的数量,以及其他运行时相关值:

fmt.Println(runtime.NumGoroutine())     // 当前正在运行的 goroutine 的数量
fmt.Println(runtime.NumCPU())           // 机器的 CUP 数量

2. 通道(chan)

goroutine 之间使用 通道chan(也叫“管道”、“channel”)进行通讯。通道类似一个同步的队列,在一个 goroutine 中向通道发数据,另一个 goroutine 中接收。如果通道中没有空闲容量装数据或不能立即发送出去,则发送时阻塞;如果通道中没有数据,则接收时阻塞。通道chan 属于引用类型,其零值为 nil

创建通道使用 make() 内置函数:

// 创建一个通道, T 表示通道中传递数据的类型, cap 表示通道的容量
// cap 为 0 时 (默认), 通道中无法缓存数据 (即只能即发即收 或 即收即发, 否则阻塞)
ch := make(chan T, cap int)

也可以使用 var 声明通道变量:

// 声明一个通道变量, T 表示通道中传递数据的类型, 这种方式声明的通道变量默认值为 nil
var ch chan T

发送数据到通道 和 从通道接收数据,使用 <- 操作符,其中箭头的指向表示数据的流向:

ch <- data          // 发送数据到通道
data := <-ch        // 从通道接收数据

查询通道内当前缓存的数据量:

len(ch)             // 返回通道内当前缓存的数据量
cap(ch)             // 返回通道的总容量

如果发送方没有更多数据,需要调用 close() 内置函数关闭通道并通知接收者:

// 关闭通道, 通道通常由发送方来关闭, 因为发送方才知道还有没有更多数据需要发送
close(ch)

通道chan 总结:

  • 读已关闭的 chan:
    • chan 关闭后,如果还有缓存的数据,还可以继续从 chan 中读取。
    • chan 关闭后,如果没有缓存数据,则后续的接收操作将不阻塞并直接返回零值。
  • 写已关闭的 chan:
    • 向已关闭的 chan 写数据会触发 panic。
  • 读写零值 chan:
    • 如果 chan 的值为 nil,读写 chan 均会被阻塞。

2.1 使用通道在协程之间通信

异步计算 [1, n] 的平方和:

package main

import "fmt"

func main() {
    // 创建一个容量 (缓冲大小) 为 10 的通道
    ch := make(chan int, 10)
    
    fmt.Println(cap(ch))    // 输出: 10

    n := 100

    for i := 1; i <= n; i++ {
        // 异步执行计算平方, 把结果发送到通道
        go square(i, ch)
    }

    sum := 0

    // 从通道中接收结果, 接收 n 次
    for i := 1; i <= n; i++ {
        sum += <-ch
    }
    close(ch)

    fmt.Println("sum:", sum)
}

func square(x int, result chan int) {
    r := x * x
    // 把计算结果发送到通道
    result <- r
}

2.2 通道关闭判断(是否成功读取到数据而非返回默认零值)

没有一个直接的方式来判断通道是否已关闭,但可以在接收时间接处理:

for {
    // 使用两个变量从通道接收数据, 则第二个参数是一个 bool 类型, 
    // true  表示成功从 chan 内接收到由发送方发送的数据 (chan 也可能已关闭, 只是还有缓存), 
    // false 表示当前通道 已关闭 并且 已读完缓存。
    x, ok := <-ch
    if !ok {
        // 通道关闭且数据已读完
        break
    }
    ...
    // PS: 第二个参数 ok 并不是真正表示 chan 的关闭状态, 
    //     而是表示是否有从 chan 中读取到由发送方发送的数据。
}

上面写法比较笨拙,从通道读取数据支持 for range 循环,上面代码相当于:

// 从通道中循环读取数据 (通道关闭并且缓存已读取完后结束循环)
for x := range ch {
    ...
}

2.3 单向通道类型

创建的一个通道默认为双向通道,即可以发送数据到通道,也可以从通道读取数据。通道变量在传给函数形参时,可以使用 <- 操作符限制函数内对此通道的读写。

有时候为了明确对通道的控制权限,只希望在函数内对通道只读或只写。在通道类型关键字左边或右边加上 <-,即表示该通道为只读或只写通道(左读右写)。关闭通道的内置函数 close(c chan<- Type) 接受的参数类型就是只写通道类型。

通道类型:

  • chan T:可读写通道,可以发送数据到通道,也可以从通道读取数据。
  • <-chan T:只读通道,不可以发送数据到通道,可以从通道读取数据。
  • chan<- T:只写通道,可以发送数据到通道,不可以从通道读取数据。

可读写通道(chan T) 变量可以赋值给 只读通道(<-chan T) 和 只写通道(chan<- T),反过来则不行。

单向通道类型代码示例:

package main

import "fmt"

func main() {
    n := 100

    // 创建两个通道
    data := make(chan int, 10)
    result := make(chan int)

    // 异步计算平方和, 从 通道data 中读取数据, 把计算结果发送到通道result
    go CalcQuadraticSum(data, result)

    // 循环发送数据到通道
    for i := 1; i <= n; i++ {
        data <- i
    }
    // 数据发送完毕, 关闭数据通道
    close(data)

    // 从结果通道接收数据
    sum := <-result
    fmt.Println(sum)

    close(result)
}

// CalcQuadraticSum 计算平方和,
// 从 只读通道in 中读取数据计算, 让把计算结果发送到 只写通道out。
func CalcQuadraticSum(in <-chan int, out chan<- int) {
    sum := 0
    // 从 只读通道in 中循环读取数据计算 (通道关闭后结束循环)
    for x := range in {
        sum += x * x
    }
    // 让把计算结果发送到 只写通道out
    out <- sum
}

2.4 无缓冲通道

容量为 0 通道,称为 无缓冲通道。无缓冲通道在发送数据时会阻塞直到有其他 goroutine 接收到数据才结束阻塞,在接收数据时也会阻塞直到有其他 goroutine 发送了数据才结束阻塞。

package main

import "fmt"

func main() {
    // 创建通道 (容量为0, 即无缓冲通道)
    ch := make(chan string)

    // 异步运行
    go func() {
        // 在 goroutine 中发数据到通道,
        // (无缓冲通道) 阻塞到刚好有其他 goroutine 接收到数据才结束阻塞
        ch <- "Hello World"
        
        // 关闭通道
        close(ch)
    }()

    // 从通道中接收数据,
    // (无缓冲通道) 阻塞到刚好有其他 goroutine 发送了数据才结束阻塞
    s := <-ch
    fmt.Println(s)
}

通道在通信阻塞时(等待发送或接收数据),如果没有其他处于活动状态的 goroutine,程序将导致死锁并触发 panic。

2.5 select 多路复用

select 语句专用于通道的通信操作(并且只能用于通道)。select 的工作模式与 switch 语句类似。

select 语法格式:

// 选择下面可以立即执行的一个分支, 没有则阻塞等待
select {
case <-ch1:         // 从通道 ch1 接收数据, 忽略接收到的数据
    ...
case x := <-ch2     // 从通道 ch2 接收数据, 并声明变量保存
    ...
case ch3<- y        // 发送数据到通道 ch3, 如果 y 是表达式, 则会先计算出表达式的结果
    ...
default:            // (可选分支) 所有 case 中的通道通信(发送或接收)均阻塞, 则执行 default
    ...
}

select 语句与 switch 语句一样,有 一系列的情况(case) 和 一个默认的可选分支(default)。select 的每一种情况(case) 都必须是一次 通信(在通道上发送或接收数据)和 关联的一段代码块。接收表达式中可以用短变量声明变量来保存接收到的数据,之后在关联的代码块中可使用。

执行到 select 代码块时,查询所有 case 是否有可以立即通信(发送或接收)的。如果没可通信的 case 分支,则 select 将一直等待。直到触发到某一个 case 可以通信,然后它进行这次通信,并执行关联的代码块,然后结束整个 select 代码块。在 case 关联的代码块中,也可以中途使用 break 语句跳出整个 select 代码块。

如果 select 有 default 分支,而其他 case 都无法立即通信,则会立即执行 default 分支,然后结束整个select 代码块。

如果有多个 case 语句中的通道通信(发送或接收)都不需要阻塞,则 select 会随机选择其中一个 case 语句执行(与 case 顺序无关)。

如果 case 语句中的通道值是 nil,则无论是读写通道都会忽略这条 case 语句,不会触发 panic。但如果在 case 中向已关闭的通道发送数据则会触发 panic。

和 switch 一样,一次 select 代码块的执行,最多只会有一个 case/default 分支被执行,而且不会从上到下贯穿执行。没有任何 case/default 分支的 select(select{ })将永久阻塞(没有其他更多 goroutine 则触发 panic)。

select 代码示例:

package main

import (
    "fmt"
    "time"
)

func main() {
    n := 45
    result := make(chan int)

    // 异步计算, 把结果发送到 result通道
    go CalcFib(n, result)

    select {
    
    case f := <-result:                     // 等待接收结果 (和 if 类似, 可以在 case 中声明变量)
        fmt.Printf("fib(%d) = %d\n", n, f)
        close(result)
        
    case <-time.After(3 * time.Second):     // After() 函数返回一个 只读通道, 在指定时间后往该通道发送数据
        fmt.Println("计算超时了")
    }
}

// CalcFib 计算斐波那契数列, 把结果发送到 result通道
func CalcFib(n int, result chan<- int) {
    f := fib(n)
    result <- f
}

func fib(n int) int {
    if n < 2 {
        return n
    }
    return fib(n-1) + fib(n-2)
}

2.6 for-range 遍历通道

for-range 循环也可以用于遍历通道。for-range 遍历通道时将从通道循环读取数据,没有更多数据时会阻塞,直到通道被关闭并且读完缓存后退出循环:

ch := make(chan string, 10)
ch <- "Hello"
ch <- "World"
time.AfterFunc(2*time.Second, func() {
    close(ch)
})
for s := range ch {
    fmt.Println(s)
}
// 上面代码将输出:
// Hello
// World
// 然后等待2秒后通道被关闭, 循环退出

如果用 for-range 遍历一个值为 nil 的通道,则会永久阻塞。

3. 并发同步(sync)

多个 goroutine 并发访问同一个变量时需要确保并发安全,即 同步原语。

Go 语言内置的 sync 包中提供了基本的同步原语,例如 互斥锁(sync.Mutex)。

3.1 互斥锁: sync.Mutex

sync.Mutex 类型表示一个互斥锁,是一个结构体类型,零值是一个未上锁状态的互斥锁。sync.Mutex 锁变量使用后不能复制。

m *Mutex 的方法:

m.Lock()            // 获取锁, 如果锁已被获取, 当前 goroutine 将阻塞, 直到成功获取到锁
m.Unlock()          // 释放锁, 如果锁是已释放的状态, 调用 Unlock() 将导致宕机
m.TryLock()         // 尝试获取锁, 如果锁是已释放状态则获取锁并返回 true, 如锁已被获取则不阻塞返回 false

上锁 和 解锁 必须成对操作,通常可以使用 defer 延迟函数解锁:

var m sync.Mutex

func f() {
    m.Lock()
    defer m.Unlock()
    // 同步代码块
}

下面的互斥锁 sync.Mutex 代码示例,演示一个钱包并发 存钱、支付、查询 的功能:

package main

import (
    "fmt"
    "math/rand"
    "sync"
    "time"
)

// Wallet 表示一个钱包
type Wallet struct {
    balance int             // 钱包余额
    mutex   sync.Mutex      // 互斥锁, 访问余额变量, 必须先获取这个互斥锁
}

// Add 增加余额 (存钱)
func (w *Wallet) Add(amount int) {
    w.mutex.Lock()
    defer w.mutex.Unlock()
    w.balance += amount     // 存钱增加余额
    fmt.Printf("存入 %d, 余额 %d\n", amount, w.balance)
}

// Pay 减少余额 (付款)
func (w *Wallet) Pay(amount int) {
    w.mutex.Lock()
    defer w.mutex.Unlock()
    if w.balance < amount {
        fmt.Printf("支付失败, 余额 %d, 需要支付 %d\n", w.balance, amount)
        return
    }
    w.balance -= amount     // 支付减少余额
    fmt.Printf("支付 %d, 余额 %d\n", amount, w.balance)
}

// Query 查询余额
func (w *Wallet) Query() {
    w.mutex.Lock()
    defer w.mutex.Unlock()
    fmt.Printf("查询 余额为 %d\n", w.balance)
}

func main() {
    rand.Seed(time.Now().UnixNano())
    n := 100
    ch := make(chan bool)

    // 创建一个钱包对象
    var w Wallet

    // 开启 n 个 goroutine, 随机执行 存入、支付、查询 操作,
    // 每一条操作记录都输出操作后的余额, 查看余额流水是否有误
    for i := 0; i < n; i++ {
        f := rand.Float64()

        if f < 0.45 {
            go func() {
                w.Add(rand.Intn(10))    // 随机存储 [0, 10)
                ch <- true
            }()
        } else if f < 0.9 {
            go func() {
                w.Pay(rand.Intn(10))    // 随机支付 [0, 10)
                ch <- true
            }()
        } else {
            go func() {
                w.Query()
                ch <- true
            }()
        }
    }

    // 等待所有 goroutine 完成
    for i := 0; i < n; i++ {
        <-ch
    }
}

通过打印的信息,可以看到并发 存钱、支付、查询,每一条信息前后的余额都不会出错。可以尝试把互斥锁去掉,多运行几遍,会发现打印的余额流水存在错误。

3.2 读写互斥锁: sync.RWMutex

sync.RWMutex 是一个读写互斥锁,可以同时由任意多个 goroutine 读取 和 单个 goroutine 写入。读写锁实现了 读读不互斥,读写和写写互斥。sync.RWMutex 是结构体类型,零值是一个未上锁状态的互斥锁,锁变量使用后不能复制。

rwm *RWMutex 的方法:

rwm.Lock()              // 获取 读写锁, 用于写入, 读锁和读写锁都处于释放状态可获取
rwm.Unlock()            // 释放 读写锁

rwm.RLock()             // 获取  读锁, 用于读取, 读写锁都处于释放状态可获取
rwm.RUnlock()           // 释放 读锁

rwm.TryLock() bool      // 尝试获取 读写锁, 不阻塞, 返回释放成功获取
rwm.TryRLock() bool     // 尝试获取 读锁,   不阻塞, 返回是否成功获取

rwm.RLocker()           // 返回读锁, 然后再获取或释放读锁, 
                        // RLocker().Lock()   相当于 RLock()
                        // RLocker().Unlock() 相当于 RUnlock()

Lock()Unlock()RLock()RUnlock() 都必须成对调用。

上面示例中把互斥锁换成读写锁,查询金额时可以使用读锁:

type Wallet struct {
    balance int
    mutex   sync.RWMutex
}

func (w *Wallet) Query() {
    w.mutex.RLock()
    defer w.mutex.RUnlock()
    fmt.Printf("查询 余额为 %d\n", w.balance)
}

3.3 延迟初始化: sync.Once

使用 sync.Mutex 互斥锁延迟初始化示例:

package main

import (
    "fmt"
    "math/rand"
    "sync"
    "time"
)

var mutex sync.Mutex
var resMap map[int]string

func GetRes(key int) (string, bool) {
    if resMap == nil {
        mutex.Lock()
        if resMap == nil {
            // 需要使用到时才加载资源
            resMap = loadRes()
        }
        mutex.Unlock()
    }
    value, ok := resMap[key]
    return value, ok
}

func loadRes() map[int]string {
    fmt.Println("模拟延时加载资源,只加载一次")
    m := make(map[int]string)
    for i := 0; i < 100; i++ {
        m[i] = fmt.Sprintf("res %d", i)
    }
    return m
}

func main() {
    rand.Seed(time.Now().UnixNano())
    ch := make(chan bool)

    // 并发访问获取资源
    for i := 0; i < 10; i++ {
        go func() {
            fmt.Println(GetRes(rand.Intn(100)))
            ch <- true
        }()
    }

    // 等待其他 goroutine 完成
    for i := 0; i < 10; i++ {
        <-ch
    }
}

sync 包提供了并发访问时针对一次性初始化问题的简单解决方案,就是 sync.Once。sync.Once 是一个结构体类型,有一个 Do(f func()) 方法,传递一个函数对象,可以确保在并发多次调用 Do() 方法时传入的函数只被回调一次,之后再调用 Do() 方法就相当于空操作。

sync.Once 使用示例:

package main

import (
    "fmt"
    "math/rand"
    "sync"
    "time"
)

var once sync.Once
var resMap map[int]string

func GetRes(key int) (string, bool) {
    once.Do(loadRes)
    value, ok := resMap[key]
    return value, ok
}

func loadRes() {
    fmt.Println("模拟延时加载资源,只加载一次")
    resMap = make(map[int]string)
    for i := 0; i < 100; i++ {
        resMap[i] = fmt.Sprintf("res %d", i)
    }
}

func main() {
    rand.Seed(time.Now().UnixNano())
    ch := make(chan bool)

    // 并发访问获取资源
    for i := 0; i < 10; i++ {
        go func() {
            fmt.Println(GetRes(rand.Intn(100)))
            ch <- true
        }()
    }

    for i := 0; i < 10; i++ {
        <-ch
    }
}

4. WaitGroup

WaitGroup 用于等待一组 goroutine 的完成,主 goroutine 调用 Add(delta int) 来设置要等待的 goroutines 的数量。然后每个 goroutine 运行并在完成时调用 Done()。同时,Wait() 可以用来阻塞,直到所有 goroutines 完成。

wg *WaitGroup 的方法:

// WaitGroup 计数器增加 delta, delta 可以为负数。如果增加后导致计数器的是负数, 则触发 panic。
wg.Add(delta int)

// 将 WaitGroup 计数器减 1。
wg.Done()

// 阻塞等待, 直到 WaitGroup 计数器为零。如果等待时计数器已为 0, 则不阻塞。可以在多个 goroutine 中同时等待。
wg.Wait()

WaitGroup 代码示例:

package main

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

func main() {
    wg := &sync.WaitGroup{}
    urls := []string{
        "https://httpbin.org/get",
        "https://www.baidu.com/",
        "https://xiets.blog.csdn.net/",
    }

    for _, url := range urls {
        wg.Add(1) // 计数器+1
        go func(url string) {
            resp, err := http.Get(url)
            if err != nil {
                fmt.Printf("URL: %s, Err: %s\n", url, err.Error())
            } else {
                fmt.Printf("URL: %s, Resp: %s, %s\n", url, resp.Proto, resp.Status)
            }
            wg.Done() // 计数器-1
        }(url)
    }

    wg.Wait() // 等待计数器清零
    fmt.Println("over")
}

// 输出:
// URL: https://www.baidu.com/, Resp: HTTP/1.1, 200 OK
// URL: https://xiets.blog.csdn.net/, Resp: HTTP/2.0, 200 OK
// URL: https://httpbin.org/get, Resp: HTTP/2.0, 200 OK
// OVER

5. context

context 中文翻译为“上下文”,是 Go 开发常用的并发控制技术。与 WaitGroup 相比,context 拥有更强的控制能力,可以控制呈树状的多级 goroutines。

如果一个 goroutine 中衍生出多个子 goroutines,子 goroutine 又继续派衍生出新的 goroutines,对于这种 goroutines 数量不确定、层级也不确定的情况,WaitGroup 就无法很好地并发控制,而 context 就可以很容易实现。

比如 HTTP 服务端启动一个 goroutine 来处理一个 HTTP 请求,在这个 goroutine 中可能需要衍生出若干个子 goroutines 协同工作,有的从数据库获取数据,有的使用 gRPC 调用其他服务,还有的记录日志等。这些 goroutines 需要共享当前请求的相关数据(如 token/cookie、请求ID 等),并且当达到的规定的处理超时时间后,这些 goroutines 需要同时被关闭,而 Go 无法直接中断 goroutine,这时就可以通过 context 来实现。可以简单认为,context 是为了解决若干个相关联的 goroutines 之间共享数据、传递取消通知、超时通知、错误通知 而设计的。

context 包中的 context.Context 表示的就是一个上下文对象,Context 是一个接口类型,有 4 个方法:

type Context interface {
    // 返回此上下文的 截止时间(deadline), 到达截止时间时此上下文关联的任务如果还没有完成则应该被取消。
    // 当 ok == true 时, 截止时间才有效; 如果 ok == false, 表示没有设置截止时间时 (此时 deadline 是零值)。
    // 对 Deadline() 的连续调用将返回相同的值。
    Deadline() (deadline time.Time, ok bool)

    // 当此上下文关联的任务应该被取消时 (可能是 被主动取消 或 到达截止时间/deadline), 返回一个已关闭的只读通道。
    // 如果此上下文永远不会被取消, 将返回 nil。对 Done() 的连续调用将返回相同的值 (虽然返回的是同一个通道对象, 但通道状态可能不一样)。
    // WithCancel() 上下文安排在调用 cancel() 时关闭 Done 通道, 可能会在 cancel() 函数返回后异步发生;
    // WithDeadline() 上下文安排在截止时间到期时关闭 Done 通道;
    // WithTimeout() 上下文安排在超时结束时关闭 Done 通道。
    // Done() 返回的通道通常用在 select-case 语句中, 返回的通道通常是一个没有缓冲区的通道, 如果可以从中读取值, 说明通道已被关闭。
    Done() <-chan struct{}

    // 如果 Done 通道尚未关闭, Err() 返回 nil; 如果 Done 通道关闭, Err() 返回关闭的错误原因。
    // 如果上下文被取消导致关闭, 则 error 为 context.Canceled (一个全局变量值);
    // 如果上下文超时导致关闭, 则 error 为 context.DeadlineExceeded (一个全局变量值)。
    // 在 Err() 返回一个非零值错误后, 对 Err() 的连续调用返回相同的错误。
    Err() error

    // 返回与此上下文关联的 key 对应的 value 值。如果 key 不存在, 则返回 nil。
    // 使用相同的 key 连续调用 Value() 会返回相同的结果。
    // 有些上下文可以以 map 的方式在 goroutines 之间传递信息, WithValue() 返回的上下文实现就属于这种,
    // Value() 则是用于这种上下文根据 key 查询 map 中的 value 值。
    Value(key any) any
}

Context 上下文是一个接口,context 包内包含了多个上下文的实现:emptyCtxcancelCtxtimerCtxvalueCtx。这些实现都是不可导出的类型,需要通过 context 包提供的导出函数返回单例或创建实例:

  • emptyCtx:空上下文,实例化函数有 context.Background()context.TODO()

  • cancelCtx:可取消的上下文,实例化函数有 context.WithCancel()context.WithCancelCause()

  • timerCtx:有截止时间的上下文,实例化函数有 context.WithDeadline()context.WithTimeout()

  • valueCtx:可以传递值的上下文,实例化函数有 context.WithValue()

  • emptyCtx 的底层类型是 intcancelCtxvalueCtx 继承了 Context,而 timerCtx 继承了 cancelCtx

5.1 emptyCtx: 空上下文

emptyCtx 表示一个空的上下文,它没有截止时间,永远不会被取消,也没有关联的值。emptyCtx 只是简单地实现了 Context,一般只作为其他 Context 的根节点使用。

context 包内定义了两个 emptyCtx 类型的的公共变量,分别通过 context.Background()context.TODO() 函数获取(这两个函数返回的是单例)。

// 此函数返回的上下文实例, 通常由主函数、初始化和测试使用, 并作为顶级上下文传给其他类型上下文的创建函数。
func Background() Context

// 当不清楚使用哪个上下文或上下文还不可用时使用 (因为周围的函数还没有扩展到接受上下文参数)。
// 例如调用一个需要传递 Context 参数的函数时, 可以临时使用 TODO() 占位, 以后再换成其他具体的上下文类型。
func TODO() Context

emptyCtx 的底层类型是 int,而 cancelCtxtimerCtxvalueCtx 都直接或间接继承了 Context,也就是创建这三种上下文的函数都需要传入一个父上下文,而 Background() 就通常作为顶级的父上下文。这三种上下文也可以互为父节点形成一个复杂的上下文链条,从而组成不同的应用形式。

5.2 cancelCtx: 可取消的上下文

cancelCtx 用于可取消任务的上下文,通过 context.WithCancel()context.WithCancelCause() 函数创建实例。

// 创建一个可取消的上下文实例(返回父上下文的副本), 参数 parent 表示上下文的父节点, 通常使用 Background()。
// 返回值 cancel 是一个空参数的函数 func(), 调用此函数可主动取消上文进而关闭 Done 通道, 
// 取消后调用 ctx.Err() 将返回 context.Canceled。
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

// 与 WithCancel() 一样创建可取消的上下文, 返回的取消函数 cancel 的类型为 func(cause error), 
// 调用此取消函数可以传递被上下文被取消的错误原因。
func WithCancelCause(parent Context) (ctx Context, cancel CancelCauseFunc)

WithCancel() 代码示例:

package main

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

func DoTask(ctx context.Context) {
    // 1 个任务中又启动了 2 个 goroutine 处理子任务
    go DoSubTask01(ctx)
    go DoSubTask02(ctx)
    // 模拟任务处理
    for {
        select {
        case <-ctx.Done():
            // 上下文已被取消, 任务不再需要继续, 做收尾工作, 然后直接返回, 防止 goroutine 泄露
            fmt.Printf("DoTask: context done, err = %v, return.\n", ctx.Err())
            return
        default:
            fmt.Println("DoTask: doing ...")
            time.Sleep(2 * time.Second)
            // 如果在上下文取消之前完成任务并 return, 则 goroutine 正常结束
        }
    }
}

func DoSubTask01(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("DoSubTask01: context done, err = %v, return.\n", ctx.Err())
            return
        default:
            fmt.Println("DoSubTask01: doing ...")
            time.Sleep(2 * time.Second)
        }
    }
}

func DoSubTask02(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("DoSubTask02: context done, err = %v, return.\n", ctx.Err())
            return
        default:
            fmt.Println("DoSubTask02: doing ...")
            time.Sleep(2 * time.Second)
        }
    }
}

func main() {
    // 创建可取消的上下文, 使用 Background() 作为上下文根节点
    ctx, cancel := context.WithCancel(context.Background())

    // 启动 goroutine 异步处理任务
    go DoTask(ctx)

    // 等待 5 秒后, 取消上下文
    time.Sleep(5 * time.Second)
    cancel()

    // 等待子 goroutines 正常退出
    time.Sleep(5 * time.Second)
    fmt.Println("main over")
}

// 输出:
// DoTask: doing ...
// DoSubTask01: doing ...
// DoSubTask02: doing ...
// DoTask: doing ...
// DoSubTask01: doing ...
// DoSubTask02: doing ...
// DoTask: doing ...
// DoSubTask01: doing ...
// DoSubTask02: doing ...
// DoTask: context done, err = context canceled, return.
// DoSubTask01: context done, err = context canceled, return.
// DoSubTask02: context done, err = context canceled, return.
// main over

上面示例中,为了处理一个任务,启动了一个 goroutine,然后又衍生出了多个 goroutine 一起处理任务。当超过一定时间时取消上下文,所有为了处理任务而启动的多个 goroutines 都能从 ctx.Done() 收到任务已取消通知,然后可以正常结束 goroutine 函数以防止 goroutine 泄漏。

WithCancelCause() 是可以在取消时传递取消的错误原因的上下文,简单示例:

// 创建可带原因取消的上下文
ctx, cancel := context.WithCancelCause(parent)

// 取消上下文, 传入错误原因
cancel(myError)

// 返回上下文 Done 通道被关闭的原因, 原因是上下文本取消
ctx.Err()               // returns context.Canceled

// 通过 Cause() 函数可以获取上下文被取消的原因
context.Cause(ctx)      // returns myError

5.3 timerCtx: 有截止时间的上下文

timerCtx 用于有截止时间任务的上下文,通过 context.WithDeadline()context.WithTimeout() 创建实例。timerCtx 内部实际是继承了 cancelCtx,因此创建 timerCtx 实例的函数也会返回 CancelFunc 取消函数,也可以调用返回的取消函数取消上下文并关闭 Done 通道。

// 返回父上下文的副本, 截止时间调整为不晚于d, parent 也可以是一个 timerCtx 或 其他类型的Context。
// 如果 parent 的截止时间早于 d, 则 WithDeadline(parent, d) 在语义上等同于 parent。
// 也就是说把 parent 的截止时间 和 d 比较, 使用较早的时间作为真正的截止时间。
// 
// 当截止时间到期, 或调用了返回的取消函数, 或parent的Done通道被关闭, 此上下文的 Done 通道也同时被关闭, 以先发生者为准。
// 也就是说父子上下文之间的 Done 通道状态是同步的, 其中一个上下文 Done 通道关闭, 其他关联的父子上下文 Done 通道也将关闭。
//
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)

// 使用时长作为截止时间, 相当于 WithDeadline(parent, time.Now().Add(timeout))
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

WithDeadline() 代码示例:

package main

import (
    "context"
    "fmt"
    "math/rand"
    "time"
)

func DoTask(ctx context.Context) {
    // 模拟任务处理
    for {
        select {
        case <-ctx.Done():
            // 上下文已被取消 或 到达截止时间, 任务不再需要继续, 做收尾工作, 然后直接返回, 防止 goroutine 泄露
            fmt.Printf("DoTask: context done, err = %v, return.\n", ctx.Err())
            return
        default:
            fmt.Println("DoTask: doing ...")
            time.Sleep(1 * time.Second)
            // 如果在上下文取消之前完成任务并 return, 则 goroutine 正常结束
        }
    }
}

func main() {
    // 创建有截止时间和可取消的上下文, 截止时间为5秒后
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)

    // 启动 goroutine 异步处理任务
    go DoTask(ctx)

    // 随机等待 4~6 秒后, 取消上下文
    time.Sleep(time.Duration(4+rand.Intn(3)) * time.Second)
    cancel()

    // 如果截止时间先到来, 则 Done 通道关闭的原因为 ctx.Err() == context.DeadlineExceeded
    // 如果截止时间到来前被取消, 则 Done 通道关闭的原因为 ctx.Err() == context.Canceled

    // 等待子 goroutine 正常退出
    time.Sleep(2 * time.Second)
    fmt.Println("main over")
}

// 如果截止时间先到来, 则输出:
// DoTask: doing ...
// DoTask: doing ...
// DoTask: doing ...
// DoTask: doing ...
// DoTask: doing ...
// DoTask: context done, err = context deadline exceeded, return.
// main over

//
// 如果截止时间到来前被取消, 则输出:
// DoTask: doing ...
// DoTask: doing ...
// DoTask: doing ...
// DoTask: doing ...
// DoTask: doing ...
// DoTask: context done, err = context canceled, return.
// main over

5.4 valueCtx: 可以传递值的上下文

valueCtx 携带了一个 key-value 键值对,可以通过 ctx.Value(key) 方法获取出 value,并将所有其他调用委托给嵌入上下文。

// 返回 parent 的副本, 并携带了一个 key-value 键值对。
func WithValue(parent Context, key, val any) Context

valueCtx 实例作为父节点时,在子节点也可以通过 Value(key) 获取出父节点携带的值。

WithValue() 代码示例:

package main

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

func DoTask(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("DoTask: context done, err = %v, return.\n", ctx.Err())
            return
        default:
            // 获取上下文携带的值
            value := ctx.Value("token")
            fmt.Printf("DoTask: token = %v, doing ...\n", value)
            time.Sleep(1 * time.Second)
        }
    }
}

func main() {
    // 创建一个携带了 key-value 键值对的上下文
    valueCtx := context.WithValue(context.Background(), "token", "hello-world")

    // 把 valueCtx 作为父节点创建可取消的上下文
    timerCtx, cancel := context.WithCancel(valueCtx)

    // 异步任务处理
    go DoTask(timerCtx)

    // 等待 3 秒后取消上下文
    time.Sleep(3 * time.Second)
    cancel()

    time.Sleep(2 * time.Second)
    fmt.Println("main over")
}

// 输出:
// DoTask: token = hello-world, doing ...
// DoTask: token = hello-world, doing ...
// DoTask: token = hello-world, doing ...
// DoTask: context done, err = context canceled, return.
// main over
  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

谢TS

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值