golang並發(1)介紹
概述
簡而言之,所謂並發編程是指在一臺處理器上“同時”處理多個任務。
隨著硬件的發展,並發程序變得越來越重要。Web服務器會一次處理成千上萬的請求。平板電腦和手機app在渲染用戶畫面同時還會後臺執行各種計算任務和網絡請求。即使是傳統的批處理問題--讀取數據,計算,寫輸出--現在也會用並發來隱藏掉I/O的操作延遲以充分利用現代計算機設備的多個核心。計算機的性能每年都在以非線性的速度增長。
宏觀的並發是指在一段時間內,有多個程序在同時運行。
並發在微觀上,是指在同一時刻只能有一條指令執行,但多個程序指令被快速的輪換執行,使得在宏觀上具有多個進程同時執行的效果,但在微觀上並不是同時執行的,只是把時間分成若幹段,使多個程序快速交替的執行。
並行和並發
並行(parallel):指在同一時刻,有多條指令在多個處理器上同時執行。
並發(concurrency):指在同一時刻只能有一條指令執行,但多個進程指令被快速的輪換執行,使得在宏觀上具有多個進程同時執行的效果,但在微觀上並不是同時執行的,只是把時間分成若幹段,通過cpu時間片輪轉使多個進程快速交替的執行。
大師曾以咖啡機的例子來解釋並行和並發的區別。
- 並行是兩個隊列同時使用兩臺咖啡機 (真正的多任務)
- 並發是兩個隊列交替使用一臺咖啡機 ( 假 的多任務)
常見並發編程技術
進程並發
程序和進程
程序,是指編譯好的二進制文件,在磁盤上,不占用系統資源
進程,是一個抽象的概念,與操作系統原理聯系緊密。進程是活躍的程序,占用系統資源。在內存中執行。(程序運行起來,產生一個進程)
程序 → 劇本(紙) 進程 → 戲(舞臺、演員、燈光、道具...)
同一個劇本可以在多個舞臺同時上演。同樣,同一個程序也可以加載為不同的進程(彼此之間互不影響)
如:同時開兩個終端。各自都有一個bash但彼此ID不同。
在windows系統下,通過查看“任務管理器”,可以查看相應的進程。包括我們在基礎班寫的“飛機大戰”等程序,運行起來後也可以在“任務管理器”中查看到。運行起來的程序就是一個進程。如下圖所示:
進程狀態
進程基本的狀態有5種。分別為初始態,就緒態,運行態,掛起態與終止態。其中初始態為進程準備階段,常與就緒態結合來看。
進程並發
在使用進程 實現並發時會出現什麽問題呢?
1:系統開銷比較大,占用資源比較多,開啟進程數量比較少。
2:在unix/linux系統下,還會產生“孤兒進程”和“僵屍進程”。
通過前面查看操作系統的進程信息,我們知道在操作系統中,可以產生很多的進程。在unix/linux系統中,正常情況下,子進程是通過父進程fork創建的,子進程再創建新的進程。
並且父進程永遠無法預測子進程 到底什麽時候結束。 當一個 進程完成它的工作終止之後,它的父進程需要調用系統調用取得子進程的終止狀態。
孤兒進程
孤兒進程: 父進程先於子進程結束,則子進程成為孤兒進程,子進程的父進程成為init進程,稱為init進程領養孤兒進程。
僵屍進程
僵屍進程: 進程終止,父進程尚未回收,子進程殘留資源(PCB)存放於內核中,變成僵屍(Zombie)進程。
Windows下的進程和Linux下的進程是不一樣的,它比較懶惰,從來不執行任何東西,只是為線程提供執行環境。然後由線程負責執行包含在進程的地址空間中的代碼。當創建一個進程的時候,操作系統會自動創建這個進程的第一個線程,成為主線程。
線程並發
什麽是線程
LWP:light weight process 輕量級的進程,本質仍是進程 (Linux下)
進程:獨立地址空間,擁有PCB
線程:有獨立的PCB,但沒有獨立的地址空間(共享)
區別:在於是否共享地址空間。獨居(進程);合租(線程)。
線程:最小的執行單位
進程:最小分配資源單位,可看成是只有一個線程的進程。
Windows系統下,可以直接忽略進程的概念,只談線程。因為線程是最小的執行單位,是被系統獨立調度和分派的基本單位。而進程只是給線程提供執行環境。
線程同步
同步即協同步調,按預定的先後次序運行。
線程同步,指一個線程發出某一功能調用時,在沒有得到結果之前,該調用不返回。同時其它線程為保證數據一致性,不能調用該功能。
舉例1: 銀行存款 5000。櫃臺,折:取3000;提款機,卡:取 3000。剩余:2000
舉例2: 內存中100字節,線程T1欲填入全1, 線程T2欲填入全0。但如果T1執行了50個字節失去cpu,T2執行,會將T1寫過的內容覆蓋。當T1再次獲得cpu繼續 從失去cpu的位置向後寫入1,當執行結束,內存中的100字節,既不是全1,也不是全0。
產生的現象叫做“與時間有關的錯誤”(time related)。為了避免這種數據混亂,線程需要同步。
“同步”的目的,是為了避免數據混亂,解決與時間有關的錯誤。實際上,不僅線程間需要同步,進程間、信號間等等都需要同步機制。
因此,所有“多個控制流,共同操作一個共享資源”的情況,都需要同步。
鎖的應用
互斥量mutex
Linux中提供一把互斥鎖mutex(也稱之為互斥量)。
每個線程在對資源操作前都嘗試先加鎖,成功加鎖才能操作,操作結束解鎖。
資源還是共享的,線程間也還是競爭的,
但通過“鎖”就將資源的訪問變成互斥操作,而後與時間有關的錯誤也不會再產生了。
但,應註意:同一時刻,只能有一個線程持有該鎖。
當A線程對某個全局變量加鎖訪問,B在訪問前嘗試加鎖,拿不到鎖,B阻塞。C線程不去加鎖,而直接訪問該全局變量,依然能夠訪問,但會出現數據混亂。
所以,互斥鎖實質上是操作系統提供的一把“建議鎖”(又稱“協同鎖”),建議程序中有多線程訪問共享資源的時候使用該機制。但,並沒有強制限定。
因此,即使有了mutex,如果有線程不按規則來訪問數據,依然會造成數據混亂。
讀寫鎖
與互斥量類似,但讀寫鎖允許更高的並行性。其特性為:寫獨占,讀共享。
l 讀寫鎖狀態:
特別強調:讀寫鎖只有一把,但其具備兩種狀態:
1. 讀模式下加鎖狀態 (讀鎖)
2. 寫模式下加鎖狀態 (寫鎖)
l 讀寫鎖特性:
- 讀寫鎖是“寫模式加鎖”時, 解鎖前,所有對該鎖加鎖的線程都會被阻塞。
- 讀寫鎖是“讀模式加鎖”時, 如果線程以讀模式對其加鎖會成功;如果線程以寫模式加鎖會阻塞。
- 讀寫鎖是“讀模式加鎖”時, 既有試圖以寫模式加鎖的線程,也有試圖以讀模式加鎖的線程。那麽讀寫鎖會阻塞隨後的讀模式鎖請求。優先滿足寫模式鎖。讀鎖、寫鎖並行阻塞,寫鎖優先級高
讀寫鎖也叫共享-獨占鎖。當讀寫鎖以讀模式鎖住時,它是以共享模式鎖住的;當它以寫模式鎖住時,它是以獨占模式鎖住的。寫獨占、讀共享。
讀寫鎖非常適合於對數據結構讀的次數遠大於寫的情況。
協程並發
協程:coroutine。也叫輕量級線程。
與傳統的系統級線程和進程相比,協程最大的優勢在於“輕量級”。可以輕松創建上萬個而不會導致系統資源衰竭。而線程和進程通常很難超過1萬個。這也是協程別稱“輕量級線程”的原因。
一個線程中可以有任意多個協程,但某一時刻只能有一個協程在運行,多個協程分享該線程分配到的計算機資源。
多數語言在語法層面並不直接支持協程,而是通過庫的方式支持,但用庫的方式支持的功能也並不完整,比如僅僅提供協程的創建、銷毀與切換等能力。如果在這樣的輕量級線程中調用一個同步 IO 操作,比如網絡通信、本地文件讀寫,都會阻塞其他的並發執行輕量級線程,從而無法真正達到輕量級線程本身期望達到的目標。
在協程中,調用一個任務就像調用一個函數一樣,消耗的系統資源最少!但能達到進程、線程並發相同的效果。
在一次並發任務中,進程、線程、協程均可以實現。從系統資源消耗的角度出發來看,進程相當多,線程次之,協程最少。
Go並發
Go 在語言級別支持協程,叫goroutine。Go 語言標準庫提供的所有系統調用操作(包括所有同步IO操作),都會出讓CPU給其他goroutine。這讓輕量級線程的切換管理不依賴於系統的線程和進程,也不需要依賴於CPU的核心數量。
有人把Go比作21世紀的C語言。第一是因為Go語言設計簡單,第二,21世紀最重要的就是並行程序設計,而Go從語言層面就支持並行。同時,並發程序的內存管理有時候是非常復雜的,而Go語言提供了自動垃圾回收機制。
Go語言為並發編程而內置的上層API基於順序通信進程模型CSP(communicating sequential processes)。這就意味著顯式鎖都是可以避免的,因為Go通過相對安全的通道發送和接受數據以實現同步,這大大地簡化了並發程序的編寫。
Go語言中的並發程序主要使用兩種手段來實現。goroutine和channel。
Goroutine
什麽是Goroutine
goroutine是Go並行設計的核心。goroutine說到底其實就是協程,它比線程更小,十幾個goroutine可能體現在底層就是五六個線程,Go語言內部幫你實現了這些goroutine之間的內存共享。執行goroutine只需極少的棧內存(大概是4~5KB),當然會根據相應的數據伸縮。也正因為如此,可同時運行成千上萬個並發任務。goroutine比thread更易用、更高效、更輕便。
一般情況下,一個普通計算機跑幾十個線程就有點負載過大了,但是同樣的機器卻可以輕松地讓成百上千個goroutine進行資源競爭。
Goroutine的創建
只需在函數調?語句前添加 go 關鍵字,就可創建並發執?單元。開發?員無需了解任何執?細節,調度器會自動將其安排到合適的系統線程上執行。
在並發編程中,我們通常想將一個過程切分成幾塊,然後讓每個goroutine各自負責一塊工作,當一個程序啟動時,主函數在一個單獨的goroutine中運行,我們叫它main goroutine。新的goroutine會用go語句來創建。而go語言的並發設計,讓我們很輕松就可以達成這一目的。
示例代碼:
package main import ( "fmt" "time" ) func newTask() { i := 0 for { i++ fmt.Printf("new goroutine: i = %d\n", i) time.Sleep(1 * time.Second) //延時1s } } func main() { //創建一個 goroutine,啟動另外一個任務 go newTask() i := 0 //main goroutine 循環打印 for { i++ fmt.Printf("main goroutine: i = %d\n", i) time.Sleep(1 * time.Second) //延時1s } }
程序運行結果:
Goroutine特性
主goroutine退出後,其它的工作goroutine也會自動退出:
1 package main 2 3 import ( 4 "fmt" 5 "time" 6 ) 7 8 func newTask() { 9 i := 0 10 for { 11 i++ 12 fmt.Printf("new goroutine: i = %d\n", i) 13 time.Sleep(1 * time.Second) //延時1s 14 } 15 } 16 17 func main() { 18 //創建一個 goroutine,啟動另外一個任務 19 go newTask() 20 21 fmt.Println("main goroutine exit") 22 }View Code
程序運行結果:
runtime包
Gosched
runtime.Gosched() 用於讓出CPU時間片,讓出當前goroutine的執行權限,調度器安排其他等待的任務運行,並在下次再獲得cpu時間輪片的時候,從該出讓cpu的位置恢復執行。
有點像跑接力賽,A跑了一會碰到代碼runtime.Gosched() 就把接力棒交給B了,A歇著了,B繼續跑。
示例代碼:
1 package main 2 3 import ( 4 "fmt" 5 "runtime" 6 ) 7 8 func main() { 9 //創建一個goroutine 10 go func(s string) { 11 for i := 0; i < 2; i++ { 12 fmt.Println(s) 13 } 14 }("world") 15 16 for i := 0; i < 2; i++ { 17 runtime.Gosched() //import "runtime" 包 18 /* 19 屏蔽runtime.Gosched()運行結果如下: 20 hello 21 hello 22 23 沒有runtime.Gosched()運行結果如下: 24 world 25 world 26 hello 27 hello 28 */ 29 fmt.Println("hello") 30 } 31 }View Code
以上程序的執行過程如下:
主協程進入main()函數,進行代碼的執行。當執行到go func()匿名函數時,創建一個新的協程,開始執行匿名函數中的代碼,主協程繼續向下執行,執行到runtime.Gosched( )時會暫停向下執行,直到其它協程執行完後,再回到該位置,主協程繼續向下執行。
Goexit
調用 runtime.Goexit() 將立即終止當前 goroutine 執?,調度器確保所有已註冊 defer延遲調用被執行。
示例代碼:
1 package main 2 3 import ( 4 "fmt" 5 "runtime" 6 ) 7 8 func main() { 9 go func() { 10 defer fmt.Println("A.defer") 11 12 func() { 13 defer fmt.Println("B.defer") 14 runtime.Goexit() // 終止當前 goroutine, import "runtime" 15 fmt.Println("B") // 不會執行 16 }() 17 18 fmt.Println("A") // 不會執行 19 }() //不要忘記() 20 21 //死循環,目的不讓主goroutine結束 22 for { 23 } 24 }View Code
程序運行結果:
GOMAXPROCS
調用 runtime.GOMAXPROCS() 用來設置可以並行計算的CPU核數的最大值,並返回之前的值。
示例代碼:
1 package main 2 3 import ( 4 "fmt" 5 ) 6 7 func main() { 8 //n := runtime.GOMAXPROCS(1) // 第一次 測試 9 //打印結果:111111111111111111110000000000000000000011111... 10 11 n := runtime.GOMAXPROCS(2) // 第二次 測試 12 //打印結果:010101010101010101011001100101011010010100110... 13 fmt.Printf("n = %d\n", n) 14 15 for { 16 go fmt.Print(0) 17 fmt.Print(1) 18 } 19 }View Code
在第一次執行runtime.GOMAXPROCS(1) 時,最多同時只能有一個goroutine被執行。所以會打印很多1。過了一段時間後,GO調度器會將其置為休眠,並喚醒另一個goroutine,這時候就開始打印很多0了,在打印的時候,goroutine是被調度到操作系統線程上的。
在第二次執行runtime.GOMAXPROCS(2) 時, 我們使用了兩個CPU,所以兩個goroutine可以一起被執行,以同樣的頻率交替打印0和1。
golang並發(1)介紹