相信大家在學(xué)習(xí)Go的過程中,都會(huì)看到類似這樣一句話:"與傳統(tǒng)的系統(tǒng)級(jí)線程和進(jìn)程相比,協(xié)程的最大優(yōu)勢在于其‘輕量級(jí)’,可以輕松創(chuàng)建上百萬個(gè)而不會(huì)導(dǎo)致系統(tǒng)資源衰竭"。那是不是意味著我們在開發(fā)過程中,可以隨心所欲的調(diào)用協(xié)程,而不關(guān)心它的數(shù)量呢?
答案當(dāng)然是否定的。我們在開發(fā)過程中,如果不對Goroutine加以控制而進(jìn)行濫用的話,可能會(huì)導(dǎo)致服務(wù)程序整體崩潰。
這里我先模擬一下協(xié)程數(shù)量太多的危害:
func main() {
number := math.MaxInt64
for i := 0; i < number; i++ {
go func(i int) {
// 做一些業(yè)務(wù)邏輯處理
fmt.Printf("go func: %d\n", i)
time.Sleep(time.Second)
}(i)
}
}
如果number是用戶輸入的一個(gè)參數(shù),沒有做限制。有些開發(fā)人員會(huì)全部丟進(jìn)去進(jìn)行循環(huán),認(rèn)為全部都并發(fā)使用Goroutine去做一件事情,效率比較高。但這樣的話,噩夢般的事情就開始了,服務(wù)器系統(tǒng)資源利用率不斷上漲,到最后程序自動(dòng)killed。
通過執(zhí)行top命令查看到該程序占用的CPU、內(nèi)存較高。
為了避免上圖這種情況,下面會(huì)簡單的介紹一下Goroutine以及在我們?nèi)粘i_發(fā)中如何控制Goroutine的數(shù)量。
一、基本介紹
工欲善其事必先利其器。先簡單的介紹一下Goroutine,Goroutine是Go中最基本的執(zhí)行單元。事實(shí)上每一個(gè)Go程序至少有一個(gè)Goroutine:主Goroutine。當(dāng)程序啟動(dòng)時(shí),它會(huì)自動(dòng)創(chuàng)建。
為了更好理解Goroutine,先講一下進(jìn)程、線程和協(xié)程的概念。
進(jìn)程(process):用戶下達(dá)運(yùn)行程序的命令后,就會(huì)產(chǎn)生進(jìn)程。同一程序可產(chǎn)生多個(gè)進(jìn)程(一對多關(guān)系),以允許同時(shí)有多位用戶運(yùn)行同一程序,卻不會(huì)相沖突。進(jìn)程需要一些資源才能完成工作,如CPU使用時(shí)間、存儲(chǔ)器、文件以及I/O設(shè)備,且為依序逐一進(jìn)行,也就是每個(gè)CPU核心任何時(shí)間內(nèi)僅能運(yùn)行一項(xiàng)進(jìn)程。進(jìn)程的局限是創(chuàng)建、撤銷和切換的開銷比較大。
線程(Thread):有時(shí)被稱為輕量級(jí)進(jìn)程(Lightweight Process,LWP),是程序執(zhí)行流的最小單元。一個(gè)標(biāo)準(zhǔn)的線程由線程ID,當(dāng)前指令指針(PC),寄存器集合和堆棧組成。另外,線程是進(jìn)程中的一個(gè)實(shí)體,是被系統(tǒng)獨(dú)立調(diào)度和分派的基本單位,線程自己不擁有系統(tǒng)資源,只擁有一點(diǎn)兒在運(yùn)行中必不可少的資源,但它可與同屬一個(gè)進(jìn)程的其它線程共享進(jìn)程所擁有的全部資源。線程擁有自己獨(dú)立的棧和共享的堆,共享堆,不共享?xiàng)#€程的切換一般也由操作系統(tǒng)調(diào)度。
協(xié)程(coroutine):又稱微線程與子例程(或者稱為函數(shù))一樣,協(xié)程(coroutine)也是一種程序組件。相對子例程而言,協(xié)程更為一般和靈活,但在實(shí)踐中使用沒有子例程那樣廣泛。和線程類似,共享堆,不共享?xiàng)#瑓f(xié)程的切換一般由程序員在代碼中顯式控制。它避免了上下文切換的額外耗費(fèi),兼顧了多線程的優(yōu)點(diǎn),簡化了高并發(fā)程序的復(fù)雜。
Goroutine和其他語言的協(xié)程(coroutine)在使用方式上類似,但從字面意義上來看不同(一個(gè)是Goroutine,一個(gè)是coroutine),再就是協(xié)程是一種協(xié)作任務(wù)控制機(jī)制,在最簡單的意義上,協(xié)程不是并發(fā)的,而Goroutine支持并發(fā)的。因此Goroutine可以理解為一種Go語言的協(xié)程,同時(shí)它可以運(yùn)行在一個(gè)或多個(gè)線程上。
在Go中生成一個(gè)Goroutine的方式非常的簡單:只要在函數(shù)前面加上go就生成了。
func number() {
for i := 0; i < ; i++ {
fmt.Printf("%d ", i)
}
}
func main() {
go number() // 啟動(dòng)一個(gè)goroutine
number()
}
二、協(xié)程池解決?
回到開頭的問題,如何控制Goroutine的數(shù)量?相信有過開發(fā)經(jīng)驗(yàn)的人,第一想法是生成協(xié)程池,通過協(xié)程池控制連接的數(shù)量,這樣每次連接都從協(xié)程池里去拿。在Golang開發(fā)中需要協(xié)程池嗎?這里分享下知乎有個(gè)相關(guān)點(diǎn)贊最高的回答:
顯然不需要,goroutine的初衷就是輕量級(jí)的線程,為的就是讓你隨用隨起,結(jié)果你又搞個(gè)池子來,這不是脫褲子放屁么?你需要的是限制并發(fā),而協(xié)程池是一種違背了初衷的方法。池化要解決的問題一個(gè)是頻繁創(chuàng)建的開銷,另一個(gè)是在等待時(shí)占用的資源。goroutine 和普通線程相比,創(chuàng)建和調(diào)度都不需要進(jìn)入內(nèi)核,也就是創(chuàng)建的開銷已經(jīng)解決了。同時(shí)相比系統(tǒng)線程,內(nèi)存占用也是輕量的。所以池化技術(shù)要解決的問題goroutine 都不存在,為什么要?jiǎng)?chuàng)建 goroutine pool 呢?如果因?yàn)?goroutine 持有資源而要去創(chuàng)建goroutine pool,那只能說明代碼的耦合度較高,應(yīng)該為這類資源創(chuàng)建一個(gè)goroutine-safe的對象池,而不是把goroutine本身池化。
在我們?nèi)粘4蟛糠謭鼍跋拢恍枰褂脜f(xié)程池。因?yàn)镚oroutine非常輕量,默認(rèn)2kb,使用go func()很難成為性能瓶頸。當(dāng)然一些極端情況下需要追求性能,可以使用協(xié)程池實(shí)現(xiàn)資源的復(fù)用,例如FastHttp使用協(xié)程池性能提高許多。
當(dāng)然現(xiàn)在我們?nèi)绻枰褂肎oroutine池也不需要重復(fù)造輪子了,目前github上已經(jīng)有開源的項(xiàng)目ants來實(shí)現(xiàn) Goroutine 池。ants已經(jīng)實(shí)現(xiàn)了對大規(guī)模 Goroutine 的調(diào)度管理、Goroutine 復(fù)用,允許使用者在開發(fā)并發(fā)程序的時(shí)候限制 Goroutine 數(shù)量,復(fù)用資源,達(dá)到更高效執(zhí)行任務(wù)的效果。
項(xiàng)目地址:https://github.com/panjf2000/ants
三、 通過channel和sync方式限制協(xié)程數(shù)量
3.1 Channel
Goroutine運(yùn)行在相同的地址空間,因此訪問共享內(nèi)存必須做好同步。那么Goroutine之間如何進(jìn)行數(shù)據(jù)的通信呢?Go提供了一個(gè)很好的通信機(jī)制channel,channel可以與 Unix shell 中的雙向管道做類比:可以通過它發(fā)送或者接收值。這些值只能是特定的類型:channel類型。定義一個(gè)channel時(shí),也需要定義發(fā)送到channel的值的類型。注意,必須使用make創(chuàng)建channel。
3.2 Sync
Go語言中有一個(gè)sync.WaitGroup,WaitGroup 對象內(nèi)部有一個(gè)計(jì)數(shù)器,最初從0開始,它有三個(gè)方法:Add(), Done(), Wait() 用來控制計(jì)數(shù)器的數(shù)量。下面示例代碼中wg.Wati會(huì)阻塞代碼的運(yùn)行,直到計(jì)數(shù)器值為0。
通過Golang自帶的channel和sync,可以實(shí)現(xiàn)需求,下面代碼中通過channel控制Goroutine數(shù)量。
package main
import (
"fmt"
"sync"
"time"
)
type Glimit struct {
n int
c chan struct{}
}
// initialization Glimit struct
func New(n int) *Glimit {
return &Glimit{
n: n,
c: make(chan struct{}, n),
}
}
// Run f in a new goroutine but with limit.
func (g *Glimit) Run(f func()) {
g.c <- struct{}{}
go func() {
f()
<-g.c
}()
}
var wg = sync.WaitGroup{}
func main() {
number := 10
g := New(2)
for i := 0; i < number; i++ {
wg.Add(1)
value :=i
goFunc := func() {
// 做一些業(yè)務(wù)邏輯處理
fmt.Printf("go func: %d\n", value)
time.Sleep(time.Second)
wg.Done()
}
g.Run(goFunc)
}
wg.Wait()
}
四、總結(jié)
在文章的開頭通過在服務(wù)器模擬Goroutine數(shù)量太多導(dǎo)致系統(tǒng)資源上升,提醒大家避免這類問題。當(dāng)然每個(gè)人可根據(jù)自己所在的場景選擇最合適的方案,有時(shí)候成熟的第三方庫也是個(gè)很好的選擇,可以避免重復(fù)造輪子。
下面有兩個(gè)思考問題,大家可以嘗試著去思考一下。
思考1:為什么我們要使用sync.WaitGroup?
這里如果我們不使用sync.WaitGroup控制的話,原因出在當(dāng)主程序結(jié)束時(shí),子協(xié)程也是會(huì)被終止掉的。因此剩余的 goroutine 沒來及把值輸出,程序就已經(jīng)中斷了
思考2:代碼中channel數(shù)據(jù)結(jié)構(gòu)為什么定義struct,而不定義成bool這種類型呢?
因?yàn)榭战Y(jié)構(gòu)體變量的內(nèi)存占用大小為0,而bool類型內(nèi)存占用大小為1,這樣可以更加最大化利用我們服務(wù)器的內(nèi)存空間。
func main(){
a :=struct{}{}
b := true
fmt.Println(unsafe.Sizeof(a)) # println 0
fmt.Println(unsafe.Sizeof(b)) # println 1
}