我对并发编程的理解

我对并发编程的理解

1. 为什么使用并发

  • 功能需求,比如我们的用户界面,既要同时处理用户请求;又要在后台做一些工作。
  • 性能需求,比如讲单个任务分成几部分且各自并行运行,从而降低总运行时间

2. 并发可能导致的问题

  • 并发编程的难度较高,可能会存在潜在的 bug,如果并发带来的收益足够大,可以尝试
  • 线程会在系统中存在固有的开销,操作系统必须分配相关的内核资源和堆栈空间,然后将其加入调度器中,这个过程有一定的时间开销。如果任务实际运行时间远远小于启动线程的开销时间,使用多线程反而会降低效率
  • 多线程之间需要操作系统做上下文切换,每个上下文切换都需要耗费CPU时间。因此需要参考硬件(CPU数量)调整运行线程的数量

一、C++的并发编程

使用 c++11 的语法进行说明,使用 #include <thread> 包含 thread 头文件

1. 启动与释放线程

启动线程只需要构造 std::thread 对象即可。

  • 创建出来的线程可以调用 detach() 来分离线程,也就是显式的决定不等待这个线程
  • 或者可以调用 join() 显式等待线程的结束,且只能对一个线程调用一次 join() d

注意:需要确保 std::thread 对象被销毁前已调用 join() 或者 detach() 函数。如果要分离线程,通常在线程启动后就可以立即调用 detach(),但是如果要等待线程,那就要注意异常情况,比如在 try/catch 块中也要调用 join(),如下

1
2
3
4
5
6
std::thread t(my_func)
try {
} catch(...) {
t.join()
}
t.join()

推荐“资源获取即初始化“(RAII)的语法,在析构函数中进行 join()。同时注意要 delete 掉拷贝构造函数和拷贝赋值函数。

2. 后台运行线程

std::thread 对象上调用 detach() 会把线程丢在后台运行,此时没有直接的方法与之通信,也不再可能等待该线程结束。所有权和控制权交给 C++ 运行时库,以确保与线程相关联的资源在线程退出后能够被正确的回收。

对于那种即用即忘的任务,使用分离线程是有意义的

3. 传递参数给线程函数

如果参数是指针,注意此指针的生命周期,如果指针指向的是局部变量,可能会导致未定义问题。

1
2
3
4
5
6
7
8
9
void f(int i, std::string const& s);

void use_thread(int some_param) {
char buffer[1024];
sprintf(buffer, "%i", some_param);
std::thread t1(f, 3, buffer); // 1
std::thread t2(f, 3, std::string(buffer)); // 2
t.detach();
}

如上,执行 use_thread 的线程可能会在 t1 线程转换 buffer 到 string 之前退出,导致 buffer 空间被释放,出现未定义行为。线程 t2 则解决了这个问题,将 buffer 传递给 std::string 的构造函数之前转换为 std::string

第二种情况,需要对象的引用,来改变这个对象。多线程的 std::thread 必须显式通过 std::ref 来绑定引用进行传参,否则,形参的引用声明是无效的。

1
2
3
4
5
6
7
8
9
10
11
void threadFunc(std::string& str, int a) {
str = "change by threadFunc";
a = 13;
}

int main() {
std::string str("main");
int a = 9;
std::thread th(threadFunc, std::ref(str), a);
th.join();
}

当然也有类似传指针、或者 std::move 等编程技巧。

4. 其他

  • std::thread::hardware_concurrency() 这个函数返回一个对于给定程序执行时能够真正并发运行的线程数量的指示。在多核系统上,可能是 CPU 核心的数量,仅仅是一个提示,如果该信息不可用则函数会返回 0 。
  • std::thread 对象的 get_id() 方法可以获取 thread 的 tid。

二、线程间竞争数据

常用互斥锁来保护共享数据。调用 lock() 来加锁,同时意味着需要在离开的所有路径上都调用 unlock() 来解锁,包括异常情况,比较难把握。C++ 库 std::lock_guard() 类模板实现了 RAII 的互斥元,构造时加锁,析构时解锁。

1. 死锁

产生死锁的四个必要条件

  • 互斥条件:一个资源每次只能被一个进程使用
  • 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放
  • 不可剥夺条件:进程已获得的资源,在未使用完之前,不能强行剥夺
  • 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系

这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。

如何避免死锁

  • 避免嵌套锁。如果已经持有一个锁,就别再获取锁。在持有锁的时候,尽量避免调用用户提供的代码,因为用户提供的代码中可能有锁。但是在写泛型时,参数类型上的每一个操作都是用户提供的代码,比较难避免,因此需要其他准则
  • 以固定顺序获取锁。如果必须要获取两个或更多的锁,那么保证在每个线程中以相同的顺序获取这些锁。
  • 使用 try_lock() 也可以一定程度上避免死锁

2. unique_lock

std::unique_lock 的功能比 std::lock_guard 强大,构造时支持延迟加锁、尝试加锁、马上加锁三种加锁模式,且支持移动;但性能开销较大。

3. 锁粒度

关注锁范围内的耗时,尽量让锁粒度降低。

4. 初始化时保护共享数据

我们经常会遇到这种场景,某个对象只需要初始化一次,但是可能有多个线程同时初始化。这也就谈到了 double-checked locking 模式

1
2
3
4
5
6
7
8
9
void undefined_behaviour_with_double_checked_locking() {
if (!resource_ptr) { // 1
std::lock_guard<std::mutex> lk(resource_mutex);
if (!resource_ptr) {
resource_ptr.reset(new some_resource); // 3
}
}
resource_ptr->do_something();
}

如上,因为所外部的读取(注释 1)与锁内部由另一线程完成的写入(注释 3)不同步。一个对象的创建包含三个部分:分配对象的内存空间、初始化对象、设置指针指向刚分配的内存地址。在第二步和第三步之间,编译器可能会重排序。就会导致可能其他线程拿到的对象并没有初始化完成。

C++ 标准库提供了 std::once_flagstd::call_once 来处理这种情况。

1
2
3
4
5
std::once_flag flag;
void Initialize() {
std::cout << "init" << std::endl;
}
std::call_once(flag, Initialize);

使用 static 关键字,将局部变量声明为 static 的。对于多个调用该函数的线程,这意味着可能会有针对“首次”的竞争条件

  • 在许多 C++11 之前的编译器上,这个竞争条件在实践中是有问题的,因为多个线程可能都认为他们是第一个,并试图去初始化该变量;又或者会出现一个线程未初始化完,另一个线程去使用它了。
  • 在 C++11 中,static 的初始化被定义为只发生在一个线程上,并且其他线程不可以使用它直到它初始化完成。

5. 多读少写

多读少写的场景使用互斥锁性能偏低,可以使用读写锁。比如 boost 库提供的 boost::shared_lock