年薪百萬程序員告訴你為什麼說C語言不是低級語言?

在相繼出現 Meltdown 和 Spectre 漏洞之後,花一些時間研究造成漏洞的根本原因是值得的。這兩個漏洞都涉及處理器繞過某種訪問檢查直接執行指令,讓攻擊者可以通過側通道觀察執行結果。導致這些漏洞的原因讓 C 語言程序員相信他們正在使用的是一門低級的編程語言,但幾十年來,情況並非如此。

什麼是低級編程語言?

計算機科學先驅 Alan Perlis 對低級編程語言的定義:

“當一門編程語言的程序要求把注意力放在不相關的內容上時,那它就是低級的編程語言”。

或許這個定義適用於 C 語言,但它並不能準確表達人們對低級語言的認識。人們通過多種屬性來判斷一門編程語言是否是低級的。我們假設將編程語言視為一個連續的整體,一端是彙編,另一端是星際級的計算機接口。低級語言“接近金屬”,而高級語言更接近人類的思維方式。

對於“接近金屬”的語言,必須提供一個抽象機,以便輕鬆映射到目標平臺公開的抽象上。人們很容易認為 C 語言是 PDP-11 的低級語言。在 C 語言模型中,程序都是按順序執行,內存是一個扁平的空間,甚至預增量和後增量運算符都與 PDP-11 尋址模式完全一致。

PDP-11 模擬器

Spectre 和 Meltdown 漏洞的根本原因在於,處理器架構師不僅試圖構建出快速的處理器,他們還試圖構建與 PDP-11 一樣的抽象機。這樣就可以讓 C 語言程序員相信他們的語言是接近底層硬件的。

C 語言的代碼提供了一個幾乎串行的抽象機(直到 C11,如果排除非標準的廠商擴展,那麼它就是完全串行的抽象機)。眾所周知,創建新線程是一種昂貴的庫操作,因此希望保持執行單元忙於運行 C 語言代碼的處理器不得不依賴 ILP(指令級並行)。它們檢查相鄰的操作,然後並行執行獨立的操作。在這種情況下,為了讓程序員編能夠按照串行的方式編寫代碼,增加複雜性(和功耗)是不可避免的。相比之下,GPU 在沒有這種邏輯的情況下實現了非常高的性能,但代價是要求程序代碼必須是並行的。

追求高 ILP 是導致 Spectre 和 Meltdown 漏洞的直接原因。現代英特爾處理器一次最多可以執行 180 條指令(與串行 C 語言抽象機形成鮮明的對比,後者希望每個操作在下一個操作開始之前完成)。C 語言代碼的典型規則是平均每七個指令就會有一個分支。如果你希望在單個線程中保留這樣的管道,那麼就必須猜測接下來的 25 個目標分支。這再次增加了複雜性,也意味著不正確的猜測會導致已經完成的操作被丟棄,造成資源浪費。丟棄操作具有明顯的副作用,Spectre 和 Meltdown 攻擊就是利用了這些副作用。

現代高端 CPU 上的寄存器重命名引擎是晶模和功率的最大消耗者之一。更糟糕的是,在運行指令時我們無法將其關閉或對其進行功率門控。但這個單元在 GPU 上顯然是不存在的,GPU 的並行性源於多個線程,而非標量代碼的指令。如果指令不需要對依賴項進行重拍序,那麼寄存器重命名就不是必需的。

讓我們來看一下 C 語言抽象機內存模型的另一個核心部分:平面內存。為了降低延遲,現代處理器通常在寄存器和主存儲器之間使用了三級高速緩存。

顧名思義,緩存對程序員是透明的,因此對 C 語言是不可見的。使用緩存是讓代碼在現代處理器上快速運行的最重要的方法之一,但這完全被抽象機隱藏了起來,程序員必須瞭解高速緩存的實現細節(例如,兩個 64 字節的值可能會處在同一個高速緩存行中)才能寫出高效的代碼。

優化 C 語言

低級語言的一個常見屬性是運行速度快,它們應該能夠在不使用特別複雜的編譯器的情況下編譯成快速執行的代碼。足夠聰明的編譯器可以加快一門語言的運行速度,C 語言支持者在談論其他編程語言時卻常常忽略了這一點。

通過簡單編譯就能獲得快速執行的代碼,但對 C 語言來說並不是這麼一回事。儘管處理器架構師努力設計可以快速運行 C 語言代碼的芯片,但 C 語言程序員所期望的性能水平只能通過非常複雜的編譯器轉換來實現。Clang 編譯器(包括 LLVM 的相關部分)大約有 200 萬行代碼。即使只算上為了讓 C 語言代碼運行更快所需的分析和轉換,也會增加近 20 萬行代碼(不包括註釋和空行)。

例如,在 C 語言中,在處理大量數據時需要使用循環來串行地處理每個元素。要在現代 CPU 上以最佳方式運行,編譯器必須先確定循環是獨立的。這個時候可以藉助 C 語言的 restrict 關鍵字,它可以保證對一個指針的寫入不會干擾對另一個指針的讀取。但這些信息比 Fortran 要少得多,這也是 C 語言在高性能計算方面未能取代 Fortran 的重要原因。

一旦編譯器確定循環是獨立的,那麼下一步就是嘗試對結果進行矢量化,因為現代處理器的矢量代碼吞吐量是標量代碼的四到八倍。這類處理器的低級語言將具有任意長度的原生矢量類型。LLVM IR(中間表示)就是這樣,因為將大型矢量運算分成較小的矢量運算總是比構造更大的矢量運算更容易。

在這種情況下,優化器必須與 C 語言內存佈局保證作鬥爭。C 語言保證具有相同前綴的結構體可以互換使用,並且它將結構體字段的偏移量暴露給了語言。這意味著編譯器不能隨意重新排序字段或通過插入填充來改進矢量化(例如,將數組結構體轉換為一組結構體,或者反過來)。對於低級語言來說,這不一定是個問題。在低級語言中,對數據結構體佈局的細粒度控制是一個特性,但它確實會讓快速運行 C 語言代碼變得更難。

C 語言還需要在結構體的末尾進行填充。填充是 C 語言規範中特別複雜的部分,並且與語言的其他部分沒有太多交互。例如,你必須使用對類型不敏感的方式(例如 memcmp)來比較兩個結構體,因此結構體的副本必須保留填充。在一些實驗中,一些工作負載花費了大量的運行時間來複制填充。

現在讓我們來看一下 C 語言編譯器的兩個核心優化:SROA(聚合的標量替換)和循環測試外提(unswitching)。SROA 嘗試使用個體變量來替換結構體(和具有固定長度的數組)。然後,如果可以證明結果永遠不可見,那麼編譯器完全可以將其視為獨立的省略操作。在某些情況下,這對刪除填充來說具有一些副作用。

第二種優化將包含條件的循環轉換為帶有循環的條件。這改變了流程控制,而且與程序員應該知道代碼執行順序的想法相矛盾。它還可能導致 C 語言的未指定值和未定義行為的概念出現嚴重錯亂。

在 C 語言中,未初始化的變量就是未指定的值,每次讀取時可能為任意值。這點很重要,因為這樣可以進行內存頁的惰性回收:例如,在 FreeBSD 上,malloc 會告訴操作系統說當前頁未被使用,然後操作系統根據第一次頁寫入來判斷 malloc 的通知是否有效。讀取新 malloc 內存可能會讀取到舊值,然後操作系統可能會重用底層物理頁,下一次在頁中的其他位置寫入時將其替換為新的歸零頁。然後,第二次讀取相同位置將得到零值。

如果使用了流程控制的未指定值(例如,將其用在 if 判斷中),就會得到未定義的行為:任何情況都有可能發生。對於循環測試外提,最終循環會執行零次。在原始版本中,整個循環體是死代碼。在測試外提版本中,變量上有一個分支,可能是未初始化的。一些死代碼現在變成了未定義行為。

總之,要 C 語言代碼快速運行是有可能的,但可能需要花費數千人年來構建足夠智能的編譯器,而且要違反 C 語言的某些規則。編譯器開發者讓 C 語言程序員假裝他們正在編寫“接近金屬”的代碼,但如果他們希望 C 語言程序員繼續相信他們使用的是一門快速的語言,則必須生成具有不同行為的機器碼。

理解 C 語言

低級語言的一個關鍵屬性是程序員可以很容易地理解語言的抽象機如何映射到底層物理機。在 PDP-11 上肯定是這樣的,因為每個 C 語言表達式都映射到一至兩個指令。類似地,編譯器將局部變量直接降到棧槽,並將原始類型映射成 PDP-11 可以直接操作的元素。

從那時起,為了維護人們對 C 語言可以很容易映射到底層硬件並能夠提供快速執行的代碼的印象,C 語言的實現變得越來越複雜。2015 年的一份針對 C 語言程序員、編譯器開發者和標準委員會成員的調查指出了幾個有關 C 語言可理解性的問題。例如,C 語言的實現可以填充結構體(不是數組),以確保所有字段都能夠對齊目標。如果將結構體歸零,然後設置一些字段,那麼填充位是否都為零?根據調查結果,36%的人表示會是零,29%的人表示不知道。事實上,根據編譯器(和優化級別)的不同,它可能是也可能不是。

這是一個相當簡單的例子,但很大一部分程序員的理解要麼是錯的,要麼不確定。當你在使用指針時,C 語言的語義就會變得更加混亂。BCPL 模型非常簡單:值就是單詞。每個單詞就是某些數據或某些數據的地址。內存是通過地址進行索引的扁平存儲單元陣列。

相比之下,C 語言模型允許在各種目標平臺上實現,包括分段式架構(指針可能是分段 ID 和偏移量),甚至是基於垃圾回收機制的虛擬機。C 語言規範十分謹慎地限制對指針的操作,以避免此類系統出現問題。人們對 C 語言缺陷報告 260 的反應就包括了指針起源(provenance)的概念。

然而,“起源”這個詞根本沒有出現在 C11 規範中,所以完全由編譯器來決定它的含義。例如,GCC(GNU 編譯器集合)和 Clang 在對指針進行強制轉換是否保留起源這個問題上存在不同的理解。編譯器可以自由決定指向不同 malloc 結果或棧分配的兩個指針總是不相等,即使指針的按位比較操作可能顯示它們描述的是相同的地址。

這些誤解本質上不是純粹的學術問題。例如,我們已經從有符號整數溢出(C 語言的未定義行為)和一些代碼中找到了安全漏洞。對於後者,在進行空值檢查之前取消引用指針是在告訴編譯器指針不能為空,因為取消引用空指針在 C 語言中是未定義行為,因此可以假定這種情況不會發生(https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2009-1897)。

因此,我們很難說程序員可以準確理解 C 語言程序將如何映射到底層架構。

想象一個非 C 語言的處理器

針對 Spectre 和 Meltdown 漏洞的修復方案將顯著降低性能,在很大程度上抵消了過去十年在微架構方面所取得的進步。或許,現在是時候停止嘗試如何讓 C 語言代碼變得更快,而是想辦法設計出能夠在快速處理上運行的編程模型。

我們有很多設計的例子,它們並沒有專注於從傳統的 C 語言代碼中獲得靈感。例如,高度多線程芯片(Sun/Oracle 的 UltraSPARC Tx 系列)不需要那麼多的緩存來保持執行單元的滿載。研究處理器已經將這個概念擴展到非常大量的硬件調度線程。這些設計背後的關鍵思想是,通過足夠的高級並行度,我們可以將等待內存數據的線程掛起,並使用其他指令填充執行單元。這種設計的問題在於 C 語言程序往往沒有繁忙的線程。

ARM 的 SVE(標量向量擴展)為程序和硬件之間的接口提供了另一個視角。常規向量單元提供固定大小的向量操作,並讓編譯器嘗試將算法映射到可用的單元大小。相反,SVE 接口會讓程序員描述可用的並行度,並依賴硬件將其映射到可用的執行單元數。要在 C 語言中使用這個相當複雜,因為自動向量器必須在循環結構體中推斷出可用的並行度。通過函數式的映射操作為它生成代碼非常簡單:映射數組的長度就是可用的並行度。

緩存很大,但大小並不是造成複雜性的唯一因素。緩存一致性協議是造成現代 CPU 難以實現速度和正確性的重要原因。其中大多數複雜性來自於為既能共享數據又能修改數據的編程語言提供支持。相反,在 Erlang 風格的抽象機中,每個對象都是線程局部或不可變的(Erlang 甚至可以進一步簡化,讓每個線程只擁有一個可變對象)。用於這種系統的高速緩存一致性協議需要處理兩種情況:可變或共享。將一個線程遷移到另一個不同的處理器上,需要顯式地讓其緩存失效,但這不是一種常見的操作。

不可變對象可以進一步簡化緩存,並降低一些操作開銷。Sun Labs 的 Maxwell 項目指出,緩存中的對象和將在年輕代中分配的對象幾乎屬於同一類。如果對象在被逐出緩存之前就已經死亡,那麼不將它們寫回主存儲器就可以極大地降低開銷。Maxwell 項目提出了一個年輕代垃圾回收器(和分配器),它將在緩存中運行並實現內存快速回收。利用堆上的不可變對象和可變棧,垃圾回收器變成了一個非常簡單的狀態機,可在硬件中實現,並且可以更有效地使用相對較小的緩存。

純粹為速度而不是為在速度和對 C 語言的支持之間做出折衷而設計的處理器,可能會支持大量線程,具有寬矢量單元和更簡單的內存模型。在這樣的系統上運行 C 語言代碼會有問題,因此,考慮到世界上存在大量的遺留 C 語言代碼,這種設計不太可能在商業上取得成功。

在軟件開發領域存在一個迷思,就是認為並行編程很難。對於能夠教會兒童使用基於 actor 模型的語言的 Alan Kay 來說,可能會感到很吃驚,因為他的學生們可以寫出支持 200 多個線程的程序。對於 Erlang 程序員來說,他們也會感到很吃驚,因為他們通常使用數千個並行組件開發程序。更準確地說,使用帶有類似 C 語言抽象機這樣的語言進行並行編程是很困難的,並且隨著並行硬件的普及,從多核 CPU 到多核 GPU,只能說,C 語言無法很好地映射到現代硬件上。


分享到:


相關文章: