0%

Go 语言并发机制的初步理解

GO 语言第一周学习,对比CPP和Java设计角度的区别

摘要:Go用channel来实现了语义上的无锁并发,实际上runtime才真正在管理并发和锁。这是一种相当优雅的封装。

1. Go语言的并发机制

Go程序中实现并发主要有两种机制

  • 通过 Go协程(Go routine)通道(channel) 的协作来实现并发
  • 通过同步原语,如 sync.Mutex 等来实现并发,这是对 channel 机制的补充

协程与通道机制的设计来自一条重要的设计哲学:

“Do not communicate by sharing memory; instead, share memory by communicating.”
(不要通过共享内存来通信,通过通信来共享内存。)—Effective Go

协程+通道的方式有些像是消息队列的消费模式,生产者和消费者各自有协程,通过通道进行通信,以此来实现无锁并发。

但是所谓的无锁,其实是表面上的。Channel 本身在底层也是会被竞争的,只是Go语言的Runtime帮助开发者解决了 Channel 竞争的问题。

使用这种方式来解决资源竞争的问题,非常具有现代编程的特色,明显受到现代高并发系统实践经验的影响,在Go语言设计之初就原生支持这样的特性。

2. 并发机制对比

以下代码,我写了一个并发计数器,分别使用三种并发方式来进行对照

  • 仅 goroutine 无 channel
  • 通过 sync.Mutex 来保护共享内存
  • 通过 goroutine + channel
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
package main

import (
"fmt"
"sync"
"time"
)

const goroutine_nums = 100
const increase_per_goroutine = 1000

func main() {
count, time := only_go_routine()
fmt.Printf("only go routine: count: %d time: %v \n", count, time)
count, time = use_mutex()
fmt.Printf("use mutex: count: %d time: %v \n", count, time)
count, time = use_channel()
fmt.Printf("use channel: count: %d time: %v \n", count, time)
}

// 1. 仅使用 goroutine 不使用 channel 和锁
func only_go_routine() (int, time.Duration) {
start := time.Now()

// WaitGroup 本质上是一个协程计数器
// 通过 Add 方法增加计数,通过 Done 方法减少计数
// 通过 Wait 方法阻塞当前协程,直到计数器归零
var wg sync.WaitGroup

// 这是一个计数器,放在这个函数的作用域中,让所有的 goroutine 竞争使用这个计数器
var count int

for i := 0; i < goroutine_nums; i++ {
wg.Add(1)

go func() {
for j := 0; j < increase_per_goroutine; j++ {
count++
}
wg.Done()
}()
}
wg.Wait()
return count, time.Since(start)
}

// 2. 使用 mutex
func use_mutex() (int, time.Duration) {
start := time.Now()
var wg sync.WaitGroup
var mu sync.Mutex
var count int

for i := 0; i < goroutine_nums; i++ {
wg.Add(1)
go func() {
for j := 0; j < increase_per_goroutine; j++ {
// 几乎不用解释,和cpp的写法基本一致
mu.Lock()
count++
mu.Unlock()
}
wg.Done()
}()
}
wg.Wait()

return count, time.Since(start)
}

// 3. 使用 goroutine + channel
func use_channel() (int, time.Duration) {
start := time.Now()
var wg sync.WaitGroup
// 创建一个 channel
ch := make(chan int)
// 计数器
var count int

for i := 0; i < goroutine_nums; i++ {
wg.Add(1)
// 创建一个局部计数器
localCount := 0
go func() {
for j := 0; j < increase_per_goroutine; j++ {
// 通过 ch <- 1 将数据发送到 channel 中
localCount++
}
// 把局部计数器的值发送到 channel 中
ch <- localCount
wg.Done()
}()
}

// 创建一个 goroutine,用于关闭 channel, 这里必须放入协程中来关闭channel,不然由于ch是无缓冲的,主协程会被阻塞,导致死锁
go func() {
// 等待所有 goroutine 执行完毕
wg.Wait()
// 关闭 channel
close(ch)
}()

for c := range ch {
count += c
}

return count, time.Since(start)
}

3. 验证

image-20250912051042889

可以看到当仅使用 goroutine 的时候,计算结果出现了一次错误,这说明程序没有做好资源竞争的管理,根本就是错误的。

而使用mutex时,它的耗时通常是最长的,这是因为锁竞争情况非常严重,频繁加解锁导致大量时间消耗在锁竞争上。这其实也体现了让runtime去做调度比自己管理mutex还是要强很多的。

当使用goroutine + channel 的时候,程序比较容易理解,同时由于无锁(表面上),runtime调度起来压力比较小,性能表现还不错。

这个用例其实有很多缺点,但是拿来对比效果还不错。

仅使用 goroutine 使用 sync.Mutex 使用 goroutine + channel
线程安全 不安全 安全 安全
性能表现 快(但结果错误,无参考价值) 慢(频繁加解锁,锁竞争严重) 最快(无锁、并行计算、通信开销低)
性能瓶颈 数据竞争导致结果错误 锁竞争 + 调度开销 channel 通信和 goroutine 启动开销
代码可读性 简单(但错误) 本case比较简单,但通常较难 稍复杂(需理解 channel 和 WaitGroup 协作),但结构清晰

4. 待探索问题

  • channel缓冲区大小对性能的影响
  • 仅 goroutine 的情况下 runtime 如何调度存在数据竞争的 goroutine
  • 使用工具来更详细地分析内部竞争
  • 使用go内置的埋点方法来继续进行学习
  • 用 pprof / trace 工具可视化这三种模式下的 goroutine 阻塞与锁竞争