您好,登錄后才能下訂單哦!
Go(又稱Golang)是Google開發的一種靜態強類型、編譯型、并發型,并具有垃圾回收功能的編程語言。
羅伯特·格瑞史莫(Robert Griesemer),羅勃·派克(Rob Pike)及肯·湯普遜(Ken Thompson)于2007年9月開始設計Go,稍后Ian Lance Taylor、Russ Cox加入項目。Go是基于Inferno操作系統所開發的。Go于2009年11月正式宣布推出,成為開放源代碼項目,并在Linux及Mac OS X平臺上進行了實現,后來追加了Windows系統下的實現。在2016年,Go被軟件評價公司TIOBE 選為“TIOBE 2016 年最佳語言”。 目前,Go每半年發布一個二級版本(即從a.x升級到a.y)。
Go 在類型和接口上的思考是:
Go 的類型系統是比較容易和 C++/Java 混淆的,特別是習慣于類體系和虛函數的思路后,很容易想在 Go 走這個路子,可惜是走不通的。而 interface 因為太過于簡單,而且和 C++/Java 中的概念差異不是特別明顯,所以本章節專門分析 Go 的類型系統。
先看一個典型的問題 Is it possible to call overridden method from parent struct in golang? 代碼如下所示:
package main
import (
"fmt"
)
type A struct {
}
func (a *A) Foo() {
fmt.Println("A.Foo()")
}
func (a *A) Bar() {
a.Foo()
}
type B struct {
A
}
func (b *B) Foo() {
fmt.Println("B.Foo()")
}
func main() {
b := B{A: A{}}
b.Bar()
}
本質上它是一個模板方法模式 (TemplateMethodPattern),A 的 Bar 調用了虛函數 Foo,期待子類重寫虛函數 Foo,這是典型的 C++/Java 解決問題的思路。
我們借用模板方法模式 (TemplateMethodPattern) 中的例子,考慮實現一個跨平臺編譯器,提供給用戶使用的函數是 crossCompile
,而這個函數調用了兩個模板方法 collectSource
和 compileToTarget
:
public abstract class CrossCompiler {
public final void crossCompile() {
collectSource();
compileToTarget();
}
//Template methods
protected abstract void collectSource();
protected abstract void compileToTarget();
}
C 版,不用 OOAD 思維參考 C: CrossCompiler use StateMachine,代碼如下所示:
// g++ compiler.cpp -o compiler && ./compiler
#include <stdio.h>
void beforeCompile() {
printf("Before compile\n");
}
void afterCompile() {
printf("After compile\n");
}
void collectSource(bool isIPhone) {
if (isIPhone) {
printf("IPhone: Collect source\n");
} else {
printf("Android: Collect source\n");
}
}
void compileToTarget(bool isIPhone) {
if (isIPhone) {
printf("IPhone: Compile to target\n");
} else {
printf("Android: Compile to target\n");
}
}
void IDEBuild(bool isIPhone) {
beforeCompile();
collectSource(isIPhone);
compileToTarget(isIPhone);
afterCompile();
}
int main(int argc, char** argv) {
IDEBuild(true);
//IDEBuild(false);
return 0;
}
C 版本使用 OOAD 思維,可以參考 C: CrossCompiler,代碼如下所示:
// g++ compiler.cpp -o compiler && ./compiler
#include <stdio.h>
class CrossCompiler {
public:
void crossCompile() {
beforeCompile();
collectSource();
compileToTarget();
afterCompile();
}
private:
void beforeCompile() {
printf("Before compile\n");
}
void afterCompile() {
printf("After compile\n");
}
// Template methods.
public:
virtual void collectSource() = 0;
virtual void compileToTarget() = 0;
};
class IPhoneCompiler : public CrossCompiler {
public:
void collectSource() {
printf("IPhone: Collect source\n");
}
void compileToTarget() {
printf("IPhone: Compile to target\n");
}
};
class AndroidCompiler : public CrossCompiler {
public:
void collectSource() {
printf("Android: Collect source\n");
}
void compileToTarget() {
printf("Android: Compile to target\n");
}
};
void IDEBuild(CrossCompiler* compiler) {
compiler->crossCompile();
}
int main(int argc, char** argv) {
IDEBuild(new IPhoneCompiler());
//IDEBuild(new AndroidCompiler());
return 0;
}
我們可以針對不同的平臺實現這個編譯器,比如 Android 和 iPhone:
public class IPhoneCompiler extends CrossCompiler {
protected void collectSource() {
//anything specific to this class
}
protected void compileToTarget() {
//iphone specific compilation
}
}
public class AndroidCompiler extends CrossCompiler {
protected void collectSource() {
//anything specific to this class
}
protected void compileToTarget() {
//android specific compilation
}
}
在 C++/Java 中能夠完美的工作,但是在 Go 中,使用結構體嵌套只能這么實現,讓 IPhoneCompiler 和 AndroidCompiler 內嵌 CrossCompiler,參考 Go: TemplateMethod,代碼如下所示:
package main
import (
"fmt"
)
type CrossCompiler struct {
}
func (v CrossCompiler) crossCompile() {
v.collectSource()
v.compileToTarget()
}
func (v CrossCompiler) collectSource() {
fmt.Println("CrossCompiler.collectSource")
}
func (v CrossCompiler) compileToTarget() {
fmt.Println("CrossCompiler.compileToTarget")
}
type IPhoneCompiler struct {
CrossCompiler
}
func (v IPhoneCompiler) collectSource() {
fmt.Println("IPhoneCompiler.collectSource")
}
func (v IPhoneCompiler) compileToTarget() {
fmt.Println("IPhoneCompiler.compileToTarget")
}
type AndroidCompiler struct {
CrossCompiler
}
func (v AndroidCompiler) collectSource() {
fmt.Println("AndroidCompiler.collectSource")
}
func (v AndroidCompiler) compileToTarget() {
fmt.Println("AndroidCompiler.compileToTarget")
}
func main() {
iPhone := IPhoneCompiler{}
iPhone.crossCompile()
}
執行結果卻讓人手足無措:
# Expect
IPhoneCompiler.collectSource
IPhoneCompiler.compileToTarget
# Output
CrossCompiler.collectSource
CrossCompiler.compileToTarget
Go 并沒有支持類繼承體系和多態,Go 是面向對象卻不是一般所理解的那種面向對象,用老子的話說“道可道,非常道”。
實際上在 OOAD 中,除了類繼承之外,還有另外一個解決問題的思路就是組合 Composition,面向對象設計原則中有個很重要的就是 The Composite Reuse Principle (CRP),Favor delegation over inheritance as a reuse mechanism
,重用機制應該優先使用組合(代理)而不是類繼承。類繼承會喪失靈活性,而且訪問的范圍比組合要大;組合有很高的靈活性,另外組合使用另外對象的接口,所以能獲得最小的信息。
C++ 如何使用組合代替繼承實現模板方法?可以考慮讓 CrossCompiler 使用其他的類提供的服務,或者說使用接口,比如 CrossCompiler
依賴于 ICompiler
:
public interface ICompiler {
//Template methods
protected abstract void collectSource();
protected abstract void compileToTarget();
}
public abstract class CrossCompiler {
public ICompiler compiler;
public final void crossCompile() {
compiler.collectSource();
compiler.compileToTarget();
}
}
C 版本可以參考 C: CrossCompiler use Composition,代碼如下所示:
// g++ compiler.cpp -o compiler && ./compiler
#include <stdio.h>
class ICompiler {
// Template methods.
public:
virtual void collectSource() = 0;
virtual void compileToTarget() = 0;
};
class CrossCompiler {
public:
CrossCompiler(ICompiler* compiler) : c(compiler) {
}
void crossCompile() {
beforeCompile();
c->collectSource();
c->compileToTarget();
afterCompile();
}
private:
void beforeCompile() {
printf("Before compile\n");
}
void afterCompile() {
printf("After compile\n");
}
ICompiler* c;
};
class IPhoneCompiler : public ICompiler {
public:
void collectSource() {
printf("IPhone: Collect source\n");
}
void compileToTarget() {
printf("IPhone: Compile to target\n");
}
};
class AndroidCompiler : public ICompiler {
public:
void collectSource() {
printf("Android: Collect source\n");
}
void compileToTarget() {
printf("Android: Compile to target\n");
}
};
void IDEBuild(CrossCompiler* compiler) {
compiler->crossCompile();
}
int main(int argc, char** argv) {
IDEBuild(new CrossCompiler(new IPhoneCompiler()));
//IDEBuild(new CrossCompiler(new AndroidCompiler()));
return 0;
}
我們可以針對不同的平臺實現這個 ICompiler
,比如 Android 和 iPhone。這樣從繼承的類體系,變成了更靈活的接口的組合,以及對象直接服務的調用:
public class IPhoneCompiler implements ICompiler {
protected void collectSource() {
//anything specific to this class
}
protected void compileToTarget() {
//iphone specific compilation
}
}
public class AndroidCompiler implements ICompiler {
protected void collectSource() {
//anything specific to this class
}
protected void compileToTarget() {
//android specific compilation
}
}
在 Go 中,推薦用組合和接口,小的接口,大的對象。這樣有利于只獲得自己應該獲取的信息,或者不會獲得太多自己不需要的信息和函數,參考 Clients should not be forced to depend on methods they do not use. –Robert C. Martin,以及 The bigger the interface, the weaker the abstraction, Rob Pike。關于面向對象的原則在 Go 中的體現,參考 Go: SOLID 或中文版 Go: SOLID。
先看如何使用 Go 的思路實現前面的例子,跨平臺編譯器,Go Composition: Compiler,代碼如下所示:
package main
import (
"fmt"
)
type SourceCollector interface {
collectSource()
}
type TargetCompiler interface {
compileToTarget()
}
type CrossCompiler struct {
collector SourceCollector
compiler TargetCompiler
}
func (v CrossCompiler) crossCompile() {
v.collector.collectSource()
v.compiler.compileToTarget()
}
type IPhoneCompiler struct {
}
func (v IPhoneCompiler) collectSource() {
fmt.Println("IPhoneCompiler.collectSource")
}
func (v IPhoneCompiler) compileToTarget() {
fmt.Println("IPhoneCompiler.compileToTarget")
}
type AndroidCompiler struct {
}
func (v AndroidCompiler) collectSource() {
fmt.Println("AndroidCompiler.collectSource")
}
func (v AndroidCompiler) compileToTarget() {
fmt.Println("AndroidCompiler.compileToTarget")
}
func main() {
iPhone := IPhoneCompiler{}
compiler := CrossCompiler{iPhone, iPhone}
compiler.crossCompile()
}
這個方案中,將兩個模板方法定義成了兩個接口,CrossCompiler
使用了這兩個接口,因為本質上 C++/Java 將它的函數定義為抽象函數,意思也是不知道這個函數如何實現。而 IPhoneCompiler
和 AndroidCompiler
并沒有繼承關系,而它們兩個實現了這兩個接口,供 CrossCompiler
使用;也就是它們之間的關系,從之前的強制綁定,變成了組合。
type SourceCollector interface {
collectSource()
}
type TargetCompiler interface {
compileToTarget()
}
type CrossCompiler struct {
collector SourceCollector
compiler TargetCompiler
}
func (v CrossCompiler) crossCompile() {
v.collector.collectSource()
v.compiler.compileToTarget()
}
Rob Pike 在 Go Language: Small and implicit 中描述 Go 的類型和接口,第 29 頁說:
foo(f *os.File)
,最初依賴于 os.File
,但實際上可能只是依賴于 io.Reader
就可以方便做 UTest,那么可以直接修改成 foo(r io.Reader)
所有地方都不用修改,特別是這個接口是新增的自定義接口時就更明顯;隱式實現接口有個很好的作用,就是兩個類似的模塊實現同樣的服務時,可以無縫的提供服務,甚至可以同時提供服務。比如改進現有模塊時,比如兩個不同的算法。更厲害的時,兩個模塊創建的私有接口,如果它們簽名一樣,也是可以互通的,其實簽名一樣就是一樣的接口,無所謂是不是私有的了。這個非常強大,可以允許不同的模塊在不同的時刻升級,這對于提供服務的服務器太重要了。
比較被嚴重誤認為是繼承的,莫過于是 Go 的內嵌 Embeding,因為 Embeding 本質上還是組合不是繼承,參考 Embeding is still composition。
Embeding 在 UTest 的 Mocking 中可以顯著減少需要 Mock 的函數,比如 Mocking net.Conn,如果只需要 mock Read 和 Write 兩個函數,就可以通過內嵌 net.Conn 來實現,這樣 loopBack 也實現了整個 net.Conn 接口,不必每個接口全部寫一遍:
type loopBack struct {
net.Conn
buf bytes.Buffer
}
func (c *loopBack) Read(b []byte) (int, error) {
return c.buf.Read(b)
}
func (c *loopBack) Write(b []byte) (int, error) {
return c.buf.Write(b)
}
Embeding 只是將內嵌的數據和函數自動全部代理了一遍而已,本質上還是使用這個內嵌對象的服務。Outer 內嵌了Inner,和 Outer 繼承 Inner 的區別在于:內嵌 Inner 是不知道自己被內嵌,調用 Inner 的函數,并不會對 Outer 有任何影響,Outer 內嵌 Inner 只是自動將 Inner 的數據和方法代理了一遍,但是本質上 Inner 的東西還不是 Outer 的東西;對于繼承,調用 Inner 的函數有可能會改變 Outer 的數據,因為 Outer 繼承 Inner,那么 Outer 就是 Inner,二者的依賴是更緊密的。
如果很難理解為何 Embeding 不是繼承,本質上是沒有區分繼承和組合的區別,可以參考 Composition not inheritance,Go 選擇組合不選擇繼承是深思熟慮的決定,面向對象的繼承、虛函數、多態和類樹被過度使用了。類繼承樹需要前期就設計好,而往往系統在演化時發現類繼承樹需要變更,我們無法在前期就精確設計出完美的類繼承樹;Go 的接口和組合,在接口變更時,只需要變更最直接的調用層,而沒有類子樹需要變更。
The designs are nothing like hierarchical, subtype-inherited methods. They are looser (even ad hoc), organic, decoupled, independent, and therefore scalable.
組合比繼承有個很關鍵的優勢是正交性 orthogonal
,詳細參考正交性。
真水無香,真的牛逼不用裝。——來自網絡
軟件是一門科學也是藝術,換句話說軟件是工程。科學的意思是邏輯、數學、二進制,比較偏基礎的理論都是需要數學的,比如 C 的結構化編程是有論證的,那些關鍵字和邏輯是夠用的。實際上 Go 的 GC 也是有數學證明的,還有一些網絡傳輸算法,又比如奠定一個新領域的論文比如 Google 的論文。藝術的意思是,大部分時候都用不到嚴密的論證,有很多種不同的路,還需要看自己的品味或者叫偏見,特別容易引起口水仗和爭論,從好的方面說,好的軟件或代碼,是能被感覺到很好的。
由于大部分時候軟件開發是要靠經驗的,特別是國內填鴨式教育培養了對于數學的莫名的仇恨(“莫名”主要是早就把該忘的不該忘記的都忘記了),所以在代碼中強調數學,會激發起大家心中一種特別的鄙視和懷疑,而這種鄙視和懷疑應該是以蔥白和畏懼為基礎——大部分時候在代碼中吹數學都會被認為是裝逼。而 Orthogonal (正交性)則不擇不扣的是個數學術語,是線性代數(就是矩陣那個玩意兒)中用來描述兩個向量相關性的,在平面中就是兩個線條的垂直。比如下圖:
Vectors A and B are orthogonal to each other.
旁白:妮瑪,兩個線條垂直能和代碼有個毛線關系,八竿子打不著關系吧,請繼續吹。
先請看 Go 關于 Orthogonal 相關的描述,可能還不止這些地方:
Composition not inheritance Object-oriented programming provides a powerful insight: that the behavior of data can be generalized independently of the representation of that data. The model works best when the behavior (method set) is fixed, but once you subclass a type and add a method, the behaviors are no longer identical. If instead the set of behaviors is fixed, such as in Go's statically defined interfaces, the uniformity of behavior enables data and programs to be composed uniformly, orthogonally, and safely.
JSON-RPC: a tale of interfaces In an inheritance-oriented language like Java or C++, the obvious path would be to generalize the RPC class, and create JsonRPC and GobRPC subclasses. However, this approach becomes tricky if you want to make a further generalization orthogonal to that hierarchy.
實際上 Orthogonal 并不是只有 Go 才提,參考 Orthogonal Software。實際上很多軟件設計都會提正交性,比如 OOAD 里面也有不少地方用這個描述。我們先從實際的例子出發吧,關于線程一般 Java、Python、C# 等語言,會定義個線程的類 Thread,可能包含以下的方法管理線程:
var thread = new Thread(thread_main_function);
thread.Start();
thread.Interrupt();
thread.Join();
thread.Stop();
如果把 goroutine 也看成是 Go 的線程,那么實際上 Go 并沒有提供上面的方法,而是提供了幾種不同的機制來管理線程:
go
關鍵鍵字啟動 goroutine;sync.WaitGroup
等待線程退出;chan
也可以用來同步,比如等 goroutine 啟動或退出,或者傳遞退出信息給 goroutine;context
也可以用來管理 goroutine,參考 Context。s := make(chan bool, 0)
q := make(chan bool, 0)
go func() {
s <- true // goroutine started.
for {
select {
case <-q:
return
default:
// do something.
}
}
} ()
<- s // wait for goroutine started.
time.Sleep(10)
q <- true // notify goroutine quit.
注意上面只是例子,實際中推薦用 Context 管理 goroutine。
如果把 goroutine 看成一個向量,把 sync 看成一個向量,把 chan 看成一個向量,這些向量都不相關,也就是它們是正交的。
再舉個 Orthogonal Software 的例子,將對象存儲到 TEXT 或 XML 文件,可以直接寫對象的序列化函數:
def read_dictionary(file)
if File.extname(file) == ".xml"
# read and return definitions in XML from file
else
# read and return definitions in text from file
end
end
這個的壞處包括:
如果改進下這個例子,將存儲分離:
class Dictionary
def self.instance(file)
if File.extname(file) == ".xml"
XMLDictionary.new(file)
else
TextDictionary.new(file)
end
end
end
class TextDictionary < Dictionary
def write
# write text to @file using the @definitions hash
end
def read
# read text from @file and populate the @definitions hash
end
end
如果把 Dictionay 看成一個向量,把存儲方式看成一個向量,再把 JSON 或 INI 格式看成一個向量,他們實際上是可以不相關的。
再看一個例子,考慮上面 JSON-RPC: a tale of interfaces 的修改,實際上是將序列化的部分,從 *gob.Encoder
變成了接口 ServerCodec
,然后實現了 jsonCodec 和 gobCodec 兩種 Codec,所以 RPC 和 ServerCodec 是正交的。非正交的做法,就是從 RPC 繼承兩個類 jsonRPC 和 gobRPC,這樣 RPC 和 Codec 是耦合的并不是不相關的。
Orthogonal 不相關到底有什么好說的?
數學中不相關的兩個向量,可以作為空間的基,比如平面上就是 x 和 y 軸,從向量看就是兩個向量,這兩個不相關的向量 x 和 y 可以組合出平面的任意向量,平面任一點都可以用 x 和 y 表示;如果向量不正交,有些區域就不能用這兩個向量表達,有些點就不能表達。這個在接口設計上就是:正交的接口,能讓用戶靈活組合出能解決各種問題的調用方式,不相關的向量可以張成整個向量空間;同樣的如果不正交,有時候就發現自己想要的功能無法通過現有接口實現,必須修改接口的定義;
比如 goroutine 的例子,我們可以用 sync 或 chan 達到自己想要的控制 goroutine 的方式。比如 context 也是組合了 chan、timeout、value 等接口提供的一個比較明確的功能庫。這些語言級別的正交的元素,可以組合成非常多樣和豐富的庫。比如有時候我們需要等 goroutine 啟動,有時候不用;有時候甚至不需要管理 goroutine,有時候需要主動通知 goroutine 退出;有時候我們需要等 goroutine 出錯后處理;
比如序列化 TEXT 或 XML 的例子,可以將對象的邏輯完全和存儲分離,避免對象的邏輯中隨處可見存儲對象的代碼,維護性可以極大的提升。另外,兩個向量的耦合還可以理解,如果是多個向量的耦合就難以實現,比如要將對象序列化為支持注釋的 JSON 先存儲到網絡有問題再存儲為 TEXT 文件,同時如果是程序升級則存儲為 XML 文件,這種復雜的邏輯實際上需要很靈活的組合,本質上就是空間的多個向量的組合表達出空間的新向量(新功能);
Copy(src, dst io.ReadWriter)
就有問題,因為 src 明顯不會用到 Write
而 dst不會用到 Read
,所以改成 Copy(src io.Reader, dst io.Writer)
才是合理的。由此可見,Orthogonal 是接口設計中非常關鍵的要素,我們需要從概念上考慮接口,盡量提供正交的接口和函數。比如 io.Reader
、io.Writer
和 io.Closer
是正交的,因為有時候我們需要的新向量是讀寫那么可以使用 io.ReadWriter
,這實際上是兩個接口的組合。
我們如何才能實現 Orthogonal 的接口呢?特別對于公共庫,這個非常關鍵,直接決定了我們是否能提供好用的庫,還是很爛的不知道怎么用的庫。有幾個建議:
好用的公共庫,使用者可以通過 IDE 的提示就知道怎么用,不應該提供多個不同的路徑實現一個功能,會造成很大的困擾。比如 Android 的通訊錄,超級多的完全不同的類可以用,實際上就是非常難用;
必須要有完善的文檔。完全通過代碼就能表達 Why 和 How,是不可能的。就算是 Go 的標準庫,也是大量的注釋,如果一個公共庫沒有文檔和注釋,會非常的難用和維護;
如果上面數學上有不嚴謹的請原諒我,我數學很渣。
先把最重要的說了,關于 modules 的最新詳細信息可以執行命令 go help modules
或者查這個長長的手冊 Go Modules,另外 modules 弄清楚后很好用遷移成本低。
Go Module 的好處,可以參考 Demo:
Go 最初是使用 GOPATH 存放依賴的包(項目和代碼),這個 GOPATH 是公共的目錄,如果依賴的庫的版本不同就杯具了。2016 年也就是 7 年后才支持 vendor 規范,就是將依賴本地化了,每個項目都使用自己的 vendor 文件夾,但這樣也解決不了沖突的問題(具體看下面的分析),相反導致各種包管理項目天下混戰,參考 pkg management tools。
2017 年也就是 8 年后,官方的 vendor 包管理器 dep 才確定方案,看起來命中注定的 TheOne 終于塵埃落定。不料 2018 年也就是 9 年后,又提出比較完整的方案 versioning 和 vgo,這年 Go1.11 支持了 Modules,2019 年 Go1.12 和 Go1.13 改進了不少 Modules 內容,Go 官方文檔推出一系列的 Part 1 — Using Go Modules、Part 2 — Migrating To Go Modules 和 Part 3 — Publishing Go Modules,終于應該大概齊能明白,這次真的確定和肯定了,Go Modules 是最終方案。
為什么要搞出 GOPATH、Vendor 和 GoModules 這么多技術方案?本質上是為了創造就業崗位,一次創造了 index、proxy 和 sum 三個官網,哈哈哈。當然技術上也是必須要這么做的,簡單來說是為了解決古老的 DLL Hell
問題,也就是依賴管理和版本管理的問題。版本說起來就是幾個數字,比如 1.2.3
,實際上是非常復雜的問題,推薦閱讀 Semantic Versioning,假設定義了良好和清晰的 API,我們用版本號來管理 API 的兼容性;版本號一般定義為 MAJOR.MINOR.PATCH
,Major 變更時意味著不兼容的API變更,Minor 是功能變更但是是兼容的,Patch 是 BugFix 也是兼容的,Major 為 0 時表示 API 還不穩定。由于 Go 的包是 URL 的,沒有版本號信息,最初對于包的版本管理原則是必須一直保持接口兼容:
If an old package and a new package have the same import path, the new package must be backwards compatible with the old package.
試想下如果所有我們依賴的包,一直都是接口兼容的,那就沒有啥問題,也沒有 DLL Hell
。可惜現實卻不是這樣,如果我們提供過包就知道,對于持續維護和更新的包,在最初不可能提供一個永遠不變的接口,變化的接口就是不兼容的了。就算某個接口可以不變,還有依賴的包,還有依賴的依賴的包,還有依賴的依賴的依賴的包,以此往復,要求世界上所有接口都不變,才不會有版本問題,這么說起來,包管理是個極其難以解決的問題,Go 花了 10 年才確定最終方案就是這個原因了,下面舉例子詳細分析這個問題。
備注:標準庫也有遇到接口變更的風險,比如 Context 是 Go1.7 才引入標準庫的,控制程序生命周期,后續有很多接口的第一個參數都是
ctx context.Context
,比如net.DialContext
就是后面加的一個函數,而net.Dial
也是調用它。再比如http.Request.WithContext
則提供了一個函數,將 context 放在結構體中傳遞,這是因為要再為每個 Request 的函數新增一個參數不太合適。從 context 對于標準庫的接口的變更,可以看得到這里有些不一致性,有很多批評的聲音比如 Context should go away for Go 2,就是覺得在標準庫中加 context 作為第一個參數不能理解,比如Read(ctx context.Context
等。
咱們先看 GOPATH 的方式。Go 引入外部的包,是 URL 方式的,先在環境變量 $GOROOT
中搜索,然后在 $GOPATH
中搜索,比如我們使用 Errors,依賴包 github.com/o***s/go-oryx-lib/errors
,代碼如下所示:
package main
import (
"fmt"
"github.com/o***s/go-oryx-lib/errors"
)
func main() {
fmt.Println(errors.New("Hello, playground"))
}
如果我們直接運行會報錯,錯誤信息如下:
prog.go:5:2: cannot find package "github.com/o***s/go-oryx-lib/errors" in any of:
/usr/local/go/src/github.com/o***s/go-oryx-lib/errors (from $GOROOT)
/go/src/github.com/o***s/go-oryx-lib/errors (from $GOPATH)
需要先下載這個依賴包 go get -d github.com/o***s/go-oryx-lib/errors
,然后運行就可以了。下載后放在 GOPATH 中:
Mac $ ls -lh $GOPATH/src/github.com/o***s/go-oryx-lib/errors
total 72
-rw-r--r-- 1 chengli.ycl staff 1.3K Sep 8 15:35 LICENSE
-rw-r--r-- 1 chengli.ycl staff 2.2K Sep 8 15:35 README.md
-rw-r--r-- 1 chengli.ycl staff 1.0K Sep 8 15:35 bench_test.go
-rw-r--r-- 1 chengli.ycl staff 6.7K Sep 8 15:35 errors.go
-rw-r--r-- 1 chengli.ycl staff 5.4K Sep 8 15:35 example_test.go
-rw-r--r-- 1 chengli.ycl staff 4.7K Sep 8 15:35 stack.go
如果我們依賴的包還依賴于其他的包,那么 go get
會下載所有依賴的包到 GOPATH。這樣是下載到公共的 GOPATH 的,可以想到,這會造成幾個問題:
為了解決這些問題,引入了 vendor,在 src 下面有個 vendor 目錄,將依賴的庫都下載到這個目錄,同時會有描述文件說明依賴的版本,這樣可以實現升級不同庫的升級。參考 vendor,以及官方的包管理器 dep。但是 vendor 并沒有解決所有的問題,特別是包的不兼容版本的問題,只解決了項目或應用,也就是會編譯出二進制的項目所依賴庫的問題。
咱們把上面的例子用 vendor 實現,先要把項目軟鏈或者挪到 GOPATH 里面去,若沒有 dep 工具可以參考 Installation 安裝,然后執行下面的命令來將依賴導入到 vendor 目錄:
dep init && dep ensure
這樣依賴的文件就會放在 vendor 下面,編譯時也不再需要從遠程下載了:
├── Gopkg.lock
├── Gopkg.toml
├── t.go
└── vendor
└── github.com
└── o***s
└── go-oryx-lib
└── errors
├── errors.go
└── stack.go
Remark: Vendor 也會選擇版本,也有版本管理,但每個包它只會選擇一個版本,也就是本質上是本地化的 GOPATH,如果出現鉆石依賴和沖突還是無解,下面會詳細說明。
我們來看 GOPATH 和 Vencor 無法解決的一個問題,版本依賴問題的一個例子 Semantic Import Versioning,考慮鉆石依賴的情況,用戶依賴于兩個云服務商的 SDK,而它們可能都依賴于公共的庫,形成一個鉆石形狀的依賴,用戶依賴 AWS 和 Azure 而它們都依賴 OAuth:
如果公共庫 package(這里是 OAuth)的導入路徑一樣(比如是 github.com/google/oauth),但是做了非兼容性變更,發布了 OAuth-r1 和 OAuth-r2,其中一個云服務商更新了自己的依賴,另外一個沒有更新,就會造成沖突,他們依賴的版本不同:
在 Go 中無論怎么修改都無法支持這種情況,除非在 package 的路徑中加入版本語義進去,也就是在路徑上帶上版本信息(這就是 Go Modules了),這和優雅沒有關系,這實際上是最好的使用體驗:
另外做法就是改變包路徑,這要求包提供者要每個版本都要使用一個特殊的名字,但使用者也不能分辨這些名字代表的含義,自然也不知道如何選擇哪個版本。
先看看 Go Modules 創造的三大就業崗位,index 負責索引、proxy 負責代理緩存和 sum 負責簽名校驗,它們之間的關系在 Big Picture 中有描述。可見 go-get 會先從 index 獲取指定 package 的索引,然后從 proxy 下載數據,最后從 sum 來獲取校驗信息:
還是先跟著官網的三部曲,先了解下 modules 的基本用法,后面補充下特別要注意的問題就差不多齊了。首先是 Using Go Modules,如何使用 modules,還是用上面的例子,代碼不用改變,只需要執行命令:
go mod init private.me/app && go run t.go
Remark:和vendor并不相同,modules并不需要在GOPATH下面才能創建,所以這是非常好的。
執行的結果如下,可以看到 vgo 查詢依賴的庫,下載后解壓到了 cache,并生成了 go.mod 和 go.sum,緩存的文件在 $GOPATH/pkg
下面:
Mac:gogogo chengli.ycl$ go mod init private.me/app && go run t.go
go: creating new go.mod: module private.me/app
go: finding github.com/o***s/go-oryx-lib v0.0.7
go: downloading github.com/o***s/go-oryx-lib v0.0.7
go: extracting github.com/o***s/go-oryx-lib v0.0.7
Hello, playground
Mac:gogogo chengli.ycl$ cat go.mod
module private.me/app
go 1.13
require github.com/o***s/go-oryx-lib v0.0.7 // indirect
Mac:gogogo chengli.ycl$ cat go.sum
github.com/o***s/go-oryx-lib v0.0.7 h2:k8ml3ZLsjIMoQEdZdWuy8zkU0w/fbJSyHvT/s9NyeCc=
github.com/o***s/go-oryx-lib v0.0.7/go.mod h2:i2tH4TZBzAw5h+HwGrNOKvP/nmZgSQz0OEnLLdzcT/8=
Mac:gogogo chengli.ycl$ tree $GOPATH/pkg
/Users/winlin/go/pkg
├── mod
│ ├── cache
│ │ ├── download
│ │ │ ├── github.com
│ │ │ │ └── o***s
│ │ │ │ └── go-oryx-lib
│ │ │ │ └── @v
│ │ │ │ ├── list
│ │ │ │ ├── v0.0.7.info
│ │ │ │ ├── v0.0.7.zip
│ │ │ └── sumdb
│ │ │ └── sum.golang.org
│ │ │ ├── lookup
│ │ │ │ └── github.com
│ │ │ │ └── o***s
│ │ │ │ └── go-oryx-lib@v0.0.7
│ └── github.com
│ └── o***s
│ └── go-oryx-lib@v0.0.7
│ ├── errors
│ │ ├── errors.go
│ │ └── stack.go
└── sumdb
└── sum.golang.org
└── latest
可以手動升級某個庫,即 go get 這個庫:
Mac:gogogo chengli.ycl$ go get github.com/o***s/go-oryx-lib
go: finding github.com/o***s/go-oryx-lib v0.0.8
go: downloading github.com/o***s/go-oryx-lib v0.0.8
go: extracting github.com/o***s/go-oryx-lib v0.0.8
Mac:gogogo chengli.ycl$ cat go.mod
module private.me/app
go 1.13
require github.com/o***s/go-oryx-lib v0.0.8
升級某個包到指定版本,可以帶上版本號,例如 go get github.com/o***s/go-oryx-lib@v0.0.8
。當然也可以降級,比如現在是 v0.0.8,可以 go get github.com/o***s/go-oryx-lib@v0.0.7
降到 v0.0.7 版本。也可以升級所有依賴的包,執行 go get -u
命令就可以。查看依賴的包和版本,以及依賴的依賴的包和版本,可以執行 go list -m all
命令。查看指定的包有哪些版本,可以用 go list -m -versions github.com/o***s/go-oryx-lib
命令。
Note: 關于 vgo 如何選擇版本,可以參考 Minimal Version Selection。
如果依賴了某個包大版本的多個版本,那么會選擇這個大版本最高的那個,比如:
比如下面代碼,依賴了四個包,而這四個包依賴了某個包的不同版本,分別選擇不同的包,執行 rm -f go.mod && go mod init private.me/app && go run t.go
,可以看到選擇了不同的版本,始終選擇的是大版本最高的那個(也就是滿足要求的最小版本):
package main
import (
"fmt"
"github.com/winlinvip/mod_ref_a" // 1.0.1
"github.com/winlinvip/mod_ref_b" // 1.2.3
"github.com/winlinvip/mod_ref_c" // 1.0.3
"github.com/winlinvip/mod_ref_d" // 0.0.7
)
func main() {
fmt.Println("Hello",
mod_ref_a.Version(),
mod_ref_b.Version(),
mod_ref_c.Version(),
mod_ref_d.Version(),
)
}
若包需要升級大版本,則需要在路徑上加上版本,包括本身的 go.mod 中的路徑,依賴這個包的 go.mod,依賴它的代碼,比如下面的例子,同時使用了 v1 和 v2 兩個版本(只用一個也可以):
package main
import (
"fmt"
"github.com/winlinvip/mod_major_releases"
v2 "github.com/winlinvip/mod_major_releases/v2"
)
func main() {
fmt.Println("Hello",
mod_major_releases.Version(),
v2.Version2(),
)
}
運行這個程序后,可以看到 go.mod 中導入了兩個包:
module private.me/app
go 1.13
require (
github.com/winlinvip/mod_major_releases v1.0.1
github.com/winlinvip/mod_major_releases/v2 v2.0.3
)
Remark: 如果需要更新 v2 的指定版本,那么路徑中也必須帶 v2,也就是所有 v2 的路徑必須帶 v2,比如
go get github.com/winlinvip/mod_major_releases/v2@v2.0.3
。
而庫提供大版本也是一樣的,參考 mod_major_releases/v2,主要做的事情:
git checkout -b v2
,比如 https://github.com/winlinvip/mod_major_releases/tree/v2;module github.com/winlinvip/mod_major_releases/v2
;git tag v2.0.0
,分支和 tag 都要提交到 git。其中 go.mod 更新如下:
module github.com/winlinvip/mod_major_releases/v2
go 1.13
代碼更新如下,由于是大版本,所以就變更了函數名稱:
package mod_major_releases
func Version2() string {
return "mmv/2.0.3"
}
Note: 更多信息可以參考 Modules: v2,還有 Russ Cox: From Repository to Modules 介紹了兩種方式,常見的就是上面的分支方式的例子,還有一種文件夾方式。
Go Modules 特別需要注意的問題:
對于公開的 package,如果 go.mod 中描述的 package,和公開的路徑不相同,比如 go.mod 是 private.me/app
,而發布到 github.com/winlinvip/app
,當然其他項目 import 這個包時會出現錯誤。對于庫,也就是希望別人依賴的包,go.mod 描述的和發布的路徑,以及 package 名字都應該保持一致;
如果一個包沒有發布任何版本,則會取最新的 commit 和日期,格式為 v0.0.0-日期-commit 號,比如 v0.0.0-20191028070444-45532e158b41
,參考 Pseudo Versions。版本號可以從 v0.0.x
開始,比如 v0.0.1
或者 v0.0.3
或者 v0.1.0
或者 v1.0.1
之類,沒有強制要求必須要是 1.0 開始的發布版本;
mod replace 在子 module 無效,只在編譯的那個 top level 有效,也就是在最終生成 binary 的 go.mod 中定義才有效,官方的說明是為了讓最終生成時控制依賴。例如想要把 github.com/pkg/errors
重寫為 github.com/winlinvip/errors
這個包,正確做法參考分支 replace_errors;若不在主模塊 (top level) 中 replace 參考 replace_in_submodule,只在子模塊中定義了 replace 但會被忽略;如果在主模塊 replace 會生效 replace_errors,而且在主模塊依賴掉子模快依賴的模塊也生效 replace_deps_of_submodule。不過在子模快中也能 replace,這個預感到會是個混淆的地方。有一個例子就是 fork 倉庫后修改后自己使用,這時候 go.mod 的 package 當然也變了,參考 Migrating Go1.13 Errors,Go1.13 的 errors 支持了 Unwrap 接口,這樣可以拿到 root error,而 pkg/errors 使用的則是 Cause(err) 函數來獲取 root error,而提的 PR 沒有支持,pkg/errors 不打算支持 Go1.13 的方式,作者建議 fork 來解決,所以就可以使用 go mod replace 來將 fork 的 url 替換 pkg/errors;
go get
并非將每個庫都更新后取最新的版本,比如庫 github.com/winlinvip/mod_minor_versions
有 v1.0.1、v1.1.2 兩個版本,目前依賴的是 v1.1.2 版本,如果庫更新到了 v1.2.3 版本,立刻使用 go get -u
并不會更新到 v1.2.3,執行 go get -u github.com/winlinvip/mod_minor_versions
也一樣不會更新,除非顯式更新 go get github.com/winlinvip/mod_minor_versions@v1.2.3
才會使用這個版本,需要等一定時間后才會更新;
對于大版本比如 v2,必須用 go.mod 描述,直接引用也可以比如 go get github.com/winlinvip/mod_major_error@v2.0.0
,會提示 v2.0.0+incompatible
,意思就是默認都是 v0 和 v1,而直接打了 v2.0.0 的 tag,雖然版本上匹配到了,但實際上是把 v2 當做 v1 在用,有可能會有不兼容的問題。或者說,一般來說 v2.0.0 的這個 tag,一定會有接口的變更(否則就不能叫 v2 了),如果沒有用 go.mod 會把這個認為是 v1,自然可能會有兼容問題了;
go get github.com/winlinvip/mod_major_releases/v2@v2.0.1
,如果路徑中沒有這個 v2 則會報錯無法更新,比如 go get github.com/winlinvip/mod_major_releases@v2.0.1
,錯誤消息是 invalid version: module contains a go.mod file, so major version must be compatible: should be v0 or v1
,這個就是說 mod_major_releases 這個下面有 go.mod 描述的版本是 v0 或 v1,但后面指定的版本是 @v2 所以不匹配無法更新;
和上面的問題一樣,如果在 go.mod 中,大版本路徑中沒有帶版本,比如 require github.com/winlinvip/mod_major_releases v2.0.3
,一樣會報錯 module contains a go.mod file, so major version must be compatible: should be v0 or v1
,這個有點含糊因為包定義的 go.mod 是 v2 的,這個錯誤的意思是,require 的那個地方,要求的是 v0 或 v1,而實際上版本是 v2.0.3,這個和手動要求更新 go get github.com/winlinvip/mod_major_releases@v2.0.1
是一回事;
注意三大崗位有 cache,比如 mod_major_error@v5.0.0 的 go.mod 描述有錯誤,應該是 v5,而不是 v3。如果在打完 tag 后,獲取了這個版本 go get github.com/winlinvip/mod_major_error/v5
,會提示錯誤 but does not contain package github.com/winlinvip/mod_major_error/v5
等錯誤,如果刪除這個 tag 后再推 v5.0.0,還是一樣的錯誤,因為 index 和 goproxy 有緩存這個版本的信息。解決版本就是升一個版本 v5.0.1,直接獲取這個版本就可以,比如 go get github.com/winlinvip/mod_major_error/v5@v5.0.1
,這樣才沒有問題。詳細參考 Semantic versions and modules;
github.com/winlinvip/mod_major_error
沒有打版本 v3.0.1,就請求 go get github.com/winlinvip/mod_major_error/v3@v3.0.1
,會提示沒有這個版本。如果后面再打這個 tag,就算有這個 tag 后,也會提示 401 找不到 reading https://sum.golang.org/lookup/github.com/winlinvip/mod_major_error/v3@v3.0.1: 410 Gone
。只能再升級個版本,打個新的 tag 比如 v3.0.2 才能獲取到。總結來說:
GOPATH,自從默認為 $HOME/go
后,很好用,依賴的包都緩存在這個公共的地方,只要項目不大,完全是很直接很好用的方案。一般情況下也夠用了,估計 GOPATH 可能會被長期使用,畢竟習慣才是最可怕的,習慣是活的最久的,習慣就成為了一種生活方式,用余老師的話說“文化是一種精神價值和生活方式,最終體現了集體人格”;
vendor,vendor 緩存依賴在項目本地,能解決很多問題了,比 GOPATH 更好的是對于依賴可以定期更新,一般的項目中,對于依賴都是有需要了去更新,而不是每次編譯都去取最新的代碼。所以 vendor 還是非常實用的,如果能保持比較克制,不要因為要用一個函數就要依賴一個包,結果這個包依賴了十個,這十個又依賴了百個;
現有 GOPATH 和 vendor 的項目,如何遷移到 modules 呢?官方的遷移指南 Migrating to Go Modules,說明了項目會有三種狀態:
https://go.googlesource.com/lint
,而包路徑是 golang.org/x/lint。Note: 特別注意如果是庫支持了 v2 及以上的版本,那么路徑中一定需要包含 v2,比如
github.com/ru***oss/blackfriday/v2
。而且需要更新引用了這個包的 v2 的庫,比較蛋疼,不過這種情況還好是不多的。
咱們先看一個使用 GOPATH 的例子,我們新建一個測試包,先以 GOPATH 方式提供,參考 github.com/winlinvip/mod_gopath,依賴于 github.com/pkg/errors,rsc.io/quote 和 github.com/gorilla/websocket。
再看一個 vendor 的例子,將這個 GOPATH 的項目,轉成 vendor 項目,參考 github.com/winlinvip/mod_vendor,安裝完 dep 后執行 dep init
就可以了,可以查看依賴:
chengli.ycl$ dep status
PROJECT CONSTRAINT VERSION REVISION LATEST PKGS USED
github.com/gorilla/websocket ^1.4.1 v1.4.1 c3e18be v1.4.1 1
github.com/pkg/errors ^0.8.1 v0.8.1 ba968bf v0.8.1 1
golang.org/x/text v0.3.2 v0.3.2 342b2e1 v0.3.2 6
rsc.io/quote ^3.1.0 v3.1.0 0406d72 v3.1.0 1
rsc.io/sampler v1.99.99 v1.99.99 732a3c4 v1.99.99 1
接下來轉成 modules 包,先拷貝一份 github.com/winlinvip/mod_gopath 代碼(這里為了演示差別所以拷貝了一份,直接轉換也是可以的),變成 github.com/winlinvip/mod_gopath_vgo,然后執行命令 go mod init github.com/winlinvip/mod_gopath_vgo && go test ./... && go mod tidy
,接著發布版本比如 git add . && git commit -am "Migrate to vgo" && git tag v1.0.1 && git push origin v1.0.1
:
Mac:mod_gopath_vgo chengli.ycl$ cat go.mod
module github.com/winlinvip/mod_gopath_vgo
go 1.13
require (
github.com/gorilla/websocket v1.4.1
github.com/pkg/errors v0.8.1
rsc.io/quote v1.5.2
)
depd 的 vendor 的項目也是一樣的,先拷貝一份 github.com/winlinvip/mod_vendor 成 github.com/winlinvip/mod_vendor_vgo,執行命令 go mod init github.com/winlinvip/mod_vendor_vgo && go test ./... && go mod tidy
,接著發布版本比如 git add . && git commit -am "Migrate to vgo" && git tag v1.0.3 && git push origin v1.0.3
:
module github.com/winlinvip/mod_vendor_vgo
go 1.13
require (
github.com/gorilla/websocket v1.4.1
github.com/pkg/errors v0.8.1
golang.org/x/text v0.3.2 // indirect
rsc.io/quote v1.5.2
rsc.io/sampler v1.99.99 // indirect
)
這樣就可以在其他項目中引用它了:
package main
import (
"fmt"
"github.com/winlinvip/mod_gopath"
"github.com/winlinvip/mod_gopath/core"
"github.com/winlinvip/mod_vendor"
vcore "github.com/winlinvip/mod_vendor/core"
"github.com/winlinvip/mod_gopath_vgo"
core_vgo "github.com/winlinvip/mod_gopath_vgo/core"
"github.com/winlinvip/mod_vendor_vgo"
vcore_vgo "github.com/winlinvip/mod_vendor_vgo/core"
)
func main() {
fmt.Println("mod_gopath is", mod_gopath.Version(), core.Hello(), core.New("gopath"))
fmt.Println("mod_vendor is", mod_vendor.Version(), vcore.Hello(), vcore.New("vendor"))
fmt.Println("mod_gopath_vgo is", mod_gopath_vgo.Version(), core_vgo.Hello(), core_vgo.New("vgo(gopath)"))
fmt.Println("mod_vendor_vgo is", mod_vendor_vgo.Version(), vcore_vgo.Hello(), vcore_vgo.New("vgo(vendor)"))
}
Note: 對于私有項目,可能無法使用三大件來索引校驗,那么可以設置 GOPRIVATE 來禁用校驗,參考 Module configuration for non public modules。
Vendor 并非不能用,可以用 modules 同時用 vendor,參考 How do I use vendoring with modules? Is vendoring going away?,其實 vendor 并不會消亡,Go 社區有過詳細的討論 vgo & vendoring 決定在 modules 中支持 vendor,有人覺得,把 vendor 作為 modules 的存儲目錄挺好的啊。在 modules 中開啟 vendor 有幾個步驟:
先轉成 modules,參考前面的步驟,也可以新建一個 modules 例如 go mod init xxx
,然后把代碼寫好,就是一個標準的 module,不過文件是存在 $GOPATH/pkg
的,參考 github.com/winlinvip/mod_vgo_with_vendor@v1.0.0;
go mod vendor
,這一步做的事情,就是將 modules 中的文件都放到 vendor 中來。當然由于 go.mod 也存在,當然也知道這些文件的版本信息,也不會造成什么問題,只是新建了一個 vendor 目錄而已。在別人看起來這就是這正常的 modules,和 vendor 一點影響都沒有。參考 github.com/winlinvip/mod_vgo_with_vendor@v1.0.1;
go build -mod=vendor
,修改 mod 這個參數,默認是會忽略這個 vendor 目錄了,加上這個參數后就會從 vendor 目錄加載代碼(可以把 $GOPATH/pkg
刪掉發現也不會下載代碼)。當然其他也可以加這個 flag,比如 go test -mod=vendor ./...
或者 go run -mod=vendor .
。調用這個包時,先使用 modules 把依賴下載下來,比如 go mod init private.me/app && go run t.go
:
package main
import (
"fmt"
"github.com/winlinvip/mod_vendor_vgo"
vcore_vgo "github.com/winlinvip/mod_vendor_vgo/core"
"github.com/winlinvip/mod_vgo_with_vendor"
vvgo_core "github.com/winlinvip/mod_vgo_with_vendor/core"
)
func main() {
fmt.Println("mod_vendor_vgo is", mod_vendor_vgo.Version(), vcore_vgo.Hello(), vcore_vgo.New("vgo(vendor)"))
fmt.Println("mod_vgo_with_vendor is", mod_vgo_with_vendor.Version(), vvgo_core.Hello(), vvgo_core.New("vgo with vendor"))
}
然后一樣的也要轉成 vendor,執行命令 go mod vendor && go run -mod=vendor t.go
。如果有新的依賴的包需要導入,則需要先使用 modules 方式導入一次,然后 go mod vendor
拷貝到 vendor。其實一句話來說,modules with vendor 就是最后提交代碼時,把依賴全部放到 vendor 下面的一種方式。
Note: IDE 比如 goland 的設置里面,有個
Preferences /Go /Go Modules(vgo) /Vendoring mode
,這樣會從項目的 vendor 目錄解析,而不是從全局的 cache。如果不需要導入新的包,可以默認開啟 vendor 方式,執行命令go env -w GOFLAGS='-mod=vendor'
。
并發是服務器的基本問題,并發控制當然也是基本問題,Go 并不能避免這個問題,只是將這個問題更簡化。
早在十八年前的 1999 年,千兆網卡還是一個新玩意兒,想當年有吉比特帶寬卻只能支持 10K 客戶端,還是個值得研究的問題,畢竟 Nginx 在 2009 年才出來,在這之前大家還在內核折騰過 HTTP 服務器,服務器領域還在討論如何解決 C10K 問題,C10K 中文翻譯在這里。讀這個文章,感覺進入了繁忙服務器工廠的車間,成千上萬錯綜復雜的電纜交織在一起,甚至還有古老的驚群 (thundering herd) 問題,驚群像遠古狼人一樣就算是在 21 世紀還是偶然能聽到它的傳說。現在大家討論的都是如何支持 C10M,也就是千萬級并發的問題。
并發,無疑是服務器領域永遠無法逃避的話題,是服務器軟件工程師的基本能力。Go 的撒手锏之一無疑就是并發處理,如果要從 Go 眾多優秀的特性中挑一個,那就是并發和工程化,如果只能選一個的話,那就是并發的支持。大規模軟件,或者云計算,很大一部分都是服務器編程,服務器要處理的幾個基本問題:并發、集群、容災、兼容、運維,這些問題都可以因為 Go 的并發特性得到改善,按照《人月神話》的觀點,并發無疑是服務器領域的固有復雜度 (Essential Complexity) 之一。Go 之所以能迅速占領云計算的市場,Go 的并發機制是至關重要的。
借用《人月神話》中關于固有復雜度 (Essential Complexity) 的概念,能比較清晰的說明并發問題。就算沒有讀過這本書,也肯定聽過軟件開發“沒有銀彈”,要保持軟件的“概念完整性”,Brooks 作為硬件和軟件的雙重專家和出色的教育家始終活躍在計算機舞臺上,在計算機技術的諸多領域中都作出了巨大的貢獻,在 1964 年 (33 歲) 領導了 IBM System/360 和 IBM OS/360 的研發,于 p1993 年 (62 歲) 獲得馮諾依曼獎,并于 1999 年 (68 歲) 獲得圖靈獎,在 2010 年 (79 歲) 獲得虛擬現實 (VR) 的獎項 IEEE Virtual Reality Career Award (2010)。
在軟件領域,很少能有像《人月神話》一樣具有深遠影響力和暢銷不衰的著作。Brooks 博士為人們管理復雜項目提供了具有洞察力的見解,既有很多發人深省的觀點,又有大量軟件工程的實踐。本書內容來自 Brooks 博士在 IBM 公司 System/360 家族和 OS/360 中的項目管理經驗,該項目堪稱軟件開發項目管理的典范。該書英文原版一經面世,即引起業內人士的強烈反響,后又譯為德、法、日、俄、中、韓等多種文字,全球銷售數百萬冊。確立了其在行業內的經典地位。
Brooks 是我最崇拜的人,有理論有實踐,懂硬件懂軟件,致力于大規模軟件(當初還沒有云計算)系統,足夠(長達十年甚至二十年)的預見性,孜孜不倦奮斗不止,強烈推薦軟件工程師讀《人月神話》。
短暫的廣告回來,繼續討論并發 (Concurrency) 的問題,要理解并發的問題就必須從了解并發問題本身,以及并發處理模型開始。2012 年我在當時中國最大的 CDN 公司藍汛設計和開發流媒體服務器時,學習了以高并發聞名的 NGINX 的并發處理機制 EDSM(Event-Driven State Machine Architecture),自己也照著這套機制實現了一個流媒體服務器,和 HTTP 的 Request-Response 模型不同,流媒體的協議比如 RTMP 非常復雜中間狀態非常多,特別是在做到集群 Edge 時和上游服務器的交互會導致系統的狀態機翻倍,當時請教了公司的北美研發中心的架構師 Michael,Michael 推薦我用一個叫做 ST(StateThreads) 的技術解決這個問題,ST 實際上使用 setjmp 和 longjmp 實現了用戶態線程或者叫協程,協程和 goroutine 是類似的都是在用戶空間的輕量級線程,當時我本沒有懂為什么要用一個完全不懂的協程的東西,后來我花時間了解了 ST 后豁然開朗,原來服務器的并發處理有幾種典型的并發模型,流媒體服務器中超級復雜的狀態機,也廣泛存在于各種服務器領域中,屬于這個復雜協議服務器領域不可 Remove 的一種固有復雜度 (Essential Complexity)。
我翻譯了 ST(StateThreads) 總結的并發處理模型高性能、高并發、高擴展性和可讀性的網絡服務器架構:State Threads for Internet Applications,這篇文章也是理解 Go 并發處理的關鍵,本質上 ST 就是 C 語言的協程庫(騰訊微信也開源過一個 libco 協程庫),而 goroutine 是 Go 語言級別的實現,本質上他們解決的領域問題是一樣的,當然 goroutine 會更廣泛一些,ST 只是一個網絡庫。我們一起看看并發的本質目標,一起看圖說話吧,先從并發相關的性能和伸縮性問題說起:
橫軸是客戶端的數目,縱軸是吞吐率也就是正常提供服務需要能吐出的數據,比如 1000
個客戶端在觀看 500Kbps
碼率的視頻時,意味著每個客戶端每秒需要 500Kb 的數據,那么服務器需要每秒吐出 500*1000Kb=500Mb
的數據才能正常提供服務,如果服務器因為性能問題 CPU 跑滿了都無法達到 500Mbps 的吞吐率,客戶端必定就會開始卡頓;
圖中黑色的線是客戶端要求的最低吞吐率,假設每個客戶端都是一樣的,那么黑色的線就是一條斜率固定的直線,也就是客戶端越多吞吐率就越多,基本上和客戶端數目成正比。比如 1 個客戶端需要 500Kbps 的吞吐率, 1000 個就是 500Mbps 吞吐率;
圖中藍色的實線,是服務器實際能達到的吞吐率。在客戶端比較少時,由于 CPU 空閑,服務器(如果有需要)能夠以超過客戶端要求的最低吞吐率給數據,比如點播服務器的場景,客戶端看 500Kbps 碼率的點播視頻,每秒最少需要 500Kb 的數據,那么服務器可以以 800Kbps 的吞吐率給客戶端數據,這樣客戶端自然不會卡頓,客戶端會將數據保存在自己的緩沖區,只是如果用戶放棄播放這個視頻時會導致緩存的數據浪費;
圖中藍色實線會有個天花板,也就是服務器在給定的 CPU 資源下的最高吞吐率,比如某個版本的服務器在 4CPU 下由于性能問題只能達到 1Gbps 的吞吐率,那么黑線和藍線的交叉點,就是這個服務器能正常服務的最多客戶端比如 2000 個。理論上如果超過這個最大值比如 10K 個,服務器吞吐率還是保持在最大吞吐率比如 1Gbps,但是由于客戶端的數目持續增加需要繼續消耗系統資源,比如 10K 個 FD 和線程的切換會搶占用于網絡收發的 CPU 時間,那么就會出現藍色虛線,也就是超負載運行的服務器,吞吐率會降低,導致服務器無法正常服務已經連接的客戶端;
負載伸縮性 (Load Scalability) 就是指黑線和藍線的交叉點,系統的負載能力如何,或者說是否并發模型能否盡可能的將 CPU 用在網絡吞吐上,而不是程序切換上,比如多進程的服務器,負載伸縮性就非常差,有些空閑的客戶端也會 Fork 一個進程服務,這無疑是浪費了 CPU 資源的。同時多進程的系統伸縮性會很好,增加 CPU 資源時吞吐率基本上都是線性的;
并發的模型包括幾種,總結 Existing Architectures 如下表:
Arch | Load Scalability | System Scalability | Robust | Complexity | Example |
---|---|---|---|---|---|
Multi-Process | Poor | Good | Great | Simple | Apache1.x |
Multi-Threaded | Good | Poor | Poor | Complex | Tomcat, FMS/AMS |
Event-Driven<br />State Machine | Great | Great | Good | Very<br />Complex | Nginx, CRTMPD |
StateThreads | Great | Great | Good | Simple | SRS, Go |
MP(Multi-Process)多進程模型:每個連接 Fork 一個進程服務。系統的魯棒性非常好,連接彼此隔離互不影響,就算有進程掛掉也不會影響其他連接。負載伸縮性 (Load Scalability) 非常差 (Poor),系統在大量進程之間切換的開銷太大,無法將盡可能多的 CPU 時間使用在網絡吞吐上,比如 4CPU 的服務器啟動 1000 個繁忙的進程基本上無法正常服務。系統伸縮性 (System Scalability) 非常好,增加 CPU 時一般系統吞吐率是線性增長的。目前比較少見純粹的多進程服務器了,特別是一個連接一個進程這種。雖然性能很低,但是系統復雜度低 (Simple),進程很獨立,不需要處理鎖或者狀態;
MT(Multi-Threaded) 多線程模型:有的是每個連接一個線程,改進型的是按照職責分連接,比如讀寫分離的線程,幾個線程讀,幾個線程寫。系統的魯棒性不好 (Poor),一個連接或線程出現問題,影響其他的線程,彼此互相影響。負載伸縮性 (Load Scalability) 比較好 (Good),線程比進程輕量一些,多個用戶線程對應一個內核線程,但出現被阻塞時性能會顯著降低,變成和多進程一樣的情況。系統伸縮性 (System Scalability) 比較差 (Poor),主要是因為線程同步,就算用戶空間避免鎖,在內核層一樣也避免不了;增加 CPU 時,一般在多線程上會有損耗,并不能獲得多進程那種幾乎線性的吞吐率增加。多線程的復雜度 (Complex) 也比較高,主要是并發和鎖引入的問題;
我將 Go 也放在了 ST 這種模型中,雖然它是
多線程+協程
,和 SRS 不同是多進程+協程
(SRS 本身是單進程+協程
可以擴展為多進程+協程
)。
從并發模型看 Go 的 goroutine,Go 有 ST 的優勢,沒有 ST 的劣勢,這就是 Go 的并發模型厲害的地方了。當然 Go 的多線程是有一定開銷的,并沒有純粹多進程單線程那么高的負載伸縮性,在活躍的連接過多時,可能會激活多個物理線程,導致性能降低。也就是 Go 的性能會比 ST 或 EDSM 要差,而這些性能用來交換了系統的維護性,個人認為很值得。除了 goroutine,另外非常關鍵的就是 chan。Go 的并發實際上并非只有 goroutine,而是 goroutine+chan,chan 用來在多個 goroutine 之間同步。實際上在這兩個機制上,還有標準庫中的 context,這三板斧是 Go 的并發的撒手锏。
goroutine: Go 對于協程的語言級別原生支持,一個 go 就可以啟動一個協程,ST 是通過函數來實現;
chan 和 select: goroutine 之間通信的機制,ST 如果要實現兩個協程的消息傳遞和等待,只能自己實現 queue 和 cond。如果要同步多個呢?比如一個協程要處理多種消息,包括用戶取消,超時,其他線程的事件,Go 提供了 select 關鍵字。參考 Share Memory By Communicating
;
Go Concurrency Patterns: Timing out, moving on
和 Go Concurrency Patterns: Context
。由于 Go 是多線程的,關于多線程或協程同步,除了 chan 也提供了 Mutex,其實這兩個都是可以用的,而且有時候比較適合用 chan 而不是用 Mutex,有時候適合用 Mutex 不適合用 chan,參考 Mutex or Channel
。
Channel | Mutex |
---|---|
passing ownership of data,<br />distributing units of work,<br /> communicating async results | caches,<br />state |
特別提醒:不要懼怕使用 Mutex,不要什么都用 chan,千里馬可以一日千里卻不能抓老鼠,HelloKitty 跑不了多快抓老鼠卻比千里馬強。
實際上 goroutine 的管理,在真正高可用的程序中是非常必要的,我們一般會需要支持幾種gorotine的控制方式:
錯誤處理:比如底層函數發生錯誤后,我們是忽略并告警(比如只是某個連接受到影響),還是選擇中斷整個服務(比如 LICENSE 到期);
用戶取消:比如升級時,我們需要主動的遷移新的請求到新的服務,或者取消一些長時間運行的 goroutine,這就叫熱升級;
超時關閉:比如請求的最大請求時長是 30 秒,那么超過這個時間,我們就應該取消請求。一般客戶端的服務響應是有時間限制的;
而 goroutine 的管理,最開始只有 chan 和 sync,需要自己手動實現 goroutine 的生命周期管理,參考 Go Concurrency Patterns: Timing out, moving on
和 Go Concurrency Patterns: Context
,這些都是 goroutine 的并發范式。
直接使用原始的組件管理 goroutine 太繁瑣了,后來在一些大型項目中出現了 context 這些庫,并且 Go1.7 之后變成了標準庫的一部分。具體參考 GOLANG 使用 Context 管理關聯 goroutine 以及 GOLANG 使用 Context 實現傳值、超時和取消。
Context 也有問題:
支持 Cancel、Timeout 和 Value,這些都是擴張 Context 樹的節點。Cancel 和 Timeout 在子樹取消時會刪除子樹,不會一直膨脹;Value 沒有提供刪除的函數,如果他們有公共的根節點,會導致這個 Context 樹越來越龐大;所以 Value 類型的 Context 應該掛在 Cancel 的 Context 樹下面,這樣在取消時 GC 會回收;
會導致接口不一致或者奇怪,比如 io.Reader 其實第一個參數應該是 context,比如 Read(Context, []byte)
函數。或者提供兩套接口,一種帶 Contex,一種不帶 Context。這個問題還蠻困擾人的,一般在應用程序中,推薦第一個參數是 Context;
備注:關于對 Context 的批評,可以參考 Context should go away for Go 2,作者覺得在標準庫中加 context 作為第一個參數不能理解,比如
Read(ctx context.Context
等。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。