Go并发的竞争条件

2023-09-18 09:08:46

在一个线性(就是说只有一个goroutine的)的程序中,程序的执行顺序只由程序的逻辑来决定。例如,我们有一段语句序列,第一个在第二个之前(废话),以此类推。在有两个或更多goroutine的程序中,每一个goroutine内的语句也是按照既定的顺序去执行的,但是一般情况下我们没法去知道分别位于两个goroutine的事件x和y的执行顺序,x是在y之前还是之后还是同时发生是没法判断的。当我们没有办法自信地确认一个事件是在另一个事件的前面或者后面发生的话,就说明x和y这两个事件是并发的。

考虑一下,一个函数在线性程序中可以正确地工作。如果在并发的情况下,这个函数依然可以正确地工作的话,那么我们就说这个函数是并发安全的,并发安全的函数不需要额外的同步工作。我们可以把这个概念概括为一个特定类型的一些方法和操作函数,对于某个类型来说,如果其所有可访问的方法和操作都是并发安全的话,那么该类型便是并发安全的。

在一个程序中有非并发安全的类型的情况下,我们依然可以使这个程序并发安全。确实,并发安全的类型是例外,而不是规则,所以只有当文档中明确地说明了其是并发安全的情况下,你才可以并发地去访问它。我们会避免并发访问大多数的类型,无论是将变量局限在单一的一个goroutine内,还是用互斥条件维持更高级别的不变性,都是为了这个目的。我们会在本章中说明这些术语。

相反,包级别的导出函数一般情况下都是并发安全的。由于package级的变量没法被限制在单一的gorouine,所以修改这些变量“必须”使用互斥条件。

一个函数在并发调用时没法工作的原因太多了,比如死锁(deadlock)、活锁(livelock)和饿死(resource starvation)。我们没有空去讨论所有的问题,这里我们只聚焦在竞争条件上。

竞争条件指的是程序在多个goroutine交叉执行操作时,没有给出正确的结果。竞争条件是很恶劣的一种场景,因为这种问题会一直潜伏在你的程序里,然后在非常少见的时候蹦出来,或许只是会在很大的负载时才会发生,又或许是会在使用了某一个编译器、某一种平台或者某一种架构的时候才会出现。这些使得竞争条件带来的问题非常难以复现而且难以分析诊断。

传统上经常用经济损失来为竞争条件做比喻,所以我们来看一个简单的银行账户程序。

// Package bank implements a bank with only one account. package bank var balance int func Deposit(amount int) { balance = balance + amount } func Balance() int { return balance }

(当然我们也可以把Deposit存款函数写成balance += amount,这种形式也是等价的,不过长一些的形式解释起来更方便一些。)

对于这个简单的程序而言,我们一眼就能看出,以任意顺序调用函数Deposit和Balance都会得到正确的结果。也就是说,Balance函数会给出之前的所有存入的额度之和。然而,当我们并发地而不是顺序地调用这些函数的话,Balance就再也没办法保证结果正确了。考虑一下下面的两个goroutine,其代表了一个银行联合账户的两笔交易:

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

Alice存了$200,然后检查她的余额,同时Bob存了$100。因为A1和A2是和B并发执行的,我们没法预测他们发生的先后顺序。直观地来看的话,我们会认为其执行顺序只有三种可能性:“Alice先”,“Bob先”以及“Alice/Bob/Alice”交错执行。下面的表格会展示经过每一步骤后balance变量的值。引号里的字符串表示余额单。

Alice first Bob first Alice/Bob/Alice 0 0 0 A1 200 B 100 A1 200 A2 "= 200" A1 300 B 300 B 300 A2 "= 300" A2 "= 300"

所有情况下最终的余额都是$300。唯一的变数是Alice的余额单是否包含了Bob交易,不过无论怎么着客户都不会在意。

但是事实是上面的直觉推断是错误的。第四种可能的结果是事实存在的,这种情况下Bob的存款会在Alice存款操作中间,在余额被读到(balance + amount)之后,在余额被更新之前(balance = ...),这样会导致Bob的交易丢失。而这是因为Alice的存款操作A1实际上是两个操作的一个序列,读取然后写;可以称之为A1r和A1w。下面是交叉时产生的问题:

Data race 0 A1r 0 ... = balance + amount B 100 A1w 200 balance = ... A2 "= 200"

在A1r之后,balance + amount会被计算为200,所以这是A1w会写入的值,并不受其它存款操作的干预。最终的余额是$200。银行的账户上的资产比Bob实际的资产多了$100。(译注:因为丢失了Bob的存款操作,所以其实是说Bob的钱丢了。)

这个程序包含了一个特定的竞争条件,叫作数据竞争。无论任何时候,只要有两个goroutine并发访问同一变量,且至少其中的一个是写操作的时候就会发生数据竞争。

如果数据竞争的对象是一个比一个机器字(译注:32位机器上一个字=4个字节)更大的类型时,事情就变得更麻烦了,比如interface,string或者slice类型都是如此。下面的代码会并发地更新两个不同长度的slice:

var x []int go func() { x = make([]int, 10) }() go func() { x = make([]int, 1000000) }() x[999999] = 1 // NOTE: undefined behavior; memory corruption possible!

最后一个语句中的x的值是未定义的;其可能是nil,或者也可能是一个长度为10的slice,也可能是一个长度为1,000,000的slice。但是回忆一下slice的三个组成部分:指针(pointer)、长度(length)和容量(capacity)。如果指针是从第一个make调用来,而长度从第二个make来,x就变成了一个混合体,一个自称长度为1,000,000但实际上内部只有10个元素的slice。这样导致的结果是存储999,999元素的位置会碰撞一个遥远的内存位置,这种情况下难以对值进行预测,而且debug也会变成噩梦。这种语义雷区被称为未定义行为,对C程序员来说应该很熟悉;幸运的是在Go语言里造成的麻烦要比C里小得多。

尽管并发程序的概念让我们知道并发并不是简单的语句交叉执行。我们将会在9.4节中看到,数据竞争可能会有奇怪的结果。许多程序员,甚至一些非常聪明的人也还是会偶尔提出一些理由来允许数据竞争,比如:“互斥条件代价太高”,“这个逻辑只是用来做logging”,“我不介意丢失一些消息”等等。因为在他们的编译器或者平台上很少遇到问题,可能给了他们错误的信心。一个好的经验法则是根本就没有什么所谓的良性数据竞争。所以我们一定要避免数据竞争,那么在我们的程序中要如何做到呢?

我们来重复一下数据竞争的定义,因为实在太重要了:数据竞争会在两个以上的goroutine并发访问相同的变量且至少其中一个为写操作时发生。根据上述定义,有三种方式可以避免数据竞争:

第一种方法是不要去写变量。考虑一下下面的map,会被“懒”填充,也就是说在每个key被第一次请求到的时候才会去填值。如果Icon是被顺序调用的话,这个程序会工作很正常,但如果Icon被并发调用,那么对于这个map来说就会存在数据竞争。

var icons = make(map[string]image.Image) func loadIcon(name string) image.Image // NOTE: not concurrency-safe! func Icon(name string) image.Image { icon, ok := icons[name] if !ok { icon = loadIcon(name) icons[name] = icon } return icon }

反之,如果我们在创建goroutine之前的初始化阶段,就初始化了map中的所有条目并且再也不去修改它们,那么任意数量的goroutine并发访问Icon都是安全的,因为每一个goroutine都只是去读取而已。

var 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"), } // Concurrency-safe. func Icon(name string) image.Image { return icons[name] }

上面的例子里icons变量在包初始化阶段就已经被赋值了,包的初始化是在程序main函数开始执行之前就完成了的。只要初始化完成了,icons就再也不会被修改。数据结构如果从不被修改或是不变量则是并发安全的,无需进行同步。不过显然,如果update操作是必要的,我们就没法用这种方法,比如说银行账户。

第二种避免数据竞争的方法是,避免从多个goroutine访问变量。这也是前一章中大多数程序所采用的方法。例如前面的并发web爬虫(§8.6)的main goroutine是唯一一个能够访问seen map的goroutine,而聊天服务器(§8.10)中的broadcaster goroutine是唯一一个能够访问clients map的goroutine。这些变量都被限定在了一个单独的goroutine中。

由于其它的goroutine不能够直接访问变量,它们只能使用一个channel来发送请求给指定的goroutine来查询更新变量。这也就是Go的口头禅“不要使用共享数据来通信;使用通信来共享数据”。一个提供对一个指定的变量通过channel来请求的goroutine叫做这个变量的monitor(监控)goroutine。例如broadcaster goroutine会监控clients map的全部访问。

下面是一个重写了的银行的例子,这个例子中balance变量被限制在了monitor goroutine中,名为teller:

gopl.io/ch9/bank1

// Package bank provides a concurrency-safe bank with one account. package bank var deposits = make(chan int) // send amount to deposit var balances = make(chan int) // receive balance func Deposit(amount int) { deposits <- amount } func Balance() int { return <-balances } func teller() { var balance int // balance is confined to teller goroutine for { select { case amount := <-deposits: balance += amount case balances <- balance: } } } func init() { go teller() // start the monitor goroutine }

即使当一个变量无法在其整个生命周期内被绑定到一个独立的goroutine,绑定依然是并发问题的一个解决方案。例如在一条流水线上的goroutine之间共享变量是很普遍的行为,在这两者间会通过channel来传输地址信息。如果流水线的每一个阶段都能够避免在将变量传送到下一阶段后再去访问它,那么对这个变量的所有访问就是线性的。其效果是变量会被绑定到流水线的一个阶段,传送完之后被绑定到下一个,以此类推。这种规则有时被称为串行绑定。

下面的例子中,Cakes会被严格地顺序访问,先是baker gorouine,然后是icer gorouine:

type Cake struct{ state string } func baker(cooked chan<- *Cake) { for { cake := new(Cake) cake.state = "cooked" cooked <- cake // baker never touches this cake again } } func icer(iced chan<- *Cake, cooked <-chan *Cake) { for cake := range cooked { cake.state = "iced" iced <- cake // icer never touches this cake again } }

第三种避免数据竞争的方法是允许很多goroutine去访问变量,但是在同一个时刻最多只有一个goroutine在访问。这种方式被称为“互斥”,在下一节来讨论这个主题。

更多推荐

【C++】详解std::mutex

2023年9月11日,周一中午开始2023年9月11日,周一晚上23:25写完目录概述头文件std::mutex类的成员类型方法没有std::mutex会产生什么问题问题一:数据竞争问题二:不一致lock和unlock死锁概述std::mutex是C++标准库中提供的一种同步原语,用于保护共享资源的访问。std::mu

防火墙 (五十四)

目录前言一、防火墙作用二、防火墙分类三、防火墙性能四、硬件防火墙五、软件防火墙5.1iptables六、iptables应用前言本文就简单的介绍了防火墙的基础内容和一些简单案例的操作。提示:以下是本篇文章正文内容,下面案例可供参考一、防火墙作用在计算机领域,防火墙是用于保护信息安全的设备,其会依照用户定义的规则,允许或

Ascend-pytorch插件介绍及模型迁移

Ascend-pytorch插件介绍及模型迁移用于昇腾适配PyTorch框架,为使用PyTorch框架的开发者提供昇腾AI处理器的超强算力。links:AscendPyTorch官方仓库PyTorch官方主页PyTorch官方文档PyTorch官方仓库当前(2023.9.20)AscendPyTorch支持的pytor

数字孪生行业相关政策梳理--工业领域相关政策(可下载)

&nbsp;&nbsp;&nbsp;&nbsp;自2021年国家“十四五”规划纲要提出“探索建设数字孪生城市”以来,国家发展和改革委员会、工业和信息化部、住房和城乡建设部、水利部、农业农村部等部门纷纷出台政策,大力推动数字孪生在千行百业的落地发展。这些政策不仅为数字孪生的应用提供了广阔的舞台,也为相关产业的发展提供了坚

【Linux】线程控制

文章目录📖前言1.线程的id1.1pthread_self:1.2线程独立栈结构:1.3pthread_t究竟是什么:1.4线程的局部存储:2.线程退出的三种方式2.2-1方式一:pthread_cancel2.2-2方式二:pthread_exit2.2-3方式三:隐式退出3.线程的分离3.1新线程分离后,主线程先

【物联网】简要解释RTK(Real-Time Kinematic)>>实时动态差分定位

引言:RTK(Real-TimeKinematic)技术是一种基于差分GPS的高精度定位技术,它通过实时通信和数据处理,能够提供厘米级甚至亚米级的定位精度。RTK技术在许多领域都得到了广泛应用,如测绘、航空航天、农业等。本文将介绍如何使用C语言实现RTK技术的基本功能,包括获取GPS数据、差分修正数据以及计算修正后的位

卫星地图-航拍影像-叠加配准套合(ArcGIS版)

卫星地图-航拍影像-叠加配准套合(ArcGIS版)发布时间:2018-01-17版权:BIGEMAP第一步工具准备BIGEMAP地图下载器:Bigemap系列产品-GIS行业基础软件kml\shp相关教程:CAD文件直接导入BIGEMAP进行套合配准(推荐)本实例使用ArcMap10.2软件进行影像与矢量数据叠加配准。

【Git】Git cherry-pick

Gitcherry-pick1.指令效果与基本用法在Git的文档中,对于cherry-pick指令的描述如下:gitcherry-pick命令用来获得在单个提交中引入的变更,然后尝试将作为一个新的提交引入到你当前分支上。从一个分支单独一个或者两个提交而不是合并整个分支的所有变更是非常有用的。该命令的基本语法如下:git

探索Java生态系统的其他技术与工具

导言:Java生态系统拥有广泛的技术和工具,其中一些对于开发者来说至关重要。除了核心Java编程语言和开发框架,还有一些其他技术和工具可以帮助开发者更好地构建和管理Java项目。本文将深入探索这些技术和工具,包括Maven和Gradle的项目构建、SonarQube的代码质量管理、Spring框架的应用开发、Hiber

Java面向对象编程

下面关于IP地址的论述中哪个是不正确的()A.用户主机的IP地址可静态分配也可以动态分配B.IP地址有单播地址,也有多播地址C.一个用户主机只能有一个IP地址D.在以太局域网中使用ARP协议查找与一IP地址对应的MAC地址答案:Ctcp套接字中,不会阻塞的是哪一种操作()A.readB.writeC.acceptD.b

Openresty(二十一)ngx.balance和balance_by_lua灰度发布

一openresty实现灰度发布①灰度发布说明:'早期'博客对'灰度'发布的'概念'进行解读,并且对'原生nginx'灰度实现进行讲解后续:主要拿'节点引流'的灰度发布,并且关注'gray灰度策略'相关借鉴②回顾HTTP反向代理流程ngx_http_upstream可'操作'点:根据'负载均衡策略'选择上游的服务器wr

热文推荐