go语言并发编程(高级)

Posted 开源你我

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了go语言并发编程(高级)相关的知识,希望对你有一定的参考价值。

一.多核并行化

在执行一些昂贵的计算任务时,我们希望尽可能地利用现代服务器的多核特性来尽量将任务并行化,从而达到降低总计算时间的目的。在这种情况下,我们需要了解CPU核心数量。并针对性分解计算任务到多个goroutine中去并行运行。


案列:计算N个整型数的和,将所有整数分成M份,M即是CPU的个数,让每个CPU开始计算分给它的那份计算任务,最后将计算的结果再做一次累加,这样咱们就可以的到所有N个数的总和。


type Vector []float64


//分配给每个CPU的计算任务

func (v Vector) DoSome (i, n int, u Vector, c  chan int) {

    for ; i < n; i++ {

        v[i] += u.Op(v[i])

    }

    c <- 1  //发信号给任务管理已经完成计算

}


const NCPU = 16   //模拟CPU的个数


func (v Vector) DoAll (u Vector) {

    // 用于接收每个CPU的任务完成信号

    c := make(chan int, NCPU)

    for i := 0; i < NCPU; i++ {

        go v.DoSome (i * len(v)/ X+NCPU, u, c)

    }

    

    for i := 0; i < NCPU; i++ {

        <-c //接收到数据,表示一个CPU已经执行完成

    }

    // 到此处所有的计算任务结束

}


这两个函数看起来设计非常合理。DoAll()会根据CPU的核心数对任务进行分割,然后开辟多个协程开并行的做这些计算任务。


上面这个案例是否真正可以将时间降低近原来 1/N?答案是不一定。看go语言的版本,在低版本的go语言中,你会发现总的执行时间并没有缩短。再去观察CPU运行状态,你会发现尽管我们有16个CPU,但在整个计算的过程中只有一个CPU处于运行状态。


出现这个问题的原因是,低版本的go语言编译器还不能很智能去发现和利用多核的优势。虽然我们创建多个协程,并且从运行的状态上看这些协程也都在并发运行,但是他们运行在同一个CPU。在一个goroutine得到CPU的时间片的时候,其他的goroutine都会处于等待的状态。从这点可以看出,虽然我们用协程简化代码的过程,但实际上整体的运行效率并没有高于单线程模式。


在低版本的go语言中,可以通过设置环境变量GOMAXPROCS的值来控制使用CPU的核心数量,具体的操作方法是通过直接设置环境变量GOMAXPROCS的值,或者在代码中启动goroutine之前先调用下面这个语句设置使用16个CPU的核心:

    

        runtime.GOMAXPROCS(16)


在runtime包中,还提供了一个NumCPU()函数,用于获取CPU个数,这样话,先获取CPU数目,然后再去设计GOMAXPROCS。


二.出让时间片


我们可以在每个goroutine中控制何时主动出让时间片给其他的goroutine,这可以使用runtime包中的Gosched()函数实现。如果想要很好地控制协程,最后熟悉runtime包的具体功能。


三.同步

用通信来共享数据,而不是通过共享数据来进行通信,但是考虑到即使我们成功地用channel来作为通信的手段,还是避免不了多个goroutine之间共享数据的问题,基于这种情况,go语言提供锁机制。


1.同步锁


go语言的包中的sync包提供两种类型的锁:sync.Mutex和sync.RWMutex。mutex是最简单的锁类型,同时也比较暴力,当一个goroutine获得了mutex后,其他的goroutine只能乖乖等着这个goroutine释放Mutex。RWMutex相对友好一些,是经典单写多读模型。在读写锁占用的情况下,会阻止写,但不阻止写,也就是多个goroutine可以同时获得读锁。读锁(RLock()),写锁(Lock()).。从RWMutex来看,RWMutex类型实际上是组合Mutex。


type RWMtex struct {

    w Mutex

    writerSem uint32

    readerSem uint32

    readerCount uint32

    readerWait uint32

}


对于这两种锁类型,任何一个Lock()或RLock()均要保证对应有UnLock()或RUuLock()调用与之对应,否则可能导致等待该锁的所有goroutine处于饥饿状态,甚至可能导致死锁。


var l sync.Mutex 

func foo(){

    l.Lock()

    defer l.UnLock()

    //.....

}

上面是一个锁饿经典使用模型,这里我们再次见证了defer关键字带来的优雅。


2.全局唯一性操作


对于从全局的角度只需要运行一次的代码,比如全局初始化初始化操作,go语言提供了一个Once类型来保证全局的唯一性操作,具体操作如下:


var a string 

var once sync.Once


func setup() {

    a = "hello world"

}


func droprint() {

    once.Do(setup)

    print(a)

}


func twoprint() {

    go doprint()

    go doprint()

}


如果这段代码没有引入Once,setup()将会被每一个goroutine先调用一次,这至少对于这个例子是多余。在现在中,我们也经常会遇到这样的情况。go语言标准库为我们引入了Once类型以解决这个问题。once的Do()方法可以保证在全局范围内只调用指定的函数一次(这里指setup()函数),而且所有其他goroutine在调用到此语句时,将会先被阻塞,直至全局唯一的once.Do()函数调用结束后才继续。


这个机制比较轻巧地解决了使用其他语言时开发者不得不自行设计和实现这种Once效果的难题,也是go语言为并发并发性编程做了尽量多考虑的一种的体现。


如果没有once.Do(),我们很可能只能添加一个全局的bool变量,在函数setup()的最后一行将该bool变量设置为true。在对setup()的所谓调用之前,需要先判断bool变量是否已被设置为true,如果该值任然是false,则调用一次setup(),否则应该跳过该语句。实现代码:


var done bool = false


func setup() {

    a = "hello world"

    done = true

}


func doprint() {

    if !done {

        setup()

    }

    print(a)

}


这段代码初看起来比较合理,但是细看还是会有问题,因为setup()并不是一个原子操作,这种写法可能导致setup()函数被多次调用,从而无法达到全局只执行一次的目标。这个问题的复杂性也更加体现了Once类型的价值。


为了更好的控制并行中的原子操作,sync包中还含有一个atomic子包,它提供了对于一些基础数据类型的原子操作函数,比如下面这个函数:


func CompareAndSwapUint64(val *uint64, old, new uint64) (swapped bool) 


就提供了比较和交换两个uint64类型的数据操作。这让开发者无需再为这样的操作专门添加Lock操作。




以上是关于go语言并发编程(高级)的主要内容,如果未能解决你的问题,请参考以下文章

Go并发编程-并发与并行

2021-GO语言并发编程

2021-GO语言并发编程

Go语言学习之旅--并发编程

Go语言学习之旅--并发编程

Go语言学习之旅--并发编程