golang 并发同步(sync 库)-- 单例模式的实现(六)


在前面章节 golang 并发–goroutine(四)我们讲过 golang 是天生支持高并发的语言。那么有并发必然涉及到线程安全的问题,为了防止多个 go 程同时操作同一个临界资源,我们必然需要引入锁,golang 内置库 sync 就是为我们提供锁的操作方法的。这篇文章我们通过一些例子来看一看 sync 库怎么用。

golang 单例模式

单例模式是常见的一种设计模式,学习过 java 的伙伴们应该知道单例模式可以保障在一个程序中一个类只实例化一个对象。在 golang 中没有类和对象的概念,但是存在类似的概念 struct (结构体),golang 单的例模式的就是保证一个结构体只能实例化出一个变量。

下面我们就先通过实现一个 golang 单例模式来学习 sync 库的用法。

非线程安全的单例模式

package main

import (
    "fmt"
    "sync"
    "time"
)

type testStruct struct {
    num int
}

var mu sync.Mutex
var instance *testStruct
var num int = 0

func NewTestSturct() *testStruct {
    if instance == nil {
        num++
        time.Sleep(500 * time.Millisecond)
        instance = &testStruct{num: num}
    }
    return instance
}

func createAndCheckSingleton() {
    ins := NewTestSturct()
    fmt.Printf("ins 的地址为 %p, ins 的 num 属性值期望为 1,实际为 %d\n", ins, ins.num)
}

func main() {
    go createAndCheckSingleton()
    go createAndCheckSingleton()
    time.Sleep(1 * time.Second)
}

上面代码看似没问题,但是我们执行一下,看输出结果:

ins 的地址为 0xc000184000, ins 的 num 属性值期望为 1,实际为 2
ins 的地址为 0xc000094000, ins 的 num 属性值期望为 1,实际为 2

我们并不能得到我们期望的输出,这是因为 NewTestSturct 函数比较耗时,在 testStruct 还没完全初始化时别的 go 程再次调用了这个函数。

注意:上面两个 time.Sleep,第一个 time.Sleep 是为了模拟耗时长的临界资源操作,第二个 time.Sleep 是为了防止主 go 程过早退出。

加锁的单例模式

为了解决上面的问题我们可以用 sync.Mutex 在执行 NewTestSturct 时加锁,看下面代码:

package main

import (
    "fmt"
    "sync"
    "time"
)

type testStruct struct {
    num int
}

var mu sync.Mutex
var instance *testStruct
var num int = 0

func NewTestSturct() *testStruct {
    mu.Lock()
    defer mu.Unlock()
    if instance == nil {
        num++
        time.Sleep(500 * time.Millisecond)
        instance = &testStruct{num: num}
    }
    return instance
}

func createAndCheckSingleton() {
    ins := NewTestSturct()
    fmt.Printf("ins 的地址为 %p, ins 的 num 属性值期望为 1,实际为 %d\n", ins, ins.num)
}

func main() {
    go createAndCheckSingleton()
    go createAndCheckSingleton()
    time.Sleep(1 * time.Second)
}

输出结果:

ins 的地址为 0xc000180000, ins 的 num 属性值期望为 1,实际为 1
ins 的地址为 0xc000180000, ins 的 num 属性值期望为 1,实际为 1

通过加锁的方式,确保了 NewTestSturct 不会在多个 go 程中并发运行,这次我们得到了我们想要的结果。

sync.Once 更优雅的方式实现单例模式

单例模式就是结构体的初始化只被执行一次,其实 golang 的 sync.Once 已经为我们实现了这个机制,下面直接看代码:

package main

import (
    "fmt"
    "sync"
    "time"
)

type testStruct struct {
    num int
}

var once sync.Once
var instance *testStruct
var num int = 0

func NewTestSturct() *testStruct {
    once.Do(func() {
        num++
        time.Sleep(500 * time.Millisecond)
        instance = &testStruct{num: num}
    })
    return instance
}

func createAndCheckSingleton() {
    ins := NewTestSturct()
    fmt.Printf("ins 的地址为 %p, ins 的 num 属性值期望为 1,实际为 %d\n", ins, ins.num)
}

func main() {
    go createAndCheckSingleton()
    go createAndCheckSingleton()
    time.Sleep(1 * time.Second)
}

sync.Once.Do 的参数是一个函数,对于同一个 sync.Once 的变量 once 无论调用多少次 once.Do 只有第一次调用是执行了其参数传递进来的函数的。

输出结果:

ins 的地址为 0xc000100000, ins 的 num 属性值期望为 1,实际为 1
ins 的地址为 0xc000100000, ins 的 num 属性值期望为 1,实际为 1

更优雅的方式防止主 go 程提前退出

在上面的例子中我使用了 time.Sleep 来防止主 go 程在其他 go 程还没运行完的情况下提前结束,其实这是一种非常非常笨拙的方式,我们没法准确的把握需要 sleep 的时间,强烈建议大家不要在正式代码中这么使用。

接下面我们介绍一个优雅的方法 sync.WaitGroup,我们直接看示例代码:

package main

import (
    "fmt"
    "sync"
    "time"
)

type testStruct struct {
    num int
}

func testWaitGroup(wg *sync.WaitGroup) {
    defer wg.Done()
    time.Sleep(1 * time.Second)
    fmt.Println("子 go 程已经执行完毕,我的父 go 程可以退出了")
}

func main() {
    var wg sync.WaitGroup = sync.WaitGroup{}
    wg.Add(1)
    go testWaitGroup(&wg)
    wg.Wait()
}

输出结果:

 go 程已经执行完毕,我的父 go 程可以退出了

看见没,这里我们就没用 sleep,而且父 go 程也能及时的知道子 go 程执行完成了。但是在使用 sync.WaitGroup 时有两点需要特别注意

1、wg.Add(1) 这个语句如果放在子 go 程函数开始的位置可以吗?我们来直接看例子:

package main

import (
    "fmt"
    "sync"
    "time"
)

type testStruct struct {
    num int
}

func testWaitGroup(wg *sync.WaitGroup) {
    wg.Add(1)
    defer wg.Done()
    time.Sleep(1 * time.Second)
    fmt.Println("子 go 程已经执行完毕,我的父 go 程可以退出了")
}

func main() {
    var wg sync.WaitGroup = sync.WaitGroup{}
    go testWaitGroup(&wg)
    wg.Wait()
}

我们运行这个代码会发现它没有任何输出。这是怎么回事呢?答案就是wg.Add(1) 在子 go 程中根本就没有机会执行,因为在系统调度执行子 go 程前,其父 go 程就已经结束了。

2、函数 testWaitGroup 的参数能不使用指针吗?还是直接看例子:

package main

import (
    "fmt"
    "sync"
    "time"
)

type testStruct struct {
    num int
}

func testWaitGroup(wg sync.WaitGroup) {
    defer wg.Done()
    time.Sleep(1 * time.Second)
    fmt.Println("子 go 程已经执行完毕,我的父 go 程可以退出了")
}

func main() {
    var wg sync.WaitGroup = sync.WaitGroup{}
    wg.Add(1)
    go testWaitGroup(wg)
    wg.Wait()
}

输出结果:

子 go 程已经执行完毕,我的父 go 程可以退出了
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [semacquire]:
sync.runtime_Semacquire(0xc000016180?)
        /usr/local/go/src/runtime/sema.go:56 +0x25
sync.(*WaitGroup).Wait(0x0?)
        /usr/local/go/src/sync/waitgroup.go:136 +0x52
main.main()
        /root/yjfwk/testgo/main.go:23 +0x7d
exit status 2

可以看到在子 go 程执行结束后,程序抛出了 deadlock 的异常。这是为什么呢?其实答案就隐藏在我之前的文章中:golang 函数参数传递–指针,引用和值(二)

因为 sync.WaitGroup 是一个结构体,结构体是一个值类型的变量,在函数参数传递中值类型的变量会发送 copy,也就是说不使用指针子 go 程和父 go 程的的 wg 已经不是同一个了,在子 go 程中执行 wg.Done() 并不会影响到其父 go 程,因此子 go 程退出后父 go 程还继续尝试等待但是系统系统检测到没有其他 go 程了等下去是无意义的,所以就抛出了异常。

sync.Cond–golang 指挥家

我们应该注意到一个特点 golang 创建要给新的 go 程没有任何返回,这里多进程和多线程不一样,以多进程为例,父进程调用 fork 函数创建子进程时,父进程会得到子进程的进程号,后续父进程就可以通过这个进程号控制子进程了。golang 并没有这样的机制,但是 golang 有很多其他办法到达 go 程相互影响的效果。sync.Cond 就是其中之一,Condconductor 的简写,直译为指挥家,从这里也可以大概猜出来 sync.Cond 作用,sync.Cond 有三个方法:WaitSignalBroadcast。调用 Wait 会使 go 程阻塞陷入等待直到收到其他go 程发出的信号;Signal 用于发送信号,如果有多个 go 程阻塞陷入等待,Signal 只会解除其中一个的等待状态;Broadcast 也是用于发送信号的,但是 Broadcast 发送的信号会被所有陷入阻塞的 go 程收到。下面我们直接看例子:

package main

import (
    "fmt"
    "sync"
    "time"
)

type testStruct struct {
    num int
}

func testWaitGroup(wg *sync.WaitGroup, conductor *sync.Cond) {
    defer wg.Done()
    conductor.L.Lock()
    defer conductor.L.Unlock()
    fmt.Printf("%s 等待信号\n", time.Now().Format("2006-01-02 15:04:05"))
    conductor.Wait()
    fmt.Printf("%s 收到信号\n", time.Now().Format("2006-01-02 15:04:05"))
}

func main() {
    var wg sync.WaitGroup
    conductor := sync.NewCond(&sync.Mutex{})
    wg.Add(3)
    go testWaitGroup(&wg, conductor)
    go testWaitGroup(&wg, conductor)
    go testWaitGroup(&wg, conductor)
    time.Sleep(5 * time.Second)
    conductor.Broadcast()
    wg.Wait()
}

输出结果:

2022-09-02 11:52:26 等待信号
2022-09-02 11:52:26 等待信号
2022-09-02 11:52:26 等待信号
2022-09-02 11:52:31 收到信号
2022-09-02 11:52:31 收到信号
2022-09-02 11:52:31 收到信号

读者可以自己吧 conductor.Broadcast() 换成 conductor.Signal() 试一试。

注意:和 sync.WaitGroup 一样 sync.Cond 在初始化后不能被 copy,因此作为函数参数时只能用指针。

sync 中的其他方法

  • sync.RWMutex 是在 sync.Mutex 基础上进行扩展,把锁分成了读锁和写锁:读锁可以被多个 go 程申请,go 程不会陷入阻塞,但是只要有一个 go 程持有了读锁,那么任意 go 程申请写锁都会陷入阻塞;写锁只能同时被一个 go 程持有,当有一个 go 程持有写锁,其他 go 程申请写锁或者读锁都会陷入阻塞。感觉比较容易理解,这里就不写代码演示了。

  • sync.Map 可以理解为是对 golang map 类型的一个扩展,它提供了几个方法保证了 sync.Map 是线程安全的,多个 go 程可以放心的对同一个 sync.Map 变量进行操作
    在这里插入图片描述

    上图这些方法也是比较好理解的,这里就不单独写示例了,如果读者其中的某些方法有疑惑,可以在评论区留言。

  • sync.Pool 这个在之前的文章中专门介绍过,其是用于减少系统 gc,提高系统性能的,感兴趣的读者可以参考:golang 内存那些事–如何快速分配内存,减少系统 GC (三)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值