Go語言並發編程,並發指在同一時間內可以執行多個任務。大傢可以觀察到,電腦、平板、手機,它們都可以一邊播放音樂一邊玩遊戲,同時還能上網聊天,每個程序都要同時渲染畫面和發出聲音。隨著科技的發展與人類需求的增長,並發變得越來越重要。一臺Web服務器會一次處理成千上萬的請求。出色的並發性是Go語言的特色之一,詳細介紹Go語言的並發機制。

並發和並行

Ø 單道程序與多道程序

回到在Windows和Linux出現之前的古老年代,計算機編程使用單道程序設計模型,既串行,所有任務一個一個排序執行。一個任務運行完成之後,另一個任務才會被讀取。即使CPU空閑,在人機交互時必須阻塞,不能同時播放音樂和瀏覽網頁。顯然,串行程序在很多場景下無法滿足客戶的要求

現代計算機編程采用多道程序設計模型,多個任務輪流使用CPU (當下常見CPU為納秒級, 1秒可以執行近10億條指令)。人們在使用計算機時可以邊聽音樂邊上網,是因為人眼的反應速度是毫秒級,所以看似任務同時在運行。這種可以概括為,宏觀並行,微觀串行。

Ø 並發與並行的區別

在討論如何在Go中進行並發處理之前,首先必須瞭解什麼是並發,以及它與並行性有什麼不同。

1. 並發

並發(Concurrency)是同時處理許多個任務。實際上是把任務在不同的時間點交給處理器進行處理。在微觀層面,任務不會同時運行。

2. 並行

並行(Parallelism)是把一個任務分配給每一個處理器獨立完成。多個任務一定是同時運行。並行就是同時做很多事情。乍聽起來可能與並發類似,但實際上是不同的。

串行、並行、並發的區別如圖所示。

生活中也有類似的場景,比如許多人去打水,多人排隊使用一個水龍頭,每個人一次性打滿水後才輪到下一個人,這種情況就是串行。顯然排在後面的人需要等很久才能打到水。

當多人使用一個水龍頭時,每個人隻能一次接水5秒,用完水後再去排隊,這種情況就是並發。此時排在後面的人不需要等太久就能用到水瞭,宏觀上看他們可以同時用到水。

當4個人同時使用4個水龍頭打水時,這種情況就是並行,要求打水人數和水龍頭數量相等才能做到。但是實際情況是需要打水的人往往要比水龍頭的數量多,所以多數情況還是需要並發處理。

Ø 程序與進程

程序是編譯好的二進制文件,在磁盤上,不占用系統資源(CPU、內存、設備)。進程是活躍的程序,占用系統資源。在內存中執行。程序運行起來,產生一個進程。程序就像是劇本,進程就像是演戲,同一個劇本可以在多個舞臺同時上演。同樣,同一個程序也可以加載為不同的進程(彼此之間互不影響),比如同時運行兩個QQ。

Ø 進程與線程的區別

線程也叫輕量級進程,通常一個進程包含若幹個線程;線程可以利用進程所擁有的資源,在引入線程的操作系統中,通常都是把進程作為分配資源的基本單位,而把線程作為獨立運行和獨立調度的基本單位,比如音樂進程,可以一邊查看排行榜一邊聽音樂,互不影響。

Ø 進程與線程的聯系

進程和線程是操作系統級別的兩個基本概念。計算機的核心是CPU,它承擔瞭所有的計算任務。它就像一座工廠,時刻在運行。進程就好比工廠的車間,它代表CPU所能處理的單個任務。進程是一個容器。線程就好比車間裡的工人。一個進程可以包括多個線程,線程是容器中的工作單位。

Ø 協程的概念

協程(Coroutine),最初在1963年被提出。又稱為微線程。是一種比線程更加輕量級的存在。正如一個進程可以擁有多個線程一樣,一個線程也可以擁有多個協程。如圖所示。

協程是編譯器級的,進程和線程是操作系統級的。協程不被操作系統內核管理,而完全是由程序控制。因此沒有線程切換的開銷。和多線程比,線程數量越多,協程的性能優勢就越明顯。協程的最大優勢在於其輕量級,可以輕松創建上萬個而不會導致系統資源衰竭。

Ø Go語言中的協程

Go語言中的協程叫做Goroutine,Goroutine由Go程序運行時(runtime)調度和管理,Go程序會智能地將Goroutine中的任務合理地分配給每個CPU。創建Goroutine的成本很小。每個Goroutine的堆棧隻有幾kb,且堆棧可以根據應用程序的需要增長和收縮。

Ø Coroutine和Goroutine

Goroutine可能並行執行;但是Coroutine隻能順序執行;Goroutine可在多線程環境產生,Coroutine隻能發生在單線程,Coroutine程序需要主動交出控制權,系統才能獲得控制權並將控制權交給其他Coroutine。

Coroutine的運行機制屬於協作式任務處理,應用程序在不使用CPU時,需要主動交出CPU使用權。如果開發者無意間讓應用程序長時間占用CPU,操作系統也無能為力,計算機很容易失去響應或者死機。

Goroutine屬於搶占式任務處理,和現有的多線程和多進程任務處理非常類似。應用程序對CPU的控制最終需要有操作系統來管理,如果操作系統如何發現一個應用程序常時間占用CPU,那麼用戶有權終止這個任務。

Ø 普通函數創建Goroutine

在函數或方法調用前面加上關鍵字go,將會同時運行一個新的Goroutine。

使用go關鍵字創建Goroutine時,被調用的函數往往沒有返回值,如果有返回值也會被忽略。如果需要在Goroutine中返回數據,必須使用channel,通過channel把數據從Goroutine中作為返回值傳出。

Go程序的執行過程是:創建和啟動主Goroutine,初始化操作,執行main()函數,當main()函數結束,主Goroutine隨之結束,程序結束。使用方式參見。

被啟動的Goroutine叫做子Goroutine。如果main()的Goroutine終止瞭,程序將被終止,而其他Goroutine將不再運行。換句話說,所有Goroutine在main()函數結束時會一同結束。如上例所示。如果main()的Goroutine比子Goroutine先終止,運行的結果就不會打印Hello world goroutine。修改後如例所示。

接下來再看一個案例,如例所示。

該案例中,Go程序在啟動時,運行時runtime默認為main()函數創建一個Goroutine。在main()函數的Goroutine執行到go running()語句時,歸屬於running()函數的Goroutine被創建,running()函數開始在自己的goroutine中執行。此時,main()繼續執行,兩個Goroutine通過GO程序的調度機制同時運行。

Ø 匿名函數創建Goroutine

go關鍵字後也可以是匿名函數或閉包。將例12-3修改為匿名函數形式,參見。

Ø 啟動多個Goroutine

下面通過一個案例展示多個Goroutines啟動效果。參見。

Ø 調整並發的運行性能

在Go程序運行時,runtime實現瞭一個小型的任務調度器。此調度器的工作原理類似於操作系統調度線程,Go程序調度器可以高效地將CPU資源分配給每一個任務。在多個Goroutines的情況下,可以使用runtime.Gosched()交出控制權。

傳統邏輯中,開發者需要維護線程池中的線程與CPU核心數量的對應關系。在Go語言中可以通過runtime.GOMAXPROCS()函數做到。

語法為格式如下所示。

邏輯CPU數量有幾種數值,如表所示。

數值 含義
<1 不修改任何數值
=1 單核執行
>1 多核並發執行

Go1.5版本之前,默認使用單核執行。Go1.5版本開始,默認執行:runtime.GOMAXPROCS(邏輯CPU數量),讓代碼並發執行,最大效率地利用CPU。

Ø chanenl的概述

Channels即Go的通道,是協程之間的通信機制。一個channel是一條通信管道,它可以讓一個協程通過它給另一個協程發送數據。每個channel都需要指定數據類型,即channel可發送數據的類型。如果使用channel發送int類型數據,可以寫成chan int。數據發送的方式如同水在管道中的流動。

傳統的線程之間可以通過共享內存進行數據交互,不同的線程之間對共享內存的同步問題需要使用鎖來解決,這樣會導致性能低下。Go語言中提倡使用channel的方式代替共享內存。換言之,Go語言主張通過數據傳遞來實現共享內存,而不是通過共享內存來實現消息傳遞。

Ø 創建channel類型

聲明channel類型的語法格式如下所示。

chan類型的空置是nil,聲明後需要配合make才能使用。

channel是引用類型,需要使用make進行創建,語法格式如下所示。

具體創建語法如下所示。

Ø 使用channel發送數據

channel發送使用特殊的操作符"<-",將數據通過channel發送的語法格式如下所示。

channel發送的值的類型必須與channel的元素類型一致。如果接收方一直沒有接收,那麼發送操作將持續阻塞。此時所有的goroutine,包括main的goroutine都處於等待狀態。

運行會提示報錯:fatal error: all goroutines are asleep – deadlock!

使用channel時要考慮發生死鎖(deadlock)的可能。如果Goroutine在一個channel上發送數據,其他的Goroutine應該接收得到數據。如果這種情況沒有發生,那麼程序將在運行時出現死鎖。如果Goroutine正在等待從channel接收數據,其他一些Goroutine將會在該channel上寫入數據,否則程序將會死鎖。

Ø 使用channel接收數據

channel收發操作在不同的兩個Goroutine間進行。語法格式有四種。

1. 阻塞接收數據

channel接收同樣使用特殊的操作符"<-"。語法格式如下所示。

執行該語句時將會阻塞,直到接收到數據並賦值給data變量。

2. 完整寫法

阻塞接收數據的完整寫法如下所示。

data 表示接收到的數據。未接收到數據時,data為channel類型的零值。

ok表示是否接收到數據。通過ok值可以判斷當前channel是否被關閉。

3. 忽略接收數據

接收任意數據,忽略接收的數據,語法格式如下所示。

執行該語句時將會阻塞。其目的不在於接收channel中數據,而是為瞭阻塞goroutine。

4. 循環接收數據

循環接收數據,需要配合使用關閉channel,借助普通for循環和for … range語句循環接收多個元素。遍歷channel,遍歷的結果就是接收到的數據,數據類型就是channel的數據類型。普通for循環接收channel數據,需要有break循環的條件;for range會自動判斷出channel已關閉,而無需通過判斷來終止循環。循環接收數據的三種語法格式參見教材例12-6。

Ø 阻塞

channel默認是阻塞的。當數據被發送到channel時會發生阻塞,直到有其他Goroutine從該channel中讀取數據。當從channel讀取數據時,讀取也會被阻塞,直到其他Goroutine將數據寫入該channel。這些channel的特性是幫助Goroutines有效地通信,而不需要使用其他語言中的顯式鎖或條件變量。

阻塞基本用法。

Ø 關閉channel

發送方如果數據寫入完畢,需要關閉channel,用於通知接受方數據傳遞完畢。一般都是發送方關閉channel。通過多重返回值判斷channel是否關閉,如果返回值是false,則表示channel已經被關閉。如果往關閉的channel中寫入數據,會報錯:panic: send on closed channel。但是可以從關閉後的channel中取數據,返回數據的默認值和false。

Ø 緩沖channel

默認創建的都是非緩沖channel,讀寫都是即時阻塞。緩沖channel自帶一塊緩沖區,可以暫時存儲數據,如果緩沖區滿瞭,就會發生阻塞。下面通過案例對比緩沖channel與非緩沖channel,參見。

非緩沖channel部分的打印結果是輸入數據和接收數據交替的,這說明讀寫都是即時阻塞。緩沖channel部分的打印數據輸入完畢以後才打印接收數據,這意味著當緩沖區沒有滿的情況下是非阻塞的。

Ø 單向channel

channel默認都是雙向的。即可讀可寫。定向channel也叫單向channel,隻讀,或隻寫。

隻讀channel使用方式如下所示。

隻寫channel使用方式如下所示。

創建channel時,采用單向channel是沒有意義的。通常都是創建雙向channel。然後將channel作為參數傳遞的時候使用單向channel。

time包中與channel相關的函數、select分支語句、sync包

Ø Timer結構體

計時器類型表示單個事件。當計時器過期時,當前時間將被發送到C上(C是一個隻讀channel<-chan time.Time,該channel中放入的是Time結構體),除非計時器是AfterFunc創建的。計時器必須使用NewTimer()或After()創建。

Timer結構體的源碼定義如下所示。

Ø NewTimer()函數

NewTimer創建一個新的計時器,它會在至少持續時間d之後將當前時間發送到其channel上。

NewTimer()函數的源碼如下所示。

具體使用方式參見。

Ø After()函數

After()函數相當於NewTimer(d). C。

After()函數的源碼如下所示。

具體使用方式參見。

Ø select分支語句—執行流程

14天搞定Go語言,從0到1保姆級教程-Go語言開發實戰-6select語句

select語句的機制有點像switch語句,不同的是,select會隨機挑選的一個可通信的case來執行,如果所有case都沒有數據到達,則執行default,如果沒有default語句,select就會阻塞,直到有case接收到數據。

Ø select分支語句—示例代碼

select分支語句的用法參見。

Ø sync包

sync包提供瞭互斥鎖。除瞭Once和WaitGroup類型,其餘多數適用於低水平的程序,多數情況下,高水平的同步使用channel通信性能會更優一些。sync包類型的值不應被拷貝。

前面的案例中,一般使用time.Sleep()函數,通過睡眠將主線程阻塞至所有線程結束。而更好的做法是使用WaitGroup來實現。

Ø 同步等待組

同步的sync是串行執行,異步的sync是同時執行。

WaitGroup同步等待組,定義如下所示。

WaitGroup,等待一組線程結束。父線程調用Add方法來設置應等待線程的數量。每個被等待的線程在結束時應該調用Done方法。與此同時,主線程裡可調用Wait方法阻塞至所有線程結束。

WaitGroup中的方法如下所示。

Add()方法向內部計數加上delta,delta可以是負數;如果內部計數器變為0,Wait方法阻塞等待的所有線程都會釋放,如果計數器小於0,則該方法panic。註意Add()加上正數的調用應在Wait之前,否則Wait可能隻會等待很少的線程。通常來說本方法應該在創建新的線程或者其他應該等待的事件之前調用。

Done()方法減少WaitGroup計數器的值,應在線程的最後執行,定義如下所示。

Wait()方法阻塞直到WaitGroup計數器減為0。定義如下所示。

Ø 互斥鎖

互斥鎖的定義如下所示。

Mutex是一個互斥鎖,可以創建為其他結構體的字段;零值為解鎖狀態。Mutex類型的鎖和線程無關,可以由不同的線程加鎖和解鎖。

Mutex中的方法如下所示。

Lock()方法鎖住m,如果m已經加鎖,則阻塞直到m解鎖。

Unlock()方法解鎖m,如果m未加鎖會導致運行時錯誤。鎖和線程無關,可以由不同的線程加鎖和解鎖。

Ø 讀寫互斥鎖

讀寫鎖的定義如下所示。

讀寫互斥鎖的定義方式如下所示。

RWMutex是讀寫互斥鎖,簡稱讀寫鎖。該鎖可以被同時多個讀取者持有或唯一個寫入者持有。RWMutex可以創建為其他結構體的字段;零值為解鎖狀態。RWMutex類型的鎖也和線程無關,可以由不同的線程加讀取鎖/寫入和解讀取鎖/寫入鎖。

讀寫鎖的使用中,寫操作都是互斥的,讀和寫是互斥的,讀和讀不互斥。

該規則可以理解為,可以多個Goroutine同時讀取數據,但是隻允許一個Goroutine寫數據。

Mutex中的方法如下所示。

Lock()方法將rw鎖定為寫入狀態,禁止其他線程讀取或者寫入。

Unlock()方法解除rw的寫入鎖狀態,如果m未加寫入鎖會導致運行時錯誤。

RLock()方法將rw鎖定為讀取狀態,禁止其他線程寫入,但不禁止讀取。

Runlock方法解除rw的讀取鎖狀態,如果m未加讀取鎖會導致運行時錯誤。

Rlocker()方法返回一個互斥鎖,通過調用rw.Rlock和rw.Runlock實現瞭Locker接口。

Ø 條件變量

條件變量定義如下所示。

Cond實現瞭一個條件變量,一個線程集合地,供線程等待或者宣佈某事件的發生。

每個Cond實例都有一個相關的鎖(一般是*Mutex或*RWMutex類型的值),它須在改變條件時或者調用Wait方法時保持鎖定。Cond可以創建為其他結構體的字段,Cond在開始使用後不能被拷貝。條件變量:sync.Cond,多個goroutine等待或接受通知的集合地

Cond中的方法定義如下所示。

使用鎖l創建一個*Cond。Cond條件變量,總是要和鎖結合使用。

Broadcast()喚醒所有等待c的線程。調用者在調用本方法時,建議(但並非必須)保持c.L的鎖定。

Signal()喚醒等待c的一個線程(如果存在)。調用者在調用本方法時,建議(但並非必須)保持c.L的鎖定。發送通知給一個人。

Wait自行解鎖c.L並阻塞當前線程,在之後線程恢復執行時,Wait()方法會在返回前鎖定c.L。和其他系統不同,Wait()除非被Broadcast()或者Signal()喚醒,不會主動返回。廣播給所有人。

因為線程中Wait()方法是第一個恢復執行的,而此時c.L未加鎖。調用者不應假設Wait()恢復時條件已滿足,相反,調用者應在循環中等待。

具體使用方式如例所示。

Go語言並發編程小結

本篇首先介紹瞭Goroutine的特性以及使用方法,其次是Channnel的使用方法,然後是select的機制,最後是sync與time包的使用。本章的內容至關重要,尤其在Go服務器的編程中用處頗多,大傢多加練習就可以編寫出高可用的並發服務器瞭。