写在开头:本书中的许多代码过于复杂,使用了C++中较新的特性等。
关于多线程一些名词的概念在链接: link.第十一章
第一章
多线程的之始:
包含<thread>头文件.
std::thread t(A_func);
// 声明一个线程对象,之后这个线程执行函数A_func()
第二章 线程对象的基本操作
1.概:
若声明线程对象后,需要显示调用结合或者分离。如果在对象超出作用域析构时未执行这两个操作中其中一个,那么程序会被终止。
若无明确说明,本章中所有的t都代表一个线程对象。
PS:一个小问题,若在声明线程对象后,结合或分离之前发生异常,那么异常导致栈解退,线程对象在没有被结合或分离前就被析构,那么导致程序结束。不过可以使用RAII来解决,类似标准库中的智能指针一样,在线程对象超出作用域时析构时调用join()。当然,未处理的异常也会导致程序结束。
2.结合还是分离:
结合(同步等待):t.join(),如此调用后,在当前进程等待t所指示的线程执行完毕。
分离(异步执行):t.detach(),如此调用后,t所指的线程像守护进程一样,被抛到后台执行。
不管结合还是分离,都只能执行其中一个操作,结合了就不能分离,分离了就不能结合。关于结合操作,最好在结合前执行t.joinable()
,来判断线程是否能被结合。
3.传递参数给线程函数的操作和两个问题:
类成员函数启动线程:
class X{ public: void something(int a) };
X my_x;
std::thread t2(&X::something, &my_x, 123);
普通函数启动线程:
void f(int i, std::string const & s);
std::thread t1(f, 3, "hello");
第一个问题:
当字符串"hello"存储char buffer[10]中时,调用std::thread t1(f, 3, buffer);
会导致使用buffer的数据构造一个临时string对象交给线程引用,但是线程可能并没有及时启动,buffer也只是缓存中等待被构成string,若buffer是一个局部数组,那么很有可能发生函数退出,buffer被释放,线程又没有及时启动,在缓存中持有的buffer指针成为了野指针,此刻线程被启动,野指针buffer构造string对象,将导致未定义行为。
解决方法:调用thread之前,函数的参数总是先转型为合适的类型,再声明线程对象。如:std::thread t1(f, 3, std::string(buffer));
,先生成string的临时对象,就不会导致持有buffer的空指针。
第二个问题:
虽然函数f(int, std::string const&)
的第二参数是一个引用,但是thread的行为是:总是对传递过来的参数生成副本,供线程操作,不会影响到原值。
解决方法:std::ref();。std::thread t1( f, 3, std::ref(某个string对象) );
4.转移线程的所有权(句柄传递):
线程对象需要作为参数传递,线程对象被返回的情况。
当一个线程对象将要被被返回时,它是一个右值,会自动转让。
当线程对象作为参数传递,因为线程对象不能被复制,所以需要转让,f(std::move(t));
。这里使用了移动语义来传递对象。
5.线程数量的选择
通过函数std::thread::hardware_currency()
,返回硬件所支持的线程数量。
6.线程的ID
线程的ID类型为:std::thread::id
。
通过t.get_id()取得。或者通过std::this_thread::get_id()
取得当前线程的线程ID。
第三章 线程间共享数据的操作(互斥元与锁)
1.共享数据的操作通过两种大类的对象配合操作。
一种是互斥元,std:mutex、shared_mutex、recursive_mutex等等等。
一种是锁,std::lock_guard、shared_lock、std::unique_lock等等等。
互斥元和数据之间 没有直接关系,只是在使用时,应该明白某个互斥元和某组数据是相互对应的。
锁和互斥元之间是 使用关系,只是锁的行为不同,如共享锁或者独占锁,他们可以锁定同一个互斥元,只是行为上是不同的。
2.自解锁
普通的std::lock锁,需要调用lock上锁,之后又需要通过unlock解锁。这就产生了类似第二章中描述的问题:lock之后,unlock之前发生异常,永远没办法解锁,从而产生死锁。
由此产生了诸如本章第一小节中如std::lock_guard的自解锁,这些自解锁的行为同样类似于智能指针,声明局部锁对象,在超出作用域时,局部对象被析构,析构函数执行解锁操作。
但自解锁方便的同时,也可能增加了锁的粒度,除非像书中的那样,上锁之后,随后就是return语句。return语句则跟随一些函数调用
3.不要传出对受保护数据的引用或指针
许多地方采用类的形式将互斥元和数据组织在一起。
但是如果某个成员函数返回了数据的指针。那么数据就暴露在了不安全的环境下。当数据指针被传出,成员函数还在通过加锁解锁来进行操作时,外部线程可能已经通过被传出的数据指针已经修改了数据。
4.一些使用锁的例子及说明
lock_guard:
以下这段代码的行为是:首先调用lock函数对两个互斥元同时上锁,如果其中一个无法上锁,那么另外一个上锁的会被暂时解锁,然后阻塞等待直到上锁。接下来声明两个lock_guard对象,通过构造函数的第二参数std::adopt_lock
指明互斥元已被锁定,不再进行锁定,沿用互斥元上已有的所有权,最终超出作用,对象被析构,自动解锁。
{
std::mutex m1,m2;
std::lock(m1,m2);
std::lock_guard<std::mutex> lock_a(m1, std::adopt_lock);
std::lock_guard<std::mutex> lock_b(m2, std::adopt_lock);
dosomething();
}
std::unique_lock:
这里主要说明另一个参数std::defer_lock
,指定该参数后的行为是,声明锁对象时,传递的互斥元不会被加锁,之后可以随时加锁,在超出作用域时自动解锁。
转移锁对互斥元的所有权(锁不能被复制):如返回一个锁,或者以某个锁作为参数。如果转移的操作取决于unique_lock对象是左值还是右值。①当一个unique_lock对象被返回时,作为一个右值,其所有权自动被转移。②当作为参数传递时,是一个左值,使用std::move();来对所有权进行转移。
{
std::mutex m1,m2;
std::unique_lock<std::mutex> lock_a(m1, std::defer_lock);
std::unique_lock<std::mutex> lock_a(m2, std::defer_lock);
std::lock(lock_a,lock_a);
dosomething();
}
shared_lock:
c++标准库好像没有读写锁,使用share_lock、shared_mutex 和 std::lock_guard配合来实现读写锁。
在read函数中,共享锁和共享的互斥元配合,可以在多个线程中同时锁定,因为是读操作,没有竞争条件。
在write中,使用独占锁和共享的互斥元配合,独占互斥元,阻塞共享锁的锁定。
std::shared_mutex m;
void read();
{
// 书中代码上boost::shared_lock,但实际上已经在std中加入了
std::shared_lock<std::shared_mutex> read_lock(m);
READ_dosomething();
}
void write()
{
std::lock_guard<std::shared_mutex> write_lock(m);
WRITE_dosomething();
}
还有一个递归锁,书中并没有例子。
2020年8月11日15:58:23 1,2,3章
第四章 同步
1.使用条件变量实现同步
标准库提供了两个条件变量的实现:std::condition_variable
和std::condition_variable_any
,两者区别在于前者只能和普通的std::mutex
一起工作,后者则没有限制。
以下代码展示了如何使用条件变量实现线程间的同步:
在读线程中:首先声明一个锁并对互斥元自动上锁,接下来条件变量调用wait等待,首先检查第二参数的条件,条件成立则继续运行,条件不成立则暂时对第一参数的lock解锁,等待来自写线程的通知,收到通知后,取出数据执行操作。使用unique_lock
的原因是lock_guard
不支持。
在写线程中:对互斥元上锁,然后插入数据,发出通知。
std::mutex mut;
std::queue<data> dataQueue;
std::condition_variable dataCond;
void readThread()
{
while(...)
{
std::unique_lock<std::mutex> lk(mut);
dataCond.wait(lk, []{return !dataQueue.empty();});
...//取出数据
lk.unlock();
...
}
}
void writeThread()
{
while(...)
{
...
std::lock_guard<std::mutex> lk(mut);
dataQueue.push(data);
dataCond.notify_one();
}
}
2.future 一次性事件
future类似一个指针,指向用某个函数启动的异步任务,通过使用future的成员函数get()来获取线程结果。
标准库提供了两种future:std::future
和std::shared_future
。区别是前者只能get()一次,一次之后所得结果结果就不确定了,而后者大概是引用计数,多个不同的shared_future对象都可以调用get()。
前者可通过std::move
移动给后者。前者是不可复制的,后者则是可复制的。
注:只要调用get()
,线程就会阻塞到得到结果。
使用std::async()
来启动一个异步任务,并返回一个future对象。
若作为std::async()
一部分的函数调用引发了异常,该异常被存储正在future中,代替所存储的值,在调用get()
时再次引发异常。
std::future<int> answer = std::async(someThingFunc, par1); someThingFunc是异步任务的函数
... 若函数有参数,则传递方式和thread的传递方式一样。
int res = answer.get();
↓↓↓特殊参数的例子,
std::launch::async表示在新线程中运行,
std::launch::deferred表示在调用wait()或get()时运行。
auto f1 = std::async(std::launch::async, someThingFunc, par1);
auto f2 = std::async(std::launch::deferred, someThingFunc, par1);
3.时间限制的等待
条件变量、锁、线程等这些对象,指定等待一段时间,超出等待时间时就返回。
关于哪些对象有这些时间限制等待的函数,本章最后有一张表。
通过后缀_for()的函数等待一段指定的时间,比如说几秒或者几分。
通过后缀_until()的函数,等待一个绝对的时间,如等待到某年某月某日某时某分某秒。
关于等待时间的使用,通过标准库的时钟、时间段、时间点三个概念来配合。(翻了翻C++标准库找这三个概念,突然就不想写了,几乎都能找到)。
时钟:标准库提供了三个clock对象:system_clock、steady_clock、high_resolution_clock
。通过调用clock::now()来取得当前对象。
时间段:用来表示几分几秒,甚至微妙纳秒的表示,就是一段时间。
时间点:一个绝对的时间,具体到某年的某分某秒。
关于这三个概念如何使用就不写了,网上到处都是,上个例子结束本章:
auto timeout = std::chrono::steady_clock::now() + std::chrono::millisecond(500); // 获取当前时间然后加上500毫秒,作为绝对超时时间
std::unique_lock<std::mutex> lk(m);
std::condition_variable cv;
if( cv.wait_unitl(lk, timeout) == std::cv_status::timeout )
printf("timeout");
2020年8月14日18:04:31 4章
结尾
来写个结束把,这本书之后几章大概讲了原子操作和内存顺序,内存顺序大概是更底层的另类的锁,书中用它来实现了无锁的线程安全结构,再往后就是讲一些线程使用的场景之类的。很早之前看过这本书一遍,后面也是看的迷迷糊糊的,现在也是,不过前四章对于多线程编程来说足够了。
2020年8月20日22:07:28