Go 面试题:string 是线程安全的
大家好,我是煎鱼。
之前在某知名平台看到大家在交流 Go 岗位相关的面试题,其中有一道引起了大家的一些讨论,勾起被八股文的深深回忆。
面试题如下:
如标题所示,原题是: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 的并发写导致出错的场景,更是每隔一段时间就能在线上看到一两次。
每次做并发操作时,都建议想清楚,这个场景的到底需不需要保护共享变量,做好原子操作等