Goroutine Local Storage 的一些實現方案和必要性討論
Java 的 ThreadLocal 是 Java 爲每個線程提供的專用存儲,把一些信息放在 ThreadLocal 上,可以用於來簡化上層應用的 API 使用。一個顯著的應用場景是,有了 ThreadLocal 後,就不需要在調用棧裏的每個函數上都增加額外的參數來傳遞一些與調用鏈和日誌鏈路追蹤相關的上下文信息了。
Go Team 針對增加 LocalStorage 的提案,明確說明過,他們更推薦顯式地使用 Context 參數而不是使用 LocalStorage 來進行上下文信息的傳遞。社區裏倒是有幾個 GLS(Goroutine Local Storage)的實現方案,我們團隊也在系統裏使用了 GLS,應用後並沒有明顯的性能降低,主要還是不想在每個函數定義上都添加參數來傳遞用來做日誌鏈路追蹤的 TraceId,但是並不建議業務邏輯依賴這些三方的 GLS 庫。
關於是否需要增加 GLS 的討論以及 GLS 帶來的性能和不兼容問題還是挺多的,正好看到一篇文章對 Go 語言是否該引入 GLS 的討論進行了總結,在這裏分享給大家。
原文作者:蘭陵子
原文鏈接:http://lanlingzi.cn/post/technical/2016/0813_go_gls/
背景
最近在設計調用鏈與日誌跟蹤的 API,發現相比於 Java 與 C++,Go 語言中沒有原生的線程(協程)上下文,也不支持 TLS(Thread Local Storage),更沒有暴露 API 獲取 Goroutine 的 Id(後面簡稱GoId
)。這導致無法像 Java 一樣,把一些信息放在 TLS 上,用於來簡化上層應用的 API 使用:不需要在調用棧的函數中通過傳遞參數來傳遞調用鏈與日誌跟蹤的一些上下文信息。
在 Java 與 C++ 中,TLS 是一種機制,指存儲在線程環境內的一個結構,用來存放該線程內獨享的數據。進程內的線程不能訪問不屬於自己的 TLS,這就保證了 TLS 內的數據在線程內是全局共享的,而對於線程外卻是不可見的。
在 Java 中,JDK 庫提供Thread.CurrentThread()
來獲取當前線程對象,提供ThreadLocal
來存儲與獲取線程局部變量。由於 Java 能通過Thread.CurrentThread()
獲取當前線程,其實現的思路就很簡單了,在 ThreadLocal 類中有一個 Map,用於存儲每一個線程的變量。
ThreadLocal 的 API 提供瞭如下的 4 個方法:
public T get()
protected T initialValue()
public void remove()
public void set(T value)
-
T get()
: 返回此線程局部變量的當前線程副本中的值,如果這是線程第一次調用該方法,則創建並初始化此副本。 -
protected T initialValue()
: 返回此線程局部變量的當前線程的初始值。最多在每次訪問線程來獲得每個線程局部變量時調用此方法一次,即線程第一次使用get()
方法訪問變量的時候。如果線程先於get
方法調用set(T)
方法,則不會在線程中再調用initialValue
方法。 -
void remove()
: 移除此線程局部變量的值。這可能有助於減少線程局部變量的存儲需求。如果再次訪問此線程局部變量,那麼在默認情況下它將擁有其initialValue
。 -
void set(T value)
將此線程局部變量的當前線程副本中的值設置爲指定值。許多應用程序不需要這項功能,它們只依賴於initialValue()
方法來設置線程局部變量的值。
在 Go 語言中,而 Google 提供的解決方法是採用golang.org/x/net/context
包來傳遞 GoRoutine 的上下文。對 Go 的 Context 的深入瞭解可參考我之前的分析:理解 Go Context 機制。Context
也是能存儲 Goroutine 一些數據達到共享,但它提供的接口是WithValue
函數來創建一個新的Context
對象。
func WithValue(parent Context, key interface{}, val interface{}) Context {
return &valueCtx{parent, key, val}
}
type valueCtx struct {
Context
key, val interface{}
}
func (c *valueCtx) Value(key interface{}) interface{} {
if c.key == key {
return c.val
}
return c.Context.Value(key)
}
從上面代碼中可以看出,Context
設置一次 Value,就會產生一個Context
對象,獲取 Value 是先找當前Context
存儲的值,若沒有再向父一級查找。獲取Value
可以說是多 Goroutine 訪問安全,因爲它的接口設計上,是隻一個 Goroutine 一次設置Key/Value
,其它多 Goroutine 只能讀取Key
的Value
。
爲什麼無獲取 GoId 接口
This, among other reasons, to prevent programmers for simulating thread local storage using the goroutine id as a key.
官方說,就爲了避免採用Goroutine Id
當成Thread Local Storage
的Key
。
Please don’t use goroutine local storage. It’s highly discouraged. In fact, IIRC, we used to expose Goid, but it is hidden since we don’t want people to do this.
用戶經常使用 GoId 來實現goroutine local storage
,而 Go 語言不希望用戶使用goroutine local storage
。
when goroutine goes away, its goroutine local storage won’t be GCed. (you can get goid for the current goroutine, but you can’t get a list of all running goroutines)
不建議使用goroutine local storage
的原因是由於不容易 GC,雖然能獲當前的 GoId,但不能獲取其它正在運行的 Goroutine。
what if handler spawns goroutine itself? the new goroutine suddenly loses access to your goroutine local storage. You can guarantee that your own code won’t spawn other goroutines, but in general you can’t make sure the standard library or any 3rd party code won’t do that.
另一個重要的原因是由於產生一個 Goroutine 非常地容易(而線程通用會採用線程池),新產生的 Goroutine 會失去訪問goroutine local storage
。需要上層應用保證不會產生新的 Goroutine,但我們很難確保標準庫或第三庫不會這樣做。
thread local storage is invented to help reuse bad/legacy code that assumes global state, Go doesn’t have legacy code like that, and you really should design your code so that state is passed explicitly and not as global (e.g. resort to goroutine local storage)
TLS 的應用是幫助重用現有那些不好(遺留)的採用全局狀態的代碼。而 Go 語言建議是重新設計代碼,採用顯示地傳遞狀態而不是採用全局狀態(例如採用goroutine local storage
)。
其它手段獲取 GoId
雖然 Go 語言有意識地隱藏 GoId,但目前還是有手段來獲取 GoId:
-
修改源代碼暴露 GoId,但 Go 語言可能隨時修改源碼,導致不兼容
在標準庫的
runtime/proc.go
(Go 1.6.3)中的newextram
函數,會產生個 GoId:mp.lockedg = gp gp.lockedm = mp gp.goid = int64(atomic.Xadd64(&sched.goidgen, 1))
-
通過
runtime.Stack
來分析 Stack 輸出信息獲取 GoId。在標準庫的
runtime/mprof.go
(Go 1.6.3)中,runtime.Stack
會獲取 gp 對象 (包含 GoId) 並輸出整個 Stack 信息:func Stack(buf []byte, all bool) int { if all { stopTheWorld("stack trace") } n := 0 if len(buf) > 0 { gp := getg() sp := getcallersp(unsafe.Pointer(&buf)) pc := getcallerpc(unsafe.Pointer(&buf)) systemstack(func() { g0 := getg() g0.m.traceback = 1 g0.writebuf = buf[0:0:len(buf)] goroutineheader(gp) traceback(pc, sp, 0, gp) if all { tracebackothers(gp) } g0.m.traceback = 0 n = len(g0.writebuf) g0.writebuf = nil }) } if all { startTheWorld() } return n }
從文件名就可以看出,
runtime/mprof.go
是用於做 Profile 分析,獲取 Stack 肯定性能不會太好。從上面的代碼來看,若第二個參數指定爲 true,還會 STW,業務系統無論如何都無法接受。若 Go 語言修改了 Stack 的輸出,分析 Stack 信息也會導致無法正常獲取 GoId。 -
通用
runtime.Callers
來給調用 Stack 來打標籤代碼參考:https://github.com/jtolds/gls/blob/master/stack_tags_main.go#L43
-
通過內聯 c 或者內聯彙編
go 版本 1.5,x86_64arc 下彙編,估計也不通用
// func GoID() int64 TEXT s3lib GoID(SB),NOSPLIT,$0-8 MOVQ TLS, CX MOVQ 0(CX)(TLS*1), AX MOVQ AX, ret+0(FP) RET
開源 goroutine local storage 實現
只要有機制獲取 GoId,就可以像 Java 一樣來採用全局的 map 實現goroutine local storage
,在 Github 上搜索一下,發現有兩個:
-
tylerb/gls
GoId 是通過
runtime.Stack
來分析 Stack 輸出信息獲取 GoId。 -
jtolds/gls
GoId 是通用
runtime.Callers
來給調用 Stack 來打標籤
第二個有人在 2013 年測試過性能,數據如下:
BenchmarkGetValue 500000 2953 ns/op
BenchmarkSetValues 500000 4050 ns/op
上面的測試結果看似還不錯,但goroutine local storage
實現無外乎是map+RWMutex
,存在性能瓶頸:
-
Goroutine 不像 Thread,它的個數可以上十萬併發,當這麼多的 Goroutine 同時競爭同一把鎖時,性能會急劇惡化。
-
GoId 是通過分析調用 Stack 的信息來獲取,也是一個高成本的調用,一個字:慢。
不管怎麼樣,沒有官方的 GLS,的確不是很方便,第三方實現又存在性能與不兼容風險。連jtolds/gls
作者也貼出其它人的評價:
“Wow, that’s horrifying.”
“This is the most terrible thing I have seen in a very long time.”
“Where is it getting a context from? Is this serializing all the requests? What the heck is the client being bound to? What are these tags? Why does he need callers? Oh god no. No no no.”
小結
Go 語言官方認爲 TLS 來存儲全局狀態是不好的設計,而是要顯示地傳遞狀態。Google 給的解決方法是golang.org/x/net/context
。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/luUxiLZCf_x7ZA1LUlP7sA