你是否想知道如何應對高併發?Go語言為你提供了答案!

努力的小雨發表於2023-12-29

image

併發程式設計是當前軟體領域中不可忽視的一個關鍵概念。隨著CPU等硬體的不斷髮展,我們都渴望讓我們的程式執行速度更快、更快。而Go語言在語言層面天生支援併發,充分利用現代CPU的多核優勢,這也是Go語言能夠廣泛流行的一個重要原因。

在Java中,要支援高併發有幾種方案可供選擇。首先,我們可以透過開啟多部署節點叢集來增加高併發處理能力,透過增加機器硬體來實現。其次,我們可以在單節點上開啟多執行緒來處理請求。然而,即使在單節點內建立執行緒也是非常耗費資源的。因此,通常情況下我們會使用執行緒池來管理執行緒的建立和銷燬。然而,有一個公式你可能會很熟悉,即核心執行緒數等於CPU核數的一半加一。這意味著我們並不是執行緒建立得越多,對於我們的Java程式就越好。

在我們明確了問題的痛點之後,我們可以進一步探究一下Go語言是如何解決這些問題,並且將高併發作為Go語言的一項特色功能。

goroutine

我們在Java中開啟執行緒的方式是直接建立一個Thread物件。然而,在Go語言中,如果我們想要實現非同步處理,我們可以使用"go"關鍵字來開啟一個goroutine協程。協程的最大優勢在於其輕量級,可以輕鬆建立上百萬個協程而不會導致系統資源的耗盡,而執行緒和程式通常最多也不能超過1萬個。舉個例子:

go f()  // 建立一個新的 goroutine 執行函式f

在Go語言中,我們可以非常簡單地使用關鍵字"go"來開啟一個協程,從而實現非同步處理函式f。只需在函式f的呼叫前面加上"go"關鍵字,就能使得該函式在一個獨立的協程中非同步執行。

不僅可以使用"go"關鍵字來開啟一個協程非同步執行具名函式,還可以使用"go"關鍵字來開啟一個協程非同步執行匿名函式。

go func(){
  // ...
}()

今天我們的重點不在這裡,而是要討論為什麼Go語言適合處理高併發的情況。我們都知道,作業系統的CPU最小排程單位是執行緒,然而Go語言卻使用了協程的概念。那麼問題來了,Go語言是如何將這些協程交給CPU來處理的呢?如果無法將它們交給CPU處理,那麼就算再建立多少協程也無法執行程式碼。在這裡,我們就需要了解一下Go語言的排程器,也就是GPM排程模型。

GPM排程模型

可以借鑑一下以下圖例,總的來說,我們可以像執行緒池一樣,無論建立了多少協程,都需要將它們放入佇列中。然後,剩下的任務就交給排程器來處理。

image

其中:

  • G:使用關鍵字"go"加上一個函式呼叫可以建立一個goroutine(簡稱G)。每次呼叫"go f()"都會建立一個新的G,其中包含要執行的函式f以及相關的上下文資訊。
  • 全域性佇列(Global Queue)是用來存放等待執行的 G(Goroutine)的地方。
  • P 是指 goroutine 執行所需的物理資源,每個 P 最多可以承載 GOMAXPROCS 個 goroutine 的執行。
  • P 的本地佇列是類似於全域性佇列的,它存放了等待執行的G,並且數量限制在256個以內。每當新建一個G時,優先將其加入到P的本地佇列中,如果本地佇列已滿,則會批次移動部分G到全域性佇列中。
  • 為了使執行緒能夠執行任務,需要透過獲取排程器(P)來獲取任務(G)。執行緒首先嚐試從排程器的本地佇列獲取任務,如果本地佇列為空,則執行緒會嘗試從全域性佇列或其他排程器的本地佇列獲取任務。一旦執行緒獲取到任務,就會執行任務,並在任務執行完畢後再次從排程器獲取下一個任務,持續重複這個過程。

Goroutine 排程器和作業系統排程器透過 M 結合起來,形成了排程的基本單位。在這個結合中,每個 M 代表一個核心執行緒,而作業系統排程器則負責將這些核心執行緒分配到 CPU 的核心上進行執行。

channel

單純地將函式併發執行是沒有意義的,因為函式與函式之間需要進行資料交換,才能真正體現併發執行函式的意義。

雖然可以利用共享記憶體進行資料交換,但是在不同的 goroutine 中使用共享記憶體容易導致競態問題的出現。為了確保資料交換的正確性,許多併發模型都需要透過使用互斥量對記憶體進行加鎖來解決這個問題。然而,這種做法往往會帶來效能問題,因為加鎖操作會引入額外的開銷。

Go語言採用的併發模型是CSP(Communicating Sequential Processes),這個模型強調了透過通訊共享記憶體的方式來實現併發,而不是透過共享記憶體來實現通訊。這種設計理念使得Go語言在處理併發任務時更加高效和安全。

如果說 goroutine 是Go程式中實現併發執行的主體,那麼channel就是連線這些goroutine之間的紐帶。channel是一種能夠使得一個goroutine向另一個goroutine傳送特定值的通訊機制。

Mutex(互斥鎖)在實現上也是使用了重量級鎖。與Java的互斥鎖相比,Go語言的Mutex有以下幾點區別:

記憶體開銷:Go語言的Mutex相對較輕量,使用較少的記憶體。這是因為Go語言的Mutex只包含一個欄位,用於表示鎖的狀態,而Java的互斥鎖通常包含更多的欄位和資料結構。

鎖的語法:在Go語言中,可以使用mutex.Lock()和mutex.Unlock()方法來手動控制鎖的獲取和釋放,這樣可以更靈活地控制鎖的粒度。而在Java中,使用synchronized關鍵字來實現互斥鎖,鎖的粒度相對固定,只能對整個方法或程式碼塊進行加鎖。

鎖的效能:由於Go語言的Mutex較為輕量,並且採用了更高效的實現方式,比如以下幾個方面:

  • 自旋鎖:在低併發的情況下,Go語言的Mutex會採用自旋鎖的方式。自旋鎖是一種忙等待的鎖,當一個Goroutine嘗試獲取鎖時,如果鎖已經被其他Goroutine持有,則該Goroutine會一直迴圈檢查鎖的狀態,直到成功獲取鎖。這種方式避免了執行緒切換的開銷,提高了效能。
  • 最佳化的排程策略:Go語言的排程器在處理Goroutine的排程時會進行最佳化,儘量將鎖的持有者與等待者排程到同一個處理器(P)上執行,減少執行緒之間的上下文切換和鎖競爭的開銷。
  • 等待佇列:當一個Goroutine無法獲取到Mutex鎖時,它會進入等待佇列,等待鎖的釋放。Go語言的Mutex的等待佇列是基於連結串列實現的,相比Java的互斥鎖使用的等待佇列,具有更低的記憶體開銷和更高的效率。

總結

併發程式設計是當前軟體領域中一個重要的概念。Go語言透過goroutine和channel的特性,天生支援高併發處理,充分利用現代CPU的多核優勢。與Java相比,Go語言的協程更加輕量級,可以輕鬆建立上百萬個協程。Go語言的排程器採用GPM排程模型,透過將協程放入佇列中,由排程器分配給CPU處理。此外,Go語言採用CSP模型,透過channel實現協程之間的通訊,避免了共享記憶體帶來的競態問題。相比之下,Go語言的Mutex鎖更輕量、靈活,並且具有更高的效能。總的來說,Go語言適合處理高併發的情況,成為了當前軟體開發領域的熱門語言之一。

相關文章