C++并发编程学习笔记

1 并发概述

1.1 并发的方式

        常见的并发有两种方式:多进程并发和多线程并发。

        多进程并发中,这些独立进程可以通过所有常规的进程间通信途径相互传递信息(信号、套接字、文件、管道等)。这种进程间通信普遍存在短处:或设置复杂,或速度慢,甚至二者兼有,因为操作系统往往要在进程之间提供大量防护措施,以免某进程意外改动另一个进程的数据;还有一个短处是运行多个进程的固定开销大,进程的启动花费时间,操作系统必须调配内部资源来管控进程,等等。

        多线程并发中,每个线程都独立运行,并能各自执行不同的指令序列。不过,同一进程内的所有线程都共用相同的地址空间,且所有线程都能直接访问大部分数据。全局变量依然全局可见,指向对象或数据的指针和引用能在线程间传递。尽管进程间共享内存通常可行,但这种做法设置复杂,往往难以驾驭,原因是同一数据的地址在不同进程中不一定相同。

1.2 为什么使用并发

         应用软件使用并发技术的主要原因有两个:分离关注点性能提升

        分离关注点:归类相关代码,隔离无关代码,使程序更易于理解和测试。用线程分离关注点的依据是设计理念,不以增加运算吞吐量为目的。

        增强性能的并发方式有两种。第一种,最直观地,将单一任务分解成多个部分,各自并行运作,从而节省总运行耗时。此方式即为任务并行。尽管听起来浅白、直接,但这却有可能涉及相当复杂的处理过程,因为任务各部分之间也许存在纷繁的依赖。任务分解可以针对处理过程,调度某线程运行同一算法的某部分,另一线程则运行其他部分;也可以针对数据,线程分别对数据的不同部分执行同样的操作,这被称为数据并行。易于采用上述并行方式的算法常常被称为尴尬并行算法。其含义是,将算法的代码并行化实在简单,甚至简单得会让我们尴尬,实际上这是好事。

        第二种增强性能的并发方式是利用并行资源解决规模更大的问题。

归根结底,不用并发技术的唯一原因是收益不及代价。

1.3 C++多线程示例

#include<iostream>
#include<thread>

void hello()
{
    std::cout<<"Hello Concurrent World"<<endl;
}

int main()
{
    std::thread t(hello);
    t.join();
}

2 线程管控

2.1 线程的基本管控

        每个C++程序都含有至少一个线程,即运行main()的线程,它由C++运行时(C++ runtime)系统启动。随后,程序就可以发起更多线程,它们以别的函数作为入口(entry point)。

2.1.1 发起线程

        线程通过构建std::thread对象而启动,该对象指明线程要运行的任务。不论线程具体要做什么,也不论它从程序内哪个地方发起,只要通过C++标准库启动线程,归根结底,代码总会构造std::thread对象。需要包含头文件 <thread>。

        任何可调用类型(callable type)都适用于std::thread。

        将函数对象传递给std::thread的构造函数时,为避免编译器将其解释成函数声明,而不是定义对象,可以使用统一初始化语法(uniform initialization syntax)又名列表初始化来初始化对象,也可使用lambda表达式来初始化。

std::thread myThread{doSomething()};
std::thread myThread([]{
    doSomething();
    doSomethingElse();
});

        一旦启动了线程,就要设置新线程,使之正确的汇合(join())分离(detach()),即使有异常抛出也照样如此。如果选择的分离,那么在新线程运行结束前,必须保证它所访问的外部数据始终正确、有效。

        常见的处理方法是令线程函数完全自含(self-contained),将数据复制到新线程内部,而不是共享数据。若由可调用对象完成线程函数的功能,那它就会完整地将数据复制到新线程内部,因此原对象即使立刻被销毁也无碍。然而,如果可调用对象含有指针或引用,那我们仍需谨慎行事。意图在函数中创建线程,并让线程访问函数的局部变量是极不可取的,除非线程肯定会在该函数退出前结束。

        另一种处理方法是汇合新线程,此举可以确保在主线程的函数退出前,新线程执行完毕。

2.1.2 等待线程

        若需等待线程完成,可以在与之关联的std::thread实例上,通过调用成员函数join()实现。

        join()简单而粗暴,我们抑或一直等待线程结束,抑或干脆完全不等待。如需选取更精细的粒度控制线程等待,如查验线程结束与否,或限定只等待一段时间,那我们便得改用其他方式,如条件变量和future。

        只要调用了join(),隶属于该线程的任何存储空间即会因此清除,std::thread对象遂不再关联到已结束的线程。事实上,它与任何线程均无关联。其中的意义是,对于某个给定的线程,join()仅能调用一次;只要std::thread对象曾经调用过join(),线程就不再可汇合(joinable),成员函数joinable()将返回false。

在出现异常的情况下等待:

        使用 join() 等待线程时,要小心选择join()的位置,防止在join()尚未执行时,线程已抛出异常,此时join()将被略过。一般地,如果打算在没发生异常的情况下调用join(),那么就算出现了异常,同样需要调用join(),以避免意外的生存期问题。

        为了防止因抛出异常而导致的应用程序终结,我们可以设计一个类,运用标准的RAII(Resource Acquisition Is Initialization,RAII,资源获取即初始化)手法,在其析构函数中调用join()。

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 向线程传递参数

        若需向新线程上的函数或可调用对象传递参数,直接向std::thread的构造函数增添更多参数即可。不过请务必牢记,线程具有内部存储空间,参数会按照默认方式先复制到该处,新创建的执行线程才能直接访问它们。然后,这些副本被当成临时变量,以右值形式传给新线程上的函数或可调用对象。即便函数的相关参数按设想应该是引用,上述过程依然会发生。

2.3 移交线程归属

        std::thread支持移动语义,对于一个具体的执行线程,其归属权可以在几个std::thread实例间转移。

void someFunc();
void someOtherFunc();
std::thread t1(someFunc);
std::thread t2 = std::move(t1);
t1 = std::thread(someOtherFunc);


void func(std::thread t);
void g()
{
    func(std::thread(someFunc));
    std::thread t(someFunc);
    func(std::move(t));
}

2.4 选择线程的数量

        C++标准库的std::thread::hardware_concurrency()函数,它的返回值是一个指标,表示程序在各次运行中可真正并发的线程数量。

2.5 识别线程

        线程ID所属型别是std::thread::id,它有两种获取方法。首先,在与线程关联的std::thread对象上调用成员函数get_id(),即可得到该线程的ID。如果std::thread对象没有关联任何执行线程,调用get_id()则会返回一个std::thread::id对象,它按默认构造方式生成,表示“线程不存在”。其次,当前线程的ID可以通过调用std::this_thread::get_id()获得,函数定义位于头文件<thread>内。

        C++标准库容许我们随意判断两个线程ID是否相同,没有任何限制;std::thread::id型别具备全套完整的比较运算符。

3 线程间数据共享

防止恶性竞争的方法:

        1.采取保护措施包装数据结构,确保不变量被破坏时,中间状态只对执行改动的线程可见。在其他访问同一数据结构的线程的视角中,这种改动要么尚未开始,要么已经完成。

        2.修改数据结构的设计及其不变量,由一连串不可拆分的改动完成数据变更,每个改动都维持不变量不被破坏。这通常被称为无锁编程,难以正确编写。

        3.将修改数据结构当作事务(transaction)来处理,类似于数据库在一个事务内完成更新:把需要执行的数据读写操作视为一个完整序列,先用事务日志存储记录,再把序列当成单一步骤提交运行。若别的线程改动了数据而令提交无法完整执行,则事务重新开始。这称为软件事务内存(Software Transactional Memory,STM)。

3.1 用互斥保护共享数据

        互斥(mutual exclusion,略作mutex)是C++最通用的共享数据保护措施之一。

        在C++中,我们通过构造std::mutex的实例来创建互斥,调用成员函数lock()对其加锁,调用unlock()解锁。但我不推荐直接调用成员函数的做法。原因是,若按此处理,那我们就必须记住,在函数以外的每条代码路径上都要调用unlock(),包括由于异常导致退出的路径。取而代之,C++标准库提供了类模板std::lock_guard<>,针对互斥类融合实现了RAII手法:在构造时给互斥加锁,在析构时解锁,从而保证互斥总被正确解锁。

        大多数场景下的普遍做法是,将互斥与受保护的数据组成一个类。这是面向对象设计准则的典型运用:将两者放在同一个类里,我们清楚表明它们互相联系,还能封装函数以增强保护。

        若利用互斥保护共享数据,则需谨慎设计程序接口,从而确保互斥已先行锁定,再对受保护的共享数据进行访问,并保证不留后门(指针、引用等)。不得向锁所在的作用域之外传递指针和引用,指向受保护的共享数据,无论是通过函数返回值将它们保存到对外可见的内存,还是将它们作为参数传递给使用者提供的函数。

        C++标准库提供的std::lock()函数,可以同时锁住多个互斥,而没有发生死锁的风险。C++17还进一步提供了新的RAII类模板std::scoped_lock<>。std:: scoped_lock<>和std::lock_guard<>完全等价,只不过前者是可变参数模板(variadic template),接收各种互斥型别作为模板参数列表,还以多个互斥对象作为构造函数的参数列表。

防范死锁的原则:

        1.避免嵌套锁:如果已经持有一个锁,就不要试图获取第二个锁。万一确有需要获取多个锁,我们应采用std::lock()函数,借单独的调用动作一次获取全部锁来避免死锁。

        2.一旦持锁,就须避免调用由用户提供的程序接口。

        3.按固定顺序获取锁:如果多个锁是绝对必要的,却无法通过std::lock()在一步操作中全部获取,我们只能退而求其次,在每个线程内部都依从固定顺序获取这些锁。

        4.按层级加锁:锁的层级划分就是按特定方式规定加锁次序,在运行期据此查验加锁操作是否遵从预设规则。按照构思,我们把应用程序分层,并且明确每个互斥位于哪个层级。若某线程已对低层级互斥加锁,则不准它再对高层级互斥加锁。

        只要代码采取了防范死锁的设计,函数std::lock()和类std::lock_guard即可涵盖大多数简单的锁操作,不过我们有时需要更加灵活。标准库针对一些情况提供了std::unique_lock<>模板。它与std::lock_guard<>一样,也是一个依据互斥作为参数的类模板,并且以RAII手法管理锁,不过它更灵活一些。

        因为std::unique_lock实例不占有与之关联的互斥,所以随着其实例的转移,互斥的归属权可以在多个std::unique_lock实例之间转移。转移有一种用途:准许函数锁定互斥,然后把互斥的归属权转移给函数调用者,好让他在同一个锁的保护下执行其他操作。

        锁操作有两个要点:一是选择足够粗大的锁粒度,确保目标数据受到保护;二是限制范围,务求只在必要的操作过程中持锁。加锁时选用恰当的粒度,不仅事关锁定数据量的大小,还牵涉持锁时间以及持锁期间能执行什么操作。

3.2 保护共享数据的其他工具

3.2.1 在初始化中保护数据

        在C++标准库中提供了std::once_flag类和std:: call_once()函数,专门用于处理初始化中的数据保护。令所有线程共同调用std::call_once()函数,从而确保在该调用返回时, 指针初始化由其中某线程安全且唯一地完成。

std::shared_ptr<some_resource> resource_ptr;
std::once_flag resource_flag;

void init_resource()
{
    resource_ptr.reset(new some_resource);
}

void foo()
{
    std::call_once(resource_flag, init_resource);
    resource_ptr->do_something();
}

3.2.2 保护甚少更新的数据(读写锁)

        我们想要一种数据结构,若线程对其进行更新操作,则并发访问从开始到结束完全排他,及至更新完成,数据结构方可重新被多线程并发访问。所以,若采用std::mutex保护数据结构,则过于严苛,原因是即便没发生改动,它照样会禁止并发访问。我们需在这里采用新类型的互斥。由于新的互斥具有两种不同的使用方式,因此通常被称为读写互斥:允许单独一个“写线程”进行完全排他的访问,也允许多个“读线程”共享数据或并发访问。

        C++17标准库提供了两种新的互斥:std::shared_mutex和std::shared_timed_mutex。C++14标准库只有std::shared_timed_mutex,而C++11标准库都没有。但可以考虑使用Boost程序库,它也提供了这两个互斥。它们通过提案被纳入C++标准,提案的原始版本即为Boost实现的依据。std::shared_mutex和std::shared_timed_mutex的区别在于,后者支持更多操作。所以,若无须进行额外操作,则应选用std::shared_mutex,其在某些平台上可能会带来性能增益。

        除了std::mutex,我们也可以利用std::shared_mutex的实例施加同步操作。更新操作可用std::lock_guard<std::shared_mutex>和std::unique_lock<std::shared_mutex>锁定,代替对应的std::mutex特化。它们与std::mutex一样,都保证了访问的排他性质。对于那些无须更新数据结构的线程,可以另行改用共享锁std::shared_lock<std::shared_mutex>实现共享访问。C++14引入了共享锁的类模板,其工作原理是RAII过程,使用方式则与std::unique_lock相同,只不过多个线程能够同时锁住同一个std::shared_mutex。

        共享锁仅有一个限制,即假设它已被某些线程所持有,若别的线程试图获取排他锁,就会发生阻塞,直到那些线程全都释放该共享锁。反之,如果任一线程持有排他锁,那么其他线程全都无法获取共享锁或排他锁,直到持锁线程将排他锁释放为止。

        共享锁即读锁,对应std::shared_lock<std::shared_mutex>;排他锁即写锁,对应std::lock_guard <std::shared_mutex>和std::unique_lock<std::shared_mutex>。

3.2.3 递归加锁

        C++标准库为此提供了std::recursive_mutex,其工作方式与std::mutex相似,不同之处是,其允许同一线程对某互斥的同一实例多次加锁。我们必须先释放全部的锁,才可以让另一个线程锁住该互斥。只要正确地使用std::lock_guard<std::recursive_mutex>和std::unique_lock<std::recursive_mutex>,它们便会处理好递归锁的余下细节。

        假如认为需要用到递归锁,实际上大多数时候并非如此,那么你的设计很可能需要修改。

3.3 总结

互斥:

std::mutex        

std::recursive_mutex

std::shared_mutex

std::shared_timed_mutex

        若我们已经在某个std::mutex对象上获取锁,那么再次试图从该互斥获取锁将导致未定义行为;而std::recursive_mutex类型的互斥准许同一线程重复加锁。

类模板,融合RAII:

std::lock_guard        在构造时加锁,析构时解锁。如果互斥已被上锁,则实例的构造参数需要额外提供了std::adopt_lock对象,以指明互斥已被锁住,即互斥上有锁存在,std::lock_guard实例应当据此接收锁的归属权,不得在构造函数内试图另行加锁。

std::unique_lock        不占有互斥的所有权,互斥的所有权可以在多个std::unique_lock实例间转移。std::unique_lock也可接收第二个参数:std::adopt_lock表明互斥已上锁,std::unique_lock管理该锁;std::defer_lock使互斥在完成构造时处于无锁状态,在需要时在std::unique_lock对象(而非互斥对象)上调用lock()(成员函数)获取锁,或者将std::unique_lock对象传给std::lock()函数加锁。因为std::unique_lock类具有成员函数lock()、try_lock()和unlock(),所以它的实例得以传给std::lock()函数。std::unique_lock占用更多存储空间,也比std::lock_guard略慢。

        转移有一种用途:准许函数锁定互斥,然后把互斥的归属权转移给函数调用者,好让他在同一个锁的保护下执行其他操作。

std::scope_lock        C++17提供此模板,功能与std::lock_guard完全一样,不过是可变参数模板,可以接收不同类型的互斥,还可以接收多个互斥对象作为构造函数的参数列表。同时还加入了类模板参数推导。

        如果需要同时获取多个锁,std::lock()函数和std::scoped_lock<>模板即可帮助防范死锁。

std::shared_lock        std:shared_lock<std::shared_mutex> 共享锁即读写锁

4 并发操作的同步

4.1 条件变量

        C++标准库提供了条件变量的两种实现:std::condition_variable 和std::condition_variable_any。它们都在标准库的头文件<condition_variable>内声明。两者都需配合互斥,方能提供妥当的同步操作。

        std::condition_variable仅限于与std::mutex一起使用;然而,只要某一类型符合成为互斥的最低标准,足以充当互斥,std::condition_variable_any即可与之配合使用,因此它的后缀是“_any”。由于std::condition_variable_any更加通用,它可能产生额外开销,涉及其性能、自身的体积或系统资源等,因此std::condition_variable应予优先采用,除非有必要令程序更灵活。

std::mutex mut;
std::queue<data_chunk> data_queue;
std::condition_variable data_cond;

void data_preparation_thread()            // 由线程乙运行
{
    while(more_data_to_prepare())
    {
        data_chunk const data=prepare_data();
        {
            std::lock_guard<std::mutex> lk(mut);
            data_queue.push(data);
        }
        data_cond.notify_one();
    }
}

void data_processing_thread()           // 由线程甲运行
{
    while(true)
    {
        std::unique_lock<std::mutex> lk(mut);
        data_cond.wait(lk, []{return !data_queue.empty();});
        data_chunk data=data_queue.front();
        data_queue.pop();
        lk.unlock();
        process(data);
        if(is_last_chunk(data))
            break;
    }
}

        wait()函数检查条件是否成立,若成立则返回,互斥仍被锁住;若不成立则解锁互斥,并令线程进入阻塞或等待状态。notify_one()通知条件变量,线程甲随之从休眠中觉醒(阻塞解除),重新在互斥上获取锁,再次查验条件。

        可以向wait()传递普通函数或可调用对象。在wait()调用期间,可以多次检查给定条件的条件,检查期间互斥总是被锁住的,只有给定的条件返回true时,wait()函数才会立即返回。

4.2 future 等待一次性事件发生

        C++标准库使用future来模拟一次性事件发生:若线程需等待某个特定的一次性事件发生,则会以恰当的方式取得一个future,它代表目标事件;接着,该线程就能一边执行其他任务,一边在future上等待;同时,它以短暂的间隔反复查验目标事件是否已经发生。这个线程也可以转换运行模式,先不等目标事件发生,直接暂缓当前任务,而切换到别的任务,及至必要时,才回头等待future准备就绪。一旦目标事件发生,其future即进入就绪状态,无法重置。

        C++标准库由两套模板提供两种future,独占式std::future和共享式std::shared_future。同一事件只能关联一个future实例,但是可以关联多个shared_future实例。只要目标事件发生,与shared_future关联的所有实例就会同时就绪,并且,它们全都可以访问与该目标事件关联的任何数据。

        future可以关联数据,也可以不关联数据。当不关联数据时,可以用特化模板std::future<void>和std::shared_future<void>。

        虽然future可用于线程间通信,但future本身并不提供同步访问。若多个线程访问同一个future时,必须使用互斥或其他同步方式保护。若我们要让多个线程等待同一个目标事件,则需改用std::shared_future。一个std::shared_future<>对象可能派生出多个副本,这些副本都指向同一个异步结果,由多个线程分别独占,它们可访问属于自己的那个副本而无须互相同步。

4.2.1 std::async()

        std::async()函数(申明在<future>中)按异步方式启动任务函数。我们从std::async()函数处获得一个future对象(而非std::thread对象),任务一旦执行完成,其返回值由future对象持有。若要使用返回值,只需在future对象上调用get()函数,当前线程就会阻塞,以便future准备妥当并返回该值。get()仅能被有效调用唯一一次,原因是第一次调用get()会进行移动操作,之后该值不复存在。

        调用std::async()函数时和std::thread的构造函数一样,可以接收附加参数作为任务函数的参数。若要调用类的成员函数,则第一个参数函数指针,指向类的目标成员函数,第二参数是类的实例对象,剩余参数就是目标函数的参数。

        默认情况下,std::async()在等待future时会自行决定是启动新线程还是同步执行任务。不过,我们也可以补充一个参数来决定采用哪种运行方式。参数类型为std::launch,值是 std::launch::deferred 或 std::launch::async。deferred表示延后执行,等到future调用wait()或get()函数时才执行任务函数;async表示必须开启新线程执行任务函数。该参数取值还可以是std::launch::deferred | std::launch::async,表示由std::async()的实现自行决定,该值也是默认值。

        凭借std::async(),即能简便地把算法拆分成多个子任务,且可并发运行。不过,使std::future和任务关联并非唯一的方法:运用类模板std::packaged_task<>的实例,我们也能将任务包装起来;又或者利用std::promise<>类模板编写代码,显式地异步求值。与std::promise相比,std::packaged_task的抽象层级更高。

4.2.2 std::packaged_task<>

        std::packaged_task<>连结了future对象与函数(或可调用对象)。std::packaged_task<>对象在执行任务时,会调用关联的函数(或可调用对象),把返回值保存为future的内部数据,并令future准备就绪。它可作为线程池的构件单元,亦可用于其他任务管理方案。

        std::packaged_task<>是类模板,其模板参数是函数签名(function signature)。类模板std::packaged_task<>具有成员函数get_future(),它返回std::future<>实例,该future的特化类型取决于函数签名所指定的返回值。std::packaged_task<>还具备函数调用操作符,它的参数取决于函数签名的参数列表。

template<>
class packaged_task<std::string(std::vector<char>*,int)>
{
public:
    template<typename Callable>
    explicit packaged_task(Callable&& f);
    std::future<std::string> get_future();
    void operator()(std::vector<char>*,int);
};

        std::packaged_task对象是可调用对象,我们可以直接调用,还可以将其包装在std::function对象内,当作线程函数传递给std::thread对象,也可以传递给需要可调用对象的函数。

4.2.3 std::promise<>

std::promise<T>给出了一种异步求值的方法(类型为T),某个std::future<T>对象与结果关联,能延后读出需要求取的值。配对的std::promise和std::future可实现下面的工作机制:等待数据的线程在future上阻塞,而提供数据的线程利用相配的promise设定关联的值,使future准备就绪。若需从给定的std::promise实例获取关联的std::future对象,调用前者的成员函数get_future()即可,这与std::packaged_task一样。promise的值通过成员函数set_value()设置,只要设置好,future即准备就绪,凭借它就能获取该值。如果std::promise在被销毁时仍未曾设置值,保存的数据则由异常代替。

4.2.4 将异常保存在future中

        若经由std::async()调用的函数抛出异常,则会被保存到future中,代替本该设定的值,future随之进入就绪状态,等到其成员函数get()被调用,存储在内的异常即被重新抛出(C++标准没有明确规定应该重新抛出原来的异常,还是其副本;为此,不同的编译器和库有不同的选择)。

        假如我们把任务函数包装在std::packaged_task对象内,也依然如是。若包装的任务函数在执行时抛出异常,则会代替本应求得的结果,被保存到future内并使其准备就绪。只要调用get(),该异常就会被再次抛出。

        自然,std::promise也具有同样的功能,它通过成员函数的显式调用实现。假如我们不想保存值,而想保存异常,就不应调用set_value(),而应调用成员函数set_exception()。

4.2.5 多线程一起等待

        若我们要让多个线程等待同一个目标事件,则需改用std::shared_future

        std::future仅能移动构造和移动赋值,所以归属权可在多个实例之间转移,但在相同时刻,只会有唯一一个future实例指向特定的异步结果;std::shared_future的实例则能复制出副本,因此我们可以持有该类的多个对象,它们全指向同一异步任务的状态数据。

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

        future和promise都具备成员函数valid(),用于判别异步状态是否有效。

        std::shared_future的实例由std::future构造而得,状态也是一直的。须使用std::move()来转移future的归属权,使std::future变成真空态。

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

4.3 限时等待

        有两种超时机制:迟延超时(duration-based timeout),线程根据指定的时长而继续等待(如30毫秒);绝对超时(absolute timeout),在某特定时间点(time point)来临之前,线程一直等待。处理迟延超时的函数变体以“_for”为后缀,而处理绝对超时的函数变体以“_until”为后缀。

        时钟类都在标准库的头文件<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        //相关任务被延后

        

std::this_thread::sleep_for()函数,在各次查验之间短期休眠。

5 C++的内存模型及原子操作

5.1

5.2 原子操作的内存次序

        原子类型上的操作服从 6 种内存次序:memory_order_relaxed、memory_order_consume、memory_order_acquire、memory_order_release、memory_order_acq_rel和memory_order_seq_cst。其中,memory_order_seq_cst是可选的最严格的内存次序,各种原子类型的所有操作都默认遵从该次序,除非我们特意为某项操作另行指定。虽然内存次序共有6种,但它们只代表3种模式:

先后一致次序:memory_order_seq_cst

获取-释放次序:memory_order_consume、memory_order_acquire、memory_order_release和memory_order_acq_rel

宽松次序:memory_order_relaxed

        1.memory_order_seq_cst:这是最严格的内存次序,它保证了所有操作按照一定的顺序执行,即在任何线程中观察到的操作顺序都是一致的。例如,一个线程对一个原子变量进行写操作,并使用memory_order_seq_cst,那么在其他线程中,对该原子变量的所有操作都将按照这个写操作的顺序来执行。

        2.memory_order_consume:这种内存次序保证了操作之间的依赖关系。如果一个操作A依赖于另一个操作B的结果,那么在A之前,B必须已经完成。例如,一个线程对一个原子指针进行写操作,然后另一个线程读取该指针并解引用它,如果写操作使用了memory_order_consume,那么读操作可以不使用内存屏障而直接读取该指针。

        3.memory_order_acquire:这种内存次序保证了在一个操作之后的所有读操作都在该操作之前完成。例如,一个线程对一个原子变量进行写操作,并使用memory_order_acquire,那么在其他线程中,对该原子变量的后续读操作将看到这个写操作的效果。

        4.memory_order_release:这种内存次序保证了在一个操作之前的所有写操作都在该操作之后完成。例如,一个线程对一个原子变量进行写操作,并使用memory_order_release,那么在其他线程中,对该原子变量的后续写操作将在这个写操作之后发生。

        5.memory_order_acq_rel:这是memory_order_acquirememory_order_release的组合,同时具有两者的特性。例如,一个线程对一个原子变量进行写操作,并使用memory_order_acq_rel,那么在其他线程中,对该原子变量的读写操作都将在这个写操作的前后发生。

        6.memory_order_relaxed:这是最宽松的内存次序,它仅保证原子性,不保证操作之间的顺序。例如,多个线程对同一个原子变量进行写操作,每个线程都使用memory_order_relaxed,那么这些写操作可能以任何顺序出现在其他线程中。

  • 14
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值