Golang面向并發的內存模型

面向并發的內存模型

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

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

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

Goroutine和系統線程

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

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

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

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

原子操作

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

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

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的循環中,為了保證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.AddUint64函數調用保證了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重新實現單件模式:

var (
    instance *singleton
    once     sync.Once
)

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

sync/atomic包對基本的數值類型及復雜對象的讀寫都提供了原子操作的支持。atomic.Value原子對象提供了LoadStore兩個原子方法,分別用于加載和保存數據,返回值和參數都是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()
            // ...
        }
    }()
}

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

順序一致性內存模型

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

var a string
var done bool

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

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

我們創建了setup線程,用于對字符串a的初始化工作,初始化完成之后設置done標志為truemain函數所在的主線程中,通過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("你好, 世界")
}

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

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

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

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

    <-done
}

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

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

func main() {
    var mu sync.Mutex

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

    mu.Lock()
}

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

初始化順序

前面函數章節中我們已經簡單介紹過程序的初始化順序,這是屬于Go語言面向并發的內存模型的基礎規范。

Go程序的初始化和執行總是從main.main函數開始的。但是如果main包里導入了其它的包,則會按照順序將它們包含進main包里(這里的導入順序依賴具體實現,一般可能是以文件名或包路徑名的字符串順序導入)。如果某個包被多次導入的話,在執行的時候只會導入一次。當一個包被導入時,如果它還導入了其它的包,則先將其它的包包含進來,然后創建和初始化這個包的常量和變量。然后就是調用包里的init函數,如果一個包有多個init函數的話,實現可能是以文件名的順序調用,同一個文件內的多個init則是以出現的順序依次調用(init不是普通函數,可以定義有多個,所以不能被其它函數調用)。最終,在main包的所有包常量、包變量被創建和初始化,并且init函數被執行后,才會進入main.main函數,程序開始正常執行

要注意的是,在main.main函數執行之前所有代碼都運行在同一個Goroutine中,也是運行在程序的主系統線程中。如果某個init函數內部用go關鍵字啟動了新的Goroutine的話,新的Goroutine和main.main函數是并發執行的。

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

Goroutine的創建

go語句會在當前Goroutine對應函數返回前創建新的Goroutine. 例如:

var a string

func f() {
    print(a)
}

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

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

基于Channel的通信

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

var done = make(chan bool)
var msg string

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

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

可保證打印出“hello, world”。該程序首先對msg進行寫入,然后在done管道上發送同步信號,隨后從done接收對應的同步信號,最后執行println函數。

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

var done = make(chan bool)
var msg string

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

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

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

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

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發送完成前,后臺線程<-done接收已經開始,這保證msg = "hello, world"被執行了,所以之后println(msg)的msg已經被賦值過了。簡而言之,后臺線程首先對msg進行寫入,然后從done中接收信號,隨后main線程向done發送對應的信號,最后執行println函數完成。但是,若該Channel為帶緩沖的(例如,done = make(chan bool, 1)),main線程的done <- true接收操作將不會被后臺線程的<-done接收操作阻塞,該程序將無法保證打印出“hello, world”。

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

我們可以根據控制Channel的緩存大小來控制并發執行的Goroutine的最大數目, 例如:

var limit = make(chan int, 3)

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

最后一句select{}是一個空的管道選擇語句,該語句會導致main線程阻塞,從而避免程序過早退出。還有for{}<-make(chan int)等諸多方法可以達到類似的效果。因為main線程被阻塞了,如果需要程序正常退出的話可以通過調用os.Exit(0)實現。

不靠譜的同步

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

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

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

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

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

嚴謹的并發程序的正確性不應該是依賴于CPU的執行速度和休眠時間等不靠譜的因素的。嚴謹的并發也應該是可以靜態推導出結果的:根據線程內順序一致性,結合Channel或sync同步事件的可排序性來推導,最終完成各個線程各段代碼的偏序關系排序。如果兩個事件無法根據此規則來排序,那么它們就是并發的,也就是執行先后順序不可靠的。

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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,488評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,034評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,327評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,554評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,337評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,883評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,975評論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,114評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,625評論 1 332
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,555評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,737評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,244評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,973評論 3 345
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,362評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,615評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,343評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,699評論 2 370

推薦閱讀更多精彩內容