一、Runtime
1. 為什么需要runtime
-
goroutines調度
goroutines是go的執行單元,goroutines如果直接對應操作系統的線程,go在調度goroutines時,勢必將像操作系統調度線程一樣,需要設置信號掩碼、CPU親和性及cgroup的資源管理等,這些額外的線程操作不是go運行goroutines所需要的,這些操作將消耗大量資源、影響性能。所以go需要runtime來執行goroutines的調度,而不是讓操作系統調度線程。 -
垃圾回收
go是需要支持垃圾回收的,執行垃圾回收時,我們需要保證goroutines處于暫停的狀態,go的內存才會處于一種一致的狀態。
當沒有調度器時,線程是由操作系統來調度,但這對于go而言是不可控的,go無法很好的控制線程狀態及內存。所以為了GC,go需要自己的調度器。goroutines有go自身調度器控制才能確保內存一致,才能正確地執行GC。
所以要支持協程\線程調度就要有runtime。要支持垃圾回收就要有runtime。
2. 什么是runtime
上面可以分析出runtime所擔任的職責:goroutines調度,垃圾回收,當然還提供goroutines的執行環境。
所以這也相當于簡要解釋了什么是runtime。
go的可執行程序可以分成兩個層:用戶代碼和運行時:
- 運行時提供接口函數供用戶代碼調用,用來管理goroutines,channels和其他一些內置抽象結構。
-
用戶代碼對操作系統API的任何調用都會被運行時層截取,以方便調度和垃圾回收。
二、GMP調度模型
- global queue(全局隊列):存放等待運行的G。為保證數據競爭問題,需要加鎖處理。
- local queue(本地隊列):本地隊列時無鎖的,可以可以提升處理速度。同全局隊列類似,存放的也是等待運行的G,存的數量有限,不超過256個。新建G'時,G'優先加入到P的本地隊列,如果隊列滿了,則會把本地隊列中一半的G移動到全局隊列。
- P列表:所有的P都在程序啟動時創建,并保存在數組中,最多有GOMAXPROCS(可配置)個。在任何時候,每個P只能有一個M運行。
- M:線程想運行任務就得獲取P,從P的本地隊列獲取G,P隊列為空時,M也會嘗試從全局隊列拿一批G放到P的本地隊列,或從其他P的本地隊列偷一半放到自己P的本地隊列。M運行G,G執行之后,M會從P獲取下一個G,不斷重復下去。
GMP主要包含4個基本單元:g、m、p、schedt。
- g是協程任務信息單元
- m是實際執行體
- p是本地資源池和本地g任務池
- schedt是全局資源池和全局g任務池
下面我們詳細看下這4個基本單元的主要結構:
1. G
G
是Goroutine的縮寫,是對Goroutine的抽象。其中包括執行的函數指令及參數;G保存著任務對象,線程上下文切換,現場保護和現場恢復需要的寄存器(SP、IP)等信息。
Goroutine的主要結構(詳見runtime/runtime2.go)
type g struct {
stack stack // 描述了當前 Goroutine 的棧內存范圍 [stack.lo, stack.hi)
stackguard0 uintptr // 是對比 Go 棧增長的 prologue 的棧指針, 可以用于調度器搶占式調度
stackguard1 uintptr // 是對比 C 棧增長的 prologue 的棧指針
...
_panic *_panic // 最內側的 panic 結構體
_defer *_defer // 最內側的延遲函數結構體
m *m // 當前的m
sched gobuf // goroutine切換時,用于保存g的上下文
...
param unsafe.Pointer // 用于傳遞參數,睡眠時其他goroutine可以設置param,喚醒時該goroutine可以獲取
atomicstatus uint32 // Goroutine 的狀態
stackLock uint32
goid int64 // goroutine的ID
...
waitsince int64 // g被阻塞的大體時間
preempt bool // 搶占信號
preemptStop bool // 搶占時將狀態修改成 `_Gpreempted`
preemptShrink bool // 在同步安全點收縮棧
...
lockedm *m // G被鎖定只在這個m上運行
...
}
g
中最主要的當然是sched
了,保存了goroutine的上下文。goroutine切換的時候,不同于線程有OS來負責這部分數據,而是由一個gobuf對象來保存,這樣能夠更加輕量級,再來看看gobuf的結構(詳見runtime/runtime2.go):
type gobuf struct {
// The offsets of sp, pc, and g are known to (hard-coded in) libmach.
//
// ctxt is unusual with respect to GC: it may be a
// heap-allocated funcval, so GC needs to track it, but it
// needs to be set and cleared from assembly, where it's
// difficult to have write barriers. However, ctxt is really a
// saved, live register, and we only ever exchange it between
// the real register and the gobuf. Hence, we treat it as a
// root during stack scanning, which means assembly that saves
// and restores it doesn't need write barriers. It's still
// typed as a pointer so that any other writes from Go get
// write barriers.
sp uintptr // 棧指針
pc uintptr // 程序計數器
g guintptr // 當前gobuf所屬的g
ctxt unsafe.Pointer
ret uintptr 系統調用的返回值
lr uintptr
bp uintptr // for framepointer-enabled architectures
}
2. M
M
是一個線程或稱為Machine,M是有線程棧的。如果不對該線程棧提供內存的話,系統會給該線程棧提供內存(不同操作系統提供的線程棧大小不同)。當指定了線程棧,則M.stack→G.stack,M的PC寄存器指向G提供的函數,然后去執行。
type m struct {
// g0是帶有調度棧的goroutine。
// 普通的Goroutine棧是在Heap分配的可增長的stack,而g0的stack是M對應的線程棧。
// 所有調度相關代碼,會先切換到該Goroutine的棧再執行。
g0 *g
......
gsignal *g // 處理信號的goroutine
......
tls [6]uintptr // thread-local storage
mstartfn func() //m入口函數
curg *g // 當前運行的goroutine
caughtsig guintptr
p puintptr // 關聯p和執行的go代碼
nextp puintptr
oldp puintptr // 在執行系統調用之前所附加的p
id int32
mallocing int32 // 狀態
......
locks int32 //m的鎖
......
spinning bool // m不在執行g,但在積極尋找可執行的g
blocked bool // m是否被阻塞
newSigstack bool
printlock int8
incgo bool // m是否在執行cgo
freeWait uint32 // 如果為0,將安全釋放g0并刪除m(原子性)。
fastrand uint32
......
ncgocall uint64 // cgo調用的總數
ncgo int32 // 當前cgo調用的數目
......
park note
alllink *m // 用于鏈接allm
schedlink muintptr
lockedg *g // 鎖定g在當前m上執行,而不會切換到其他m
createstack [32]uintptr // thread創建的棧
......
nextwaitm muintptr // 下一個等待的m
......
}
而g0的棧是M對應的線程的棧。所有調度相關的代碼,會先切換到該goroutine的棧中再執行。也就是說線程的棧也是用的g實現,而不是使用的OS的。
3. P
P
代表一個處理器,每一個運行的M都必須綁定一個P,就像線程必須在么一個CPU核上執行一樣,由P來調度G在M上的運行,P的個數就是GOMAXPROCS(最大256),啟動時固定的,一般不修改;M的個數和P的個數不一定一樣多(會有休眠的M或者不需要太多的M)(最大10000);每一個P保存著本地G任務隊列,也有一個全局G任務隊列。P的數據結構:
type p struct {
id int32
status uint32 // 狀態,可以為pidle/prunning/...
link puintptr
schedtick uint32 // 每調度一次加1
syscalltick uint32 // 每一次系統調用加1
sysmontick sysmontick
m muintptr // 回鏈到關聯的m
mcache *mcache //當前m的內存緩存,意味著不必為每一個M都配備一塊內存,避免了過多的內存消耗。
pcache pageCache
raceprocctx uintptr
......
// goroutine ids的緩存,攤銷對runtime-sched.goidgen的訪問。
goidcache uint64
goidcacheend uint64
// 可運行的goroutine的隊列. 不需要鎖即可訪問
runqhead uint32
runqtail uint32
runq [256]guintptr
runnext guintptr // 下一個運行的g,以高優先級執行 unblock G,提高了一些包的性能。
// 可用的G (status == Gdead, Gdead 表示這個goroutine目前未被使用)
gFree struct {
gList
n int32
}
// sudog 代表等待列表中的一個G,例如在向通道執行發送/接收的G。
sudogcache []*sudog
sudogbuf [128]*sudog
// 堆中mspan對象的緩存
mspancache struct {
// len 被用于不允許寫障礙的調用代碼路徑中
len int
buf [128]*mspan
}
......
palloc persistentAlloc // per-P to avoid mutex
......
}
其中P的狀態有Pidle, Prunning, Psyscall, Pgcstop, Pdead;在其內部隊列runqhead里面有可運行的goroutine,P優先從內部獲取執行的g,這樣能夠提高效率。
4. schedt
除此之外,還有一個數據結構需要在這里提及,就是schedt,可以看做是一個全局的調度者:
type schedt struct {
// 原子操作訪問
goidgen uint64
lastpoll uint64 // 最后一次網絡輪詢的時間,如果正在輪詢則為0
pollUntil uint64 // 當前輪詢的睡眠時間
lock mutex
// When increasing nmidle, nmidlelocked, nmsys, or nmfreed, be
// sure to call checkdead().
midle muintptr // idle狀態的m
nmidle int32 // idle狀態的m個數
nmidlelocked int32 // lockde狀態的m個數
mnext int64 // 已經創建的m的數量和下一個m的ID
maxmcount int32 // m允許的最大個數
nmsys int32 // 非locked狀態的系統M的數量
nmfreed int64 // 累計可用的M的數量
ngsys uint32 // 系統中goroutine的數目,會自動更新
pidle puintptr // idle的p
npidle uint32
nmspinning uint32 // 尋找可執行gotoutine的m數量
// goroutine全局可運行隊列.
runq gQueue
runqsize int32
......
// _Gdead狀態G的全局緩存
gFree struct {
lock mutex
stack gList // 有stack的Gs
noStack gList // 沒有stack的Gs
n int32
}
// sudog的Central 緩存
sudoglock mutex
sudogcache *sudog
......
// 等待被釋放的m的列表
freem *m
......
}
二、GMP調度細節分析
1. schedule
proc.go:3291 findrunnable()
- m是否阻塞?阻塞則sleep此m,其他m在從本地隊列尾部獲取g執行,執行完成后,繼續執行schedule()
- 是否需要執行gc?需要gc則停止spining、釋放p、開始sleep,gc完成后會被喚醒,繼續執行schedule()
- 若存在gcBgMarkWorker則獲取此g,否則偶爾(
1/61
概率)會從全局隊列獲取g(避免全局對列饑餓),沒有則從本地隊列獲取g。 - 若仍未獲取到g,則會進入findrunnable流程,循環地去找g
- 獲取到g后,停止spining
- 獲取到鎖定的g,則將把對應鎖定的m調度給當前的p并喚醒m。否則該m將移交p給其他等待中的m并喚醒,該m將sleep
- 再次進入schedule()循環
2. findrunnable
proc.go:2705 findrunnable():
- 是否需要執行gc?需要gc則停止spining、釋放p、開始sleep,gc完成后會被喚醒,繼續執行findrunnable()
- 從本地隊列尋找g,沒有則從全局隊列尋找g。獲取g后,將繼續schedule()
- 本地隊列、全局隊列都沒有g,則查看是否存在net或file需要執行。存在則獲取g,并繼續schedule()
- 沒有可執行的g,開始spining,從其他p的本地隊列尾部竊取一半的g,并繼續scheudle()
- 其他p的本地隊列沒有g,將會在sleep之前,再次嘗試查看全局隊列中是否有g
- 仍沒有g,則釋放p,停止spining并準備sleep
- 檢查是否有idle的p,則sleep,直到再次被喚醒
3. spining
線程自旋(spining)是相對于線程阻塞而言的,表象就是循環執行一個指定邏輯(調度邏輯,目的是不停地尋找 G)
缺點: 始終獲取不到G時,自旋屬于空轉,浪費CPU
優點: 降低了 M 的上下文切換成本,提高了性能
GMP中有兩個地方會引入自旋:
- 類型1:沒有P的M找P掛載,保證一有 P 釋放就結合
- 類型2:沒有G的M找G運行,保證一有runnable的G就運行
由于P最多只有GOMAXPROCS,所以自旋的M最多只允許GOMAXPROCS個,多了就沒有意義了。
同時當有類型1的自旋M存在時,類型2的自旋M就不阻塞,阻塞會釋放P,一釋放P就馬上被類型1的自旋M搶走了,沒必要。
有空閑的 P時,在以下3種場景,go調度器會確保至少有一個自旋 M 存在(喚醒或者創建一個 M):
-
新G創建之前
如果有空閑的 P,就意味著新 G 可以被立即執行,即便不在同一個 P 也無妨,所以我們保留一個自旋的 M,就可以保證新 G 很快被運行。
為了執行G,不需要關注在哪個P上運行,這時應該不存在類型 1 的自旋只有類型 2 的自旋 -
M進入系統調用(syscall)之前
當 M 進入系統調用,意味著 M 不知道何時可以醒來,那么 M 對應的 P 中剩下的 G 就得有新的 M 來執行,所以我們保留一個自旋的 M 來執行剩下的 G。
為了執行P本地隊列中的G,需要和P綁定,這時應該不存在類型 2 的自旋只有類型1的自旋 -
M從空閑變成活躍之前
如果M從空閑變成活躍,意味著可能一個處于自旋狀態的M進入工作狀態了,這時要檢查并確保還有一個自旋M存在,以防還有G或者還有P空著的。
4. GMP模式優點
- G分布在全局隊列和P本地隊列,全局隊列依舊是全局鎖,但是使用場景明顯很少;P 本地隊列使用無鎖隊列,使用原子操作來面對可能的并發場景。(解決了GM模式單一全局互斥鎖的問題)
- G 創建時就在 P 的本地隊列,可以避免在 P 之間傳遞(竊取除外),G 對 P 的數據局部性好; 當 G 開始執行了,系統調用返回后 M 會嘗試獲取可用 P,獲取到了的話可以避免在 M 之間傳遞 。而且優先獲取調用阻塞前的 P,所以 G 對 M 數據局部性好,G 對 P 的數據局部性也好。(解決了GM模式中G傳遞帶來的開銷問題,以及數據局部性問題)
- 內存 mcache 只存在 P 結構中,就不必為每一個M都配備一塊內存了,避免過多的內存消耗。P 最多只有 GOMAXPROCS 個,遠小于 M 的個數,所以也不會出現過多的內存消耗。(解決了GM模式中內存消耗大的問題)
- 通過引入自旋,保證任何時候都有處于等待狀態的自旋M,避免在等待可用的P和G時頻繁的阻塞和喚醒。(解決了GM模式中嚴重的線程阻塞/解鎖問題)
5. syscall阻塞情況下的調度
當M1執行某一個G時候如果發生了syscall或者其他阻塞操作后,M1會阻塞。如果當前P中仍有一些G待執行,runtime會將 Goroutine 的狀態更新至 _Gsyscall,將 Goroutine 的P和M暫時分離并更新P的狀態到 _Psyscall,表明這個 P 的 G 正在 syscall 中。
當系統調用結束后,會調用退出系統調用的函數 runtime.exitsyscall
為當前 Goroutine 重新分配資源,該函數有兩個不同的執行路徑:
- 調用
runtime.exitsyscallfast
; - 切換至調度器的 Goroutine 并調用
runtime.exitsyscall0
;
采用較快的路徑runtime.exitsyscallfast
優先來重新獲取原來的P,能獲取到就繼續綁回去,這樣有利于數據的局部性。runtime.exitsyscallfast
中包含兩個不同的分支:
- 如果 Goroutine 的原P處于 _Psyscall 狀態,會直接調用
wirep
將 Goroutine 與原P進行關聯 - 如果原P不處于 _Psyscall 狀態,且調度器中存在閑置的P,會調用 runtime.acquirep 使用閑置的P處理當前 Goroutine;
如果通過runtime.exitsyscallfast
獲取不到P,runtime就會采用另一個相對較慢的路徑 runtime.exitsyscall0
,將當前 Goroutine 切換至 _Grunnable
狀態,并移除線程 M 和當前 Goroutine 的關聯,然后執行以下邏輯分支:
- 當我們通過
runtime.pidleget
獲取到閑置的處理器時就會在該處理器上執行 Goroutine; -
否則找不到空閑的P,runtime就會把 G 放回 global queue,M 放回到 idle list,等待調度器調度
6. sysmon搶占調度
sysmon 也叫監控線程,它在一個單獨的 M 上執行,無需 P 也可以運行,它是一個死循環,每 20us~10ms 循環一次,循環完一次就 sleep 一會。為什么會是一個變動的周期呢,主要是避免空轉,如果每次循環都沒什么需要做的事,那么 sleep 的時間就會加大。
1. sysmon的主要作用:
- 釋放閑置超過 5 分鐘的 span 物理內存
- 如果超過 2 分鐘沒有垃圾回收,強制執行
- 將長時間未處理的 netpoll 添加到全局隊列
- 向長時間運行的 G 任務發出
搶占調度
- 收回因 syscall 長時間阻塞的 P
2. 滿足什么條件會觸發搶占調度
呢?
go 1.13 搶占調度
sysmon發現一個 P 一直處于running狀態超過了10ms,將調用preemptone 將 G 的stackguard0=stackPreempt,同時設置sched.gcwaiting=1。被標記后,在該G調用新函數時,通過g.stackguard0判斷是否需要棧增長,需要棧增長就會通過morestack()檢查執行schedule(),檢查到sched.gcwaiting==1時,就會讓當前G讓出。
G設置了標記位后,也不一定會被搶占。如果G調用的新函數是一個簡單的死循環,將無法被搶占。如果G調用的新函數所需棧空間很少也不會被搶占,只有當新函數觸發棧空間檢查(morestack()), 所需棧大于128字節,才會被搶占。那么這里就帶來一些問題,我們看下下面的代碼示例:
package main
import "fmt"
func main(n int) {
go func(n int){
for{
n++
fmt.Println(n)
}
}(0)
for{}
}
在go 1.13版本之前,執行上述代碼,會阻塞在go func()。原因是當go的GC觸發時,會執行STW。而STW會搶占所有的P,讓GC來運行。而go func()中是1個簡單的死循環,這類操作無法進行newstack、morestack、syscall,所以無法檢測stackguard0 == stackpreempt,也就不會執行后續的schedule()讓當前G讓出,導致阻塞。這種依賴棧增長的方式,不算是真正的搶占式調度。
go 1.14 搶占調度
在go 1.14版本實現了基于信號的搶占式調度
。
- sysmon發現一個 P 一直處于running狀態超過了10ms,調用preemptone()方法時,會通過系統調用,向m發送sigPreempt信號。
- m收到信號后,會將信號交給sighandler處理
- sighandler確定信號為sigPreempt以后,調用doSigPreempt函數
- doSigPreempt函數在確認P和G允許搶占,并可以安全地執行搶占后,會向G的執行上下文中注入異步搶占函數asyncPreempt。
- asyncPreempt匯編函數調用后,就會保存G的上下文,并調用schedule()讓當前G讓出。
我們可以看到基于信號的搶占式調度,不再依賴于棧增長,即使空的for{}沒有執行棧增長檢測代碼,也依然沒有阻塞,可以成功實現搶占式調度。
7. netpoller
1. 什么是netpoller
在Go的實現中,期望在用戶層面(程序員層面)所有IO都是阻塞調用的,Go的設計思想是程序員使用阻塞式的接口來編寫程序,然后通過goroutine+channel來處理并發。因此所有的IO邏輯都是直來直去的,先xx,再xx, 你不再需要回調,不再需要future,要的僅僅是step by step。這對于代碼的可讀性是很有幫助的。
但是如果在Runtime內部也采用阻塞 I/O 調用,那么物理線程將也處于阻塞狀態,導致大量資源的浪費。所以Runtime內部實際使用的是OS提供的非阻塞IO訪問模式。那么如何將OS的異步I/O與Golang接口的阻塞I/O互相轉換呢?golang內部就通過OS提供的非阻塞IO訪問模式、并配合epll/kqueue等IO事件監控機制,通過runtime上做的一層封裝,實現將OS的異步I/O與Goroutine的阻塞 I/O互相轉換 。這一部分被稱之為netpoller
1. goroutine同步調用轉OS異步調用
當一個goroutine進行I/O操作時,并且文件描述符數據還沒有準備好,經過一系列的調用,最后會進入gopark函數,gopark將當前正在執行的goroutine狀態保存起來,然后切換到新的堆棧上執行新的goroutine。由于當前goroutine狀態是被保存起來的,因此后面可以被恢復。這樣進行I/O操作的goroutine以為一直同步阻塞到現在,其實內部是異步完成的。
2. goroutine什么時候調度回來
在schedule()執行時,findrunnable()中的netpoll()方法被調用后,處于就緒狀態的 fd 對應的 G 就會被調度回來。
8. scheduler affinity
goroutine之間使用channel來回通信時,會導致goroutine頻繁阻塞,導致其在本地隊列會進行頻繁地重新排隊,導致goroutine存在被重排后有可能會被竊取的風險。
Go 1.5 在 P 中引入了runnext 特殊的一個字段,當一組goroutines在communicate-and-wait模式中被阻塞,但很快就runnable了,便會將runnext分別指向這組goroutines。在當前G運行結束,之后將立即執行runnext對應的G,而不是本地隊列中的G。這允許 goroutine 在再次被阻塞之前能夠快速運行,提高了一部分性能。
六、goroutine的生命周期
- 流程步驟:
- runtime創建最初的線程m0和goroutine g0,并把2者關聯。
- 調度器初始化:初始化m0、棧、垃圾回收,以及創建和初始化P列表,P的數目優先取環境變量GOMAXPROCS,否則默認是cpu核數。隨后把第一個P(便于理解可以叫它p0)與m0進行綁定,這樣m0就有他自己的p了,就有條件執行后續的任務g了。
- m0的g0會執行調度任務(runtime.newproc),創建一個g,g指向runtime.main()(還不是我們main包中的main),并放到p的本地隊列。這樣m0就已經同時具有了任務g和p,什么條件都具備了。
runtime.main(): 啟動 sysmon 線程;啟動 GC 協程;執行 init,即代碼中的各種 init 函數;執行 main.main 函數。
- 啟動m0,m0已經綁定了P,會從P的本地隊列獲取g。
- g擁有棧,m根據g中的棧信息和調度信息設置運行環境
- M運行g
- g退出,再次回到M獲取可運行的G,這樣重復下去,直到main.main退出,runtime.main執行Defer和Panic處理,或調用runtime.exit退出程序。
-
M0
M0
是啟動程序后的編號為0的主線程,這個M對應的實例會在全局變量runtime.m0中,不需要在heap上分配,M0負責執行初始化操作和啟動第一個G, 在之后M0就和其他的M一樣了。 -
G0
G0
是每次啟動一個M都會第一個創建的gourtine,G0僅負責調度,即shedule() 函數, 每個M都會有一個自己的G0。在調度或系統調用時會使用G0的棧空間, 全局變量的G0是M0的G0。 -
G0的調度
以下3種場景,G0會執行調度:
- 當前 G 執行完成,G0會執行調度獲取下一個G
- 當前 G 阻塞時:系統調用、互斥鎖或 chan,G0會執行調度
- 在函數調用期間,如果當前 G 必須擴展其堆棧,G0會執行調度
與常規 G 相反,G0 有一個固定和更大的棧。G0除了負責G的調度,還有以下功能:
- Defer 函數的分配
- GC 收集,比如 STW、掃描 G 的堆棧和標記、清除操作
- 棧擴容,當需要的時候,由 g0 進行擴棧操作
從 g 到 g0 或從 g0 到 g 的切換是相當迅速的,它們只包含少量固定的指令。相反,對于schedule(),執行schedule()需要檢查許多資源以便確定下一個要運行的 G。
g0調度具體流程:
- 當前 g 阻塞在 chan 上并切換到 g0:
1、g的PC (程序計數器)和堆棧指針一起保存在內部結構中;
2、將 g0 設置為正在運行的 goroutine;
3、g0 的堆棧替換當前堆棧; - g0 執行schedule(),尋找runnable g
- g0 使用所選的 G 進行切換:
1、PC 和堆棧指針是從G內部結構中獲取的;
2、程序跳轉到對應的 PC 地址;
References:
http://www.cs.columbia.edu/~aho/cs6998/reports/12-12-11_DeshpandeSponslerWeiss_GO.pdf
https://www.yuque.com/aceld/golang/srxd6d
https://zhuanlan.zhihu.com/p/68299348
https://rakyll.org/scheduler/
https://zhuanlan.zhihu.com/p/27056944
https://www.cnblogs.com/sunsky303/p/11058728.html
https://yizhi.ren/2019/06/03/goscheduler
https://yizhi.ren/2019/06/08/gonetpoller
https://cloud.tencent.com/developer/article/1234360
https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-goroutine