Hi,您好,歡迎來到西安天任軟件科技有限責任公司!

C++性能優化大局觀

發布時間:2024-01-30 13:41:33

C++ 可算是(shì)一種聲名在外的編程語言了。這個名聲有好有壞。從好的方面講,C++ 性能非常好,哪個編程語言性能好的話(huà)總忍不住要跟 C++ 來單挑一下。從壞的方面講,它是(shì)臭名昭著的複雜(zá)、難學、難用。

不管說 C++ 是(shì)好還是(shì)壞,不可否認的是(shì),C++ 仍然是(shì)一門非常流行且非常具有活力的語言。繼沉寂了十多年後發布語言标準的第二版——C++11——之後,C++ 以每三年一版的頻(pín)度發布着新的語言标準,每一版都在基本保留向後兼容性的同時提供着改進和新功能。

雖然在語言領域,也有Rust這樣的新語言在向 C++ 發起挑戰,但(dàn)是(shì),不可否認的是(shì),C++ 仍然是(shì)面向性能的領域裏的編程語言王者。我甚至不認爲 C++ 在性能方面次于 C——在極緻追求速度時,C++ 可以比 C 更強,而 C 相(xiàng)比 C++ 的主要優點是(shì)更加簡單:不管是(shì)學習、使用,還是(shì)産生的二進制代碼的體積上。

今天,我們就來大略讨論一下,C++ 是(shì)如何做到高性能的。

Bjarne 老爺子認爲 C++ 最主要的特點在于以下兩方面的關注:

跟 C 語言一樣,C++ 提供非常底層的數據操作能力,爲開發者提供了靈活性。跟“高級”語言一樣,C++ 提供了強大的抽象能力(可以說超越了大部分語言)。而且,相(xiàng)比 C,C++ 要安全得多。在語言誕生的初期就是(shì)如此,現(xiàn)在就更不用說了。

C++ 的類型系統比 C 更加嚴格,因此雖然一直有 C++ 是(shì) C 的超集的說法,這個說法嚴格來說從來就沒成立過。最近(2023 年)碰到過一個程序崩潰的案例,簡化來講,就是(shì)開發者使用了一個 char 的二維數組(char names[MAX_NAMES] [MAX_NAME_LEN]),然後把它傳給了一個接收 char** 參數的函數……這代碼當然是(shì)錯的,但(dàn) C 編譯器雖然給了個告警,但(dàn)編譯還是(shì)沒有失敗。如果這是(shì) C++ 代碼的話(huà),那編譯器就會直接報告錯誤,不給通過了。

而第二點,零開銷抽象,對于 C++ 的性能至關重要。我們有很多的抽象機制,同時,使用這些抽象機制并不會帶來額外的開銷。在某些情況下,使用這些機制,反而有“負開銷”—— “使用者”可以非常安全地使用這門語言,即可獲得極高的性能。同時,C++ 還給予 了“定制者”根據自己的需求來寫出更貼近使用場景的庫的能力,可以進一步方便“使用者”。

當然,定制對程序員(yuán)的技能有非常高的要求。初學 C++ 的更需要掌握 C++ 的标準庫的使用——用好标準庫,就能獲得非常不錯的性能。正如高德納大神的名言的完整版:

而 C++ 已經提供相(xiàng)當多的機制,可以允許我們很容易地獲取高性能,在很多場景下遠遠超過高德納所說的 12%。

舉個例子, C++ 标準庫的sort和 C 标準庫的qsort:在關閉優化時,在某一測試場景下得到了 1:2.5 的性能差異,C++ 似乎要慢(màn)不少;但(dàn)一旦打開 -O2(允許内聯)時,兩者的性能差異突變成 3.5:1,C++ 的性能比 C 高出了好幾倍!這就是(shì)所謂的“負開銷”了。C++ 的代碼比 C 的更簡單、更直觀,性能還更高。原因自然就是(shì) C++ 的函數對象和模闆機制允許編譯器更好地進行内聯,從而産生更加高性能的代碼。

因此,學會用好 C++ 的第一步是(shì)用好 C++ 的基本機制和标準庫,了解标準庫的不同機制的性能開銷,包括時間和空間。

任何情況下學習 C++,第一需要了解的就是(shì)析構函數和 RAII(resource acquisition is initialization)慣用法。對,雖然 C++ 誕生時名字是(shì)“帶類的 C”,但(dàn)類和面向對象并不等同,對面向對象編程的支持并不是(shì) C++ 的最重要特性。C++ 的自定義類型的最特别之處不在多态,而在對其行爲的定制上——最重要的就是(shì)對象銷毀時應該做些什麽。析構函數和析構函數帶來的 RAII 慣用法,是(shì) C++ 裏最重要的特性,也是(shì)用 C++ 進行資源管理的關鍵。

重載是(shì)另外一個非常重要的 C++ 特性。除了你不用在名字上區分 process_char、process_string、process_int 帶來的方便性外,它對泛型編程也很重要,還對現(xiàn)代 C++ 的一個基本特性“移動語義”非常重要。刨除語法上的細節,本質上來說,移動語義就是(shì)讓程序員(yuán)可以方便地區分會繼續使用的對象和以後不再使用的對象,允許對後者使用構造函數和賦值運算符的重載來“竊取”其中的資源。對于一個普通的 vector,拷貝的開銷是(shì) O(n) 或更高(如果 vector 成員(yuán)是(shì)容器或其他具有高拷貝開銷的對象),但(dàn)移動開銷通常(是(shì),隻是(shì)通常;不過通常你也不會遇到這種例外的特殊情況)是(shì) O(1),常數複雜(zá)度。這就是(shì)我們在 C++ 裏高效傳遞對象的一種常見(jiàn)方式了。

C++ 标準庫裏最常用的組件恐怕就是(shì) string 和各種容器了。它們都對移動進行了優化。當然,除了這個基本的性能點外,容器都有各自的特殊性能點,比如不同情況下的插入性能差異。這些都是(shì)需要學習的地方。

比如,vector 在尾部插入性能比較好,在中間插入性能比較差。不過,更進一步的是(shì),你需要知(zhī)道,尾部插入性能好的前提條件是(shì)元素的類型對移動有很好的實現(xiàn),并且移動構造函數聲明成了 noexcept!如果你實現(xiàn)了開銷爲 O(1) 的移動構造函數,但(dàn)忘了把它聲明爲 noexcept,那仍然是(shì)白搭,vector 的尾部插入仍然有性能問題。

又(yòu)如,list 不管從開頭、結尾還是(shì)中間插入,都具有很高的性能。但(dàn)是(shì),對于相(xiàng)同元素的 list 和 vector,list 的遍曆性能可能要差一個數量級。這個原因就不完全是(shì) C++ 的知(zhī)識點了,而是(shì)跟硬件的緩存組織相(xiàng)關。如果我們關心性能的話(huà),這些都是(shì)需要了解的地方

前面我們已經提到過模闆,而 string 和容器也都是(shì)模闆,行爲可以通過模闆參數來進行定制,并允許高效的内聯優化。模闆當然是(shì) C++ 裏比較複雜(zá)的一個地方,但(dàn)基本的使用則相(xiàng)當簡單:vector 就是(shì)一個放(fàng) int 的 vector,用起來跟一個普通的類沒有區别——隻是(shì)模闆創建者的工作簡單了,不需要手工爲不同的類型創建不同的類。

用好 C++、在項目中獲得令人滿意的性能 當然不止上面這一些。最基本的,我們還需要了解标準庫算法,并合适地使用并發和并行來充分利用硬件。在本文中我們暫且就不展開了。

當我們用熟了 C++ 之後,慢(màn)慢(màn)地,我們就會不再滿足于 C++ 标準庫這一“制式武器”。我們會尋找适合自己的第三方庫,甚至自己造輪子來滿足項目的特定需求。此時,我們就需要進一步了解 C++ 的高級特性。我們需要了解模闆的進一步細節,尤其是(shì)特化。我們需要了解 SFINAE 和模闆元編程。我們需要了解 constexpr 和它帶來更方便的編譯期編程。C++ 的使用者也許可以暫時不關心這些問題,但(dàn)定制者,或者說項目裏的框架搭建者和工具提供者,必須去(qù)了解 C++ 的這些高級特性,爲你的項目提供紮實的基礎。

舉個例子,C++ 的标準庫提供了 list,雙向鏈表。這個庫沒啥問題,但(dàn)在某些使用場景下,它的時間和空間開銷都不令人滿意,比如我們的對象除了正常的管理,還需要一個額外的 LRU(least recently used)算法來抛棄其中最老的項。你當然可以使用 list,但(dàn)每次插入操作都需要插入一個對象,除了有堆内存分配開銷,你還需要考慮在這個 list 裏到底存什麽。也許用智能指針?情況是(shì)不是(shì)越搞越複雜(zá)了?

這種情況下,最合理的選擇是(shì)使用某種 intrusive_list,侵入式的鏈表,不需要在每次插入或删除時進行内存管理。C++ 标準庫沒有提供這個功能。你可以使用 Boost 裏提供的容器,或者自己寫一個新的。對于這個例子,Boost 多半就足夠好了。但(dàn)總可能出現(xiàn)一些現(xiàn)成庫解決不了的問題的,這時候,利用 C++ 的高級特性來自己造輪子就是(shì)一件非常自然的事。我們可以做到既有合适的定制,同時用法又(yòu)跟已有的容器相(xiàng)似,沒有額外的學習成本。

或者,也許你希望使用分配器來創建一個容器内存池,來提供對内存的使用效率。這在 C++ 裏也是(shì)非常容易完成的,隻要你了解合适的定制機制。根據洋蔥原則,你可以不管這些定制點,直接用 C++,這樣最簡單;也可以把标準庫“切開”,以自己最喜歡的方式來拼接定制使用——當然,這種做法确實跟切洋蔥一樣,很容易就會哭鼻子的。但(dàn)它确實能幫助你獲得最高的可能性能。


以上爲本次所有分享内容


上一篇:動靜态庫的創建 | 使用 | 加載
下一篇:Linux 設置定時任務常用的三種方法