go语言-面向并发的内存模型

语言: CN / TW / HK

Go语言是基于消息并发模型的集大成者,它将基于CSP( Communicating Sequential Processes )模型的并发变成内置到了语言中,通过一个go关键字就可以轻易地启动一个Goroutine,且Go语言的Goroutine之间是共享内存的。

1.Goroutine和系统线程

Goroutine是Go语言特有的并发体,是一种轻量级的线程,由go关键字启动。在真是的Go语言实现中,Goroutine和系统线程也不是等价的。尽管两者的区别实际上只是一个量的区别,但正是这个两边引发了Go语言并发变成质的飞跃。

首先,每个系统级线程都会有一种固定大小的栈(一般默认可能是2 MB),这个栈主要用来保存函数递归调用时的参数和局部变量。固定了栈的大小导致了两个问题:一是对于很多只需要很小的栈空间的线程是一种巨大的浪费;二是对于少数需要巨大栈空间的线程又面临栈溢出的风险。针对这两个问题的解决方案是:要么降低固定的栈大小,提升空间利用率;要么增大栈的大小以允许更深的函数递归调用,但这两者是无法兼得的。相反,一个Goroutine会以一个很小的栈启动(可能是2 KB 或 4 KB),当遇到深度递归导致当前栈空间不足时,Goroutine会根据需要动态地伸缩栈的大小(主流实现中栈的最大值可达 1 GB)。因为启动的代价很小,所以我们可以轻易地启动成千上万个Goroutine。

Go的运行时还包含了其自己的调度器,这个调度器使用了一些技术手段,可以在n个操作系统线程上多工调度m个Goroutine。Go调度器的工作原理和内核的调度是相似的,但是这个调度器只关注单独的Go程序中的Goroutine。Goroutine采用的是半抢占式的协作调度,只有在当前Goroutine发生阻塞时才会导致调度;同时发生在用户态,调度器会根据具体函数只保存必要的寄存器,切换的代价要比系统线程低得多。运行时有一个runtime.GOMAXPROCS变量,用户控制当前运行正常非阻塞Goroutine的系统线程数目。

Go语言中启动一个Goroutine不仅和调用函数一样简单,而且Goroutine之间调度代价也很低,这些因素及大地促进了并发进程的流行和发展。

2. 原子操作

所谓原子操作就是并发变成中“最小的且不可并行化”的操作,通常,如果多个并发体对同一个共享资源进行的操作是原子操作的话,那么同一时刻最多只能有一个并发体对该资源进行操作。从线程角度看,在当前线程修改共享资源期间,其他线程是不能访问该资源的。原子操作对多线程并发编程模型来说,不会有别于单线程的意外情况,共享资源的完整性可以得以保证。

一般情况下,原子操作都是通过“互斥”访问来保证的,通常由特殊的CPU指令提供保护。当然,如果仅仅是模拟粗粒度的原子操作们可以借助于synx.Mutex来实现:

import (
    "sync"
)

var total stuct {
    sync.Mutex
    value int
}

func worker(wg *sync.WaitGroup) {
    defer wg.Done()
    for i := 0; i <= 100; i++ {
        total.Lock()
        total.value += 1
        total.Unlock()
    }
}

func main() {
    var wg sync.WaitGroup
    wg.Add(2)
    go worker(&wg)
    go worker(&wg)
    wg.Wait()
    
    fmt.Println(total.value)
}

在worker的循环中,为了保证total.value += i的原子性,我们通过sync.Mutex加锁和解锁来保证该语句在同一时刻只被一个县城访问。对多线程模型的程序而言,进出临界区前后进行加锁和解锁都是必须的。如果没有锁的保护,total的最终值将由于多线程之间的竞争而可能不正确。

用互斥锁来保护一个数值型的共享资源麻烦且效率低下。标准库的sync/atomic包对原子操作提供了丰富的支持。我们可以重新实现上面的例子

import (
    "sync"
    "sync/atomic"
)

var total uint64
func worker(wg *sync.WaitGroup) {
    defer wg.Done()
    
    var i uint64
    for i = 0; i <= 100; i++ {
        atomic.AddUint64(&total, i)
    }
}

func main() {
    var wg sync.WaitGroup
    wg.Add(2)
    go worker(&wg)
    go worker(&wg)

    wg.Wait()
}

atomic.AddUnit64()函数调用保证了total的读取、更新和保存是一个院子操作,因此在多线程中访问也是安全的。

原子操作配合互斥锁可以实现非常高效的单件模式。互斥锁的代价比普通整数的原子读写高很多,在性能敏感的地方可以增加一个数字型的标志位,通过原子检测标志位状态降低互斥锁的使用次数来提高性能。

type singleton struct {}

var (
    instance    *singleton
    initialized uint32
    mu          sync.Mutex
)

func Instance() *singleton {
    if atomic.LoadUint32(&initialized) == 1{
        return instance
    }
    
    mu.Lock()
    defer mu.Unlock()
    
    if instance == nil {
        defer atomic.StoreUint32(&initialized, 1)
        instance = &singleton{}
    }
    return instance
}

我们将通用的代码提出来,就成了标准库中sync.Once的实现:

type Once struct {
    m     Mutex
    done  uint32
}

func (o *once) Do(f func()) {
    if atomic.LoadUint32(&o.done) == 1 {
        return
    }
    
    o.m.Lock()
    defer o.m.Unlock()
    
    if o.done == 0 {
        defer atomic.StoreUint32(&o.done, 1)
        f()
    }
}

基于 sync.Once 重新实现单例(singleton)模式:

var (
    instance *singleton
    once     sync.Once
)

func Instance() *singleton {
    once.Do(func() {
        instance = &singleton{}
    })
    return instance
}

sync/atomic 包对基本数值类型及复杂对象的读写都提供了原子操作的支持。atomic.Value原子对象提供了Load()和Sort()两个原子方法,分别用于加载和保存数据,返回值和参数都是interface{}类型,因此可以用于任意的自定义复杂类型。

var config atomic.Value    //保存当前配置信息

//初始化配置信息
config.Store(loadConfig())

//启动一个后台线程,加载更新后的配置信息
go func() {
    for {
        time.Sleep(time.Second)
        config.Store(loadConfig)
    }
}

//用于处理请求的工作者线程适中采用最新的配置信息
for i := 0; i < 10; i++ {
    go func() {
        for r := range requests() {
            c := config.Load()
            //...
        }
    }
}

这是一个简化的生产者-消费者模型:后台线程生成最新的配置信息;前台多个工作者线程获取最新的配置信息。所有线程共享配置信息资源。

3.顺序一致性内存模型

如果只是想简单地在线程之间进行数据同步的话,原子操作已经为编程人员提供了一些同步保障。不过这种保障有一个前提:顺序一致性的内存模型。

var a string
var done bool
func setup() {
    a = "hello, world"
    done = true
}

func main() {
    go setup()
    for !done {}
    print(a)
}

我们创建了setup线程,用于对字符串a的初始化工作,初始化完成之后设置done为true。main()函数所在的主线程中,通过for !done {}检测done 变为true时,认为初始化工作完成,然后进行字符串打印工作。

但是Go语言并不保证main()函数中观测到的对done的写入操作发生在对字符串a的写入操作之后,因此程序很可能打印一个空字符串。更糟糕的是,因为两个线程之间没有同步事件,setup线程对done的写入操作甚至无法被main线程看到,main()函数有可能陷入死循环中。

在Go语言中,同一个Goroutine线程内部,顺序一致性的内存模型是得到保证的。但是不同的Goroutine之间,并不满足顺序一致性的内存模型,需要通过明确定义的同步事件来作为同步的参考。如果两个事件不可排序,那么就说这两件事是并发的。为了最大化并行,Go语言的编译器和处理器在不影响上述规定的前提下可能会执行语句重新排序(CPU也会对一些指令进行乱序执行)。

因此,如果在一个Goroutine钟顺序执行a = 1; b = 2;这两个语句,虽然当前的Goroutine中可以认为a = 1;语句先于b = 2;语句执行,但是在另一个Goroutine中b = 2;语句可能会先于 a = 1;语句执行,甚至在另一个Goroutine中无法看到它们的变化。就是说在另一个Goroutine看来,a = 1;b = 2;这两个语句的执行顺序是不确定的。如果一个并发程序无法确定事件的顺序关系,那么程序的运行结果往往会有不确定的结果。例如:

func main() {
    go println("hello world")
}

根据Go语言规范,main()函数退出时程序结束,不会等待任何后台线程因为Goroutine的执行和main()函数的返回事件是并发的,谁都有可能先发生,所以什么时候打印,能否打印都是未知的。

用前面的原子操作并不能解决问题,因为我们无法确定两个原子操作之间的顺序。解决问题的办法就是通过同步原语来给两个事件明确排序:

func main() {
    done := make(chan int)
    
    go func() {
        println("hello world")
        done <- 1
    }()
    
    <-done
}

当<-done执行时,必然要求done <- 1也已经执行。根据同一个Goroutine依然满足顺序一致性规则,可以判断done <- 1执行时,println("hello world")语句必然已经执行完成了,因此,现在的程序确保可以正常打印结果。

当然,通过sync.Mutex 互斥量也是可以实现同步的:

func main() {
    var mu sync.Mutex
    
    mu.Lock()
    go func(){
        println("hello world")
        mu.Unlock()
    }()
    
    mu.Lock()
}

可以确定后台线程的mu.Unlock()必然在println("你好,世界")完成后发生(同一个线程满足顺序一致性),main()函数的第二个mu.Lock()必然在后台线程的mu.Unlock()之后发生,此时后台现成的打印工作已经顺利完成了。

4 初始化顺序

Go程序的初始化和执行总是从main.main()函数开始的。但是如果main包里导入了其他的包,则会按照顺序将它们包含到main包里。如果某个包被导入多次,那么在执行的时候只会导入一次。当一个包被导入时,如果它还导入了其他的包,则现将其它的包包含进来,然后创建和初始化这个包的常量和变量。再调用包里的init()函数,如果一个包邮多个init()函数,实现可能是以文件名的顺序调用,那么同一个文件内的多个init()是以出现的顺序依次调用的。最终,在main包的所有包常量、包变量被创建和初始化,并且只有在init()函数被执行后,才会进入main.main()函数,程序开始正常执行。

要注意的是,在main.main()函数执行之前,所有代码都运行在同一个Goroutine中,也是运行在程序的主系统线中,如果某个init()函数内部用go关键字启动了新的Goroutine,那么新的Goroutine和main.main()是函数是并发执行的。

分享到: