首页 > 其他分享 >Go语言精进之路读书笔记第35条——了解sync包的正确用法

Go语言精进之路读书笔记第35条——了解sync包的正确用法

时间:2024-02-25 11:22:56浏览次数:26  
标签:log 读书笔记 goroutine sync 35 Mutex func var

Go语言通过标准库的sync包提供了针对传统基于共享内存并发模型的基本同步原语。

35.1 sync包还是channel

在下面一些场景下,我们依然需要sync包提供的低级同步原语

  • (1) 需要高性能的临界区同步机制场景
  • (2) 不想转移结构体对象所有权,但又要保证结构体内部状态数据的同步访问的场景。基于channel的并发,需要在goroutine间通过channel转移数据对象的所有权。只有拥有数据对象所有权(从channel接收到该数据)的goroutine才可以对该数据对象进行状态变更。

sync.Mutex和channel各自实现的临界区同步机制的一个简单性能对比,sync.Mutex实现的同步机制的性能要比channel实现的高出两倍多

var cs = 0 // 模拟临界区要保护的数据
var mu sync.Mutex
var c = make(chan struct{}, 1)

func criticalSectionSyncByMutex() {
    mu.Lock()
    cs++
    mu.Unlock()
}

func criticalSectionSyncByChan() {
    c <- struct{}{}
    cs++
    <-c
}

func BenchmarkCriticalSectionSyncByMutex(b *testing.B) {
    for n := 0; n < b.N; n++ {
        criticalSectionSyncByMutex()
    }
}

func BenchmarkCriticalSectionSyncByChan(b *testing.B) {
    for n := 0; n < b.N; n++ {
        criticalSectionSyncByChan()
    }
}

35.2 使用sync包的注意事项

sync包中定义的结构类型首次使用后不应对其进行复制操作

g3阻塞在加锁操作上了,而g2则按预期正常运行

  • g2是在互斥锁首次使用之前创建的
  • g3则是在互斥锁执行完加锁操作并处于锁定状态之后创建的,并且创建g3的时候复制了foo的实例(包含sync.Mutes的实例),并在之后使用了这个副本
type foo struct {
    n int
    sync.Mutex
}

func main() {
    f := foo{n: 17}

    go func(f foo) {
        for {
            log.Println("g2: try to lock foo...")
            f.Lock()
            log.Println("g2: lock foo ok")
            time.Sleep(3 * time.Second)
            f.Unlock()
            log.Println("g2: unlock foo ok")
        }
    }(f)

    f.Lock()
    log.Println("g1: lock foo ok")

    // 在mutex首次使用后复制其值
    go func(f foo) {
        for {
            log.Println("g3: try to lock foo...")
            f.Lock()
            log.Println("g3: lock foo ok")
            time.Sleep(5 * time.Second)
            f.Unlock()
            log.Println("g3: unlock foo ok")
        }
    }(f)

    time.Sleep(1000 * time.Second)
    f.Unlock()
    log.Println("g1: unlock foo ok")
}

分析:

  • Mutex由state和sema两个字段组成
    • state int32:表示当前互斥锁的状态
    • sema uint32:用于控制锁状态的信号量
  • 对Mutex实例的复制即是对两个整型字段的复制,初始状态下Mutex实例处于Unlocked状态,state和sema均为0
  • g2复制了处于初始状态的Mutex实例,副本的state和sema均为0,这与g2自定义一个新的Mutex实例无异,因此g2后续可以按预期正常运行
  • 后续主程序调用了Lock方法,Mutex实例变为Locked状态,state字段值为sync.mutex-Locked
  • g3复制了处于Locked状态的Mutex实例,副本的state为sync.mutex-Locked,因此g3再对其实例副本调用Lock方法将会导致进入阻塞状态(也就是死锁状态,因为没有任何其他机会再调用该副本的Unlock方法了,并且Go不支持递归锁)
  • 总结
    • sync包中类型的实例在首次使用后被复制得到的副本一旦再被使用将导致不可预期的结果
    • 在使用sync包中类型时,推荐通过闭包方式或传递类型实例(或包裹该类型的类型实例)的地址或指针的方式进行

35.3 互斥锁还是读写锁

  • 在并发量较小的情况下,互斥锁性能更好;随着并发量增大,互斥锁的竞争激烈,导致加锁和解锁性能下降
  • 读写锁的读锁性能并未随并发量的增大而发生较大变化,性能始终恒定在40ns左右
  • 在并发量较大的情况下,读写锁的写锁性能比互斥锁、读写锁的读锁都差,并且随着并发量增大,其写锁性能有继续下降的趋势
  • 总结:读写锁适合应用在具有一定并发量且读多写少的场合
var cs1 = 0 // 模拟临界区要保护的数据
var mu1 sync.Mutex
var cs2 = 0 // 模拟临界区要保护的数据
var mu2 sync.RWMutex

func BenchmarkReadSyncByMutex(b *testing.B) {
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            mu1.Lock()
            _ = cs1
            mu1.Unlock()
        }
    })
}

func BenchmarkReadSyncByRWMutex(b *testing.B) {
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            mu2.RLock()
            _ = cs2
            mu2.RUnlock()
        }
    })
}

func BenchmarkWriteSyncByRWMutex(b *testing.B) {
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            mu2.Lock()
            cs2++
            mu2.Unlock()
        }
    })
}

35.4 条件变量

一个条件变量可以理解为一个容器,这个容器中存放着一个或者一组等待着某个条件成立的goroutine。当条件成立时,这些处于等待状态的goroutine将得到通知并被唤醒以继续后续的工作。

如果没有条件变量,我们可能需要在goroutine中通过连续轮询的方式检查是否满足条件。连续轮询非常消费资源,因为goroutine在这个过程中处于活动状态但其工作并无进展。下面是用sync.Mutex实现对条件的轮询等待的例子。

type signal struct{}

var ready bool

func worker(i int) {
    fmt.Printf("worker %d: is working...\n", i)
    time.Sleep(1 * time.Second)
    fmt.Printf("worker %d: works done\n", i)
}

func spawnGroup(f func(i int), num int, mu *sync.Mutex) <-chan signal {
    c := make(chan signal)
    var wg sync.WaitGroup

    for i := 0; i < num; i++ {
        wg.Add(1)
        go func(i int) {
            for {
                mu.Lock()
                if !ready {
                    mu.Unlock()
                    time.Sleep(100 * time.Millisecond)
                    continue
                }
                mu.Unlock()
                fmt.Printf("worker %d: start to work...\n", i)
                f(i)
                wg.Done()
                return
            }
        }(i + 1)
    }

    go func() {
        wg.Wait()
        c <- signal(struct{}{})
    }()
    return c
}

func main() {
    fmt.Println("start a group of workers...")
    mu := &sync.Mutex{}
    c := spawnGroup(worker, 5, mu)

    time.Sleep(5 * time.Second) // 模拟ready前的准备工作
    fmt.Println("the group of workers start to work...")

    mu.Lock()
    ready = true
    mu.Unlock()

    <-c
    fmt.Println("the group of workers work done!")
}

sync.Cond为goroutine在上述场景下提供了另一种可选的、资源消耗更小、使用体验更佳的同步方式。下面是使用sync.Cond改造后的例子。

type signal struct{}

var ready bool

func worker(i int) {
    fmt.Printf("worker %d: is working...\n", i)
    time.Sleep(1 * time.Second)
    fmt.Printf("worker %d: works done\n", i)
}

func spawnGroup(f func(i int), num int, groupSignal *sync.Cond) <-chan signal {
    c := make(chan signal)
    var wg sync.WaitGroup

    for i := 0; i < num; i++ {
        wg.Add(1)
        go func(i int) {
            groupSignal.L.Lock()
            for !ready {
                groupSignal.Wait()
            }
            groupSignal.L.Unlock()
            fmt.Printf("worker %d: start to work...\n", i)
            f(i)
            wg.Done()
        }(i + 1)
    }

    go func() {
        wg.Wait()
        c <- signal(struct{}{})
    }()
    return c
}

func main() {
    fmt.Println("start a group of workers...")
    groupSignal := sync.NewCond(&sync.Mutex{})
    c := spawnGroup(worker, 5, groupSignal)

    time.Sleep(5 * time.Second) // 模拟ready前的准备工作
    fmt.Println("the group of workers start to work...")

    groupSignal.L.Lock()
    ready = true
    groupSignal.Broadcast()
    groupSignal.L.Unlock()

    <-c
    fmt.Println("the group of workers work done!")
}

分析:

  • sync.Cond初始化需要一个满足实现了sync.Lock接口的类型实例,通常使用sync.Mutex
  • 条件变量需要这个互斥锁来同步临界区,保护用作条件的数据
  • 各个等待条件成立的goroutine在加锁后判断条件是否成立,如果不成立,则调用sync.Cond的Wait方法进入等待状态。Wait方法在goroutine挂起前会进行Unlock操作
  • 调用sync.Cond的Broadcast方法后,各个阻塞的goroutine将被唤醒并从Wait方法中返回
  • 在Wait方法返回前,Wait方法会再次加锁让goroutine进入临界区,接下来goroutine会再次对条件数据进行判定,如果条件成立,则解锁并进入下一个工作阶段;如果条件依旧不成立,那么再次调用Wait方法挂起等待

35.5 使用sync.Once实现单例模式

sync.Once保证任意一个函数在程序运行期间只被执行一次,常用于初始化和资源清理,避免重复执行初始化或资源关闭操作

type Foo struct {
}

var once sync.Once
var instance *Foo

func GetInstance(id int) *Foo {
    defer func() {
        if e := recover(); e != nil {
            log.Printf("goroutine-%d: caught a panic: %s", id, e)
        }
    }()
    log.Printf("goroutine-%d: enter GetInstance\n", id)
    once.Do(func() {
        instance = &Foo{}
        time.Sleep(3 * time.Second)
        log.Printf("goroutine-%d: the addr of instance is %p\n", id, instance)
        panic("panic in once.Do function")
    })
    return instance
}

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(i int) {
            inst := GetInstance(i)
            log.Printf("goroutine-%d: the addr of instance returned is %p\n", i, inst)
            wg.Done()
        }(i + 1)
    }
    time.Sleep(5 * time.Second)
    inst := GetInstance(0)
    log.Printf("goroutine-0: the addr of instance returned is %p\n", inst)

    wg.Wait()
    log.Printf("all goroutines exit\n")
}

分析:

  • once.Do会等待f执行完毕后才返回,这期间其他执行once.Do函数的goroutine将会阻塞等待
  • Do函数返回后,后续的goroutine再执行Do函数将不再执行f并立即返回
  • 即便在函数f中出现panic,sync.Once原语也会认为once.Do执行完毕,后续对once.Do的调用将不再执行f

35.6 使用sync.Pool减轻垃圾回收压力

sync.Pool是一个对象缓存池,有如下特点

  • 它是goroutine并发安全的,可以被多个goroutine同时使用
  • 放入该缓存池中的数据对象的生命是暂时的,随时都可能被垃圾回收掉
  • 缓存池中的数据对象是可以重复利用的,这样可以在一定程度上降低数据对象重新分配的频度,减轻GC压力
  • sync.Pool为每个P(GMP中的P)单独建立一个local缓存池,进一步降低高并发下对锁的争抢

通过sync.Pool来复用数据对象的方式可以有效降低内存分配频率,减轻垃圾回收压力,从而提高性能

var bufPool = sync.Pool{
    New: func() interface{} {
        return new(bytes.Buffer)
    },
}

func writeBufFromPool(data string) {
    b := bufPool.Get().(*bytes.Buffer)
    b.Reset()
    b.WriteString(data)
    bufPool.Put(b)
}
func writeBufFromNew(data string) *bytes.Buffer {
    b := new(bytes.Buffer)
    b.WriteString(data)
    return b
}

func BenchmarkWithoutPool(b *testing.B) {
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        writeBufFromNew("hello")
    }
}

func BenchmarkWithPool(b *testing.B) {
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        writeBufFromPool("hello")
    }
}

典型应用:建立像bytes.Buffer这样类型的临时缓存对象池

var bufPool = sync.Pool{
    New: func() interface{} {
        return new(bytes.Buffer)
    },
}

可能带来的问题:

  • sync.Pool的Get方法从缓存池中挑选bytes.Buffer数据对象时并未考虑数据对象是否满足调用者的需求
  • 可能出现bytes.Buffer刚扩容,但被长期用于处理小数据,导致扩容后的大内存长时间得不到释放,给应用带来沉重的内存消耗负担

解决方式:

  • (1) 限制要放回缓存池中的数据对象大小,参考Go标准库fmt包的代码if cap(p.buf) > 64<<10 {issue#23199
  • (2) 建立多级缓存池,标准库的http包在处理http2数据时,预先建立了多个不同大小的缓存池,这样就可以根据要处理的数据的大小从最合适的缓存池中获取Buffer对象,并在完成数据处理后将对象归还到对应的池中,避免大材小用、浪费内存的情况

标签:log,读书笔记,goroutine,sync,35,Mutex,func,var
From: https://www.cnblogs.com/brynchen/p/18032185

相关文章

  • 35W的锐龙7 8700GE APU首测:性能、功耗、温度都神了!
    AMD即将发布新的锐龙8000GEAPU系列,热设计功耗从65W降至35W,现在顶级型号锐龙78700GE的跑分出来了,很惊喜。锐龙78700GE的基本规格不变,都是8个Zen4CPU核心、12个RDNA3GPU核心,基准频率从4.2GHz降至3.65GHz左右,加速频率不详,但按惯例不会损失太多。样品最高频率为5.1GHz,内存频率......
  • 《程序是怎样跑起来的》第8章读书笔记
    了解了源文件,就要了解怎样执行文件。首先用某种编程语言编写的程序称为源代码,将源代码保存成一个文件就称为源文件源代码是不能直接运行的,因为CPU能直接解释和执行的,只有本机代码,所以必须翻译成本机代码才能被CPU理解和执行。而windows的exe文件中的程序内容就是本机代码转组是指......
  • Go语言精进之路读书笔记第34条——了解channel的妙用
    c:=make(chanint)//创建一个无缓冲(unbuffered)的int类型的channelc:=make(chanint,5)//创建一个带缓冲的int类型的channelc<-x//向channelc中发送一个值<-c//从channelc中接收一个值x=<-c//从channelc接收一个值并......
  • 《程序是怎样跑起来的》第7章读书笔记
    第7章就把重点放到了这本书程序是怎么跑起来的重点上,但同时也难理解了许多。我们知道的是程序要在特定的运行环境上才能运行,而运行环境等于操作系统加硬盘,每个程序都有其对应的运行环境操作系统和硬件决定了程序的运行环境,还需要知道的是,在将硬件作为程序运行环境考虑是CPU的类型......
  • 《程序是怎样跑起来的》第6章读书笔记
    前面讲述了内存跟磁盘,而内存跟磁盘里面的储存量也是有限的,那么我们就需要去压缩数据,而数据该怎么压缩呢?第6章就为我们介绍了。首先要了解文件中储存数据的格式文件是在磁盘等储存媒体中储存数据的一种形式,程序是以字节为单位向文件中储存数据的储存在文件中的数据。如果表示字符,那......
  • Go语言精进之路读书笔记第33条——掌握Go并发模型和常见并发模式
    不要通过共享内存来通信,而应该通过通信来共享内存。——RobPike33.1Go并发模型CSP(CommunicatingSequentialProcess,通信顺序进程)模型。一个符合CSP模型的并发程序应该是一组通过输入/输出原语连接起来的P的集合。Go始终推荐以CSP模型风格构建并发程序。Go针对CSP模型提供......
  • 《程序是怎么跑起来的》第5章读书笔记
    第4张介绍了内存那么第5张就是磁盘。在开篇告诉了我们内存只主存而磁盘主要指硬盘。计算机中的储存器包括内存和磁盘储存在磁盘中的程序需要先加载到内存才能运行,不能在磁盘上直接运行。内存与磁盘的联系是非常密切的。第1个体现是磁盘缓存。磁盘缓存是一块内存空间,用于临时存放从......
  • 《程序是怎么跑起来的》第4章读书笔记
    计算机是处理数据的机器,而处理对象的数据储存在内存和磁盘中。内存本质上是一种名为内存芯片的装置,内存芯片分为ram,rom等不同类型,但从外部来看,它们的基本原理是相同的内存芯片外部有引脚负责连接电源以及输入地址信号等等。内存芯片内部有很多能储存巴比特数据的容器,只要指定容器......
  • 《程序是怎么跑起来的》第3章读书笔记
    经过前两章对计算机内容最基本的理解之后,就迎来了对计算机的计算,而计算机也不是万能的,它也会出现错误,那么就涉及到计算机在计算小数时会出现错误的原因,首先课题通过一个问题将0.1累加100次的结果不是10这一话题成功将读者引入进去。然后告诉了我们为什么在计算机中会这样子出错的......
  • 迅为RK3568开发板驱动开发指南-输入子系统
     《iTOP-RK3568开发板驱动开发指南》更新,本次更新内容对应的是驱动(第十三篇输入子系统)视频,帮助用户快速入门,大大提升研发速度。 第13篇-输入子系统目录第1篇驱动基础篇第2篇字符设备基础第3篇并发与竞争第4篇高级字符设备进阶第5篇中断第6篇平台总线第7篇设备树......