Golang并发
并行与并发的区别:
- 并发是指一个处理器同时处理多个任务。
- 并行是指多个处理器或者是多核的处理器同时处理多个不同的任务。
Go 的并发模型基于 协程 和 通道(channels)。
Goroutine
Goroutine就是Go并发中的协程,是一种更轻量的用户级线程,由Go在运行时管理。特点如下:
- 轻量:系统线程栈空间通常$\ge$1MB,Goroutine 的栈空间初始大小只有 2KB,可以动态扩容
- 高效:Goroutine 的调度器采用 M:N 模型,可以将 M 个 Goroutine 映射到 N 个 OS 线程上,实现高效调度
- 高并发:可创建数十万协程
- 方便:在Golang中,只要在函数调用前加上关键字
go
就可以启动异步Goroutine
这里给出一个简单的Goroutine例子:
1 | package main |
这里补充一个Go1.22之前的版本存在的一个问题。在Go1.22之前,对于 for
循环中的范围表达式(for range
),循环变量的初始化是在循环开始时仅执行一次的。这意味着每次循环迭代时,都会使用同一个变量,而不是为每次迭代创建一个新的变量副本。
通常情况下不会出现问题,除了for range
循环变量和Goroutine结合使用时:
1 | func main() { |
直观上觉得程序会输出"ab"
,但实际上往往会输出"bb"
。这是因为goroutine是异步的,当第一个循环结束时,程序可能还未打印出v
,结果进入第二个循环后,v
被修改为了"b"
,两个goroutine就都打印了最后的v
,即结果为"bb"
解决办法:
- 函数传参
- 创建新局部变量
1 | func main() { |
在Go1.22中,for range
循环变量改成为每次迭代创建一个新的变量副本,故不存在上述问题了。
Channel
channel
是Go中的一种复杂数据类型,可看作特殊的队列,具有先进先出的特点,用于同步协程间通信。一般用于协程间的数据通信。
声明格式
一般的通道声明格式如下:
1 | // var ch chan [type] |
一个通道只能传输一种类型的数据;所有的类型的数据都可以用于通道,包括空接口。
使用make()
对通道进行声明时,如果不指定缓冲区大小,则返回无缓冲通道,否则返回带缓冲通道。
通道中还有只读通道和只写通道。当然,声明一个只读或只写的通道没有意义,所以这两种通道一般用于构建函数参数:
1 | func Reader(ch <-chan int) {} // 只读通道 |
这样就可以保证Reader
中ch通道是只读的,Writer
中ch
通道是只写的。下文会给出更详细的例子。
发送数据
1 | // 向通道写入10 |
值得注意的是,当向一个无缓冲通道(或者有缓冲但会写入数据量超过缓冲区的通道)写入数据时,必须保证有一个读协程随时准备从通道数据,否则会出现死锁报错:fatal error: all goroutines are asleep - deadlock!
1 | // Bad! |
接收数据
1 | // 读操作的第二个返回值如果是false,则管道关闭且为空 |
注意的是,当尝试向空通道进行读操作时,会引发通道阻塞,直到通道中有新值写入,这样新值就会被读出,并结束阻塞。
关闭channel
通道是可以关闭的。一旦关闭,就无法向该channel写入数据。注意的是,空通道关闭后,仍可以多次从通道读出零值。
1 | ch := make(chan int) |
遍历channel
想要遍历channel
前,必须先关闭channel
。关闭channel
后就不能向channel
写入数据。
1 | // 关闭通道 |
遍历方法一般有两种,如下:
1 | // 方案A,能自动检测管道是否关闭 |
Select语句
Go中的select
语句是专门处理通道操作的语句,一般与for
语句配合使用,也被频繁用在Go的并发编程中:
1 | for { |
select语句特点如下:
- 非阻塞:
- 如果没有任何 case 的通道准备就绪,
select
语句将选择执行default
子句(如果有的话)。 - 如果没有
default
子句并且所有 case 的通道都不准备就绪,则select
语句将阻塞,直到其中一个通道准备就绪。
- 如果没有任何 case 的通道准备就绪,
- 随机选择:
- 如果有多个 case 的通道都准备就绪,
select
语句将随机选择一个 case 来执行。 - 这种随机选择有助于避免死锁和其他竞态条件。
- 如果有多个 case 的通道都准备就绪,
- case 表达式:
select
语句的每个 case 必须是一个通道操作,例如发送或接收。
WaitGroup
除了channel
,Go中还提供了一些重要的工具来协调goroutine之间的同步。sync
包下的WaitGroup
是其中之一。
WaitGroup
用于确保一些goroutine完成其任务后程序再执行其他内容。这里我们可以回顾一下全文的第一个代码示例:
1 | func main() { |
这里的time.Sleep(5 * time.Second)
是用于阻塞主线程的进行,来确保goroutine执行完再打印最后的"Done."
。显然我们无法预测所有程序的goroutine执行所需的大概时间,所以这里使用WaitGroup
可以更有效地同步协程与主线程:
1 | package main |
代码变换体现在worker
函数和main
函数中。WaitGroup
本质上用一个计数器来实现协程的同步。接下来是对WaitGroup
的三个方法的解释。
WaitGroup.Add
用于向计数器添加一个计数值,表示当前任务列表中新值了多少任务(参数为负数时表示减少)。一般在你想协调的goroutine任务执行前调用wg.Add(n)
。
最好不要使Add
和Wait
并发调用,否则有可能会达不到同步协程的效果:
1 | func main() { |
该例子中,我们期望goroutine都执行完后执行doMain()
,但实际上很有可能在goroutine开始执行前(执行wg.Add(1)
前)就跳过了wg.Wait()
,导致提前执行doMain()
。
WaitGroup.Done
用于让计数器减一,表示当前列表中有一个任务完成了。事实上,wg.Done()
的底层实现就是wg.Add(-1)
。一定要在每个goroutine完成任务后执行wg.Done()
,否则会造成死锁。
当然执行了多余的wg.Done()
也会导致死锁。
WaitGroup.Wait
执行函数时,检测当前计数器的值是否为0,不是则阻塞当前进程/协程。
在需要将
WaitGroup
变量传入协程函数时,要使用指针引入,而不是值引入:
1
2
3
4
5
6
7
8
9
10
11
12
13
14 func worker(id int, wg sync.WaitGroup) { // 值引入,Bad!
defer wg.Done() // 看似wg.Done()执行了,实际上和main中的wg没关系。
doSomething()
}
func main() {
var wg sync.WaitGroup
for i := range 5 {
wg.Add(1)
go worker(i, &wg)
}
wg.Wait() // wg没Done()过,死锁。
fmt.Println("All workers finished")
}
Mutex
在进行并发操作时,对于临界区的操作需要通过加锁来实现并发安全。Golang标准包sync
提供了两种锁:
- 互斥锁(Mutex)
- 读写锁(RWMutex)
Mutex
Mutex
就两个方法:Lock()
和Unlock()
,对临界区操作前Lock()
,如果有其他goroutine获取的锁,当前goroutine阻塞,否则当前goroutine获得锁(信号量机制),操作结束后Unlock()
。
这里给一个场景:
1 | func main() { |
由于条件竞争,cnt
并没有累加到100000。通过上锁就能解决问题。
1 | func main() { |
RWMutex
RWMutex
相比互斥锁,它允许多个读操作同时进行,但在写操作进行时,会阻塞所有的读操作和写操作。这样可以提高并发性能。
RWMutex
提供了四个方法:RLock()
,RUnlock()
,Lock()
,Unlock()
,前两者用于读操作,后两者用于写操作。使用方法与Mutex
类似。
Atomic
sync/atomic
包提供了Golang中的一些原子变量和原子操作。原子操作,即不会被分割的操作,作用上与互斥锁相似,但底层由CPU指令实现,不涉及加锁解锁,故性能高于互斥锁。原子变量则是用于执行原子操作的特殊变量。
sync/atomic
中原子操作一共有五类:
- 读取(Load)
- 写入(Store)
- 交换(Swap)
- 比较并交换 (CompareAndSwap)
- 增减(Add)
sync/atomic
中原子变量类型有以下几种:
- bool 布尔值
- (u)int32 32位整型
- (u)int64 64位整型
- pointer 不可参与指针运算的指针
- uinptr 无法持有对象的指针
- value 空接口
所有的原子变量都实现了前四类的原子操作方法(Load, Store, Swap, CompareAndSwap),能参与加减运算的类型还实现了第五类原子操作方法(Add)
原子操作函数
原子操作函数中第一个参数往往是数据地址,如
1 | func main() { |
原子操作函数的操作对象限制在(u)int32
,(u)int64
,uintptr
,Pointer
上。
原子操作方法
官方文档更推荐使用原子变量,通过调用其方法来进行原子操作。这样比直接调用原子操作函数更加直观和不容易出错,支持的操作对象类型的更多。
1 | func main() { |
Context
Golang中context
可用来定义goroutine的上下文,用优雅的方式传递取消信号和设置超时。
创建根节点Context
有两种方法创建空context:
1 | // 方法一: |
两种方法能返回一个没有 deadline、没有取消函数的 context.Context
对象,只有语义的区别,即:
context.Background()
:- 表示一个顶层或根上下文。
- 适用于程序启动时或作为顶级上下文来开始处理一个请求。
- 一般用于实际的生产代码中。
context.TODO()
:- 表示一个待办事项上下文。
- 主要用于代码尚未完成时作为占位符。
- 不推荐在生产代码中使用。
创建派生节点
派生节点由根节点派生而来,用形如WithXXX
格式的函数进行创建。
WithValue
创建一个带键值对的节点,同时保留父节点的数据。
1 | // WithValue(parent Context, key, val any) Context |
WithCancel
创建一个派生节点和终止该节点执行的cancel()
函数。
1 | func main() { |
这里ctx
就是消费者协程和生产者协程通信的桥梁。通过调用cancel
函数,关闭与ctx
关联的done channel
,这样case <-ctx.Done()
就不再阻塞,可以执行关闭生产者的相关代码。父节点被取消后还会将取消消息传递给所有派生的子节点。
WithDeadline
在WithCancel
的基础上,设置一个超时时间。被创建的子context
会在指定的时间点自动关闭 Done
通道。
1 | deadline, err := time.Parse("2006-01-02 15:04:05", "2024-12-31 23:59:59")\ |
WithTimeout
与WithDeadline
类似,只不过接受一个持续时间而不是一个绝对时间。事实上WithTimeout(1*time.Second)
等同于 WithDeadline(time.Now().Add(1*time.Second))
这里用生产者-消费者模型,来展示channel的基本使用:
1 | package main |
Runtime
runtime
是Golang的核心组件之一,负责管理程序执行过程中的各种底层细节。这里只介绍一些和并发有关的接口。
runtime.GOMAXPROCS(n)
: 设置最多可以并发运行的 CPU 数量。runtime.Goexit()
: 使当前 Goroutine 退出。runtime.Gosched()
: 让出当前 Goroutine 的 CPU 时间片,允许其他 Goroutine 运行。
这些只是 runtime
包提供的众多功能中的一部分,对于更深入的了解和使用,请查阅官方文档和相关教程。