閒聊
Go 語言寫起來相當愉快,除了冗長的if err != nil
檢查之外。其樂趣的根本原因之一是goroutine,這是 Go 語言的核心特性。深入了解 goroutines 是值得的,因為它們極大地提升了使用 Go 語言的樂趣。因此,讓我們來談談 goroutines,希望能為讀者提供一些見解。
TL;DR:我們將從組裝電腦開始,然後深入探討作業系統的一些概念,例如程序和線程,然後再探討 goroutines 本身。
組裝電腦
讓我們專注於桌上型電腦,而不是筆記型電腦。想想桌上型電腦的常見組件:顯示器、鍵盤、滑鼠和中央處理單元。現在,讓我們仔細看看中央處理單元,它通常包含幾個必要的組件:主機板、CPU、顯示卡(發燒友通常選擇獨立顯示卡,而預算有限的用戶則經常依靠 CPU 中的整合顯示卡 :( )、硬碟和記憶體模組。
讓我們注意 CPU 和記憶體模組。從 CPU 的示意圖開始:
CPU 如何運作?CPU 就像一個傻瓜;它有一組固定的指令集。我們從集合中選擇一些指令,將它們排列起來,然後將它們提供給 CPU。它會乖乖地執行它們,當被告知向東走時就向東走,當被告知向西走時就向西走。CPU 主要由兩個組件組成:算術邏輯單元 (ALU) 和控制單元。這兩個組件包含各種專用的暫存器。
讓我們考慮運算1 + 1 = 2
:
- 從記憶體中取出 1 並將其儲存到暫存器 A 中。
- 從記憶體中取出另一個 1 並將其儲存到暫存器 B 中。
- 執行加法運算並將結果寫入暫存器 A。
- 將暫存器 A 中的結果複製到儲存結果的記憶體位置。
現在,讓我們看看字串串聯,例如"hello" + "world" = "helloworld"
:
- 從記憶體中取出字串 "hello" 並計算其長度 (5)。
- 從記憶體中取出字串 "world" 並計算其長度 (5)。
- 將長度相加並配置一個大小合適的記憶體區塊(某處)。
- 將 "hello" 複製到配置的記憶體(某處)的前 5 個槽中。
- 將 "world" 複製到配置的記憶體(某處)的槽 6-10 中。
- 返回配置的記憶體(某處)的地址。
CPU 是否正是這樣執行這些運算的?就具體的低階細節而言,並非完全如此,因為它處理的是 0 和 1 的塊。但是,從抽象的角度來看,是的。所有馮·諾伊曼架構系統,包括 Intel 和 ARM 等特定 CPU 架構,以及 JVM 和 Python 的eval.c
中龐大的switch...case..
(因為虛擬機器模擬物理 CPU)等虛擬機器,都遵循類似的過程。
現在我們了解了 CPU 的運作方式,讓我們來探討作業系統的一些概念。
安裝作業系統
該安裝系統了;這令人緊張。經過幾個步驟後,Linux 啟動並運行了。但是為什麼我們需要作業系統呢?沒有它,程式設計師的生活將充滿挑戰,需要執行以下任務:
- 學習購買的 CPU 的指令集並閱讀手冊。
- 檢查硬碟的前 512 個位元組以了解分割。
- 在 1024 個位元組處找到所需的程式並將其載入記憶體。
- 意識到記憶體太小,只載入程式的一部分。
- 嘗試一個簡單的運算,例如 1+1=2,但意外地覆蓋了另一個程式。
- 重新啟動並重新開始。
幸運的是,在過去幾十年中,優秀的程式設計師開發了作業系統,使我們免於這些困難。
如前所述,我們的程式駐留在硬碟上。要執行它們,我們必須將程式從硬碟讀入記憶體,然後執行特定的指令。如果我要編寫作業系統,我會將程式的所有資訊組織到一個地方以便於管理。它看起來像這樣:
type process struct {
instructions unsafe.Pointer;
instruction_size int64;
current_offset int64; // 目前執行指令的偏移量
};
這就是一個程序。讓我們看看 Linux 中程序的記憶體佈局:
然而,後來人們發現程序似乎有點太大。因此,出現了線程的概念。以下是 Linux 中線程的記憶體佈局:
我們只解釋程序的記憶體佈局;讀者可以自行了解線程的記憶體佈局。查看程序的記憶體佈局圖,有幾個概念:
text
:這些是前面提到的指令;這是我們的程式碼,編譯後生成的指令。data
:這包括在程式碼中初始化的變數,例如int i = 1
。bss
:這是用於程式碼中未初始化的變數,例如int a
。heap
:我們稍後會討論堆。stack
:我們稍後會討論堆疊。
首先,讓我們想想為什麼需要堆疊。堆疊資料結構的特點是後進先出 (LIFO),對吧?這正是函數調用的運作方式,不是嗎?例如:
func foo() {
println("foo")
}
func main() {
foo()
}
這段程式碼的執行順序無疑是main
先執行,然後是foo
,然後是println
,println
返回,foo
返回,最後,由於main
中沒有其他程式碼,它也返回。
你看到了嗎?main 是第一個被調用的函數,但它是最後一個返回的(就目前而言,讓我們暫時忽略執行時中的其他程式碼)。在函數調用堆疊中,每個函數調用都放在一個稱為框架的資料結構中。
讓我們重建上述函數調用過程(就其在記憶體中的表示而言):
首先,執行時中的函數調用 main 函數:
| runtime | main
然後,main 函數調用 foo:
| runtime | main | foo
接下來,foo 函數調用 println:
| runtime | main | foo | println
之後,println 返回:
| runtime | main | foo
然後,foo 返回:
| runtime | main
最後,main 返回:
| runtime
如你所見,函數返回後,函數中存在的變數和其他資料就不再存在了(從技術上講,它們仍然存在,但通常,行為良好的程式不會訪問它們)。那麼,如果我們有一些不想在函數返回後丟失的東西呢?換句話說,我們希望某些資料獨立於函數調用生命週期。這就是我們的堆發揮作用的地方。
堆的存在就是為了這個目的。程式碼配置的哪種類型的記憶體最終會進入堆?如果是 C 程式碼,則由 malloc 配置的記憶體最終會進入堆。如果是 Go 語言,則在逃逸分析之後,類似於:
func open(name string) (*os.File, error) {
f := &File{}
return f
}
函數f
將在堆上配置。為什麼?如果f
在堆疊上配置,則在 open 函數返回後它將消失。呼叫者如何愉快地繼續使用它?了解了這一點,讓我們現在看看 goroutines。
Goroutine
Go 語言最初是用 C 語言編寫的,後來用 Go 語言實現了自己的引導程式。現在,讓我們想想,如果我們要自己實現 goroutines,我們該怎麼做?
等等!什麼是 goroutine?我們還沒有說明這一點。Go 中的 goroutine 是一種協程。簡單來說,協程是由使用者而不是作業系統排程的最小執行單元。線程,正如我們所知,是由作業系統排程的最小單元。協程是由程式設計師手動排程的最小執行單元。它們類似於函數調用,但關鍵區別是多個協程可以儲存它們的狀態,而在函數調用中,一旦函數退出,狀態就會丟失。
所以,我們當然不能使用作業系統配置的堆疊,對吧?但是函數必須有一個堆疊才能執行。因此,我們在堆中配置一塊記憶體並將其用作堆疊。這樣應該可以工作,對吧?這就是為什麼 goroutine 的結構看起來像這樣:
type g struct {
// 堆疊參數。
// stack 描述實際的堆疊記憶體:[stack.lo, stack.hi)。
// stackguard0 是在 Go 堆疊增長序言中比較的堆疊指標。
// 它通常是 stack.lo+StackGuard,但可以是 StackPreempt 以觸發搶佔。
// stackguard1 是在 C 堆疊增長序言中比較的堆疊指標。
// 它在 g0 和 gsignal 堆疊上是 stack.lo+StackGuard。
// 它在其他 goroutine 堆疊上是 ~0,以觸發對 morestackc 的調用(並崩潰)。
stack stack // runtime/cgo 已知的偏移量
stackguard0 uintptr // liblink 已知的偏移量
stackguard1 uintptr // liblink 已知的偏移量
_panic *_panic // 最內層的 panic - liblink 已知的偏移量
_defer *_defer // 最內層的 defer
m *m // 目前的 m;arm liblink 已知的偏移量
sched gobuf
...
}
Go 語言使用 GMP 架構實現 goroutines。
如前所述,馮·諾伊曼架構依賴於這些元素。堆疊和堆等東西對於實現狀態轉換是必要的。由於 goroutine 是程式設計師可以排程的最小單元,因此它應該有自己的堆疊和堆。堆可以在程序中的多個線程之間共享,但每個線程都有自己的堆疊,防止共享使用。因此,我們在堆中配置一塊記憶體來作為 goroutines 的堆疊。這就是 goroutines 的本質。