GoSuda

Go 并发入门包

By snowmerak views ...

概述

简短介绍

Go 语言提供了许多用于管理并发的工具。在本文中,我将介绍其中一部分工具及一些技巧。

协程?

goroutine 是 Go 语言所支持的一种新型并发模型。通常,程序为了同时执行多项任务,会从操作系统获取 OS 线程,并根据核心数量并行地执行任务。而要实现更小粒度的并发,则需要在用户空间创建绿色线程,使多个绿色线程在一个 OS 线程内轮流执行任务。不过,goroutine 将这种形式的绿色线程做得更加小巧高效。这种 goroutine 占用的内存比线程更少,并且能够比线程更快地被创建和切换。

使用 goroutine 只需要简单地使用 go 关键字。这使得在编写程序的过程中,能够直观地将同步代码以异步方式执行。

 1package main
 2
 3import (
 4    "fmt"
 5    "time"
 6)
 7
 8func main() {
 9    ch := make(chan struct{})
10    go func() {
11        defer close(ch)
12        time.Sleep(1 * time.Second)
13        fmt.Println("Hello, World!")
14    }()
15
16    fmt.Println("Waiting for goroutine...")
17    for range ch {}
18}

这段代码将一段先休眠 1 秒再输出 Hello, World! 的同步代码改写为异步流程。当前的示例较为简单,但如果将稍微复杂的代码从同步改写为异步,相比 async await 或 promise 等既有方式,代码的可读性、可见性和可理解性会更好。

然而,在许多情况下,如果不理解这种将同步代码简单地异步调用的流程,以及类似 fork & join 的流程(类似于分治的流程),就可能写出不良的 goroutine 代码。针对这种情况,本文将介绍一些方法和技巧。

并发管理

context

作为第一个管理技术出现 context 或许令人意外。但在 Go 语言中,context 不仅仅是简单的取消功能,它在管理整个工作树方面发挥着卓越的作用。如果有不熟悉的读者,我将简要介绍该包。

 1package main
 2
 3func main() {
 4    ctx, cancel := context.WithCancel(context.Background())
 5    defer cancel()
 6
 7    go func() {
 8        <-ctx.Done()
 9        fmt.Println("Context is done!")
10    }()
11
12    time.Sleep(1 * time.Second)
13
14    cancel()
15
16    time.Sleep(1 * time.Second)
17}

上述代码使用 context,在 1 秒后输出 Context is done!。context 可以通过 Done() 方法确认是否已取消,并提供 WithCancel、WithTimeout、WithDeadline、WithValue 等方法,以实现多种取消方式。

我们来编写一个简单的示例。假设您使用 aggregator 模式编写代码,以获取 user、post 和 comment,并且要求所有请求必须在 2 秒内完成,那么可以这样编写。

 1package main
 2
 3func main() {
 4    ctx, cancel := context.WithTimeout(context.Background(), 2 * time.Second)
 5    defer cancel()
 6
 7    ch := make(chan struct{})
 8    go func() {
 9        defer close(ch)
10        user := getUser(ctx)
11        post := getPost(ctx)
12        comment := getComment(ctx)
13
14        fmt.Println(user, post, comment)
15    }()
16
17    select {
18    case <-ctx.Done():
19        fmt.Println("Timeout!")
20    case <-ch:
21        fmt.Println("All data is fetched!")
22    }
23}

上述代码如果在 2 秒内未能获取所有数据,则输出 Timeout!;如果获取了所有数据,则输出 All data is fetched!。以这种方式使用 context,即使在多个 goroutine 运行的代码中,也能轻松管理取消和超时。

与 context 相关的各种函数和方法可以在 godoc context 中查看。希望您能够学习简单的部分,并方便地加以使用。

channel

unbuffered channel

channel 是用于 goroutine 之间通信的工具。channel 可以通过 make(chan T) 创建,其中 T 是该 channel 所传递数据的类型。channel 可以使用 <- 收发数据,并可以使用 close 关闭。

 1package main
 2
 3func main() {
 4    ch := make(chan int)
 5    go func() {
 6        ch <- 1
 7        ch <- 2
 8        close(ch)
 9    }()
10
11    for i := range ch {
12        fmt.Println(i)
13    }
14}

上述代码使用 channel 输出 1 和 2。该代码仅展示了向 channel 发送和接收值的基本操作,但 channel 还提供更多功能。首先,我们来了解 buffered channel 与 unbuffered channel。在此之前需要说明,上面的示例使用的是 unbuffered channel,向通道发送数据和接收数据的操作必须同时进行。如果这些操作不同时发生,则可能导致死锁。

buffered channel

如果上述代码不是简单输出,而是两个执行繁重任务的进程呢?如果第二个进程在读取并处理数据时长时间阻塞,那么第一个进程也将在此期间停止。为了防止这种情况,我们可以使用 buffered channel。

 1package main
 2
 3func main() {
 4    ch := make(chan int, 2)
 5    go func() {
 6        ch <- 1
 7        ch <- 2
 8        close(ch)
 9    }()
10
11    for i := range ch {
12        fmt.Println(i)
13    }
14}

上述代码使用 buffered channel 输出 1 和 2。在该代码中,通过使用 buffered channel,向 channel 发送数据和接收数据的操作无需同时进行。这样为通道设置缓冲区后,就会留出相应长度的余量,从而可以防止由于后续任务的影响而产生的任务延迟。

select

在处理多个通道时,使用 select 语法可以轻松实现 fan-in 结构。

 1package main
 2
 3import (
 4    "fmt"
 5    "time"
 6)
 7
 8func main() {
 9    ch1 := make(chan int, 10)
10    ch2 := make(chan int, 10)
11    ch3 := make(chan int, 10)
12
13    go func() {
14        for {
15            ch1 <- 1
16            time.Sleep(1 * time.Second)
17        }
18    }()
19    go func() {
20        for {
21            ch2 <- 2
22            time.Sleep(2 * time.Second)
23        }
24    }()
25    go func() {
26        for {
27            ch3 <- 3
28            time.Sleep(3 * time.Second)
29        }
30    }()
31
32    for i := 0; i < 3; i++ {
33        select {
34        case v := <-ch1:
35            fmt.Println(v)
36        case v := <-ch2:
37            fmt.Println(v)
38        case v := <-ch3:
39            fmt.Println(v)
40        }
41    }
42}

上述代码创建了三个定期传递 1、2、3 的通道,并使用 select 从通道中接收值并输出。以这种方式使用 select,可以同时从多个通道接收数据,并且按照值到达的顺序进行处理。

for range

channel 可以使用 for range 轻松接收数据。对 channel 使用 for range 时,每当该通道中有数据添加就会执行一次,当通道关闭时则结束循环。

 1package main
 2
 3func main() {
 4    ch := make(chan int)
 5    go func() {
 6        ch <- 1
 7        ch <- 2
 8        close(ch)
 9    }()
10
11    for i := range ch {
12        fmt.Println(i)
13    }
14}

上述代码使用 channel 输出 1 和 2。在该代码中,使用 for range 在每次有数据添加到通道时接收数据并输出。当通道关闭时,循环结束。

如前面多次示例所示,此语法也可用作简单的同步手段。

 1package main
 2
 3func main() {
 4    ch := make(chan struct{})
 5    go func() {
 6        defer close(ch)
 7        time.Sleep(1 * time.Second)
 8        fmt.Println("Hello, World!")
 9    }()
10
11    fmt.Println("Waiting for goroutine...")
12    for range ch {}
13}

上述代码在休眠 1 秒后输出 Hello, World!。在该代码中,使用 channel 将同步代码改写为异步代码。以这种方式使用 channel,可以轻松地将同步代码改写为异步代码,并设置 join 节点。

etc

  1. 向 nil channel 发送或从中接收数据,可能陷入无限阻塞并导致死锁。
  2. 关闭通道后再发送数据,将引发 panic。
  3. 即使不显式关闭通道,GC 在回收时也会关闭通道。

mutex

spinlock

spinlock 是一种通过循环不断尝试获取锁的同步方法。在 Go 语言中,可以使用指针较容易地实现自旋锁。

 1package spinlock
 2
 3import (
 4    "runtime"
 5    "sync/atomic"
 6)
 7
 8type SpinLock struct {
 9    lock uintptr
10}
11
12func (s *SpinLock) Lock() {
13    for !atomic.CompareAndSwapUintptr(&s.lock, 0, 1) {
14        runtime.Gosched()
15    }
16}
17
18func (s *SpinLock) Unlock() {
19    atomic.StoreUintptr(&s.lock, 0)
20}
21
22func NewSpinLock() *SpinLock {
23    return &SpinLock{}
24}

上述代码是 spinlock 包的实现。该代码使用 sync/atomic 包实现了 SpinLock。Lock 方法使用 atomic.CompareAndSwapUintptr 尝试获取锁,Unlock 方法使用 atomic.StoreUintptr 释放锁。由于此方式会不停地尝试获取锁,在获得锁之前会持续占用 CPU,因此可能陷入无限循环。所以,spinlock 最好用于简单的同步,或仅用于持续时间很短的场景。

sync.Mutex

mutex 是用于 goroutine 之间同步的工具。sync 包提供的 mutex 提供 Lock、Unlock、RLock、RUnlock 等方法。mutex 可以通过 sync.Mutex 创建,也可以使用 sync.RWMutex 来使用读写锁。

 1package main
 2
 3import (
 4    "sync"
 5)
 6
 7func main() {
 8    var mu sync.Mutex
 9    var count int
10
11    go func() {
12        mu.Lock()
13        count++
14        mu.Unlock()
15    }()
16
17    mu.Lock()
18    count++
19    mu.Unlock()
20
21    println(count)
22}

在上述代码中,两个 goroutine 几乎同时访问同一个 count 变量。此时,如果使用 mutex 将访问 count 变量的代码设为临界区,就可以防止对 count 变量的并发访问。这样,无论运行多少次,该代码都会始终输出 2。

sync.RWMutex

sync.RWMutex 是一种可以区分读锁和写锁使用的 mutex。可以使用 RLock、RUnlock 方法来加锁和解锁读锁。

 1package cmap
 2
 3import (
 4    "sync"
 5)
 6
 7type ConcurrentMap[K comparable, V any] struct {
 8    sync.RWMutex
 9    data map[K]V
10}
11
12func (m *ConcurrentMap[K, V]) Get(key K) (V, bool) {
13    m.RLock()
14    defer m.RUnlock()
15
16    value, ok := m.data[key]
17    return value, ok
18}
19
20func (m *ConcurrentMap[K, V]) Set(key K, value V) {
21    m.Lock()
22    defer m.Unlock()
23
24    m.data[key] = value
25}

上述代码使用 sync.RWMutex 实现了 ConcurrentMap。在该代码中,Get 方法加读锁,Set 方法加写锁,从而可以安全地访问和修改 data 映射。之所以需要读锁,是因为在大量仅涉及读取操作的情况下,可以不加写锁,只加读锁,使多个 goroutine 能够同时执行读取操作。通过这种方式,在不需要改变状态、无需加写锁的情况下,仅加读锁即可提升性能。

fakelock

fakelock 是一种实现 sync.Locker 的简单技巧。该结构体提供与 sync.Mutex 相同的方法,但实际上不执行任何操作。

1package fakelock
2
3type FakeLock struct{}
4
5func (f *FakeLock) Lock() {}
6
7func (f *FakeLock) Unlock() {}

上述代码是 fakelock 包的实现。该包实现了 sync.Locker,提供 Lock、Unlock 方法,但实际上不执行任何操作。为什么需要这样的代码,我将在合适的时机再作说明。

waitgroup

sync.WaitGroup

sync.WaitGroup 是一种用于等待所有 goroutine 任务完成的工具。它提供 Add、Done、Wait 方法,其中 Add 方法用于增加 goroutine 的数量,Done 方法用于通知某个 goroutine 的任务已完成。然后,Wait 方法会等待所有 goroutine 的任务完成。

 1package main
 2
 3import (
 4    "sync"
 5    "sync/atomic"
 6)
 7
 8func main() {
 9    wg := sync.WaitGroup{}
10    c := atomic.Int64{}
11
12    for i := 0; i < 100 ; i++ {
13        wg.Add(1)
14        go func() {
15            defer wg.Done()
16            c.Add(1)
17        }()
18    }
19
20    wg.Wait()
21    println(c.Load())
22}

上述代码使用 sync.WaitGroup,让 100 个 goroutine 同时对变量 c 进行加法运算。在该代码中,使用 sync.WaitGroup 等待所有 goroutine 完成后,输出 c 变量累加的结果。在对少量任务进行 fork & join 时,仅使用通道就已足够;但在对大量任务进行 fork & join 时,使用 sync.WaitGroup 也是不错的选择。

with slice

与切片一同使用时,waitgroup 可以成为无需加锁即可管理出色并发执行任务的工具。

 1package main
 2
 3import (
 4	"fmt"
 5	"sync"
 6    "rand"
 7)
 8
 9func main() {
10	var wg sync.WaitGroup
11	arr := [10]int{}
12
13	for i := 0; i < 10; i++ {
14		wg.Add(1)
15		go func(id int) {
16			defer wg.Done()
17
18			arr[id] = rand.Intn(100)
19		}(i)
20	}
21
22	wg.Wait()
23	fmt.Println("Done")
24
25    for i, v := range arr {
26        fmt.Printf("arr[%d] = %d\n", i, v)
27    }
28}

上述代码仅使用 waitgroup,让每个 goroutine 同时生成 10 个随机整数,并将其保存到分配的索引中。在该代码中,使用 waitgroup 等待所有 goroutine 完成后,输出 Done。通过这种方式使用 waitgroup,多个 goroutine 可以同时执行任务,在所有 goroutine 完成之前无需加锁即可保存数据,并在任务结束后统一进行后处理。

golang.org/x/sync/errgroup.ErrGroup

errgroup 是对 sync.WaitGroup 的扩展包。与 sync.WaitGroup 不同,errgroup 在任一 goroutine 的任务发生错误时,会取消所有 goroutine 并返回该错误。

 1package main
 2
 3import (
 4    "context"
 5    "fmt"
 6    "golang.org/x/sync/errgroup"
 7)
 8
 9func main() {
10    g, ctx := errgroup.WithContext(context.Background())
11    _ = ctx
12
13    for i := 0; i < 10; i++ {
14        i := i
15        g.Go(func() error {
16            if i == 5 {
17                return fmt.Errorf("error")
18            }
19            return nil
20        })
21    }
22
23    if err := g.Wait(); err != nil {
24        fmt.Println(err)
25    }
26}

上述代码使用 errgroup 创建 10 个 goroutine,并在第 5 个 goroutine 中引发错误。为了展示错误发生的情况,我们有意在第五个 goroutine 中引发错误。但在实际使用时,应使用 errgroup 创建 goroutine,并针对各 goroutine 中发生错误的情况进行各种后处理。

once

这是一种只应执行一次的代码的执行工具。可以通过下面的构造函数来执行相关代码。

1func OnceFunc(f func()) func()
2func OnceValue[T any](f func() T) func() T
3func OnceValues[T1, T2 any](f func() (T1, T2)) func() (T1, T2)

OnceFunc

OnceFunc 仅保证该函数在整个程序中只执行一次。

 1package main
 2
 3import "sync"
 4
 5func main() {
 6    once := sync.OnceFunc(func() {
 7        println("Hello, World!")
 8    })
 9
10    once()
11    once()
12    once()
13    once()
14    once()
15}

上述代码使用 sync.OnceFunc 输出 Hello, World!。在该代码中,使用 sync.OnceFunc 创建 once 函数,即使多次调用 once 函数,Hello, World! 也只会输出一次。

OnceValue

OnceValue 并非仅保证该函数只执行一次,而是会保存该函数的返回值,在再次调用时返回已保存的值。

 1package main
 2
 3import "sync"
 4
 5func main() {
 6    c := 0
 7    once := sync.OnceValue(func() int {
 8        c += 1
 9        return c
10    })
11
12    println(once())
13    println(once())
14    println(once())
15    println(once())
16    println(once())
17}

上述代码使用 sync.OnceValue 使变量 c 每次增加 1。在该代码中,使用 sync.OnceValue 创建 once 函数,即使多次调用 once 函数,变量 c 也只会增加一次,并返回增加后的值 1。

OnceValues

OnceValues 的作用与 OnceValue 相同,但可以返回多个值。

 1package main
 2
 3import "sync"
 4
 5func main() {
 6    c := 0
 7    once := sync.OnceValues(func() (int, int) {
 8        c += 1
 9        return c, c
10    })
11
12    a, b := once()
13    println(a, b)
14    a, b = once()
15    println(a, b)
16    a, b = once()
17    println(a, b)
18    a, b = once()
19    println(a, b)
20    a, b = once()
21    println(a, b)
22}

上述代码使用 sync.OnceValues 使变量 c 每次增加 1。在该代码中,使用 sync.OnceValues 创建 once 函数,即使多次调用 once 函数,变量 c 也只会增加一次,并返回增加后的值 1。

atomic

atomic 包是提供原子操作的包。atomic 包提供 Add、CompareAndSwap、Load、Store、Swap 等方法,但近来更推荐使用 Int64、Uint64、Pointer 等类型。

 1package main
 2
 3import (
 4    "sync"
 5    "sync/atomic"
 6)
 7
 8func main() {
 9    wg := sync.WaitGroup{}
10    c := atomic.Int64{}
11
12    for i := 0; i < 100 ; i++ {
13        wg.Add(1)
14        go func() {
15            defer wg.Done()
16            c.Add(1)
17        }()
18    }
19
20    wg.Wait()
21    println(c.Load())
22}

这是之前使用过的示例。该代码使用 atomic.Int64 类型以原子方式增加变量 c。通过 Add 方法和 Load 方法,可以原子地增加变量并读取变量的值。此外,可以使用 Store 方法存储值,使用 Swap 方法交换值,并使用 CompareAndSwap 方法比较值,若符合条件则进行替换。

cond

sync.Cond

cond 包是提供条件变量的包。cond 包可以通过 sync.Cond 创建,并提供 Wait、Signal、Broadcast 方法。

 1package main
 2
 3import (
 4    "sync"
 5)
 6
 7func main() {
 8    c := sync.NewCond(&sync.Mutex{})
 9    ready := false
10
11    go func() {
12        c.L.Lock()
13        ready = true
14        c.Signal()
15        c.L.Unlock()
16    }()
17
18    c.L.Lock()
19    for !ready {
20        c.Wait()
21    }
22    c.L.Unlock()
23
24    println("Ready!")
25}

上述代码使用 sync.Cond 等待变量 ready 变为 true。在该代码中,使用 sync.Cond 等待 ready 变量变为 true 后,输出 Ready!。以这种方式使用 sync.Cond,可以让多个 goroutine 同时等待某个特定条件得到满足。

利用此特性,可以实现一个简单的 queue。

 1package queue
 2
 3import (
 4    "sync"
 5    "sync/atomic"
 6)
 7
 8type Node[T any] struct {
 9    Value T
10    Next  *Node[T]
11}
12
13type Queue[T any] struct {
14    sync.Mutex
15    Cond *sync.Cond
16    Head *Node[T]
17    Tail *Node[T]
18    Len  int
19}
20
21func New[T any]() *Queue[T] {
22    q := &Queue[T]{}
23    q.Cond = sync.NewCond(&q.Mutex)
24    return q
25}
26
27func (q *Queue[T]) Push(value T) {
28    q.Lock()
29    defer q.Unlock()
30
31    node := &Node[T]{Value: value}
32    if q.Len == 0 {
33        q.Head = node
34        q.Tail = node
35    } else {
36        q.Tail.Next = node
37        q.Tail = node
38    }
39    q.Len++
40    q.Cond.Signal()
41}
42
43func (q *Queue[T]) Pop() T {
44    q.Lock()
45    defer q.Unlock()
46
47    for q.Len == 0 {
48        q.Cond.Wait()
49    }
50
51    node := q.Head
52    q.Head = q.Head.Next
53    q.Len--
54    return node.Value
55}

如此使用 sync.Cond,就不必通过 spin-lock 占用大量 CPU,而是可以高效地等待,并在条件满足时恢复运行。

semaphore

golang.org/x/sync/semaphore.Semaphore

semaphore 包是提供信号量的包。semaphore 包可以通过 golang.org/x/sync/semaphore.Semaphore 创建,并提供 Acquire、Release、TryAcquire 方法。

 1package main
 2
 3import (
 4    "context"
 5    "fmt"
 6    "golang.org/x/sync/semaphore"
 7)
 8
 9func main() {
10    s := semaphore.NewWeighted(1)
11
12    if s.TryAcquire(1) {
13        fmt.Println("Acquired!")
14    } else {
15        fmt.Println("Not Acquired!")
16    }
17
18    s.Release(1)
19}

上述代码使用 semaphore 创建信号量,并通过 Acquire 方法获取信号量,再通过 Release 方法释放信号量。该代码展示了使用 semaphore 获取和释放信号量的方法。

结语

基本内容到这里应该就足够了。希望您能够基于本文的内容理解如何使用 goroutine 管理并发,并能够在实际中加以运用。希望本文对您有所帮助。谢谢。