C++核心技术:并发编程

15 分钟阅读 2662 字 + 886 词
在操作系统看来,我们编译完可执行的C++程序,在单次运行的时候就是一个进程。而每个进程里可以有一个或多个线程:
  • 每个进程有自己的独立地址空间,不与其他进程分享;一个进程里可以有多个线程,彼此共享同一个地址空间。
  • 堆内存、文件、套接字等资源都归进程管理,同一个进程里的多个线程可以共享使用。每个进程占用的内存和其他资源,会在进程退出或被杀死时返回给操作系统。
因此,并发编程可以有多种不同的方式,如:
  1. 单核或多核系统上的多线程编程(一般使用共享内存来通信)
  2. 单核或多核系统上的多进程编程(一般使用消息机制来通信)
  3. 跨多个系统的分布式编程(一般使用网络协议来通信)
这些系统之间的区别很大,而我们主要关注多核系统(当然也覆盖了更简单的单核系统)里的多线程编程。如开头所说,这是目前的程序员必须面对的实际场景,而现代C++对这种方式的开发具有较完整的支持,开发效率上相比其他方式也有一定的优势(虽然在安全性上有所欠缺)。从概念上来说,这些不同形式的并发仍有一些共同的关注点。我们需要理解的基本概念是:
  • 线程 -- 基本的执行单元,是可以独立执行的指令序列,通常在某一时刻会占用一个处理器核 。对于多线程的编程模型,“线程”当然是最自然的术语;对于其他编程模型,这里的“线程”也可以理解成其他相对应的执行单元概念。
  • 共享数据 --可能被多个线程访问的数据。对于不是原子量的共享数据,我们需要确保:在修改共享数据时,没有其他线程在同时修改或读该数据;否则即会导致 数据竞争(datarace) 。数据竞争是并发编程中最常见的问题,后果是未定义行为。
  • --一种同步机制,持有锁意味着我们有权利执行某项操作,如对共享数据的读或写。对共享数据提供锁,要求持有锁才能访问共享数据,是一种非常简单的避免数据竞争的方式。但反过来,等待锁也往往是并发编程的性能瓶颈所在。
  • 通知- -一种抽象的同步机制,用来通知其他线程发生了某个事件。根据具体的并发环境,通知可能携带额外的数据,也可能没有。
  • 数据同步 --共享数据可能同时存在于多个不同的地方,因此需要某种机制来同步多份数据。数据同步可能会带来程序员意料之外的延迟。即使在多核共享内存这样的简单系统里,都因为有存储层次结构而存在同步延迟
考虑到并发编程需要一种不同的思维模式,这一变化是一个不小的挑战。在某种程度上,有点像从经典力学的思维模式切换到相对论--这不完全是个比喻,因为并发编程里的某些难点,真是因为光速有限造成的!
目前的 C++标准提供了不止一种互斥量。我们先看最简单、也最常用的mutex。它可以默认构造,不可拷贝(或移动),不可赋值。mutex主要提供的方法是:
  • lock:锁定,锁已经被其他线程获得时则阻塞执行
  • try_lock:尝试锁定,获得锁之后返回 true,在锁被其他线程获得时返回 false
  • unlock:解除锁定(只允许在已获得锁时调用)
你可能会想到,如果一个线程已经锁定了某个互斥量,再次锁定会发生什么?对于mutex,回答是危险的未定义行为。你不应该这么做。如果需要在同一线程对同一个互斥最多次加锁,就要用到递归锁 recursive mutex了。除了允许同一线程可以无阻塞地多次加锁外(也必须有对应数量的解锁操作),recursive mutex的其他行为和 mutex 一致。
除了mutex和recursive mutex,C++标准库还提供了:
  • timed_mutex:允许锁定超时的互斥量
  • recursive_timed_mutex:允许锁定超时的递归互斥量
  • shared_mutex:允许共享和独占两种获得方式的互斥量(读写锁)
  • shared_timed_mutex:允许共享和独占两种获得方式且允许锁定超时的互斥量
另外, 头文件中也定义了锁的 RAII 帮助类,如我们上面用过的 1ock guard。为了避免手动加/解锁的麻烦,以及在有异常或出错返回时发生漏解锁,我们一般应当使用 1ock_guard,而不是手工调用互斥量的 1ock 和 unlock 方法。比 1ock guard 更复杂的是 unique_lock,它除了具有自动加/解锁的功能外,还额外支持可移动、构造时延迟加锁、手动加/解锁等操作。在需要这些额外功能时,就可以使用它(注意相比 1ock guard,它的额外开销更大)
条件变量的正确用法
使用正确惯用法的代码要复杂不少:
void work(condition variable& cv,mutex& cv_mut,bool& result ready,int& result){
	this thread::sleep for(2s);
	result =42;
    {
		lock_guard guard{cv_mut};
    	result ready=true;
    }
	cv.notify_one();
}

int main(){
	condition variable cv;
    mutex cv mut;
    bool result ready =false;
    int result;
	scoped_thread th{work,ref(cv),ref(cv mut),ref(result ready),ref(result)};
    cout<<"I am waiting now\n";
	unique lock lock{cv mut};
    cv.wait(lock,[&]{ return result_ready;});
    cout<<"Answer:"<<result<<'\n';
}
不要在没有条件时使用wait
期值
asynce和future
int work(){
    this_thread::sleep_for(2s);
    return 42;
}
int main(){
    auto fut = async(launch:async,work);
    cout<<"I am waitting now\n";
    cout<<"Answer:"<<fut.get()<<'\n';
}
调用async函数模板可以获得一个期值(future);launch::async是运行策略,告诉async应当在新线程里异步调用目标函数.
async函数模板可以根据参数来推导出返回类型,比如这个是future< int>
使用shared_future可以解决future只能调用一次get的问题,可以用移动构造也可以调用future的成员函数share来生成。
promise和future
int work(promise<int> prom){
    this_thread::sleep_for(2s);
    prom.set_value(42);
}
int main(){
    promise<int> prom;
    auto fut = prom.get_future();
    scope_thread th{work,std::move(prom)};
    cout<<"I am waitting now\n";
    cout<<"Answer:"<<fut.get()<<'\n';
}
packaged_task和future
int work(){
    this_thread::sleep_for(2s);
    return 42;
}
int main(){

    packaged_task<int()> task{work};
    auto fut = task.get_future();
    scope_thread th{std::move(task)};//构造thread对象需要将task移进去
    this_thread::sleep_for(1s);
    cout<<"I am waitting now\n";
    cout<<"Answer:"<<fut.get()<<'\n';
}
内存序和原子量
如果软件和硬件可以对指令进行重排,那我们之前使用锁的代码有没有问题呢? 必须没有问题。软件和硬件允许重排都是为了提升软件的性能,而不是跟程序员作对。对于锁这样的已经沿用了多年的机制,我们必须保证它的行为不会在现代C++里突然发生变化。正式来讲,锁具有获得-释放语义:这也是这两个词的来源, 获得(acquire)锁和释放(release)锁 。这两个术语的基本含义是:
  • 获得语义 是内存操作的一个属性, 当前线程所有在该操作后面的内存读写不允许被重排到该操作之前。
  • 释放语义 是内存操作的一个属性, 当前线程所有在该操作前面的内存读写不允许被重排到该操作之后。
atomic
原子操作有三类:
  • 读:在读取的过程中,读取位置的内容不会发生任何改变。
  • 写:在写入的过程中,其他执行线程不会看到部分写入的结果。
  • 读-修改-写:读取内存,修改数值,然后写回内存,整个操作的过程中不会有其他写入操作插入,其他执行线程不会看到部分写入的结果。原子量的++和--操作就是这种。
内存序:
memory_order_relaxed:宽松内存序,只提供基本保证
memory_order_consume:不鼓励使用
memory_order_acquire:获得操作,在读取某原子量时,当前线程所有后续的读写操作都不可重排到该操作之前,并且其他线程在释放同一个原子量之前的所有内存写入都对当前线程可见。
memory_order_release:释放操作,在写入某原子量时,当前线程所有之前的读写操作都不可重排到该操作之后,并且当前线程所有之前的内存写入都对获取同一个原子量的其他线程可见。
memory_order_acq_rel:获得释放操作,一个读-修改-写操作同时具有获得语义和释放语义,也就是它前后的任何读写操作都不允许跟该操作重排,并且其他线程在释放同一个原子量之前的所有内存写入都对当前线程可见,当前线程所有之前的内存写入都对获取同一个原子量的其他线程可见。
memory_order_seq_cst:序列一致性,对于读操作相当于获取,对于写操作相当于释放。,它是所有原子量的默认内存序。
那我们是不是应该多用atomic 呢?并不是:
  1. 如果你不确定能不能用好 atomic,那就别用。优先用高层抽象(如期值),或者大家较为熟知的概念(如线程和锁)。C++里的很多机制是提供给需要极致性能的底层库/框架开发者的。
  2. 如果你不确定该用哪种内存序,那就用默认的序列一致性。
  3. 尽量不要碰宽松内存序,那一般是留给专家的,陷阱特别多。
下面我们再来说说一些实际可以用atomic 的地方。
我们可以使用 atomic 来做多线程同步标识,比如用来通知线程应当结束运行。 最简单的用法是在检查的地方使用获得语义(stop_flag.load(memory_order_acquire)),在通知的地方使用释放语义(stop_flag.store(true,memory_order_release))。不过,如果这种检查较为频繁,并且线程退出完全不涉及其他的内存操作或原子量的话,你也可以考虑使用宽松内存序。
我们可以使用 atomic来管理一个对象的延迟初始化。当指针值不空的时候,使用指针的人就知道对象已经初始化完成。此时,使用获得-释放语义非常合适:检查使用获得语义,通知使用释放语义。
我们可以使用 atomic 或其他整数类型的原子量在多线程中进行计数。这样,我们不需要使用互斥量就能安全地进行计数。一般情况下我们应当使用内存序 memory_-order_acq_rel,但在不检查结果的情况使用 memory_order_relaxed 也很合理。比如,以libc++的shared_ptr的实现里可以看到,它对引用计数增一使用了 add_fetch(1,memory_order_relaxed),对引用计数减一则使用了 add_fetch(-1, memory_order_acq_rel)。指定不同的内存序究竟有没有差异,则因平台而异。
我们可以使用原子量的 compare_exchange_weak之类的方法来实现无锁编程里的CAS操作。实现并发队列就需要这样的技巧。