Go 底層探索 -七-: 協程的由來

1. 介紹

Go語言以簡單、高效地編寫高併發程序而聞名,這離不開Go語言原語中協程(Goroutine)的設計,也正對應了多核處理器的時代需求。

與傳統多線程開發不同,GO語言通過更輕量級的協程讓開發更便捷,同時避免了許多傳統多線程開發需要面對的困難。因此,Go語言在設計和使用方式上都與傳統的語言有所不同,必須理解其基本的設計哲學和使用方式才能正確地使用。

2. 系統進化史

爲了瞭解Go語言協程的設計,我們從系統歷史設計出發,來看看最終Goroutine怎麼一步一步到現在的設計;

2.1 單進程時代

早期的操作系統每個程序就是一個進程,操作系統在一段時間只能運行一個進程,直到這個進程運行完,才能運行下一個進程,這個時期可以成爲單進程時代——串行時代,如下圖:

單進程時代的問題:

2.2 多進程時代

從單進程時代,可以看出CPU沒有被充分利用,直到後來系統升級爲多進程;當一個進程阻塞時,CPU就會切換到另外一個進程並執行,這樣就能儘量把CPU利用起來;這樣系統就具有了最早期的併發能力。

在多進程時代,有了時間片的概念,進程按照調度算法, 分時間片在CPU上執行,由於 CPU 執行速度很快, 1 秒中可能切換進程好幾千次, 這樣看上去就像多個進程在同時運行一樣。

多進程時代的問題:

多進程帶來的優點顯而易見,即便是單核系統,也可以併發執行多個進程,即使某個進程IO阻塞時,也能保證CPU的利用率。除了優點之外,還有以下不可忽視的缺點:

@注: 進程是資源分配的最小單位。

2.3 多線程時代

多進程時代雖然可以提高CPU使用率,但CPU大部分時間都被用於進程調度,除此之外,多進程之間還存在:資源不能進行共享、進程切換消耗大、創建進程開銷大等原因;

基於多進程的一些缺點,後來就誕生出:多線程(輕量級的進程) 。

2.3.1 線程和進程的關係

@注: 線程是 CPU 調度的最小單位

2.3.2 多線程運行

線程默認棧大小:

  1. 在 32 位Windows系統上,每個線程默認的棧大小爲1MB;在 64 位Windows系統上,每個線程默認的棧大小爲2MB

  2. Linux系統上,每個線程默認的棧大小可以通過ulimit命令或pthread庫的相關函數進行配置; 通常情況下,默認的棧大小爲8MB

多線程時代的問題:

2.4 協程時代

由於線程是CPU執行的最小單位,所以線程的切換, 執行的調度器依然是操作系統在調度的, 我們稱之爲 內核態,後來發現了用戶態的線程 , 也就是協程(輕量級的線程)。

@注: CPU並不能感知到用戶態線程的存在, 它只能感知到內核態的線程,所以用戶態線程往往都需要綁定到內核態線程上。

2.4.1 協程和線程的區別

協程和線程是兩種不同的併發編程概念,它們具有一些重要的區別。

  1. 調度機制:線程的調度和切換由操作系統內核完成,而協程的調度和切換是由程序員手動控制的。線程的切換需要進行系統調用,涉及上下文切換和保存線程狀態,而協程的切換隻涉及用戶態的切換,開銷更小。

  2. 併發性:線程是操作系統級別的併發單元,多個線程可以同時執行在不同的CPU核心上。而協程在任意時刻只有一個在運行,其他協程處於暫停狀態,需要等待當前協程主動釋放控制權。協程通過協作式調度實現併發,多個協程之間需要顯式地進行切換。

  3. 內存佔用:線程需要爲每個線程分配獨立的堆棧和上下文信息,而協程的堆棧可以共用,所以協程的內存佔用更小。

  4. 編程模型:線程的併發編程通常需要使用鎖、條件變量等機制進行同步和通信,而協程通過消息傳遞、共享變量等方式進行通信和同步,更加簡潔和直觀。

總的來說,線程是由操作系統內核進行調度和切換的併發模型,具有系統級別的併發能力,而協程是由程序員手動控制調度和切換的併發模型,更加輕量級且具有更高的性能。協程在編程模型上更加靈活和簡潔,但需要顯式地進行切換管理。

3. Go 協程和線程

爲了更深入地理解Go協程與線程的區別,我們從調度方式、上下文切換的速度、調度策略、棧的大小這四個方面,分析線程與協程的不同之處。

3.1 調度方式

Go語言中,協程的管理,依賴Go語言運行時自身提供的調度器。同時,Go語言中的協程從屬於某一個線程;協程與線程的對應關係爲 M:N,即多對多, 如下圖所示:

Go語言調度器可以將多個協程調度到一個線程中,一個協程也可能切換到多個線程中執行。

3.2 上下文切換速度

協程(Goroutines)是Go語言中的輕量級線程,由Go語言運行時環境(runtime)管理。與傳統的操作系統線程相比,協程的上下文切換開銷通常較小。下面是協程上下文切換和線程上下文切換的速度對比:

  1. 協程上下文切換速度:協程的上下文切換由Go語言的運行時環境自行管理,通常不需要與操作系統進行交互,因此上下文切換的開銷較小。

  2. 線程上下文切換速度:線程的上下文切換由操作系統內核負責管理。線程上下文切換需要保存和恢復線程的執行狀態,包括寄存器、棧等信息,這些操作都需要與內核進行交互,開銷相對較大。

總體而言,協程的上下文切換速度通常比線程的上下文切換速度更快。這是由於協程的調度是在用戶態完成的,而線程的調度需要涉及內核態和用戶態之間的切換。協程的輕量級特性和協作式調度機制使得上下文切換的開銷較小,這使得Go語言在高併發場景下能夠更高效地利用系統資源。

上下文切換的速度受到諸多因素的影響,這裏列出一些值得參考的量化指標:線程切換的速度大約爲 1~2 微秒,Go 語言中協程切換的速度比它快數倍,爲 0.2 微秒左右。

3.3 調度策略

3.3.1 協程調度策略

Go 語言的協程調度採用了協作式調度策略。在協作式調度中,協程主動讓出CPU資源給其他協程運行,而不是由操作系統進行強制性的搶佔。當一個協程遇到阻塞操作時,如等待I/O完成或休眠時間到達,它會主動交出控制權,讓其他就緒的協程運行。這種調度策略避免了搶佔式調度帶來的頻繁上下文切換和鎖競爭的開銷。

Go語言的協程調度器會根據一些策略在協程之間平均分配CPU時間片,以實現公平調度。此外,調度器還會根據當前系統負載和協程的阻塞情況等因素進行動態調整,以提高系統的整體性能。

3.3.2 線程調度策略

傳統的線程調度採用了搶佔式調度策略。在搶佔式調度中,操作系統內核可以在任何時間中斷正在執行的線程,並將CPU資源分配給其他就緒的線程。這種調度策略依賴於時鐘中斷或其他事件觸發的機制,以進行上下文切換和線程的調度。

線程調度器通常採用一些調度算法,如時間片輪轉、優先級調度等,以決定哪個線程能夠執行,並在需要時進行上下文切換。這種調度策略可以在多核系統中更好地利用硬件資源,並允許操作系統對線程進行強制性的搶佔。

3.4 棧的大小

線程的棧大小一般是在創建時指定的,爲了避免出現棧溢出(Stack Overflow),默認的棧會相對較大(例如2MB),這意味着每創建1000個線程就需要消耗2GB的虛擬內存,大大限制了線程創建的數量(64位的虛擬內存地址空間已經讓這種限制變得不太嚴重)。

Go語言中的協程棧默認爲2KB,在實踐中,經常會看到成千上萬的協程存在。同時,線程的棧在運行時不能更改,但是Go語言中的協程棧在Go運行時的幫助下會動態檢測棧的大小,並動態地進行擴容。

4. 主協程和子協程

4.1 示例代碼

package main

import "fmt"

func main() {
 // 在循環中開啓協程打印1~10的數字
 for i := 1; i <= 10; i++ {
  n := i
  go func() {
   fmt.Println("i:", n)
  }()
 }
}

@注: 當執行上述程序,不會有任何輸出;原因是: 當主協程退出時,程序就會直接退出,這是主協程與其他協程的顯著區別。

4.2 定義和區別

4.2.1 定義

4.2.2 區別

主協程和子協程之間的區別主要體現在以下幾個方面:

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/85sEdjQUrmkOYaKG5Uthtg