《C++ Concurrency in Action - SECOND EDITION》笔记

第一章 你好,C++的并发世界

进程并发的优点:

  • 依赖于操作系统提供的通信手段,可以有效地保护进程不被其他进程访问和修改,操作系统提供了保护操作和更高级别的通信机制。
  • 可以实现不同主机之间的并发(网络通信)

进程并发的缺点:

  • 进程占用资源更多,启动更慢,通信手段复杂或者低效。
  • C++没有提供对多进程的支持,依赖操作系统,不方便跨平台

线程并发的优点:

  • 内存共享,方便地进行通信
  • 资源消耗小,启动更快
  • c++支持,所以可以编写跨平台代码

线程并发的缺点:

  • 线程的共享管理更复杂,需要良好的维护
  • 无法跨主机
1
std::thread my_thread(background_task());

注意这个语法可能会被解析成为函数声明,因此可以用my_thread{backgroud_task())}等。被称为最令人头痛的语法解析

注意一旦声明了一个线程,就必须要在thread被销毁之前决定是join还是detach。否则的话当thread被销毁的时候会调用terminal 杀死进程。注意杀死的不是什么别的东西,杀死的是进程本身。所以主线程和子线程都死了!

而且注意到一个很奇怪的事实就是,如果main函数退出的话,所有线程也被杀死了。看起来main函数退出是进程退出,资源回收(进程是资源分配的单位,线程也是资源),所以所有的线程也要退出。

join 加入,等到线程完成之后再退出。要注意如果主线程异常的话,join会被跳过。

下面的一些代码展示如何避免这个问题,就是捕捉异常之后要及时地决定线程join或者detach。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct func; // 定义在清单2.1中
void f()
{
int some_local_state=0;
func my_func(some_local_state);
std::thread t(my_func);
try
{
do_something_in_current_thread();
}
catch(...)
{
t.join(); // 1
throw;
}
t.join(); // 2
}

根据异常之后会自动进行析构函数销毁变量,可以重定义thread,让他在析构的时候首先join。

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
class thread_guard
{
std::thread& t;
public:
explicit thread_guard(std::thread& t_):
t(t_)
{}
~thread_guard()
{
if(t.joinable()) // 1
{
t.join(); // 2
}
}
thread_guard(thread_guard const&)=delete; // 3 禁止拷贝或者赋值,是很危险的
thread_guard& operator=(thread_guard const&)=delete;
};

struct func; // 定义在清单2.1中

voidf()
{
int some_local_state=0;
func my_func(some_local_state);
std::thread t(my_func);
thread_guard g(t);
do_something_in_current_thread();
} // 4

detach,分离,一个thread对象调用detach,表示把线程和这个对象分离开,主要是让线程在后台运行。thread被销毁不会影响到他了。注意线程如果引用了临时变量,那么当主线程销毁的时候,该临时变量也可能变得未定义了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct func
{
int& i;
func(int& i_) : i(i_) {}
voidoperator()()
{
for (unsigned j=0 ; j<1000000 ; ++j)
{
do_something(i); // 1 潜在访问隐患:悬空引用
}
}
};

void oops()
{
int some_local_state=0;
func my_func(some_local_state);
std::thread my_thread(my_func);
my_thread.detach(); // 2 不等待线程结束
} // 3 新线程可能还在运行

注意看上面这段代码,some_local_state是局部变量,并且直接被my_func使用。当采用detach策略,那么当oops运行完毕的时候,这个变量就会销毁,从而my_func访问了未定义变量。

【引用的底层实现机制,为什么std::ref()能够提供引用,为什么thread传参需要复制而非引用(可能是无视函数的需求)】

1
2
3
4
5
6
7
class X
{
public:
void do_lengthy_work();
};
X my_x;
std::thread t(&X::do_lengthy_work,&my_x); // 1

注意看这段文字的初始化方法,第一个参数实际上不是对象的函数,而是类的函数,而第二个参数提供了对象的起始地址,这个方法应该等价于t(my_x.do_lengthy_work))

线程可以移动,但不能复制,所以同一时间线程只能和一个thread关联,但可以在不同thread之间移动。

1
2
3
4
5
6
7
8
void some_function();
void some_other_function();
std::thread t1(some_function); // 1
std::thread t2=std::move(t1); // 2
t1=std::thread(some_other_function); // 3
std::thread t3; // 4
t3=std::move(t2); // 5
t1=std::move(t3); // 6 赋值操作将使程序崩溃

看上面这段例子,t2初始化的对象是t1,t1作为thread可移动但不能复制。所以如果直接用=则是复制操作会崩溃。而3位置是可以的,因为右边是临时对象,移动操作将会隐式的调用。就是移动赋值。

t3可以直接赋值,但是t1不行,因为t1有线程,所以直接调用terminal终止了进程。不能通过赋值的方式丢弃进程,只能detach,或者join等待线程完成,然后t1才能空下来,这个时候就可以赋值了。

【std::thread对象的容器,如果这个容器是移动敏感的(比如,标准中的 std::vector<>),那么移动操作同样适用于这些容器。】

标识符是std::thread::id类型,注意不同于int,尽管cout可以输出int。可以拷贝和对比,因为可以复用。

第三章

当其中一个成员函数返回的是保护数据的指针或引用时,会破坏数据。具有访问能力的指针或引用可以访问(并可能修改)被保护的数据,而不会被互斥锁限制。这就需要对接口有相当谨慎的设计,要确保互斥量能锁住数据的访问,并且不留后门。

接口之间可能存在竞争的关系,所谓接口就是开放给外部访问的数据?

比如,一个stack,size() empty 的返回结果是没问题的,但是是不可靠的,因为可能存在有其他竞争在其返回后访问stack。

一些重要的类含义记录:

lock_guard:这个类就是对某个互斥量的上锁, 同时在该对象被销毁的时候解锁。

unique_lock:这个类就是lock_guard的加强版,可以加第二个参数,要不要上锁。因为可以调用lock()函数统一上锁。这个函数需要参数都有lock() unlock() try_lock()三个参数。

shared_ptr:就是对某块堆上的数据进行智能的监控,当引用为0的时候自动销毁。比如说

1
shared_ptr<int> p = new int();

对于这样的一句话,根据下图所示,首先调用new,生成了widget,然后shared_ptr会生成控制块也就是中间的,包括指针,被引用的次数等。

但是shared_ptrs都是强引用的,那么会有循环引用的问题:

比如说这个图中,当我退出程序了,那么Sp1和Sp2都被销毁了,这没啥问题,但是问题是他们指向的对象不会被销毁,因为他们互相引用。需要用weak_ptr在节点内部的指针。

make_shared:这个是专门用来制作shared_ptr的,但是不同于上文中shared_ptr p = new int(),这里会发生两次alloc。通过make_shared可以将其减少为1次。

比如说make_shared (move(0))这样,会在分配内存的时候,将控制块和数据内容分配在一起。

所以实质上所谓的shared_ptr不过是个伪指针罢了。make_shared的好处在于,减少一次alloc开销,而这对性能提升是很大的,其次,占的内存更小,因为减少了大小跟踪的分配器开销,还有增强了数据的局部性,这两个块很可能是在同一个页面或者同一缓冲行。但是带来的问题就在于,由于这两个块分配在了一起,所以必须要等到weak_ptr被释放之后,那一整个块才能被释放(因为make_shared生成的是一整个块)。但是如果是两次分配的话,强指针为0的时候,对应的数据块就被释放了,等到弱指针释放之后,控制块被释放。

wait函数详解

这个函数支持一个锁和一个函数为参数。首先会调用这个函数,如果是true,那么就继续执行,如果是false,那么会释放前面的锁,注意,这个锁在传入的时候应该是锁上的。释放之后,进入睡眠状态(为什么要释放呢?因为往往是因为没有东西可以消费,所以阻塞在这里,要释放锁让其他线程从事生产)等到notice_one的时候,再唤醒,然后加锁,然后调用函数,重复上述过程。


《C++ Concurrency in Action - SECOND EDITION》笔记
https://refrain-wbh.github.io/2022/09/17/《C-Concurrency-in-Action-SECOND-EDITION》笔记/
作者
流雨溪
发布于
2022年9月17日
许可协议