@@ -1219,58 +1219,146 @@ int main() {
1219
1219
1220
1220
这段代码很简单,以至于我们可以在这里来再说一条概念:
1221
1221
1222
- - ` counting_semaphore ` 是一个轻量同步原语,能控制对共享资源的访问。不同于 [ std::mutex] ( https://zh.cppreference.com/w/cpp/thread/mutex ) ,` counting_semaphore ` ** 允许同一资源进行多个并发的访问** ,至少允许 ` LeastMaxValue ` 个同时的访问者 。
1222
+ - ` counting_semaphore ` 是一个轻量同步原语,能控制对共享资源的访问。不同于 [ std::mutex] ( https://zh.cppreference.com/w/cpp/thread/mutex ) ,` counting_semaphore ` ** 允许同一资源进行多个并发的访问** ,*** 至少 ** 允许 ` LeastMaxValue ` 个同时访问者 * [ ^ 5 ] 。
1223
1223
- ` binary_semaphore ` 是 ` std::counting_semaphore ` 的特化的别名,其 ` LeastMaxValue ` 为 1。
1224
1224
1225
- ` LeastMaxValue ` 是我们设置的非类型模板参数,意思是信号量维护的计数最大值。我们这段代码设置的是 ` 3 ` ,也就是允许 3 个同时访问者。事实上我们的代码就是这样做的。
1225
+ ` LeastMaxValue ` 是我们设置的非类型模板参数,意思是信号量维护的** 计数最大值** 。我们这段代码设置的是 ` 3 ` ,也就是允许 3 个同时访问者。
1226
+
1227
+ > 虽然说是说有 LeastMaxValue 可能不是最大,但是我们通常不用在意这个事情,[ MSVC STL 的实现] ( https://github.com/microsoft/STL/blob/697653d/stl/inc/semaphore#L63-L65 ) 中 max 函数就是直接返回 ` LeastMaxValue ` ,将它视为信号量维护的计数最大值即可。
1226
1228
1227
1229
牢记信号量的基本的概念不变,计数的值不能小于 ` 0 ` ,如果当前信号量的计数值为 ` 0 ` ,那么执行“*** 等待*** ”(acquire)操作的线程将会** 一直阻塞** 。明白这点,那么就都不存在问题。
1228
1230
1229
1231
通过这种方式,可以有效控制 Web 服务器处理并发请求的数量,防止服务器过载。
1230
1232
1231
1233
[ ^ 4 ] :注:** 如果信号量只有二进制的 0 或 1,称为二进制信号量(binary semaphore)** ,这就是这个类型名字的由来。
1232
1234
1233
- ## C++20 闩与屏障
1235
+ [ ^ 5 ] :注:如其名所示,LeastMaxValue 是** 最小 的最大值** ,而** 非实际 最大值** 。静态成员函数 [ ` max() ` ] ( https://zh.cppreference.com/w/cpp/thread/counting_semaphore/max ) 可能产生大于 LeastMaxValue 的值。
1236
+
1237
+ ## C++20 闩与屏障
1234
1238
1235
1239
闩 (latch) 与屏障 (barrier) 是线程协调机制,允许任何数量的线程阻塞** 直至期待数量的线程到达** 。闩不能重复使用,而屏障则可以。
1236
1240
1237
1241
- ** ` std::latch ` :单次使用的线程屏障**
1238
1242
- ** ` std::barrier ` :可复用的线程屏障**
1239
1243
1240
- 它们定义在标头 ** ` <latch> ` ** 。
1244
+ 它们定义在标头 ** ` <latch> ` ** 与 ** ` <barrier> ` ** 。
1241
1245
1242
1246
与信号量类似,屏障也是一种古老而广泛应用的同步机制。许多系统 API 提供了对屏障机制的支持,例如 POSIX 和 Win32。此外,[ OpenMP] ( https://learn.microsoft.com/zh-cn/cpp/parallel/openmp/2-directives?view=msvc-170#263-barrier-directive ) 也提供了屏障机制来支持多线程编程。
1243
1247
1244
1248
### ` std::latch `
1245
1249
1246
- “闩”,这个字其实个人觉得是不常见,“** 门闩** ” 是指们背后用来关门的棍子 。好了好了,不用在意,在 C++ 中就是先前说的:* 单次使用的线程屏障* 。
1250
+ “闩”,这个字其实个人觉得是不常见,“** 门闩** ” 是指门背后用来关门的棍子 。好了好了,不用在意,在 C++ 中就是先前说的:* 单次使用的线程屏障* 。
1247
1251
1248
- ` latch ` 类维护着一个 [ ` std::ptrdiff_t ` ] ( https://zh.cppreference.com/w/cpp/types/ptrdiff_t ) 类型的计数[ ^ 5 ] ,且只能** 减少** 计数,** 无法增加计数** 。在创建对象的时候初始化计数器的值。线程可以阻塞,直到 latch 对象的计数减少到零。由于无法增加计数,这使得 ` latch ` 成为一种** 单次使用的屏障** 。
1252
+ ` latch ` 类维护着一个 [ ` std::ptrdiff_t ` ] ( https://zh.cppreference.com/w/cpp/types/ptrdiff_t ) 类型的计数[ ^ 6 ] ,且只能** 减少** 计数,** 无法增加计数** 。在创建对象的时候初始化计数器的值。线程可以阻塞,直到 latch 对象的计数减少到零。由于无法增加计数,这使得 ` latch ` 成为一种** 单次使用的屏障** 。
1249
1253
1250
1254
``` cpp
1251
- std::latch work_done { 3 };
1255
+ std::latch work_start { 3 };
1252
1256
1253
1257
void work(){
1254
1258
std::cout << "等待其它线程执行\n";
1255
- work_done .wait(); // 等待计数为 0
1259
+ work_start .wait(); // 等待计数为 0
1256
1260
std::cout << "任务开始执行\n";
1257
1261
}
1262
+
1258
1263
int main (){
1259
1264
std::jthread thread{ work };
1260
1265
std::this_thread::sleep_for (3s);
1261
- work_done.count_down(); // 默认值是 1
1262
- work_done.count_down(2); // 传递参数 减少计数 2
1266
+ std::cout << "休眠结束\n";
1267
+ work_start.count_down(); // 默认值是 1 减少计数 1
1268
+ work_start.count_down(2); // 传递参数 2 减少计数 2
1269
+ }
1270
+ ```
1271
+
1272
+ [ ** 运行结果** ] ( https://godbolt.org/z/sEhMeraYs ) :
1273
+
1274
+ ``` txt
1275
+ 等待其它线程执行
1276
+ 休眠结束
1277
+ 任务开始执行
1278
+ ```
1279
+
1280
+ 在这个例子中,通过调用 ` wait ` 函数阻塞子线程,直到主线程调用 ` count_down ` 函数原子地将计数减至 ` 0 ` ,从而解除阻塞。这个例子清楚地展示了 ` latch ` 的使用,其逻辑比信号量更简单。
1281
+
1282
+ ---
1283
+
1284
+ 由于 ` latch ` 的计数不可增加,它的使用通常非常简单,可以用来划分任务执行的工作区间。例如:
1285
+
1286
+ ``` cpp
1287
+ std::latch latch{ 10 };
1288
+
1289
+ void f(int id) {
1290
+ //todo.. 脑补任务
1291
+ std::this_thread::sleep_for(1s);
1292
+ std::cout << std::format("线程 {} 执行完任务,开始等待其它线程执行到此处\n", id);
1293
+ latch.arrive_and_wait();
1294
+ std::cout << std::format("线程 {} 彻底退出函数\n", id);
1295
+ }
1296
+
1297
+ int main () {
1298
+ std::vector<std::jthread> threads;
1299
+ for (int i = 0; i < 10; ++i) {
1300
+ threads.emplace_back(f,i);
1301
+ }
1263
1302
}
1264
1303
```
1265
1304
1266
- > [ 运行] ( https://godbolt.org/z/Trhh9jdbf ) 测试。
1305
+ > [ 运行] ( https://godbolt.org/z/KKjdWWKdq ) 测试。
1306
+
1307
+ [ ` arrive_and_wait ` ] ( https://zh.cppreference.com/w/cpp/thread/latch/arrive_and_wait ) 函数等价于:` count_down(n); wait(); ` 。也就是减少计数 + 等待。这意味着
1267
1308
1268
- 通过调用 ` wait ` 函数阻塞子线程,直到主线程调用 ` count_down ` 函数原子地将计数减至 ` 0 ` ,得以解除阻塞。相信这个例子就能很清楚的展示 ` latch ` 的使用,它的逻辑比信号量还要简单 。
1309
+ 必须等待所有线程执行到 ` latch.arrive_and_wait(); ` 将 latch 的计数减少至 ` 0 ` 才能继续往下执行。这个示例非常直观地展示了如何使用 ` latch ` 来划分任务执行的工作区间 。
1269
1310
1270
- [ ^ 5 ] : 注:通常的[ 实现] ( https://github.com/microsoft/STL/blob/939513b/stl/inc/latch#L88 ) 是直接保有一个 ` std::atomic<std::ptrdiff_t> ` 私有数据成员,以保证计数修改的原子性。原子类型在我们第五章的内容会详细展开。
1311
+ 由于 ` latch ` 的功能受限,通常用于简单直接的需求,不少情况很多同步设施都能完成你的需求,在这个时候请考虑** 使用尽可能功能最少的那一个** 。
1312
+
1313
+ - 使用功能尽可能少的设施有助于开发者阅读代码理解含义。如果使用的是一个功能丰富的设施,可能就无法直接猜测其意图。
1314
+
1315
+ [ ^ 6 ] : 注:通常的[ 实现] ( https://github.com/microsoft/STL/blob/939513b/stl/inc/latch#L88 ) 是直接保有一个 ` std::atomic<std::ptrdiff_t> ` 私有数据成员,以保证计数修改的原子性。原子类型在我们第五章的内容会详细展开。
1271
1316
1272
1317
### ` std::barrier `
1273
1318
1319
+ 上节我们学习了 ` std::latch ` ,本节内容也不会对你构成难度。
1320
+
1321
+ [ ` std::barrier ` ] ( https://zh.cppreference.com/w/cpp/thread/barrier ) 和 ` std::latch ` 最大的不同是,前者可以在阶段完成之后将计数重置为构造时传递的值,而后者只能减少计数。我们用一个非常简单直观的示例为你展示:
1322
+
1323
+ ``` cpp
1324
+ std::barrier barrier{ 10,
1325
+ [ n = 1] ( ) mutable noexcept {std::cout << "\t第" << n++ << "轮结束\n"; }
1326
+ };
1327
+
1328
+ void f (int start, int end){
1329
+ for (int i = start; i <= end; ++i) {
1330
+ std::osyncstream{ std::cout } << i << ' ';
1331
+ barrier.arrive_and_wait(); // 减少计数并等待 解除阻塞时就重置计数并调用函数对象
1332
+
1333
+ std::this_thread::sleep_for(300ms);
1334
+ }
1335
+ }
1336
+
1337
+ int main(){
1338
+ std::vector< std::jthread > threads;
1339
+ for (int i = 0; i < 10; ++i) {
1340
+ threads.emplace_back(f, i * 10 + 1, (i + 1) * 10);
1341
+ }
1342
+ }
1343
+ ```
1344
+
1345
+ **可能的[运行结果](https://godbolt.org/z/9Tnsz537e)**:
1346
+
1347
+ ```txt
1348
+ 1 21 11 31 41 51 61 71 81 91 第1轮结束
1349
+ 12 2 22 32 42 52 62 72 92 82 第2轮结束
1350
+ 13 63 73 33 23 53 83 93 43 3 第3轮结束
1351
+ 14 44 24 34 94 74 64 4 84 54 第4轮结束
1352
+ 5 95 15 45 75 25 55 65 35 85 第5轮结束
1353
+ 6 46 16 26 56 96 86 66 76 36 第6轮结束
1354
+ 47 17 57 97 87 67 77 7 27 37 第7轮结束
1355
+ 38 8 28 78 68 88 98 58 18 48 第8轮结束
1356
+ 9 39 29 69 89 99 59 19 79 49 第9轮结束
1357
+ 30 40 70 10 90 50 60 20 80 100 第10轮结束
1358
+ ```
1359
+
1360
+ 注意输出的规律,第一轮每个数字最后一位都是 ` 1 ` ,第二轮每个数字最后一位都是 ` 2 ` ……以此类推,因为我们分配给每个线程的输出任务就是如此,然后利用了屏障一轮一轮地打印。
1361
+
1274
1362
## 总结
1275
1363
1276
1364
在并发编程中,同步操作对于并发编程至关重要。如果没有同步,线程基本上就是独立的,因其任务之间的相关性,才可作为一个整体执行(比如第二章的并行求和)。本章讨论了多种用于同步操作的工具,包括条件变量、future、promise、package_task、信号量。同时,详细介绍了 C++ 时间库的知识,以使用并发支持库中的“限时等待”。还使用 CMake + Qt 构建了一个带有 UI 界面的示例,展示异步多线程的** 必要性** 。
0 commit comments