Skip to content

Commit 31712e4

Browse files
committed
1. 修改第三章,线程存储期的内容。修改措辞,进行一些补充,强调 MSVC 的 std::async 存在的问题
2. 新建一节 CPU 变量,与全局变量、线程变量一起总结,阐述其在多线程并发中的作用与不同(创建标题) 3. 修改第五章,基本完成 `std::atomic<shared_ptr>` 的所有内容 Mq-b#12
1 parent 9275631 commit 31712e4

File tree

2 files changed

+103
-4
lines changed

2 files changed

+103
-4
lines changed

md/03共享数据.md

+39-1
Original file line numberDiff line numberDiff line change
@@ -900,7 +900,7 @@ C++ 只保证了 `operator new`、`operator delete` 这两个方面的线程安
900900
901901
## 线程存储期
902902
903-
**线程存储期**(也有人喜欢称作“[*线程局部存储*](https://zh.wikipedia.org/wiki/%E7%BA%BF%E7%A8%8B%E5%B1%80%E9%83%A8%E5%AD%98%E5%82%A8)”)的概念源自操作系统,是一种非常古老的机制,广泛应用于各种编程语言。线程存储期的对象在线程开始时分配,并在线程结束时释放。每个线程拥有自己独立的对象实例,互不干扰。在 C++11中,引入了[**`thread_local`**](https://zh.cppreference.com/w/cpp/keyword/thread_local)关键字,用于声明具有线程存储期的对象。
903+
**线程存储期**(也有人喜欢称作“[*线程局部存储*](https://zh.wikipedia.org/wiki/%E7%BA%BF%E7%A8%8B%E5%B1%80%E9%83%A8%E5%AD%98%E5%82%A8)”)的概念源自操作系统,是一种非常古老的机制,广泛应用于各种编程语言。线程存储期的对象在线程开始时分配,并在线程结束时释放。每个线程拥有自己独立的对象实例,互不干扰。在 C++11中,引入了[**`thread_local`**](https://zh.cppreference.com/w/cpp/keyword/thread_local)关键字,用于声明具有线程存储期的对象。不少开发者喜欢直接将声明为线程存储期的对象称为:**线程变量**;也与**全局变量****CPU 变量**,在一起讨论。
904904
905905
以下是一段示例代码,展示了 `thread_local` 关键字的使用:
906906
@@ -955,6 +955,44 @@ MSVC 无法使用 GCC 的编译器扩展,GCC 也肯定无法使用 MSVC 的扩
955955
956956
了解其它 API 以及编译器扩展有助于理解历史上线程存储期的演进。同时扩展知识面。
957957
958+
> ### 注意事项
959+
>
960+
> 需要注意的是,在 MSVC 的实现中,`std::async` 策略为 [`launch::async`](https://zh.cppreference.com/w/cpp/thread/launch) 并不是每次都创建一个新的线程,而是从线程池获取线程。**这意味着无法保证线程局部变量在任务完成时会被销毁**。如果线程被回收并用于新的 `std::async` 调用,则旧的线程局部变量仍然存在。因此,**建议不要将线程局部变量与 `std::async` 一起使用**。[文档](https://learn.microsoft.com/zh-cn/cpp/standard-library/future-functions?view=msvc-170)。
961+
>
962+
> 虽然还没有讲 `std::async` ,不过还是可以注意一下这个问题,我们用一个简单的示例为你展示:
963+
>
964+
> ```cpp
965+
> int n = 0;
966+
>
967+
> struct X {
968+
> ~X() { ++n; }
969+
> };
970+
>
971+
> thread_local X x{};
972+
>
973+
> void use_thread_local_x() {
974+
> // 如果不写此弃值表达式,那么在 Gcc 与 Clang 编译此代码,会优化掉 x
975+
> (void)x;
976+
> }
977+
>
978+
> int main() {
979+
> std::cout << "使用 std::thread: \n";
980+
> std::thread{ use_thread_local_x }.join();
981+
> std::cout << n << '\n';
982+
>
983+
> std::cout << "使用 std::async: \n";
984+
> std::async(std::launch::async, use_thread_local_x);
985+
> std::cout << n << '\n';
986+
> }
987+
> ```
988+
>
989+
> 在不同编译器上的输出结果:
990+
>
991+
> - **Linux/Windows GCC、Clang**:会输出 `1`、`2`,因为线程变量 `x` 在每个任务中被正确销毁析构。
992+
> - **Windows MSVC**:会输出 `1`、`1`,因为线程被线程池复用,线程依然活跃,线程变量 x 也还未释放。
993+
994+
## CPU 变量
995+
958996
## 总结
959997
960998
本章讨论了多线程的共享数据引发的恶性条件竞争会带来的问题。并说明了可以使用互斥量(`std::mutex`)保护共享数据,并且要注意互斥量上锁的“**粒度**”。C++标准库提供了很多工具,包括管理互斥量的管理类(`std::lock_guard`),但是互斥量只能解决它能解决的问题,并且它有自己的问题(**死锁**)。同时我们讲述了一些避免死锁的方法和技术。还讲了一下互斥量所有权转移。然后讨论了面对不同情况保护共享数据的不同方式,使用 `std::call_once()` 保护共享数据的初始化过程,使用读写锁(`std::shared_mutex`)保护不常更新的数据结构。以及特殊情况可能用到的互斥量 `recursive_mutex`,有些人可能喜欢称作:**递归锁**。最后聊了一下 `new`、`delete` 运算符的库函数实际是线程安全的,以及线程存储期。

md/05内存模型与原子操作.md

+64-3
Original file line numberDiff line numberDiff line change
@@ -478,11 +478,41 @@ int main(){
478478
479479
2. 任一线程使用 shared_ptr 的**非 const** 成员函数
480480
481-
---
481+
使用 `std::atomic<shared_ptr>` 修改:
482+
483+
```cpp
484+
std::atomic<std::shared_ptr<Data>> data = std::make_shared<Data>();
485+
486+
void writer() {
487+
for (int i = 0; i < 10; ++i) {
488+
std::shared_ptr<Data> new_data = std::make_shared<Data>(i);
489+
data.store(new_data); // 原子地替换所保有的值
490+
std::this_thread::sleep_for(10ms);
491+
}
492+
}
493+
494+
void reader() {
495+
for (int i = 0; i < 10; ++i) {
496+
if (data.load()) {
497+
std::cout << "读取线程值: " << data.load()->get_value() << std::endl;
498+
}
499+
else {
500+
std::cout << "没有读取到数据" << std::endl;
501+
}
502+
std::this_thread::sleep_for(10ms);
503+
}
504+
}
505+
```
506+
507+
很显然,这是线程安全的,`store` 是原子操作,而 `data.load()->get_value()` 只是个读取操作。
482508

483-
不过事实上 `std::atomic<std::shared_ptr>` 的功能相当有限,单看它提供的修改接口(`=`、`store`、`load`、`exchang`)就能明白。如果要操作其保护的共享指针指向的资源还是得 `load()` 获取底层共享指针的副本。
509+
我知道,你肯定会想着:*能不能调用 `load()` 成员函数原子地返回底层的 `std::shared_ptr` 再调用 `swap` 成员函数?*
484510

511+
可以,但是**没有意义**,因为 `load()` 成员函数返回的是底层 `std::shared_ptr`**副本**,也就是一个临时对象。对这个临时对象调用 `swap` 并不会改变 `data` 本身的值,因此这种操作没有实际意义,尽管这不会引发数据竞争(因为是副本)。
485512

513+
由于我们没有对读写操作进行同步,只是确保了操作的线程安全,所以多次运行时可能会看到一些无序的打印,这是正常的。
514+
515+
不过事实上 `std::atomic<std::shared_ptr>` 的功能相当有限,单看它提供的修改接口(`=``store``load``exchang`)就能明白。如果要操作其保护的共享指针指向的资源还是得 `load()` 获取底层共享指针的副本。此时再进行操作时就得考虑 `std::shared_ptr` 本身在多线程的支持了。
486516

487517
---
488518

@@ -501,7 +531,7 @@ std::atomic<std::shared_ptr<int>> ptr = std::make_shared<int>(10);
501531
2. 解引用,等价 `*get()`,返回了 `int&`
502532
3. 直接修改这个引用所指向的资源。
503533

504-
在第一步时,已经脱离了 `std::atomic` 的保护,第二步就获取了被保护的数据的引用,第三步进行了修改,这导致了数据竞争。当然了,这是做法非常的愚蠢,只是为了表示,所谓的线程安全,也是要靠**开发者的正确使用**
534+
在第一步时,已经脱离了 `std::atomic` 的保护,第二步就获取了被保护的数据的引用,第三步进行了修改,这导致了数据竞争。当然了,这种做法非常的愚蠢,只是为了表示,所谓的线程安全,也是要靠**开发者的正确使用**
505535

506536
正确的用法如下:
507537

@@ -512,6 +542,37 @@ ptr.store(std::make_shared<int>(100));
512542

513543
通过使用 `store` 成员函数,可以原子地替换 `ptr` 所保护的值。
514544

545+
---
546+
547+
最后再来稍微聊一聊提供的 `wait``notify_one``notify_all` 成员函数。这并非是 `std::atomic<shared_ptr>` 专属,C++20 以后任何 atomic 的特化都拥有这些成员函数,使用起来也都十分的简单,我们这里用一个简单的例子为你展示一下:
548+
549+
```cpp
550+
std::atomic<std::shared_ptr<int>> ptr = std::make_shared<int>();
551+
552+
void wait_for_wake_up(){
553+
std::osyncstream{ std::cout }
554+
<< "线程 "
555+
<< std::this_thread::get_id()
556+
<< " 阻塞,等待更新唤醒\n";
557+
558+
// 等待 ptr 变为其它值
559+
ptr.wait(ptr.load());
560+
561+
std::osyncstream{ std::cout }
562+
<< "线程 "
563+
<< std::this_thread::get_id()
564+
<< " 已被唤醒\n";
565+
}
566+
567+
void wake_up(){
568+
std::this_thread::sleep_for(5s);
569+
570+
// 更新值并唤醒
571+
ptr.store(std::make_shared<int>(10));
572+
ptr.notify_one();
573+
}
574+
```
575+
515576
## 内存次序
516577

517578
### 前言

0 commit comments

Comments
 (0)