Go:使用共享变量实现并发

news2025/4/18 13:33:43

竞态

在串行程序中,步骤执行顺序由程序逻辑决定;而在有多个 goroutine 的并发程序中,不同 goroutine 的事件先后顺序不确定,若无法确定两个事件先后,它们就是并发的。若一个函数在并发调用时能正确工作,称其为并发安全。当类型的所有可访问方法和操作都是并发安全时,该类型为并发安全类型。并发安全的类型并非普遍存在,若要在并发中安全访问变量,需限制变量仅在一个 goroutine 内存在,或维护更高层的互斥不变量。

package bank

var balance int

func Deposit(amount int) { balance = balance + amount }
func Balance() int { return balance }

// Alice:
go func() {
    bank.Deposit(200)  // A1
    fmt.Println("=", bank.Balance()) // A2
}()
// Bob:
go func() {
    bank.Deposit(100)  // B
}()

竞态是指多个 goroutine 按交错顺序执行时,程序无法给出正确结果的情形。它对程序是致命的,可能潜伏在程序中,出现频率低,且难以再现和分析。以银行账户程序为例,在并发调用DepositBalance函数时,若多个 goroutine 交错执行,可能出现数据竞态,导致账户余额计算错误,如出现存款丢失等情况。数据竞态发生在两个或多个 goroutine 并发读写同一个变量,且至少其中一个是写入时。当变量类型大于机器字长(如接口、字符串或 slice)时,数据竞态问题会更复杂。

避免数据竞态的方法

  • 不修改变量:对于延迟初始化的 map,若并发调用访问可能存在数据竞态。但如果在创建其他 goroutine 之前,用完整数据初始化 map 且不再修改,那么多个 goroutine 可安全并发调用相关函数读取 map。
package bank

var deposits = make(chan int) // 发送存款额
var balances = make(chan int) // 接收余额

func Deposit(amount int) { deposits <- amount }
func Balance() int { return <-balances }

func teller() {
    var balance int
    for {
        select {
        case amount := <-deposits:
            balance += amount
        case 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 // 保护 balance
    balance int
)
func Deposit(amount int) {
    mu.Lock()
    balance = balance + amount
    mu.Unlock()
}
func Balance() int {
    mu.Lock()
    b := balance
    mu.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 int

func 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 = 1
    fmt.Print("y:", y, " ")
}()
go func() {
    y = 1
    fmt.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程序设计语言》

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2336617.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

豆瓣图书数据采集与可视化分析

文章目录 一、适用题目二、豆瓣图书数据采集1. 图书分类采集2. 爬取不同分类的图书数据3. 各个分类数据整合 三、豆瓣图书数据清洗四、数据分析五、数据可视化1. 数据可视化大屏展示 源码获取看下方名片 一、适用题目 基于Python的豆瓣图书数据采集与分析基于Python的豆瓣图书…

常见的爬虫算法

1.base64加密 base64是什么 Base64编码&#xff0c;是由64个字符组成编码集&#xff1a;26个大写字母AZ&#xff0c;26个小写字母az&#xff0c;10个数字0~9&#xff0c;符号“”与符号“/”。Base64编码的基本思路是将原始数据的三个字节拆分转化为四个字节&#xff0c;然后…

YOLOV8 OBB 海思3516训练流程

YOLOV8 OBB 海思3516训练流程 目录 1、 下载带GPU版本的torch(可选) 1 2、 安装 ultralytics 2 3、 下载pycharm 社区版 2 4、安装pycharm 3 5、新建pycharm 工程 3 6、 添加conda 环境 4 7、 训练代码 5 9、配置Ymal 文件 6 10、修改网络结构 9 11、运行train.py 开始训练模…

[MySQL] 事务管理(二) 事务的隔离性底层

事务的隔离性底层 1.数据库并发的场景2.读-写2.1MVCC三个变量2.1.1 3个记录隐藏列字段2.1.2 undo日志 模拟MVCCselect 的读取2.1.3 Read View&#xff08;读视图&#xff09; 3.RR与RC的区别 1.数据库并发的场景 读-读&#xff1a;不存在问题&#xff0c;也不需要并发控制读-写…

20、.NET SDK概述

.NET SDK&#xff08;Software Development Kit&#xff09; 是微软提供的一套开发工具包&#xff0c;用于构建、运行和管理基于 .NET 平台的应用程序。它包含了一组丰富的工具、库和运行时环境&#xff0c;支持开发者在多种操作系统&#xff08;如 Windows、Linux 和 macOS&am…

18-21源码剖析——Mybatis整体架构设计、核心组件调用关系、源码环境搭建

学习视频资料来源&#xff1a;https://www.bilibili.com/video/BV1R14y1W7yS 文章目录 1. 架构设计2. 核心组件及调用关系3. 源码环境搭建3.1 测试类3.2 实体类3.3 核心配置文件3.4 映射配置文件3.5 遇到的问题 1. 架构设计 Mybatis整体架构分为4层&#xff1a; 接口层&#…

冒泡排序、插入排序、快速排序、堆排序、希尔排序、归并排序

目录 冒泡排序插入排序快速排序(未优化版本)快速排序(优化版本)堆排序希尔排序归并排序各排序时间消耗对比 冒泡排序 冒泡排序核心逻辑就是对数组从第一个位置开始进行遍历&#xff0c;如果发现该元素比下一个元素大&#xff0c;则交换位置&#xff0c;如果不大&#xff0c;就…

CD27.【C++ Dev】类和对象 (18)友元和内部类

目录 1.友元 友元函数 几个特点 友元类 格式 代码示例 2.内部类(了解即可) 计算有内部类的类的大小 分析 注意:内部类不能直接定义 内部类是外部类的友元类 3.练习 承接CD21.【C Dev】类和对象(12) 流插入运算符的重载文章 1.友元 友元函数 在CD21.【C Dev】类和…

QT安装详细步骤

下载 清华源 &#xff1a; 清华源 1. 2. 3. 4.

Unity游戏多语言工具包

由于一开始的代码没有考虑多语言场景&#xff0c;导致代码中提示框和UI显示直接用了中文&#xff0c;最近开始提取代码的中文&#xff0c;提取起来太麻烦&#xff0c;所以拓展了之前的多语言包&#xff0c;降低了操作复杂度。最后把工具代码提取出来到单独项目里面&#xff0c;…

实验三 I/O地址译码

一、实验目的 掌握I/O地址译码电路的工作原理。 二、实验电路 实验电路如图1所示&#xff0c;其中74LS74为D触发器&#xff0c;可直接使用实验台上数字电路实验区的D触发器&#xff0c;74LS138为地址译码器&#xff0c; Y0&#xff1a;280H&#xff5e;287H&…

视觉语言导航(VLN):连接语言、视觉与行动的桥梁

文章目录 1. 引言&#xff1a;什么是VLN及其重要性&#xff1f;2. VLN问题定义3. 核心挑战4. 基石&#xff1a;关键数据集与模拟器5. 评估指标6. 主要方法与技术演进6.1 前CLIP时代&#xff1a;奠定基础6.2 后CLIP时代&#xff1a;视觉与语言的统一 7. 最新进展与前沿趋势 (202…

计算机网络中科大 - 第7章 网络安全(详细解析)-以及案例

目录 &#x1f6e1;️ 第8章&#xff1a;网络安全&#xff08;Network Security&#xff09;优化整合笔记&#x1f4cc; 本章学习目标 一、网络安全概念二、加密技术&#xff08;Encryption&#xff09;1. 对称加密&#xff08;Symmetric Key&#xff09;2. 公钥加密&#xff0…

XCTF-web(四)

unserialize3 需要反序列化一下&#xff1a;O:4:“xctf”:2:{s:4:“flag”;s:3:“111”;} php_rce 题目提示rce漏洞&#xff0c;测试一下&#xff1a;?s/Index/\think\app/invokefunction&functioncall_user_func_array&vars[0]phpinfo&vars[1][]1 flag&#xff1…

在Vue项目中查询所有版本号为 1.1.9 的依赖包名 的具体方法,支持 npm/yarn/pnpm 等主流工具

以下是 在Vue项目中查询所有版本号为 1.1.9 的依赖包名 的具体方法&#xff0c;支持 npm/yarn/pnpm 等主流工具&#xff1a; 一、使用 npm 1. 直接过滤依赖树 npm ls --depth0 | grep "1.1.9"说明&#xff1a; npm ls --depth0&#xff1a;仅显示直接依赖&#xf…

若依微服务版启动小程序后端

目录标题 本地启动&#xff0c;dev对应 nacos里的 xxx-xxx-dev配置文件 本地启动&#xff0c;dev对应 nacos里的 xxx-xxx-dev配置文件

莒县第六实验小学:举行“阅读世界 丰盈自我”淘书会

4月16日&#xff0c;莒县第六实验小学校园内书香四溢、笑语盈盈&#xff0c;以“阅读世界 丰盈自我”为主题的第二十四届读书节之“淘书会”活动火热开启。全校师生齐聚一堂&#xff0c;以书会友、共享阅读之乐&#xff0c;为春日校园增添了一抹浓厚的文化气息。 活动在悠扬的诵…

国产数据库与Oracle数据库事务差异分析

数据库中的ACID是事务的基本特性&#xff0c;而在Oracle等数据库迁移到国产数据库国产中&#xff0c;可能因为不同数据库事务处理机制的不同&#xff0c;在迁移后的业务逻辑处理上存在差异。本文简要介绍了事务的ACID属性、事务的隔离级别、回滚机制和超时机制&#xff0c;并总…

C++学习记录:

今天我们来学习一门新的语言&#xff0c;也是C语言最著名的一个分支语言&#xff1a;C。 在C的学习中&#xff0c;我们主要学习的三大组成部分&#xff1a;语法、STL、数据结构。 C的介绍 C的历史可追溯至1979年&#xff0c;当时贝尔实验室的本贾尼斯特劳斯特卢普博士在面对复杂…

等离子体浸没离子注入(PIII)

一、PIII 是什么&#xff1f;基本原理和工艺 想象一下&#xff0c;你有一块金属或者硅片&#xff08;就是做芯片的那种材料&#xff09;&#xff0c;你想给它的表面“升级”&#xff0c;让它变得更硬、更耐磨&#xff0c;或者有其他特殊功能。怎么做呢&#xff1f;PIII 就像是用…