当前位置: 首页 > news >正文

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 按交错顺序执行时,程序无法给出正确结果的情形。它对程序是致命的,可能潜伏在程序中,出现频率低,且难以再现和分析。以银行账户程序为例,在并发调用DepositBalance函数时,若多个 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
}

示例:以银行账户程序为例,定义musync.Mutex类型来保护balance变量 。在DepositBalance函数中,通过先调用mu.Lock()获取互斥锁,访问或修改balance变量,最后调用mu.Unlock()释放锁 ,确保共享变量不会被并发访问 。这种函数、互斥锁、变量的组合方式称为监控(monitor)模式。

func Balance() int {mu.Lock()defer mu.Unlock()return balance
}

LockUnlock之间的代码区域称为临界区域,此区域内可自由读写共享变量 。一个 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请求可并发运行,只要DepositWithdraw请求不同时运行即可 。为满足这种场景需求,需要一种特殊的锁,即多读单写锁,Go 语言中的sync.RWMutex可提供此功能。

  • 读锁操作:定义musync.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 并发访问共享变量xy,在未使用互斥锁时存在数据竞态,预期输出为y:0 x:1x:0 y:1x:1 y:1y:1 x:1这四种情况之一 。但实际可能出现x:0 y:0y: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为一次性初始化问题提供简化方案 。它包含布尔变量记录初始化是否完成,以及互斥量保护相关数据 。OnceDo方法以初始化函数为参数 ,首次调用Do时,锁定互斥量并检查布尔变量,若为假则调用初始化函数并将变量设为真,后续调用相当于空操作 。通过使用sync.Once,可确保变量在正确构造之前不被其他 goroutine 访问,避免竞态问题 。

竞态检测器

Go 语言运行时和工具链提供竞态检测器,用于检测并发编程中的数据竞态问题。在go buildgo rungo 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程序设计语言》

相关文章:

  • 多种方案对比实现 Kaggle 比赛介绍进行行业分类
  • 线代第二章矩阵第一课:矩阵的概念
  • 数据一致性的守护神:深入Spring声明式事务管理 (@Transactional)
  • Redis适用场景
  • 双目视觉中矩阵等参数说明及矫正
  • 分布式专题-Redis Cluster集群运维与核心原理剖析
  • Redis面试问题缓存相关详解
  • 插件化设计,打造个性化音乐体验!
  • 算法——果蝇算法
  • C++23 Lambda 表达式上的属性:P2173R1 深度解析
  • 【ROS】map_server 地图的保存和加载
  • 50、Spring Boot 详细讲义(七) Spring Boot 与 NoSQL
  • 在生信分析中,从生物学数据库中下载的序列存放在哪里?要不要建立一个小型数据库,或者存放在Gitee上?
  • 常见数据结构
  • 【系统分析师之1、绪论+2、数学与工程基础】
  • 【正点原子STM32MP257连载】第四章 ATK-DLMP257B功能测试——LED、按键测试
  • 删除win11电脑上的阿尔巴尼亚输入法SQI
  • OSPF综合实验
  • MySQL——流程控制
  • 【Unity笔记】Unity开发笔记:ScriptableObject实现高效游戏配置管理(含源码解析)
  • 居民被脱落的外墙瓦砖砸中致十级伤残,小区物业赔付16万元
  • 秦洪看盘|避险情绪升温,短线或延续相对钝化状态
  • 俄总理:2024年俄罗斯GDP增长4.3%
  • 对话|四代中国缘的麦肯锡前高管:在混乱中制定规则,而不是复制旧秩序
  • 对话上海外贸企业:关税战虽起,中国供应商却难以被取代
  • 瑞穗银行(中国)有限公司行长:重庆赛力斯超级工厂的智能化程度令人震惊