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

简介: Go 面试题:string 是线程安全的吗?

面试题如下:



如标题所示,原题是:Go 中的 string 赋值是线程安全的吗?


我们可以一起先想想答案,看看中不中。


线程安全是什么


线程安全是指在多线程环境下,程序的执行能够正确地处理多个线程并发访问共享数据的情况,保证程序的正确性和可靠性。


a38e574980823f4b8136fb30ce1fea91.png



能被称之为:线程安全,需要在多个线程同时访问共享数据时,满足如下几个条件:


  • 不会出现数据竞争(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 的并发写导致出错的场景,更是每隔一段时间就能在线上看到一两次。

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

相关文章
|
21天前
|
存储 安全 算法
Go语言是如何支持多线程的
【10月更文挑战第21】Go语言是如何支持多线程的
106 72
|
21天前
|
Go 调度 开发者
Go语言多线程的优势
【10月更文挑战第15天】
15 4
|
2月前
|
存储 缓存 安全
【Java面试题汇总】多线程、JUC、锁篇(2023版)
线程和进程的区别、CAS的ABA问题、AQS、哪些地方使用了CAS、怎么保证线程安全、线程同步方式、synchronized的用法及原理、Lock、volatile、线程的六个状态、ThreadLocal、线程通信方式、创建方式、两种创建线程池的方法、线程池设置合适的线程数、线程安全的集合?ConcurrentHashMap、JUC
【Java面试题汇总】多线程、JUC、锁篇(2023版)
|
2月前
|
安全 Java API
【Java面试题汇总】Java基础篇——String+集合+泛型+IO+异常+反射(2023版)
String常量池、String、StringBuffer、Stringbuilder有什么区别、List与Set的区别、ArrayList和LinkedList的区别、HashMap底层原理、ConcurrentHashMap、HashMap和Hashtable的区别、泛型擦除、ABA问题、IO多路复用、BIO、NIO、O、异常处理机制、反射
【Java面试题汇总】Java基础篇——String+集合+泛型+IO+异常+反射(2023版)
|
2月前
|
消息中间件 前端开发 NoSQL
面试官:线程池遇到未处理的异常会崩溃吗?
面试官:线程池遇到未处理的异常会崩溃吗?
74 3
面试官:线程池遇到未处理的异常会崩溃吗?
|
2月前
|
消息中间件 存储 前端开发
面试官:说说停止线程池的执行流程?
面试官:说说停止线程池的执行流程?
51 2
面试官:说说停止线程池的执行流程?
|
2月前
|
消息中间件 前端开发 NoSQL
面试官:如何实现线程池任务编排?
面试官:如何实现线程池任务编排?
33 1
面试官:如何实现线程池任务编排?
|
2月前
|
算法 程序员 Go
PHP 程序员学会了 Go 语言就能唬住面试官吗?
【9月更文挑战第8天】学会Go语言可提升PHP程序员的面试印象,但不足以 solely “唬住” 面试官。学习新语言能展现学习能力、拓宽技术视野,并增加就业机会。然而,实际项目经验、深入理解语言特性和综合能力更为关键。全面展示这些方面才能真正提升面试成功率。
57 10
|
3月前
|
Java
【多线程面试题二十五】、说说你对AQS的理解
这篇文章阐述了对Java中的AbstractQueuedSynchronizer(AQS)的理解,AQS是一个用于构建锁和其他同步组件的框架,它通过维护同步状态和FIFO等待队列,以及线程的阻塞与唤醒机制,来实现同步器的高效管理,并且可以通过实现特定的方法来自定义同步组件的行为。
【多线程面试题二十五】、说说你对AQS的理解
|
3月前
|
消息中间件 缓存 算法
Java多线程面试题总结(上)
进程和线程是操作系统管理程序执行的基本单位,二者有明显区别: 1. **定义与基本单位**:进程是资源分配的基本单位,拥有独立的内存空间;线程是调度和执行的基本单位,共享所属进程的资源。 2. **独立性与资源共享**:进程间相互独立,通信需显式机制;线程共享进程资源,通信更直接快捷。 3. **管理与调度**:进程管理复杂,线程管理更灵活。 4. **并发与并行**:进程并发执行,提高资源利用率;线程不仅并发还能并行执行,提升执行效率。 5. **健壮性**:进程更健壮,一个进程崩溃不影响其他进程;线程崩溃可能导致整个进程崩溃。
47 2

热门文章

最新文章