C++核心技术:并发编程
15 分钟阅读
•
2662 字
+
886 词
在操作系统看来,我们编译完可执行的C++程序,在单次运行的时候就是一个进程。而每个进程里可以有一个或多个线程:
头文件中也定义了锁的 RAII 帮助类,如我们上面用过的 1ock guard。为了避免手动加/解锁的麻烦,以及在有异常或出错返回时发生漏解锁,我们一般应当使用 1ock_guard,而不是手工调用互斥量的 1ock 和 unlock 方法。比 1ock guard 更复杂的是 unique_lock,它除了具有自动加/解锁的功能外,还额外支持可移动、构造时延迟加锁、手动加/解锁等操作。在需要这些额外功能时,就可以使用它(注意相比 1ock guard,它的额外开销更大)
条件变量的正确用法
如果软件和硬件可以对指令进行重排,那我们之前使用锁的代码有没有问题呢?
必须没有问题。软件和硬件允许重排都是为了提升软件的性能,而不是跟程序员作对。对于锁这样的已经沿用了多年的机制,我们必须保证它的行为不会在现代C++里突然发生变化。正式来讲,锁具有获得-释放语义:这也是这两个词的来源,
获得(acquire)锁和释放(release)锁
。这两个术语的基本含义是:
原子操作有三类:
来做多线程同步标识,比如用来通知线程应当结束运行。
最简单的用法是在检查的地方使用获得语义(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操作。实现并发队列就需要这样的技巧。
- 每个进程有自己的独立地址空间,不与其他进程分享;一个进程里可以有多个线程,彼此共享同一个地址空间。
- 堆内存、文件、套接字等资源都归进程管理,同一个进程里的多个线程可以共享使用。每个进程占用的内存和其他资源,会在进程退出或被杀死时返回给操作系统。
- 单核或多核系统上的多线程编程(一般使用共享内存来通信)
- 单核或多核系统上的多进程编程(一般使用消息机制来通信)
- 跨多个系统的分布式编程(一般使用网络协议来通信)
- 线程 -- 基本的执行单元,是可以独立执行的指令序列,通常在某一时刻会占用一个处理器核 。对于多线程的编程模型,“线程”当然是最自然的术语;对于其他编程模型,这里的“线程”也可以理解成其他相对应的执行单元概念。
- 共享数据 --可能被多个线程访问的数据。对于不是原子量的共享数据,我们需要确保:在修改共享数据时,没有其他线程在同时修改或读该数据;否则即会导致 数据竞争(datarace) 。数据竞争是并发编程中最常见的问题,后果是未定义行为。
- 锁 --一种同步机制,持有锁意味着我们有权利执行某项操作,如对共享数据的读或写。对共享数据提供锁,要求持有锁才能访问共享数据,是一种非常简单的避免数据竞争的方式。但反过来,等待锁也往往是并发编程的性能瓶颈所在。
- 通知- -一种抽象的同步机制,用来通知其他线程发生了某个事件。根据具体的并发环境,通知可能携带额外的数据,也可能没有。
- 数据同步 --共享数据可能同时存在于多个不同的地方,因此需要某种机制来同步多份数据。数据同步可能会带来程序员意料之外的延迟。即使在多核共享内存这样的简单系统里,都因为有存储层次结构而存在同步延迟
- lock:锁定,锁已经被其他线程获得时则阻塞执行
- try_lock:尝试锁定,获得锁之后返回 true,在锁被其他线程获得时返回 false
- unlock:解除锁定(只允许在已获得锁时调用)
- timed_mutex:允许锁定超时的互斥量
- recursive_timed_mutex:允许锁定超时的递归互斥量
- shared_mutex:允许共享和独占两种获得方式的互斥量(读写锁)
- shared_timed_mutex:允许共享和独占两种获得方式且允许锁定超时的互斥量
使用正确惯用法的代码要复杂不少:
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';
}
内存序和原子量
- 获得语义 是内存操作的一个属性, 当前线程所有在该操作后面的内存读写不允许被重排到该操作之前。
- 释放语义 是内存操作的一个属性, 当前线程所有在该操作前面的内存读写不允许被重排到该操作之后。
atomic
- 读:在读取的过程中,读取位置的内容不会发生任何改变。
- 写:在写入的过程中,其他执行线程不会看到部分写入的结果。
- 读-修改-写:读取内存,修改数值,然后写回内存,整个操作的过程中不会有其他写入操作插入,其他执行线程不会看到部分写入的结果。原子量的++和--操作就是这种。
- 如果你不确定能不能用好 atomic,那就别用。优先用高层抽象(如期值),或者大家较为熟知的概念(如线程和锁)。C++里的很多机制是提供给需要极致性能的底层库/框架开发者的。
- 如果你不确定该用哪种内存序,那就用默认的序列一致性。
- 尽量不要碰宽松内存序,那一般是留给专家的,陷阱特别多。