面试题如下:
如标题所示,原题是:Go 中的 string 赋值是线程安全的吗?
我们可以一起先想想答案,看看中不中。
线程安全是什么
线程安全是指在多线程环境下,程序的执行能够正确地处理多个线程并发访问共享数据的情况,保证程序的正确性和可靠性。
能被称之为:线程安全,需要在多个线程同时访问共享数据时,满足如下几个条件:
- 不会出现数据竞争(data race):多个线程同时对同一数据进行读写操作,导致数据不一致或未定义的行为。
- 不会出现死锁(deadlock):多个线程互相等待对方释放资源而无法继续执行的情况。
- 不会出现饥饿(starvation):某个线程因为资源分配不公而无法得到执行的情况。
string 线程安全
需要有一个基础了解,对于 string 类型,运行时表现对照是 StringHeader 结构体。
如下:
type StringHeader struct { Data uintptr Len int }
- Data:存放指针,其指向具体的存储数据的内存区域。
- Len:字符串的长度。
在了解前置知识后,接下来进入到实践环境。看看在 Go 里 string 类型的变量,做并发赋值到底是否线程安全。
案例一:并发访问
我们先看第一个案例,多个 goroutine 中并发访问同一个 string 变量的场景。如下代码:
package main import ( "fmt" "sync" ) func main() { var wg sync.WaitGroup str := "脑子进煎鱼了" for i := 0; i < 5; i++ { wg.Add(1) go func() { defer wg.Done() fmt.Println(str) }() } wg.Wait() }
输出结果:
脑子进煎鱼了 脑子进煎鱼了 脑子进煎鱼了 脑子进煎鱼了 脑子进煎鱼了
在上面的例子中,我们定义了一个 string 变量 str,然后启动了 5 个 goroutine,每个 goroutine 都会输出 str 的值。由于 str 是不可变类型,因此在多个 goroutine 中并发访问它是安全的。
可能有同学疑惑不可变类型是什么?
不可变类型,指的是一种不能被修改的数据类型,也称为值类型(value type)。不可变类型在创建后其值不能被改变,任何对它的修改操作都会返回一个新的值,而不会改变原有的值。
案例二:并发写入
第一个案例看起来没什么问题。我们再看第二个案例,针对多个 goroutine 并发写入的场景来进行验证。
如下代码:
func main() { var wg sync.WaitGroup str := "脑子进煎鱼了" for i := 0; i < 5; i++ { wg.Add(1) go func() { defer wg.Done() str += "!" // 修改 str 变量 fmt.Println(str) }() } wg.Wait() }
输出结果:
脑子进煎鱼了! 脑子进煎鱼了!! 脑子进煎鱼了!!! 脑子进煎鱼了!!!! 脑子进煎鱼了!!!!!
看起来没什么问题,还是正常的拼接结果,输出的顺序也完全没有问题的样子。(大雾)
我们再多运行几次。再看看输出结果:
// demo1 脑子进煎鱼了! 脑子进煎鱼了!! 脑子进煎鱼了!!! 脑子进煎鱼了!!! 脑子进煎鱼了!!! // demo2 脑子进煎鱼了! 脑子进煎鱼了!!! 脑子进煎鱼了!! 脑子进煎鱼了!!!!! 脑子进煎鱼了!!!!
在上面的例子中,我们在每个 goroutine 中向 str 变量中添加了一个感叹号。由于多个 goroutine 同时修改了 str 变量,因此可能会出现数据竞争的情况。
我们会发现程序输出结果会出现乱序或不一致的情况,可以确认 string 类型变量在多个 goroutine 中是不安全的。
要警惕这种场景,在实际业务代码中,常有人前人留 BUG,后人因此翻车。主打一个熬夜查和修 BUG,分分钟还得洗脏数据。
string 实现线程安全
使用互斥锁
要实现 string 类型变量的线程安全,第一种方式:使用互斥锁(Mutex)来保护共享变量,确保同一时间只有一个 goroutine 可以访问它。下面是一个改造后的例子。
如下代码:
func main() { var wg sync.WaitGroup var mu sync.Mutex // 定义一个互斥锁 str := "煎鱼" for i := 0; i < 5; i++ { wg.Add(1) go func() { defer wg.Done() mu.Lock() // 加锁 str += "!" fmt.Println(str) mu.Unlock() // 解锁 }() } wg.Wait() }
输出结果:
煎鱼! 煎鱼!! 煎鱼!!! 煎鱼!!!! 煎鱼!!!!!
在上面的例子中,我们使用了 sync 包中的 Mutex 类型来定义一个互斥锁 mu。在每个 goroutine 中,我们先使用 mu.Lock() 方法来加锁,确保同一时间只有一个 goroutine 可以访问 str 变量。
再修改 str 变量的值并输出,最后使用 mu.Unlock() 方法来解锁,让其他 goroutine 可以继续访问 str 变量。
需要注意,互斥锁会带来一些性能上的开销,两全难齐美。
使用 atomic 包
第二种方案是使用 atomic 包来实现原子操作,如下代码:
func main() { var wg sync.WaitGroup var str atomic.Value // 定义一个原子变量 str.Store("hello, world") for i := 0; i < 5; i++ { wg.Add(1) go func() { defer wg.Done() oldStr := str.Load().(string) // 读取原子变量的值 newStr := oldStr + "!" str.Store(newStr) // 写入原子变量的值 fmt.Println(newStr) }() } wg.Wait() }
这样子也可以保证 string 类型变量的原子操作。但在现实场景下,仍然无法解决多 goroutine 导致的竞态条件(race condition)。
也就是存在多个 goroutine 并发取到的变量值都是一样的,得到的结果还是不固定的,最终还是要用 Mutex 或者 RWMutex 锁来做共享变量保护。
这两者没有绝对的好坏,但需要分清楚你的使用场景,决定用锁还是 atomic,又或是其他逻辑上的调整。
总结
在前面我们有把 StringHeader 结构体让大家看看,其实很明显是不支持线程安全的。平白无故每个类型都去支持线程安全的话,会增加很多开销。
绝大多数的情况下,你可以默认任何数据类型的变量赋值都不是线程安全的,除非他加了锁(Mutex)或 atomic(原子操作)。而在 string、slice、map 的并发写导致出错的场景,更是每隔一段时间就能在线上看到一两次。
每次做并发操作时,都建议想清楚,这个场景的到底需不需要保护共享变量,做好原子操作等。