前言
我们知道,在多线程程序中,如果多个线程操作同一个共享变量,在不添加特殊处理代码(比如加锁)的情况下,是会有线程安全问题的。但是,如果换成是“多个协程操作同一个变量”呢?还会有安全问题吗?
实验环境
Windows 11
Go 1.20.2
过程
先看一段Golang代码示例:
func main() {
count := 0
wg := sync.WaitGroup{}
wg.Add(2)
// 协程1
go func() {
for i := 0; i < 100000; i++ {
count++
}
wg.Done()
}()
// 协程2
go func() {
for i := 0; i < 100000; i++ {
count++
}
wg.Done()
}()
wg.Wait() // 等待上面两个协程都执行完了,再往下执行
fmt.Println(count)
}
上面代码中,协程1 和 协程2 共同对 count
变量执行 +1 操作,各自循环10万遍。
理论上最后输出count
的值应该等于20万,但实际输出的值远小于20万。比如我的输出结果是116346
为什么会这样呢,首先是因为 count++
不是一个原子性的操作,它实际是由三句代码组成的,等同于:
tmp := count
tmp = tmp + 1
count = tmp
是一个“先查询后更新”的操作。
然后,Go语言默认情况下是多线程的,线程数量默认等于CPU的核心数。比如双核CPU,Go就会开启两个线程来运行协程,协程1
在线程A
上执行,协程2
在线程B
上执行,由于是多核CPU,这两个线程是可以并行执行的。因此归根到底,这实际上是一个线程安全的问题,即多个线程操作同一个变量导致的。
疑问1
既然是多线程的原因,那如果改成单线程,是不是就不会有问题了呢?
Go语言刚好有提供这样的配置:runtime.GOMAXPROCS(N)
,其中N
就是你想要设置的线程数量。想改为单线程那么只要将N
设置为 1 就好。
更改后的代码例子:
func main() {
runtime.GOMAXPROCS(1) // 设置为单线程
count := 0
wg := sync.WaitGroup{}
wg.Add(2)
// 协程1
go func() {
for i := 0; i < 100000; i++ {
count++
}
wg.Done()
}()
// 协程2
go func() {
for i := 0; i < 100000; i++ {
count++
}
wg.Done()
}()
wg.Wait() // 等待上面两个协程都执行完了,再往下执行
fmt.Println(count) // 输出:200000
}
果然改为单线程后,就不存在“线程安全”的问题了,能正确输出count
的值了。
注:在实际生产环境中,不建议通过设置为单线程模式来避免此类问题,因为单线程会降低Go执行协程的效率,可以通过其它方式,比如加锁、channel之类的方式来解决。
疑问2
改为单线程后,就可以毫无顾虑的使用多协程了吗?答:并不是。
在某些场景下如果不注意还是会有隐患的,比如这段代码:
func main() {
runtime.GOMAXPROCS(1) // 设置为单线程
count := 0
wg := sync.WaitGroup{}
wg.Add(2)
// 协程1
go func() {
for i := 0; i < 100000; i++ {
count++
}
wg.Done()
}()
// 协程2
go func() {
fmt.Printf("我是协程2,此时count = %d\n", count)
for i := 0; i < 100000; i++ {
tmp := count
tmp = tmp + 1
count = tmp
}
time.Sleep(time.Second * 2) // 此处会迫使协程2让出执行权
fmt.Printf("我是协程2,已经执行了10万次 +1 操作,此时count = %d\n", count)
wg.Done()
}()
wg.Wait() // 等待上面两个协程都执行完了,再往下执行
}
上述代码会输出:
我是协程2,此时count = 0
我是协程2,已经执行了10万次 +1 操作,此时count = 200000
协程2
一开始查询到的count
值是0,执行了10万次+1后,count
值居然变成了20万。
这是因为睡眠语句time.Sleep(time.Second * 2)
会迫使协程2
让出CPU的使用权,让出CPU使用权后,当前线程会改为去执行协程1
,当协程1
执行完后或遇到 IO阻塞 时,才会又切回来执行协程2
,但切回来后,此时的count
值已经被协程1
修改过了,所以肯定跟协程2
刚开始时查询到的值是不一样的。
除了sleep语句外,当协程遇到IO阻塞时,也会让出CPU使用权
所以在协程执行过程中,如果我们想要确保全局变量不会被其它协程修改,就要给变量加锁。
并发安全
其实,“线程安全”这种说法容易让人产生误解,以为只有在多线程环境中才会有安全问题。但其实不管是多进程、多线程还是多协程,只要有并发场景存在,并且产生了竞态,就会有安全问题,所以更准确的说法应该是“并发安全”。
几个并发安全的例子
1、多个协程给同一个变量+1(使用通道方法)
因为golang的通道是并发安全的,所以我们可以利用通道来实现
func main() {
count := 0
ch := make(chan int)
wgWrite := sync.WaitGroup{}
wgWrite.Add(2)
wgRead := sync.WaitGroup{}
wgRead.Add(2)
// 协程1:负责通道写入
go func() {
defer wgWrite.Done()
for i := 0; i < 100000; i++ {
ch <- 1
}
}()
// 协程2:负责通道写入
go func() {
defer wgWrite.Done()
for i := 0; i < 100000; i++ {
ch <- 1
}
}()
// 协程3:负责读取ch通道,并将值累加到count变量
go func() {
defer wgRead.Done()
for val := range ch {
count += val
}
}()
// 协程4:负责等待协程1、协程2执行完毕
go func() {
defer wgRead.Done()
wgWrite.Wait()
// 执行完毕后,要关闭ch通道,不然协程3会阻塞在读取通道处
close(ch)
}()
wgRead.Wait() // 等待协程3、协程4执行完毕
fmt.Println(count)
}
2、多个协程给同一个变量+1(使用互斥锁方法)
使用互斥锁,相当于牺牲了协程的并发性能,改为串行执行,因为只有当一个协程释放了锁,另外一个协程才能获得锁去执行代码
func main() {
count := 0
mutex := sync.Mutex{}
wgWrite := sync.WaitGroup{}
wgWrite.Add(2)
// 协程1
go func() {
defer wgWrite.Done()
defer mutex.Unlock()
mutex.Lock()
for i := 0; i < 100000; i++ {
count++
}
}()
// 协程2
go func() {
defer wgWrite.Done()
defer mutex.Unlock()
mutex.Lock()
for i := 0; i < 100000; i++ {
count++
}
}()
wgWrite.Wait() // 等待两个协程执行完毕
fmt.Println(count)
}