前面我们已经学会了用goroutine和channel来实现并发,感觉就像让很多小帮手一起分工合作一样,写起来也很顺手。 不过,等我们真的开始写复杂的并发程序时,会发现里面还有不少“坑”等着我们,比如多个goroutine一起用同一个变量时,可能会出现一些意想不到的小问题。
在只有一个goroutine的顺序程序中,代码的执行就像我们熟悉的流水线一样,一步一步按顺序进行。比如先执行第一行代码,再执行第二行,以此类推。 但是当程序中有两个或更多goroutine同时运行时,每个goroutine内部的代码仍然按顺序执行,但我们无法确定一个goroutine中的事件是否在另一个goroutine中的事件之前发生,还是之后发生,或者同时发生。 当我们无法确定地说一个事件在另一个事件之前发生时,这两个事件就是并发的。

函数在并发调用时可能无法正常工作的原因有很多,包括死锁、活锁和资源饥饿,这里我们讨论最常见的一种:竞态条件。
竞态条件是指程序对于多个goroutine操作的某些交错执行不能给出正确结果的情况。
让我们用一个简单的购物车例子来说明竞态条件的严重性。
|// Package cart 实现了一个简单的购物车系统 package cart var totalPrice int func AddItem(price int) { totalPrice = totalPrice + price } func GetTotal() int { return totalPrice }
对于这样一个简单的程序,我们可以一眼看出,对AddItem和GetTotal的任何调用序列都会给出正确答案,也就是说,GetTotal将报告之前所有添加商品的总价。 但是,如果我们不是按顺序调用这些函数,而是并发调用,GetTotal就不再保证给出正确答案。
考虑以下两个goroutine,它们代表两个用户同时向购物车添加商品:
|// 用户A: go func() { cart.AddItem(50) // A1 fmt.Println("购物车总价:", cart.GetTotal()) // A2 }() // 用户B: go cart.AddItem(30) // B
用户A添加了50元的商品,然后查看总价,而用户B添加了30元的商品。由于步骤A1和A2与B并发发生,我们无法预测它们发生的顺序。直观上,可能看起来只有三种可能的排序,我们称之为"用户A先"、"用户B先"和"用户A/用户B/用户A"。下表显示了每个步骤后totalPrice变量的值。引号中的字符串代表打印的总价。
在所有情况下,最终总价都是80元。唯一的变体是用户A看到的总价是否包括用户B添加的商品,但用户对这两种方式都满意。
但这种直觉是错误的。还有第四种可能的结果,即用户B的添加操作发生在用户A添加操作的中间,在总价被读取(totalPrice + price)之后但在更新(totalPrice = ...)之前,导致用户B的商品价格消失。 这是因为用户A的添加操作A1实际上是一系列两个操作,一个读取和一个写入;我们称它们为A1r和A1w。这是有问题的交错:
数据竞争
|0 A1r 0 ... = totalPrice + price B 30 A1w 50 totalPrice = ... A2 "购物车总价:50"
在A1r之后,表达式totalPrice + price计算为50,所以这是A1w期间写入的值,尽管中间有用户B添加商品。最终总价只有50元。系统丢失了用户B添加的30元商品。
这个程序包含一种特定类型的竞态条件,称为数据竞争。当两个goroutine并发访问同一个变量,并且至少有一个访问是写入时,就会发生数据竞争。
如果数据竞争涉及大于单个机器字的变量类型,比如接口、字符串或切片,情况会变得更加混乱。这段代码并发地将x更新为两个不同长度的切片:
|var x []int go func() { x = make([]int, 10) }() go func() { x = make([]int, 1000000) }() x[999999] = 1 // 注意:未定义行为;可能出现内存损坏!
最终语句中x的值是未定义的;它可能是nil,或者长度为10的切片,或者长度为1,000,000的切片。但回想一下,切片有三个部分:指针、长度和容量。如果指针来自第一次调用make,而长度来自第二次调用, x将是一个嵌合体,一个名义长度为1,000,000但其底层数组只有10个元素的切片。在这种情况下,存储到元素999,999将破坏任意远处的内存位置,其后果无法预测且难以调试和定位。这种语义雷区被称为未定义行为,C程序员对此很熟悉; 幸运的是,在Go中它很少像在C中那样麻烦。
许多程序员——甚至一些非常聪明的人——偶尔会为他们程序中已知的数据竞争提供理由:"互斥的成本太高","这个逻辑只是用于日志记录","我不介意丢失一些消息",等等。 在给定编译器和平台上没有问题可能给他们虚假的信心。一个好的经验法则是,没有良性数据竞争这种东西。那么我们如何在程序中避免数据竞争呢?
我们将重复这个定义,因为它非常重要:当两个goroutine并发访问同一个变量,并且至少有一个访问是写入时,就会发生数据竞争。从这个定义可以得出,有三种方法可以避免数据竞争。
第一种方法是不写入变量。考虑下面的映射,它在每次首次请求键时懒加载填充。如果Icon被顺序调用,程序工作正常,但如果Icon被并发调用,访问映射时就会出现数据竞争。
|var icons = make(map[string]image.Image) func loadIcon(name string) image.Image // 注意:不是并发安全的! func Icon(name string) image.Image { icon, ok := icons[name] if !ok { icon =
如果我们改为在创建额外的goroutine之前用所有必要的条目初始化映射,并且之后不再修改它,那么任意数量的goroutine都可以安全地并发调用Icon,因为每个都只读取映射。
|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"), } // 并发安全的。 func Icon(
在上面的例子中,icons变量在包初始化期间被赋值,这发生在程序的main函数开始运行之前。一旦初始化,icons就永远不会被修改。从未被修改或不可变的数据结构本质上是并发安全的,不需要同步。 但显然,如果更新是必要的,比如购物车总价,我们就不能使用这种方法。
避免数据竞争的第二种方法是避免从多个goroutine访问变量。这是前一章中许多程序采用的方法。例如,并发网络爬虫(§8.6)中的主goroutine是唯一访问seen映射的goroutine,聊天服务器(§8.10)中的广播goroutine是唯一访问clients映射的goroutine。这些变量被限制在单个goroutine中。由于其他goroutine不能直接访问变量,它们必须使用通道向限制goroutine发送查询或更新变量的请求。这就是Go箴言"不要通过共享内存来通信;相反,通过通信来共享内存"的含义。使用通道请求代理对限制变量访问的goroutine被称为该变量的监控goroutine。例如,广播goroutine监控对clients映射的访问。
这是用totalPrice变量限制到称为cartManager的监控goroutine重写的购物车例子:
|// Package cart 提供了一个并发安全的购物车,只有一个账户。 package cart var addItems = make(chan int) // 发送商品价格 var getTotals = make(chan int) // 接收总价 func AddItem(price int) { addItems <- price } func GetTotal() int { return <-getTotals } func cartManager() {
即使变量不能在其整个生命周期内被限制在单个goroutine中,限制仍然可能是并发访问问题的解决方案。 例如,在管道中通过通道将变量的地址从一个阶段传递到下一个阶段来在goroutine之间共享变量是很常见的。 如果管道的每个阶段在将变量发送到下一阶段后都避免访问该变量,那么对变量的所有访问都是顺序的。实际上,变量被限制在管道的一个阶段,然后限制在下一个阶段,以此类推。这种纪律有时被称为串行限制。
在下面的例子中,Cakes首先被串行限制到baker goroutine,然后限制到icer goroutine:
|type Cake struct{ state string } func baker(cooked chan<- *Cake) { for { cake := new(Cake) cake.state = "cooked" cooked <- cake // baker永远不会再碰这个蛋糕 } } func icer(iced chan<- *Cake, cooked
避免数据竞争的第三种方法是允许多个goroutine访问变量,但一次只允许一个, 这种方法被称为互斥。
衔接上面的例子,如果我们把通道的容量设为1,就能保证同一时刻只有一个goroutine能访问某个变量。这样只允许一个“令牌”通过的信号量,我们通常叫它“二元信号量”,它就像门卫一样,谁拿到令牌谁进门,其他人只能等着。
|var ( sema = make(chan struct{}, 1) // 保护totalPrice的二元信号量 totalPrice int ) func AddItem(price int) { sema <- struct{}{} // 获取令牌 totalPrice = totalPrice + price <-sema // 释放令牌 } func GetTotal() int
这种互斥模式非常有用,以至于sync包中的Mutex类型直接支持它。它的Lock方法获取令牌(称为锁),它的Unlock方法释放它:
|import "sync" var ( mu sync.Mutex // 保护totalPrice totalPrice int ) func AddItem(price int) { mu.Lock() totalPrice = totalPrice + price mu.Unlock() } func GetTotal() int { mu.
每次goroutine访问购物车变量(这里只是totalPrice)时,它必须调用互斥锁的Lock方法来获取独占锁。如果其他goroutine已经获取了锁,这个操作将阻塞,直到其他goroutine调用Unlock并且锁变为可用。 互斥锁保护共享变量。按照惯例,被互斥锁保护的变量在互斥锁本身的声明之后立即声明。如果你偏离这一点,一定要记录它。
goroutine在Lock和Unlock之间可以自由读取和修改共享变量的代码区域称为临界区。锁持有者对Unlock的调用发生在任何其他goroutine可以为自己获取锁之前。 一旦完成,goroutine在所有路径(包括错误路径)上释放锁是至关重要的。
上面的购物车程序例证了一个常见的并发模式。一组导出的函数封装一个或多个变量,使得访问变量的唯一方式是通过这些函数(或者对于对象的变量,通过方法)。 每个函数在开始时获取互斥锁,在结束时释放它,从而确保共享变量不被并发访问。这种函数、互斥锁和变量的安排被称为监控器。("监控器"这个词的旧用法启发了"监控goroutine"这个术语。两种用法都共享确保变量被顺序访问的代理的含义。)
由于AddItem和GetTotal函数中的临界区如此短——单行,没有分支——在末尾调用Unlock是直接的。 在更复杂的临界区中,特别是那些必须通过提前返回来处理错误的临界区,可能很难判断Lock和Unlock的调用在所有路径上都严格配对。 Go的defer语句来救援:通过延迟对Unlock的调用,临界区隐式地扩展到当前函数的末尾,使我们不必记住在远离Lock调用的一个或多个地方插入Unlock调用。
|func GetTotal() int { mu.Lock() defer mu.Unlock() return totalPrice }
在上面的例子中,Unlock在return语句读取totalPrice的值之后执行,所以GetTotal函数是并发安全的。作为奖励,我们不再需要局部变量t。
此外,延迟的Unlock即使在临界区发生panic时也会运行,这在使用recover的程序中可能很重要(§5.10)。defer比显式调用Unlock稍微昂贵一些,但不足以证明不太清晰的代码是合理的。 对于并发程序,总是优先考虑清晰度,抵制过早优化。在可能的情况下,使用defer并让临界区扩展到函数的末尾。
考虑下面的RemoveItem函数。成功时,它将总价减少指定的金额并返回true。但如果购物车中的商品总价不足以进行移除操作,RemoveItem恢复总价并返回false。
|// 注意:不是原子的! func RemoveItem(price int) bool { AddItem(-price) if GetTotal() < 0 { AddItem(price) return false // 商品总价不足 } return true }
这个函数最终给出正确的结果,但它有一个讨厌的副作用。当尝试移除超过购物车总价的商品时,总价暂时降到零以下。 这可能导致对适度金额的并发移除操作被虚假地拒绝。所以如果用户A试图移除一件昂贵的商品,用户B就无法移除他的便宜商品。 问题是RemoveItem不是原子的:它由三个独立操作的序列组成,每个操作都获取然后释放互斥锁,但没有任何东西锁定整个序列。
理想情况下,RemoveItem应该在整个操作周围获取一次互斥锁。但是,这种尝试不会工作:
|// 注意:不正确! func RemoveItem(price int) bool { mu.Lock() defer mu.Unlock() AddItem(-price) if GetTotal() < 0 { AddItem(price) return false // 商品总价不足 } return true }
AddItem试图通过调用mu.Lock()第二次获取互斥锁,但由于互斥锁不是可重入的——不可能锁定已经锁定的互斥锁——这导致死锁,没有任何东西可以继续,RemoveItem永远阻塞。
Go的互斥锁不可重入是有充分理由的。互斥锁的目的是确保共享变量的某些不变量在程序执行期间的关键点得到维护。其中一个不变量是"没有goroutine正在访问共享变量",但可能还有特定于互斥锁保护的数据结构的额外不变量。 当goroutine获取互斥锁时,它可能假设不变量成立。在持有锁时,它可能更新共享变量,使得不变量暂时被违反。但是,当它释放锁时,它必须保证秩序已经恢复,不变量再次成立。 虽然可重入互斥锁会确保没有其他goroutine正在访问共享变量,但它无法保护这些变量的额外不变量。
一个常见的解决方案是将AddItem这样的函数分为两个:一个未导出的函数addItem,它假设锁已经被持有并做真正的工作,以及一个导出的函数AddItem,它在调用addItem之前获取锁。然后我们可以用addItem来表示RemoveItem,像这样:
|func RemoveItem(price int) bool { mu.Lock() defer mu.Unlock() addItem(-price) if totalPrice < 0 { addItem(price) return false // 商品总价不足 } return true } func AddItem(price int) {
想象一下我们正在开发一个在线图书馆系统。小明是一个图书管理员,负责管理图书的借阅和归还。他发现每当有读者查询图书信息时,整个系统都会暂停,导致其他读者无法同时查询,这严重影响了用户体验。
问题的根源在于我们使用了普通的互斥锁来保护图书数据库。即使只是读取图书信息(比如查询书名、作者、库存数量),系统也会完全锁定,阻止其他读取操作。
实际上,多个读取操作可以安全地并发进行,只要没有写入操作(如添加新书、更新库存)在同时进行。这种情况下,我们需要一种特殊的锁,它允许多个读取操作并行执行,但写入操作需要独占访问权限。
这种锁就是读写互斥锁,在Go中由sync.RWMutex提供:
|var mu sync.RWMutex var books map[string]Book func GetBookInfo(isbn string) (Book, bool) { mu.RLock() // 获取读锁 defer mu.RUnlock() book, exists := books[isbn] return book, exists } func AddNewBook(isbn
GetBookInfo函数现在调用RLock和RUnlock方法来获取和释放读锁(共享锁)。AddNewBook函数调用Lock和Unlock方法来获取和释放写锁(独占锁)。
经过这个改进,多个读者可以同时查询图书信息,大大提高了系统的并发性能。读锁在更多时间内可用,写操作也能及时进行。
需要注意的是,只有在临界区内没有对共享变量的写入操作时,才能使用RLock。我们不能简单地假设看起来只读的函数实际上不会修改任何变量。
例如,一个看似简单的查询方法可能会更新访问计数器或缓存信息。如果有疑问,应该使用独占的Lock。
只有当获取锁的大多数goroutine都是读者,并且锁经常处于竞争状态时,使用RWMutex才有意义。
RWMutex的内部实现比普通互斥锁更复杂,因此在无竞争的情况下,它的性能可能不如普通的sync.Mutex。
你可能想知道为什么我们需要互斥锁来保护看似简单的读取操作。比如在图书馆系统中,为什么查询图书信息这样的只读操作也需要同步?毕竟,读取操作看起来是原子的,不会出现“中间”被其他goroutine打断的情况。
这里有两个重要原因。第一个原因是确保读取操作不会在写入操作(如添加新书、更新库存)的中间执行。第二个更微妙的原因是,同步不仅仅是关于多个goroutine的执行顺序;它还影响内存的可见性。
在现代计算机中,可能有几十个处理器,每个都有自己的本地内存缓存。为了提高效率,对内存的写入在每个处理器内被缓冲,只有在必要时才刷新到主内存。
它们甚至可能以与写入goroutine不同的顺序提交到主内存。像通道通信和互斥操作这样的同步原语会强制处理器刷新并提交所有累积的写入,确保goroutine的执行效果对其他处理器上运行的goroutine可见。
让我们用一个在线考试系统的例子来说明这个问题:
|var ( studentScore int examStatus string ) go func() { studentScore = 85 // A1:设置学生分数 fmt.Print("考试状态:", examStatus, " ") // A2:打印考试状态 }() go func() { examStatus = "已完成" // B1:更新考试状态 fmt.Print("学生分数:", studentScore, " ") // B2:打印学生分数
由于这两个goroutine是并发的,并且在没有互斥的情况下访问共享变量,存在数据竞争,所以我们不应该惊讶程序不是确定性的。我们可能期望它打印这四个结果中的任何一个:
|考试状态: 学生分数:85 学生分数:0 考试状态:已完成 学生分数:85 考试状态:已完成 考试状态:已完成 学生分数:85
但是,这两个结果可能令人惊讶:
|考试状态: 学生分数:0 学生分数:0 考试状态:
在单个goroutine内,每个语句的效果保证按执行顺序发生;goroutine是顺序一致的。但在没有使用通道或互斥锁进行显式同步的情况下,不能保证所有goroutine都以相同顺序看到事件。 虽然goroutine A必须在读取examStatus的值之前观察到写入studentScore=85的效果,但它不一定观察到goroutine B对examStatus的写入,所以A可能打印examStatus的陈旧值。
试图将并发理解为好像它对应于每个goroutine语句的某种交错是诱人的,但正如上面的例子所示,这不是现代编译器或CPU的工作方式。 因为赋值和Print引用不同的变量,编译器可能得出结论,两个语句的顺序不能影响结果,并交换它们。 如果两个goroutine在不同的CPU上执行,每个都有自己的缓存,一个goroutine的写入在缓存与主内存同步之前对另一个goroutine的Print不可见。
所有这些并发问题都可以通过一致使用简单、已建立的模式来避免。在可能的情况下,将变量限制在单个goroutine中;对于所有其他变量,使用互斥。
将昂贵的初始化步骤推迟到需要的那一刻是好的做法。预先初始化变量会增加程序的启动延迟,如果执行不总是到达使用该变量的程序部分,这是不必要的。让我们用一个在线学习平台的例子来说明这个问题:
|var courseMaterials map[string]CourseMaterial
假设我们正在开发一个在线学习平台,需要加载各种课程资料。CourseMaterial这个版本使用延迟初始化:
|func loadCourseMaterials() { courseMaterials = map[string]CourseMaterial{ "math101": loadMaterial("math101"), "physics101": loadMaterial("physics101"), "chemistry101": loadMaterial("chemistry101"), "biology101": loadMaterial("biology101"), } } // 注意:不是并发安全的!
对于只被单个goroutine访问的变量,我们可以使用上面的模式,但如果GetCourseMaterial被并发调用,这个模式就不安全。 像我们之前看到的购物车例子一样,GetCourseMaterial由多个步骤组成:它测试courseMaterials是否为nil,然后加载课程资料,然后将courseMaterials更新为非nil值。 直觉可能表明上面竞态条件的最坏可能结果是loadCourseMaterials函数被调用几次。当第一个goroutine忙于加载资料时,进入GetCourseMaterial的另一个goroutine会发现变量仍然等于nil,也会调用loadCourseMaterials。
但这种直觉也是错误的。(我们希望到现在你已经对并发有了新的直觉,即关于并发的直觉是不可信的!)。 在没有显式同步的情况下,编译器和CPU可以以任意数量的方式重新排序对内存的访问,只要每个goroutine的行为是顺序一致的。 loadCourseMaterials语句的一个可能重新排序如下所示。它在填充之前将空映射存储在courseMaterials变量中:
|func loadCourseMaterials() { courseMaterials = make(map[string]CourseMaterial) courseMaterials["math101"] = loadMaterial("math101") courseMaterials["physics101"] = loadMaterial("physics101") courseMaterials["chemistry101"] = loadMaterial("chemistry101") courseMaterials["biology101"]
因此,发现courseMaterials为非nil的goroutine可能不能假设变量的初始化已完成。
确保所有goroutine观察loadCourseMaterials效果的最简单正确方法是使用互斥锁同步它们:
|var mu sync.Mutex // 保护courseMaterials var courseMaterials map[string]CourseMaterial // 并发安全的。 func GetCourseMaterial(courseID string) CourseMaterial { mu.Lock() defer mu.Unlock() if courseMaterials == nil { loadCourseMaterials() } return courseMaterials[courseID]
但是,强制对courseMaterials进行互斥访问的成本是两个goroutine不能并发访问变量,即使变量已经安全初始化并且永远不会再被修改。这表明需要一个多读者锁:
|var mu sync.RWMutex // 保护courseMaterials var courseMaterials map[string]CourseMaterial // 并发安全的。 func GetCourseMaterial(courseID string) CourseMaterial { mu.RLock() if courseMaterials != nil { material := courseMaterials[courseID] mu.RUnlock() return material
现在有两个临界区。goroutine首先获取读者锁,查询映射,然后释放锁。如果找到条目(常见情况),它被返回。如果没有找到条目,goroutine获取写者锁。 没有办法在不首先释放共享锁的情况下将共享锁升级为独占锁,所以我们必须重新检查courseMaterials变量,以防另一个goroutine在中间已经初始化了它。
上面的模式给了我们更大的并发性,但很复杂,因此容易出错。幸运的是,sync包为一次性初始化问题提供了专门的解决方案:sync.Once。 概念上,Once由互斥锁和记录初始化是否已发生的布尔变量组成;互斥锁保护布尔变量和客户端的数据结构。唯一的方法Do接受初始化函数作为其参数。让我们使用Once来简化GetCourseMaterial函数:
|var loadCourseMaterialsOnce sync.Once var courseMaterials map[string]CourseMaterial // 并发安全的。 func GetCourseMaterial(courseID string) CourseMaterial { loadCourseMaterialsOnce.Do(loadCourseMaterials) return courseMaterials[courseID] }
对Do(loadCourseMaterials)的每次调用都锁定互斥锁并检查布尔变量。在第一次调用中,变量为false,Do调用loadCourseMaterials并将变量设置为true。 后续调用什么都不做,但互斥锁同步确保loadCourseMaterials对内存(特别是courseMaterials)的效果对所有goroutine可见。以 这种方式使用sync.Once,我们可以避免与其他goroutine共享变量,直到它们被正确构造。
9. 竞态条件修复练习
分析以下代码的竞态条件,使用sync.Mutex修复问题。
|package main import ( "fmt" "sync" "time" ) // 有问题的代码(存在竞态条件) var counter int func increment() { counter++ // 这不是原子操作,存在竞态条件 } func getCounter() int { return counter } // 修复后的代码
10. 使用RWMutex优化读多写少场景
实现一个线程安全的计数器,使用sync.RWMutex优化读操作。
|package main import ( "fmt" "sync" "time" ) type SafeCounter struct { mu sync.RWMutex // 使用读写锁 value int } // 写操作:使用写锁 func (sc *SafeCounter) Increment() { sc.mu.Lock
11. 使用sync.Once实现单例模式
使用sync.Once确保配置只加载一次。
|package main import ( "fmt" "sync" "time" ) type Config struct { DatabaseURL string APIKey string MaxConnections int } type ConfigManager struct { once sync.Once config *Config
12. 线程安全的银行账户
实现一个线程安全的银行账户,使用Mutex保护余额操作。
|package main import ( "fmt" "sync" "time" ) type BankAccount struct { mu sync.Mutex balance int } func NewBankAccount(initialBalance int) *BankAccount { return &BankAccount
|=== 有问题的代码(存在竞态条件)=== 最终计数(可能不正确): 987 === 修复后的代码(使用Mutex)=== 最终计数(正确): 1000
说明:
counter++不是原子操作,多个goroutine同时执行会导致竞态条件sync.Mutex保护共享资源Lock()和Unlock()必须成对使用defer确保即使发生panic也会解锁|读goroutine 0: 当前值 = 0 读goroutine 1: 当前值 = 0 读goroutine 2: 当前值 = 1 读goroutine 3: 当前值 = 2 读goroutine 4: 当前值 = 3 ... 最终值: 500
说明:
sync.RWMutex允许多个goroutine同时持有读锁Lock()和Unlock()RLock()和RUnlock()|正在加载配置... 配置加载完成 Goroutine 0 获取配置: &{DatabaseURL:localhost:5432 APIKey:secret-key MaxConnections:100} Goroutine 1 获取配置: &{DatabaseURL:localhost:5432 APIKey:secret-key MaxConnections:100} Goroutine 2 获取配置: &{DatabaseURL:localhost:5432 APIKey:secret-key MaxConnections:100} Goroutine 3 获取配置: &{DatabaseURL:localhost:5432 APIKey:secret-key MaxConnections:100} Goroutine 4 获取配置: &{DatabaseURL:localhost:5432 APIKey:secret-key MaxConnections:100} 所有goroutine都获取到了配置
说明:
sync.Once确保某个操作只执行一次Do()方法中的函数只会执行一次,即使被多个goroutine调用|存款 100,当前余额: 1100 存款 100,当前余额: 1200 取款 50,当前余额: 1150 存款 100,当前余额: 1250 取款 50,当前余额: 1200 存款 100,当前余额: 1300 取款 50,当前余额: 1250 存款 100,当前余额: 1350 取款 50,当前余额: 1300 存款 100,当前余额: 1400 取款 50,当前余额: 1350 最终余额: 1350
说明:
balance的操作都需要加锁Deposit和Withdraw都需要保护,因为它们都会修改余额GetBalance也需要加锁,确保读取的是最新值defer确保锁一定会被释放