What is goroutine?

English 简体中文 繁体中文 ภาษาไทย Tiếng Việt
Summary

Goroutines are Go's lightweight, user-scheduled execution units, functioning as coroutines that can save and restore their state, unlike standard function calls. While operating systems manage processes and threads, each with their own stack and heap considerations, goroutines are managed by the Go runtime. To enable this user-level scheduling and state preservation, each goroutine allocates its own stack directly from the heap, rather than relying on an OS-managed stack. This design allows for highly efficient and concurrent program execution within the Go ecosystem.

閒聊

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. 從記憶體中取出 1 並將其儲存到暫存器 A 中。
  2. 從記憶體中取出另一個 1 並將其儲存到暫存器 B 中。
  3. 執行加法運算並將結果寫入暫存器 A。
  4. 將暫存器 A 中的結果複製到儲存結果的記憶體位置。

現在,讓我們看看字串串聯,例如"hello" + "world" = "helloworld"

  1. 從記憶體中取出字串 "hello" 並計算其長度 (5)。
  2. 從記憶體中取出字串 "world" 並計算其長度 (5)。
  3. 將長度相加並配置一個大小合適的記憶體區塊(某處)。
  4. 將 "hello" 複製到配置的記憶體(某處)的前 5 個槽中。
  5. 將 "world" 複製到配置的記憶體(某處)的槽 6-10 中。
  6. 返回配置的記憶體(某處)的地址。

CPU 是否正是這樣執行這些運算的?就具體的低階細節而言,並非完全如此,因為它處理的是 0 和 1 的塊。但是,從抽象的角度來看,是的。所有馮·諾伊曼架構系統,包括 Intel 和 ARM 等特定 CPU 架構,以及 JVM 和 Python 的eval.c中龐大的switch...case..(因為虛擬機器模擬物理 CPU)等虛擬機器,都遵循類似的過程。

現在我們了解了 CPU 的運作方式,讓我們來探討作業系統的一些概念。

安裝作業系統

該安裝系統了;這令人緊張。經過幾個步驟後,Linux 啟動並運行了。但是為什麼我們需要作業系統呢?沒有它,程式設計師的生活將充滿挑戰,需要執行以下任務:

  1. 學習購買的 CPU 的指令集並閱讀手冊。
  2. 檢查硬碟的前 512 個位元組以了解分割。
  3. 在 1024 個位元組處找到所需的程式並將其載入記憶體。
  4. 意識到記憶體太小,只載入程式的一部分。
  5. 嘗試一個簡單的運算,例如 1+1=2,但意外地覆蓋了另一個程式。
  6. 重新啟動並重新開始。

幸運的是,在過去幾十年中,優秀的程式設計師開發了作業系統,使我們免於這些困難。

如前所述,我們的程式駐留在硬碟上。要執行它們,我們必須將程式從硬碟讀入記憶體,然後執行特定的指令。如果我要編寫作業系統,我會將程式的所有資訊組織到一個地方以便於管理。它看起來像這樣:

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,然後是printlnprintln返回,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 的本質。

參考資料

EXPLANATION GOLANG GOROUTINE

  RELATED

  COMMENTS

0

No comment for this article.