並行與並行,同步和非同步,Go lang1.18入門精煉教學,由白丁入鴻儒,Go lang並行程式設計之GoroutineEP13

2022-08-30 18:03:12

如果說Go lang是靜態語言中的皇冠,那麼,Goroutine就是並行程式設計方式中的鑽石。Goroutine是Go語言設計體系中最核心的精華,它非常輕量,一個 Goroutine 只佔幾 KB,並且這幾 KB 就足夠 Goroutine 執行完,這就能在有限的記憶體空間內支援大量 Goroutine協程任務,方寸之間,運籌帷幄,用極少的成本獲取最高的效率,支援了更多的並行,毫無疑問,Goroutine是比Python的協程原理事件迴圈更高階的並行非同步程式設計方式。

GMP排程模型(Goroutine-Machine-Processor)

為什麼Goroutine比Python的事件迴圈高階?是因為Go lang的排程模型GMP可以參與系統核心執行緒中的排程,這裡G為Goroutine,是被排程的最小單元;M是系統起了多少個執行緒;P為Processor,也就是CPU處理器,排程器的核心處理器,通常表示執行上下文,用於匹配 M 和 G 。P 的數量不能超過 GOMAXPROCS 設定數量,這個引數的預設值為當前電腦的總核心數,通常一個 P 可以與多個 M 對應,但同一時刻,這個 P 只能和其中一個 M 發生繫結關係;M 被建立之後需要自行在 P 的 free list 中找到 P 進行繫結,沒有繫結 P 的 M,會進入阻塞狀態,每一個P最多關聯256個G。

說白了,就是GMP和Python一樣,也是維護一個任務佇列,只不過這個任務佇列是通過Goroutine來排程,怎麼排程?通過Goroutine和系統執行緒M的協商,尋找非阻塞的通道,進入P的本地小佇列,然後交給系統內的CPU執行,藉此,充分利用了CPU的多核資源。

而Python的協程方式僅僅停留在使用者態,它沒法參與到執行緒核心的排程,彌補方式是單執行緒多協程任務下開多程序,Go lang則是全權交給Goroutine,使用者不需要參與底層操作,同時又可以利用CPU的多核資源。

啟動Goroutine

首先預設情況下,golang程式還是由上自下的序列方式:

package main  
  
import (  
	"fmt"  
)  
  
func job() {  
	fmt.Println("任務執行")  
}  
func main() {  
	job()  
	fmt.Println("任務執行完了")  
}

程式返回:

任務執行  
任務執行完了

這裡job中的列印函數是先於main中的列印函數。

現在,在執行job函數前面加上關鍵字go,也就是啟動一個goroutine去執行job這個函數:

package main  
  
import (  
	"fmt"  
	"time"  
)  
  
func job() {  
	fmt.Println("任務執行")  
}  
func main() {  
	go job()  
	fmt.Println("任務執行完了")  
	time.Sleep(time.Second)  
}

注意,開啟Goroutine是在函數執行的時候開啟,並非宣告的時候,程式返回:



任務執行完了  
任務執行


可以看到,執行順序顛倒了過來,首先為什麼會先列印任務執行完了,是因為系統在建立新的Goroutine的時候需要耗費一些資源,因為就算只有幾kb,也需要時間來建立,而此時main函數所在的goroutine是繼續執行的。

第二,為什麼要人為的把main函數延遲一秒鐘?

因為當main()函數返回的時候main所在的Goroutine就結束了,所有在main()函數中啟動的goroutine會一同結束,所以這裡必須人為的「阻塞」一下main函數,讓它後於job結束,有點像公園如果要關門必須等最後一個遊客走了才能關,否則就把遊客關在公園裡了,出不去了。

與此同時,此邏輯和Python中的執行緒阻塞邏輯非常一致,用過Python多執行緒的朋友肯定知道要想讓所有子執行緒都執行完畢,必須阻塞主執行緒,不能讓主執行緒提前執行完,這和Goroutine有異曲同工之妙。

在Go lang中實現並行程式設計就是如此輕鬆,我們還可以啟動多個Goroutine:



package main  
  
import (  
	"fmt"  
	"sync"  
)  
  
var wg sync.WaitGroup  
  
func job(i int) {  
	defer wg.Done() // 協程結束就通知  
	fmt.Println("協程任務執行", i)  
}  
func main() {  
  
	for i := 0; i < 10; i++ {  
		wg.Add(1) // 啟動協程任務後入隊  
		go job(i)  
	}  
	wg.Wait() // 等待所有登記的goroutine都結束  
  
	fmt.Println("所有任務執行完畢")  
}  



程式返回:



協程任務執行 8  
協程任務執行 9  
協程任務執行 5  
協程任務執行 0  
協程任務執行 1  
協程任務執行 4  
協程任務執行 7  
協程任務執行 2  
協程任務執行 3  
協程任務執行 6  
所有任務執行完畢


這裡我們摒棄了相對土鱉的time.Sleep(time.Second)方式,而是採用sync包的WaitGroup方式,原理是當啟動協程任務後,在WaitGroup登記,當每個協程任務執行完成後,通知WaitGroup,直到所有的協程任務都執行完畢,然後再執行main函數所在的協程,所以「所有任務執行完畢」會在所有協程任務執行完畢後再列印。

和Python協程區別

我們再來看看,如果是Python,會怎麼做?



import asyncio  
import random  
  
async def job(i):  
  
    print("協程任務執行{}".format(i))  
    await asyncio.sleep(random.randint(1,5))  
    print("協程任務結束{}".format(i))  
  
  
  
async def main():  
  
    tasks = [asyncio.create_task(job(i)) for i in range(10)]  
      
    res = await asyncio.gather(*tasks)  
  
  
if __name__ == '__main__':  
    asyncio.run(main())


程式返回:

協程任務執行0  
協程任務執行1  
協程任務執行2  
協程任務執行3  
協程任務執行4  
協程任務執行5  
協程任務執行6  
協程任務執行7  
協程任務執行8  
協程任務執行9  
協程任務結束0  
協程任務結束1  
協程任務結束3  
協程任務結束6  
協程任務結束9  
協程任務結束8  
協程任務結束2  
協程任務結束4  
協程任務結束5  
協程任務結束7

可以看到,Python協程工作的前提是,必須在同一個事件迴圈中,同時邏輯內必須由使用者來手動切換,才能達到「並行」的工作方式,假設,如果我們不手動切換呢?

import asyncio  
import random  
  
async def job(i):  
  
    print("協程任務執行{}".format(i))  
    print("協程任務結束{}".format(i))  
  
  
  
async def main():  
  
    tasks = [asyncio.create_task(job(i)) for i in range(10)]  
      
    res = await asyncio.gather(*tasks)  
  
  
if __name__ == '__main__':  
    asyncio.run(main())

程式返回:

協程任務執行0  
協程任務結束0  
協程任務執行1  
協程任務結束1  
協程任務執行2  
協程任務結束2  
協程任務執行3  
協程任務結束3  
協程任務執行4  
協程任務結束4  
協程任務執行5  
協程任務結束5  
協程任務執行6  
協程任務結束6  
協程任務執行7  
協程任務結束7  
協程任務執行8  
協程任務結束8  
協程任務執行9  
協程任務結束9

一望而知,只要你不手動切任務,它就立刻回到了「序列」的工作方式,同步的執行任務,那麼協程的意義在哪兒呢?

所以,歸根結底,Goroutine除了可以極大的利用系統多核資源,它還能幫助開發者來切換協程任務,簡化開發者的工作,說白了就是,不懂協程工作原理,也能照貓畫虎寫go lang程式碼,但如果不懂協程工作原理的前提下,寫Python協程並行邏輯呢?恐怕夠嗆吧。

結語

綜上,Goroutine的工作方式,就是多個協程在多個執行緒上切換,既可以用到多核,又可以減少切換開銷。但有光就有影,有利就有弊,Goroutine確實不需要開發者過度參與,但這樣開發者就少了很多自由度,一些客製化化場景下,就只能採用單一的Goroutine手段,比如一些純IO密集型任務場景,像爬蟲,你有多少cpu的意義並不大,因為cpu老是等著你的io操作,所以Python這種協程工作方式在純IO密集型任務場景下並不遜色於Goroutine。