What is goroutine?

  sonic0002        2024-01-21 03:26:10       1,401        0          English  简体中文  繁体中文  ภาษาไทย  Tiếng Việt 

閒聊

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


  0 COMMENT


No comment for this article.



  RANDOM FUN

Nesting if-else in Skype source code

One piece of source code of Skype. Is this code snippet written by interns?