
Goroutine入門輕量級線程為什么這么輕摘要Go goroutine詳解包括go關鍵字啟動協(xié)程、GMP調度模型原理、goroutine與操作系統(tǒng)線程的區(qū)別、??臻g動態(tài)擴容機制、goroutine泄漏排查與context生命周期管理。我之前寫Java的時候服務里配了個200線程的池子QPS一高就排隊。后來轉到Go寫了個爬蟲隨手起了上萬個goroutine抓數(shù)據(jù)內存才漲了幾十兆。當時我就覺得這玩意兒有點東西。模塊一我們打完了基礎從語法到編碼規(guī)范都過了一遍。從這篇開始進入模塊二并發(fā)編程。Go能在云原生領域火起來goroutine功不可沒。這篇先從最基本的用法講起再深入聊聊它為什么這么輕。一、Goroutine基本使用啟動一個goroutine簡單到離譜在函數(shù)調用前面加個go關鍵字就行。packagemainimport(fmttime)// 普通函數(shù)會被goroutine執(zhí)行funcsayHello(namestring){// 簡單的打印任務fmt.Printf(hello, %s\n,name)}funcmain(){// 用go關鍵字啟動goroutine不會阻塞主線程gosayHello(張三)gosayHello(李四)gosayHello(王五)// main函數(shù)本身也是一個goroutine主goroutine// 如果主goroutine退出了所有子goroutine會被強制終止// 這里sleep是為了等子goroutine執(zhí)行完time.Sleep(time.Second)}也可以用匿名函數(shù)啟動goroutine適合簡短的任務。packagemainimport(fmtsync)funcmain(){varwg sync.WaitGroup// 啟動5個goroutinefori:0;i5;i{// 每啟動一個goroutine計數(shù)器加1wg.Add(1)// 注意循環(huán)變量的捕獲問題// 必須把i作為參數(shù)傳入否則所有goroutine拿到的都是最終值gofunc(idint){// 函數(shù)退出時調用Done計數(shù)器減1deferwg.Done()fmt.Printf(goroutine %d 正在工作\n,id)}(i)}// 等待所有goroutine完成wg.Wait()fmt.Println(全部完成)}二、為什么這么輕Java的線程是操作系統(tǒng)線程的一對一映射創(chuàng)建一個Java線程要分配大約1MB的??臻g。開1000個線程就吃掉1GB內存開10000個直接OOM。Go的goroutine是用戶態(tài)線程初始??臻g只有2KB。2KB是什么概念開100萬個goroutine才占2GB內存。而且goroutine的棧是動態(tài)增長的用多少給多少。packagemainimport(fmtruntime)funcmain(){// 打印當前goroutine數(shù)量// main函數(shù)本身算一個fmt.Printf(啟動前 goroutine數(shù)量: %d\n,runtime.NumGoroutine())// 啟動10萬個goroutinefori:0;i100000;i{gofunc(){// 阻塞這個goroutine防止它退出// 這樣才能觀察到存活數(shù)量select{}}()}// 打印啟動后的goroutine數(shù)量fmt.Printf(啟動后 goroutine數(shù)量: %d\n,runtime.NumGoroutine())// 打印當前內存使用情況varm runtime.MemStats runtime.ReadMemStats(m)// 10萬個goroutine大約占用幾十MB內存// 如果是Java線程10萬個需要約100GBfmt.Printf(內存占用: %.2f MB\n,float64(m.Alloc)/1024/1024)}跑一下這個程序10萬個goroutine大概占用幾十兆內存。同樣的操作用Java線程做直接炸。三、GMP調度模型goroutine這么輕的秘訣在于Go的調度器核心是GMP模型。G代表goroutine每個go語句創(chuàng)建一個G。M代表操作系統(tǒng)的線程goroutine最終要在M上跑。P代表處理器持有可運行的goroutine隊列數(shù)量默認等于CPU核心數(shù)。packagemainimport(fmtruntime)funcmain(){// GOMAXPROCS控制P的數(shù)量// 默認等于CPU邏輯核心數(shù)fmt.Printf(CPU核心數(shù): %d\n,runtime.NumCPU())fmt.Printf(GOMAXPROCS: %d\n,runtime.GOMAXPROCS(0))// 可以手動設置P的數(shù)量// 一般不需要改默認值就是最優(yōu)的runtime.GOMAXPROCS(4)fmt.Printf(修改后GOMAXPROCS: %d\n,runtime.GOMAXPROCS(0))// 當goroutine執(zhí)行阻塞IO操作時比如網絡請求// 調度器會把當前M和P分離讓P去執(zhí)行其他goroutine// 這就是為什么goroutine做IO密集型任務特別高效}調度器的工作流程大致是這樣的。P的本地隊列里有待運行的goroutineM從P的隊列里取G來執(zhí)行。如果本地隊列空了P會去全局隊列偷一些。如果全局也空了P會去別的P的隊列里偷一半過來。這種work-stealing機制讓所有CPU核心都能充分利用。goroutine遇到系統(tǒng)調用阻塞時調度器會把這個M和P解綁讓另一個M接手P繼續(xù)執(zhí)行其他goroutine。等系統(tǒng)調用返回原來的M會嘗試獲取一個空閑的P繼續(xù)跑沒有空閑P的話這個G就進全局隊列等待。四、Goroutine生命周期管理goroutine啟動容易管理難。最常見的問題是goroutine泄漏啟動了但永遠不退出。packagemainimport(contextfmttime)funcworker(ctx context.Context,idint){// 使用context來管理goroutine的生命周期// context的Done通道在取消或超時時會關閉for{select{case-ctx.Done():// 收到取消信號退出goroutinefmt.Printf(worker %d 收到取消信號退出\n,id)returndefault:// 執(zhí)行正常工作fmt.Printf(worker %d 正在工作\n,id)time.Sleep(500*time.Millisecond)}}}funcmain(){// 創(chuàng)建帶超時的context3秒后自動取消ctx,cancel:context.WithTimeout(context.Background(),3*time.Second,)defercancel()// 確保cancel被調用釋放資源// 啟動3個worker goroutinefori:1;i3;i{goworker(ctx,i)}// 等待足夠時間讓所有worker超時退出time.Sleep(4*time.Second)fmt.Println(主程序退出)}獨家踩坑Goroutine泄漏有一次線上服務內存蹭蹭往上漲每個小時漲幾百兆。用了pprof查了半天發(fā)現(xiàn)是goroutine數(shù)量在持續(xù)增長從啟動時的幾百個漲到了十萬個。排查代碼發(fā)現(xiàn)是一個HTTP處理函數(shù)里起了goroutine做異步任務但這個goroutine內部有個無限循環(huán)只有channel收到數(shù)據(jù)才退出。問題是這個channel在某些異常路徑下永遠不會被寫入goroutine就永遠卡在那里。每個請求泄漏一個goroutine一天下來就泄漏了好幾萬個。// 錯誤代碼goroutine會泄漏funchandleRequest(w http.ResponseWriter,r*http.Request){resultCh:make(chanint)// 啟動goroutine處理任務gofunc(){val:doSomething()// 如果doSomething panic了這里永遠不會執(zhí)行// channel永遠不會被寫入但goroutine也不會退出resultCh-val}()// 如果客戶端斷開連接這里不會讀取resultCh// 上面的goroutine永遠阻塞在發(fā)送操作上select{caseres:-resultCh:w.Write([]byte(fmt.Sprintf(result: %d,res)))case-time.After(2*time.Second):w.Write([]byte(timeout))// 超時返回了但goroutine還卡在resultCh - val// 這就是泄漏的原因}}修復方案是加context控制和帶緩沖的channel。// 修復后的代碼funchandleRequest(w http.ResponseWriter,r*http.Request){// 用request的context客戶端斷開時自動取消ctx:r.Context()// 用帶緩沖的channel發(fā)送方不會永久阻塞resultCh:make(chanint,1)gofunc(){// 用recover防止panic導致goroutine異常退出deferfunc(){ifr:recover();r!nil{// 記錄日志goroutine正常退出log.Printf(goroutine panic: %v,r)}}()val:doSomething()// 帶緩沖的channel即使沒人接收也不會阻塞resultCh-val}()select{caseres:-resultCh:w.Write([]byte(fmt.Sprintf(result: %d,res)))case-ctx.Done():// 客戶端斷開連接直接返回// goroutine會通過帶緩沖的channel自動完成發(fā)送然后退出returncase-time.After(2*time.Second):w.Write([]byte(timeout))}}對比分析跟Java比goroutine的創(chuàng)建成本極低。Java線程是1:1映射到操作系統(tǒng)線程的創(chuàng)建和切換開銷大。Java要開大量并發(fā)任務得用線程池復用線程。Go的goroutine是N:M映射多個goroutine復用少量操作系統(tǒng)線程創(chuàng)建和切換都在用戶態(tài)完成。跟Python比Python的asyncio是單線程協(xié)程一個協(xié)程阻塞了整個事件循環(huán)都卡住。Go的goroutine可以真正利用多核CPU一個goroutine阻塞不影響其他goroutine。Python要利用多核得靠多進程進程間通信成本遠高于goroutine。跟Rust比Rust的async跟goroutine理念類似但Rust要求手動管理異步運行時tokio等學習曲線陡。Go開箱即用go關鍵字一加就完事了??偨Y這篇講了goroutine的基本用法和底層原理。go關鍵字啟動協(xié)程GMP調度模型讓少量線程跑大量goroutine2KB初始棧加上動態(tài)擴容是輕量的關鍵。踩坑那節(jié)說的goroutine泄漏是Go開發(fā)中最常見的坑用context和帶緩沖channel可以避免。下一篇我們講channelgoroutine之間怎么通信。Go的并發(fā)哲學是不要通過共享內存來通信而要通過通信來共享內存channel就是實現(xiàn)這套哲學的核心工具。