Go 語言 面向并發(fā)的內(nèi)存模型

2023-03-22 14:57 更新

原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch1-basic/ch1-05-mem.html


1.5 面向并發(fā)的內(nèi)存模型

在早期,CPU 都是以單核的形式順序執(zhí)行機器指令。Go 語言的祖先 C 語言正是這種順序編程語言的代表。順序編程語言中的順序是指:所有的指令都是以串行的方式執(zhí)行,在相同的時刻有且僅有一個 CPU 在順序執(zhí)行程序的指令。

隨著處理器技術(shù)的發(fā)展,單核時代以提升處理器頻率來提高運行效率的方式遇到了瓶頸,目前各種主流的 CPU 頻率基本被鎖定在了 3Ghz 附近。單核 CPU 的發(fā)展的停滯,給多核 CPU 的發(fā)展帶來了機遇。相應(yīng)地,編程語言也開始逐步向并行化的方向發(fā)展。Go 語言正是在多核和網(wǎng)絡(luò)化的時代背景下誕生的原生支持并發(fā)的編程語言。

常見的并行編程有多種模型,主要有多線程、消息傳遞等。從理論上來看,多線程和基于消息的并發(fā)編程是等價的。由于多線程并發(fā)模型可以自然對應(yīng)到多核的處理器,主流的操作系統(tǒng)因此也都提供了系統(tǒng)級的多線程支持,同時從概念上講多線程似乎也更直觀,因此多線程編程模型逐步被吸納到主流的編程語言特性或語言擴展庫中。而主流編程語言對基于消息的并發(fā)編程模型支持則相比較少,Erlang 語言是支持基于消息傳遞并發(fā)編程模型的代表者,它的并發(fā)體之間不共享內(nèi)存。Go 語言是基于消息并發(fā)模型的集大成者,它將基于 CSP 模型的并發(fā)編程內(nèi)置到了語言中,通過一個 go 關(guān)鍵字就可以輕易地啟動一個 Goroutine,與 Erlang 不同的是 Go 語言的 Goroutine 之間是共享內(nèi)存的。

1.5.1 Goroutine和系統(tǒng)線程

Goroutine是 Go 語言特有的并發(fā)體,是一種輕量級的線程,由 go 關(guān)鍵字啟動。在真實的 Go 語言的實現(xiàn)中,goroutine 和系統(tǒng)線程也不是等價的。盡管兩者的區(qū)別實際上只是一個量的區(qū)別,但正是這個量變引發(fā)了 Go 語言并發(fā)編程質(zhì)的飛躍。

首先,每個系統(tǒng)級線程都會有一個固定大小的棧(一般默認可能是 2MB),這個棧主要用來保存函數(shù)遞歸調(diào)用時參數(shù)和局部變量。固定了棧的大小導(dǎo)致了兩個問題:一是對于很多只需要很小的棧空間的線程來說是一個巨大的浪費,二是對于少數(shù)需要巨大??臻g的線程來說又面臨棧溢出的風險。針對這兩個問題的解決方案是:要么降低固定的棧大小,提升空間的利用率;要么增大棧的大小以允許更深的函數(shù)遞歸調(diào)用,但這兩者是沒法同時兼得的。相反,一個 Goroutine 會以一個很小的棧啟動(可能是 2KB 或 4KB),當遇到深度遞歸導(dǎo)致當前棧空間不足時,Goroutine 會根據(jù)需要動態(tài)地伸縮棧的大小(主流實現(xiàn)中棧的最大值可達到1GB)。因為啟動的代價很小,所以我們可以輕易地啟動成千上萬個 Goroutine。

Go的運行時還包含了其自己的調(diào)度器,這個調(diào)度器使用了一些技術(shù)手段,可以在 n 個操作系統(tǒng)線程上多工調(diào)度 m 個 Goroutine。Go 調(diào)度器的工作和內(nèi)核的調(diào)度是相似的,但是這個調(diào)度器只關(guān)注單獨的 Go 程序中的 Goroutine。Goroutine 采用的是半搶占式的協(xié)作調(diào)度,只有在當前 Goroutine 發(fā)生阻塞時才會導(dǎo)致調(diào)度;同時發(fā)生在用戶態(tài),調(diào)度器會根據(jù)具體函數(shù)只保存必要的寄存器,切換的代價要比系統(tǒng)線程低得多。運行時有一個 runtime.GOMAXPROCS 變量,用于控制當前運行正常非阻塞 Goroutine 的系統(tǒng)線程數(shù)目。

在 Go 語言中啟動一個 Goroutine 不僅和調(diào)用函數(shù)一樣簡單,而且 Goroutine 之間調(diào)度代價也很低,這些因素極大地促進了并發(fā)編程的流行和發(fā)展。

1.5.2 原子操作

所謂的原子操作就是并發(fā)編程中“最小的且不可并行化”的操作。通常,如果多個并發(fā)體對同一個共享資源進行的操作是原子的話,那么同一時刻最多只能有一個并發(fā)體對該資源進行操作。從線程角度看,在當前線程修改共享資源期間,其它的線程是不能訪問該資源的。原子操作對于多線程并發(fā)編程模型來說,不會發(fā)生有別于單線程的意外情況,共享資源的完整性可以得到保證。

一般情況下,原子操作都是通過“互斥”訪問來保證的,通常由特殊的 CPU 指令提供保護。當然,如果僅僅是想模擬下粗粒度的原子操作,我們可以借助于 sync.Mutex 來實現(xiàn):

import (
    "sync"
)

var total struct {
    sync.Mutex
    value int
}

func worker(wg *sync.WaitGroup) {
    defer wg.Done()

    for i := 0; i <= 100; i++ {
        total.Lock()
        total.value += i
        total.Unlock()
    }
}

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

    fmt.Println(total.value)
}

在 worker 的循環(huán)中,為了保證 total.value += i 的原子性,我們通過 sync.Mutex 加鎖和解鎖來保證該語句在同一時刻只被一個線程訪問。對于多線程模型的程序而言,進出臨界區(qū)前后進行加鎖和解鎖都是必須的。如果沒有鎖的保護,total 的最終值將由于多線程之間的競爭而可能會不正確。

用互斥鎖來保護一個數(shù)值型的共享資源,麻煩且效率低下。標準庫的 sync/atomic 包對原子操作提供了豐富的支持。我們可以重新實現(xiàn)上面的例子:

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.AddUint64 函數(shù)調(diào)用保證了 total 的讀取、更新和保存是一個原子操作,因此在多線程中訪問也是安全的。

原子操作配合互斥鎖可以實現(xiàn)非常高效的單件模式。互斥鎖的代價比普通整數(shù)的原子讀寫高很多,在性能敏感的地方可以增加一個數(shù)字型的標志位,通過原子檢測標志位狀態(tài)降低互斥鎖的使用次數(shù)來提高性能。

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 的實現(xiàn):

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 重新實現(xiàn)單件模式:

var (
    instance *singleton
    once     sync.Once
)

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

sync/atomic 包對基本的數(shù)值類型及復(fù)雜對象的讀寫都提供了原子操作的支持。atomic.Value 原子對象提供了 Load 和 Store 兩個原子方法,分別用于加載和保存數(shù)據(jù),返回值和參數(shù)都是 interface{} 類型,因此可以用于任意的自定義復(fù)雜類型。

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()
            // ...
        }
    }()
}

這是一個簡化的生產(chǎn)者消費者模型:后臺線程生成最新的配置信息;前臺多個工作者線程獲取最新的配置信息。所有線程共享配置信息資源。

1.5.3 順序一致性內(nèi)存模型

如果只是想簡單地在線程之間進行數(shù)據(jù)同步的話,原子操作已經(jīng)為編程人員提供了一些同步保障。不過這種保障有一個前提:順序一致性的內(nèi)存模型。要了解順序一致性,我們先看看一個簡單的例子:

var a string
var done bool

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

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

我們創(chuàng)建了 setup 線程,用于對字符串 a 的初始化工作,初始化完成之后設(shè)置 done 標志為 true。main 函數(shù)所在的主線程中,通過 for !done {} 檢測 done 變?yōu)?nbsp;true 時,認為字符串初始化工作完成,然后進行字符串的打印工作。

但是 Go 語言并不保證在 main 函數(shù)中觀測到的對 done 的寫入操作發(fā)生在對字符串 a 的寫入的操作之后,因此程序很可能打印一個空字符串。更糟糕的是,因為兩個線程之間沒有同步事件,setup線程對 done 的寫入操作甚至無法被 main 線程看到,main函數(shù)有可能陷入死循環(huán)中。

在 Go 語言中,同一個 Goroutine 線程內(nèi)部,順序一致性內(nèi)存模型是得到保證的。但是不同的 Goroutine 之間,并不滿足順序一致性內(nèi)存模型,需要通過明確定義的同步事件來作為同步的參考。如果兩個事件不可排序,那么就說這兩個事件是并發(fā)的。為了最大化并行,Go 語言的編譯器和處理器在不影響上述規(guī)定的前提下可能會對執(zhí)行語句重新排序(CPU 也會對一些指令進行亂序執(zhí)行)。

因此,如果在一個 Goroutine 中順序執(zhí)行 a = 1; b = 2; 兩個語句,雖然在當前的 Goroutine 中可以認為 a = 1; 語句先于 b = 2; 語句執(zhí)行,但是在另一個 Goroutine 中 b = 2; 語句可能會先于 a = 1; 語句執(zhí)行,甚至在另一個 Goroutine 中無法看到它們的變化(可能始終在寄存器中)。也就是說在另一個 Goroutine 看來, a = 1; b = 2;兩個語句的執(zhí)行順序是不確定的。如果一個并發(fā)程序無法確定事件的順序關(guān)系,那么程序的運行結(jié)果往往會有不確定的結(jié)果。比如下面這個程序:

func main() {
    go println("你好, 世界")
}

根據(jù) Go 語言規(guī)范,main函數(shù)退出時程序結(jié)束,不會等待任何后臺線程。因為 Goroutine 的執(zhí)行和 main 函數(shù)的返回事件是并發(fā)的,誰都有可能先發(fā)生,所以什么時候打印,能否打印都是未知的。

用前面的原子操作并不能解決問題,因為我們無法確定兩個原子操作之間的順序。解決問題的辦法就是通過同步原語來給兩個事件明確排序:

func main() {
    done := make(chan int)

    go func(){
        println("你好, 世界")
        done <- 1
    }()

    <-done
}

當 <-done 執(zhí)行時,必然要求 done <- 1 也已經(jīng)執(zhí)行。根據(jù)同一個 Goroutine 依然滿足順序一致性規(guī)則,我們可以判斷當 done <- 1 執(zhí)行時,println("你好, 世界") 語句必然已經(jīng)執(zhí)行完成了。因此,現(xiàn)在的程序確保可以正常打印結(jié)果。

當然,通過 sync.Mutex 互斥量也是可以實現(xiàn)同步的:

func main() {
    var mu sync.Mutex

    mu.Lock()
    go func(){
        println("你好, 世界")
        mu.Unlock()
    }()

    mu.Lock()
}

可以確定后臺線程的 mu.Unlock() 必然在 println("你好, 世界") 完成后發(fā)生(同一個線程滿足順序一致性),main 函數(shù)的第二個 mu.Lock() 必然在后臺線程的 mu.Unlock() 之后發(fā)生(sync.Mutex 保證),此時后臺線程的打印工作已經(jīng)順利完成了。

1.5.4 初始化順序

前面函數(shù)章節(jié)中我們已經(jīng)簡單介紹過程序的初始化順序,這是屬于 Go 語言面向并發(fā)的內(nèi)存模型的基礎(chǔ)規(guī)范。

Go程序的初始化和執(zhí)行總是從 main.main 函數(shù)開始的。但是如果 main 包里導(dǎo)入了其它的包,則會按照順序?qū)⑺鼈儼M main 包里(這里的導(dǎo)入順序依賴具體實現(xiàn),一般可能是以文件名或包路徑名的字符串順序?qū)耄?。如果某個包被多次導(dǎo)入的話,在執(zhí)行的時候只會導(dǎo)入一次。當一個包被導(dǎo)入時,如果它還導(dǎo)入了其它的包,則先將其它的包包含進來,然后創(chuàng)建和初始化這個包的常量和變量。然后就是調(diào)用包里的 init 函數(shù),如果一個包有多個 init 函數(shù)的話,實現(xiàn)可能是以文件名的順序調(diào)用,同一個文件內(nèi)的多個 init 則是以出現(xiàn)的順序依次調(diào)用(init不是普通函數(shù),可以定義有多個,所以不能被其它函數(shù)調(diào)用)。最終,在 main 包的所有包常量、包變量被創(chuàng)建和初始化,并且 init 函數(shù)被執(zhí)行后,才會進入 main.main 函數(shù),程序開始正常執(zhí)行。下圖是 Go 程序函數(shù)啟動順序的示意圖:


圖 1-12 包初始化流程

要注意的是,在 main.main 函數(shù)執(zhí)行之前所有代碼都運行在同一個 Goroutine 中,也是運行在程序的主系統(tǒng)線程中。如果某個 init 函數(shù)內(nèi)部用 go 關(guān)鍵字啟動了新的 Goroutine 的話,新的 Goroutine 和 main.main 函數(shù)是并發(fā)執(zhí)行的。

因為所有的 init 函數(shù)和 main 函數(shù)都是在主線程完成,它們也是滿足順序一致性模型的。

1.5.5 Goroutine的創(chuàng)建

go 語句會在當前 Goroutine 對應(yīng)函數(shù)返回前創(chuàng)建新的 Goroutine。例如:

var a string

func f() {
    print(a)
}

func hello() {
    a = "hello, world"
    go f()
}

執(zhí)行 go f() 語句創(chuàng)建 Goroutine 和 hello 函數(shù)是在同一個 Goroutine 中執(zhí)行, 根據(jù)語句的書寫順序可以確定 Goroutine 的創(chuàng)建發(fā)生在 hello 函數(shù)返回之前, 但是新創(chuàng)建 Goroutine 對應(yīng)的 f() 的執(zhí)行事件和 hello 函數(shù)返回的事件則是不可排序的,也就是并發(fā)的。調(diào)用 hello 可能會在將來的某一時刻打印 "hello, world",也很可能是在 hello 函數(shù)執(zhí)行完成后才打印。

1.5.6 基于 Channel 的通信

Channel 通信是在 Goroutine 之間進行同步的主要方法。在無緩存的 Channel 上的每一次發(fā)送操作都有與其對應(yīng)的接收操作相配對,發(fā)送和接收操作通常發(fā)生在不同的 Goroutine 上(在同一個 Goroutine 上執(zhí)行兩個操作很容易導(dǎo)致死鎖)。無緩存的 Channel 上的發(fā)送操作總在對應(yīng)的接收操作完成前發(fā)生.

var done = make(chan bool)
var msg string

func aGoroutine() {
    msg = "你好, 世界"
    done <- true
}

func main() {
    go aGoroutine()
    <-done
    println(msg)
}

可保證打印出“你好, 世界”。該程序首先對 msg 進行寫入,然后在 done 管道上發(fā)送同步信號,隨后從 done 接收對應(yīng)的同步信號,最后執(zhí)行 println 函數(shù)。

若在關(guān)閉 Channel 后繼續(xù)從中接收數(shù)據(jù),接收者就會收到該 Channel 返回的零值。因此在這個例子中,用 close(c) 關(guān)閉管道代替 done <- false 依然能保證該程序產(chǎn)生相同的行為。

var done = make(chan bool)
var msg string

func aGoroutine() {
    msg = "你好, 世界"
    close(done)
}

func main() {
    go aGoroutine()
    <-done
    println(msg)
}

對于從無緩沖 Channel 進行的接收,發(fā)生在對該 Channel 進行的發(fā)送完成之前。

基于上面這個規(guī)則可知,交換兩個 Goroutine 中的接收和發(fā)送操作也是可以的(但是很危險):

var done = make(chan bool)
var msg string

func aGoroutine() {
    msg = "hello, world"
    <-done
}
func main() {
    go aGoroutine()
    done <- true
    println(msg)
}

也可保證打印出“hello, world”。因為 main 線程中 done <- true 發(fā)送完成前,后臺線程 <-done 接收已經(jīng)開始,這保證 msg = "hello, world" 被執(zhí)行了,所以之后 println(msg) 的msg已經(jīng)被賦值過了。簡而言之,后臺線程首先對 msg 進行寫入,然后從 done 中接收信號,隨后 main 線程向 done 發(fā)送對應(yīng)的信號,最后執(zhí)行 println 函數(shù)完成。但是,若該 Channel 為帶緩沖的(例如,done = make(chan bool, 1)),main線程的 done <- true 接收操作將不會被后臺線程的 <-done 接收操作阻塞,該程序?qū)o法保證打印出“hello, world”。

對于帶緩沖的Channel,對于 Channel 的第 K 個接收完成操作發(fā)生在第 K+C 個發(fā)送操作完成之前,其中 C 是 Channel 的緩存大小。 如果將 C 設(shè)置為 0 自然就對應(yīng)無緩存的 Channel,也即使第 K 個接收完成在第 K 個發(fā)送完成之前。因為無緩存的 Channel 只能同步發(fā) 1 個,也就簡化為前面無緩存 Channel 的規(guī)則:對于從無緩沖 Channel 進行的接收,發(fā)生在對該 Channel 進行的發(fā)送完成之前。

我們可以根據(jù)控制 Channel 的緩存大小來控制并發(fā)執(zhí)行的 Goroutine 的最大數(shù)目, 例如:

var limit = make(chan int, 3)
var work = []func(){
    func() { println("1"); time.Sleep(1 * time.Second) },
    func() { println("2"); time.Sleep(1 * time.Second) },
    func() { println("3"); time.Sleep(1 * time.Second) },
    func() { println("4"); time.Sleep(1 * time.Second) },
    func() { println("5"); time.Sleep(1 * time.Second) },
}

func main() {
    for _, w := range work {
        go func(w func()) {
            limit <- 1
            w()
            <-limit
        }(w)
    }
    select{}
}

在循環(huán)創(chuàng)建 Goroutine 過程中,使用了匿名函數(shù)并在函數(shù)中引用了循環(huán)變量 w,由于 w 是引用傳遞的而非值傳遞,因此無法保證 Goroutine 在運行時調(diào)用的 w 與循環(huán)創(chuàng)建時的 w 是同一個值,為了解決這個問題,我們可以利用函數(shù)傳參的值復(fù)制來為每個 Goroutine 單獨復(fù)制一份 w。

循環(huán)創(chuàng)建結(jié)束后,在 main 函數(shù)中最后一句 select{} 是一個空的管道選擇語句,該語句會導(dǎo)致 main 線程阻塞,從而避免程序過早退出。還有 for{}、<-make(chan int) 等諸多方法可以達到類似的效果。因為 main 線程被阻塞了,如果需要程序正常退出的話可以通過調(diào)用 os.Exit(0) 實現(xiàn)。

1.5.7 不靠譜的同步

前面我們已經(jīng)分析過,下面代碼無法保證正常打印結(jié)果。實際的運行效果也是大概率不能正常輸出結(jié)果。

func main() {
    go println("你好, 世界")
}

剛接觸 Go 語言的話,可能希望通過加入一個隨機的休眠時間來保證正常的輸出:

func main() {
    go println("hello, world")
    time.Sleep(time.Second)
}

因為主線程休眠了 1 秒鐘,因此這個程序大概率是可以正常輸出結(jié)果的。因此,很多人會覺得這個程序已經(jīng)沒有問題了。但是這個程序是不穩(wěn)健的,依然有失敗的可能性。我們先假設(shè)程序是可以穩(wěn)定輸出結(jié)果的。因為 Go 線程的啟動是非阻塞的,main 線程顯式休眠了 1 秒鐘退出導(dǎo)致程序結(jié)束,我們可以近似地認為程序總共執(zhí)行了 1 秒多時間?,F(xiàn)在假設(shè) println 函數(shù)內(nèi)部實現(xiàn)休眠的時間大于 main 線程休眠的時間的話,就會導(dǎo)致矛盾:后臺線程既然先于 main 線程完成打印,那么執(zhí)行時間肯定是小于 main 線程執(zhí)行時間的。當然這是不可能的。

嚴謹?shù)牟l(fā)程序的正確性不應(yīng)該是依賴于 CPU 的執(zhí)行速度和休眠時間等不靠譜的因素的。嚴謹?shù)牟l(fā)也應(yīng)該是可以靜態(tài)推導(dǎo)出結(jié)果的:根據(jù)線程內(nèi)順序一致性,結(jié)合 Channel 或 sync 同步事件的可排序性來推導(dǎo),最終完成各個線程各段代碼的偏序關(guān)系排序。如果兩個事件無法根據(jù)此規(guī)則來排序,那么它們就是并發(fā)的,也就是執(zhí)行先后順序不可靠的。

解決同步問題的思路是相同的:使用顯式的同步。



以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號