Go的sync.Mutex互斥锁

2023-09-20 09:44:12

我们使用了一个buffered channel作为一个计数信号量,来保证最多只有20个goroutine会同时执行HTTP请求。同理,我们可以用一个容量只有1的channel来保证最多只有一个goroutine在同一时刻访问一个共享变量。一个只能为1和0的信号量叫做二元信号量(binary semaphore)。

gopl.io/ch9/bank2

var ( sema = make(chan struct{}, 1) // a binary semaphore guarding balance balance int ) func Deposit(amount int) { sema <- struct{}{} // acquire token balance = balance + amount <-sema // release token } func Balance() int { sema <- struct{}{} // acquire token b := balance <-sema // release token return b }

这种互斥很实用,而且被sync包里的Mutex类型直接支持。它的Lock方法能够获取到token(这里叫锁),并且Unlock方法会释放这个token:

gopl.io/ch9/bank3

import "sync" var ( mu sync.Mutex // guards 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 }

每次一个goroutine访问bank变量时(这里只有balance余额变量),它都会调用mutex的Lock方法来获取一个互斥锁。如果其它的goroutine已经获得了这个锁的话,这个操作会被阻塞直到其它goroutine调用了Unlock使该锁变回可用状态。mutex会保护共享变量。惯例来说,被mutex所保护的变量是在mutex变量声明之后立刻声明的。如果你的做法和惯例不符,确保在文档里对你的做法进行说明。

在Lock和Unlock之间的代码段中的内容goroutine可以随便读取或者修改,这个代码段叫做临界区。锁的持有者在其他goroutine获取该锁之前需要调用Unlock。goroutine在结束后释放锁是必要的,无论以哪条路径通过函数都需要释放,即使是在错误路径中,也要记得释放。

上面的bank程序例证了一种通用的并发模式。一系列的导出函数封装了一个或多个变量,那么访问这些变量唯一的方式就是通过这些函数来做(或者方法,对于一个对象的变量来说)。每一个函数在一开始就获取互斥锁并在最后释放锁,从而保证共享变量不会被并发访问。这种函数、互斥锁和变量的编排叫作监控monitor(这种老式单词的monitor是受“monitor goroutine”的术语启发而来的。两种用法都是一个代理人保证变量被顺序访问)。

由于在存款和查询余额函数中的临界区代码这么短——只有一行,没有分支调用——在代码最后去调用Unlock就显得更为直截了当。在更复杂的临界区的应用中,尤其是必须要尽早处理错误并返回的情况下,就很难去(靠人)判断对Lock和Unlock的调用是在所有路径中都能够严格配对的了。Go语言里的defer简直就是这种情况下的救星:我们用defer来调用Unlock,临界区会隐式地延伸到函数作用域的最后,这样我们就从“总要记得在函数返回之后或者发生错误返回时要记得调用一次Unlock”这种状态中获得了解放。Go会自动帮我们完成这些事情。

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

上面的例子里Unlock会在return语句读取完balance的值之后执行,所以Balance函数是并发安全的。这带来的另一点好处是,我们再也不需要一个本地变量b了。

此外,一个deferred Unlock即使在临界区发生panic时依然会执行,这对于用recover(§5.10)来恢复的程序来说是很重要的。defer调用只会比显式地调用Unlock成本高那么一点点,不过却在很大程度上保证了代码的整洁性。大多数情况下对于并发程序来说,代码的整洁性比过度的优化更重要。如果可能的话尽量使用defer来将临界区扩展到函数的结束。

考虑一下下面的Withdraw函数。成功的时候,它会正确地减掉余额并返回true。但如果银行记录资金对交易来说不足,那么取款就会恢复余额,并返回false。

// NOTE: not atomic! func Withdraw(amount int) bool { Deposit(-amount) if Balance() < 0 { Deposit(amount) return false // insufficient funds } return true }

函数终于给出了正确的结果,但是还有一点讨厌的副作用。当过多的取款操作同时执行时,balance可能会瞬时被减到0以下。这可能会引起一个并发的取款被不合逻辑地拒绝。所以如果Bob尝试买一辆sports car时,Alice可能就没办法为她的早咖啡付款了。这里的问题是取款不是一个原子操作:它包含了三个步骤,每一步都需要去获取并释放互斥锁,但任何一次锁都不会锁上整个取款流程。

理想情况下,取款应该只在整个操作中获得一次互斥锁。下面这样的尝试是错误的:

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

上面这个例子中,Deposit会调用mu.Lock()第二次去获取互斥锁,但因为mutex已经锁上了,而无法被重入(译注:go里没有重入锁,关于重入锁的概念,请参考java)——也就是说没法对一个已经锁上的mutex来再次上锁——这会导致程序死锁,没法继续执行下去,Withdraw会永远阻塞下去。

关于Go的mutex不能重入这一点我们有很充分的理由。mutex的目的是确保共享变量在程序执行时的关键点上能够保证不变性。不变性的一层含义是“没有goroutine访问共享变量”,但实际上这里对于mutex保护的变量来说,不变性还包含更深层含义:当一个goroutine获得了一个互斥锁时,它能断定被互斥锁保护的变量正处于不变状态(译注:即没有其他代码块正在读写共享变量),在其获取并保持锁期间,可能会去更新共享变量,这样不变性只是短暂地被破坏,然而当其释放锁之后,锁必须保证共享变量重获不变性并且多个goroutine按顺序访问共享变量。尽管一个可以重入的mutex也可以保证没有其它的goroutine在访问共享变量,但它不具备不变性更深层含义。(译注:更详细的解释,Russ Cox认为可重入锁是bug的温床,是一个失败的设计)

一个通用的解决方案是将一个函数分离为多个函数,比如我们把Deposit分离成两个:一个不导出的函数deposit,这个函数假设锁总是会被保持并去做实际的操作,另一个是导出的函数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 }

当然,这里的存款deposit函数很小,实际上取款Withdraw函数不需要理会对它的调用,尽管如此,这里的表达还是表明了规则。

封装(§6.6),用限制一个程序中的意外交互的方式,可以使我们获得数据结构的不变性。因为某种原因,封装还帮我们获得了并发的不变性。当你使用mutex时,确保mutex和其保护的变量没有被导出(在go里也就是小写,且不要被大写字母开头的函数访问啦),无论这些变量是包级的变量还是一个struct的字段。

更多推荐

ClickHouse进阶(二十):clickhouse管理与运维-熔断机制

进入正文前,感谢宝子们订阅专题、点赞、评论、收藏!关注IT贫道,获取高质量博客内容!🏡个人主页:IT贫道_大数据OLAP体系技术栈,ApacheDoris,Kerberos安全认证-CSDN博客📌订阅:拥抱独家专题,你的订阅将点燃我的创作热情!👍点赞:赞同优秀创作,你的点赞是对我创作最大的认可!⭐️收藏:收藏原创

NuttX实时操作系统介绍(最详细)

比起安卓、iOS、鸿蒙等,NuttX知名度不算高,该系统于2007年由GregoryNutt先生正式开源。发展至今,NuttX以功能丰富、性能稳定、商业化成熟度高赢得了市场的认可。NuttX是一个成熟的实时操作系统,于07年由GregoryNutt先生正式开源,2016年被三星选为TizenRT操作系统的内核,2019

GNU-ncurses库简介

文章目录前言一、安装与使用二、ncurses库基本用法2.1initscr()2.2refresh()2.3endwin()三、初始化3.1raw(),cbreak()3.2echo()noecho()3.3keypad()3.4inthalfdelay(int)四、窗口机制简介五、输出函数六、输入函数七、输出修饰八、

Docker与Kubernetes集成以实现云原生应用程序:云原生在线教育平台

文章目录项目概述项目架构项目步骤项目收益🎈个人主页:程序员小侯🎐CSDN新晋作者🎉欢迎👍点赞✍评论⭐收藏✨收录专栏:云计算✨文章内容:云原生在线教育平台🤝希望作者的文章能对你有所帮助,有不足的地方请在评论区留言指正,大家一起学习交流!🤗当将Docker与Kubernetes集成以实现云原生应用程序时,可以采

Python开发利器之VS Code

Python官方提供了一个Python集成开发环境(IDE):IDLE(IntegratedDevelopmentandLearningEnvironment)。它提供了一个图形用户界面,可以让开发者编写、调试和执行Python程序。IDLE包含Python解释器、代码编辑器、调试器和文件浏览器等工具。IDLE非常适合

爬虫技术对携程网旅游景点和酒店信息的数据挖掘和分析应用

导语爬虫技术是一种通过网络爬取目标网站的数据并进行分析的技术,它可以用于各种领域,如电子商务、社交媒体、新闻、教育等。本文将介绍如何使用爬虫技术对携程网旅游景点和酒店信息进行数据挖掘和分析,以及如何利用Selenium库和代理IP技术实现爬虫程序。概述携程网是在线旅行服务平台,提供酒店预订、机票预订、旅游度假、商旅管理

学习笔记 --- RabbitMQ

简介RabbitMQ是一款开源的消息队列中间件,它实现了高级消息队列协议(AMQP)标准。作为一个消息代理,RabbitMQ可以在应用程序之间可靠地传递和存储消息,并支持多种消息传递模式。基本概念和特性消息:在RabbitMQ中,消息是传输的基本单位。它由消息体和可选的属性组成,消息体是要传递的实际数据,而属性则包含有

容器管理工具 Docker生态架构及部署

目录一、Docker生态架构1.1DockerContainersAreEverywhere1.2生态架构1.2.1DockerHost1.2.2Dockerdaemon1.2.3Registry1.2.4Dockerclient1.2.5Image1.2.6Container1.2.7DockerDashboard1

VL系列 Exchanging-based Multimodal Fusion with Transformer 论文阅读笔记

多模态融合Exchanging-basedMultimodalFusionwithTransformer论文阅读笔记一、Abstract二、引言三、相关工作3.1深度多模态融合四、方法4.1低维投影和embedding归一化低维投影Embedding归一化4.2多模态交换Transformer基础CrossTransf

<git>如何快速上手并高效协同

git是什么?Git是一种分布式版本控制系统,用于跟踪计算机文件的变化和协调多个人之间的工作。它最初由LinusTorvalds于2005年创建,旨在管理Linux内核的开发。Git可以在本地计算机上存储完整的版本历史记录,并允许用户在不同的分支上进行开发和合并。它还提供了许多工具和命令,用于管理代码库、协作开发、解决

ClickHouse与Elasticsearch比较总结

目录背景分布式架构存储架构写入链路设计Elasticsearch再谈Schemaless查询架构计算引擎数据扫描再谈高并发性能测试日志分析场景access_log(数据量197921836)trace_log(数据量569816761)官方Ontime测试集用户画像场景(数据量262933269)二级索引点查场景(数据

热文推荐