吃透 Golang 基础:基于共享变量的并发

文章目录

  • sync.Mutex 互斥锁
  • sync.RWMutex 读写锁
  • sync.Once 惰性初始化
  • Goroutine 与线程
    • 动态栈
    • Goroutine 调度
    • GOMAXPROCS
    • Goroutine 没有 ID 号

上一篇文章当中我们已经系统性地回顾了在 Go 当中基于 Goroutine 和 Channel 进行并发控制的方法,Goroutine 指的是 Golang 的应用级线程,相比于系统线程,goroutine 是轻量级的,完全在用户态进行调度;Channel 可以被理解为 goroutine 之间进行通信的信道,它是 Golang 当中的引用类型,底层引用的数据结构是一个数组。基于 channel,在 Golang 当中我们可以直接进行 Goroutine 之间的通信,而无需基于共享内存进行通信。

这一节的内容是对 Golang 并发机制的深入,介绍了 Go 当中的锁机制,基于锁机制我们可以在 Go 当中基于共享变量来进行线程之间的通信。基于这一节的内容,我们将完整地回顾完与 Golang 并发机制以及 Goroutine 调度有关的知识,在这一节的最后,将会详细地回顾 goroutine 与操作系统线程的区别。
在这里插入图片描述

sync.Mutex 互斥锁

基于 channel,我们实际上可以实现在同一时刻只有一个并发执行的 goroutine 访问存储关键资源的共享变量。下例模拟了一个银行的存取逻辑,基于缓冲区大小为 1 的 channel 来确保不同 goroutine 对关键资源的访问互斥:

var (sema =  make(chan struct{}, 1)	// 二元信号量balance int
)func Deposit(amount int) {sema <- struct{}{}			// 如果缓冲区为空, 就不会被阻塞balance = balance + amount<- sema						// 释放锁信号
}func Balance() int {sema <- struct{}{}			// 换言之, 如果缓冲区被填满, 这条写入操作就会被阻塞b := balance<- semareturn b
}

Golang 的sync包中有一个名为Mutex的类型同样能够实现上述逻辑,它具有两个方法,分别是LockUnlock,前者会加锁而后者会释放锁:

import "sync"var (mu      sync.Mutex // guards balancebalance int
)func Deposit(amount int) {mu.Lock()balance = balance + amountmu.Unlock()
}func Balance() int {mu.Lock()b := balancemu.Unlock()return b
}

LockUnlock之间代码段的内容可以被当前持有锁的 goroutine 随意读取或修改,这个代码段叫做临界区。锁的持有者在其他 goroutine 获取锁之前需要调用Unlock,这也就意味着持有锁的 goroutine 在结束之前必须将锁释放。

可以将Unlock行为与defer关键字组合,来确保锁最终被释放:

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

接下来我们研究一个更加复杂的案例,考虑下面的Withdraw函数,在成功时,它会调用Deposit扣减余额并返回 true,如果银行资金不足,那么就恢复余额并返回 false:

func Withdraw(amount int) bool {Deposit(-amount)if Balance() < 0 {Deposit(amount)return false}return true
}

函数可以给出正确的结果,但这个函数有一个副作用,那就是过多的取款操作并发时,balance 可能会瞬间被减到 0,这可能会导致并发取款与支付被不合理的拒绝。产生上述问题的原因是,在当前的取款$ \rightarrow $支付逻辑不是一个原子性的操作,每一步都需要去单独地获取互斥锁,任何一次上锁都不会锁住整个流程。

理想情况下,取款$ \rightarrow $支付逻辑应该在开始时获取互斥锁,结束时释放,但由于我们还没有修改Deposit/Balance的逻辑,这就意味着下述代码会产生错误:

// ❌ INCORRECT
func Withdraw(amount int) bool {mu.Lock()defer mu.Unlock()Deposit(-amount)if Balance() < 0 {Deposit(amount)return false // insufficient funds}return true
}

Withdraw开始时,我们去获取锁,但是在Deposit中我们会再次尝试获取锁,由于 Golang 的 Mutex 不可重入,无法对一个已经上锁的 Mutex 再次加锁,这就会导致程序死锁,没有办法继续执行下去(因为Deposit等待的锁永远不会释放)。

基于上述原因,我们能够做的就是对Deposit进行修改,新建一个它的非导出版本,在这个非导出版本当中,不需要基于锁进行并发控制,因为它将会被Withdraw调用:

func Withdraw(amount int) bool {mu.Lock()defer mu.Unlock()deposit(-amount)if balance < 0 {deposit(amount)return false // insufficient funds}return true
}func Deposit(amount int) {mu.Lock()defer mu.Unlock()deposit(amount)
}func Balance() int {mu.Lock()defer mu.Unlock()return balance
}// This function requires that the lock be held.
func deposit(amount int) { balance += amount }

sync.RWMutex 读写锁

上例当中的Balance函数实际的行为就是读取变量的状态,而不会对变量进行状态改变,所以实际上我们并发调用多个Balance是安全的。

sync.RWMutex是一种特殊类型的锁,它允许多操作并发执行,但是写操作互斥,这种锁叫做“多读单写”锁:

var mu sync.RWMutex
var balance int
func Balance() int {mu.RLock()defer mu.RUnlock()return balance
}

sync.Once 惰性初始化

在单例模式当中,我们已经见到了「惰性初始化」的基本用法。一个线程安全的单例模式的模版是:

package mainimport ("fmt""sync"
)var lock sync.Mutextype singleton struct{}var instance *singletonfunc GetInstance() *singleton {lock.Lock()defer lock.Unlock()if instance == nil {return new(singleton)} else {return instance}
}func (s *singleton) SomeThing() {fmt.Println("SomeThing is called")
}func main() {s := GetInstance()s.SomeThing()
}

在“懒汉式”的单例模式下,为了确保单例类实例只有在需要的时候才被初始化,我们引入了一个 Mutex 锁,来在调用GetInstance函数的时候,首先加锁判断单例类实例是否被创建,如果没有被创建,则新建这个单例类实例。

我们进行进一步的细化,考虑下面这样的一个 icons 变量:

var icons map[string]image.Image

我们尝试使用“懒汉式”的做法来对 icons 进行初始化:

var mu sync.Mutex
var icons map[string]image.Imagefunc loadIcon(path string) image.Image {// ... ... ...return /* ... */
}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"),}
}// NOTE: not concurrency-safe!
func Icon(name string) image.Image {mu.Lock()defer mu.Unlock()if icons == nil {loadIcons() // one-time initialization}return icons[name]
}

上述做法下,icons 的初始化当然是安全的。如果有 goroutine 并发地调用Icon,由于锁机制的存在,如果当前有其他 goroutine 正在调用Icon,那么当前Icon将会被阻塞。

一个问题在于,如果Icon已经被初始化完成,那么并发的 goroutine 无法并发地读 icons 这个变量,这会导致性能的下降,我们可以进一步使用RLock来对上述Icon函数的逻辑进行修改:

var mu sync.RWMutex // guards icons
var icons map[string]image.Image
// Concurrency-safe.
func Icon(name string) image.Image {mu.RLock()if icons != nil {icon := icons[name]mu.RUnlock()return icon}mu.RUnlock()// acquire an exclusive lockmu.Lock()if icons == nil { // NOTE: must recheck for nilloadIcons()}icon := icons[name]mu.Unlock()return icon
}

修改后的Icon既在初始化时线程安全,又支持并发读,但是代码较为复杂,我们可以使用 Golang sync 包内置的sync.Once来专门解决这种“懒汉式”的一次性初始化的并发问题。

下例使用sync.Once继续优化Icon的逻辑:

var loadIconsOnce sync.Once
var icons map[string]image.Image
func Icon(name string) image.Image{loadIconsOnce.Do(loadIcons)return icons[name]
}

每一次Do(loadIcons)的调用都会锁定mutex,并检查记录初始化是否完成的 bool 变量。只有 bool 为 false 的时候才锁定 mutex 来完成初始化操作。

总结一下,对于“懒汉式”初始化这种典型场景,为了确保并发调用该函数访问单例类实例时,不会产生:

  1. 重复初始化;
  2. 读等待;

可以使用sync.Once当中的Do来优化初始化逻辑。

Goroutine 与线程

在这一小节当中,我们具体地来区分一下 goroutine 这个用户态线程与操作系统线程之间的区别。这些区别可以说是在面试 Golang 开发岗位时必知必会的细节。

动态栈

操作系统线程的栈有一个固定的内存块(一般为 2MB),这个内存块将会用作栈,这个栈用于存储当前正在被调用的函数或挂起的函数(指的是为了调用当前这个函数而被暂时挂起的其他函数)的内部变量

2MB 对于一个线程的栈而言,很大又很小。与 goroutine 相比,初始的 goroutine 的栈内存大小仅为 2KB,2MB 是它的一千倍。

对于 Go 程序而言,同时创建成百上千个 goroutine 是很普遍的,如果每一个 goroutine 都需要这么大的内存用作栈的话,那么让成百上千这个数量级的 goroutine 同时运行是不可能的。一个 goroutine 的栈是动态的,在其生命周期开始时,栈的大小只有 2KB。goroutine 的栈的作用与操作系统线程的栈类似,都是用于保存当前活跃以及挂起的函数调用的本地变量。goroutine 栈的大小可以动态伸缩,最大值可以达到 1TB,比传统的固定大小的栈大得多得多,但实际上大多数情况下栈的内存空间不会达到这个数量级。

Goroutine 调度

操作系统线程会基于操作系统的内核进行调度。每几毫秒,一个硬件的计时器会中断处理器,调用一个名为 scheduler 的内核函数。这个函数会挂起当前执行的线程,并将它的寄存器内容保存到内存当中,检查线程列表并决定下一次调度哪一个线程到 CPU 上执行,scheduler 会从内存中恢复该线程的寄存器信息,然后恢复该线程的现场并开始执行线程。

显然,由于操作系统线程是被内核当中的 scheduler 函数调度的,所以一个线程向另一个线程移动需要进行完整的上下文切换(首先保存当前线程的上下文状态到内存,之后检查线程列表根据调度策略选择下一个要调度的线程,从内存当中将它的状态转移到寄存器,恢复线程的现场,开始执行线程)。上下文切换的操作很慢,需要经过若干次的内存访问,会增加 CPU 的运行周期。

Go 的运行时包含了自己的调度器(GMP 线程调度模型,Golang 开发面试时的考察热点),比如m:n调度,它会让n个操作系统线程多工调度m个 goroutine。Go 调度器的工作原理与内核当中的 scheduler 函数非常的相似,但是 Go 的调度发生在用户态,而非内核态。由于不需要进入内核进行上下文切换,所以 Go 调度 goroutine 的成本比操作系统线程的调度成本要低很多。

GOMAXPROCS

Go 的调度器会使用一个名为 GOMAXPROCS 的变量来决定会有多少个 OS 线程同时执行 Go 代码,其默认值是运行机器上的 CPU 的核数。比如对于一个 8 核的机器,调度器一次会在 8 个 OS 线程上调度 Go 代码。

GOMAXPROCS 可以设置为比 CPU 核数更多的值,但是这样做通常不会带来性能的提升,甚至可能会由于过多的线程切换而导致性能下降。

GOMAXPROCS 对应的是 GMP 调度模型当中的 P,即具体的“调度器”,负责协调 G 和 M 的执行,GOMAXPROCS 的值就是 P 的数量。

有关 Golang 的 GMP 调度模型,可以详见我之前的文章:https://blog.csdn.net/Coffeemaker88/article/details/146607091

Goroutine 没有 ID 号

大多数支持多线程的操作系统或程序设计语言,都会为线程分配一个独特的身份(ID),并且这个身份可以以一个普通值的形式(比如一个整型数值)被轻易地获取到。基于线程的 ID,我们可以对线程进行本地存储(Thread-Local Storage,TLS),只需要使用一个 map 将线程 ID 与实际的线程对应起来即可。

TLS 可能会被滥用,因为线程本身的身份信息可能会改变,这就会使得承载在这个线程上的函数的行为变得不可预测。因此直接在程序当中基于 TLS 与线程进行交互是不安全的。

Goroutine 没有身份信息的概念,避免了 TLS 的滥用。

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

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

相关文章

智绅科技丨如何选择一家好的养老机构?

居家养老、社区养老和机构养老是我们在养老相关消息中常常听到的3个词。在地方文件中&#xff0c;居家养老和社区养老还经常被统称为居家社区养老或 社区居家养老。那么&#xff0c;这三者之间到底有什么不同呢&#xff1f; 居家养老服务涵盖生活照料、家政服务、康复护理、医…

【支持向量机】SVM线性支持向量机学习算法——软间隔最大化支持向量机

支特向量机(support vector machines, SVM)是一种二类分类模型。它的基本模型是定义在特征空间上的间隔最大的线性分类器。包含线性可分支持向量机、 线性支持向量机、非线性支持向量机。 当训练数据近似线性可分时&#xff0c;通过软间隔最大化学习线性分类器&#xff0c; 即为…

面试 — 预准备 — 面试前准备攻略

好记忆不如烂笔头&#xff0c;能记下点东西&#xff0c;就记下点&#xff0c;有时间拿出来看看&#xff0c;也会发觉不一样的感受. 只讲干货&#xff0c;不罗里吧嗦&#xff01; 作为一个软件从业者&#xff0c;在面试前的准备工作至关重要&#xff0c;能大幅提升你的求职成功…

Oracle停库shutdown长时间无反应

Oracle停库shutdown长时间无反应 现象:Oracle停库卡住,长时间没有反应。 SQL> shutdown immediate;注:此时切记不可Ctrl+C直接取消!切记不可Ctrl+C直接取消!切记不可Ctrl+C直接取消! 检查alert_SID.log日志看是哪些会话进程导致的: Shutting down instance (immed…

使用ZYNQ芯片和LVGL框架实现用户高刷新UI设计系列教程(第十八讲

列表部件基本上是一个采用垂直布局的矩形&#xff0c;可向其中添加按钮和文本。 部件包含&#xff1a; LV_PART_MAIN - 主要的属性&#xff0c;大部分是这个部件。 LV_PART_SCROLLBAR - 滚动条的属性。 &#xff08;1&#xff09; 添加文本 lv_obj_t * lv_list_add_text(lv_o…

Android Navigation 原理解析

1. nav_graph.xml 如何生成路由表 NavGraph 解析流程与原理 关键技术点&#xff1a; XML 解析&#xff1a; 使用 XmlResourceParser 解析 XML 文件 遍历所有节点&#xff08;<fragment>, <activity>, <navigation>等&#xff09; Destination 创建&#…

HarmonyOS 应用权限管控流程

HarmonyOS 应用权限管控流程详解 一、权限管控概述 HarmonyOS 通过多层次的安全机制保护用户数据和系统资源&#xff0c;其中应用权限管控是核心组成部分。系统通过以下机制实现权限管控&#xff1a; 应用沙箱&#xff1a;每个应用运行在独立沙箱中&#xff0c;通过TokenID识…

Python训练营-Day33

import torch torch.cudaimport torch# 检查CUDA是否可用 if torch.cuda.is_available():print("CUDA可用&#xff01;")# 获取可用的CUDA设备数量device_count torch.cuda.device_count()print(f"可用的CUDA设备数量: {device_count}")# 获取当前使用的C…

【STM32】中断优先级管理 NVIC

这篇文章是对 Cortex-M3 内核中断系统 和 STM32F1 系列 NVIC(嵌套向量中断控制器) 的解析说明。我将从结构清晰、层次分明的角度,对 NVIC 中断优先级分组的概念和 STM32F103 的实际情况做一个系统性的总结与叙述。 参考资料: STM32F1xx官方资料:《STM32中文参考手册V10》…

Angular2--高级特性(TODO)

1 基础 关于Angular的基础部分&#xff0c;几个核心部分和框架&#xff0c;在之前都写过了。Angular1--Hello-CSDN博客 Angular的几个核心部分和框架&#xff1a; 模板就是组件中的template&#xff0c;对应MVC的V。 组件类就是Component类&#xff0c;对应对应MVC的C。 服…

pikachu靶场通关笔记44 SSRF关卡02-file_get_content(三种方法渗透)

目录 一、SSRF 1、简介 2、原理 二、file_get_contents函数 1、功能 2、参数 3、返回值 4、file_get_contents与SSRF 三、渗透实战 1、基本探测 2、http协议 &#xff08;1&#xff09;访问upload-labs靶场 &#xff08;2&#xff09;访问yijuhua.txt 3、file协议…

Android 控件 - EditText 的 Hint(Hint 基本用法、Hint 进阶用法、单独设置 Hint 的大小)

一、EditText 的 Hint 1、基本介绍 在 Android 开发中&#xff0c;EditText 的 Hint 用于显示提示文本 提示文本当用户没有输入任何内容时显示&#xff0c;输入内容后自动消失 2、基本使用 &#xff08;1&#xff09;在 XML 布局文件中设置 在 XML 布局文件中设置 Hint …

PostgreSQL(知识片):索引关联度indexCorrelation

索引关联度的绝对值越大&#xff0c;说明这个索引数据越好。绝对值最大为1。 首先我们创建一个表&#xff1a;tbl_corr&#xff0c;包含列&#xff1a;col、col_asc、col_desc、col_rand、data&#xff0c;col_asc存储顺序数据&#xff0c;col_desc存储降序数据&#xff0c;col…

React纯函数和hooks原理

纯函数 JS 若满足其下条件 &#xff0c;被称为纯函数 1。确定的输入一定产生确定的输出 2 不产生副作用 另外redux中的reducer也要求是纯函数 Fiber 架构和hooks原理 useRef 在组件的整个声明周期内保持不变 用法&#xff1a;1绑定dom元素 或者 绑定一个类组件 因为函数式…

养老专业实训室虚拟仿真建设方案:助力人才培养与教育教学革新

随着我国老龄化程度加深&#xff0c;养老服务行业人才需求激增。养老专业实训室虚拟仿真建设方案凭借虚拟仿真技术&#xff0c;为养老专业教育教学带来革新&#xff0c;对人才培养意义重大。点击获取实训室建设方案 一、构建多元化虚拟场景&#xff0c;丰富实践教学内容 模拟居…

LangChain 提示词工程:语法结构详解与完整实战指南

LangChain 提示词工程&#xff1a;语法结构详解与完整实战指南 我将为您系统性地解析 LangChain 中各类提示模板的核心语法结构&#xff0c;通过清晰展示语法与对应代码示例&#xff0c;帮助您彻底掌握提示工程的实现方法。所有示例均围绕报幕词生成场景展开。 在这里插入图片…

20250625解决在Ubuntu20.04.6LTS下编译RK3588的Android14出现cfg80211.ko的overriding问题

Z:\14TB\versions\rk3588-android14-FriendlyElec\mkcombinedroot\res\vendor_modules.load 【拿掉/删除这一项目&#xff01;】 cfg80211.ko 20250625解决在Ubuntu20.04.6LTS下编译RK3588的Android14出现cfg80211.ko的overriding问题 2025/6/25 20:20 缘起&#xff1a;本文针对…

在WSL下搭建JavaWeb: JDBC学习环境

在WSL下搭建JavaWeb: JDBC学习环境 前言 ​ 笔者最近打算放松一下&#xff0c;接触一点经典的Java Web技术&#xff0c;自己在闲暇时间时玩一玩JavaWeb技术。这里开一个小系列整理一下最近学习的东西&#xff0c;以供参考和学习。 ​ 笔者的计划是使用VSCode写代码&#xff…

pscc系统如何部署,怎么更安全更便捷?

磐石云PSCC系统的安全高效部署需结合云原生架构与零信任安全模型&#xff0c;以下是经过大型项目验证的部署方案及最佳实践&#xff1a; 一、智能部署架构&#xff08;混合云模式&#xff09; 二、安全增强部署方案 1. 基础设施安全 网络隔离 采用 三层网络分区&#xff1a;互…

协程驱动的高性能异步 HTTP 服务器:基础实现与任务调度机制

一、引言&#xff1a;为什么用协程实现 HTTP 服务器&#xff1f; 传统 HTTP 服务器的编程模型大致分为&#xff1a; 多线程阻塞型&#xff1a;每连接一线程&#xff0c;简洁但扩展性差 事件驱动模型&#xff08;如 epoll 状态机&#xff09;&#xff1a;高性能但逻辑复杂 回…