兩萬字長文,史上最全 C++ 年度總結!

【編者按】C++ 四十年,歷久彌新長盛不衰。幾日前 CSDN 組織了一次C++ 直播對話,在非常短的時間內就吸引了兩萬多開發者觀看,足以說明 C++ 在開發者中的影響力。本文的四位作者聯合撰文,寫下了這篇兩萬字的長文,深度總結了 C++ 的新進展,以及未來的演進方向,值得所有開發者收藏。

作者 | 祁宇 許傳奇 袁秩昊 卜恪

責編 | 唐小引

出品 | 《新程式設計師》編輯部

不平凡的 2022 年已經過去了,受到疫情影響,C++ 標準委員會(以下簡稱委員會)只能在線上 Review 提案,效率較低,但在新標準的制定上仍然取得了一些進展。同時,C++20 的 Modules 和 Coroutine 也有一些新的突破,本文將集中介紹 C++ 最新的進展以及大家極為關注的點,譬如:

  • 過去的一年 C++ 社區也有一些大新聞,比如 Google 推出的程式語言Carbon號稱下一個 C++,它又會對 C++造成什麼影響呢?

  • C++20 發佈已經快兩年了,相應的 C++20 庫有沒有跟上呢?過去這一年裡 C++ 社區有哪些值得推薦的 C++20 庫呢?我們也會重點推薦一些 C++20 基礎庫,可以幫助使用者快速構建高性能 C++ 應用。

  • 我們已經進入了 2023 年,C++23 會在今年發佈,它又有哪些值得關注的新特性呢?本文也將介紹 C++23 相關的特性。

  • C++ 未來比較重要特性(如 executors)現在又是什麼狀態?相信這也是大家比較關心的,在本篇 C++ 的年度總結中,我們都將為你細細道來。

2022 年度 C++ 標準關鍵新進展

2022 年度 C++ 標準關鍵新進展

在 2022 年 2 月,C++23 就進入了 feature freeze(功能凍結期),即在這之後 C++23 將不會接受除了 Defect Resolution 之外的任何更改。委員會的精力將主要集中在現有的 Bug Fix 以及 C++26 中。在 2022 年 11 月,委員會也開啟自全球大疫情以來的第一次線下集會。在過去的三年,因為疫情的原因,委員會放棄了往常線下聚會為主的工作方式,改為以線上工作為主。根據大家的反饋以及最後沒能達成 C++23 的規劃來看,疫情還是對委員會的工作效率造成了不小的影響。而 11 月重啟的線下聚會或許也能表明委員會的工作將會重新走入正軌。本節將會提及一些過去一年中在標準方面相對比較重大或較為有意思的改動。由於筆者能力與興趣原因,可能會有遺漏,望大家見諒。

(1)C++23 的探險者

三年前我們在給 C++23 圈定目標時,誰也不知道這個新版本到底要以何種方式應對未來世界的挑戰。但現在,事情已經很清楚了:C++23 要從其他程式語言社區搶人。

import std;int main(){std::println("hello, world");}

用到的新特性:

  • 標準庫模組 std 和 std.compat

  • std::print 和 std::println,整合 std::format 到標準輸出

語言核心的現代化

如果說 C++11 看起來像一個新語言,C++23 看起來就像是某個你很熟悉的程式語言。是的,我們連 Hello World 都改了,學校裡教 C++ 的書都得重寫了。

struct Path{auto exists(this Path& self) -> bool;auto rename(this Path& self, string_view target) -> void;auto mkdir(this Path& self, mode_t mode = 0777) -> void;};

如果你熟悉 Rust,它看起來就像是 Rust;如果你習慣加了 type hints 的 Python,它看起來就像 Python。這裡的 this 僅僅是堆在 self 參數前一個關鍵字;self 不過是筆者自顧自取的一個參數名。這下 self.mode 和 mode 不會搞混了,至少在建構函式和虛擬函式之外的地方是如此。

用到的新特性:

  • 顯式對象參數和顯式對象成員函數

但光看著像是不夠的。C++ 這個名字就意味著,凡事都要做到更好,不單是和 C 相比。

標準庫與其他部分的協作

談談我最近寫 Python 遇到的事情,我看到一個 review 裡有很多這樣的語句:

print(list(mapping.keys()))

如果 mapping == {‘nice’: 1, ‘boat’: 2},這個 print 就會列印 [‘nice’, ‘boat’]。

但為什麼 print(mapping.keys()) 不行?試了一下,結果列印出:

dict_keys(['nice', 'boat'])

好吧,雖然不是自己想要的,但也不算太糟。要說太糟的話,這個就有點太糟了:

>>> print(iter(mapping))

除非是你自定義的生成器類型,否則都列印不出有意義的東西。

>>> def fib(n: int) -> int:...     a, b = 0, 1...     for _ in range(n):...         yield a...         a, b = b, a + b>>> print(f'fib: {fib(5)}')fib: 

但是 C++ 的話則靈活了許多:

std::println("{}", mapping | views::keys);

列印:

["nice", "boat"]

生成器:

auto fib(int n) -> std::generator{auto [a, b] = std::tuple(0, 1);for (auto _ : views::iota(0, n)){co_yield a;std::tie(a, b) = std::tuple(b, a + b);}}/* ... */std::println("fib: {}", fib(5));

列印:

fib: [0, 1, 1, 2, 3]

不管是容器、view、生成器,還是 tuple 一類的異質容器,不論來自標準庫還是第三方,都不需要為看到一點合理的輸出從頭實現一整個演算法。

用到的新特性:

  • 標準庫生成器 std::generator

  • std::format 支持 ranges

不足之處

黑了這麼久 Python,還是得承認 Python 和 Rust 這樣這樣的語言,在讓使用者上手方面是積累了很多經驗的。比如在程序遇到意料之外的錯誤時,runtime 能列印棧回溯。如果你在 Rust 中把一個字串解析為 32 位整數:

let v = arg1.parse::().unwrap();

若解析失敗,程序運行時就可能看到這樣的東西:

thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: ParseIntError { kind: InvalidDigit }', src/main.rs:5:37note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

設置環境變數 RUST_BACKTRACE=1 重新跑,沒有偵錯程式也能看到不少診斷資訊:

thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: ParseIntError { kind: InvalidDigit }', src/main.rs:5:37stack backtrace:0: rust_begin_unwindat /rustc/90743e7298aca107ddaa0c202a4d3604e29bfeb6/library/std/src/panicking.rs:575:51: core::panicking::panic_fmtat /rustc/90743e7298aca107ddaa0c202a4d3604e29bfeb6/library/core/src/panicking.rs:65:142: core::result::unwrap_failedat /rustc/90743e7298aca107ddaa0c202a4d3604e29bfeb6/library/core/src/result.rs:1791:53: core::result::Result::unwrapat /rustc/90743e7298aca107ddaa0c202a4d3604e29bfeb6/library/core/src/result.rs:1113:234: playground::mainat ./src/main.rs:5:175: core::ops::function::FnOnce::call_onceat /rustc/90743e7298aca107ddaa0c202a4d3604e29bfeb6/library/core/src/ops/function.rs:251:5note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

我們可以在 C++ 裡寫一個幾乎等價的 parse 函數:

templateauto parse(std::string_view from) -> std::expected {T to;auto ed = std::to_address(from.end());if (auto [ptr, ec] = std::from_chars(from.data(), ed, to);ec != std::errc())return std::unexpected{ec};else if (ptr != ed)return std::unexpected{std::errc::invalid_argument};elsereturn to;}

然後試一試:

auto v = parse(arg1).value();

但你只會看到:

terminate called after throwing an instance of 'std::bad_expected_access'what():  bad access to std::expected without expected value

和 Rust 程序沒開 RUST_BACKTRACE=1 時差不多,甚至沒有行號。

C++23 做了一些努力。你可以直接列印當前的棧回溯:

std::println(stderr, "{}", std::stacktrace::current());

但當前的棧 != 異常拋出時的棧;我期待 C++26 給出一個開箱即用的解決方案。

用到的新特性:

  • std::stacktrace 標準庫類型

  • std::expected,類似 Rust 的 Result

關於 C++23,以上提到的許多特性,文章只展示了浮光掠影的一小部分,它們對 C++ 特殊的意義遠遠超出「製造一點熟悉感」;讓一個語言的新版本中或大或小的特性保持正交、挖掘協同作用,是一項大工程。期待你能在 C++23 對 C++ 的應用產生深遠影響之時從中獲益。

(2)Executors

Executors 算得上 C++ 標準提案中的明星提案了。它能獲得如此高的期望度的原因之一可能是包括網路庫、協程庫在內的提案都需要依賴 Executors 提案。另一方面可能也說明大家對於一個統一的排程器接口的期望。在過去的三年內,由於提案過大、疫情導致只能線上 Review 等諸多緣由,Executors 提案的進度並不算快。在 2021 年 12 月至 2022 年 2 月,Executors 提案的作者們以時間不足、Executor 十分重大為由,發起了罕見的衝鋒式 Review。然而委員會還是以提案過大、無法完成 Review 的理由拒絕了該提案進入 C++23,將其放入了 C++26 的週期內。

雖然這一結果後續導致了不少微詞,筆者依然覺得委員會的決定是理智和冷靜的。一方面在過去包括 Modules、Concepts、Reflections 在內的諸多提案都被反覆延遲過,感覺不到 Executors 需要特事特辦的理由。另一方面,個人認為,「慢」 與其說是 C++ 特性發展的 Defect(缺點),不如說是 C++ 特性發展的 Feature(特徵)。畢竟對於程序語言來說,「亂」 是比 「慢」 可怕得多的事。更何況今年來 C++ 標準的發展速度其實已經非常快了。

(3)SIMD

跳出 C++ 標準本身,非同步化和並行化是當今 C++ 世界的兩大浪潮。對於 C++ 程式設計師來說,當你想顯著地提高程序性能時,從非同步化和並行化這兩個方面開始思考是比較穩妥的方式。對於非同步化而言,上面提到的 Executors 和下面提到的 Coroutines 都可算是相關的話題。對於並行化而言,無論是 GPU 加速、CPU SVE、編譯器向量化最佳化亦或者是各種並行程式設計庫(例如 Open_MP)都與並行化有關。

再讓我們回到 C++ 標準本身,與並行化相關的概念則在 Parallelism TS 當中。而 Parallelism TS 中的 SIMD 庫則是距離我們最近的一部分。目前 SIMD 庫已經脫離了 Parallelism TS ,之後的所有改動都將直接在 LEWG 中討論。

SIMD 庫的意義在於將各種之前需要手寫 SIMD instrinsic 的操作封裝成跨平臺的標準化街口。一方面調用函數接口肯定比手寫 SIMD 指令要友好的多,而且庫實現大概率會比手寫的效率高。另一方面 SIMD 庫的封裝對於編譯器向量化也會有好處。最後,SIMD 庫的出現對於目前國記憶體在體系結構遷移需求的開發者們來說,會是一個非常大的福音。不然任何之前通過手寫 SIMD 指令以獲取性能提升的項目都會付出當初難以預料的成本。希望 SIMD 庫可以如期進入 C++26。

(4)Concurrency v2 TS

2022 年 2 月發佈的 TS:

https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2021/n4895.pdf

在 2022 年 11 月的會議中,Concurrency v2 TS 的兩個比較重要的變動是:RCU(Read-copy-update)以及 std::hazard_pointers 將會脫離 Concurrency v2 TS,作為獨立的 feature targeting C++26。RCU本身是 Linux 核心中的一種同步技術,支持併發地執行一個 Updater 以及多個 Reader 而不需要上鎖,是一種很高效的同步機制。

而 hazard pointer 則是一種只允許單個寫執行緒持有,多個讀執行緒共享的指針,是 lock-free 程式設計中的重要資料結構。RCU 和 hazard pointer 都是在實踐中被長期驗證過的高效、經典的同步資料結構,如果能成功被加入標準的話,想必對於 C++ 的使用者們來說會帶來不少用處。哪怕我們在日常開發中不會用到這種高級的同步資料結構,我們在引用的庫中應該也能得到 RCU 和 hazard pointer 標準化的好處。

除了 RCU 和 std::hazard_pointer 之外,Concurrency TS 中還將包含 synchronized_value,byte-wise atomic memcpy 以及 asymetric fence 等等元件。總體來說,值得期待。

(5)Library Fundamentals v3 TS

2022 年 7 月通過的基礎庫擴展 v3:

https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2022/n4908.html

Library Fundamentals v2 TS,即 Library Fundamntals TS 的上一次發佈,還是在 2017 年。而 Library Fundamentals v3 TS 相比較於 Library Fundamentals v2 TS 相比,只增加了一個新 feature:(scope guard)。同時 Library Fundamentals v3 TS 還保留著以下 Library Fundamentals v2 TS 中的元件:

  • detection idiom

  • propagate_const

  • observer_ptr

  • ostream joiner

  • sample

  • shuffle

  • randint

  • reseed

委員會認為 Library Fundamentals TS 的發展效率總體比較低,同時在委員會的討論中,Library Fundamentals TS 元件的優先級也低於直接單獨發的庫提案。最後在 2022 年 11 月的會議中,委員會宣佈 Library Fundamentals v3 TS 將會是最後的 Library Fundamentals TS,這表示 Library Fundamentals TS 將不會再有任何發展。之後,如果 Library Fundamentals TS 中的某些元件比較引人感興趣的話,就應該直接作為單獨的提案提出了。

(6)Transactional Memory TS

2022 年 11 月發佈的 Transaction Memory v2 TS:

https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2022/n4923.pdf

Transactional Memory 是電腦科學中的重要概念。在有了併發程式設計之後,Transactional Memory 將會容易很多。但如果沒有 hardware 支持的 transcational memory,依靠軟體模擬的 Transactional Memory 的開銷很大而顯得使用 Transactional Memory 的好處並不明顯,不太有意思。

而隨著支持 Transactional Memory 的商用級處理器日漸出現之後(例如 Arm 的 Hardware Transactional Memory Extension 以及 Intel 的 Transactional Synchronization Extensions ),Transactional Memory 的分量與前景也在肉眼可見的加重。C++委員會也及時地加快了對 Transactional Memory 標準化的設計。

在對 Transactional Memory 具體的設計上,C++ 委員會的選擇出人意料地非常簡潔和剋制。基本只引入了一個語法:atomic do {}。

unsigned int f(){static unsigned int i = 0;atomic do {++i;return i;}}

不需要任何解釋,讀者看到這段程式碼後的理解與實際的語義基本差不了多少。而且和其他 C++ 近年新引入的語法相比,背後並沒有那麼多的彎彎繞繞。在筆者所知的近年來所有新 C++ feature 當中,Transactional Memory 的設計是最簡潔的,對標準文字的影響也是最小的。而在具體的語義上,委員會的設計也給實現者留下了非常巨大的空間。唯一美中不足的是,目前還看不到任何實現和實現的跡象,可能只能等硬體公司的開發者們將此事提上日程。

(7)C++ Ecosystem International Standard

C++ 標準委員會的核心產出是一份說明 C++ 核心語言與 C++ 標準庫定義的文件。除此之外的事情,原則上都不歸委員會管了。雖然 C++ 程式設計師們談起 C++ 標準時往往會帶著敬畏的態度,但C++ 今日的成功決不只取決於 C++ 語言本身,更取決於 C++ 的生態。

例如,對於絕大多數 C++ 程式設計師來說,他們閱讀標準的時間應該是遠小於他們與編譯器、連結器、構建系統、包管理器、偵錯程式、靜態分析工具與動態分析工具等等工具打交道的時間的。這裡我們暫且將 C++ 生態的概念限制為 C++ 工具的生態。然而與有著統一標準 C++ 語言規範不同,C++ 工具間的互動能力(interoperability)的規範只能說是經驗主義、約定俗成的。這對於現有 C++ 工具的維護者來說是個不小的負擔。而對於新 C++ 工具的開發者而言,更是要花上大量的時間去關注非規範的 C++ 工具生態,這給新 C++ 工具的開發帶來了非常重的、額外的、其實本不必要的負擔。

這個問題在 C++ 引入 Modules 後變得更嚴峻了,因為 Modules 會給現有的幾乎所有 C++ 工具帶來全新的挑戰。為了解決以上提到的這些問題,委員會提出有必要制訂 C++ Ecosystem International Standard[1]來為 C++ 生態制訂明確的規範。

雖然目前距離第一版規範的面世還遙遙無期,或者說 C++ Ecosystem International Standard 應該包含那些部分都還沒有完全確定下來。但我們相信,這一定是 C++ 發展歷史上極為重要的一步。

Modules

Modules

Header files are a major source of complexity, errors caused by dependencies, and slow compilation. Modules address all three problems.

頭檔案是複雜性、依賴錯誤、編譯太慢的主要根源,而 Modules 則能夠解決了這三個問題。

—— Bjarne Stroustrup,C++ 之父

Modules 被很多人認為是 C++20 中最重要的特性,同時也是對 C++未來影響最大的特性。原因之一可能是因為只能使用文字替換以引入依賴的 C++ 看起來確實很不 Modern。在筆者所知的所有主流高級語言中,除了 C++ 之外,唯一還使用 Modules 的語言是 C 語言,就連 Fortran 也都早就用上了 Modules。

但與之相對應的,Modules 也是 C++20 四大特性(Modules、Coroutines、Concepts 和 Ranges)中被各個編譯器支持地最慢、最不完善的一個特性。我們在本節中會先對 Modules 語法做一個簡單的介紹、之後會介紹 Modules 在編譯器、構建系統及其他工具中的支持情況,再對 Modules 的未來做一個展望。

(1)語法簡介

Modules 可簡單分類為 Named Modules 和 Header Units。對文字比較敏感的朋友看到這句話肯定會覺得很難受。為什麼 Units 和 Modules 是並列的呢?這裡指的其實是 import 關鍵字後可接的內容。import後可接 module-name(及 partition-name)和 header-name。

嚴格來說,Modules 可分為 Named Modules 和 Unnamed Modules(也叫 Global Modules)。Named Modules 是由 module-unit 聲明的。module-unit 是一類特殊的 translation-unit。而 header-unit 則是在 import header-name; 時合成(Synthesized)的一種特殊 translation-unit,同時 header-unit 中的聲明均視為位於 Global Module 中。這樣一來,大家也就能理解為什麼 Modules 會被分類為 Named Modules 和 Header Units 了。

接下來我們會簡單介紹下 Modules 的語法,但不會引入所有細節,只是希望大家通過這一小節能對 Modules 有個直觀的感受。大家感興趣的話可以再找更進階的材料學習。

Header Units

Header Units 的語法為:

import header-name;header-name:< h-char-sequence >" q-char-sequence "h-char-sequence:h-charh-char-sequence h-charh-char:any member of the translation character set except new-line and U+003E GREATER-THAN SIGN

例如:

import ;import "importable-header";

看上去很簡單,似乎只需要把 #include 換成 import 再加個分號就好了。但事實遠沒這麼簡單。觀察例子的第二行,這裡寫的是 import “importable-header”; 即 Header Units 只能 import 所謂的 importable-header。但什麼是 importable-header 呢?C++ 標準的說法是 implementation-defined。只有標準庫中的頭檔案需要是 importable-header。這給包括工具鏈開發者在內的廣泛使用者帶來了非常深的困擾。意味著我們無法確定任何使用了 Header Units 的程式碼是否是符合標準的、跨編譯器與跨平臺兼容的。

Header Units 的問題還不止於此,來看下面這個例子:

// a.cpp// Compile flags: -std=c++20 -DFOOimport "foo.h";...// b.cpp// Compile flags: -std=c++20import "foo.h";

這個例子中有兩個源檔案 a.cpp 和 b.cpp,它們都 import 了 foo.h,但它們的編譯選項是不一致的。此時將 a.cpp 和 b.cpp 中的 import “foo.h”; 編譯為同一個 header unit 明顯是不合適的。但如果將 a.cpp 和 b.cpp 中的 import “foo.h”; 編譯為兩個不同的 header unit,那麼一方面編譯速度不但不會更快還會更慢,因為我們需要付出額外的序列化和反序列化的時間。另一方面這個做法也與我們的原則:「在一個項目中,一個 module 只編譯一次」相違背,還會增加 One-Definition-Rule Violation 的可能性。除此之外,header units 還有不少其他的問題,這裡不再展開。

直到 2022 年 11 月的會議上,委員會的工具鏈小組依然花了很多時間討論編譯器與構建系統該如何合作來讓 Header Units 可用。而這個問題直到今天也沒有達成共識。

Named Modules

我們可以 module-unit 中聲明 Named Modules。module-unit 是包含 module declaration 的 translation unit。module-unit 的語法為:

module-unit:module-declaration declaration-seqglobal-module-fragment module-declaration declaration-seq

這個語法的含義是,module-unit 要麼由 module-declaration 開頭,要麼由 global-module-fragment 開頭後接 module-declaration。這裡的 declaration-seq 表示後續的各種聲明。

global-module-fragment 的語法為:

global-module-fragment:module preprocessing-directives

這裡的 preprocessing-directives 指 #include、#define 等各種 # 開頭的 directives 以及 import、export 等語句(覺得奇怪的話,可以暫且忘掉)。

module-declaration 的語法為:

[export] module module_name[:partition_name];

根據 module-declaration 的不同,可將該 module unit 分為:

  • Primary Module Interface Unit

  • Module Implementation Unit

  • Module Interface Partition Unit

  • Internal Module Partition Unit

讓我們用一個例子來理解下這些概念:

// M.cppmexport module M;export import :interface_part;import :impl_part;export void Hello();// interface_part.cppmexport module M:interface_part;export void World();// impl_part.cppmmodule;#include #include module M:impl_part;import :interface_part;std::string W = "World.";void World() {std::cout << W << std::endl;}// Impl.cppmodule;#include module M;void Hello() {std::cout << "Hello ";}// User.cppimport M;int main() {Hello();World();return 0;}

在例子中的 M.cppm 是一個 Primary Module Interface Unit。Primary Module Interface Unit 將聲明一個 Module,一個 Module 中只可包含一個 Primary Module Interface Unit。例子中的 interface_part.cppm 和 impl_part.cppm 分別是 Module Interface Partition Unit 和 Internal Module Partition Unit(它們之間的區別比較複雜,大家可以暫且不用在意)。

總之,Module Partition Unit 將會聲明一個 Module 的一個 Partition。一個 Module 中的 Partition 需要是唯一的。Module 外的使用者無法直接 import Module Partition。例子中的 Impl.cpp 是一個 Module Implementation Unit。一個 Module 可以有多個 Module Implementation Unit。Module Implementation Unit 會隱式的 import 對應的 primary module。Module Implementation Unit 可用於定義各種 Module Interface Unit 中聲明的實現。在例子中的 interface_part.cppm 和 impl_part.cppm 包含了 Global module fragment 用以依賴所需的頭檔案。這也正是 Global module fragment 的設計用途,用以後向兼容各種所需的頭檔案。在 M.cppm 和 interface_part.cppm 中,函數 Hello() 與函數 World() 前有關鍵字 export,表面這兩個聲明是可被 Module 的使用者使用的。

雖然本節看上去有些長,但稍微總結下 Module 的定義即先寫個 module; 引入 Global Module Fragment,之後引入所需的頭檔案,再使用 export module module-name; 聲明 Module 的名字。之後在需要對外可見的聲明前加上 export 關鍵字就好了。如果當前檔案寫得太長了,還可以另起一個檔案聲明下 Partition 就好,export module module-name:partition-name 也可以將具體實現放到 Module Implementation Unit 當中。至於 Module 的使用就更簡單了,需要什麼 Module,直接 import 進來即可。

雖然讀者們可能也能感覺到還會有各式各樣的細節,但起碼看上去 Modules 確實不難對吧?

(2)Modules 的好處

封裝性

我們以 asio 庫中的 asio::string_view 為例進行說明。以下是 asio::string_view 的實現:

namespace asio {#if defined(ASIO_HAS_STD_STRING_VIEW)using std::basic_string_view;using std::string_view;#elif defined(ASIO_HAS_STD_EXPERIMENTAL_STRING_VIEW)using std::experimental::basic_string_view;using std::experimental::string_view;#endif // defined(ASIO_HAS_STD_EXPERIMENTAL_STRING_VIEW)} // namespace asio# define ASIO_STRING_VIEW_PARAM asio::string_view#else // defined(ASIO_HAS_STRING_VIEW)# define ASIO_STRING_VIEW_PARAM const std::string&#endif // defined(ASIO_HAS_STRING_VIEW)

該檔案的位置是 /asio/detail/string_view.hpp,位於 detail 目錄下。同時我們從 asio 的官方文件[2]中也找不到 string_view 的痕跡。所以基本可以判斷 asio::string_view 這個元件在 asio 中是不對外提供的,只在庫內部使用,作為在 C++ 標準不夠高時的備選。然而使用者們確可能將 asio::string_view 作為一個元件單獨使用 Examples[3],這違背了庫作者的設計意圖。從長遠來看,類似的問題可能會導致庫使用者程式碼不穩定。因為庫作者很可能不會對沒有暴露的功能做兼容性保證。

這個問題的本質是頭檔案的機制根本無法保證封裝。使用者想拿什麼就拿什麼。

而 Modules 的機制可以保障使用者無法使用我們不讓他們使用的東西,極強地增強了封裝性:

隔離性

隔離性

這指的是 import module; 不會受到上下文所影響。例如每一個人都能看出下面程式碼的問題:

#define true false#include "header.h"

header.h 中的實現將嚴重受到影響。當然這個例子可能過於極端了,真實世界也不會有人這麼寫程式碼。但可以看一個更真實的案例:

#include "headerA.h"#include "headerB.h"// 以及#include "headerB.h"#include "headerA.h"

這個例子說的是由於 #include 頭檔案順序不同導致的行為差異。沒踩過這個坑的 C++ 程式設計師想必不多。

而在使用 Modules 之後,不會再受到外界定義的宏的影響,同時 import modules; 的順序也不會改變程序的行為。

更強的一致性檢查

One Definition Rule(ODR)是 C++ 的重要規則。ODR 可以簡單理解為在一個程序中一個 Entity 只應該擁有一個定義。違反 ODR 可能給 C++ 程序帶來很嚴重同時很難查的 bug。但在之前的編譯模型當中,每個 TU 都是單獨編譯的,互不干擾。這使得編譯器只能在當前 TU 中檢查 ODR,對於跨 TU 的 ODR Violation,之前的編譯器是無能為力的。

之前的實踐方式都是將跨 TU 的 ODR violation 檢查交給連結器來做。但由於從高級語言到連結器之間已經損失了非常多的資訊,連結器能檢查到的 ODR violation 是有限的。而在 Modules 進入 C++ 之後,我們就擁有了在編譯器前端進行跨 TU 檢查 ODR violation 的能力,這是一個很大的進步。

編譯加速

Modules 很吸引 C++ 程式設計師的一個特性即是 Modules 的編譯加速能力。從定性的角度分析 Modules 編譯加速能力時,我比較喜歡用這個例子來解釋:如果一個項目中存在 N 個頭檔案與 M 個源檔案,每個源檔案都 include 了每個頭檔案,那麼這個項目的編譯時間複雜度可以表示為 O(N*M)。

而如果將項目以一個頭檔案對應一個 Module Unit 的方式重構之後的話,因為每個 Module Unit 中的程式碼不會被重複編譯,我們可以將整個項目的編譯時間複雜度表示為 O(N+M)。從 O(N*M) 到 O(N+M) 的改進是非常巨大的。

當然這個模型顯然是太過於粗糙了,有很多的因素都沒有考慮,例如模版、inline 函數、編譯器最佳化等等,但我們應該還是能看出 Modules 在 C++ 項目編譯加速方面的潛力。

能預計到很多讀者會好奇使用 Modules 到底具體地能給我們的項目帶來多大的編譯加速比?大概是一個什麼樣的數字?這樣直觀的資料當然是非常吸引人的。然而在當下的環境中,直接給出一個 Modules 編譯加速能力的數字是不負責任的和誤導人的。一方面每個項目的程式碼結構和組織方式都天差地別,另一方面編譯器中相關的實現無論宏觀架構或者細節部分可能都有較大的調整空間,再者目前對於 Modules 使用的實踐的方式不夠多、規模也不夠大。在這樣的情況下,具體數字的價值就很小了。

這段話本身想對讀者表達的是大家目前不應該被 Modules 具體的加速比數字所迷惑了。當前在網上能搜到的資料中,從百分之幾到幾十倍都有。在筆者所做的實驗中,根據配置與程式碼的不同,從百分之十幾到幾倍的資料都有。大家如果好奇自己的項目在 Modules 中能得到多大的加速比的話,最好的辦法還是自己上手試一下。

(3)std modules

std modules 是 C++23 的一個重要特性。在由於疫情導致產出下降的 C++23 中,std modules 可能是其中最亮眼的特性了。std modules 允許使用者直接 import std; 而匯入標準庫中的所有聲明(宏除外)。例如:

import std;int main() {std::cout << "Hello World.\n";}

對於使用者來說,這個語法並沒有太多需要值得關心的地方。如果你在 import std; 後出了任何問題,那大概率都會是工具鏈的問題而不會是你的問題。當然了,工具鏈什麼時候 ready 就是另一個問題了。目前 MSVC 已經推出了需要使用者自行安裝的 std.ixx[4];libc++ 正在做非常初期的探索;暫時沒有聽到 libstdc++ 相關的傳聞。

有人可能還有疑問,之後的新特性都會不會只加到 std module 中而不會加到標準庫頭檔案中?或者說標準庫頭檔案在未來是否會被逐漸 deprecate?無論從 Modules 在工具鏈方面的實際進度還是從向後兼容性這兩個角度來看,目前都沒有這個徵兆。

(4)目前編譯器支持狀態

總體來說 MSVC 對 Modules 的支持狀態是最領先的,其次是 Clang 和 GCC。筆者對於 Modules 在 Clang 中的狀態相對比較熟悉些,這裡就描述下 Modules 在 Clang 中的狀態吧。

Modules 技術在某種程度上可以理解為對 C++ 程式碼的序列化和反序列化。目前 Clang 和 GCC 的做法都是對 C++ 程式碼對應的 AST 進行序列化和反序列化。在 Clang 中相關的技術最早可以追述到 Clang 開發時用於幫助 Debugging 的技術。之後 PCH(Precompiled Header)技術也複用了這個技術。然後 Apple 開發了 Objective-C++ Modules。後續 Apple 和 Google 在這之上開發了 Clang C++ Modules 技術。

Clang C++ Modules 是 Clang 的一個 C++ 擴展,可以將 Header 隱式地轉換為 Modules,所以也叫 Clang Header Modules 以及 Clang Implicit Modules。後來當 Modules 確認進入 C++ 標準後,Google 在 Clang 中做了 Standard C++ Modules 初步的支持。不過之後因為各種原因,Google 在 C++ 標準方面的投入放緩,Clang 中 Standard C++ Modules 的支持也陷入了停滯。

從 2021 年下半年開始,筆者和 GCC 的 maintainers 對 Clang 中 Standard C++ Modules 進行了完善。在 2022 年 9 月,Clang15 發佈,這也是首個號稱支持 Standard C++ Modules 的 Clang 版本。在 Clang15 中,對 Modules 主要的語法都進行了支持。在預計於 2023 年 3 月發佈的 Clang16 中,也將會包含更多 Modules 相關的 bug 修復。

雖然號稱對 Standard C++ Modules 的語法進行了較為完整的支持,但我們還是得承認目前 Modules 的支持中存在較多的缺陷以及 Bug。這與 Modules 龐大的規模以及編譯器社區對於語言新特性的工作方式是有關係的。

首先是編譯器社區對新特性支持的工作方式,一般流程是:開發者們看著提案實現特性 -> Reviewer 們覺得沒問題之後就合入 -> 宣佈該特性已得到支持(注意:此時該特性一般並未得到廣泛使用者大規模的使用)-> 新版本發佈 -> 如有 Bug Report 則根據 Bug Report 進行修復和迭代。

這裡的關鍵點是一個特性是否得到支持的宣稱是由開發者和 Reviewer 們經過 Review 和相對有限的測試決定的。以往對於很多規模較小的特性而言,靠著開發者們的經驗,一般大家都覺得沒問題的話那問題確實也不大。

但對於 Modules 這種規模的特性而言,就必然需要長時間大規模的、基於使用者反饋的迭代才能到達一個高可用的狀態。特別是 Modules 的本質是對 C++ 語言的序列化和反序列化,這意味著只要 C++ 語言本身依然保持著演化,那 Modules 的開發就不存在 「完成」 這個說法。例如我們現在發現的不少 Modules 的 Bug 與 Concept 這樣的新語法相關。

雖然上面這段話可能顯得 Clang 中對 Modules 的支持相對較差,但筆者感覺目前三大編譯器對 Modules 的支持水平的差距可能並不大。起碼近幾天三大編譯器的開發者碰頭交流情況時,大家都表示最近在修 Bug。所以感覺進度其實都差不太多。

當然大家可能還是很好奇何時才能用上一個穩固、絲滑、高可用的 Modules。一方面感覺這個問題沒法回答,因為現在 Clang 編譯器沒有 Stable Release 的概念也不會專門宣佈像 Modules 這樣的 feature 已經完成了。另一方面我個人套用軟體發佈生命週期的概念的話,目前 Clang 中 Modules 的狀態可能處於 Alpha 或 Beta 的狀態,再經過一兩個版本就能達到較為穩定的水平了,大概是 Clang17 或 Clang18 左右(注意:這並非官方概念)。

另外比起看各種各樣的文章(包括本篇)和評測,還是很推薦大家上手試一試 Modules。一方面如果用的語法不太小眾的話,應該問題不大。另一方面如果遇到了編譯器問題的話,能做 Minimal Reproducer 向社區發 Issue Report 的話,也很有幫助。

(5)當前構建工具支持狀態

其實,阻礙大家使用 Modules 的另一個重要原因還是構建工具的支持不太夠。畢竟對大多數人來說無論是手寫 makefile 還是寫 CMake 或其他構建工具的腳本都還是比較煎熬的事。大家還是希望能有一個開箱即用的工具可以讓自己專注於新 feature 本身。本節就簡單介紹下我們所知的構建工具對 Modules 的支持情況。

  • MSBuild。VS 中自帶的構建工具。較早的支持了 Modules。不過只支持 MSVC。

  • Build2。早在 19 年就支持了 GCC Maintainer 提出的 Server-Client 模型。不過只支持 GCC 而且似乎最近沒有更新的訊息了。

  • XMake。XMake 是一個輕量級、跨平臺的基於 Lua 構建工具。XMake 的更新頻率很高且對新 feature 的跟近也很及時。看介紹已經支持了 MSVC、GCC 以及 Clang 三大編譯器。XMake 的風評很不錯,推薦感興趣的朋友看一下。

  • CMake。老牌 C++ 構建工具,不必多說。雖然之前對 Modules 的支持顯得略慢,但在過去的半年內開始發力。如果一切順利的話,在今年春天發佈的 CMake 3.26 中將包含對 MSVC、GCC 以及 Clang 三大編譯器的支持。

  • Bazel。暫時沒聽到 Bazel 官方對 C++20 Modules 進行支持的訊息。但得益於 Bazel 的擴展性,目前已經有一些基於 Bazel 的工具開始支持 C++20 Modules。其中我個人最推薦的是 rules_ll[5] 這個工具。與其他玩具性質的擴展不同,rules_ll 似乎是想做一個長期的、針對異構程式碼編譯的工具,對 C++20 Modules 的支持是其中的一個(重要)feature。

(6)對其他工具的影響

在 C++ 生態當中,除了編譯器和構建系統之外,還有許許多多的其他工具。在這些工具中,幾乎所有與項目構建以及以 C++ 程式碼作為輸入的靜態分析工具都將會受到 Modules 的強烈衝擊。即這些工具如果不對 Modules 做適配,那這些工具面對使用了 Modules 的程式碼就基本完全沒法用。這類工具數量非常多,完全無法列舉,這裡僅簡單舉幾個例子:

  • clangd。clangd 是一個 Language Server。clangd 可以為程式設計師進行程式碼自動補全、高亮提示、語法提示和程式碼跳轉等功能。而可以想象的,如果不對 Modules 做特殊適配,以上這些功能在面對使用 Modules 的程式碼時都將失效。市場上的其他類似的工具也都是一樣。

  • ccache。ccache 是一款對編譯結果進行 cache 以提升編譯速度的工具。ccache 的關鍵是通過依賴分析以保證做一致性判斷時不會誤判。但 Modules 恰好會引入新的依賴關係。這就產生了衝突。

  • distcc。distcc 是一個分散式編譯工具。與 ccache 類似,這樣的工具必然需要處理依賴關係而與 Modules 產生衝突。而對於 distcc 另一個更大的問題是,目前 Modules 產生的 module file 的體積要遠大於預處理後的頭檔案體積。這使得在網路中傳遞 module file 可能會成為一個瓶頸。而如果網路傳輸速度成為瓶頸的話,分散式編譯的意義也就受到挑戰了。當然,這本質是 Modules 技術的一個挑戰而不是分散式編譯的。

  • ……

這當然是很多的問題,但對於工具開發者們來說,這也是一個難得的機遇。而這些問題在 Modules 設計之時都已經被考慮到了。最終的結果大家也都看到了。委員會的想法是,這些確實都是很嚴肅的問題。但經過評估,這些都不是不能解決的問題。我們不能因此止步不前。C++ 語言要朝著更現代化的方向發展,C++ 生態也自然需要向著更現代化的方式去發展才行。C++ 語言的願景或者說目標使用者是:「能夠長久運行數十年的大型高效率軟體」。為達成這個目標,C++ 生態也需要與之相對應的變化。

(7)未來的方向:兼容性、分發以及包管理器

Modules 受人期待的一個重要原因即大家覺得 Modules 有希望解決 C++ 長久以來的受人詬病的分發問題。可能大多數構建過大型 C++ 項目的朋友都會有一個相同的感受:快被環境問題整吐了。不知道有多少 C++ 程式設計師在開始一個大型的 C++ 項目時都懷疑過自己到底是一個 C++ 程式設計師還是一個系統運維。

導致這種問題出現的一個本質原因即是 C++ 生態中的依賴傳遞方式要麼是全原始碼依賴要麼是半原始碼半二進位制依賴。而庫的開發人員所考慮的環境、庫的編譯環境以及使用者環境則很可能是不一致的。這導致了各種各樣的原始碼沒法編譯、沒法連結、一運行就掛等等問題。這個問題的本質是大家的環境不一致,但 C++ 語言又使得各種環境要混在一起導致的。在一些擁有良好的基礎設施的大公司,這個問題的解決方式是通過強行讓大家的環境一致來避免環境不一致帶來的混亂問題。但這種方式一方面需要很強的技術能力,另一方面卻也增加了技術交流的壁壘。

而 Modules 的出現使得我們有機會從另一個角度來解決這個問題。Modules 能夠將庫的開發環境(或者說庫的編譯環境)與使用者環境隔離開來,降低了環境衝突的風險。另一方面 Modules 能夠將庫所需的開發環境描述在 metadata 裡,這讓使用者遇到無法兼容的環境時可以儘可能早的得到報錯資訊而不是各種連結錯誤或者說運行時錯誤時滿臉茫然。

不過這節既然被放到了「未來的方向」中,即說明這個想法目前還只是個美好的願景。本質原因是想要達到上述的美好環境,我們需要支持 Modules 的二進位制分發。但任何二進位制分發都需要涉及到兼容性問題。而目前 Modules 的二進位制兼容性,基本等價於 0。別說 Clang 與 GCC 編譯的 Modules 的二進位制兼容,就連 Clang 任意不同版本編譯後的 Modules 的二進位制都是不兼容的。

這是因為目前編譯器實現 Modules 的方式基本是對 AST 的序列化和反序列化。而 AST 作為編譯器內部的資料結構,其必然是沒有什麼格式要求的。不然一個開發者交個 patch 簡單改下 AST 都需要發提案進行修改,這樣的模式顯然太低效了。據我所知目前 MSVC,Clang 和 GCC 都處於這個狀態。開發者們目前的共識是,起碼現在 Modules 的二進位制分發是不現實的,還是推薦大家原始碼分發吧。另外聽說 MSVC 在弄 Modules 的二進位制格式規範,但進度應該非常慢,不知何時才能有比較具體的東西可以看看。

雖然統一的二進位制格式顯得遙遙無期,但大家都覺得這應該是未來的方向,也是 Modules 誕生的重要意義。個人覺得,未來可期。

(8)Carbon 與 「下一個 C++」

在 22 年的夏天,Google 向大家隆重地介紹了號稱 「下一個 C++」 Carbon 語言。這裡由於篇幅與主題原因,不對 Carbon 語言做過多介紹,但非常建議感興趣的朋友們可以去看看 Carbon 官方的文件。Carbon 官方的文件豐富而流暢,筆者認為這是學習工業界系統級程式語言設計的絕佳材料,特別是 Carbon 目前還處於設計中的狀態,這種機會並不多。

回過頭來,Carbon 的核心概念其實可以簡單概括為 「C++ 的未來就是現有的 C++ 程式碼庫」。現在當我們問為什麼要用 C++ 寫產品級程式碼時,我們能得到的其中兩個較多答案是「因為我們依賴的庫是用 C++ 寫的」 「我加入的時候這個產品就是用 C++ 寫的了,後來程式碼太多改不過來了」。對於這兩個答案,翻譯一下就是,「如果依賴的庫不是 C++,那麼我不會使用 C++」 「如果我們改得過來的話,那我們就不用 C++了」。將這兩句話再延伸下就是,「我們現在及之後編寫 C++程式碼的理由是因為之前的 C++程式碼」。再延伸一步即是上面提到的這句話,「C++的未來就是現有的 C++程式碼庫」。而如果有一個可以完全兼容現有 C++程式碼的新語言,對於抱有以上兩種心態的使用者而言,他們確實不用再寫 C++程式碼了。

需要注意,筆者並不贊同以上這個觀點。但無論筆者是否贊同,都確實有以及將會有為數不少的人認同這個想法。在這裡提到這個話題的原因是,Carbon 能做到完全兼容現有 C++ 程式碼的原因之一即是 Modules 技術背後的對 C++ 程式碼進行預編譯後序列化以及反序列化的技術,而不是再 Carbon 語言裡再塞一堆內建的 C++ 語法。

可以預見的是,未來某日 Modules 技術成熟之後,實現完全兼容 C++ 的新語言的成本將會低很多。可能像目前自制語言已不稀奇一樣,也許未來的 「下一個 C++」 將會俯仰皆是。你的 「下一個 C++」 又何必是 Carbon?

Coroutines

Coroutines

協程 (Coroutines) 是 C++20 引入的 4 大特性(Modules、Coroutines、Concepts 和 Ranges)之一。協程本身也是電腦科學中的經典概念,在上個世紀 60 年代就出現了。協程的本意是一個可中斷的執行流,而根據這個執行流的上下文中是否包含棧的資訊,又可將協程分為有棧協程(stackful coroutines) 和無棧協程(stackless coroutines)。

對於有棧協程,既然它是一個可中斷的帶有棧的執行流,那這個概念和我們所熟知的執行緒就非常相似了。為了避免混淆,在一般語境中,我們說執行緒指的是由作業系統管理中斷和喚醒的執行緒。而有棧協程的中斷和喚醒則是通過使用者態程式碼實現的,所以有棧協程也被叫做使用者態執行緒,也有 fibers、green threads 這樣的名字。

Go 語言中的殺手級特性 Goroutines 就是一種有棧協程。因為有棧協程是使用者態執行緒,所以能理解執行緒概念的朋友理解有棧協程應該會很容易。有棧協程相比於執行緒的價值即是有棧協程的切換是使用者態的、不需要陷入核心態。這使用者態切換相比於核心態切換所節約的成本就是有棧協程的價值。當然,與之相對的,執行緒的切換和排程由核心控制而有棧協程的切換和排程則由使用者預先設置好,即有棧協程應該排程但沒有排程或有棧協程不應排程但排程時所產生的成本則是有棧協程的成本和代價。

對於無棧協程,它沒有棧,它執行的上下文就是開始函數本身,即無棧協程是一個可中斷和喚醒的函數。由於有棧協程更容易被大家理解和接受,所以有些地方也叫無棧協程作第二代協程(但似乎無棧協程出現的時間並不比有棧協程晚)。無棧協程因為沒有棧,無棧協程在切換時的代價非常低,基本等價於兩個函數調用,而有棧協程切換時還需要保存各種暫存器,相比起來就慢很多了。但無棧協程也有其代價,由於無棧協程天然沒有棧,而我們程式設計時的邏輯往往自然是有調用關係概念的,這使得我們用無棧協程時需要顯式的語法來表示這種特殊的調用關係。這也是常說的無棧協程的傳染性。在其他語言中,像 JavaScript 與 Rust 中的 await 與 async 都是用無棧協程來實現的。

再回到協程進入 C++ 標準時,當時協程這個概念在各個語言中都很火,特別是 Go 中 Goroutine 非常火爆,大家都覺得 C++ 也應該有協程特性才對(當然事實和這個不一定有邏輯關係,協程進入 C++ 標準很早就開始籌備了)。很自然地,C++ 標準中的協程應該是無棧協程還是有棧協程就成為了一個自然的選擇。當時有 Google 提出的有棧協程方案與微軟提出的無棧協程方案。最終由於 C++ 的零抽象原則以及無棧協程方案更高的擴展性,委員會最終選擇了無棧協程的方案。

至此,在 C++語境中提及協程(Coroutines)都默認為無棧協程。而在此之前,在 C++語境中提到協程時則可能指代更好理解的有棧協程。比如很多 C++20 之前的協程庫其實指的是有棧協程庫。

值得一題的是,目前依然有提案嘗試將有棧協程加入到 C++ 標準中。畢竟有棧協程和無棧協程終歸不是一個東西,語義的差別也比較大。所以現在標準中已有無棧協程不是一個拒絕有棧協程進入標準的很強的理由。但目前委員會的狀態似乎是沒有明確反對但也沒有很大興趣的狀態。

(1)語法簡介

委員會希望 C++20 協程有著儘可能高的可擴展性,所以在 C++20 中只設計了協程的語義框架而沒有設計任何的協程語義。這也和其他很多的語言不同,其他語言就算選擇了底層使用無棧協程實現,最終提供給使用者的都是封裝好的接口。C++20 的協程的使用者本質上是協程庫作者而非廣大的使用者。按照設計者的想法,C++協程的終端使用者(end user)只需要學習所需的、封裝好的協程庫即可而不必學習繁複的 C++20 協程語法。

如果大家對 C++20 協程語法感興趣的話,個人建議看看 Lewis Baker 的部落格[6]。這是我見過對 Coroutine 語法的解讀最好的材料了,就不在此拾人牙慧了。

(2)用途

非同步場景

協程或者說 C++20 協程最吸引人的地方在於可用同步的方式寫非同步的程式碼。正如之前提到的,當前 C++世界的兩大浪潮是非同步化和並行化。當我們想提高一個 IO 密集型的同步程序的性能時,將其非同步化能得到很好的效果。在我們的實踐中,這一般可以得到一個數量級以上的 QPS 的提升。非同步化當然不是個新概念。但之前在 C++項目中想寫非同步程序往往需要基於回調的方式。但回調的寫法不直觀、對程式設計師有很大的心智負擔。會增加出 Bug 的概率,對程序性能可能也有影響。接下來通過一個簡單的例子說明下。

首先,我們先來看看同步的程式碼,這個程式碼會讀若干個檔案後返回總的檔案體積。程式碼還是很簡單的:

uint64_t ReadSync(std::vector Inputs) {uint64_t read_size = 0;for (auto &&Input : Inputs)read_size += ReadImplSync(Input);return read_size;}

接下來我們再看下一個基於回調的版本:

template future do_for_each(Range, Lambda);                    // We need introduce another API.future ReadAsync(vector Inputs) {auto read_size = std::make_shared(0);        // We need introduce shared_ptr.return do_for_each(Inputs,                                           // Otherwise read_size would be[read_size] (auto &&Input){            // released after ReadAsync ends.return ReadImplAsync(Input).then([read_size](auto &&size){*read_size += size;return make_ready_future();});}).then([read_size] { return make_ready_future(*read_size); });}

肉眼可見地,非同步寫法麻煩了非常多。同時這裡還使用到了 std::shared_ptr。但 std::shared_ptr 會有額外的開銷。如果使用者不想要這個開銷的話需要自己實現一個非執行緒安全的 shared_ptr,還是比較麻煩的。

再來看下對應的協程版本:

Lazy ReadCoro(std::vector Inputs) {uint64_t read_size = 0;for (auto &&Input : Inputs)read_size += co_await ReadImplCoro(Input);co_return read_size;}

與同步版程式碼幾乎一致!但這份程式碼在執行時實際是非同步的。這也是協程的好處:使用同步方式寫非同步程式碼,兼具開發效率和運行效率。

同步場景與動態分配

雖然在語言層面上,協程的設計和非同步是沒有關係的。但在實踐中,協程發揮大作用的地方一般往往都是在非同步場景中。當然既然語言層面沒有限制,那用協程來寫同步程式碼也是可以的。例如 C++23 中的新協程元件 `std::generator`就是一個同步的協程元件。但用協程寫同步程式碼的一個天然劣勢在於,協程的創建會動態申請記憶體以維護生命週期。雖然在非同步場景下這個動態申請的開銷可以被攤平。但在同步場景下,這樣的動態申請可能還是會有嚴重的影響。

這個問題委員會之前也考慮到了,對此協程的設計者給出的回應是編譯器有能力將動態分配最佳化掉,這個最佳化叫做 Coroutine Elision。距離編譯器中協程的初次實現已經過去了 4 年,編譯器對於某些經典場景下協程的最佳化能力達到了一個令人驚歎的水平,比如這個例子[7]:

例如對於左邊這個經典的 generator 實現與幾個 ranges 操作,Clang 編譯器能將其最佳化到右邊加上 label 和 ret 一共只有 3 條指令的水平。可以說是非常棒了。委員會當年對於類似的結果也非常滿意,通過了協程的設計。但問題在於 Coroutine Elision 並不是個標準的一部分。也就是說一個不實現 Coroutine Elision 的編譯器也是完全符合標準的要求的,例如對於上面相同的例子[8]:

GCC 和 MSVC 都無法將其最佳化到與 Clang 相同的水準。而這點是無可指責的,因為 Coroutine Elision 並不是標準的一部分,現在看起來基本是 Clang/LLVM 的擴展。GCC 與 MSVC 並沒有實現這個特性的義務。

而就算在 Clang 上,因為這不是語言規範的一部分,使用者在編寫程式碼時也沒法確定自己的程式碼是否會被最佳化。即使使用者通過 hack 編譯器確定自己所寫的程式碼可被某個版本的編譯器最佳化,他也無法確認這個程式碼在之後的版本里是否會被一直最佳化。

即使是 Clang 的最佳化場景也是有限的,Clang 無法最佳化其他的同步場景的動態分配的情況也比比皆是。例如,協程是可中斷的函數這個特性其實很適合用於實現各種狀態機。例如實現 Parser。但某位實現者表示,他基於 C++20 Coroutines 所寫的 Parser 比起純 C 手寫的 Parser 性能下降了 50%-60%,主要原因都出在動態分配上。對此我覺得可以理解。

事實上,Coroutine 的動態分配無法被程式設計師取消也是 Executors 提案的初衷之一。雖然筆者也提出了相關的提案以試圖解決這個問題,但從目前的 Review 進度來說可能是遙遙無期了。

(3)協程庫

本節簡單介紹下一些協程庫。對協程感興趣的朋友們而言,這些協程庫可能比各種協程的語法條款要有意義得多:

  • cppcoro。cppcoro 是 Lewis Baker 之前用於 POC 的作品。為協程的發展做了很多貢獻,但目前已經不被維護了。

  • folly。folly 的 Coroutines 模組應該是目前最大的開源協程庫,包含了非常非常多的東西,功能也很多。也有不少開發人員,Lewis Baker 也曾被邀請到 facebook 開發 folly。

  • async_simple。async_simple 是阿里巴巴開源的輕量級 C++ 非同步框架。提供了基於 C++20 無棧協程(Lazy),有棧協程(Uthread)以及 Future/Promise 等非同步元件。個人認為對於覺得 folly 太重的朋友們來說,async_simple 會是個很不錯的選擇。

  • 標準協程庫。標準協程庫是 C++23 的規劃之一,目前因為 Executors 提案遲遲沒有落地而一直延期。正如之前所提到的,雖然協程在語言層面和非同步沒有關係,但在實踐中協程出現一般都是在非同步化場景中。而沒有 Executors 提案提供的排程器接口的話,非同步協程元件自然也無法開始設計。所以目前在 C++23 中存在的協程元件只包含和非同步無關的同步 std::generator。與我們在開發中所說的協程庫相比,個人感覺 std::generator 更像是 Ranges 的一部分。

雅蘭亭庫(yaLantinglibs)

雅蘭亭庫(yaLantinglibs)

目前 C++20 標準正在普及,而相應的 C++20 庫卻很少,這導致使用 C++20 新特性如用協程開發網路開發網路應用是一件困難的事,但協程對於簡化非同步程式碼來說又是非常好的,這時候就急需一些 C++20 庫來簡化使用,提高開發效率了。

因此,我們開源了一個跨平臺(Linux、macOS、Windows)的 C++20 基礎庫——yaLantingLibs(雅蘭亭庫),它是一個 C++20 基礎庫合集,已經在 GitHub 上開源[9],庫名字靈感正是來源於「蘭亭集序」,雅蘭亭庫裡面有平時開發中常用的庫,如協程庫async_simple[10]、序列化庫(struct_pack)、json 庫(struct_json)、更高效易用的 protobuf 庫 struct_pb 和 rpc(coro_rpc)庫,後面還會不斷增加新的基礎庫如 http、orm 等庫。

總之,雅蘭亭庫的目標是幫助 C++使用者快速構建高性能 C++應用,易用性、性能和安全性是它的主要特色!雅蘭亭庫的長期目標是完善 C++開發的生態。

因為雅蘭亭庫是基礎庫的合集,所以裡面的每個子庫都是可以獨立使用的,如果你只需要序列化功能,只需要包含 struct_pack 的頭檔案就行,如果只需要 rpc 功能,就包含 coro_rpc 的頭檔案,如果只想使用協程庫,那就只包含 async_simple 頭檔案。

coro_rpc 庫是 C++20 新特性應用的集大成者,它大量使用 C++20 新特性,如 concepts、coroutine、非類型的模版參數、generic lambda 等特性,還使用了一些 C++23 的特性,如 std::expected、std::source_location 等,還有正在標準化的編譯期反射特性。大家可以從程式碼中了解到這些新特性是如何應用的。

(1)雅蘭亭庫的序列化庫

一句話來概括雅蘭亭庫的序列化庫那就是:高性能易用的序列化庫。以二進位制序列化庫 struct_pack 為例,它的性能比 protobuf 要高一個數量級,易用性也更好。

struct person {int64_t id;std::string name;int age;double salary;};person person1{.id = 1, .name = "hello struct pack", .age = 20, .salary = 1024.42};// one line code serializestd::vector buffer = struct_pack::serialize(person1);// one line code deserializationauto person2 = deserialize(buffer);

無需定義 proto 檔案,也沒有宏,一行程式碼就可以完成對象的序列化和反序列化。

得益於編譯期反射的能力,struct_pack 的性能和易用性才會大幅超過 protobuf。

除此之外,還有 json 庫 struct_json,也一行程式碼既可實現 json 字串和對象的相互轉換。還有 struct_pb 庫,它可以將普通 C++對象序列化成 protobuf 格式,比原生 pb 的性能更好。

(2)雅蘭亭庫的 rpc 庫

非同步程式碼協程化是大勢所趨,通過協程可以大幅降低非同步程式碼的編寫難度,提高程式碼的可讀性、可維護性和易用性,雅蘭亭庫基於協程開發了高性能易用的 rpc 庫 coro_rpc,幾行程式碼就可以完成一個 rpc 服務。

rpc 服務端:

// 定義 rpc 服務 rpc_service.hppinline std::string echo(std::string str) { return str; }// 註冊 rpc 服務#include "rpc_service.hpp"#include int main() {register_handler();coro_rpc_server server(/*thread_num =*/10, /*port =*/9000);server.start();}

rpc 客戶端:

#include "rpc_service.hpp"#include Lazy test_client() {coro_rpc_client client;co_await client.connect("localhost", /*port =*/"9000");auto r = co_await client.call("hello coro_rpc"); //傳參數調用 rpc 函數std::cout << r.result.value() << "\n"; //will print "hello coro_rpc"}int main() {syncAwait(test_client());}

從非同步連接到非同步請求全部協程化,非同步程式碼和同步程式碼邏輯類似,簡潔易用。基於協程的 coro_rpc 的性能也是很優秀的,在 96 核伺服器上 echo 測試 qps 超過 2000 萬。

真心希望 yaLanTingLibs 能幫助大家快速開發 C++應用,希望大家能積極去使用 yaLanTingLibs,感受一下它帶來的開發效率和高性能!

Executors

Executors

(1)std::execution

很可惜,std::execution[11]最終沒能進入 C++23,但這並不妨礙它依舊是令人期待和興奮的語言特性。C++社區一直以來都沒有一個好用的非同步程式設計模型,以應對與日俱增的非同步和並行的需求帶來的挑戰。雖然各個細分領域,都有著一套獨到的非同步程式設計模型,但它們存在著各種各樣的問題。

(2)std::execution 要解決哪些問題?

自 C++11 標準以來,標準庫就涵蓋了許多多執行緒的底層基礎設施,例如 thread、atomic 和 mutex 等。這些設施過於底層,並不適合直接基於底層設施之上構建業務。稍後引入的 async/future/promise 模型則很低效,而且很難讓使用者正確使用,並且嚴重缺乏對泛型的支持。

C++17 引入的並行演算法(Parrallel Algorithm),本質上還是同步的,並缺乏演算法之間組合的能力。業內許多第三方的基於構造任務節點的框架,大多也缺乏對泛型的支持,抽象不夠直觀,無法作為基礎的程式設計模型。所以 The C++ Executors[12]應運而生,距離首個提案至今,也已發展了十餘載,旨在為 C++ 社區提供一個標準的非同步程式設計模型框架。

(3)Hello std::execution

先來看一個例子:

using namespace std::execution;sender auto s =just(1) |transfer(thread_pool_scheduler) |then([](int value){ return 2.0 * value; });auto const result = std::this_thread::sync_wait(s);

上述示例直觀地展示瞭如何通過 std::execution 將常量 1 排程到執行緒池的排程器中,然後執行與浮點常數 2.0 相乘的運算,最後在發起任務的執行緒同步等待結果返回。從接口上看,它與市面上一些 future extension 類庫並沒有多少差別,但它的底層設計與實現確有天壤之別。

(4)std::execution 是泛型的

熟悉 future 和 future extension 的讀者應該都知道,這些類庫都是類型擦除的。而在 std::execution 裡面,一切都是泛型的,類型擦除需要使用者顯式的指定。例如上述例子中,senders 推匯出的類型結構如下圖所示:

泛型可以帶來諸多好處,例如提供足夠的資訊給予編譯器進行激進的最佳化條件。最佳化之後,程式碼創建的中間結構,可以被編譯器幾乎全數裁切。這種類似的最佳化手段在 C++ coroutine 中也有,參考Naked Coroutine Live[13]。如上述示例,最佳化的結果可能如下圖結構所示。

(5)Schedulers/Senders/Receivers 模型

上面的例子雖然簡單,但把 std::execution 的模型的核心都展示出來了,也就是 Schedulers/Senders/Receivers 模型。這個模型可以完整描述了三個核心的要素:執行什麼任務,在哪裡執行以及如何排程。Schedulers 是對底層 Execution Context 的淺層封裝,一個輕量的可複製的 Handler,回答瞭如何排程的問題。而底層的 Execution Context 則回答在哪裡執行的問題。最後的 Senders/Receivers 則回答要執行哪些任務。其中 Senders 表達的是任務本體;Recievers 則是扮演 callback 的角色,把前置 Sender 生產的結果或者錯誤傳遞給後置的 Sender。

使用者需要把 Senders 組合起來,以表達複雜的任務結構。比如任務的後繼,fork 和 join. 組合 Senders 的演算法是泛型的,也是 Lazy 的,同樣也是輕量的 Handler. 與使用者直接打交道的是 Schedulers 和 Senders,還有組合 Senders 的各種工廠(Factories)和介面卡(Adaptors)演算法,但並不與 Receivers 直接打交道。因為 Receivers 是 Senders 之間組合關係連接的紐帶,它們通常由工廠和介面卡演算法負責創建,只有類庫作者在擴展演算法的時候,才需要設計和實現特定的 Receivers。

Schedulers/Senders/Receivers 模型不僅能充分表達非同步執行任務的三個核心要素,還將這三個要素進行了有效的解耦。使得類庫和框架的開發者可以在三個切面獨立考慮將要面臨的問題,也增強了模型的表達能力。

(6)std::execution 是惰性的(Lazy)

惰性(Lazy),先構建任務的結構再發起任務,則是 std::exectuion 的另一個重要的目標。在 Lazy 的假設下,std::execution 的設計與實現可以獲得很多收益。首當其衝的就是對資料競爭的避免。如果任務的創建是立即地,激進地執行,那麼後繼任務的創建就要考慮資料競爭的情況。例如,前置任務是否已經被排程,是否已經返回結構。

(7)未來的展望

雖然 std::execution 的特性強大,但這也意味著設計較為複雜。std::execution 這次沒能順利進入 C++23 標準,帶來些許遺憾,也帶來了 std::networking 的延期,同時也表達了標準委員會對該提案複雜性與成熟度的擔憂。期待 std::execution 在 C++26 能夠給出一份更優秀更完整的答卷。

展望與總結

展望與總結

C++ 是一門歷久彌新的語言,40 年來仍然在迭代更新,以順應時代潮流,並在2022 年一舉拿下 TIOBE 程式語言冠軍,須知上一次拿冠軍是在 20 年前,這發出了一個強勁的信號:C++ 仍然充滿活力,並且越來越好,未來的 C++將是使用越來越簡單,複雜度越來越低,同時生態也越來越完善的一門語言,未來可期!

[1] https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2022/p2656r0.html

[2] https://think-async.com/Asio/asio-1.22.1/doc/asio/index.html

[3] https://github.com/search?q=asio%3A%3Astring_view+extension%3A.cpp+extension%3A.h&type=Code&ref=advsearch&l=&l=

[4] https://learn.microsoft.com/en-us/cpp/cpp/tutorial-import-stl-named-module?view=msvc-170

[5] https://ll.eomii.org/guides/modules/

[6] https://lewissbaker.github.io/

[7] https://godbolt.org/z/aPaesWW65

[8] https://godbolt.org/z/hs6szacbe

[9] https://github.com/alibaba/yalantinglibs

[10] https://github.com/alibaba/async_simple

[11] https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2022/p2300r5.html#design-lazy-algorithms-complexity

[12] https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2022/p2300r5.html

[13] https://www.youtube.com/watch?v=UL3TtTgt3oU

作者資訊:

祁宇,purecpp 社區發起人、《深入應用 C++11》作者

許傳奇,C++ 標準委員會成員

袁秩昊,C++ 標準委員會成員

卜恪,purecpp社區聯合發起人

卜恪,purecpp社區聯合發起人

相關文章

最強 AI ChatGPT 真要取代程式設計師?

最強 AI ChatGPT 真要取代程式設計師?

【CSDN 編者按】ChatGPT 一出,「程式設計師要失業了」、「程式設計師要下崗了」之聲不絕於耳,引得程式設計師們不由得一陣驚慌,最強 ...

中國資料庫的諸神之戰

中國資料庫的諸神之戰

作者 | 唐小引 出品 | 《新程式設計師》編輯部 「現在的資料庫產品實在是太多了!」 前幾天,我和深耕資料庫/大資料近 30 年的盧東明老...

雲原生時代的DevOps平臺設計之道

雲原生時代的DevOps平臺設計之道

【CSDN 編者按】雲原生時代,開發和運維的分工愈加分明,運維人員通過建設並維護一套 IaaS 雲平臺,將計算資源進行池化。開發人員按需申請...