C++ 效能優化篇二《影響優化的計算機行為》

「 虛幻私塾」發表於2020-11-26

撒謊,即講述美麗而不真實的故事,乃是藝術的真正目的。

​ ——奧斯卡 • 王爾德,“謊言的衰朽”,《意圖集》,1891 年

本篇的目的是為大家提供與優化技術相關的計算機硬體的最基本的背景知識,這樣大家就不必瘋狂地研究那 處理器手冊了。本篇我們將簡單地瞭解處理器的體系結構,從中獲得效能優化的啟發。雖然本篇中的資訊非常重要且實用,但迫不及待地想學習優化技術的讀者可以先跳過本篇,當在後面的篇節中遇到本篇中的知識時再回過頭來學習。

如今所使用的微處理器裝置的種類多樣,從只有幾千個邏輯閘且時脈頻率低於 1MHz 的價 值 1 美元的嵌入式裝置,到有數十億邏輯閘且時脈頻率達到千兆赫茲級別的桌面級裝置。 一臺包含數千個獨立執行單元的大型計算機的尺寸可以與一個大房間相當,它消耗的電力足夠點亮一座小城市中所有的電燈。這很容易讓人誤以為這些種類繁多的計算裝置之間的聯絡不具有一般性。 但事實上,它們之間是有可利用的相似點的。畢竟,如果沒有任何相 似點的話,編譯器就無法為這麼多處理器編譯 C++ 程式碼了。

所有這些被廣泛使用的計算機都會執行儲存在記憶體中的指令。指令所操作的資料也是儲存在記憶體中的。**記憶體被分為許多小的字(word),這些字由若干位(bit)組成。**其中一小部分寶貴的記憶體字是暫存器(register),它們的名字被直接定義在機器指令中。其他絕大多數記憶體字則都是以數值型的地址(address)命名的。每臺計算機中都有一個特殊的暫存器儲存著下一條待執行的指令的地址。如果將記憶體看作一本書,那麼執行地址(execution address)就相當於指向要閱讀的下一個單詞的手指。執行單元(execution unit,也被稱為處理器、核心、CPU、運算器等其他名字)從記憶體中讀取指令流,然後執行它們。指令會告訴執行單元要從記憶體中讀取(載入,取得)什麼資料,如何處理資料, 以及將什麼結果寫入(儲存、儲存)到記憶體中。計算機是由遵守物理定律的裝置組成的。

從記憶體地址讀取資料和向記憶體地址寫入資料是需要花費時間的,指令對資料進行操作也是需要花費時間的。

除了這條基本原則外,就如每個計算機專業新生都知道的,計算機體系結構的“族譜”也會不斷地擴大。因為計算機體系結構是易變的,所以很難嚴格地測量出硬體行為在數值 的規律。現代處理器做了許多不同的、互動的事情來提高指令執行速度,導致指令的執行時間實際上變得難以確定。還有一個問題是,許多開發人員甚至無法準確地知道他們的代 碼會執行在什麼處理器上,多數情況下只能用試探法。

2.1 C++所相信的計算機謊言

當然,C++ 程式至少會假裝相信上節中講解過的簡單的計算機基本模型中的一個版本。其中有可以以固定字元長度的位元組為單位定址,在本質上容量是無限的記憶體。有一個與其他任何有效的記憶體地址都不同的特殊的地址,叫作 nullptr。整數 0 會被轉換為 nullptr,儘管在地址 0 上不需要 nullptr。有一個概念上的執行地址指向正在被執行的原始碼語句。 各條語句會按照編寫順序執行,受到 C++ 控制流程語句的控制。

  • C++ 知道計算機遠比這個簡單模型要複雜。它在這臺閃閃發亮的機器下提供了一些快速功能。
  • C++ 程式只需要表現得好像語句是按照順序執行的。C++ 編譯器和計算機自身只要能夠確保每次計算的含義都不會改變,就可以改變執行順序使程式執行得更快。
  • 自 C++11 開始,C++ 不再認為只有一個執行地址。C++ 標準庫現在支援啟動和終止線 程以及同步執行緒間的記憶體訪問。在C++11之前,程式設計師對C++編譯器隱瞞了他們的執行緒, 有時候這會導致難以除錯。 • 某些記憶體地址可能是裝置暫存器,而不是普通記憶體。這些地址的值可能會在同一個 執行緒對該地址的兩次連續讀的間隔發生變化,這表示硬體發生了變化。在 C++ 中用 volatile 關鍵字定義這些地址。宣告一個 volatile 變數會要求編譯器在每次使用該變 量時都獲取它的一份新的副本,而不用通過將該變數的值儲存在一個暫存器中並複用它 來優化程式。另外,也可以宣告指向 volatile 記憶體的指標。
  • C++11 提供了一個名為 std::atomic<> 的特性,可以讓記憶體在一段短暫的時間內表現得 彷彿是位元組的簡單線性儲存一樣,這樣可以遠離所有現代處理器的複雜性,包括多執行緒 執行、多層快取記憶體等。有些開發人員誤以為這與 volatile 是一樣的,其實他們錯了。

作業系統也欺騙了程式和使用者。實際上,作業系統的目的就是為了給每個程式講一個讓它們信服的謊言。最重要的謊言之一是,作業系統希望每個程式都相信它們是獨立執行於計算機上的,而且這些計算機的記憶體是無限的,還有無限的處理器來執行程式的所有執行緒。

作業系統會使用計算機硬體來隱藏這些謊言,這樣 C++ 不得不相信它們。除了降低程式的運 行速度外,這些謊言其實對程式執行並沒有什麼影響。不過,它們會導致效能測量變得複雜。

2.2 計算機的真相

只有最簡單的微處理器和某些具有悠久歷史的大型機才直接與 C++ 模型相符。對效能優化影響優化的計算機行為而言非常重要的是,真實計算機的實際記憶體硬體的處理速度與指令的執行速率相比是很慢 的。記憶體並非真的是以位元組為單位被訪問的,記憶體並非是一個由相同元素組成的簡單的線性陣列,而且它的容量也是有限的。真實的計算機可能有不止一個指令地址。真實的計算機非常快,但並非因為它們執行指令非常快,而是因為它們同時執行許多指令,而且它們 內部的複雜電路可以確保這些同時執行的指令表現得就像一個接一個地執行一樣。

2.2.1 記憶體很慢

計算機的主記憶體相對於它內部的邏輯閘和暫存器來說非常慢。將電子從微處理器晶片中注入 相對廣闊的一塊銅製電路板上的電路,然後將其沿著電路推到幾釐米外的記憶體晶片中,這個 過程所花費的時間為電子穿越微處理器內各個獨立的微距電晶體所需時間的數千倍。主記憶體 太慢,所以桌面級處理器在從主記憶體中讀取一個資料字的時間內,可以執行數百條指令。

優化的根據在於處理器訪問記憶體的開銷遠比其他開銷大,包括執行指令的開銷。

諾伊曼瓶頸

通往主記憶體的介面是限制執行速度的瓶頸。這個瓶頸甚至有一個名字,叫馮 • 諾伊曼 瓶頸。它是以著名的計算機體系結構先鋒和數學家約翰 • 馮 • 諾伊曼(1903—1957)的 名字命名的。

例如,一臺使用主頻為 1000MHz 的 DDR2 記憶體裝置的個人計算機(幾年前典型的計 算機,容易計算其效能),其理論頻寬是每秒 20 億字,也就是每字 500 皮秒(ps)。但 這並不意味著這臺計算機每 500 皮秒就可以讀或寫一個隨機的資料字。

首先,只有順序訪問才能在一個週期內完成(相當於頻率為 1000MHz 的時鐘的半個時 標)。而訪問一個非連續的位置則會花費 6 至 10 個週期。

多個活動會爭奪對記憶體匯流排的訪問。處理器會不斷地讀取包含下一條需要執行的指令的記憶體。快取記憶體控制器會將資料記憶體塊儲存至快取記憶體中,重新整理已寫的快取行。 DRAM 控制器還會“偷用”週期重新整理記憶體中的動態 RAM 基本儲存單元的電荷。多核處理器的核心數量足以確保記憶體匯流排的通訊資料量是飽和的。資料從主記憶體讀取至某 個核心的實際速率大概是每字 20 至 80 納秒(ns)。

根據摩爾定律,每年處理器核心的數量都會增加。但是這也無法讓連線主記憶體的介面 變快。因此,未來核心數量成倍地增加,對效能的改善效果卻是遞減的。這些核心只 能等待訪問記憶體的機會。上述對效能的隱式限制被稱為記憶體牆(memory wall)。

2.2.2 記憶體訪問並非以位元組為單位

雖然 C++ 認為每個位元組都是可以獨立訪問的,但計算機會通過獲取更大塊的資料來補償緩慢的記憶體速度。最小型的處理器可以每次從主記憶體中獲取 1 位元組,桌面級處理器則可以立即獲取 64 位元組。一些超級計算機和圖形處理器還可以獲取更多。

當 C++ 獲取一個多位元組型別的資料,比如一個 int、double 或者指標時,構成資料的位元組可能跨越了兩個實體記憶體字。這種訪問被稱為非對齊的記憶體訪問(unaligned memory access)。此處優化的意義在於,一次非對齊的記憶體訪問的時間相當於這些位元組在同一個字中時的兩倍,因為需要讀取兩個字。C++ 編譯器會幫助我們對齊結構體,使每個欄位的起 始位元組地址都是該欄位的大小的倍數。但是這樣也會帶來相應的問題:結構體的“洞”中 包含了無用的資料。在定義結構體時,對各個資料欄位的大小和順序稍加註意,可以在保持對齊的前提下使結構體更加緊湊。

2.2.3 某些記憶體訪問會比其他的更慢

為了進一步補償主記憶體的緩慢速度,許多計算機中都有快取記憶體(cache memory),一種非常接近處理器的快速的、臨時的儲存,來加快對那些使用最頻繁的記憶體字的訪問速度。一些計算機沒有快取記憶體,其他一些計算機則有一層或多層快取記憶體,其中每一層都比前一層更小、更快和更昂貴。當一個執行單元要獲取的位元組已經被快取時,無需訪問主記憶體即可立即獲得這些位元組。快取記憶體的速度快多少呢?一種大致的估算經驗是,快取記憶體層次中每一層的速度大約是它下面一層的 10 倍。在桌面級處理器中,通過一級快取記憶體、二級快取記憶體、三級快取記憶體、主記憶體和磁碟上的虛擬記憶體頁訪問記憶體的時間開銷範圍可以跨越五個數量級。這就是專注於指令的時鐘週期和其他“奧祕”經常會令人惱怒而且沒有效果的一個原因,快取記憶體的狀態會讓指令的執行時間變得非常難以確定。

當執行單元需要獲取不在快取記憶體中的資料時,有一些當前處於快取記憶體中的資料必須被 捨棄以換取足夠的空餘空間。通常,選擇放棄的資料都是最近很少被使用的資料。這一點與效能優化有著緊密的關係,因為這意味著訪問那些被頻繁地訪問過的儲存位置的速度會 比訪問不那麼頻繁地被訪問的儲存位置更快。

讀取一個不在快取記憶體中的位元組甚至會導致許多臨近的位元組也都被快取起來(這也意味 著,許多當前被快取的位元組將會被捨棄)。這些臨近的位元組也就可以被高速訪問了。對於 效能優化而言,這一點非常重要,因為這意味著平均而言,訪問記憶體中相鄰位置的位元組要 比訪問互相遠隔的位元組的速度更快。

就 C++ 而言,這表示一個包含迴圈處理的程式碼塊的執行速度可能會更快。這是因為組成 迴圈處理的指令會被頻繁地執行,而且互相緊挨著,因此更容易留在快取記憶體中。一段包 含函式呼叫或是含有 if 語句導致執行發生跳轉的程式碼則會執行得較慢,因為程式碼中各個 獨立的部分不會那麼頻繁地被執行,也不是那麼緊鄰著。相比緊湊的迴圈,這樣的程式碼在 快取記憶體中會佔用更多的空間。如果程式很大,而且快取有限,那麼一些程式碼必須從高速 快取中捨棄以為其他程式碼騰出空間,當下一次需要這段程式碼時,訪問速度會變慢。類似 地,訪問包含連續地址的資料結構(如陣列或向量),要比訪問包含通過指標連結的節點 的資料結構快,因為連續地址的資料所需的儲存空間更少。訪問包含通過指標連結的記錄 的資料結構(例如連結串列或者樹)可能會較慢,這是因為需要從主記憶體讀取每個節點的資料 到新的快取行中。

2.2.4 記憶體字分為大端和小端

處理器可以一次從記憶體中讀取一位元組的資料,但是更多時候都會讀取由幾個連續的位元組組成的一個數字。例如,在微軟的 Visual C++ 中,讀取 int 值時會讀取 4 位元組。由於同一個 記憶體可以以兩種不同的方式訪問,設計計算機的人必須面對一個問題:首位元組,即最低地址位元組,是組成 int 的最高有效位還是最低有效位呢?

乍一看,這似乎沒什麼問題。當然,一臺計算機中的所有部件就“最低地址是 int 的哪一 端”這一點達成一致是非常重要的,否則就會出現混亂。而且,它們之間的區別是非常明顯的。如果 int 值 0x01234567 儲存在地址 1000~1003 中,而且首先儲存小端,那麼在地 址 1000 中儲存的是 0x01,在地址 1003 中儲存的是 0x67。反之,如果首先儲存大端,那 麼在地址 1000 中儲存的是 0x67,0x01 被儲存在地址 1003 中。從首位元組地址讀取最高有 效位的計算機被稱為大端計算機,小端計算機則會首先讀取最低有效位。因為有兩種儲存 整數值(或指標)的方式,而且找不到偏向其中一種的理由,所以工作在不同處理器上的 不同公司的不同團隊的選擇可能會不同。

問題出在當被寫至磁碟上的資料或者由一臺計算機通過網路傳輸的資料會被另外一臺計算機讀取的時候。磁碟和網路一次只傳送一位元組,而不是整個 int 值。所以,這關係到哪一 端首先被儲存或傳送。如果傳送資料的計算機與接收資料的計算機在這一點上不一致,那 麼傳送的 0x01234567 則會被接收為 0x67452301,導致 int 值發生了改變。

位元組序(endian-ness)只是 C++ 不能指定 int 中位的儲存方式或是設定聯合體中的一個字 段會如何影響其他欄位的原因之一。所編寫的程式可以工作於一類計算機上,卻在另一類 計算機上崩潰,原因也在於位元組序。

2.2.5 記憶體容量是有限的

實際上,**計算機中的記憶體容量並非是無限的。**為了維持記憶體容量無限的假象,作業系統可以如同使用快取記憶體一樣使用實體記憶體,將沒有放入實體記憶體中的資料作為檔案儲存在磁碟上。這種機制被稱為虛擬記憶體(virtual memory)。虛擬記憶體製造出了擁有充足的實體記憶體的假象。

不過,從磁碟上獲取一個記憶體塊需要花費數十毫秒,對現代計算機來說,這幾乎是一個恆定值。

想讓快取記憶體更快是非常昂貴的。一臺臺式計算機或是手機中可能會有數吉位元組的主記憶體, 但是隻有幾百萬位元組的快取記憶體。通常,程式和它們的資料不會被儲存在快取記憶體中。

快取記憶體和虛擬記憶體帶來的一個影響是,由於快取記憶體的存在,在進行效能測試時,一個函式執行於整個程式的上下文中時的執行速度可能是執行於測試套件中時的萬分之一。當執行於整個程式的上下文中時,函式和它的資料不太可能儲存至快取中,而在測試套件的 上下文中,它們則通常會被快取起來。這個影響放大了減少記憶體或磁碟使用量帶來的優化 收益,而減小程式碼體積的優化收益則沒有任何變化。

第二個影響則是,如果一個大程式訪問許多離散的記憶體地址,那麼可能沒有足夠的高速緩 存來儲存程式剛剛使用的資料。這會導致一種效能衰退,稱為頁抖動(page thrashing)。當 在微處理器內部的快取記憶體中發生頁抖動時,效能會降低;當在作業系統的虛擬快取檔案 中發生頁抖動時,效能會下降為原來的 1/1000。過去,計算機的實體記憶體很少,頁抖動更 加普遍。不過,如今,這個問題仍然會發生。

2.2.6 指令執行緩慢

嵌入在咖啡機和微波爐中的簡單的微處理器被設計為執行指令的速度與從記憶體中獲取指令一樣快。桌面級微處理器則有額外的資源併發地處理指令,因此它們執行指令的速度可以比從主記憶體獲取指令快很多倍,多數時候都需要快取記憶體去“餵飽”它們的執行單元。對優化而言,這意味著記憶體訪問決定了計算開銷。

如果沒有其他東西“妨礙”,現代桌面級處理器可以以驚人的速率執行指令。它們每幾百皮秒(1 皮秒是 10-12 秒,一段非常非常短的時間)就可以完成一次指令處理。但這並不意 味著每條指令只需要皮秒數量級的時間即可執行完畢。處理器中包含一條指令“流水線”, 它支援併發執行指令。指令在流水線中被解碼、獲取引數、執行計算,最後儲存處理結 果。處理器的效能越強大,這條流水線就越複雜。它會將指令分解為若干階段,這樣就可 以併發地處理更多的指令。

如果指令 B 需要指令 A 的計算結果,那麼在計算出指令 A 的處理結果前是無法執行指令 B 的計算的。這會導致在指令執行過程中發生流水線停滯(pipeline stall)——一個短暫的暫 停,因為兩條指令無法完全同時執行。如果指令 A 需要從記憶體中獲取值,然後進行運算得 到執行緒 B 所需的值,那麼流水線停滯時間會特別長。流水線停滯會拖累高效能微處理器, 讓它變得與烤麵包機中的處理器的速度一樣慢。

2.2.7 計算機難以作決定

另一個會導致流水線停滯的原因是計算機需要作決定。大多數情況下,在執行完一條指令 後,處理器都會獲取下一個記憶體地址中的指令繼續執行。這時,多數情況下,下一條指令已經被儲存在快取記憶體中了。一旦流水線的第一道工序變為可用狀態,指令就可以連續地進入到流水線中。

但是控制轉義指令略有不同。跳轉指令或跳轉子例程指令會將執行地址變為一個新的值。 在執行跳轉指令一段時間後,執行地址才會被更新。在這之前是無法從記憶體中讀取“下 一條”指令並將其放入到流水線中的。新的執行地址中的記憶體字不太可能會儲存在高速 快取中。在更新執行地址和載入新的“下一條”指令到流水線中的過程中,會發生流水 線停滯。

在執行了一個條件分支指令後,執行可能會走向兩個方向:下一條指令或者分支目標地址 中的指令。最終會走向哪個方向取決於之前的某些計算的結果。這時,流水線會發生停 滯,直至與這些計算結果相關的全部指令都執行完畢,而且還會繼續停滯一段時間,直至決定一下條指令的地址並取得下一條指令為止。

對效能優化而言,這一項的意義在於計算比做決定更快。

2.2.8 程式執行中的多個流

任何執行於現代作業系統中的程式都會與同時執行的其他程式、檢查磁碟或者新的 Java 和 Flash 版本的定期維護程式以及控制網路介面、磁碟、聲音裝置、加速器、溫度計和其他 外設的作業系統的各個部分共享計算機。每個程式都會與其他程式競爭計算機資源。

程式不會過多在意這些事情。它只是會執行得稍微慢一點而已。不過有一個例外,那就是 當許多程式一齊開始執行,互相競爭記憶體和磁碟時。為了效能調優,如果一個程式必須在 啟動時執行或是在負載高峰期時執行,那麼在測量效能時也必須帶上負載。

在 2016 年早期,臺式計算機有多達 16 個處理器核心。手機和平板電腦中的微處理器也有 多達 8 個核心。但是,快速地瀏覽下 Windows 的工作管理員、Linux 的程式狀態輸出結果 和 Android 的任務列表就可以發現,微處理器所執行的軟體程式遠比這個數量大,而且絕 大多數程式都有多個執行緒在執行。作業系統會執行一個執行緒一段很短的時間,然後將上下 文切換至其他執行緒或程式。對程式而言,就彷彿執行一條語句花費了一納秒,但執行下一 條語句花費了 60 毫秒。

**切換上下文究竟是什麼意思呢?**如果作業系統正在將一個執行緒切換至同一個程式的另外一 個執行緒,這表示要為即將暫停的執行緒儲存處理器中的暫存器,然後為即將被繼續執行的執行緒載入之前儲存過的暫存器。現代處理器中的暫存器包含數百位元組的資料。當新執行緒繼續執行時,它的資料可能並不在快取記憶體中,所以當載入新的上下文到快取記憶體中時,會有一個緩慢的初始化階段。因此,切換執行緒上下文的成本很高。

當作業系統從一個程式切換至另外一個程式時,這個過程的開銷會更加昂貴。所有髒的快取記憶體頁面(頁面被入了資料,但還沒有反映到主記憶體中)都必須被重新整理至實體記憶體中。 所有的處理器暫存器都需要被儲存。然後,記憶體管理器中的“實體地址到虛擬地址”的內 存頁暫存器也需要被儲存。接著,新執行緒的“實體地址到虛擬地址”的記憶體頁暫存器和處 理器暫存器被載入。最後就可以繼續執行了。但是這時快取記憶體是空的,因此在快取記憶體 被填充滿之前,還有一段緩慢且需要激烈地競爭記憶體的初始化階段。

當一個程式必須等某個事件發生時,它甚至可能會在這個事件發生後繼續等待,直至操作 系統讓處理器為繼續執行程式做好準備。這會導致當程式執行於其他程式的上下文中,競 爭計算機資源時,程式的執行時間變得更長和更加難以確定。

為了能夠達到更好的效能,一個多核處理器的執行單元及相關的快取記憶體,與其他的執行 單元及相關的快取記憶體都是或多或少互相獨立的。不過,所有的執行單元都共享同樣的主 記憶體。執行單元必須競爭使用那些將可以它們連結至主記憶體的硬體,使得在擁有多個執行 單元的計算機中,馮 • 諾依曼瓶頸的限制變得更加明顯。

當執行單元寫值時,這個值會首先進入快取記憶體記憶體。不過最終,這個值將被寫入至主內 存中,這樣其他所有的執行單元就都可以看見這個值了。但是,這些執行單元在訪問主內 存時存在著競爭,所以可能在執行單元改變了一個值,然後又執行幾百個指令後,主記憶體中的值才會被更新。

因此,如果一臺計算機有多個執行單元,那麼一個執行單元可能需要在很長一段時間後才 能看見另一個執行單元所寫的資料被反映至主記憶體中,而且主記憶體發生改變的順序可能與 指令的執行順序不一樣。受到不可預測的時間因素的干擾,執行單元看到的共享記憶體字中 的值可能是舊的,也可能是被更新後的值。這時,必須使用特殊的同步指令來確保執行於 不同執行單元間的執行緒看到的記憶體中的值是一致的。對優化而言,這意味著訪問執行緒間的 共享資料比訪問非共享資料要慢得多。

2.2.9 呼叫作業系統的開銷是昂貴的

除了最小的處理器外,其他處理器都有硬體可以確保程式之間是互相隔離的。這樣,程式 A 不能讀寫和執行屬於程式 B 的實體記憶體。這個硬體還會保護作業系統核心不會被程式覆 寫。另一方面,作業系統核心需要能夠訪問所有程式的記憶體,這樣程式就可以通過系統調 用訪問作業系統。有些作業系統還允許程式傳送訪問共享記憶體的請求。許多系統呼叫的發 生方式和共享記憶體的分佈方式是多樣和神祕的。對優化而言,這意味著系統呼叫的開銷是 昂貴的,是單執行緒程式中的函式呼叫開銷的數百倍。

2.3 C++也會說謊

C++ 對使用者所撒的最大的謊言就是執行它的計算機的結構是簡單的、穩定的。為了假裝相 信這條謊言,C++ 讓開發人員不用瞭解每種微處理器裝置的細節即可程式設計,如同正在使用 真實得近乎殘酷的組合語言程式設計一樣。

2.3.1 並非所有語句的效能開銷都相同

在 Kernighan 和 Ritchie 的《C 程式設計語言》一書中,所有語句的效能開銷都一樣。一個 函式呼叫可能包含任意複雜的計算。但一個賦值語句通常只是將儲存在一個暫存器中的內 容變為另外一個內容儲存在另一個暫存器中。因此,以下賦值語句

int i,j; 
... 
i = j; 

會從 j 中複製 2 或 4 位元組到 i 中。所宣告的變數型別可能是 int、float 或 struct big struct *,但是賦值語句所做的工作量是一樣的。

不過現在,這已經不再是正確的了。在 C++ 中,將一個 int 賦值給另外一個 int 的工作量 與相應的 C 語言賦值語句的工作量是完全一樣的。但是,一個賦值語句,如 BigInstance i = OtherObject; 會複製整個物件的結構。更值得注意的是,這類賦值語句會呼叫 BigInstance 的建構函式,而其中可能隱藏了不確定的複雜性。當一個表示式被傳遞給一 個函式的形參時,也會呼叫建構函式。當函式返回值時也是一樣的。而且,由於算數操作 符和比較操作符也可以被過載,所以 A=B*C; 可能是 n 維矩陣相乘,if (x<y)…可能比較 的是具有任意複雜度的有向圖中的兩條路徑。對優化而言,這一點的意義是某些語句隱藏 了大量的計算,但從這些語句的外表上看不出它的效能開銷會有多大。

先學習 C++ 的開發人員不會對此感到驚訝。但是對那些先學習 C 的開發人員來說,他們 的直覺可能會將他們引向災難性的歧途。

2.3.2 語句並非按順序執行

C++ 程式表現得彷彿它們是按順序執行的,完全遵守了 C++ 流程控制語句的控制。上句話中的含糊其辭的“彷彿”正是許多編譯器進行優化的基礎,也是現代計算機硬體的許多技 巧的基礎。

當然,在底層,編譯器能夠而且有時也確實會對語句進行重新排序以改善效能。但是編譯 器知道在測試一個變數或是將其賦值給另外一個變數之前,必須先確定它包含了所有的最新計算結果。現代處理器也可能會選擇亂序執行指令,不過它們包含了可以確保在隨後讀 取同一個記憶體地址之前,一定會先向該地址寫入值的邏輯。甚至微處理器的記憶體控制邏輯 可能會選擇延遲寫入記憶體以優化記憶體匯流排的使用。但是記憶體控制器知道哪次寫值正在從執行單元穿越快取記憶體飛往主記憶體的“航班”中,而且確保如果隨後讀取同一個地址時會使 用這個“航班”中的值。

併發會讓情況變得複雜。C++ 程式在編譯時不知道是否會有其他執行緒併發執行。C++ 編譯器不知道哪個變數——如果有的話——會線上程間共享。當程式中包含共享資料的併發線 程時,編譯器對語句的重排序和延遲寫入主記憶體會導致計算結果與按順序執行語句的計算 結果不同。開發人員必須向多執行緒程式中顯式地加入同步程式碼來確保可預測的行為的一致性。當併發執行緒共享資料時,同步程式碼降低了併發量。

相關文章