Go 中的併發

又是快樂的週五,按照往常的習慣,週五是不可能好好的工作,不摸它一天的🐟都對不起這個好日子,但是平白無故的摸魚又有一定的罪惡感,而且還需要防範主管的巡視,但是!!!!

事情總會有漏洞,比如說當我在寫文章,雖然也是在幹着跟工作無關的內容,但是!!卻可以心安理得的,因爲這是一個學習的過程,而只有更好的學習,纔可以回報公司滴!!(必須這樣想)那麼話不多說,開始今天的內容。

在聊併發和並行的操作的時候,必須瞭解什麼是操作系統的進城,線程以及 Go 中的 goroutine。

這裏有我之前寫的一篇小文章 (juejin.cn/post/695249…)

再聊到 Go 中的併發的時候,要先了解一下併發和並行的區別。上面這張圖已經說明了併發和並行一個問題,其實可以這麼理解——

併發:在一個時間段內完成處理一些事情

並行:在一個時間點內同時做一些事情

通過上圖也是看得出來,其實併發就是一個咖啡機交替性的去處理兩個等待隊列的事情,只不過因爲處理的非常的快,所以很多時候,用戶感覺跟並行一樣。

上圖展示了一個包含了所有可以分配的資源的進程。其中可以看到一個線程就是一個執行的空間,它被操作系統所調用,用來執行相對於的一些函數等。

操作系統會在物理層面去調用線程來運行,而在 Go 中,則是由邏輯處理器來(也就是函數),來控制和調度 goroutine,其中在 Go 語言運行的時候,默認的,會爲每一個物理處理器分配一個邏輯處理器,這些邏輯處理器是用來併發的調度所有的被創建出來的 goroutine 的。

我們知道對於多線程來說,線程的調度是一件很耗能的事情,但是在 Go 中 goroutine 卻不存在這樣的問題,雖然本質上來說都是讓一個 goroutine 暫時掛起,然後讓其他的 goroutine 繼續去工作,但是因爲邏輯處理器在調度 goroutine 的時候,並不需要進入到線程調度中的內核上下文中去,所以這裏所需要的代價就小了很多。

說到 goroutine 就必須說道 Go 語言中的 GMP 模型

從上圖我們可以看到 M 指的是執行的線程,P 指的是邏輯處理器,G 指的就是 goroutine,當 Go 語言運行的時候,邏輯處理器會綁定到一個操作系統的線程上去,然後當 goroutine 可以運行的時候,會被放入邏輯處理器的執行隊列中,通過邏輯處理器,將一個個 goroutine 供給給線程去執行。

當執行到這個 goroutine 發生阻塞的時候(例如一些打開文件什麼的),邏輯處理器會從該線程中分離分離出來,然後到一個新的線程上去,繼續運行整個服務,而原來的線程則會繼續阻塞,等待系統的調用返回。

注意:如果 goroutine 進行的是一次 IO 網絡調用的話,雖然也是會發生阻塞,但跟上面的情況又有些不一樣。

首先 goroutine 會和邏輯處理器分離開來,然後所有的 goroutine 會移植到網路輪詢器上面去運行,當 goroutine 完成了對於網絡的讀或者寫的時候,它才被重新放回邏輯處理器的隊列上去。

讓我們先來看一段代碼

func main()  {
	var w sync.WaitGroup 
	
	w.Add(2) 
	go func() {
		defer w.Done()
		for i:=0;i<3 ;i++  {
			for char:='A';char<'A'+26 ;char++  {
				fmt.Printf("%c",char)
                                
			}
		}
	}()
	go func() {
		defer w.Done()
		for i:=0;i<3 ;i++  {
			for char:='a';char<'a'+26 ;char++  {
				fmt.Printf("%c",char)
                                
			}
		}
	}()
	w.Wait()
}

首先我們要知道,main 函數其實也是一個 goroutine,不過它是最主要的一個,當 main 函數退出的時候,裏面的 goroutine 就算沒有執行完成,也會被系統給回收,所以這裏我們需要用到 sync.WaitGroup 來等待 goroutine 的完成後,main 函數再退出。

而當我們執行上面那段代碼的時候,結果是這樣的

abcdefghijABCDEFGHIJKLMklmnopqrstuvwxyzabNOPQRSTUVWX......

產生這樣的結果的原因,是因爲這兩個 goroutine 是在並行執行的,因爲在運行的 Go 的時候,默認情況下,我的電腦是一臺八核的電腦,他會啓用八條進程(每個進程內有一條線程)同時的來調度 Go 函數,也就是會有多個邏輯處理器,同時來處理這些函數,而這也就是它並行的原因。

注意:只有當存在多個邏輯處理器,並且讓每個 goroutine 運行在一個獨立的邏輯處理器上面的時候,goroutine 纔會並行運行。

當邏輯處理器只有一個的時候,又會出現什麼情況?我們同樣的使用上面的代碼,不過需要加多一行,來限制它的邏輯處理器數量(也就是會有多少個線程操作這次 Go 程序

func main()  {
	var w sync.WaitGroup 

	runtime.GOMAXPROCS(1)
	w.Add(2) 
	go func() {
		defer w.Done()
		for i:=0;i<3 ;i++  {
			for char:='A';char<'A'+26 ;char++  {
				fmt.Printf("%c",char)
			}
		}
	}()
	go func() {
		defer w.Done()
		for i:=0;i<3 ;i++  {
			for char:='a';char<'a'+26 ;char++  {
				fmt.Printf("%c",char)
			}
		}
	}()
	w.Wait()
}

它的執行結果是這樣的

abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLM

我們可以看到,它是先輸出了三次小寫的 a-z,然後再輸出大寫的 A~Z,這是因爲只有一個邏輯處理器的時候,第一個 goroutine 很快執行完了,然後它的輸出被放到儲存的棧裏面去,我們知道棧是後進先出的,所以它會先輸出三遍第二個的 goroutine 的小寫字母,然後在輸出大寫字母。

當時,當一個 goroutine 持續的時間比較長的時候又會怎麼樣呢,同樣的我們也是使用上面的代碼,只不過我們把循環加大到 30000 次,執行的結果就會變成這樣

前面忽略一部分
abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijkABCDEFGHIJKLMNOPQRSTUVW
中間再忽略一部分
stuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuv

我們可以看到它在打印了很長的一段小寫字母的 a~z 之後,它會切換到去打印大寫字母,然後又切換回來打印小寫字母。

原因是邏輯處理器爲了避免一個 goroutine 在執行的時候,佔用的時間過長,當這個 goroutine 運行的時間過於漫長的時候,它會停止當前的 goroutine,轉而給其他 goroutine 運行的機會,所以就會有上面的情況出現。

goroutine 可以說是整個 Go 語言的核心,但是它也非常的難以理解,例如我們知道 goroutine 是有伸縮性的,最大它可以渠道 2G 差不多,而這也就會擴展出非常多的線程,但是,當 goroutine 執行完成被回收的時候,這些擴展出去的線程是怎麼處理的,Go 的垃圾回收機制沒有回收線程的這一個說法。(這點我還不知道—_-)

還有我上面所提到的所有對於 goroutine 的運用,都是沒有存在讀寫操作的,那麼在存在讀寫操作的時候,Go 語言又會通過怎麼樣的形式,來破解那些併發的問題?

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://juejin.cn/post/6953632279085776903