協(xié)程

1、并發(fā)與并行

并行(parallel):指在同一時刻,有多條指令在多個處理器上同時執(zhí)行。
并發(fā)(concurrency):指在同一時刻只能有一條指令執(zhí)行,但多個進程指令被快速的輪換執(zhí)行,使得在宏觀上具有多個進程同時執(zhí)行的效果,但在微觀上并不是同時執(zhí)行的,只是把時間分成若干段,使多個進程快速交替的執(zhí)行。

2、Coroutine

Coroutine(協(xié)程)是一種用戶態(tài)的輕量級線程,特點如下:
A、輕量級線程
B、非搶占式多任務處理,由協(xié)程主動交出控制權。
C、編譯器/解釋器/虛擬機層面的任務
D、多個協(xié)程可能在一個或多個線程上運行。
E、子程序是協(xié)程的一個特例。
不同語言對協(xié)程的支持:
A、C++通過Boost.Coroutine實現(xiàn)對協(xié)程的支持
B、Java不支持
C、Python通過yield關鍵字實現(xiàn)協(xié)程,Python3.5開始使用async def對原生協(xié)程的支持

3、goroutine

Go語言并發(fā)的基礎是goroutine和channel,當然Go也提供了傳統(tǒng)的對共享資源加鎖的方式實現(xiàn)并發(fā):原子函數(shù)(atomic函數(shù)-類似Java 中的AtomicInteger)和互斥鎖(mutex-類似java中的Lock)。

goroutine奉行通過通信共享內(nèi)存,而不是共享內(nèi)存來通信

這里主要講下goroutine和channel

goroutinue使用示例

示例1:使用關鍵字go來定義并啟動一個goroutine:

func loop() {
    for i := 0; i < 10; i++ {
        fmt.Printf("%d ", i)
    }
}
func main() {
    go loop()
    loop()
    time.Sleep(time.Second) // 停頓一秒
}

示例二:信號量方式

package main
import (
   "fmt"
   "sync"
)
func main(){
   var wg sync.WaitGroup
   wg.Add(2)
   go func() {
      defer wg.Done()
      for i := 0; i < 10000; i++ {
         fmt.Printf("Hello,Go.This is %d\n", i)
      }
   }()
   go func() {
      defer wg.Done()
      for i := 0; i < 10000; i++ {
         fmt.Printf("Hello,World.This is %d\n", i)
      }
   }()
   wg.Wait()
}

sync.WaitGroup是一個計數(shù)的信號量,類似java中的CountDownLatch。使main函數(shù)所在主線程等待兩個goroutine執(zhí)行完成后再結束,否則兩個goroutine還在運行時,主線程已經(jīng)結束。
sync.WaitGroup使用非常簡單,使用Add方法設設置計數(shù)器為2,每一個goroutine的函數(shù)執(zhí)行完后,調(diào)用Done方法減1。Wait方法表示如果計數(shù)器大于0,就會阻塞,main函數(shù)會一直等待2個goroutine完成再結束。

示例三:使用通道channel在并發(fā)過程中實現(xiàn)通信

package main

import (
   "fmt"
)

func main() {
   ch := make(chan int)
   go func() {
      var sum int = 0
      for i := 0; i < 10; i++ {
         sum += i
      }
      //發(fā)送數(shù)據(jù)到通道
      ch <- sum
   }()
   //從通道接收數(shù)據(jù)
   fmt.Println(<-ch)
}

在計算sum和的goroutine沒有執(zhí)行完,將值賦發(fā)送到ch通道前,fmt.Println(<-ch)會一直阻塞等待,main函數(shù)所在的主goroutine就不會終止,只有當計算和的goroutine完成后,并且發(fā)送到ch通道的操作準備好后,main函數(shù)的<-ch會接收計算好的值,然后打印出來

概念

進程:一個程序?qū)粋€獨立程序空間
線程:一個執(zhí)行空間,一個進程可以有多個線程
邏輯處理器:執(zhí)行創(chuàng)建的goroutine,綁定一個線程
調(diào)度器:Go運行時中的,分配goroutine給不同的邏輯處理器
全局運行隊列(global runqueue):所有剛創(chuàng)建的goroutine隊列
本地運行隊列:邏輯處理器的goroutine隊列


goroutine調(diào)度原理圖.png

可以在程序開頭使用runtime.GOMAXPROCS(n)設置邏輯處理器的數(shù)量。
如果需要設置邏輯處理器的數(shù)量,一般采用如下代碼設置:
runtime.GOMAXPROCS(runtime.NumCPU())
調(diào)度器對可以創(chuàng)建的邏輯處理器的數(shù)量沒有限制,但語言運行時默認限制每個程序最多創(chuàng)建10000個線程。

goroutine vs thread

1、內(nèi)存占用
goroutine并不需要太多太多的內(nèi)存占用,初始只需2kB的??臻g即可(自Go 1.4起),按照需要可以增長。一般來說一個Goroutine成本在 4 — 4.5 KB,在go程序中,一次創(chuàng)建十萬左右的goroutine很容易(4KB*100,000=400MB)。

線程初始1MB,并且會分配一個防護頁(guard page)。在64位Linux系統(tǒng),max user process限制線程數(shù)量:(可通過ulimit –a查看,默認值1024,通過ulimit –u可以修改此值)。
在使用Java開發(fā)服務器的過程中經(jīng)常會遇到request per thread的問題,如果為每個請求都分配一個線程的話,大并發(fā)的情況下服務器很快就死掉,因為內(nèi)存不夠了,所以很多Java框架比如Netty都會使用線程池來處理請求,而不會讓線程任意增長。
而使用goroutine則沒有這個問題,你頁可以看到官方的net/http庫就是使用request per goroutine這種模式進行處理的,內(nèi)存占用不會是問題。
2、上下文切換
從調(diào)度上看,goroutine的調(diào)度開銷遠遠小于線程調(diào)度開銷。
OS的線程由OS內(nèi)核調(diào)度,每隔幾毫秒,一個硬件時鐘中斷發(fā)到CPU,CPU調(diào)用一個調(diào)度器內(nèi)核函數(shù)。這個函數(shù)暫停當前正在運行的線程,把他的寄存器信息保存到內(nèi)存中,查看線程列表并決定接下來運行哪一個線程,再從內(nèi)存中恢復線程的注冊表信息,最后繼續(xù)執(zhí)行選中的線程。這種線程切換需要一個完整的上下文切換:即保存一個線程的狀態(tài)到內(nèi)存,再恢復另外一個線程的狀態(tài),最后更新調(diào)度器的數(shù)據(jù)結構。某種意義上,這種操作還是很慢的。

Go運行的時候包涵一個自己的調(diào)度器,這個調(diào)度器使用一個稱為一個M:N調(diào)度技術,m個goroutine到n個os線程(可以用GOMAXPROCS來控制n的數(shù)量),Go的調(diào)度器不是由硬件時鐘來定期觸發(fā)的,而是由特定的go語言結構來觸發(fā)的,他不需要切換到內(nèi)核語境,所以調(diào)度一個goroutine比調(diào)度一個線程的成本低很多。

當線程阻塞時,其它的線程進可能被執(zhí)行,這叫做線程的切換。切換的時候,調(diào)度器需要保存當前阻塞的線程的狀態(tài),恢復要執(zhí)行的線程狀態(tài),包括所有的寄存器,16個通用寄存器、程序計數(shù)器、棧指針、段寄存器、16個XMM寄存器、FP協(xié)處理器、16個 AVX寄存器、所有的MSR等等。
goroutine的保存和恢復只需要三個寄存器:程序計數(shù)器、棧指針和DX寄存器。因為goroutine之間共享堆空間,不共享棧空間,所以只需把goroutine的棧指針和程序執(zhí)行到那里的信息保存和恢復即可,花費很低。

其實, goroutine 用到的就是線程池的技術,當 goroutine 需要執(zhí)行時,會從 thread pool 中選出一個可用的 M 或者新建一個 M。而 thread pool 中如何選取線程,擴建線程,回收線程,Go Scheduler 進行了封裝,對程序透明,只管調(diào)用就行,從而簡化了 thread pool 的使用。

Go調(diào)度器

Go的調(diào)度器內(nèi)部有三個重要的結構:M,P, G
M:
代表真正的內(nèi)核OS線程,和POSIX里的thread差不多
P:
代表調(diào)度的上下文(邏輯處理器),可以把它看做一個局部的調(diào)度器,使go代碼在一個線程上跑,它是實現(xiàn)從N:1到N:M映射的關鍵。
M必須拿到P才能對G進行調(diào)度,P限定了go調(diào)度goroutine的最大并發(fā)度。每一個運行的M都必須綁定一個P。
G:
代表一個goroutine,它有自己的棧,instruction pointer和其他信息(正在等待的channel等等),用于調(diào)度。

Go調(diào)度器.png

調(diào)度方式:Goroutine 在 system call 和 channel call 時都可能發(fā)生阻塞,但這兩種阻塞發(fā)生后,處理方式又不一樣的。

系統(tǒng)調(diào)用時

當程序發(fā)生阻塞的 system call(如打開一個文件)時,P可以轉(zhuǎn)而投奔另一個OS線程。


系統(tǒng)調(diào)用時處理方式.png

圖中看到,當一個OS線程M0陷入阻塞時,P轉(zhuǎn)而在OS線程M1上運行。調(diào)度器保證有足夠的線程來運行所以的context P。
圖中的M1可能是被創(chuàng)建,或者從線程緩存中取出。當MO返回時,它必須嘗試取得一個context P來運行goroutine,一般情況下,它會從其他的OS線程那里steal偷一個context過來,如果沒有偷到的話,它就把goroutine放在一個global runqueue里,然后自己就去睡大覺了(放入線程緩存里)。Contexts們也會周期性的檢查global runqueue,否則global runqueue上的goroutine永遠無法執(zhí)行。

網(wǎng)絡IO調(diào)用時

當goroutine需要做一個網(wǎng)絡IO調(diào)用時,G會和P分離,并移到集成了網(wǎng)絡輪詢器的運行時,一旦該輪詢器指示某個網(wǎng)絡讀或者寫操作已經(jīng)就緒,對應的goroutine就會重新分配到P上完成操作。

channel call時

當程序發(fā)起一個 channel call時,G會和P分離,G 的狀態(tài)會設置為 waiting,M 繼續(xù)執(zhí)行其他的 G。當 G 的調(diào)用完成,會有一個可用的 M 繼續(xù)執(zhí)行它。

任務竊取

另一種情況是P所分配的任務G很快就執(zhí)行完了(分配不均),這就導致了一個上下文P閑著沒事兒干而系統(tǒng)卻任然忙碌。


任務竊取.png

但是如果global runqueue沒有任務G了,那么P就不得不從其他的上下文P那里拿一些G來執(zhí)行。一般來說,如果上下文P從其他的上下文P那里要偷一個任務的話,一般就‘偷’run queue的一半,這就確保了每個OS線程都能充分的使用。


參考:

golang語言并發(fā)與并行——goroutine和channel的詳細理解(一) - Go語言中文網(wǎng) - Golang中文社區(qū)
Go語言開發(fā)(九)、Go語言并發(fā)編程-生命不息,奮斗不止-51CTO博客

Golang 的 goroutine 是如何實現(xiàn)的? - 知乎
Goroutine 淺析
深入Go語言 - 8 goroutine_it知識共享

最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容