Go:使用共享变量实现并发
竞态
在串行程序中,步骤执行顺序由程序逻辑决定;而在有多个 goroutine 的并发程序中,不同 goroutine 的事件先后顺序不确定,若无法确定两个事件先后,它们就是并发的。若一个函数在并发调用时能正确工作,称其为并发安全。当类型的所有可访问方法和操作都是并发安全时,该类型为并发安全类型。并发安全的类型并非普遍存在,若要在并发中安全访问变量,需限制变量仅在一个 goroutine 内存在,或维护更高层的互斥不变量。
package bankvar balance intfunc Deposit(amount int) { balance = balance + amount }
func Balance() int { return balance }// Alice:
go func() {bank.Deposit(200) // A1fmt.Println("=", bank.Balance()) // A2
}()
// Bob:
go func() {bank.Deposit(100) // B
}()
竞态是指多个 goroutine 按交错顺序执行时,程序无法给出正确结果的情形。它对程序是致命的,可能潜伏在程序中,出现频率低,且难以再现和分析。以银行账户程序为例,在并发调用Deposit
和Balance
函数时,若多个 goroutine 交错执行,可能出现数据竞态,导致账户余额计算错误,如出现存款丢失等情况。数据竞态发生在两个或多个 goroutine 并发读写同一个变量,且至少其中一个是写入时。当变量类型大于机器字长(如接口、字符串或 slice)时,数据竞态问题会更复杂。
避免数据竞态的方法
- 不修改变量:对于延迟初始化的 map,若并发调用访问可能存在数据竞态。但如果在创建其他 goroutine 之前,用完整数据初始化 map 且不再修改,那么多个 goroutine 可安全并发调用相关函数读取 map。
package bankvar deposits = make(chan int) // 发送存款额
var balances = make(chan int) // 接收余额func Deposit(amount int) { deposits <- amount }
func Balance() int { return <-balances }func teller() {var balance intfor {select {case amount := <-deposits:balance += amountcase balances <- balance:}}
}func init() {go teller() // 启动监控goroutine
}
- 避免多个 goroutine 访问同一变量:通过将变量限制在单个 goroutine 内部访问来避免竞态。如 Web 爬虫中主 goroutine 是唯一能访问
seen
map 的,消息服务器中broadcaster
goroutine 是唯一能访问clients
map 的。还可通过监控 goroutine 来限制对共享变量的访问,如银行案例中用teller
goroutine 限制balance
变量的并发访问 。 - 允许多个 goroutine 访问,但同一时间只有一个可访问:通过互斥机制实现。
互斥锁:sync.Mutex
// 使用通道实现二进制信号量保护balance
var (sema = make(chan struct{}, 1) // 用来保护 balance 的二进制信号量balance int
)
func Deposit(amount int) {sema <- struct{}{} // 获取令牌balance = balance + amount<-sema // 释放令牌
}
func Balance() int {sema <- struct{}{} // 获取令牌b := balance<-sema // 释放令牌return b
}
为保证同一时间最多有一个 goroutine 能访问共享变量,可使用容量为 1 的通道作为二进制信号量。
由于互斥锁模式应用广泛,Go 语言sync
包提供了Mutex
类型来支持这种模式,Lock
方法用于获取令牌(上锁),Unlock
方法用于释放令牌(解锁)。
// 使用sync.Mutex实现互斥锁保护balance
import "sync"
var (mu sync.Mutex // 保护 balancebalance int
)
func Deposit(amount int) {mu.Lock()balance = balance + amountmu.Unlock()
}
func Balance() int {mu.Lock()b := balancemu.Unlock()return b
}
示例:以银行账户程序为例,定义mu
为sync.Mutex
类型来保护balance
变量 。在Deposit
和Balance
函数中,通过先调用mu.Lock()
获取互斥锁,访问或修改balance
变量,最后调用mu.Unlock()
释放锁 ,确保共享变量不会被并发访问 。这种函数、互斥锁、变量的组合方式称为监控(monitor)模式。
func Balance() int {mu.Lock()defer mu.Unlock()return balance
}
在Lock
和Unlock
之间的代码区域称为临界区域,此区域内可自由读写共享变量 。一个 goroutine 在使用完互斥锁后应及时释放,对于有多个分支(尤其是错误分支)的复杂函数,可使用defer
语句延迟执行Unlock
,将临界区域扩展到函数结尾,保证锁能正确释放 ,即使在临界区域崩溃时也能正常执行解锁操作 。
原子操作与互斥锁的应用
// 不正确的Withdraw实现示例
func Withdraw(amount int) bool {Deposit(-amount)if Balance() < 0 {Deposit(amount)return false // 余额不足}return true
}// 错误的Withdraw加锁尝试示例
func Withdraw(amount int) bool {mu.Lock()defer mu.Unlock()Deposit(-amount)if Balance() < 0 {Deposit(amount)return false // 余额不足}return true
}// 正确的Withdraw实现示例
func Withdraw(amount int) bool {mu.Lock()defer mu.Unlock()deposit(-amount)if balance < 0 {deposit(amount)return false // 余额不足}return true
}func Deposit(amount int) {mu.Lock()defer mu.Unlock()deposit(amount)
}func Balance() int {mu.Lock()defer mu.Unlock()return balance
}// 这个函数要求已获取互斥锁
func deposit(amount int) { balance += amount }
以Withdraw
函数为例,最初版本因不是原子操作(包含多个串行操作且未对整个操作上锁)存在问题,在尝试超额提款时可能导致余额异常 。改进版本应在整个操作开始时申请一次互斥锁 ,但直接在Withdraw
中嵌套调用已使用互斥锁的Deposit
函数会因互斥锁不可再入导致死锁 。最终解决方案是将Deposit
函数拆分为不导出的deposit
函数(假定已获取互斥锁并完成业务逻辑)和导出的Deposit
函数(负责获取锁并调用deposit
),从而正确实现Withdraw
函数 。使用互斥锁时,应确保互斥锁本身及被保护的变量都不被导出 ,以维持并发中的不变性 。
读写互斥锁:sync.RWMutex
var mu sync.RWMutex
var balance intfunc Balance() int {mu.RLock() // 读锁defer mu.RUnlock()return balance
}
以 Bob 频繁查询账户余额为例,银行的Balance
函数只是读取变量状态,多个Balance
请求可并发运行,只要Deposit
和Withdraw
请求不同时运行即可 。为满足这种场景需求,需要一种特殊的锁,即多读单写锁,Go 语言中的sync.RWMutex
可提供此功能。
- 读锁操作:定义
mu
为sync.RWMutex
类型 ,在Balance
函数中,通过调用mu.RLock()
获取读锁(共享锁),使用defer mu.RUnlock()
延迟释放读锁,确保在函数结束时释放锁 ,这样多个读操作可并发进行。 - 写锁操作:
Deposit
函数等写操作函数,仍通过调用mu.Lock()
获取写锁(互斥锁),mu.Unlock()
释放写锁 ,保证写操作时的独占访问权限。
注意事项
RLock
仅适用于临界区域内对共享变量无写操作的情形 ,因为有些看似只读的函数可能会更新内部变量,若不确定应使用独占版本的Lock
。- 当绝大部分 goroutine 都在获取读锁且锁竞争激烈时,
RWMutex
才有优势,因为其内部簿记工作更复杂,在竞争不激烈时比普通互斥锁慢 。
内存同步
以银行账户的Balance
函数为例,其需要互斥锁不仅是防止操作交错,还涉及内存同步问题。现代计算机多处理器有本地内存缓存,写操作先缓存在处理器中,刷回内存顺序可能与 goroutine 写入顺序不一致。通道通信、互斥锁等同步原语可使处理器将累积写操作刷回内存并提交,保证执行结果对其他处理器上的 goroutine 可见。
var x, y int
go func() {x = 1fmt.Print("y:", y, " ")
}()
go func() {y = 1fmt.Print("x:", x, " ")
}()
通过代码示例,两个 goroutine 并发访问共享变量x
和y
,在未使用互斥锁时存在数据竞态,预期输出为y:0 x:1
、x:0 y:1
、x:1 y:1
、y:1 x:1
这四种情况之一 。但实际可能出现x:0 y:0
、y:0 x:0
这种意外输出 。原因在于单个 goroutine 内语句执行顺序一致,但在无同步措施时,不同 goroutine 间无法保证事件顺序一致 。编译器可能因赋值和打印对应不同变量,交换语句执行顺序,CPU 也可能因缓存等问题导致一个 goroutine 的写入操作对另一个 goroutine 的Print
语句不可见 。
解决:为避免这些并发问题,可采用成熟模式,将变量限制在单个 goroutine 中;对于其他变量,使用互斥锁进行同步 。
延迟初始化sync.Once
var icons map[string]image.Image
func loadIcons() {icons = map[string]image.Image{"spades.png": loadIcon("spades.png"),"hearts.png": loadIcon("hearts.png"),"diamonds.png": loadIcon("diamonds.png"),"clubs.png": loadIcon("clubs.png"),}
}
// 并发不安全版本
func Icon(name string) image.Image {if icons == nil {loadIcons() // 一次性地初始化}return icons[name]
}
延迟昂贵的初始化步骤到实际需要时进行,可避免增加程序启动延时。以icons
变量为例,初始版本在Icon
函数中检测icons
是否为空,若为空则调用loadIcons
进行一次性初始化 ,但此方式在并发调用Icon
时不安全。
var mu sync.Mutex // 保护 icons
var icons map[string]image.Image// 并发安全版本(使用普通互斥锁)
func Icon(name string) image.Image {mu.Lock()defer mu.Unlock()if icons == nil {loadIcons()}return icons[name]
}var mu sync.RWMutex // 保护 icons
var icons map[string]image.Image// 并发安全版本(使用读写互斥锁)
func Icon(name string) image.Image {mu.RLock()if icons!= nil {icon := icons[name]mu.RUnlock()return icon}mu.RUnlock()mu.Lock()if icons == nil { // 必须重新检查nil值loadIcons()}icon := icons[name]mu.Unlock()return icon
}
在无显式同步情况下,编译器和 CPU 可能重排loadIcons
语句执行顺序,导致一个 goroutine 发现icons
不为nil
时,初始化可能尚未真正完成 。使用互斥锁可解决同步问题,如用sync.Mutex
保护icons
变量 ,但这会限制并发访问,即使初始化完成且不再更改,也会阻止多个 goroutine 并发读取 。使用sync.RWMutex
虽能改善并发读问题,但代码复杂且易出错 。
var loadIconsOnce sync.Once
var icons map[string]image.Image// 并发安全版本(使用sync.Once)
func Icon(name string) image.Image {loadIconsOnce.Do(loadIcons)return icons[name]
}
sync.Once
为一次性初始化问题提供简化方案 。它包含布尔变量记录初始化是否完成,以及互斥量保护相关数据 。Once
的Do
方法以初始化函数为参数 ,首次调用Do
时,锁定互斥量并检查布尔变量,若为假则调用初始化函数并将变量设为真,后续调用相当于空操作 。通过使用sync.Once
,可确保变量在正确构造之前不被其他 goroutine 访问,避免竞态问题 。
竞态检测器
Go 语言运行时和工具链提供竞态检测器,用于检测并发编程中的数据竞态问题。在go build
、go run
、go test
命令中添加-race
参数即可启用 。启用后,编译器会构建修改后的版本,记录运行时对共享变量的访问,以及读写变量的 goroutine 标识,还会记录同步事件(如go
语句、通道操作、互斥锁调用、WaitGroup
调用等 )。
竞态检测器通过研究事件流,找出一个 goroutine 写入变量后,无同步操作时另一个 goroutine 读写该变量的情况,即数据竞态 。检测到竞态后,会输出包含变量标识、读写 goroutine 调用栈的报告,帮助定位问题 。
它只能检测运行时发生的竞态,无法保证程序绝对不会发生竞态 。为获得最佳检测效果,测试应包含并发使用包的场景 。由于增加了额外簿记工作,带竞态检测功能的程序运行时需更长时间和更多内存,但对于排查不常发生的竞态,能节省大量调试时间 。
goroutine 和线程
可增长的栈
每个 OS 线程都有固定大小的栈内存,通常为 2MB ,用于保存在函数调用期间正在执行或临时暂停函数中的局部变量。但这个固定大小存在弊端,对于简单的 goroutine(如仅等待WaitGroup
或关闭通道 ),2MB 栈内存浪费;对于复杂深度递归函数,固定大小栈又不够用,且无法兼顾空间效率和支持更深递归。
goroutine 在生命周期开始时栈很小,典型为 2KB ,也用于存放局部变量。与 OS 线程不同,goroutine 的栈可按需增大和缩小,大小限制可达 1GB ,比线程栈大几个数量级,能更灵活适应不同场景,极少的 goroutine 才会用到这么大栈。
goroutine调度
OS 线程由 OS 内核调度。每隔几毫秒,硬件时钟中断触发 CPU 调用调度器内核函数 。该函数暂停当前运行线程,保存寄存器信息到内存,选择下一个运行线程,恢复其注册表信息后继续执行 。此过程涉及完整上下文切换,包括保存和恢复线程状态、更新调度器数据结构,因内存访问及 CPU 周期消耗,操作较慢 。
Go 运行时有自己的调度器,采用 m:n 调度技术(将 m 个 goroutine 复用 / 调度到 n 个 OS 线程 )。与内核调度器不同,Go 调度器不由硬件时钟定期触发,而是由特定 Go 语言结构触发 ,如 goroutine 调用time.Sleep
、被通道阻塞或进行互斥量操作时,调度器将其设为休眠模式,转而运行其他 goroutine,直到可唤醒该 goroutine 。由于无需切换到内核语境,调度 goroutine 成本比调度线程低很多 。
GOMAXPROCS
Go 调度器通过GOMAXPROCS
参数确定同时执行 Go 代码所需的 OS 线程数量 ,默认值为机器上的 CPU 数量 。例如在 8 核 CPU 机器上,调度器会将 Go 代码调度到 8 个 OS 线程上执行(它是 m:n 调度中的 n )。处于休眠、被通道阻塞的 goroutine 不占用线程,阻塞在 I/O 及系统调用或调用非 Go 语言函数的 goroutine 虽需独立 OS 线程,但该线程不计入GOMAXPROCS
。
for {go fmt.Print(0)fmt.Print(1)
}
// $ GOMAXPROC=1 go run hacker-cliche.go 11111111111111111118008000000000000001111...
// $ GOMAXPROCS=2 go run hacker-cliche.go 01010101010101010101100110010101101001010...
可通过GOMAXPROCS
环境变量或runtime.GOMAXPROCS
函数显式控制该参数 。文中通过一个不断输出 0 和 1 的小程序示例展示其效果 ,当GOMAXPROCS=1
时,每次最多一个 goroutine 运行,主 goroutine 和输出 0 的 goroutine 交替执行;当GOMAXPROCS=2
时,两个 goroutine 可同时运行 。由于影响 goroutine 调度因素众多且运行时不断变化,实际结果可能不同。
goroutine没有标识
在多数支持多线程的操作系统和编程语言中,当前线程有独特标识,通常为整数或指针 。利用此标识可构建线程局部存储,即一个以线程标识为键的全局 map,使每个线程能独立存储和获取值,不受其他线程干扰 。
goroutine 没有可供程序员访问的标识 ,这是设计选择。因为线程局部存储易被滥用,如 Web 服务器使用支持线程局部存储的语言时,很多函数通过访问该存储查找 HTTP 请求信息,会导致类似过度依赖全局变量的 “超距作用”,使函数行为不仅取决于参数,还与运行线程标识有关,在需要改变线程标识(如使用工作线程 )时,函数行为会变得不可预测 。
Go 语言鼓励简单编程风格,函数行为应仅由显式指定参数决定,这样程序更易阅读,且在将函数子任务分发到多个 goroutine 时,无需考虑 goroutine 标识问题 。
参考资料:《Go程序设计语言》