📑 C++ 演进系列总览 | C++11 | C++14 | C++17 | C++20 | C++23 | C++26

C++20 是继 C++11 之后最大的更新,引入了模块、协程、概念和 ranges 等重大特性。它从四个方向重塑了 C++: Concepts 让模板约束成为一等公民、Ranges 让算法与数据以管道组合、协程把异步/生成器纳入语言、模块从根本上改善编译模型。此外还有三路比较、std::formatstd::span、日历/时区、同步原语等大量实用设施。C++20 是现代 C++ 的新基线。

Concepts(概念)

Concepts 是对模板参数的命名约束——用 template<Concept T>requires 子句表达"T 必须满足什么",违反约束时编译器给出人类可读的错误(“int 不满足 Sortable”),而非 SFINAE 那种模板展开几百行的天书。

Concepts 取代了 enable_if/SFINAE 的曲折写法,让泛型代码的意图直接体现在签名里,并能用于重载决议(不同约束的模板可重载)、简化迭代器/算法的分层设计。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <concepts>

// 定义概念
template<typename T>
concept Integral = std::is_integral_v<T>;

template<typename T>
concept Addable = requires(T a, T b) {
a + b; // 必须支持 + 运算
};

// 使用概念约束模板
template<Integral T>
T add(T a, T b) {
return a + b;
}

// requires 子句
template<typename T>
requires Addable<T>
T multiply(T a, T b) {
return a * b;
}

// 简写语法(auto 配概念)
auto subtract = [](std::integral auto a, std::integral auto b) {
return a - b;
};

// 标准概念
template<std::sortable T>
void sortContainer(std::vector<T>& vec) {
std::sort(vec.begin(), vec.end());
}

requires 表达式(requires(T a, T b) { a + b; })检查表达式是否合法,合法则概念满足。标准库提供一组概念:std::integralstd::floating_pointstd::same_asstd::convertible_tostd::invocablestd::ranges::range/iterator/sentinel 等,以及算法相关的 sortable/movable/copyable。优先复用标准概念而非自造。

Ranges(范围库)

Ranges 把"算法 + 迭代器对"重构成"算法 + 范围",并引入视图(views)——惰性、可组合、零拷贝的范围适配器。你不再需要把中间结果存进临时容器,而是用管道 | 把数据流串起来:vec | filter | transform | take,用到才求值。

视图是惰性的——views::iota(1) 生成无限序列也不耗内存,take(10) 才取前 10 个。这让函数式风格的链式处理在 C++ 中零开销可用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <ranges>
#include <vector>
#include <algorithm>

std::vector<int> vec = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

// 过滤偶数
auto evens = vec | std::views::filter([](int x) { return x % 2 == 0; });

// 转换
auto squared = evens | std::views::transform([](int x) { return x * x; });

// 取前 3 个
auto result = squared | std::views::take(3);

// 链式调用
auto final = vec
| std::views::filter([](int x) { return x % 2 == 0; })
| std::views::transform([](int x) { return x * x; })
| std::views::take(3);

// 使用
for (auto x : final) {
std::cout << x << " "; // 4 16 36
}

// 直接创建视图
auto view = std::views::iota(1, 10) | std::views::filter([](int x) { return x % 2; });

视图不拥有数据、不拷贝,依赖底层范围的生命周期——对临时容器的视图要小心悬挂。视图组合的求值是惰性拉取的,遍历时才逐元素经过整条管道。C++23 进一步补了 enumerate/zip/chunk 等视图和 ranges::to 物化。

协程(Coroutines)

协程是可暂停/恢复的函数:用 co_await/co_yield/co_return 在某点挂起、保留局部状态、稍后恢复。它让异步代码能像同步代码一样线性写——回调地狱变成顺序流程,生成器不再需要手写迭代器。

C++20 提供的是协程机制(语言特性 + <coroutine> 工具),不提供现成的任务类型——你必须自己实现 promise_type,或用第三方库(如 cppcorostd::execution)。这门槛较高,标准库的 std::generator 直到 C++23 才补上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <coroutine>
#include <iostream>

// 简单的生成器
template<typename T>
struct Generator {
struct promise_type {
T value;
Generator get_return_object() {
return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
std::suspend_always yield_value(T val) {
value = val;
return {};
}
void return_void() {}
void unhandled_exception() { std::terminate(); }
};

std::coroutine_handle<promise_type> h;
Generator(std::coroutine_handle<promise_type> handle) : h(handle) {}
~Generator() { if (h) h.destroy(); }

bool next() {
h.resume();
return !h.done();
}

T value() { return h.promise().value; }
};

Generator<int> range(int start, int end) {
for (int i = start; i < end; ++i) {
co_yield i;
}
}

// 使用
auto gen = range(1, 5);
while (gen.next()) {
std::cout << gen.value() << " "; // 1 2 3 4
}

promise_type 定义协程的行为:initial_suspend 控制是否立即挂起、yield_value 处理 co_yieldfinal_suspend 控制结束时是否保持帧、unhandled_exception 处理异常。coroutine_handle 是协程帧的句柄,resume() 恢复、destroy() 销毁。协程帧通常堆分配(可由 operator new 自定义或用对称转移避免)。日常用 C++23 的 std::generator 即可,无需手写这些。

模块(Modules)

模块是替代头文件的编译单元组织方式:export module math; 声明模块,import math; 导入。模块是预编译的语义实体——导入不重复解析、不泄漏宏、导入顺序无关、接口与实现可分离。它从根本上解决头文件重复解析导致的编译慢、宏污染、循环包含等问题。

对大型项目,模块可显著缩短编译时间(头文件解析是 C++ 编译耗时大头)。代价是构建系统需支持模块扫描依赖(CMake 有 FILE_SET CXX_MODULES 支持),工具链集成仍在完善。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// math.ixx (模块接口)
export module math;

export int add(int a, int b) {
return a + b;
}

export int multiply(int a, int b) {
return a * b;
}

// main.cpp
import math;
import <iostream>;

int main() {
std::cout << add(2, 3) << "\n"; // 5
return 0;
}

模块可分区(export module math.core;)、私有片段(module :private;)。宏无法跨模块边界——这是特性,强制封装。模块与头文件可混用(import <iostream>; 是头文件单元)。注意某些项目规约(如本 skill)出于工具链成熟度暂不用模块,统一用头文件 + #pragma once

三路比较运算符(<=>)

<=>(飞船运算符)一次定义比较,自动生成 </<=/>/>=/==/!= 全部六个运算符。= default 时编译器按成员逐一比较,省去手写一堆重载。

它返回一个比较类别(strong_ordering/weak_ordering/partial_ordering),表达"小于/等于/大于"以及等价的强度(strong:等价即相等;weak:等价但可能不相等,如大小写不敏感字符串;partial:可有不可比,如浮点 NaN)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <compare>

struct Point {
int x;
int y;

// 自动生成所有比较运算符
auto operator<=>(const Point&) const = default;
};

Point p1{1, 2};
Point p2{1, 3};

if (p1 < p2) { /* ... */ }
if (p1 == p2) { /* ... */ }
if (p1 != p2) { /* ... */ }

// 自定义比较:按 major.minor.patch 逐级
struct Version {
int major;
int minor;
int patch;

std::strong_ordering operator<=>(const Version& other) const {
if (auto cmp = major <=> other.major; cmp != 0) return cmp;
if (auto cmp = minor <=> other.minor; cmp != 0) return cmp;
return patch <=> other.patch;
}
};

注意 = default<=> 默认不生成 ==/!=(除非也 = default operator==,但实际默认 <=> 会同时默认 ==)。自定义 <=> 时,若想要 == 走自定义逻辑需单独写 ==。浮点用 partial_ordering(因 NaN)。Version 这种逐字段比较用 if (auto cmp = a <=> b; cmp != 0) return cmp; 惯用法,短路在首个不同字段。

Designated Initializers(指定初始化器)

指定初始化器允许按字段名初始化结构体成员:Point{.x = 1, .z = 3},未指定的成员值初始化(零)。这让初始化的含义自文档化——{1, 0, 3} 要数位置,{.x = 1, .z = 3} 一目了然,也防止参数顺序写反。

源自 C99,C++20 纳入但限制更严:字段必须按声明顺序指定、不能嵌套指定数组、每个成员只能指定一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
struct Point {
int x;
int y;
int z;
};

Point p{.x = 1, .z = 3}; // y 初始化为 0

// 数组
int arr[5] = {[1] = 10, [3] = 30}; // {0, 10, 0, 30, 0}

// 结构体数组
Point points[] = {{.x = 1}, {.y = 2}, {.z = 3}};

C++ 中指定顺序必须与声明顺序一致(C99 允许乱序,C++ 不允许)。适合配置结构体、有大量字段且只想设几个的场合——可读性和防错性都优于位置初始化。

consteval 和 constinit

C++20 引入两个常量相关关键字,与 constexpr 互补:

  • consteval:函数必须在编译期求值(即时函数)。调用点若无法常量求值则编译错误。适合强制编译期计算(如格式串编译期校验、查表生成)。
  • constinit:变量必须在编译期初始化(但运行期可改)。解决静态/全局变量的"初始化顺序未定义"(static initialization order fiasco)问题——保证不依赖动态初始化。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// consteval:必须在编译期求值
consteval int square(int x) {
return x * x;
}

constexpr int result = square(5); // OK,编译期求值
// int x = 5;
// int y = square(x); // 编译错误,x 不是常量,无法编译期求值

// constinit:必须在编译期初始化
constinit int global = 42; // 编译期初始化,运行期可改

// 结合使用
consteval int compute(int x) { return x * 2; }
constinit int computed = compute(42);

三者对比:constexpr(可编译期可运行期)、consteval(仅编译期,强制)、constinit(仅约束初始化时机,不约束 const 性)。constinit 不隐含 const——变量运行期仍可修改,区别于 constexpr 变量(只读)。constinit 常用于全局/静态变量避免静态初始化竞态。

范围 for 循环支持初始化器

if 初始化语句一样,范围 for 现在允许在循环前加一条初始化语句:for (init; decl : range)。用于在循环前准备一个与循环相关的变量,且作用域限定在循环内。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
std::vector<int> vec = {1, 2, 3, 4, 5};

// C++20 之前
for (auto& elem : vec) {
elem *= 2;
}

// C++20:在循环中声明变量,作用域限定在 for 内
for (auto vecCopy = vec; auto& elem : vecCopy) {
// 操作 vecCopy 的副本,不影响原 vec
elem *= 2;
}

// 更复杂的用法
for (auto it = vec.begin(), end = vec.end(); auto& elem : vec) {
// 使用 it 和 end
}

典型用途:循环前拷贝一份范围(避免遍历时修改原范围)、锁定互斥量(for (std::lock_guard lk(mtx); auto& e : data))、准备循环相关状态。初始化语句的作用域仅限循环,出循环即销毁——锁会随循环结束释放。

Lambda 模板参数列表

C++14 的泛型 Lambda 用 auto 参数,但无法显式命名模板参数、无法约束。C++20 允许 Lambda 显式写模板参数列表 []<typename T>(T x){},能命名参数类型、做约束、处理数组/函数类型退化等 auto 做不到的事。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// C++20 之前
auto lambda1 = [](auto x) { return x * 2; };

// C++20:显式模板参数
auto lambda2 = []<typename T>(T x) { return x * 2; };

// 多个模板参数
auto lambda3 = []<typename T, typename U>(T a, U b) {
return a + b;
};

// 模板参数默认值
auto lambda4 = []<typename T = int>(T x) { return x; };

// 在 requires 子句中使用
auto lambda5 = []<typename T>(T x) requires std::integral<T> {
return x * 2;
};

显式模板参数让 Lambda 能写 []<typename T>(const std::vector<T>& v)(保留容器类型,auto 会退化)、按类型分派、用 Concepts 约束。实际中 auto 已够用时不必上模板参数,需要精细控制时才用。

[[no_unique_address]] 属性

[[no_unique_address]] 告诉编译器:某成员可以与其他成员共享地址,不必独占存储。主要用途是空类型成员(Empty)不占用多余空间——空类理论上 0 字节但 C++ 要求对象非零大小(通常 1 字节),该属性允许把它塞进其他成员的 padding。

这对"策略类/特征类作为成员"的模板设计(如分配器、比较器嵌入容器)很有用——无状态策略不增加对象大小。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Empty {};

struct A {
int x;
Empty e; // 占 1 字节(+padding)
};

struct B {
int x;
[[no_unique_address]] Empty e; // 编译器可与 x 共享地址,不占额外空间
};

// 编译器可能将 B 的大小优化为与 int 相同
static_assert(sizeof(B) == sizeof(int));

能否真正优化取决于实现和 ABI——MSVC 行为与 GCC/Clang 不同,static_assert 不一定所有平台都成立。空基类优化(EBO)是类似机制(针对基类),[[no_unique_address]] 是其成员版。多个 [[no_unique_address]] 同类型成员不能共享同一地址(需可区分)。

[[likely]] 和 [[unlikely]] 属性

[[likely]]/[[unlikely]] 向编译器提示分支的走向概率,指导分支预测与代码布局(把热路径放在一起、冷路径移到函数末尾)。在性能敏感代码中,正确提示能改善分支预测命中率、指令缓存局部性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int process(int value) {
switch (value) {
[[likely]] case 1:
return 10;
[[unlikely]] case 2:
return 20;
default:
return 0;
}
}

bool check(int x) {
if (x > 0) [[likely]] {
return true;
}
return false;
}

void logMessage(int level) {
if (level > 100) [[unlikely]] {
// 警告:这很少见
}
}

属性放在语句前或 case 标签后。注意现代 CPU 分支预测器很强,多数情况下无需手写提示——只有在 profile 确认某分支明显倾斜、且预测器表现差时才用。滥用可能适得其反。优先用 profile 指导而非直觉。

std::format

std::format{fmt} 库的标准化,提供 Python 风格的类型安全格式化。格式串在编译期校验(参数数量/类型不匹配编译报错),性能优于 iostream(无 locale 切换、无虚调用),语法简洁。

它取代了既啰嗦又易错的 << 链和 printf 的无类型检查。C++23 的 std::print/println 在其基础上加了直接输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <format>

std::string name = "Alice";
int age = 30;

// 类似 Python 的格式化
std::string s1 = std::format("Name: {}, Age: {}", name, age);

// 带索引(可重排参数)
std::string s2 = std::format("{1} is {0} years old", age, name);

// 格式化选项
std::string s3 = std::format("Pi: {:.2f}", 3.14159); // Pi: 3.14
std::string s4 = std::format("Hex: {:#x}", 255); // Hex: 0xff
std::string s5 = std::format("Padding: {:>10}", "test"); // Padding: test
std::string s6 = std::format("Zeros: {:0>5}", 42); // Zeros: 00042

格式说明符:{} 默认、{:.2f} 定点、{:x}/{:b}/{:o} 进制、{:>10} 右对齐、{:<10} 左对齐、{:^10} 居中、{:#x} 带前缀、{:0>5} 零填充、{1} 按索引。自定义类型特化 std::formatter<T> 接入。编译期校验要求格式串是字面量常量。

std::span

std::span<T> 是连续内存的非拥有视图——只持指针+长度,不分配、不拷贝。它统一了 std::arraystd::vectorstd::stringdata()、C 数组的访问接口。函数参数接受 span 时所有连续容器都能传,且无拷贝。

它取代了"传 const vector& 还是 T* + size"的两难——前者绑定具体容器类型、后者丢边界信息。span 既类型擦除容器类型又保留长度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <span>

void processArray(std::span<int> arr) {
for (auto x : arr) {
std::cout << x << " ";
}
}

int arr[] = {1, 2, 3, 4, 5};
std::vector<int> vec = {1, 2, 3, 4, 5};

processArray(arr); // OK,从 C 数组构造
processArray(vec); // OK,从 vector 构造

// 子视图
std::span<int> sub(arr, 3); // 前 3 个元素

// 动态大小
std::span<int> dynamic(vec.data(), vec.size());

span 有固定大小版 span<T, N>(编译期已知大小)和动态版 span<T>(默认,dynamic_extent)。只读用 std::span<const T>span 不拥有数据,生命周期依赖底层——同 string_view 的注意事项。子视图 first(n)/last(n)/subspan(off, count) 是 O(1)。

std::source_location

std::source_location 捕获调用点的源码位置(文件名、行号、列号、函数名),取代 __FILE__/__LINE__ 宏。作为默认参数 std::source_location::current(),它能在调用处求值,自动记录"谁调用了日志函数"。

相比宏,它是类型安全的对象,可传递、可存储、可格式化。适合日志、断言、错误报告。

1
2
3
4
5
6
7
8
9
10
11
#include <source_location>

void log(const std::string& msg,
std::source_location loc = std::source_location::current()) {
std::cout << loc.file_name() << ":" << loc.line() << " "
<< loc.function_name() << ": " << msg << "\n";
}

void foo() {
log("Hello"); // 自动记录调用位置:foo 内的这行
}

source_location::current() 作为默认参数在调用点展开,所以 loc 记录的是调用 log 的位置而非 log 定义处。注意它取代了 C++20 提案中的 std::experimental::source_locationstacktrace(C++23)提供完整调用栈,是更进一步的诊断工具。

std::bit_cast

std::bit_cast<To>(from) 把一个对象的位表示重新解释为另一类型——安全、constexpr、类型安全的"按位重解释"。它取代了违反严格别名规则的 reinterpret_cast/memcpy 模式,避免 UB。

要求源和目标大小相同、可平凡拷贝。典型用途:浮点与整型的位级查看(不转换值,看其二进制表示)、压缩位域的打包/解包。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <bit>

// 类型重新解释:查看 float 的二进制位表示(值不变,类型变)
float f = 3.14f;
uint32_t bits = std::bit_cast<uint32_t>(f);

// 位反转
struct Packed {
uint32_t x : 10;
uint32_t y : 10;
uint32_t z : 10;
};

uint32_t raw = 0xABCDEF;
Packed p = std::bit_cast<Packed>(raw);

reinterpret_cast 的关键区别:bit_cast 不违反严格别名(reinterpret_castfloat*uint32_t* 读是 UB),且 constexpr 可在编译期用。要求 sizeof(From) == sizeof(To) 且两者皆可平凡拷贝——不满足则编译错误。是处理二进制协议、序列化的安全工具。

std::endian

std::endian 是编译期可查询的本机字节序枚举:std::endian::native 等于 little/big/或两者(PDP 等混合序)。它取代了运行期探测字节序的技巧(把 int 写入再按字节读)。

知道本机端序后,跨平台数据交换(网络、文件、协议)可据此决定是否转换。C++23 的 std::byteswap 提供实际的字节翻转。

1
2
3
4
5
6
7
8
9
10
11
#include <bit>

// 检查字节序(编译期常量)
if constexpr (std::endian::native == std::endian::little) {
// 小端字节序,多数 x86/ARM
} else if constexpr (std::endian::native == std::endian::big) {
// 大端字节序
}

// 字节序转换(std::byteswap 是 C++23,不是 C++20)
// uint32_t swapped = std::byteswap(x); // 需 C++23 <bit>

std::endian::native 是编译期常量,可用 if constexpr 分支,无运行期开销。注意 C++20 只有 endian 查询,没有 byteswap——实际翻转在 C++20 要手写移位或用平台 htonlbyteswap 是 C++23 补充。(原文此处标注有误,已修正。)

std::make_shared 支持数组

C++17 起 std::shared_ptr 支持数组(shared_ptr<T[]>),std::make_shared<T[]>(n) 可分配并共享拥有数组。这取代了此前用 shared_ptr<vector<T>> 或自定义删除器包裹 new[] 的别扭写法。

说明:shared_ptr 数组支持与 make_shared<T[]> 实际是 C++17 引入(P0674),原文标注为 C++20 有误,此处保留示例并更正。

1
2
3
4
5
6
7
8
9
10
// C++17 之前
auto arr = std::make_shared<std::vector<int>>(10, 0);

// C++17/20 支持数组
auto arr2 = std::make_shared<int[]>(10);
arr2[0] = 1;
arr2[1] = 2;

// 多维数组
auto matrix = std::make_shared<int[][3]>(5);

shared_ptr<T[]>operator[] 提供数组访问,析构调 delete[](默认删除器自动适配)。多维用 T[][N](首维动态、其余静态)。注意 make_shared<T[]> 一次分配对象+控制块(同 make_shared 优势),但数组元素零初始化(int[] 全 0)。

contains 成员函数

C++20 给所有关联容器(set/map/unordered_set/unordered_map 及其 multi 版)加了 contains 成员函数,返回 bool 表示键是否存在。它取代了 find(key) != end() 的冗长写法,直接表达"是否包含"的意图。

1
2
3
4
5
6
7
8
9
10
11
std::set<int> s = {1, 2, 3, 4, 5};

// C++20 之前
if (s.find(3) != s.end()) { /* ... */ }

// C++20
if (s.contains(3)) { /* ... */ }

// 同样适用于 map, unordered_map, unordered_set
std::map<std::string, int> m{{"a", 1}, {"b", 2}};
if (m.contains("a")) { /* ... */ }

containsfind 更清晰、不易写错(忘记 != end 比较是常见 bug)。注意 contains 只返回 bool,需要迭代器时仍用 find。C++23 把 contains 扩展到了 std::string/string_view(子串查找)。

starts_with 和 ends_with

C++20 给 std::string/std::string_view 加了 starts_with/ends_with,返回 bool 判断前缀/后缀。取代了 str.compare(0, n, pre) == 0str.substr(...) 的繁琐写法。

1
2
3
4
5
6
7
8
9
10
std::string str = "Hello, World!";

if (str.starts_with("Hello")) { /* ... */ }
if (str.ends_with("!")) { /* ... */ }

// 同样适用于 string_view
std::string_view sv = "test.txt";
if (sv.ends_with(".txt")) { /* ... */ }

// C++23 进一步加了 std::string::contains(子串包含)

接受子串(string/string_view/const char*)或单字符。复杂度 O(n)。文件扩展名判断、协议前缀匹配、URL 路由等场景的样板代码大幅简化。C++23 的 contains 补齐了"子串包含"这最后一块。

std::jthread

std::jthread(joining thread)是 std::thread 的改进版:析构时自动 join()(而非 threadterminate),并内置 stop_token 协作取消机制。它解决了 std::thread 的两大坑——忘记 join/detach 导致 std::terminate、无法干净地取消线程。

jthread 析构时先 request_stop()(请求停止)再 join()(等待退出),让线程有机会清理后退出。这是 C++20 线程管理的默认选择。

1
2
3
4
5
6
7
8
9
10
11
#include <thread>

std::jthread jt([](std::stop_token st) {
while (!st.stop_requested()) {
// 执行任务,周期性检查停止请求
}
});

// 析构时自动 request_stop() + join()
// 或者显式请求停止
jt.request_stop();

jthread 接受一个可选的首参 std::stop_token,框架自动传入。线程内轮询 st.stop_requested() 决定何时退出。jthread 不可默认构造后赋值移动的语义与 thread 类似,但析构语义更安全。新代码用 jthread,不用 std::thread

std::stop_token

stop_token 是协作取消的载体:线程通过它查询"是否被请求停止"、注册停止回调。jthread 内部维护一个 stop_sourcerequest_stop() 触发停止,线程内的 stop_token 查询到停止请求后优雅退出。

协作式取消避免了强制终止线程(pthread_cancel 的资源泄漏、未调析构等问题)——线程自己决定何时、如何退出,保证资源释放。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <thread>

void worker(std::stop_token st, int id) {
while (!st.stop_requested()) {
// 工作
std::this_thread::sleep_for(100ms);
}
// 收到停止请求,清理后退出
}

std::jthread t1(worker, 1);
std::jthread t2(worker, 2);

// 请求停止
t1.request_stop();
t2.request_stop();

// 所有 jthread 析构时自动 join

stop_callback 可注册"停止时执行的回调"(如唤醒阻塞操作)。stop_source 可独立于 jthread 使用(手动管理停止)。stop_token 本身只读,可安全传递。这是 std::thread 时代做不到的干净取消机制。

信号量(Semaphores)

C++20 引入信号量同步原语:counting_semaphore<N>(计数信号量)和 binary_semaphore(二值,等同互斥但可从不同线程释放)。信号量维护一个计数,acquire 减一(为零则阻塞)、release 加一(唤醒等待者)。

适用"限制并发数"的场景:连接池限流、资源配额、生产者-消费者计数。与互斥的区别:互斥强调"独占访问",信号量强调"许可计数"——同一时刻可有 N 个持有者。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <semaphore>

std::counting_semaphore<5> sem(5); // 最多 5 个许可(5 个线程可同时进入)

void worker() {
sem.acquire(); // 获取许可(计数减一,为零则阻塞)
// 临界区
sem.release(); // 释放许可(计数加一,唤醒等待者)
}

// 二进制信号量
std::binary_semaphore bs(1);
std::binary_semaphore bs2(0); // 初始为 0,常用于线程启动同步

模板参数是"最大计数"(编译期上限),构造参数是初始计数。binary_semaphorecounting_semaphore<1>。与 mutex 的关键区别:信号量可由非持有者释放(任何线程都能 release),而互斥只能由持有者解锁——这让信号量适合"通知"语义而非"互斥"语义。

Barriers

std::barrier 是阶段同步原语:N 个线程到达屏障后全部放行进入下一阶段,可配一个"阶段完成回调"(最后一个到达的线程执行)。适合分阶段并行计算——各线程完成一阶段后同步,再开始下一阶段。

latch 的区别:barrier 可重用(每阶段重置计数),latch 一次性。barrier 的阶段回调常用于交换/归约各线程的中间结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <barrier>

auto phase1 = [] { /* 第一阶段工作 */ };
auto phase2 = [] { /* 第二阶段工作 */ };

std::barrier barrier(2, [] { /* 阶段完成回调:最后一个到达的线程执行 */ });

std::thread t1([&] {
phase1();
barrier.arrive_and_wait(); // 等 t2 也完成 phase1
phase2();
});

std::thread t2([&] {
phase1();
barrier.arrive_and_wait(); // 等 t1 也完成 phase1
phase2();
});

arrive_and_wait() 等价于 arrive() + wait()——递减计数并阻塞直到归零。arrive_and_drop() 递减并永久退出(用于线程数随阶段减少)。阶段回调在每阶段结束时被最后一个到达的线程调用一次,适合做阶段间的归约/广播。

Latches

std::latch 是一次性倒计数同步原语:初始化为 N,线程完成时 count_down,主线程 wait 直到计数归零。适合"等待一组一次性任务完成"——如初始化阶段等待所有工作线程就绪。

barrier 的区别:latch 一次性、不可重用、任意线程可 count_down(不必 wait)。barrier 可重用、要求线程配对。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <latch>

std::latch workDone(3); // 3 个工作线程

void worker(int id) {
// 做工作
workDone.count_down(); // 标记完成(计数减一)
}

void master() {
// 等待所有工作完成
workDone.wait();

// 继续,所有 worker 已就绪
}

count_down(n) 一次减 n,wait() 阻塞到归零,try_wait() 非阻塞查询,arrive_and_wait() 减一后等归零。典型用途:启动期等待所有工作线程初始化完成、并行计算的汇合点。一次性——归零后无法重置。

Atomic wait/notify

C++20 给原子变量加了 wait/notify_one/notify_all,实现高效的"等待值变化"——线程阻塞直到原子变量值改变且被通知。这取代了用条件变量+互斥量轮询的开销模式,原子变量的等待无需互斥量。

wait(old) 阻塞直到当前值不等于 old 且被 notify 唤醒;notify_one/notify_all 唤醒等待者。注意 wait 内部可能有伪唤醒,但标准保证语义正确(值未变时会重新阻塞)。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <atomic>

std::atomic<int> counter{0};

// 等待直到值改变(wait 返回 void,需循环检查以处理伪唤醒)
int oldValue = counter.load();
while (counter.load() == oldValue) {
counter.wait(oldValue); // 阻塞直到 notify 且值可能已变
}

// 通知所有等待的线程
counter.store(1);
counter.notify_all();

wait(old) 的语义:若当前值等于 old 则阻塞,否则立即返回。返回后需重新检查值(处理伪唤醒与已变)。notify_one 唤醒一个、notify_all 唤醒全部。这种"原子 + 等待/通知"比条件变量轻量(无互斥量、无锁),适合无锁数据结构的同步(如 atomic<shared_ptr> 链表)。(原文 wait 返回值比较的写法有误,wait 返回 void,已改为循环检查。)

std::atomic<std::shared_ptr>

C++20 特化了 std::atomicshared_ptr 的支持,让共享指针的原子加载/存储/CAS 成为类型安全的一等操作。此前要用 std::atomic_load(&sharedPtr) 自由函数(已废弃),易错且不封装。

典型用途:无锁并发数据结构,如无锁栈/链表头指针的原子更新——多个线程 CAS 修改 headshared_ptr 自动管理节点生命周期。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <atomic>
#include <memory>

struct Node {
int value;
std::atomic<std::shared_ptr<Node>> next;
};

std::atomic<std::shared_ptr<Node>> head;

// 原子地读取
std::shared_ptr<Node> current = head.load();

// 原子地 CAS:把 head 从 expected 换成 newNode
std::shared_ptr<Node> newNode = std::make_shared<Node>();
std::shared_ptr<Node> expected = head.load();
do {
newNode->next.store(expected);
} while (!head.compare_exchange_weak(expected, newNode));
// CAS 失败时 expected 被更新为 head 的当前值,重试

compare_exchange_weak/strong 在失败时把 expected 更新为当前值,便于循环重试(weak 可伪失败,适合循环)。atomic<shared_ptr> 内部通常用双重 CAS(指针+控制块)保证原子性。注意 ABA 问题——shared_ptr 的控制块计数能在一定程度上缓解 ABA,但复杂场景仍需谨慎。

常用特性总结

最常用的 C++20 特性:

  • Concepts(概念)
  • Ranges(范围库)
  • 三路比较运算符(<=>
  • std::format
  • std::span
  • contains 成员函数
  • starts_with / ends_with
  • std::jthread