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) }
func only_go_routine() (int, time.Duration) { start := time.Now()
var wg sync.WaitGroup
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) }
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++ { mu.Lock() count++ mu.Unlock() } wg.Done() }() } wg.Wait()
return count, time.Since(start) }
func use_channel() (int, time.Duration) { start := time.Now() var wg sync.WaitGroup 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++ { localCount++ } ch <- localCount wg.Done() }() } go func() { wg.Wait() close(ch) }()
for c := range ch { count += c }
return count, time.Since(start) }
|
3. 验证

可以看到当仅使用 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 阻塞与锁竞争