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

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

一、并发问题

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

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

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

二、临界区

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

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

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

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

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

三、Mutex

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

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

同步原语的使用场景:

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

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

go 复制代码
type Locker interface {
    Lock()
    Unlock()
}

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

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

go 复制代码
 func (m *Mutex) Lock()
 func (m *Mutex) Unlock()

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

举个例子:

go 复制代码
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执行上述代码,则会输出警告信息。

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

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

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

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

go 复制代码
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结构体中使用,例如:

go 复制代码
type Count struct {
    sync.Mutex
    Count uint64
}

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

go 复制代码
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)
}

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

go 复制代码
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

相关推荐
程序员爱钓鱼12 分钟前
Rust const泛型详解:让常量也成为泛型参数
后端·面试·rust
evans在进步38 分钟前
Spring Boot 工程化核心详解:Parent、Starter、热部署、事务与多数据源
spring boot·后端·python
宫水三叶的刷题日记1 小时前
馋猫外卖:AI 让字节式试错没有天花板
后端
SomeB1oody2 小时前
【RustyML入门】7.4. 按需裁剪与模块化集成
开发语言·后端·机器学习·rust·教程
罗超驿2 小时前
SpringBoot 快速入门
java·spring boot·后端
卷无止境3 小时前
除了开发api,FastAPI其实也可以配合jinja2模板写页面
后端·python·fastapi
newerp3 小时前
Redis 操作与缓存策略
后端·程序员·go
newerp3 小时前
GORM ORM 基础
后端·程序员·go
小岛前端3 小时前
AI Skills 已经封神,但新的问题却越来越严重!
前端·后端·github
拖孩3 小时前
一个人 + AI 做的小程序,上线 15 天赚了 10 块 5
前端·后端·微信小程序