1、前言
編寫正確的程序自己就不容易,編寫正確的併發程序更是難中之難,那麼併發編程究竟難道哪裏那?本節咱們就來一探究竟。java
2、數據競爭的存在
當兩個或者多個線程(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 }}
安全
微信
併發
app
函數
flex
ui
atom
如上代碼首先建立了一個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 }}
3、操做的原子性
所謂原子性操做是指當執行一系列操做時候,這些操做那麼所有被執行,那麼所有不被執行,不存在只執行其中一部分的狀況。在設計計數器時候通常都是先讀取當前值,而後+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
4、內存訪問同步
上節原子性操做第一個例子有問題是由於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個線程的併發行使用鎖轉換爲了串行執行,也就是對共享內存變量的訪問施加了同步措施。
5、總結
本文咱們從數據競爭、原子性操做、內存同步三個方面探索了併發編程到底難在哪裏,後面章節咱們會結合go的內存模型和happen-before原則在具體探索這些難點如何解決。
本文分享自微信公衆號 - 技術原始積累(gh_805ebfd2deb0)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。