如果說Go lang是靜態語言中的皇冠,那麼,Goroutine就是併發編程方式中的鑽石。Goroutine是Go語言設計體系中最核心的精華,它非常輕量,一個 Goroutine 只占幾 KB,並且這幾 KB 就足夠 Goroutine 運行完,這就能在有限的記憶體空間內支持大量 Goroutine協 ...
如果說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。