协程是安全的吗?

前言

我们都知道,多个线程操作同一个变量,是有线程安全问题的。但是,如果换成是“多个协程操作同一个变量”呢?还会有安全问题吗?

实验环境

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之类的方式来解决。

疑问

改为单线程后,就可以毫无顾虑的使用多协程了吗?答:并不是。

在某些场景下如果不注意还是会有隐患的,比如这段代码:

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使用权

所以在协程执行过程中,如果我们想要确保全局变量不会被其它协程修改,就要给变量加锁。

参阅

https://zhuanlan.zhihu.com/p/40279108
https://segmentfault.com/a/1190000041568839

你可能感兴趣的:(Golang,协程)