Go语言 互斥锁

导言

  • 原文链接: Part 25: Mutex
  • If translation is not allowed, please leave me in the comment area and I will delete it as soon as possible.

互斥锁

在这一部分,我们将讨论一下互斥锁。在之后,我们也会讨论:如何使用互斥锁和通道,去解决竞态条件。

竞态条件的英文是 race condition

临界区 (critical section)

在讨论互斥锁前,我们首先得理解,在并发编程中 临界区 的概念。
当程序并发运行时,修改共享资源的代码 不能同时 被多个协程执行。这一段修改共享资源的代码,被叫做临界区。

举个例子,下面有一段代码,它能让 x 增加 1

x = x + 1

单个协程执行这段代码时,并不会出现任何问题。

接下来,我们来看看,当协程并发时,为什么这段代码会出现错误。简单起见,我们假设这只有 2 个协程并发执行上面的代码。

对于上面的代码,系统将会通过下列几个步骤执行它。(这涉及到了一些附加知识,比如寄存器、加法器工作原理…但为了简单起见,我们假设该指令执行只需 3 个步骤)

  1. 获取 x 的当前值
  2. 计算 x + 1
  3. 将第 2 步的计算结果赋值给 x

当只有 1 个协程执行上面的 3 个步骤时,这完全没问题。

接下来,我们来讨论 2 个协程并发执行的情况。下图描绘了 2 个协程并发执行时,可能出现的一种情况。

在这里插入图片描述

我们假设 x 的初始值为 0。刚开始,协程1 获得了 x 的初始值,并计算 x + 1,在把该值赋给 x 前,系统的上下文切换给了 协程2。此时,协程 2 获得了 x 的初始值 — 此时 x 还为 0,计算 x + 1,之后系统上下文又切换回 协程1。现在,协程1 把计算所得的值 1 赋给了 x,因此,此时 x 变为了 1。之后,协程2 重新开始运行,它也把计算所得的值 1 分配给 x,此时 x 依旧等于 1。因此,两个协程执行后,x 的值为 1

接下来,我们来看看另外一种可能发生的情况。

在这里插入图片描述

在上面的情况中,协程1 开始运行并完成 3 个步骤,此时, x 的值为 1。之后,协程2 开始执行,也完成了 3 个步骤。最终,x 的值为 2

通过上面的两种情况,你就可以看出: x 的值可能是 1,也可能是 2,这取决于上下文的切换。
这一类无法确定的情况,被叫做竞态条件。(之所以无法确定,是因为程序的输出取决于协程的执行顺序)

在上面的情况中,竞态条件其实是可以避免的 — 在任意时间点,如果我们只允许一个协程执行临界区的代码,我们就能避免竞态条件。为了实现这个目的,我们可以使用 互斥锁

互斥锁介绍

互斥锁提供了一个锁机制,这个机制保证:在任意时间点,只有一个协程运行临界区的代码,从而避免竞态条件发生。

互斥锁的英文是:mutex

Mutex结构 位于 Go语言 的 sync包。Mutex 定义了 2 个操作: LockUnlock。在任意时间点,在 LockUnlock 之间的代码,都只能被一个协程执行,从而避免竞态条件。

mutex.Lock()  
x = x + 1  
mutex.Unlock()  

上面的代码可以保证:在任意时间点,只有一个协程执行x = x + 1,从而避免竞态条件。

原理:如果 协程a 已经获得了锁,而 协程b 想要获得这个锁,那么 协程b 将会阻塞,直到该锁解锁。

具有竞态条件的程序

在这一节,我们将写一个具有竞态条件的程序,而在下一节,我们将修复它。

package main  
import (  
    "fmt"
    "sync"
    )
var x  = 0  
func increment(wg *sync.WaitGroup) {  
    x = x + 1
    wg.Done()
}
func main() {  
    var w sync.WaitGroup
    for i := 0; i < 1000; i++ {
        w.Add(1)        
        go increment(&w)
    }
    w.Wait()
    fmt.Println("final value of x", x)
}

在上面的程序中,第 7 行的 increment函数 将 x 的值增加 1,并调用 Done函数 — 表示已经完成任务。在第 15 行,我们创建了 1000 个协程。协程们是并发的,于是,这将会产生竞态条件,因为有多个协程访问 x = x + 1,这是一段临界区代码。

在本地运行这段代码,你会发现结果不是确定的 — 因为出现了竞态条件。我自己运行的结果如下:

  1. 第一次: final value of x 941
  2. 第二次: final value of x 928
  3. 第三次: final value of x 922

使用互斥锁解决竞态条件

在上面的程序中,我们创建了 1000 个协程。如果每次 x 都增长 1,最终 x 应该是 1000。在这一节,我们将使用互斥锁,去解决存在的竞态条件。

package main  
import (  
    "fmt"
    "sync"
    )
var x  = 0  
func increment(wg *sync.WaitGroup, m *sync.Mutex) {  
    m.Lock()
    x = x + 1
    m.Unlock()
    wg.Done()   
}
func main() {  
    var w sync.WaitGroup
    var m sync.Mutex
    for i := 0; i < 1000; i++ {
        w.Add(1)        
        go increment(&w, &m)
    }
    w.Wait()
    fmt.Println("final value of x", x)
}

Mutex 是一个结构体类型,在第 15 行,我们创建了一个 Mutex形对象m。在上面的程序中,我们已经改变了increment函数,使 x = x + 1 位于 m.Lock()m.Unlock() 之间。此时,这段代码就不会出现竞态条件了,因为在任意时刻,只有一个协程能执行临界区代码 — x = x + 1

运行这个程序,它会输出:

final value of x 1000  

注意:在第 18 行,我们传递的是 m 的指针,这是因为:假如我们传递的是 m 的值,那么每个协程都将拥有一份 m 的拷贝,这会使得竞态条件依旧存在。

使用通道解决竞态条件

我们也使用通道来解决竞态条件。我们看看怎么做。

package main  
import (  
    "fmt"
    "sync"
    )
var x  = 0  
func increment(wg *sync.WaitGroup, ch chan bool) {  
    ch <- true
    x = x + 1
    <- ch
    wg.Done()   
}
func main() {  
    var w sync.WaitGroup
    ch := make(chan bool, 1)
    for i := 0; i < 1000; i++ {
        w.Add(1)        
        go increment(&w, ch)
    }
    w.Wait()
    fmt.Println("final value of x", x)
}

在上面的程序中,我们创建了一个有缓冲的通道,容量为1。在第 18 行,它被传递给 increment函数。使用这个有缓冲的通道,我们可以保证:在任意时间点,只有 1 个协程访问临界区代码 — x = x + 1
我们的做法是,在增加 x 前,我们将 true 传入通道。因为这个通道的容量是 1,所以如果有其他的协程,它们想向通道写入数据,它们都会阻塞,直到通道内的数据被读出。实际上,这个方法也可以保证只有一个协程位于临界区。

程序也会输出:

final value of x 1000 

互斥锁 vs 通道

通过分别使用互斥锁和通道,我们解决了竞态条件。那我们在解决竞态条件时,要选择哪个呢?
这取决于你要解决的问题是什么。如果你要解决的问题更适用于互斥锁,那你就用互斥锁吧,不用犹豫。如果你要解决的问题更适用于通道,那你就用通道。

大多数的 Go语言 新手,倾向于使用通道解决所有并发问题,因为他们认为:通道是 Go语言 中一个很棒的特性,这其实是错误的。Go语言 为我们提供了互斥锁和通道的选项,我们选择任意一个都没有错。

一般来说,当协程们需要通信时,这时可以采用通道,当协程们要访问临界区时,这时可以使用互斥锁。

对于我们上面解决的问题,我更倾向于使用互斥锁,因为此时协程们并不需要通信,互斥锁是很自然的选择。

我的建议是:选择合适的工具解决问题,而不是被工具本身所束缚。

源码透析

通道其实内置了一个互斥锁,通道结构如下:

// 文件位于: $GOROOT/src/runtime/chan.go

type hchan struct {
	// ....
	lock mutex
}

原作者留言

优质内容来之不易,您可以通过该 链接 为我捐赠。

最后

感谢原作者的优质内容。

这是我的第三次翻译,欢迎指出文中的任何错误。

  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值