golang學習筆記 ---面向併發的內存模型

Go語言是
基於消息併發模型的集大成者,它將基於CSP模型的併發編程內置到了語言中,通
過一個go關鍵字就能夠輕易地啓動一個Goroutine,與Erlang不一樣的是Go語言的
Goroutine之間是共享內存的。編程

Goroutine和系統線程安全

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

 

系統線程

每一個系統級線程都會有一個固定大小的棧(通常默承認能是2MB),這個棧
主要用來保存函數遞歸調用時參數和局部變量。併發

固定了棧的大小致使了兩個問題:
一是對於不少只須要很小的棧空間的線程來講是一個巨大的浪費,二是對於少數需
要巨大棧空間的線程來講又面臨棧溢出的風險。針對這兩個問題的解決方案是:要
麼下降固定的棧大小,提高空間的利用率;要麼增大棧的大小以容許更深的函數遞
歸調用,但這二者是無法同時兼得的。函數

Goroutine

相反,一個Goroutine會以一個很小的棧啓動性能

(多是2KB或4KB),當遇到深度遞歸致使當前棧空間不足時,Goroutine會根據
須要動態地伸縮棧的大小(主流實現中棧的最大值可達到1GB)。ui

由於啓動的代價
很小,因此咱們能夠輕易地啓動成千上萬個Goroutine。atom

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

在Go語言中啓動一個Goroutine不只和調用函數同樣簡單,並且Goroutine之間調度
代價也很低,這些因素極大地促進了併發編程的流行和發展。操作系統

 

原子操做

所謂的原子操做就是併發編程中「最小的且不可並行化」的操做。一般,若是多個並
發體對同一個共享資源進行的操做是原子的話,那麼同一時刻最多隻能有一個併發
體對該資源進行操做。從線程角度看,在當前線程修改共享資源期間,其它的線程
是不能訪問該資源的。原子操做對於多線程併發編程模型來講,不會發生有別於單
線程的意外狀況,共享資源的完整性能夠獲得保證。

通常狀況下,原子操做都是經過「互斥」訪問來保證的,一般由特殊的CPU指令提供
保護。固然,若是僅僅是想模擬下粗粒度的原子操做,咱們能夠藉助
於 sync.Mutex 來實現:

package main

import (
	"fmt"
	"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 包對原子操做提供了豐富的支持。咱們能夠從新實現上面的例
子:

package main

import (
	"fmt"
	"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()
	fmt.Println(total)

}

 

atomic.AddUint64 函數調用保證了 total 的讀取、更新和保存是一個原子操
做,所以在多線程中訪問也是安全的。

原子操做配合互斥鎖能夠實現很是高效的單件模式。互斥鎖的代價比普通整數的原
子讀寫高不少,在性能敏感的地方能夠增長一個數字型的標誌位,經過原子檢測標
志位狀態下降互斥鎖的使用次數來提升性能。

順序一致性內存模型 

package main

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; 兩個語句的執行順序是不肯定的。若是一個併發程序沒法確
定事件的順序關係,那麼程序的運行結果每每會有不肯定的結果。好比下面這個程

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

  

根據Go語言規範, main 函數退出時程序結束,不會等待任何後臺線程。由於
Goroutine的執行和 main 函數的返回事件是併發的,誰都有可能先發生,因此什
麼時候打印,可否打印都是未知的。

 

用前面的原子操做並不能解決問題,由於咱們沒法肯定兩個原子操做之間的順序。
解決問題的辦法就是經過同步原語來給兩個事件明確排序:

package main

func main() {
	done := make(chan int)
	go func() {
		println("你好, 世界")
		done <- 1
	}()
	<-done
}

  

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

 

固然,經過 sync.Mutex 互斥量也是能夠實現同步的:

package main

import "sync"

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 保證),此時後臺線程的打印工做已經順利完成了。

相關文章
相關標籤/搜索