likes
comments
collection
share

共享资源并发访问,sync.Mutex解决Data Race

作者站长头像
站长
· 阅读数 4

欢迎小伙伴们观看我的文章,若文中有不正确的地方,小伙伴们可以在评论区处指出,希望能够与小伙伴们一起成长与进步,谢谢!

一、并发问题

并发问题一直是老生常谈的问题,在许多的场景下,都会涉及到并发问题,例如竞态问题,当多个goroutine下并发访问并操作同一个资源,例如计数器,可能导致计数器不准确;在业务场景中,并发同时更新用户的账户信息,导致用户账户透支等。

如果没有对并发问题进行一定的控制,带来的后果可能会很严重,这些问题该如何解决?在Go中,可以使用Mutex

互斥锁是并发控制的一个常见基本手段,为了避免竞态问题,可以使用互斥锁来对临界区进行保护。

二、临界区

在并发编程中,如果某一块程序逻辑会被并发访问或修改,为了避免并发操作导致的无法预料的后果,这一部分程序逻辑需要被保护起来,这部分被保护的程序,即称为临界区。

简单来说,临界区其实就是被共享的资源,比如对数据库的访问、对某个共享数据结构的操作吗、对I/O设备的使用、对一个连接池中的连接调用等等,都可以看做是一个临界区。

在多个线程并发访问临界区造成的一些访问或操作错误,这并不是我们想看到的结果,因此使用互斥锁,限定临界区只能由一个线程持有,可以避免一些错误的发生。

使用互斥锁,当一个线程获取到互斥锁,持有临界区时,如果其他线程想要进入临界区,就会返回失败或者进行等待,直到持有临界区的线程退出临界区,释放互斥锁,这样等待的线程才能够获取到互斥锁,进入临界区。

共享资源并发访问,sync.Mutex解决Data Race

由上图可以看到,互斥锁通过限定临界区,能够很好解决资源访问等竞态问题。

三、Mutex

在Go标准库中,提供了Mutex来实现互斥锁。Mutex是使用最广泛的同步原语

同步原语是一组基本的、低级别的同步和通信机制。同步原语允许程序员在多线程或多协程的环境中安全地共享资源、同步操作和传递消息。

同步原语的使用场景:

  • 共享资源:并发情况下读写共享资源,会出现数据竞态的问题,在Go中可以使用互斥锁Mutex、读写锁RWMutex等并发原语来保护临界区。
  • 任务编排:多个goroutine按照一定的执行顺序执行,多个goroutine之间存在互相等待或者互相依赖的顺序关系,在Go中可以使用WaitGroup 或者 Channel 来实现任务编排。
  • 消息传递:不同的goroutine之间在某些场景下需要进行数据交流,在Go中可以使用Channel 来实现消息传递。

在Go中的sync包中提供了一系列的同步原语,其中Mutex实现了这个接口Locker接口,sync包中Locker接口的定义如下:

type Locker interface {
    Lock()
    Unlock()
}

Locker锁接口的方法集定义了两个方法,一个是获取锁(Lock)释放锁(Unlock)两个方法。而Mutex实现了该两个方法,即实现了Locker接口。

互斥锁Mutex实现了Lock方法与Unlock方法,其基本使用为:进入临界区之前调用 Lock 方法,退出临界区的时候调用 Unlock 方法;

 func (m *Mutex) Lock()
 func (m *Mutex) Unlock()

当某个goroutine调用互斥锁MutexLock方法,获取到互斥锁时,其他goroutine请求锁则会阻塞在Lock方法的调用上,直到获取锁的goroutine释放互斥锁,此时其他goroutine则可以获得互斥锁。

举个例子:

package main

import (
    "fmt"
    "sync"
)

var count = 0
var wg sync.WaitGroup

func incr() {
    defer wg.Done()
    for i := 0; i < 10000; i++ {
       count++
    }
}

func main() {
    wg.Add(10)
    for i := 0; i < 10; i++ {
       go incr()
    }
    wg.Wait()
    fmt.Println(count)
}

上述代码的执行结果打印,使用 sync.WaitGroup 来等待所有的 goroutine 执行完毕后,再输出最终的结果。预期结果为100000,但执行多次,结果却是例如54186、40948等不同的结果。

原因在于:count++并非原子操作,该操作其实分为几个步骤:

  • 读取count变量
  • 对变量值+1
  • 将结果保存到count

由于并非原子操作,则可能存在并发问题。例如,可能存在多个goroutine同时读取到了相同的count的值,并将其+1后将结果写回到count变量中,而此时并发goroutine之间可能会存在结果覆盖,从而导致count变量的结果不如预期。

Go 提供了一个检测并发访问共享资源是否有问题的工具:Go race detector。在代码运行的时候,race detector 就能监控到对共享变量的非同步访问,出现 race 的时候,就会打印出警告信息。

编译(compile)、测试(test)或者运行(run)Go 代码的时候,加上 race 参数,就有可能发现并发问题。例如上述代码,在控制台输入go run -race incr.go执行上述代码,则会输出警告信息。

共享资源并发访问,sync.Mutex解决Data Race

通过警告信息可以发现,goroutine 8对内存地址0x00000025e510进行读操作(代码中的第14行,即count++),goroutine 7对内存地址0x00000025e510进行写操作(代码中的第14行,即count++)。

race detector固然方便,但该工具只能在实际对内存地址进行读写访问的时候才能探测,并不能在编译的时候发现 data race 的问题,并且在运行时,只有触发了 data race 之后,才能检测到,如果碰巧没有触发,是检测不出来的。开启race 的程序部署在线上,会比较影响程序的性能。

上述例子存在数据竞争的问题,通过使用Mutex,可以轻松的解决数据竞争的问题。

首先,可以明确上述例子的共享资源为count变量,临界区为count++,只需要在临界区前,设置goroutine需要获取到互斥锁才能能够访问,在退出临界区时释放互斥锁,则可以解决数据竞争的问题。

package main

import (
    "fmt"
    "sync"
)

var count = 0
var wg sync.WaitGroup
var lock sync.Mutex

func incr() {
    defer wg.Done()
    for i := 0; i < 10000; i++ {
       lock.Lock()
       count++
       lock.Unlock()
    }
}

func main() {
    wg.Add(10)
    for i := 0; i < 10; i++ {
       go incr()
    }
    wg.Wait()
    fmt.Println(count)
}

多次执行上述代码,可以发现每次的执行结果都为100000,且没有出现data race的警告。

Mutex 的零值是还没有 goroutine 等待的未加锁的状态,具体来说,就是Mutex声明后,其零值表示该锁处于未加锁的状态,并且没有任何goroutine正在等待获取该锁。

Mutex不需要额外的初始化,直接声明变量(var mu sync.Mutex)即可使用。

Mutex还可以将其嵌入到struct结构体中使用,例如:

type Count struct {
    sync.Mutex
    Count uint64
}

上述结构体定义,通过匿名成员变量的方式将sync.Mutex嵌入到struct结构体中,初始化该结构体时,无需初始化Mutex成员变量即可使用,通过使用上述结构体,将例子改造如下:

package main

import (
    "fmt"
    "sync"
)

var wg sync.WaitGroup
var counter Counter

type Counter struct {
    sync.Mutex
    Count uint64
}

func incr() {
    defer wg.Done()
    for i := 0; i < 10000; i++ {
       counter.Lock()
       counter.Count++
       counter.Unlock()
    }
}

func main() {
    wg.Add(10)
    for i := 0; i < 10; i++ {
       go incr()
    }
    wg.Wait()
    fmt.Println(counter.Count)
}

同时,还可以通过为该结构体定义方法,将获取锁、释放锁、计数加一的逻辑封装成一个方法,对外不需要暴露锁等逻辑,改造后的代码如下:

package main

import (
    "fmt"
    "sync"
)

var wg sync.WaitGroup
var counter Counter

type Counter struct {
    sync.Mutex
    count uint64
}

// Incr 加1的方法,内部使用互斥锁保护
func (c *Counter) Incr() {
    c.Lock()
    c.count++
    c.Unlock()
}

// Count 得到计数器的值,也需要锁保护
func (c *Counter) Count() uint64 {
    c.Lock()
    defer c.Unlock()
    return c.count
}

// 操作方法
func operator() {
    defer wg.Done()
    for i := 0; i < 10000; i++ {
       counter.Incr()
    }
}

func main() {
    wg.Add(10)
    for i := 0; i < 10; i++ {
       go operator()
    }
    wg.Wait()
    fmt.Println(counter.Count())
}

上述代码中,获取Counter结构体的计数器值count也需要加锁的原因在于,在CPU上的写操作,不一定能够及时同步到另一个CPU核中,通过加锁的方式,来保证写操作完成后,才读取计数器值count

四、总结

在程序设计的过程中,并发问题一直是需要仔细考虑的问题,多线程下对于共享资源的操作,往往有可能会出现数据竞争的并发问题。计数器的例子展示出了数据竞争的并发问题,通过Go语言提供的Mutex互斥锁的同步原语来锁定临界区,解决数据竞争的并发问题,了解并使用Mutex