Performance Without the Event Loop

polar9527發表於2019-05-17

英文原文

譯文

本文基於我今年早些時候在 OSCON 所做的一場演講。為了簡明扼要,並針對我在演講後收到的一些反饋意見進行了編輯。

談到 Go 的時候,一個常見的說法是,Go 是一種在伺服器上執行良好的語言;靜態二進位制檔案、強大的併發性和高效能。

本文重點討論最後兩項,Go 語言和它的執行時是如何透明地讓程式設計師編寫高度可伸縮的網路伺服器,而不必擔心執行緒管理或 I/O 阻塞。

需要高效程式語言的一個依據

但在我開始技術討論之前,我想用兩個指標來說明 Go 語言的目標市場。

摩爾定律

oft mis 援引摩爾定律稱,每平方英寸電晶體的數量大約每 18 個月翻一番。

然而,時脈頻率卻是一個功能完全不同的特性,十年 Intel 設計的 Pentium 4 就在時脈頻率上達到了峰值,並在那之後 CPU 的時脈頻率一直在倒退。
Image credit: Herb Sutter (Dr. Dobb’s Journal, March 2005)

空間和功率限制


Sun Enterprise e450—about the size of a bar fridge, about the same power consumption. Image credit: eBay

這是 SUN 公司的 e450。當我開始我的職業生涯時,他們是這個行業的主力。

這些東西是非常大的。三個這樣的機器疊在一起,將裝滿 19 英寸的架子。它們每個功率大約 500 瓦。

在過去的十年裡,資料中心已經從空間受限轉向電力受限。在我參與的前兩次資料中心部署中,當機架僅僅裝滿 1/3 時,我們就達到了用電上限。

由於計算密度提高得如此之快,資料中心空間不再是一個問題。然而,現代伺服器在更小的體積內消耗了更多的能源,這使得給機房降溫更加困難,但同時也是至關重要的。

在巨集觀層面上受到功率上限的限制,你無法為一個機架 1200 瓦 1RU serverser 獲得足夠的功率配額,而在微觀層面上,每一個微小的矽片上消耗了數百瓦能源。

能源被消耗到哪裡去了?


CMOS Inverter. Image credit: Wikipedia

這是一個反向器,可能是最簡單的邏輯閘之一。如果輸入 A 為高,那麼輸出 Q 為低,反之亦然。

今天所有的消費電子產品都是用 CMOS 邏輯構建的。CMOS 代表互補金氧半導體。互補部分是關鍵。CPU 內部的每個邏輯元件都由一對電晶體實現,一個開關開啟,另一個開關關閉。

當電路接通或斷開時,沒有電流直接從源極流向漏極。然而,在過渡期間有一個短暫的時期,兩個電晶體都導電,造成直接短路。

功耗,和因此導致的散熱,與每秒電晶體狀態轉換的次數成正比——CPU 時脈頻率。 [1] CMOS power consumption is not only caused by the short circuit current when the circuit is switching. Additional power consumption comes from charging the output capacitance of the gate, and leakage current through the MOSFET gate increases as the size of the transistor decreases. You can read more about this from in a the lecture materials from CMU’s ECE322 course. Bill Herd has a published a series of articles on how CMOS works.

CPU 特徵尺寸的降低主要是為了降低功耗。減少電力消耗並不僅僅意味著“綠色”。其主要目標是將功耗和散熱保持在導致 CPU 損壞的水平以下。

隨著時脈頻率的下降,以及與功耗的直接衝突,效能的提高主要來自於微體系結構的調整和深奧的向量指令,它們對一般計算沒有直接的用處。總的來說,每一個微架構(5 年一個週期)的變化在每一代中最多產生 10%的改進,最近只有 4-6%。

“免費午餐結束了”

希望現在你已經很清楚,硬體並沒有變得更快。如果效能和規模對你很重要,那麼你會同意我的觀點,即至少在傳統意義上,靠堆硬體來解決這個問題的日子已經結束了。正如赫伯•薩特(Herb Sutter)所言:“免費午餐結束了。”

你需要一種高效的語言,因為低效的語言在生產上,在規模上,在資本支出的基礎上都是不合理的。

需要併發程式語言的一個依據

我的第二個論點緊跟著我的第一個論點。CPU 並沒有變快,而是變寬了。這就是電晶體的發展方向,這並不令人驚訝。


Image credit: Intel

多執行緒並行,或者如 Intel 所稱的超執行緒,允許一個核心在新增少量硬體的同時並行執行多個指令流。英特爾使用超執行緒來人為地細分處理器市場,甲骨文和富士通更積極地將超執行緒應用到他們的產品中,每個處理器核使用 8 或 16 個硬體執行緒。

自上世紀 90 年代末以來,Pentium Pro 就實現了 quad socket,現在大多數伺服器都支援 dual socket 或者 quad socket 設計,dual socket 已成為主流。電晶體數量的增加使得整個 CPU 處理單元可以與同一矽片上的同級 CPU 處理單元共存。移動部件上的雙核,桌面部件上的四核,甚至伺服器部件上的更多核現在都成為了現實。在預算允許的情況下,您可以在伺服器中購買儘可能多的核心。

為了利用這些額外的核心,您需要一種能有效開發出併發程式的程式語言。

處理器單元, 執行緒 和 goroutines

Go 有 goroutines,這是它能有效開發出併發程式的基礎。我想先退一步,來看看產生 goroutines 的歷史背景。

處理器單元

起初,計算機在批處理模型中一次執行一個任務。在 60 年代,對更多互動形式的計算的渴望導致了多處理,或分時作業系統的發展。到了 70 年代,這一想法已經在網路伺服器、ftp、telnet、rlogin 以及後來 Tim Burners-Lee 的 CERN httpd 上得到了很好的應用,這些伺服器通過劃分子程式來處理每個傳入的網路連線。

在分時系統中,作業系統通過記錄當前程式的狀態,然後恢復另一個程式的狀態,從而在活動程式之間快速切換 CPU,從而保持併發的假象。這稱為上下文切換。

上下文切換


Image credit: Immae (CC BY-SA 3.0)

上下文切換有三個主要成本。
  • 核心需要儲存該程式的所有 CPU 暫存器的內容,然後恢復另一個程式的值。因為程式切換可以在程式執行的任何位置發生,所以作業系統需要儲存所有這些暫存器的內容,因為它不知道當前正在使用哪些暫存器 [2] This is an oversimplification. In some cases the operating system can avoid saving and restoring infrequently used architectural registers by starting the the process in a mode where access to floating point or MMX/SSE registers will cause the program to fault, thereby informing the kernel that the process will now use those registers and it should from then on save and restore them.

  • 核心需要將 CPU 的虛擬地址重新整理為實體地址對映(TLB 快取) [3] Some CPUs have what is known as a tagged TLB. In the case of tagged TLB support the operating system can tell the processor to associate particular TLB cache entries with an identifier, derived from the process ID, rather than treating each cache entry as global. The upside is this avoids flushing out entries on each process switch if the process is placed back on the same CPU in short order.

  • 作業系統上下文切換的開銷,以及選擇下一個程式佔用 CPU 的排程程式函式的開銷。

由於與硬體相關,這些成本相對固定,並且依賴於上下文切換之間所做的工作量來攤銷它們的成本-快速上下文切換往往會超過上下文切換之間所做的工作量。

執行緒

這導致執行緒的被設計開發出來,執行緒在概念上與程式相同,但共享相同的記憶體空間。由於執行緒共享地址空間,所以它們的排程比程式更輕鬆,因此建立和切換更快。

執行緒仍然有一個昂貴的上下文切換成本;必須保留許多狀態。Goroutines 將執行緒的概念又向前推進了一步。

Goroutines

goroutine 不是依賴核心來管理它們之間的排程,而是通過協作的方式排程的。goroutine 之間的切換隻發生在預先設計好的時間點,當顯式呼叫 Go 執行時排程程式時。goroutine 被排程器搶佔的主要原因包括:

  • 在 Channel(Go 特有的語言特性,另一個是 goroutine)上產生阻塞的收發操作。
  • Go 語言中 go 這個關鍵字的使用,雖然不能保證新的 goroutine 會立即被排程。
  • 檔案操作和網路操作等系統呼叫。
  • 由於進入記憶體垃圾回收週期而被暫停。

換句話說,goroutine 的排程會在這些時間點發生,在不能得到更多資料,一個 goroutine 無法繼續執行時; 或者是在執行環境中,一個 goroutine 需要更多記憶體空間時。

許多 goroutine 在 Go 執行時被多路複用到一個作業系統執行緒上。這使得 goroutines 的製造成本和切換成本都很低。在一個程式中有成千上萬的 goroutine 是正常的,成百上千的 goroutine 是低於預期的。

從語言的角度來看,排程看起來像一個函式呼叫,並且具有相同的語義。編譯器知道當前正在使用暫存器並自動儲存它們。執行緒呼叫包含一個特定 goroutine 棧的排程器,這個排程器返回另外一個不同的 goroutine 棧。將此與執行緒應用程式進行比較,線上程應用程式中,可以在任何時間、任何指令搶佔執行緒。

這導致每個 Go 程式的作業系統執行緒相對較少,而 Go 的 runtime 負責將一個可執行的 goroutine 分配給一個空閒的作業系統執行緒。

棧的管理

在前一節中,我討論了 goroutine 如何減少管理(有時是數十萬個)過多併發執行執行緒時的開銷。goroutine 還有另一個方面,那就是堆疊管理。

程式地址空間

這是一個典型的程式記憶體佈局圖。我們感興趣的關鍵是堆和棧的位置。

在程式的地址空間中,堆通常位於記憶體的底部,位於程式程式碼之上,並向上增長。

堆疊位於虛擬地址空間的頂部,並向下增長。

因為堆和棧相互覆蓋將是災難性的,所以作業系統在堆疊和堆之間安排了一個不可訪問的記憶體區域。

這稱為保護頁,它有效地限制了程式的棧大小,通常按幾兆位元組的順序。

執行緒棧

執行緒共享相同的地址空間,因此對於每個執行緒,它必須有自己的棧和自己的保護頁。

由於很難預測特定執行緒的棧需求,因此必須為每個執行緒的棧保留大量記憶體。並寄希望於需求會比這低,同時作為警戒的保護頁永遠不會被觸發。

缺點是,隨著程式中執行緒數量的增加,可用地址空間的數量會減少。

管理 Goroutine 的棧

早期的程式模型允許程式設計師檢視堆和棧,一邊觀察其是否足夠大,而不必為此擔心。缺點是複雜而昂貴的子程式模型。

執行緒稍微改善了這種情況,但要求程式設計師猜測最合適的棧大小;太小,程式將中止;太大,虛擬地址空間將耗盡。

我們已經看到,Go 執行時將大量 goroutine 排程到少量執行緒上,但是這些 goroutine 的棧需求如何呢?

Goroutine 棧的增長過程

每個 goroutine 都從堆中分配的一個小尺寸的棧開始。大小隨時間而變化,但在 Go 1.5 中,每一個 goroutine 都以 2k 的分配開始棧。

Go 編譯器不使用保護頁,而是在每個函式呼叫中插入一個檢查,以測試是否有足夠的棧空間供函式執行。如果有足夠的棧空間,函式將正常執行。(在函式的彙編程式碼前面,由編譯器插入一段檢查程式碼。這個動作可以在函式定義前配置編譯器指令,禁用掉,不過要非常非常謹慎地使用)

如果空間不足,Go 程式的 runtime 將在堆上分配一個更大的棧空間,將當前棧的內容複製到新的棧空間,釋放舊的棧空間,然後重新啟動函式呼叫。

由於這種檢查,goroutine 的初始堆疊可以變得更小,這反過來又允許 Go 程式設計師將 goroutine 視為廉價的資源。如果有足夠多的部分未被使用,Goroutine 棧也會收縮。這是在垃圾回收期間處理的。

整合的 network poller

2002 年,丹·凱格爾(Dan Kegel)發表了他所謂的c10k問題。簡單地說,如何編寫伺服器軟體來處理每天至少 10000 個 TCP 會話。自從那篇論文撰寫以來,傳統觀點認為高效能伺服器需要原生執行緒(native threads),而最近的幾年,基於事件的迴圈代替了原生執行緒。

執行緒在排程成本和記憶體佔用方面有很高的開銷。事件迴圈降低了這些成本,但是這引入了回撥驅動的複雜程式設計風格。

Go 為程式設計師提供了兩全其美解決方案。

Go 對 c10k 問題給出的解決方案

在 Go 中,系統呼叫通常是阻塞操作,這包括讀取和寫入檔案描述符。Go 的 runtime 排程器通過找到一個空閒執行緒或生成另一個執行緒來處理這個問題,以便在原始執行緒阻塞時繼續為 goroutines 提供服務。實際上,這對於檔案 IO 很有效,因為少量阻塞執行緒可以快速耗盡本地 IO 頻寬。

但是對於網路套接字,按照設計,任何時候幾乎所有的 goroutine 都將被阻塞,等待網路 IO。在一個簡單的實現中,這將需要和 goroutine 一樣多的執行緒,所有執行緒都被阻塞,等待網路流量。由於 runtime 和 net 包之間的協作,整合到 Go 的 runtime 中的 network poller 可以有效地處理這個問題。

在較早版本的 Go 中,network poller 是一個 goroutine,負責使用 kqueue 或 epoll 輪詢準備就緒通知。輪詢 goroutine 將通過 channel 與等待的 goroutine 通訊。這實現了避免每個執行緒都做作業系統呼叫產生的瓶頸,而使用了通過 channel 傳送訊息這種通用喚醒機制。這意味著排程器不需要關心喚醒源,不需要把喚醒操作看的比較重要。

在 Go 的當前版本中,network poller 已經整合到 runtime 本身中。當 runtime 知道哪個 goroutine 正在等待網路套接字就緒時,它可以在資料包到達時立即將 goroutine 放回相同的 CPU 上,從而減少延遲並增加吞吐量。

Goroutines, 棧管理和被整合了的 network poller

總之,goroutines 提供了一個強大的抽象,使程式設計師不必擔心執行緒池或事件迴圈。

goroutine 的棧已經足夠大,而不需要考慮執行緒棧或執行緒池的大小。

被整合了的 network poller 允許程式設計師避免了複雜的回撥風格程式碼,同時仍然利用作業系統中可用的最有效的 IO 完成邏輯。

runtime 確保有足夠的執行緒來服務所有 goroutine 並保持 CPU 核處於活動狀態。

所有這些特性對 Go 程式設計師來說都是透明的。

原文作者相關文章:

  1. Hear me speak about Go performance at OSCON
  2. Go 1.1 performance improvements
  3. Go 1.2 performance improvements
  4. Go 1.1 performance improvements, part 2

歡迎轉載,請註明出處~ 作者個人主頁

相關文章