前言
衆所周知,golang協程的創建、銷燬、調度是非常輕量的,但是即便再輕量,規模大了開銷也不能忽視的。比如利用協程處理http請求,每個請求用一個協程處理,當QPS上萬的時候,資源消耗還是比較大的。
協程池和線程池一樣,池子中都是熱協程,需要的時候取出來,用完後歸還,避免了高頻率的創建和銷燬。同時協程池還能將空閒超時的協程銷燬來釋放資源,並且還有一定保護能力,通過設定協程最大數量避免無休止的創建協程把系統資源消耗殆盡。
總之,golang雖然提供了非常輕量且容易使用的協程編程環境,但是不同的應用場景對於協程的使用需求也是不同的,協程池就是一種非常通用的應用場景。
無鎖隊列
在介紹協程池的實現之前需要簡單說明一下無鎖隊列,關於無鎖隊列的實現網上有很多文章,此處只簡單的說一些根本文實現有關的重點內容:CAS操作——Compare & Set,或是 Compare & Swap,現在幾乎所有的CPU指令都支持CAS的原子操作,X86下對應的是 CMPXCHG 彙編指令。有了這個原子操作,就可以用其來實現各種無鎖(lock free)的數據結構,本文使用的就是atomic.CompareAndSwapPointer。
實現
本文實現的源碼開源連接爲:https://github.com/jinde-zgm/gopool.git,接下來進入CAAD(代碼即文檔)模式,通篇只有代碼和註釋!
接口
// Pool定義了協程池的接口
type Pool interface {
Name() string // 獲取協程池的名字,當有多個協程池對象的時候可以用名字區分不同的協程池
Capacity() int32 // 獲取協程池的容量,即最大協程數量
Tune(size int32) // 修改協程池的容量
Status() Status // 獲取協程池的狀態,關於狀態下面有定義
Go(Routine) error // 執行(阻塞)Routine,關於Routine下面也有定義
GoNonblock(Routine) error // 非阻塞執行Routine,當協程數量達到最大值且無空閒協程時立刻返回
Close() // 關閉協程池
}
// Routine定義了協程池執行的函數,context避免協程池關閉的時候協程被阻塞,也就是說協程池的使用者
// 需要將函數實現成Routine形式才能被協程池調用,
type Routine func(context.Context)
// Status定義了協程池的狀態
type Status struct {
Runnings int32 // 運行中的協程數量
Idles int32 // 空閒的協程數量
}
協程
// coroutine 定義了協程
type coroutine struct {
rc chan Routine // Routine的chan,Pool.Go(Routine)通過rc傳遞給協程執行
pool *pool // 協程池指針,每個協程通過pool指向協程池(pool是Pool的實現)
active time.Time // 活躍時間,最後一次執行完Routine的時間,用於清理空閒超時的協程
next *coroutine // 下一個協程,所謂無鎖隊列就是用這個變量將協程形成了隊列
}
// run是協程的運行函數
func (c *coroutine) run() {
// 此處只需要知道pool有一個sync.WaitGroup的成員變量wg,用來等待所有協程退出,
// 所以協程退出的時候需要調動Done
defer c.pool.wg.Done()
// 前面提到了,通過chan Routine獲取函數
for r := range c.rc {
// 空指針表示協程需要退出,比如協程池關閉或者協程空閒超時都會收到nil
if r == nil {
return
}
// 執行Routine,此處傳入了協程池的context,建議Routine的實現select該context
r(c.pool.ctx)
// 執行完函數,將該協程放到協程池的空閒隊列,此處開始進入本文的核心內容了
c.pool.pushIdle(c)
}
}
無鎖隊列
// pushIdle把協程放入空閒協程隊列
func (p *pool) pushIdle(c *coroutine) {
// 此時協程已經執行完Routine,需要記錄一下最後的活躍時間
c.active = time.Now()
for {
// 獲取空閒隊列的第一個協程,即隊列頭,clean表示協程池是否正在清理空閒隊列
head, clean := p.idleHead()
if clean {
// 如果協程池正在清理空閒協程,需要等清理完畢後再把協程放入到空閒隊列中,
// 如何才能知道協程池清理完了呢?chan或者sync.Cond應該是比較容易想到的方案,
// 筆者採用了自旋的方案,因爲清理空閒協程非常快且不頻繁,自旋是性能最好的方法。
// 此處使用了runtime.Gosched()實現自旋,此時立查詢清理是否完成多半還是在清理中,
// 倒不如把時間片讓出來給其他協程,實在沒事幹了再去查詢清理狀態會更有效的利用CPU。
// runtime.Gosched()會讓協程釋放CPU時間片,筆者此處問一個問題,如果不調用該函數,
// 採用死循環的方式自旋查詢清理狀態(即把runtime.Gosched()註釋掉)是否可行,
// 答案是不行的,原因讀者應該能夠想明白。
runtime.Gosched()
continue
}
// 到這裏說明協程池不在清理狀態,c.storeNext(head)是用c.next->head(當前),
// p.casIdleHead(head, unsafe.Pointer(c))利用CAS操作實現p.idles->c,
// 相當於把c放入了隊列頭,c.next指向了以前的隊列頭。因爲CAS是原子操作,無需用鎖互斥
// 就可以把協程放入隊列,這也是無鎖隊列的由來
if c.storeNext(head); p.casIdleHead(head, unsafe.Pointer(c)) {
// 運行中的協程數量-1,通過原子操作計數,因爲執行上面是多個協程併發執行的
// 此處需要注意,在清理超時協程的時候會插入cleaning協程,不能計爲運行中的協程
if c != cleaning {
atomic.AddInt64(&p.count, int64(-1)<<32)
}
break
}
}
}
// casIdleHead利用CAS實現協程池頭指針的操作,casIdleHead不僅可以實現插入協程到隊列頭,
// 同時可以將隊列頭協程彈出,詳情見下面的popIdle()
func (p *pool) casIdleHead(o, n unsafe.Pointer) bool {
// 實現非常簡單,就是利用了atomic.CompareAndSwapPointer()函數,p.idles指向了第一個協程,
// 目標是讓p.idles指向n,o是以前的隊列頭,CAS就是如果p.idles==o則p.idles=n,否則返回false
return atomic.CompareAndSwapPointer((*unsafe.Pointer)(unsafe.Pointer(&p.idles)), o, n)
}
// popIdle彈出隊列的第一個協程
func (p *pool) popIdle() *coroutine {
for {
// 和插入隊列一樣,都要判斷是否爲清理狀態,此處就不多解釋了。因爲隊列可能爲空,
// 所以要判斷是否爲空
head, cleaning := p.idleHead()
if nil == head {
return nil
} else if cleaning {
runtime.Gosched()
continue
}
// 下面的操作讓p.idles指向head.next,等同於將head從隊列中移除
c := (*coroutine)(head)
if next := c.loadNext(); p.casIdleHead(head, next) {
// 返回隊列頭部的協程,不難發現協程隊列其實是個棧(FILO),而隊列應該是FIFO,
// 其實是棧還是隊列並不重要,重要的是無鎖隊列是一個廣爲人知的名字,熟悉無鎖隊列
// 的讀者可以立刻想象到本文所描述的實現方案。
c.storeNext(nil)
return c
}
}
}
var cleaning = &coroutine{}
// idleHead返回隊列第一個協程,即隊列頭
func (p *pool) idleHead() (unsafe.Pointer, bool) {
// p.idles指向了第一個協程,用原子的方式讀取隊列頭,因爲多個協程都在操作p.idles實現
// 隊列的push和pop操作
head := atomicLoadCoroutine(&p.idles)
// 這句就是本文標題中簡約而不簡單的部分了,cleaning是全局變量,上面有定義,如果隊列頭指向
// cleaning表示協程池正在執行清理函數。那麼問題來了,爲什麼要用這種方式?因爲所有的協程
// 都在用CAS的方式操作隊列頭,也就是說只有隊列頭實現了全局狀態的一致性,但凡引入任何其他變量,
// 都無法通過原子的方式同時操作隊列和該變量,此時就必須要加鎖,這不是筆者想要的。有的同學可能
// 會問,空閒協程已經通過隊列的方式組織起來了,直接遍歷不就完了?答案肯定是不行的,因爲遍歷
// 隊列中的任何一個協程都需要多個操作,當判斷協程超時的時候可能已經被調度了新的Routine,此時又
// 要重頭開始。前面也提到了,多協程併發的操作隊列頭,隊列的前排是狀態變化最頻繁的,遍歷隊列的
// 過程中可能一直在前排繞圈,因爲他們一直都在變化。而在隊列頭部插入一個特殊的協程,那麼所有
// 操作隊列頭的協程(清理協程除外)都會進入等待狀態(就是前面提到的自旋),直到這個特殊的協程
// 被彈出。
return head, cleaning == (*coroutine)(head)
}
清理
// 終於到了清理函數了,來看看清理函數有沒有前面提到的非常快
func (p *pool) clean(now time.Time) {
// 經過無鎖隊列章節的說明,這句就非常好理解了,把cleaning這個特殊的協程放入隊列頭部
p.pushIdle(cleaning)
// 從cleaning.next開始遍歷,cleaning.next就是pushIdle之前的隊列頭,此處需要注意,
// from是c的前一個協程,即from.next==c.
// 需要了解一點,下面這個for循環相當於只有一個清理協程在工作,其他的協程都在自旋狀態,
// 理論上可以不用原子操作。
for from, c := cleaning, cleaning.next; nil != c; from, c = c, c.next {
// 這個應該不用解釋了,判斷空閒時間是不是超時?
if now.Sub(c.active) >= p.IdleTimeout {
// from之後的所有協程全部被刪除,爲什麼?前面提到過,協程池的數據結構是棧,越
// 靠後面的協程是越先被插入,也就是空閒的時間越長,所以只要某一個協程超時,那麼
// 該協程後面的所有協程肯定都超時。
from.storeNext(nil)
// 遍歷所有超時協程並通知退出
var count int32
for c != nil {
c.rc <- nil
c, c.next = c.next, nil
count++
}
// 從協程的總數中減去已經退出的協程數量
atomic.AddInt64(&p.count, -int64(count))
break
}
}
// 把cleaning協程從隊列中彈出,恢復狀態,不難看出,清理協程的函數最多就是遍歷一次所有空閒
// 協程,總體來看是比較快的
atomicStoreCoroutine(&p.idles, unsafe.Pointer(cleaning.next))
}
利用無鎖隊列實現的協程池,簡約而不簡單
前言
衆所周知,golang協程的創建、銷燬、調度是非常輕量的,但是即便再輕量,規模大了開銷也不能忽視的。比如利用協程處理http請求,每個請求用一個協程處理,當QPS上萬的時候,資源消耗還是比較大的。
協程池和線程池一樣,池子中都是熱協程,需要的時候取出來,用完後歸還,避免了高頻率的創建和銷燬。同時協程池還能將空閒超時的協程銷燬來釋放資源,並且還有一定保護能力,通過設定協程最大數量避免無休止的創建協程把系統資源消耗殆盡。
總之,golang雖然提供了非常輕量且容易使用的協程編程環境,但是不同的應用場景對於協程的使用需求也是不同的,協程池就是一種非常通用的應用場景。
無鎖隊列
在介紹協程池的實現之前需要簡單說明一下無鎖隊列,關於無鎖隊列的實現網上有很多文章,此處只簡單的說一些根本文實現有關的重點內容:CAS操作——Compare & Set,或是 Compare & Swap,現在幾乎所有的CPU指令都支持CAS的原子操作,X86下對應的是 CMPXCHG 彙編指令。有了這個原子操作,就可以用其來實現各種無鎖(lock free)的數據結構,本文使用的就是atomic.CompareAndSwapPointer。
實現
本文實現的源碼開源連接爲:https://github.com/jinde-zgm/gopool.git,接下來進入CAAD(代碼即文檔)模式,通篇只有代碼和註釋!需要解釋一點,開源代碼是筆者興趣使然用業餘時間寫的,並不涉及到任何工作相關的內容。
接口
// Pool定義了協程池的接口 type Pool interface { Name() string // 獲取協程池的名字,當有多個協程池對象的時候可以用名字區分不同的協程池 Capacity() int32 // 獲取協程池的容量,即最大協程數量 Tune(size int32) // 修改協程池的容量 Status() Status // 獲取協程池的狀態,關於狀態下面有定義 Go(Routine) error // 執行(阻塞)Routine,關於Routine下面也有定義 GoNonblock(Routine) error // 非阻塞執行Routine,當協程數量達到最大值且無空閒協程時立刻返回 Close() // 關閉協程池 } // Routine定義了協程池執行的函數,context避免協程池關閉的時候協程被阻塞,也就是說協程池的使用者 // 需要將函數實現成Routine形式才能被協程池調用, type Routine func(context.Context) // Status定義了協程池的狀態 type Status struct { Runnings int32 // 運行中的協程數量 Idles int32 // 空閒的協程數量 }
協程
// coroutine 定義了協程 type coroutine struct { rc chan Routine // Routine的chan,Pool.Go(Routine)通過rc傳遞給協程執行 pool *pool // 協程池指針,每個協程通過pool指向協程池(pool是Pool的實現) active time.Time // 活躍時間,最後一次執行完Routine的時間,用於清理空閒超時的協程 next *coroutine // 下一個協程,所謂無鎖隊列就是用這個變量將協程形成了隊列 } // run是協程的運行函數 func (c *coroutine) run() { // 此處只需要知道pool有一個sync.WaitGroup的成員變量wg,用來等待所有協程退出, // 所以協程退出的時候需要調動Done defer c.pool.wg.Done() // 前面提到了,通過chan Routine獲取函數 for r := range c.rc { // 空指針表示協程需要退出,比如協程池關閉或者協程空閒超時都會收到nil if r == nil { return } // 執行Routine,此處傳入了協程池的context,建議Routine的實現select該context r(c.pool.ctx) // 執行完函數,將該協程放到協程池的空閒隊列,此處開始進入本文的核心內容了 c.pool.pushIdle(c) } }
無鎖隊列
// pushIdle把協程放入空閒協程隊列 func (p *pool) pushIdle(c *coroutine) { // 此時協程已經執行完Routine,需要記錄一下最後的活躍時間 c.active = time.Now() for { // 獲取空閒隊列的第一個協程,即隊列頭,clean表示協程池是否正在清理空閒隊列 head, clean := p.idleHead() if clean { // 如果協程池正在清理空閒協程,需要等清理完畢後再把協程放入到空閒隊列中, // 如何才能知道協程池清理完了呢?chan或者sync.Cond應該是比較容易想到的方案, // 筆者採用了自旋的方案,因爲清理空閒協程非常快且不頻繁,自旋是性能最好的方法。 // 此處使用了runtime.Gosched()實現自旋,此時立查詢清理是否完成多半還是在清理中, // 倒不如把時間片讓出來給其他協程,實在沒事幹了再去查詢清理狀態會更有效的利用CPU。 // runtime.Gosched()會讓協程釋放CPU時間片,筆者此處問一個問題,如果不調用該函數, // 採用死循環的方式自旋查詢清理狀態(即把runtime.Gosched()註釋掉)是否可行, // 答案是不行的,原因讀者應該能夠想明白。 runtime.Gosched() continue } // 到這裏說明協程池不在清理狀態,c.storeNext(head)是用c.next->head(當前), // p.casIdleHead(head, unsafe.Pointer(c))利用CAS操作實現p.idles->c.next, // 相當於把c放入了隊列頭,c.next指向了以前的隊列頭。因爲CAS是原子操作,無需用鎖互斥 // 就可以把協程放入隊列,這也是無鎖隊列的由來 if c.storeNext(head); p.casIdleHead(head, unsafe.Pointer(c)) { // 運行中的協程數量-1,通過源自操作計數,因爲執行上面是多個協程併發執行的 // 此處需要注意,在清理超時協程的時候回插入cleaning協程,不能計爲運行中的協程 if c != cleaning { atomic.AddInt64(&p.count, int64(-1)<<32) } break } } } // casIdleHead利用CAS實現協程池頭指針的操作,casIdleHead不僅可以實現插入協程到隊列頭, // 同時可以將隊列頭協程彈出,詳情見下面的popIdle() func (p *pool) casIdleHead(o, n unsafe.Pointer) bool { // 實現非常簡單,就是利用了atomic.CompareAndSwapPointer()函數,p.idles指向了第一個協程, // 目標是讓p.idles指向n,o是以前的隊列頭,CAS就是如果p.idles==o則p.idles=n,否則返回false return atomic.CompareAndSwapPointer((*unsafe.Pointer)(unsafe.Pointer(&p.idles)), o, n) } // popIdle彈出隊列的第一個協程 func (p *pool) popIdle() *coroutine { for { // 和插入隊列一樣,都要判斷是否爲清理狀態,此處就不多解釋了。因爲隊列可能爲空, // 所以要判斷是否爲空 head, cleaning := p.idleHead() if nil == head { return nil } else if cleaning { runtime.Gosched() continue } // 下面的操作讓p.idles指向head.next,等同於將head從隊列中移除 c := (*coroutine)(head) if next := c.loadNext(); p.casIdleHead(head, next) { // 返回隊列頭部的協程,不難發現協程隊列其實是個棧(FILO),而隊列應該是FIFO, // 其實是棧還是隊列並不重要,重要的是無鎖隊列是一個廣爲人知的名字,熟悉無鎖隊列 // 的讀者可以立刻想象到本文所描述的實現方案。 c.storeNext(nil) return c } } } var cleaning = &coroutine{} // idleHead返回隊列第一個協程,即隊列頭 func (p *pool) idleHead() (unsafe.Pointer, bool) { // p.idles指向了第一個協程,用原子的方式讀取隊列頭,因爲多個協程都在操作p.idles實現 // 隊列的push和pop操作 head := atomicLoadCoroutine(&p.idles) // 這句就是本文標題中簡約而不簡單的部分了,cleaning是全局變量,上面有定義,如果隊列頭指向 // cleaning表示協程池正在執行清理函數。那麼問題來了,爲什麼要用這種方式?因爲所有的協程 // 都在用CAS的方式操作隊列頭,也就是說只有隊列頭實現了全局狀態的一致性,但凡引入任何其他變量, // 都無法通過原子的方式同時操作隊列和該變量,此時就必須要加鎖,這不是筆者想要的。有的同學可能 // 會問,空閒協程已經通過隊列的方式組織起來了,直接遍歷不就完了?答案肯定是不行的,因爲遍歷 // 隊列中的任何一個協程都需要多個操作,當判斷協程超時的時候可能已經被調度了新的Routine,此時又 // 要重頭開始。前面也提到了,多協程併發的操作隊列頭,隊列的前排是狀態變化最頻繁的,遍歷隊列的 // 過程中可能一直在前排繞圈,因爲他們一直都在變化。而在隊列頭部插入一個特殊的協程,那麼所有 // 操作隊列頭的協程(清理協程除外)都會進入等待狀態(就是前面提到的自旋),直到這個特殊的協程 // 被彈出。 return head, cleaning == (*coroutine)(head) }
清理
// 終於到了清理函數了,來看看清理函數有沒有前面提到的非常快 func (p *pool) clean(now time.Time) { // 經過無鎖隊列章節的說明,這句就非常好理解了,把cleaning這個特殊的協程放入隊列頭部 p.pushIdle(cleaning) // 從cleaning.next開始遍歷,cleaning.next就是pushIdle之前的隊列頭,此處需要注意, // from是c的前一個協程,即from.next==c. // 需要了解一點,下面這個for循環相當於只有一個清理協程在工作,其他的協程都在自旋狀態, // 理論上可以不用原子操作。 for from, c := cleaning, cleaning.next; nil != c; from, c = c, c.next { // 這個應該不用解釋了,判斷空閒時間是不是超時? if now.Sub(c.active) >= p.IdleTimeout { // from之後的所有協程全部被刪除,爲什麼?前面提到過,協程池的數據結構是棧,越 // 靠後面的協程是越先被插入,也就是空閒的時間越長,所以只要某一個協程超時,那麼 // 該協程後面的所有協程肯定都超時。 from.storeNext(nil) // 遍歷所有超時協程並通知退出 var count int32 for c != nil { c.rc <- nil c, c.next = c.next, nil count++ } // 從協程的總數中減去已經退出的協程數量 atomic.AddInt64(&p.count, -int64(count)) break } } // 把cleaning協程從隊列中彈出,恢復狀態,不難看出,清理協程的函數最多就是遍歷一次所有空閒 // 協程,總體來看是比較快的 atomicStoreCoroutine(&p.idles, unsafe.Pointer(cleaning.next)) }
Go
// 無論是Go還是GoNonblock,最終調用的都是goRoutine,無非是nonblocking是true還是false
func (p *pool) goRoutine(r Routine, nonblocking bool) error {
// 如果協程池不在運行狀態,返回協程池已關閉錯誤
if !p.state.is(stateRunning) {
return ErrPoolClosed
}
// 從空閒隊列中彈出第一個協程
var c *coroutine
for c = p.popIdle(); nil == c; c = p.popIdle() {
// 無空閒協程,就需要創建新的協程了,前提條件是協程數量沒有超過最大值,
if count := atomic.LoadInt64(&p.count); int32(count) >= p.Capacity() {
// 如果協程總量已經達到最大值,如果是nonblock則直接返回協程滿錯誤
if nonblocking {
return ErrPoolFull
}
// 否則自旋的方式再嘗試獲取空閒協程
runtime.Gosched()
// atomic.CompareAndSwapInt64(&p.count, count, count+1)就是協程總數+1,
// 下面的語句如果執行失敗,說明其他人搶在前面創建或者有新的空閒協程,因爲協程
// 計數發生變化,需要重新循環判斷
} else if atomic.CompareAndSwapInt64(&p.count, count, count+1) {
// 創建新協程,此處cache是sync.Pool,可以避免頻繁的申請和釋放內存
c = p.cache.Get().(*coroutine)
// 創建了新協程,wg就要+1
p.wg.Add(1)
go c.run()
break
}
}
// 增加運行中的協程計數並把Routine傳給協程
atomic.AddInt64(&p.count, int64(1)<<32)
c.rc <- r
return nil
}
總結
以上就是利用無鎖隊列實現的協程池的重點代碼,其他的代碼主要是輔助作用,此處就不一一解釋了,有問題的可以聯繫筆者。先做一個簡單的總結:
- 無鎖隊列只是個代名詞,真實是一個無鎖棧;
- 爲什麼是單向指針(next)而不是雙向指針(prev,next),因爲CAS只能操作一個指針,熟悉LevelDB的同學應該知道,MemTable使用的是跳躍表(SkipList)而不是map,其原因是跳躍表的指針也是單向指針,LevelDB使用的是內存屏障技術而不是CAS,這樣就避免鎖的操作,因爲map是線程不安全的;
- 所有的等待其實是自旋,包括等待清理,等待空閒協程,看似無鎖,其實是自旋鎖。
其實本文的方案存在一個缺點,那就是自旋(成也蕭何敗也蕭何)。等待清理的自旋並沒有什麼,畢竟清理非常快,而且清理週期相對協程的調度週期大很多。筆者指的是等待空閒協程的自旋,當協程池滿且所有運行協程都被某些事件阻塞,此時所有等待空閒協程的請求都在自旋的查詢隊列,相當於空轉。此時本應CPU使用率很低但因爲這些自旋的協程導致CPU使用率非常高,但是這並不會對程序有什麼影響,但凡有任何協程被喚醒他們都會讓出CPU時間片。說簡單點,就是這些等待的協程在利用別人不用的CPU自旋,對於完美主義者的筆者來說雖然有一些遺憾,但是能接受。
筆者做過簡單的測試,本文提到的協程池方案調度性能還是比較高的(比ants還要高10%)。至於協程池能用來幹什麼,以後會陸續介紹較大規模任務調度中使用協程池的方法,敬請期待。