性能文章>Go 面试题:string 是线程安全的吗?>

Go 面试题:string 是线程安全的吗?原创

9月前
222045

大家好,我是煎鱼。

之前在某知名平台看到大家在交流 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 的并发写导致出错的场景,更是每隔一段时间就能在线上看到一两次。

每次做并发操作时,都建议想清楚,这个场景的到底需不需要保护共享变量,做好原子操作等。

你好,我是煎鱼,出版过 Go 畅销书《Go 语言编程之旅》,再到获得 GOP(Go 领域最有观点专家)荣誉,点击蓝字查看我的出书之路

 

点赞收藏
分类:标签:
煎鱼
请先登录,查看4条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步
5
4