一、前言
編寫正確的程序本身就不容易,編寫正確的併發程序更是難中之難,那麼併發編程究竟難道哪裡那?本節我們就來一探究竟。
二、數據競爭的存在
當兩個或者多個線程(goroutine)在沒有任何同步措施的情況下同時讀寫同一個共享資源時候,這多個線程(goroutine)就處於數據競爭狀態,數據競爭會導致程序的運行結果超出寫代碼的人的期望。下面我們來看個例子:
package mainimport ( "fmt")var a int//goroutine1func main() { //1,gouroutine2 go func(){ a = 1//1.1 }() //2 if 0 == a{//2.1 fmt.Println(a)//2.2 }}
- 如上代碼首先創建了一個int類型的變量,默認被初始化為0值,運行main函數會啟動一個進程和這個進程中的一個運行main函數的goroutine(輕量級線程)
- 在main函數內使用go語句創建了一個新的goroutine(該goroutine運行匿名函數里面的內容)並啟動運行,匿名函數內給變量賦值為1
- main函數里面代碼2判斷如果變量a的值為0,則打印a的值。
運行main函數後,啟動的進程裡面存在兩個併發運行的線程,分別是開啟的新goroutine(起名為goroutine2)和main函數所在的goroutine(起名為goroutine1),前者試圖修改共享變量a,後者試圖讀取共享變量a,也就是存在兩個線程在沒有任何同步的情況下對同一個共享變量進行讀寫訪問,這就出現了數據競爭,由於數據競爭存在,導致上面程序可能會有下面三種輸出:
- 輸出0,由於運行時調度系統的隨機性,會存在goroutine1的2.2代碼比goroutine2的代碼1.1先執行
- 輸出1,當存在goroutine1先執行代碼2.1,然後goroutine2在執行代碼1.1,最後goroutine1在執行代碼2.2的時候
- 什麼都不輸出,當goroutine2執行先於goroutine1的2.1代碼時候。
由於數據競爭的存在上面一段很短的代碼會有三種可能的輸出,究其原因是goroutine1和groutine2的運行時序是不確定的,也就是沒有對他們的操作做同步,以便讓這些內存操作變為可以預知的順序執行。
這裡編寫程序者或許受單線程模型的影響認為代碼1.1會先於代碼2.1執行,當發現輸出不符合預期時候,或許會在代碼2.1前面讓goroutine1 休眠一會確保goroutine2執行完畢1.1後在讓goroutine1執行2.1,這看起來或許有效,但是這是非常低效,並且並不是所有情況下都可以解決的。
正確的做法可以使用信號量等同步措施,保證goroutine2執行完畢再讓goroutine1執行代碼2.1,如下面代碼,我們使用sync包的WaitGroup來保證goroutine2執行完畢代碼2.1後,goroutine1才可以執行步驟4.1,關於WaitGroup後面章節我們具體會講解:
package mainimport ( "fmt" "sync")var a intvar wg sync.WaitGroup//信號量//goroutine1func main() { //1. wg.Add(1);//一個信號 //2. goroutine1 go func(){ a = 1//2.1 wg.Done() }() wg.Wait()//3. 等待goroutine1運行結束 //4 if 0 == a{//4.1 fmt.Println(a)//4.2 }}
三、操作的原子性
所謂原子性操作是指當執行一系列操作時候,這些操作那麼全部被執行,那麼全部不被執行,不存在只執行其中一部分的情況。在設計計數器時候一般都是先讀取當前值,然後+1,然後更新,這個過程是讀-改-寫的過程,如果不能保證這個過程是原子性,那麼就會出現線程安全問題。如下代碼是線程不安全的,因為不能保證a++是原子性操作:
package mainimport ( "fmt" "sync")var count int32var wg sync.WaitGroup //信號量const THREAD_NUM = 1000//goroutine1func main() { //1.信號 wg.Add(THREAD_NUM) //2. goroutine for i := 0; i < THREAD_NUM; i++ { go func() { count++//2.1 wg.Done()//2.2 }() } wg.Wait() //3. 等待goroutine運行結束 fmt.Println(count) //4輸出計數}
- 如上代碼在main函數所在為goroutine內創建了THREAD_NUM個goroutine,每個新的goroutine執行代碼2.1對變量count計數增加1。
- 這裡創建了THREADNUM個信號量,用來在代碼3處等待THREADNUM個goroutine執行完畢,然後輸出最終計數,執行上面代碼我們 期望輸出1000,但是實際卻不是。
這是因為a++操作本身不是原子性的,其等價於b := count;b=b+1;count=b;是三步操作,所以可能導致導致計數不準確,如下表:
假如當前count=0那麼t1時刻線程A讀取了count值到變量countA,然後t2時刻遞增countA值為1,同時線程B讀取count的值0放到內存countB值為0(因為countA還沒有寫入主內存),t3時刻線程A才把countA為1的值寫入主內存,至此線程A一次計數完畢,同時線程B遞增CountB值為1,t4時候線程B把countB值1寫入內存,至此線程B一次計數完畢。明明是兩次計數,最後結果是1而不是2。
上面的程序需要保證count++的原子性才是正確的,後面章節會知道使用sync/atomic包的一些原子性函數或者鎖可以解決這個問題。
package mainimport ( "fmt" "sync" "sync/atomic")var count int32var wg sync.WaitGroup //信號量const THREAD_NUM = 1000//goroutine1func main() { //1.信號 wg.Add(THREAD_NUM) //2. goroutine for i := 0; i < THREAD_NUM; i++ { go func() { //count++// atomic.AddInt32(&count, 1)//2.1 wg.Done()//2.2 }() } wg.Wait() //3. 等待goroutine運行結束 fmt.Println(count) //4輸出計數}
如上代碼使用原子性操作可以保證每次輸出都是1000
四、內存訪問同步
上節原子性操作第一個例子有問題是因為count++操作是被分解為類似b := count;b=b+1;count =b; 的三部操作,而多個goroutine同時執行count++時候並不是順序執行者三個步驟的,而是可能交叉訪問的。所以如果能對內存變量的訪問添加同步訪問措施,就可以避免這個問題:
package mainimport ( "fmt" "sync")var count int32var wg sync.WaitGroup //信號量var lock sync.Mutex //互斥鎖const THREAD_NUM = 1000//goroutine1func main() { //1.信號 wg.Add(THREAD_NUM) //2. goroutine for i := 0; i < THREAD_NUM; i++ { go func() { lock.Lock() //2.1 count++ //2.2 lock.Unlock() //2.3 wg.Done() //2.4 }() } wg.Wait() //3. 等待goroutine運行結束 fmt.Println(count) //4輸出計數}
- 如上代碼創建了一個互斥鎖lock,然後goroutine內在執行count++前先獲取鎖,執行完畢後在釋放鎖。
- 當1000個goroutine同時執行到代碼2.1時候只有一個線程可以獲取到鎖,其他的線程被阻塞,直到獲取到鎖的goroutine釋放了鎖。也就是這1000個線程的併發行使用鎖轉換為了串行執行,也就是對共享內存變量的訪問施加了同步措施。
五、總結
本文我們從數據競爭、原子性操作、內存同步三個方面探索了併發編程到底難在哪裡,後面章節我們會結合go的內存模型和happen-before原則在具體探索這些難點如何解決。
閱讀更多 格局多 的文章