Go 併發模型—Goroutines

前言

Goroutines 是 Go 語言主要的併發原語。它看起來非常像線程,但是相比於線程它的創建和管理成本很低。Go 在運行時將 goroutine 有效地調度到真實的線程上,以避免浪費資源,因此您可以輕鬆地創建大量的 goroutine(例如每個請求一個 goroutine),並且您可以編寫簡單的,命令式的阻塞代碼。因此,Go 的網絡代碼往往比其它語言中的等效代碼更直接,更容易理解(這點從下文中的示例代碼可以看出)。

對我來說,goroutine 是將 Go 這門語言與其它語言區分開來的一個主要特徵。這就是爲什麼大家更喜歡用 Go 來編寫需要併發的代碼。在下面討論更多關於 goroutine 之前,我們先了解一些歷史,這樣你就能理解爲什麼你想要它們了。

基於 fork 和線程

fork_thread.jpeg

高性能服務器需要同時處理來自多個客戶端的請求。有很多方法可以設計一個服務端架構來處理這個問題。最容易想到的就是讓一個主進程在循環中調用 accept,然後調用 fork 來創建一個處理請求的子進程。這篇 Beej's Guide to Network Programming 指南中提到了這種方式。

在網絡編程中,fork 是一個很好的模式,因爲你可以專注於網絡而不是服務器架構。但是它很難按照這種模式編寫出一個高效的服務器,現在應該沒有人在實踐中使用這種方式了。

fork 同時也存在很多問題,首先第一個是成本: Linux 上的 fork 調用看起來很快,但它會將你所有的內存標記爲 copy-on-write。每次寫入 copy-on-write 頁面都會導致一個小的頁面錯誤,這是一個很難測量的小延遲,進程之間的上下文切換也很昂貴。

另一個問題是規模: 很難在大量子進程中協調共享資源(如 CPU、內存、數據庫連接等)的使用。如果流量激增,並且創建了太多進程,那麼它們將相互爭奪 CPU。但是如果限制創建的進程數量,那麼在 CPU 空閒時,大量緩慢的客戶端可能會阻塞每個人的正常使用,這時使用超時機制會有所幫助(無論服務器架構如何,超時設置都是很必要的)。

通過使用線程而不是進程,上面這些問題在一定程度上能得到緩解。創建線程比創建進程更“便宜”,因爲它共享內存和大多數其它資源。在共享地址空間中,線程之間的通信也相對容易,使用信號量和其它結構來管理共享資源,然而,線程仍然有很大的成本,如果你爲每個連接創建一個新線程,你會遇到擴展問題。與進程一樣,你此時需要限制正在運行的線程的數量,以避免嚴重的 CPU 爭用,並且需要使慢速請求超時。創建一個新線程仍然需要時間,儘管可以通過使用線程池在請求之間回收線程來緩解這一問題。

無論你是使用進程還是線程,你仍然有一個難以回答的問題: 你應該創建多少個線程?如果您允許無限數量的線程,客戶端可能會用完所有的內存和 CPU,而流量會出現小幅激增。如果你限制服務器的最大線程數,那麼一堆緩慢的客戶端就會阻塞你的服務器。雖然超時是有幫助的,但它仍然很難有效地使用你的硬件資源。

基於事件驅動

event-driven.png

那麼既然無法輕易預測出需要多少線程,當如果嘗試將請求與線程解耦時會發生什麼呢?如果我們只有一個線程專門用於應用程序邏輯(或者可能是一個小的、固定數量的線程),然後在後臺使用異步系統調用處理所有的網絡流量,會怎麼樣?這就是一種 事件驅動 的服務端架構。

事件驅動架構模式是圍繞 select 系統調用設計的。後來像 poll 這樣的機制已經取代了 select,但是 select 是廣爲人知的,它們在這裏都服務於相同的概念和目的。select 接受一個文件描述符列表(通常是套接字),並返回哪些是準備好讀寫的。如果所有文件描述符都沒有準備好,則選擇阻塞,直到至少有一個準備好

#include <sys/select.h>
#include <poll.h>

int select(int nfds, 
           fd_set *restrict readfds, 
           fd_set *restrict writefds, 
           fd_set *restrict exceptfds, 
           struct timeval *restrict timeout);

int poll(struct pollfd *fds, 
         nfds_t nfds, 
         int timeout);

爲了實現一個事件驅動的服務器,你需要跟蹤一個 socket 和網絡上被阻塞的每個請求的一些狀態。在服務器上有一個單一的主事件循環,它調用 select 來處理所有被阻塞的套接字。當 select 返回時,服務器知道哪些請求可以進行了,因此對於每個請求,它調用應用程序邏輯中的存儲狀態。當應用程序需要再次使用網絡時,它會將套接字連同新狀態一起添加回“阻塞”池中。這裏的狀態可以是應用程序恢復它正在做的事情所需的任何東西: 一個要回調的 closure,或者一個 Promise。

從技術上講,這些其實都可以用一個線程實現。這裏不能談論任何特定實現的細節,但是像 JavaScript
這樣缺乏線程的語言也很好的遵循了這個模型。Node.js 更是將自己描述爲“an event-driven JavaScript runtime, designed to build scalable network applications.”

事件驅動的服務器通常比純粹基於 fork 或線程的服務器更好地利用 CPU 和內存。你可以爲每個核心生成一個應用程序線程來並行處理請求。線程不會相互爭奪 CPU,因爲線程的數量等於內核的數量。當有請求可以進行時,線程永遠不會空閒,非常高效。效率如此之高,以至於現在大家都使用這種方式來編寫服務端代碼。

從理論上講,這聽起來不錯,但是如果你編寫這樣的應用程序代碼,就會發現這是一場噩夢。。。具體是什麼樣的噩夢,取決於你所使用的語言和框架。在 JavaScript 中,異步函數通常返回一個 Promise,你給它附加回調。在 Java gRPC 中,你要處理的是 StreamObserver。如果你不小心,你最終會得到很多深度嵌套的“箭頭代碼”函數。如果你很小心,你就把函數和類分開了,混淆了你的控制流。不管怎樣,你都是在 callback hell 裏。

下面是一個 Java gRPC 官方教程 中的一個示例:

public void routeChat() throws Exception {
  info("*** RoutChat");
  final CountDownLatch finishLatch = new CountDownLatch(1);
  StreamObserver<RouteNote> requestObserver =
      asyncStub.routeChat(new StreamObserver<RouteNote>() {
        @Override
        public void onNext(RouteNote note) {
          info("Got message \"{0}\" at {1}, {2}", note.getMessage(), note.getLocation()
              .getLatitude(), note.getLocation().getLongitude());
        }

        @Override
        public void onError(Throwable t) {
          Status status = Status.fromThrowable(t);
          logger.log(Level.WARNING, "RouteChat Failed: {0}", status);
          finishLatch.countDown();
        }

        @Override
        public void onCompleted() {
          info("Finished RouteChat");
          finishLatch.countDown();
        }
      });

  try {
    RouteNote[] requests =
        {newNote("First message", 0, 0), newNote("Second message", 0, 1),
            newNote("Third message", 1, 0), newNote("Fourth message", 1, 1)};

    for (RouteNote request : requests) {
      info("Sending message \"{0}\" at {1}, {2}", request.getMessage(), request.getLocation()
          .getLatitude(), request.getLocation().getLongitude());
      requestObserver.onNext(request);
    }
  } catch (RuntimeException e) {
    // Cancel RPC
    requestObserver.onError(e);
    throw e;
  }
  // Mark the end of requests
  requestObserver.onCompleted();

  // Receiving happens asynchronously
  finishLatch.await(1, TimeUnit.MINUTES);
}

上面代碼官方的初學者教程,它不是一個完整的例子,發送代碼是同步的,而接收代碼是異步的。在 Java 中,你可能會爲你的 HTTP 服務器、gRPC、數據庫和其它任何東西處理不同的異步類型,你需要在所有這些服務器之間使用適配器,這很快就會變得一團糟。

同時這裏如果使用鎖也很危險,你需要小心跨網絡調用持有鎖。鎖和回調也很容易犯錯誤。例如,如果一個同步方法調用一個返回 ListenableFuture 的函數,然後附加一個內聯回調,那麼這個回調也需要一個同步塊,即使它嵌套在父方法內部。

Goroutines

goroutine.jpg

終於到了我們的主角——goroutines。它是 Go 語言版本的線程。像它語言(比如:Java)中的線程一樣,每個 gooutine 都有自己的堆棧。goroutine 可以與其它 goroutine 並行執行。與線程不同,goroutine 的創建成本非常低:它不綁定到 OS 線程上,它的堆棧開始非常小(初始只有 2 K),但可以根據需要增長。當你創建一個 goroutine 時,你實際上是在分配一個 closure,並在運行時將其添加到隊列中。

在內部實現中,Go 的運行時有一組執行程序的 OS 線程(通常每個內核一個線程)。當一個線程可用並且一個 goroutine 準備運行時,運行時將這個 goroutine 調度到線程上,執行應用程序邏輯。如果一個運行例程阻塞了像 mutex 或 channel 這樣的東西時,運行時將它添加到阻塞的運行 goroutine 集合中,然後將下一個就緒的運行例程調度到同一個 OS 線程上。

這也適用於網絡:當一個線程程序在未準備好的套接字上發送或接收數據時,它將其 OS 線程交給調度器。這聽起來是不是很熟悉?Go 的調度器很像事件驅動服務器中的主循環。除了僅僅依賴於 select 和專注於文件描述符之外,調度器處理語言中可能阻塞的所有內容。

你不再需要避免阻塞調用,因爲調度程序可以有效地利用 CPU。可以自由地生成許多 goroutine(可以每個請求一個!),因爲創建它們的成本很低,而且不會爭奪 CPU,你不需要擔心線程池和執行器服務,因爲運行時實際上有一個大的線程池。

簡而言之,你可以用乾淨的命令式風格編寫簡單的阻塞應用程序代碼,就像在編寫一個基於線程的服務器一樣,但你保留了事件驅動服務器的所有效率優勢,兩全其美。這類代碼可以很好地跨框架組合。你不需要 streamobserver 和 ListenableFutures 之間的這類適配器。

下面讓我們看一下來自 Go gRPC 官方教程 的相同示例。可以發現這裏的控制流比 Java 示例中的更容易理
解,因爲發送和接收代碼都是同步的。在這兩個 goroutines 中,我們都可以在一個 for 循環中調用 stream.Recv 和stream.Send。不再需要回調、子類或執行器這些東西了。

stream, err := client.RouteChat(context.Background())
waitc := make(chan struct{})
go func() {
  for {
    in, err := stream.Recv()
    if err == io.EOF {
      // read done.
      close(waitc)
      return
    }
    if err != nil {
      log.Fatalf("Failed to receive a note : %v", err)
    }
    log.Printf("Got message %s at point(%d, %d)", in.Message, in.Location.Latitude, in.Location.Longitude)
  }
}()
for _, note := range notes {
  if err := stream.Send(note); err != nil {
    log.Fatalf("Failed to send a note: %v", err)
  }
}
stream.CloseSend()
<-waitc

虛擬線程

virtual_threads.png

如何你使用 Java 這門語言,到目前爲止,你要麼必鬚生成數量不合理的線程,要麼必須處理 Java 特有的回調地獄。令人高興的是,JEP 444 中增加了 virtual threads,這看起來很像 Go 語言中的 goroutine。

創建虛擬線程的成本很低。JVM 將它們調度到平臺線程(platform threads,內核中的真實線程)上。平臺線程的數量是固定的,一般每個內核一個平臺線程。當一個虛擬線程執行阻塞操作時,它會釋放它的平臺線程,JVM
可能會將另一個虛擬線程調度到它上面。與 gooutine 不同,虛擬線程調度是協作的: 虛擬線程在執行阻塞操作之前不會服從於調度程序。這意味着緊循環可以無限期地保持線程。目前不清楚這是實現限制還是有更深層次的問題。Go 以前也有這個問題,直到 1.14 才實現了完全搶佔式調度(可見 GopherCon 2021)。

Java 的虛擬線程現在可以預覽,預計在 JDK 21 中成爲 stable(官方消息是預計 2023 年 9 月發佈)狀態。哈哈,很期待到時候能刪除大量的 ListenableFutures。每當引入一種新的語言或運行時特性時,都會有一個漫長的遷移過渡期,個人認爲 Java 生態系統在這方面還是過於保守了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章