說說Golang goroutine並發那些事兒

摘要:今天我們一起盤點一下Golang並發那些事兒。

Golang、Golang、Golang 真的夠浪,今天我們一起盤點一下Golang並發那些事兒,準確來說是goroutine,關於多執行緒並發,咱們暫時先放一放(主要是俺現在還不太會,不敢出來瞎搞)。關於golang優點如何,咱們也不扯那些虛的。反正都是大佬在說,俺只是個吃瓜群眾,偶爾打打醬油,逃~。

說到並發,等等一系列的概念就出來了,為了做個照顧一下自己的菜,順便複習一下

基礎概念

進程

進程的定義

進程(英語:process),是指電腦中已運行的程式。進程曾經是`分時系統的基本運作單位。在面向進程設計的系統(如早期的UNIX,Linux 2.4及更早的版本)中,進程是程式的基本執行實體;在面向執行緒設計的系統(如當代多數作業系統、Linux 2.6及更新的版本)中,進程本身不是基本運行單位,而是執行緒的容器。

程式本身只是指令、數據及其組織形式的描述,相當於一個名詞,進程才是程式(那些指令和數據)的真正運行實例,可以想像說是現在進行式。若干進程有可能與同一個程式相關係,且每個進程皆可以同步或非同步的方式獨立運行。現代電腦系統可在同一段時間內以進程的形式將多個程式載入到存儲器中,並藉由時間共享(或稱時分復用),以在一個處理器上表現出同時平行性運行的感覺。同樣的,使用多執行緒技術(多執行緒即每一個執行緒都代表一個進程內的一個獨立執行上下文)的作業系統或電腦體系結構,同樣程式的平行執行緒,可在多CPU主機或網路上真正同時運行(在不同的CPU上)。

進程的創建

作業系統需要有一種方式來創建進程。

以下4種主要事件會創建進程

  1. 系統初始化 (簡單可理解為關機後的開機)
  2. 正在運行的程式執行了創建進程的系統調用(例如:朋友發了一個網址,你點擊後開啟瀏覽器進入網頁中)
  3. 用戶請求創建一個新進程(例如:打開一個程式,打開QQ、微信)
  4. 一個批量作業的初始化

進程的終止

進程在創建後,開始運行與處理相關任務。但並不會永恆存在,終究會完成或退出。那麼以下四種情況會發生進程的終止

  1. 正常退出(自願)
  2. 錯誤退出(自願)
  3. 崩潰退出(非自願)
  4. 被其他殺死(非自願)

正常退出:你退出瀏覽器,你點了一下它

錯誤退出:你此時正在津津有味的看著電視劇,突然程式內部發生bug,導致退出

崩潰退出:你程式崩潰了

被其他殺死:例如在windows上,使用任務管理器關閉進程

進程的狀態

  1. 運行態(實際佔用CPU)
  2. 就緒態(可運行、但其他進程正在運行而暫停)
  3. 阻塞態(除非某種外部的時間發生,否則進程不能運行)

前兩種狀態在邏輯上是類似的。處於這兩種狀態的進程都可以運行,只是對於第二種狀態暫時沒有分配CPU,一旦分配到了CPU即可運行

第三種狀態與前兩種不同,處於該狀態的進程不能運行,即是CPU空閑也不行。

如有興趣,可進一步了解進程的實現、多進程設計模型

進程池

進程池技術的應用至少由以下兩部分組成:

資源進程

預先創建好的空閑進程,管理進程會把工作分發到空閑進程來處理。

管理進程

管理進程負責創建資源進程,把工作交給空閑資源進程處理,回收已經處理完工作的資源進程。

資源進程跟管理進程的概念很好理解,管理進程如何有效的管理資源進程,分配任務給資源進程,回收空閑資源進程,管理進程要有效的管理資源進程,那麼管理進程跟資源進程間必然需要交互,通過IPC,訊號,訊號量,消息隊列,管道等進行交互。

進程池:準確來說它並不實際存在於我們的作業系統中,而是IPC,訊號,訊號量,消息隊列,管道等對多進程進行管理,從而減少不斷的開啟、關閉等操作。以求達到減少不必要的資源損耗

執行緒

定義

執行緒(英語:thread)是作業系統能夠進行運算調度的最小單位。大部分情況下,它被包含在進程之中,是進程中的實際運作單位。一條執行緒指的是進程中一個單一順序的控制流,一個進程中可以並發多個執行緒,每條執行緒並行執行不同的任務。在Unix System V及SunOS中也被稱為輕量進程(lightweight processes),但輕量進程更多指內核執行緒(kernel thread),而把用戶執行緒(user thread)稱為執行緒。

執行緒是獨立調度和分派的基本單位。執行緒可以為作業系統內核調度的內核執行緒

同一進程中的多條執行緒將共享該進程中的全部系統資源,如虛擬地址空間,文件描述符訊號處理等等。但同一進程中的多個執行緒有各自的調用棧(call stack),自己的暫存器環境(register context),自己的執行緒本地存儲(thread-local storage)。

一個進程可以有很多執行緒來處理,每條執行緒並行執行不同的任務。如果進程要完成的任務很多,這樣需很多執行緒,也要調用很多核心,在多核或多CPU,或支援Hyper-threading的CPU上使用多執行緒程式設計的好處是顯而易見的,即提高了程式的執行吞吐率。以人工作的樣子想像,核心相當於人,人越多則能同時處理的事情越多,而執行緒相當於手,手越多則工作效率越高。在單CPU單核的電腦上,使用多執行緒技術,也可以把進程中負責I/O處理、人機交互而常被阻塞的部分與密集計算的部分分開來執行,編寫專門的workhorse執行緒執行密集計算,雖然多任務比不上多核,但因為具備多執行緒的能力,從而提高了程式的執行效率。

執行緒池

執行緒池(英語:thread pool):一種執行緒使用模式。執行緒過多會帶來調度開銷,進而影響快取局部性和整體性能。而執行緒池維護著多個執行緒,等待著監督管理者分配可並發執行的任務。這避免了在處理短時間任務時創建與銷毀執行緒的代價。執行緒池不僅能夠保證內核的充分利用,還能防止過分調度。可用執行緒數量應該取決於可用的並發處理器、處理器內核、記憶體、網路sockets等的數量。 例如,執行緒數一般取cpu數量+2比較合適,執行緒數過多會導致額外的執行緒切換開銷。

任務調度以執行執行緒的常見方法是使用同步隊列,稱作任務隊列。池中的執行緒等待隊列中的任務,並把執行完的任務放入完成隊列中。

執行緒池模式一般分為兩種:HS/HA半同步/半非同步模式、L/F領導者與跟隨者模式。

  • 半同步/半非同步模式又稱為生產者消費者模式,是比較常見的實現方式,比較簡單。分為同步層、隊列層、非同步層三層。同步層的主執行緒處理工作任務並存入工作隊列,工作執行緒從工作隊列取出任務進行處理,如果工作隊列為空,則取不到任務的工作執行緒進入掛起狀態。由於執行緒間有數據通訊,因此不適於大數據量交換的場合。
  • 領導者跟隨者模式,在執行緒池中的執行緒可處在3種狀態之一:領導者leader、追隨者follower或工作者processor。任何時刻執行緒池只有一個領導者執行緒。事件到達時,領導者執行緒負責消息分離,並從處於追隨者執行緒中選出一個來當繼任領導者,然後將自身設置為工作者狀態去處置該事件。處理完畢後工作者執行緒將自身的狀態置為追隨者。這一模式實現複雜,但避免了執行緒間交換任務數據,提高了CPU cache相似性。在ACE(Adaptive Communication Environment)中,提供了領導者跟隨者模式實現。

執行緒池的伸縮性對性能有較大的影響。

  • 創建太多執行緒,將會浪費一定的資源,有些執行緒未被充分使用。
  • 銷毀太多執行緒,將導致之後浪費時間再次創建它們。
  • 創建執行緒太慢,將會導致長時間的等待,性能變差。
  • 銷毀執行緒太慢,導致其它執行緒資源飢餓。

協程

協程,英文叫作 Coroutine,又稱微執行緒、纖程,協程是一種用戶態的輕量級執行緒。

協程擁有自己的暫存器上下文和棧。協程調度切換時,將暫存器上下文和棧保存到其他地方,在切回來的時候,恢復先前保存的暫存器上下文和棧。因此協程能保留上一次調用時的狀態,即所有局部狀態的一個特定組合,每次過程重入時,就相當於進入上一次調用的狀態。

協程本質上是個單進程,協程相對於多進程來說,無需執行緒上下文切換的開銷,無需原子操作鎖定及同步的開銷,編程模型也非常簡單。

串列

多個任務,執行完畢後再執行另一個。

例如:吃完飯後散步(先坐下吃飯、吃完後去散步)

並行

多個任務、交替執行

例如:做飯,一會放水洗菜、一會吸收(菜比較臟,洗下菜寫下手,傲嬌~)

並發

共同出發

邊吃飯、邊看電視

阻塞與非阻塞

阻塞

阻塞狀態指程式未得到所需計算資源時被掛起的狀態。程式在等待某個操作完成期間,自身無法繼續處理其他的事情,則稱該程式在該操作上是阻塞的。

常見的阻塞形式有:網路 I/O 阻塞、磁碟 I/O 阻塞、用戶輸入阻塞等。阻塞是無處不在的,包括 CPU 切換上下文時,所有的進程都無法真正處理事情,它們也會被阻塞。如果是多核 CPU 則正在執行上下文切換操作的核不可被利用。

非阻塞

程式在等待某操作過程中,自身不被阻塞,可以繼續處理其他的事情,則稱該程式在該操作上是非阻塞的。

非阻塞並不是在任何程式級別、任何情況下都可以存在的。僅當程式封裝的級別可以囊括獨立的子程式單元時,它才可能存在非阻塞狀態。

非阻塞的存在是因為阻塞存在,正因為某個操作阻塞導致的耗時與效率低下,我們才要把它變成非阻塞的。

同步與非同步

同步

不同程式單元為了完成某個任務,在執行過程中需靠某種通訊方式以協調一致,我們稱這些程式單元是同步執行的。

例如購物系統中更新商品庫存,需要用「行鎖」作為通訊訊號,讓不同的更新請求強制排隊順序執行,那更新庫存的操作是同步的。

簡言之,同步意味著有序。

非同步

為完成某個任務,不同程式單元之間過程中無需通訊協調,也能完成任務的方式,不相關的程式單元之間可以是非同步的。

例如,爬蟲下載網頁。調度程式調用下載程式後,即可調度其他任務,而無需與該下載任務保持通訊以協調行為。不同網頁的下載、保存等操作都是無關的,也無需相互通知協調。這些非同步操作的完成時刻並不確定。

可非同步與不可非同步

經過以上了解,又是進程、又是執行緒、等等一系列的東西,那是真的難受。不過相信你已經有個初步的概率,那麼這裡我們將更加深入的去了解可非同步與不可非同步。

在此之前先總結一下,以上各種演進的路線,其實加速無非就是一句話,提高效率。(廢話~)

那麼提高效率的是兩大因素,增加投入以求增加產出、儘可能避免不必要的損耗(例如:減少上下文切換等等)。

如何區分它是可非同步程式碼還是不可非同步呢,其實很簡單那就是,它是否能夠自主完成不需要我們參與的部分。

我們從結果反向思考,

例如我們發送一個網路請求,這之間擁有網路I/O阻塞,那麼測試我們將它掛起、轉而去做其他事情,等他響應了,我們在進行此階段的下一步的操作。那麼這個是可非同步的

另外:寫作業與上洗手間,我此時正在寫著作業,突然,我想上洗手間了,走。上完洗手間後又回來繼續寫作業,在我去洗手間這段時間作業是不會有任何進展,所以我們可以理解為這是非非同步

goroutine

東扯一句,西扯一句,終於該上真傢伙了,廢話不多說。

如何實現只需定義很多個任務,讓系統去幫助我們把這些任務分配到CPU上實現並發執行。

Go語言中的goroutine就是這樣一種機制,goroutine的概念類似於執行緒,但 goroutine是由Go的運行時(runtime)調度和管理的。Go程式會智慧地將 goroutine 中的任務合理地分配給每個CPU。Go語言之所以被稱為現代化的程式語言,就是因為它在語言層面已經內置了調度和上下文切換的機制。

在Go語言編程中你不需要去自己寫進程、執行緒、協程,你的技能包里只有一個技能–goroutine,當你需要讓某個任務並發執行的時候,你只需要把這個任務包裝成一個函數,開啟一個goroutine去執行這個函數就可以了

goroutine與執行緒

可增長的棧

OS執行緒(作業系統執行緒)一般都有固定的棧記憶體(通常為2MB),一個goroutine的棧在其生命周期開始時只有很小的棧(典型情況下2KB),goroutine的棧不是固定的,他可以按需增大和縮小,goroutine的棧大小限制可以達到1GB,雖然極少會用到這麼大。所以在Go語言中一次創建十萬左右的goroutine也是可以的。

goroutine模型

GPM是Go語言運行時(runtime)層面的實現,是go語言自己實現的一套調度系統。區別於作業系統調度OS執行緒。

  • G很好理解,就是個goroutine的,裡面除了存放本goroutine資訊外 還有與所在P的綁定等資訊。
  • P管理著一組goroutine隊列,P裡面會存儲當前goroutine運行的上下文環境(函數指針,堆棧地址及地址邊界),P會對自己管理的goroutine隊列做一些調度(比如把佔用CPU時間較長的goroutine暫停、運行後續的goroutine等等)當自己的隊列消費完了就去全局隊列里取,如果全局隊列里也消費完了會去其他P的隊列里搶任務。
  • M(machine)是Go運行時(runtime)對作業系統內核執行緒的虛擬, M與內核執行緒一般是一一映射的關係, 一個groutine最終是要放到M上執行的;

P與M一般也是一一對應的。他們關係是: P管理著一組G掛載在M上運行。當一個G長久阻塞在一個M上時,runtime會新建一個M,阻塞G所在的P會把其他的G 掛載在新建的M上。當舊的G阻塞完成或者認為其已經死掉時 回收舊的M。

P的個數是通過runtime.GOMAXPROCS設定(最大256),Go1.5版本之後默認為物理執行緒數。 在並發量大的時候會增加一些P和M,但不會太多,切換太頻繁的話得不償失。

單從執行緒調度講,Go語言相比起其他語言的優勢在於OS執行緒是由OS內核來調度的,goroutine則是由Go運行時(runtime)自己的調度器調度的,這個調度器使用一個稱為m:n調度的技術(復用/調度m個goroutine到n個OS執行緒)。 其一大特點是goroutine的調度是在用戶態下完成的, 不涉及內核態與用戶態之間的頻繁切換,包括記憶體的分配與釋放,都是在用戶態維護著一塊大的記憶體池, 不直接調用系統的malloc函數(除非記憶體池需要改變),成本比調度OS執行緒低很多。 另一方面充分利用了多核的硬體資源,近似的把若干goroutine均分在物理執行緒上, 再加上本身goroutine的超輕量,以上種種保證了go調度方面的性能。

GOMAXPROCS

Go運行時的調度器使用GOMAXPROCS參數來確定需要使用多少個OS執行緒來同時執行Go程式碼。默認值是機器上的CPU核心數。例如在一個8核心的機器上,調度器會把Go程式碼同時調度到8個OS執行緒上(GOMAXPROCS是m:n調度中的n)。

Go語言中可以通過runtime.GOMAXPROCS()函數設置當前程式並發時佔用的CPU邏輯核心數。

Go1.5版本之前,默認使用的是單核心執行。Go1.5版本之後,默認使用全部的CPU邏輯核心數。

goroutine的創建

使用goroutine非常簡單,只需要在調用函數的時在函數名前面加上go關鍵字,就可以為一個函數創建一個goroutine。

一個goroutine必定對應一個函數,當然也可以創建多個goroutine去執行相同的函數。

語法如下

func main() {
    go 函數()[普通函數和匿名函數即可]
}

如果你此時興緻勃勃的想立馬試試,我只想和你說,「少俠,請稍等~」,我話還沒說完。以上我只說了如何創建goroutine,可沒說這樣就是這樣用的。嘻嘻~

首先我們先看看不用goroutine的程式碼,示例如下

# example
package main

import (
    "fmt"
    "time"
)

func example(i int) {
    //fmt.Println("HelloWord~, stamp is", i)
    time.Sleep(time.Second)
}

// normal
func main() {
    startTime := time.Now()
    for i := 0; i < 10; i++ {
        example(i)
    }
    fmt.Println("Main~")
    spendTime := time.Since(startTime)
    fmt.Println("Spend Time:", spendTime)
}

輸入結果如下

那麼我們來使用goroutine,運行

示例程式碼如下:

package main

import (
    "fmt"
    "time"
)

func example(i int) {
    fmt.Println("HelloWord~, stamp is", i)
    time.Sleep(time.Second)
}

// normal
func main() {
    startTime := time.Now()
    // 創建十個goroutine
    for i := 0; i < 10; i++ {
        go example(i)
    }
    fmt.Println("Main~")
    spendTime := time.Since(startTime)
    fmt.Println("Spend Time:", spendTime)
}

輸出如下

乍一看,好傢夥速度提升了簡直不是一個量級啊,秒啊~

仔細看你會發現,7,9 跑去哪兒呢?不見了,盯~

謎底在下一篇揭曉~

期待下一篇,盤點Golang並發那些事兒之二,goroutine並發控制得心應手

本文分享自華為雲社區《盤點Golang並發那些事兒之一》,原文作者:PayneWu。

 

點擊關注,第一時間了解華為雲新鮮技術~