C++并发编程实战

第2章:线程管理 重点

2.1 线程管理的基础
 

2.1.1 启动线程

需包含头文件<thread>,通过构建std::thread对象启动线程,任何可调用类型都适用于std::thread

void do_some_work();

struct BackgroundTask
{
    void operator()() const;
};

//空的thread对象,不接管任何线程函数
std::thread t1;

//传入普通函数
std::thread t2(do_some_work);

//传入lambda函数
std::thread t3([]() { /*do something*/ });

//传入可调用对象
BackgroundTask task;
std::thread t4(task);
//不能使用std::thread t4(BackgroundTask()),虽然本意是传入临时变量,但这会被编译器解释成函数声明。多用一对圆括号或者使用列表初始化可以解决这个问题。
std::thread t5((BackgroundTask()));
std::thread t6{BackgroundTask()};

2.1.2 等待线程

启动线程后,需要明确是等待它结束、还是任由它独自运行:

  • 调用成员函数join()会先等待线程结束,然后隶属于该线程的任何存储空间都会被清除,std::thread对象不再关联到已结束的线程。
  • 调用成员函数detach()会分离线程使其在后台运行,此后无法获得与它关联的std::thread对象。分离线程的归属权和控制权都转移给了C++运行时库,线程退出时与之关联的资源会被正确回收。

1、
首先判断线程是否已加入, 如果没有,会调用join()进行加入。 这很重要!!!!!!
因为join()只能对给定的对象调用一次, 所以对给已加入的线程再次进行加入操作时,将会导致错误。

if (t_.joinable())//只能调用一次join,suoyi 之后就不再joinable。
{
       t_.join();
}

2、
拷贝构造函数和拷贝赋值操作被标记为 =delete ③, 是为了不让编译器自动生成它们。 
直接对一个对象进行拷贝或赋值是危险的,!!!!
因为这可能会弄丢已经加入的线程。 通过删除声明, 任何尝试给thread_guard对象赋值的操作都会引发一个编译错误。 想要了解删除函数的更多知识, 请参阅附录A的A.2节。

 thread_guard(thread_guard const&) = delete;
 thread_guard& operator=(thread_guard const&) = delete;

3、
每个 std::thread 只能被进行一次 join() 或 detach(),操作后 joinable() 由 true 变为 false。

2.1.3 在后台运行的线程

若要分离线程,在线程启动后调用detach()即可。

        调用detach()函数会令线程在后台运行,遂无法与之直接通信。假若线程被分离,就无法等待它完结,也不可能获得与它关联的std::thread对象,因而无法汇合该线程。分离的线程,其归属权和控制权都转移给C++运行时库(runtime library,又名运行库),由此保证,一旦线程退出,与之关联的资源都会被正确回收。

        分离出去的线程常常被称为守护线程(daemon thread)。这种线程往往长时间运行。几乎在应用程序的整个生存期内,它们都一直运行,以执行后台任务(如:文件系统监控、从对象缓存中清除无用数据项、优化数据结构等)。另有一种模式,就是由分离线程执行“启动后即可自主完成”(a fire-and-forget task)的任务;我们还能通过分离线程实现一套机制,用于确认线程完成运行。

        与调用join()的前提条件毫无二致,检查方法也完全相同,只有当t.joinable()返回true时,我们才能调用t.detach()。

2.2 向线程函数传递参数

4、
在传递到 std::thread 构造函数之前就将字面值转化为 std::string 对象:
void f(int i,std::string const& s);

char buffer[1024]; // 1
sprintf(buffer, "%i",some_param);
std::thread t(f,3,buffer); // 2

需要将 std::thread t(f, 3, buffer); // 2
改成=》std::thread t(f, 3, std::string(buffer)); // 使用std::string, 避免悬垂指针

5、对于传非常引用时,直接传值会报错
widget_data data;
thread t(udpate_data_for_widget, data);
需要改成ref
thread t(udpate_data_for_widget, ref(data));

2.3 转移线程所有权

6、转移所有权
使用move来转移
如果转移到一个已经关联的线程,会抛出异常!(一个线程只能关联一个线程任务(线程函数),如果给他关联多个会出错!)

2.4 运行时决定线程数量

7、std::thread::hardware_concurrency() 
在新版C++标准库中是一个很有用的函数。 这个函数会返回在一个程序中“能并发的线程数量”;如果没有定义,则返回0。

计算量的最大值(任务)和硬件支持线程数中, 较小的值为启动线程的数量:
unsigned long const num_threads= std::min(hardware_threads != 0 ? hardware_threads : 2, max_threads);

2.5 标识线程

8、线程标识类型为 std::thread::id,并可以通过2种方式进行检索。 
第一种,可以通过调用 std::thread 对象的成员函数 get_id() 来直接获取。 
            如果 std::thread 对象没有与任何执行线程相关联, get_id() 将返
            回 std::thread::type 默认构造值,这个值表示“无线程”。 
第二种,如果在当前线程中,调用 std::this_thread::get_id() (这个函数定义在 <thread> 头文件中)也可以获得线程标识。

第4章:重点知识

4.1 等待一个事件或其他条件

9、当一个线程等待另一个线程完成任务时, 它会有很多选择:
第一个选择: 它可以持续的检查共享数据标志(用于做保护工作的互斥量), 直到另一线程完成工作时对这个标志进行重设。 
缺点:不过, 就是一种浪费: 线程消耗宝贵的执行时间持续的检查对应标志;
    这种情况类似与, 保持清醒状态和列车驾驶员聊了一晚上;
第二个选择:是在等待线程在检查间隙, 使用 std::this_thread::sleep_for() 进行周期性的间歇;
这个实现就进步很多, 因为当线程休眠时, 线程没有浪费执行时间, 
缺点:但是很难确定正确的休眠时间。 太短的休眠和没有休眠一样, 都会浪费执行时间; 太长的休眠时间, 可能会让任务等待线程醒来。
第三个选择(也是优先的选择)是:使用C++标准库提供的工具去等待事件的发生。 
通过另一线程触发等待事件的机制是最基本的唤醒方式(例如: 流水线上存在额外的任务时), 这种机制就称为“条件变量”。

10、4.1.1 等待条件达成
C++标准库对条件变量有两套实现: std::condition_variable 和 std::condition_variable_any 。 
这两个实现都包含在 <condition_variable> 头文件的声明中。 
两者都需要与一个互斥量一起才能工作(互斥量是为了同步); 
前者仅限于与 std::mutex 一起工作, 而后者可以和任何满足最低标准的互斥量一起工作, 从而加上了_any的后缀。
std::condition_variable 一般作为首选的类型
std::condition_variable_any 因为更加通用, 这就可能从体积、 性能, 以及系统资源的使用方面产生额外的开销, 所以 , 当对灵活性有硬性要求时, 我们才会去考虑 std::condition_variable_any 。

11、如何使用 std::condition_variable 唤醒休眠中的线程对其进行处理? 
数据准备线程:1.lock(lock_guard),2.准备好数据压入数据任务队列,                             3.condition_variable.notify_one通知“等待的线程”;
数据处理线程:1.lock(unique_lock), 2.condition_variable.wait(lock,等待条件[return !data_queue.empty();]),3.取出“队列头”数据,4.数据队列删除队列头数据,5.unlock,6.处理“取出的队列头数据”;
        其中,第3步中wait会去检查:data_queue是否不为空, 当data_queue不为空——那就意味着队列中已经准备好数据了。 

详解condition_variable.wait(lock, []{return !data_queue.empty();]):
 当data_queue不为空,条件满足,lambda函数返回true;
 当data_queue为空,条件不满足,lambda函数返回false;
1.当data_queue为空,条件不满足,lambda函数返回false,wait()函数将解锁互斥量, 并且将这个线程(上段提到的“处理数据的线程”)置于阻塞或等待状态。 
2.当“准备数据的线程”准备好数据压入数据任务队列,“准备数据的线程”调用notify_one()通知条件变量时, 处理数据的线程从睡眠状态中苏醒, 重新获取互斥锁, 并且再次检查条件是否满足。此时,data_queue不为空,条件满足。wait()返回并继续持有锁;程序继续往下走,取出队列
头部的数据,进行处理; 

=》这也是为什么“处理数据的线程”用 std::unique_lock 不是使用“准备数据的线程”中的 std::lock_guard?!
因为等待中的线程必须在等待期间解锁互斥量, 并在这之后对互斥量再次上锁, 而 std::lock_guard 没有这么灵活。 
如果互斥量在线程休眠期间保持锁住状态, 准备数据的线程将无法锁住互斥量, 也无法添加数据到队列中; 
同样的, 等待线程也永远不会知道条件何时满足。

=》本质上说, std::condition_variable::wait 是对“忙碌-等待”的一种优化。 

12、做得好的话, 同步操作可以限制在队列内部, 同步问题和条件竞争出现的概率也会降低。 
鉴于这些好处, 现在从清单4.1中提取出一个“通用线程安全的队列”;

哪些操作 需要添加到队列实现中去?
mutable std::mutex mut; // 1 互斥量必须是可变的

13、“条件变量”的等待线程只等待一次, 当条件为true时, 它就不会再等待条件变量了。
所以,在等待“一组”可用的数据块时,一个条件变量可能并非同步机制的最好选择。在这样的情况下, 期望值(future)就是一个不错的选择。
“条件变量”:用于等待“一个”数据;
“期望值future”:用于等待“一组”数据;

4.2 使用期望值等待一次性事件

14、C++标准库模型将这种一次性事件称为期望值(future)
当线程需要等待特定的一次性事件时, 目的就是要知道这个事件在未来的期望结果。

C++标准库中, 有2种期望值, 使用两种类型模板实现, 声明在 <future> 头文件中: 
唯一期望值(uniquefutures)( std::future<> ) 和 共享期望值(shared futures)( std::shared_future<> )。 
仿照了 std::unique_ptr 和 std::shared_ptr 。 
std::future 的实例只能与一个指定事件相关联,
而 std::shared_future 的实例就能关联多个事件。

4.2.1 后台任务的返回值

15、后台任务的返回值(需要 std::async,而不是std::thread )
 因为 std::thread 并不提供直接接收返回值的机制(无法接受返回值)。 这里就需要 std::async 函数模板。

 当不着急要任务结果时, 可以使用 std::async 启动一个异步任务。 与 std::thread 对象等待的方式不同, std::async 会返回一个 std::future 对象, 这个对象持有最终计算出来的结果。 
当需要这个值时, 只需要调用这个对象的get()成员函数; 并且会阻塞线程直到期望值状态为就绪为止; 之后, 返回计算结果。 

如:

int main()
{
    std::future<int> the_answer=std::async(find_the_answer_to_ltuae);//用 std::async 启动一个异步任务
    do_other_stuff();//暂时不需要异步任务结果,可以做其他事
    std::cout<<"The answer is "<<the_answer.get()<<std::endl;//当需要异步任务结果时,使用std::future 对象的get()成员,即可获取结果
}

16、std::async()函数及其参数类型:包含2个参数、3个参数的版本

template<class _Fty,
    class... _ArgTypes> inline
    future<result_of_t<decay_t<_Fty>(decay_t<_ArgTypes>...)>>
        async(_Fty&& _Fnarg, _ArgTypes&&... _Args)
    {}
template<class _Fty,
    class... _ArgTypes> inline
    future<result_of_t<decay_t<_Fty>(decay_t<_ArgTypes>...)>>
        async(launch _Policy, _Fty&& _Fnarg, _ArgTypes&&... _Args)
    {}

从第二个参数开始就是通常 变长模板的内容:_Fnarg为可调用对象,..._Args为_Fnarg可能用到的参数。
//1.std::async的2个参数的函数版本(函数,可变参数)
//=》实际上是调用了三参数版本,只是将参数1启动策略设置为“默认的启动策略”。
//默认的启动策略:std::launch::async|std::launch::deferred为默认,怎样启动取决于系统。 

//1.1 调用2个参数的版本(直接用函数,可变参数),这里可变参数是空
std::future<int> the_answer=std::async(find_the_answer_to_ltuae);

//1.2 调用2个参数的版本(函数,可变参数),这里可变参数是[类对象(函数调用对象),具体函数参数1,具体函数参数2]
auto f1=std::async(&X::foo,&x,42,"hello"); // 调用p->foo(42, "hello"), p是指向x的指针
auto f2=std::async(&X::bar,x,"goodbye"); // 调用tmpx.bar("goodbye"), tmpx是x的拷贝副本

//2.std::async的3个参数的函数版本(启动策略,函数,可变参数)
//调用3个参数的版本(启动策略,函数,可变参数)
auto f6=std::async(std::launch::async,Y(),1.2); 

17、std::async()函数第一个参数_Policy是启动策略:
(1)  std::launch::async为:“在单独的线程中立即启动”,函数必须在其所在的独立线程上执行。(立即启动执行,并不是立即返回,仍然是异步的,没有阻塞等到结果返回)
(2)  std::launch::deferred为:在wait()或get()调用时,函数才会被执行
(3)  std::launch::async|std::launch::deferred为默认,怎样启动取决于系统。

18、使用 std::async 会让 std::future 与任务实例相关联。 
不过, 这不是让 std::future 与任务实例相关联的唯一方式; 
你也可以将任务包装入 std::packaged_task<> 实例中, 
或通过编写代码的方式, 使用 std::promise<> 类型模板显示设置值。
与 std::promise<> 对比, std::packaged_task<> 具有更高层的抽象。

4.2.2 任务与期望值关联

19、打包任务:将任务包装入 std::packaged_task<> 实例中,任务与期望值关联。
用途:这可以用在构建线程池的结构单元(可见第9章), 或用于其他任务的管理, 
比如: 在任务所在线程上运行其他任务, 或将它们顺序的运行在一个特殊的后台线程上。 
当一个粒度较大的操作被分解为独立的子任务时, 其中每个子任务都可以包含在一个 std::packaged_task<> 实例中, 之后这个实例将传递到任务调度器或线程池中。 

std::packaged_task<> 的模板参数是一个函数签名,当构造出一个 std::packaged_task<> 实例时, 就必须传入一个函数或可调用对象。
参数类型可以不完全匹配,因为这里类型可以隐式转换。

使用 std::packaged_task<void()> 创建任务, 可以通过这个任务⑧调用get_future()成员函数获取期望值对象。

4.2.3 使用(std::)promises

20、使用承诺值std::promise,解决单线程多连接问题
问题:
当一个应用需要处理很多网络连接时, 它会使用不同线程尝试连接每个接口, 因为这能使网络尽早联通, 尽早执行程序。 
当连接较少的时候, 工作没有问题(也就是线程数量比较少)。 
不幸的是, 随着连接数量的增长, 这种方式变的越来越不合适; 
因为大量的线程会消耗大量的系统资源, 还有可能造成线程上下文频繁切换(当线程数量超出硬件可接受的并发数时), 这都会对性能有影响。 
解决方法:
 因此通过少数线程(可能只有一个)处理网络连接, 每个线程同时处理多个连接事件, 对需要处理大量的网络连接
的应用而言是普遍的做法。

新问题:
考虑一个线程处理多个连接事件, 来自不同的端口连接的数据包基本上是以乱序方式进行处理的; 同样的, 数据包也将以乱序的方式进入队列。 
解决方法:
std::promise<T> 提供设定值的方式(类型为T), 这个类型会和后面看到的 std::future<T> 对象相关联。 
一对 std::promise/std::future 会为这种方式提供一个可行的机制; 
期望值可以阻塞等待线程,同时,提供数据的线程可以使用组合中的承诺值来对相关值进行设置, 并将期望值的状态置为“就绪”。

21、承诺值std::promise原理


如上图所示,异步调用创建的时候,会返回一个std::future对象实例给异步调用创建方。异步调用执行方持有std::promise对象实例。
双方持有的std::promise对象实例和std::future对象实例分别“连接一个共享对象”,
这个共享对象在异步调用创建方和异步调用执行方之间构建了一个信息同步的通道(channel),
双方通过这个通道进行异步调用执行情况的信息交互。

异步调用执行方访问这个通道:是通过自身持有的std::promise实例来向通道中写入值的。
异步调用执行方:完成执行之后,会通过std::promise对象实例,向通道中写入异步调用执行的结果值。
异步调用创建方:通过自身持有的std::future对象实例,来获取通道中的值的(异步调用的结果)。

异步调用执行方通过std::promise来兑现承诺(promise,承诺调用完成之后在未来某一时刻交付结果),异步调用创建方通过std::future来获取这个未来的值(future,未来兑现的承诺对应的结果值)。

原文链接:https://blog.csdn.net/woaitingting1985/article/details/141856779
参考:https://zhuanlan.zhihu.com/p/672327290

4.2.4 将异常存与期望值中

22、C++标准库提供了一种在以上情况下清理异常的方法, 并且允许他们将异常存储为相关结果的一部分。
无论使用std::async 还是 std::packaged_task 任务包,当调用抛出一个异常时, 这个异常就会存储到期望值中,之后期望值的状态被置为“就绪”, 之后调用get()会抛出这个已存储异常。
对于std::promise,当存入的是一个异常时, 就需要调用set_exception()成员函数, 而非set_value(),这通常是用在一个catch块中。

4.2.5 多个线程的等待

23、多个线程的等待std::shared_future (在多个线程间等待一个期望,也可以称作“共享期望”)
23.1、若我们要让多个线程等待同一个目标事件,则需改用std::shared_future。
解释1:
std::future的问题:
因为 std::future 模型独享同步结果的所有权, 并且通过调用get()函数, 一次性的获取数据, 
这就让并发访问变的毫无意义——只有一个线程可以获取结果值, 因为在第一次调用get()后, 就没有值可以再获取了。
解决方法:
std::future 是只移动的, 所以其所有权可以在不同的实例中互相传递, 但是只有一个实例可以获得特定的同步结
果,
std::shared_future 实例是可拷贝的, 所以多个对象可以引用同一关联“期望”值的结果。

解释2:
std::future只支持一对一地在线程间传递。因为future在第一次get之后会发生移动操作,之后该值不复存在。
std::share_future的实例能够复制出副本,使得多个线程能持有该类的多个对象,从而实现多个线程等待同一目标。
解释3:
std::future仅能移动构造和移动赋值,所以归属权可在多个实例之间转移,但在相同时刻,
只会有唯一一个future实例指向特定的异步结果;std::shared_future的实例则能复制出副本,
因此我们可以持有该类的多个对象,它们全指向同一异步任务的状态数据。

即便改用std::shared_future,同一个对象的成员函数却依然没有同步。若我们从多个线程访问同一个对象,就必须采取锁保护以避免数据竞争。
首选方式是,向每个线程传递std::shared_future对象的副本,它们为各线程独自所有,并被视作局部变量。
因此,这些副本就作为各线程的内部数据,由标准库正确地同步,可以安全地访问。

23.2、 future和promise都具备成员函数valid(),用于判别异步状态是否有效。
23.3、构造std::shared_future对象方式
方式1:默认构造方式
因为std::future对象独占异步状态,所以若要按默认方式构造std::shared_future对象,则须用std::move向其默认构造函数传递归属权。

std::promise<int> p;
std::future<int> f(p.get_future());//std::future对象f
assert(f.valid());
std::shared_future<int> sf(std::move(f));//按默认方式构造std::shared_future对象

方式2:std::future具有成员函数share(),可直接创建新的std::shared_future对象,并向它转移归属权。

std::promise<int> p2;
auto sf2 = p2.get_future().share();

举例:

std::promise<int> p1;
auto f1 = p1.get_future();
assert(f1.valid());                               // future对象有效
std::shared_future<int> sf1 = std::move(f1);       //方式1创建
// std::shared_future<int> sf1(p1.get_future());  // 隐式转换(隐式转移所有权)
assert(!f1.valid());                              // future对象f1不再有效(期望值 f1 现在是不合法的)
assert(sf1.valid());                              // shared_future生效(sf1 现在是合法的)

std::promise<int> p2;                             
auto sf2 = p2.get_future().share();               //方式2创建

4.3 限定等待时间

24、限时等待
24.1、有两种超时机制:
基于 时间段 等待(duration-based timeout):需要指定一段时间(例如, 30毫秒),线程根据指定的时长而继续等待(如30毫秒);
基于 绝对时间点 等待(absolute timeout):在某特定时间点(time point)来临之前,线程一直等待。

处理“时间段/间隔”的函数变体以“_for”为后缀,而处理“时间点”的函数变体以“_until”为后缀。

24.2、std::chrono库中时钟是时间信息的来源,每个时钟类都提供:
当前时刻now、时间值的类型time_point、计时单元的长度ratio<>、计时速率是否恒定is_steady。
常用时钟类包括system_clock,steady_clock,high_resolution_clock。

时长类duration<>,其模板参数有两个,第一个参数:指采用何种类型表示计时单元的数量,第二个参数:指每个计时单元代表多少秒。
例如std::chrono::duration<double,std::ratio<1,1000>>代表采用double值计数的毫秒时长类。

24.3、时钟类都在标准库的头文件<chrono>中定义,此头文件还包含其他时间工具。

时钟类具有静态数据成员is_steady,该值在恒稳时钟内为true,否则为false。

通常,std::chrono::system_clock类不是恒稳时钟,因为它可调整。即便这种调整自动发生,作用是消除本地系统时钟的偏差。
恒稳时钟对于超时时限的计算至关重要,因此,C++标准库提供了恒稳时钟类std::chrono::steady_clock。

future超时的几种状态:

std::future_status::timeout      //超时
std::future_status::ready        //就绪
std::future_status::deferred     //相关任务被延后

示例(时间段 + wait_for()):

std::future<int> f=std::async(some_task);
//基于时间段的超时:使用std::future对象的wait_for()函数
if(f.wait_for(std::chrono::milliseconds(35))==std::future_status::ready)
{
    do_something_with(f.get());
}

这里可以等待期望值, 所以:
当函数等待超时时, 会返回 std::future_status::timeout ; 
当期望值状态改变, 函数会返回 std::future_status::ready ; 
当与期望值相关的任务延迟了, 函数会返回 std::future_status::deferred 。

25、
25.1、chrono的主要组件:

(1)时间点(Time Points)
        表示一个特定的时间点,例如“2023-07-06 12:00:00”。chrono 库提供了几种时间点类型,
        如 system_clock::time_point,steady_clock::time_point,high_resolution_clock::time_point 等。
(2)时间长度(Durations)
        表示一段时间,例如“5小时”,“10分钟”或“2.5秒”。chrono 库提供了多种表示时间长度的类型, 例如 hours,minutes,seconds,milliseconds,microseconds 和 nanoseconds。
(3)时钟(Clocks)

25.2、 定义时间点并获得时间
std::chrono::time_point是一个模板类,可以通过该类定义基于不同时钟的时间点。

如下,定义了一个基于system_clock的时间点。

std::chrono::time_point<std::chrono::system_clock> sys_time_tic;

之后,可以通过如下方式获得某一时间点。

sys_time_tic = std::chrono::system_clock::now();

也可以,定义一个steady_clock或high_resolution_clock的时间点,分别如下所示。

std::chrono::time_point<std::chrono::steady_clock> steady_time_tic;
std::chrono::time_point<std::chrono::high_resolution_clock> high_res_time_tic;

获取时间点的方式也有所不同,需要依赖于各自的时钟类型。

steady_time_tic = std::chrono::steady_clock::now();
high_res_time_tic = std::chrono::high_resolution_clock::now();

25.3、定义一个时间长度
 一般情况下,我们使用std::chrono::duration来定义一个时间长度。

其原型如下所示。

template<class Rep, class Period = std::ratio<1>>  class duration;

Rep是一个数值类型,用来表示此持续时间的长度。这可以是任何数值类型,但通常是整数类型(如 int64_t、long long 等)或浮点数类型(如 double、float)。这个类型决定了能够表示的时间精度和范围。

Period是一个表示时间单位的类型,它通常是 std::ratio 的一个特化,用来表示时间的分母和分子。例如,std::ratio<1> 表示以秒为单位,std::ratio<1, 1000> 表示以毫秒为单位。这个模板参数使得 duration 可以灵活地表示不同的时间单位。

std::chrono::duration还有很多变种,可以更方便的表示一段时间。

std::chrono::hours
std::chrono::minutes
std::chrono::seconds
std::chrono::milliseconds
std::chrono::microseconds
std::chrono::nanoseconds

举例来说,std::chrono::milliseconds类似于std::chrono::duration(int64_t, std::ratio(1, 1000)),但不完全等同,因为前者是一个特化类型,还包含了额外的一些特性。

25.4、计算一个时间周期
        假如我们想计算一个操作的执行时间,我们定义两个时间点,分别是sys_time_tic和sys_time_toc,那么我们就可以通过std::chrono::duration或std::chrono::duration_cast计算这一操作所花费的时间。

如下,通过std::chrono::duration计算操作所需的秒数。

std::chrono::duration<double> elapsed = sys_time_tic - sys_time_toc;

当然也可以直接的进行转换,下面两种方式是一样的。

std::chrono::milliseconds duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start); 
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);  

通过count()获取实际的时长。

std::cout << "The operation took " << duration.count() << " milliseconds." << std::endl;

原文链接:https://blog.csdn.net/tecsai/article/details/138905695    

4.4 使用同步操作简化代码

26、使用同步操作简化代码

  • 使用future的函数化编程
  • 使用消息传递的同步操作
  • 扩展规范中的持续性并发
  • 持续性连接
  • 等待多个future:when_all
  • 等待第一个future:when_any
  • 并发技术扩展规范中的锁存器和栅栏机制

第5章:参考原文《C++并发编程实战》读书笔记(4):原子变量 - MoonZZZ - 博客园

1、标准原子类型

标准原子类型的定义位于头文件<atomic>内。原子操作的关键用途是取代需要互斥的同步方式,但假设原子操作本身也在内部使用了互斥,就很可能无法达到期望的性能提升。有三种方法来判断一个原子类型是否属于无锁数据结构:

  • 所有标准原子类型(std::atomic_flag除外,因为它必须采取无锁操作)都具有成员函数is_lock_free(),若它返回true则表示给定类型上的操作是能由原子指令直接实现的,若返回false则表示需要借助编译器和程序库的内部锁来实现。
  • C++程序库提供了一组宏:ATOMIC_BOOL_LOCK_FREEATOMIC_CHAR_LOCK_FREEATOMIC_CHAR16_T_LOCK_FREEATOMIC_CHAR32_T_LOCK_FREEATOMIC_WCHAR_T_LOCK_FREEATOMIC_SHORT_LOCK_FREEATOMIC_INT_LOCK_FREEATOMIC_LONG_LOCK_FREEATOMIC_LLONG_LOCK_FREEATOMIC_POINTER_LOCK_FREE。宏取值为0表示对应的std::atomic<>特化类型从来都不属于无锁结构,取值为1表示运行时才能确定是否属于无锁结构,取值为2表示它一直属于无锁结构。
  • 从C++17开始,全部原子类型都含有一个静态常量表达式成员变量X::is_always_lock_free,功能与上述那些宏相同,用于在编译期判定一个原子类型是否属于无锁结构。当且仅当在所有支持运行该程序的硬件上,原子类型X全都以无锁结构形式实现,该成员变量的值才为true

除了std::atomic_flag,其余原子类型都是通过模板std::atomic<>特化得到的。由内建类型特化得到的原子类型,其接口反映出自身性质,例如C++标准没有为普通指针定义位运算(如&=),所以不存在专为原子化指针而定义的位运算。一些内建类型的std::atomic<>特化如下表:

原子类型的别名对应的特化
atomic_boolstd::atomic<bool>
atomic_charstd::atomic<char>
atomic_scharstd::atomic<signed char>
atomic_ucharstd::atomic<unsigned char>
atomic_intstd::atomic<int>
atomic_uintstd::atomic<unsigned>
atomic_shortstd::atomic<short>
atomic_ushortstd::atomic<unsigned short>
atomic_longstd::atomic<long>
atomic_ulongstd::atomic<unsigned long>
atomic_llongstd::atomic<long long>
atomic_ullongstd::atomic<unsigned long long>
atomic_char16_tstd::atomic<char16_t>
atomic_char32_tstd::atomic<char32_t>
atomic_wchar_tstd::atomic<wchar_t>

原子类型对象无法复制,也无法赋值,但可以接受内建类型赋值,也支持隐式地转换成内建类型。需要注意的是:按照C++惯例,赋值操作符通常返回一个引用,指向接受赋值的目标对象;而原子类型的赋值操作符不返回引用,而是按值返回(该值属于对应的非原子类型)。

2、原子操作

各种原子类型上可以执行的操作如下表所示:

操作atomic_flagatomic<bool>atomic<T*>整数原子类型其它原子类型
test_and_setY
clearY
is_lock_freeYYYY
loadYYYY
storeYYYY
exchangeYYYY
compare_exchange_weak, compare_exchange_strongYYYY
fetch_add, +=YY
fetch_sub, -=YY
fetch_or, |=Y
fetch_and, &=Y
fetch_xor, ^=Y
++, --YY

2.1、操作std::atomic_flag

std::atomic_flag是最简单的标准原子类型,表示一个布尔标志,它只有两种状态:成立或置零。std::atomic_flag对象必须由宏ATOMIC_FLAG_INIT初始化,它把标志初始化为置零状态,例如:std::atomic_flag f = ATOMIC_FLAG_INIT;。如果不进行初始化,则std::atomic_flag对象的状态是未指定的。std::atomic_flag有两个成员函数:

  • clear():将标志清零。
  • test_and_set():获取旧值并设置标志成立。

使用std::atomic_flag实现一个自旋锁的示例如下:

class spinlock_mutex
{
    std::atomic_flag flag;
public:
    spinlock_mutex() : flag(ATOMIC_FLAG_INIT) {}
    void lock()
    {
        while (flag.test_and_set());
    }
    void unlock()
    {
        flag.clear();
    }
};

2.2、操作std::atomic<bool>

相比于std::atomic_flagstd::atomic<bool>是一个功能更齐全的布尔标志。尽管它也无法拷贝构造或拷贝赋值,但还是能依据非原子布尔量创建其对象,也能接受非原子布尔量的赋值:

std::atomic<bool> b(true);
b = false;

store()是存储操作,可以向原子对象写入值。load()是载入操作,可以读取原子对象的值。exchange()是“读-改-写”操作,它获取原有的值,然后用自行选定的新值作为替换。

std::atomic<bool> b;
bool x = b.load();
b.store(true);
x = b.exchange(false);

compare_exchange_weak()compare_exchange_strong()被称为“比较-交换”操作,它们的作用是:使用者给定一个期望值,原子变量将它和自身的值进行比较,如果相等,就存入另一既定的值;否则,更新期望值所属的变量,向它赋予原子变量的值。“比较-交换”操作返回布尔类型,如果完成了保存动作(前提是两值相等),则返回true,否则返回false。对于compare_exchange_weak(),即使原子变量的值等于期望值,保存动作还是有可能失败,在这种情形下,原子变量维持原值不变,函数返回false。原子化的“比较-交换”必须由一条指令单独完成,而某些处理器没有这种指令,无从保证该操作按原子化方式完成。要实现“比较-交换”,负责的线程则须改为连续运行一系列指令,但在这些计算机上,只要出现线程数量多于处理器数量的情形,线程就有可能执行到中途因系统调度而切出,导致操作失败。这种败因不是变量值本身存在问题,而是函数执行时机不对,所以compare_exchange_weak()往往必须配合循环使用。

bool expected = false;
extern atomic<bool> b;
while(!b.compare_exchange_weak(expected,true) && !expected);

2.3、操作std::atomic<T*>

除了std::atomic<bool>所支持的操作外,std::atomic<T*>还支持算术形式的指针运算。fetch_add()fetch_sub()分别就对象中存储的地址进行原子化加减,然后返回原来的地址。另外,该原子类型还具有包装成重载运算符的+=-=,以及++--的前后缀版本,这些运算符作用在原子类型之上,效果与作用在内建类型上一样。

class Foo {};
Foo some_array[5];
std::atomic<Foo*> p(some_array);
Foo* x = p.fetch_add(2);
assert(x == some_array);
assert(p.load() == &some_array[2]);
x = (p -= 1);
assert(x == &some_array[1]);
assert(p.load() == &some_array[1]);

2.4、操作标准整数原子类型

std::atomic<int>这样的整数原子类型上,除了std::atomic<T*>所支持的操作外,还支持fetch_and()fetch_or()fetch_xor()操作,也支持对应的&=|=^=复合赋值形式。

2.5、泛化的std::atomic<>类模板

除了前文的标准原子类型,使用者还能利用泛化模板,依据自定义类型创建其它原子类型。然而,对于某个自定义的类型UDT,必须要满足一定条件才能具现化出std::atomic<UDT>

  • 必须具有平实拷贝赋值运算符(平直、简单的原始内存赋值及其等效操作)。若自定义类型具有基类或非静态数据成员,则它们同样必须具备平实拷贝赋值运算符。
  • 不得含有虚函数,也不可以从虚基类派生得出。
  • 必须由编译器代其隐式生成拷贝赋值运算符。

由于以上限制,赋值操作不涉及任何用户编写的代码,因此编译器可以借用memcpy()或采取与之等效的行为完成它。另外值得注意的是,“比较-交换”操作采取的是逐位比较运算,效果等同于直接使用memcmp()函数。

3、内存顺序

编译器优化代码时可能会进行指令重排,而且CPU执行指令时也可能会乱序执行,所以代码的执行顺序不一定和书写顺序一致。例如下面的代码可能会按照如表所示的顺序执行,从而引发断言错误。可以看出,指令重排在单线程环境下不会造成逻辑错误,但在多线程环境下可能会造成逻辑错误。

int a = 0;
bool flag = false;
void func1()
{
    a = 1;
    flag = true;
}
void func2()
{
    if (flag)
    {
        assert(a == 1);
    }
}
std::thread t1(func1);
std::thread t2(func2);
t1.join();
t2.join();
step线程t1线程t2
1flag = true
2if (flag)
3assert(a == 1)
4a = 1

内存顺序的作用,本质上是要限制单个线程中的指令顺序,从而解决多线程环境下可能出现的问题。原子类型上的操作服从6种内存顺序,在不同的CPU架构上,这几种内存模型也许会有不同的运行开销。

enum memory_order {
    memory_order_relaxed,
    memory_order_consume,
    memory_order_acquire,
    memory_order_release,
    memory_order_acq_rel,
    memory_order_seq_cst
};
  • memory_order_seq_cst

    这是所有原子操作的内存顺序参数的默认值,语义上要求底层提供顺序一致性模型,不存在任何重排,可以解决一切问题,但是效率最低。

  • memory_order_release / memory_order_acquire / memory_order_consume

    release操作可以阻止这个调用之前的读写操作被重排到后面去;acquire操作则可以保证这个调用之后的读写操作不会重排到前面去;consume操作比acquire操作宽松一些,它只保证这个调用之后的对原子变量有依赖的操作不会被重排到前面去。release与acquire/consume操作需要在同一个原子对象上配对使用,例如:

    std::atomic<int> a;
    std::atomic<bool> flag;
    void func1()
    {
        a = 1;
        flag.store(true, memory_order_release);
    }
    void func2()
    {
        if (flag.load(memory_order_acquire))
        {
            assert(a == 1);
        }
    }
    
  • memory_order_acq_rel

    兼具acquire和release的特性。

  • memory_order_relaxed

    只保证原子类型的成员函数操作本身是不可分割的,但是对于顺序性不做任何保证。

三类操作支持的内存顺序如下表所示:

存储(store)操作载入(load)操作“读-改-写”(read-modify-write)操作
memory_order_seq_cstYYY
memory_order_releaseYY
memory_order_acquireYY
memory_order_consumeYY
memory_order_acq_relY
memory_order_relaxedYYY

参考:

《c++并发编程实战》 笔记-CSDN博客

C++并发编程学习笔记_c++并发编程笔记-CSDN博客

C++并发编程实战学习笔记-CSDN博客

《C++并发编程实战》读书笔记(3):并发操作的同步 - MoonZZZ - 博客园

《C++并发编程实战》读书笔记(2):并发操作的同步-腾讯云开发者社区-腾讯云

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值