c++线程--快速上手

线程创建

头文件#include thread 是在 C++11 标准中引入的。

C++11 标准引入了对多线程编程的标准化支持,其中包括了线程的创建、管理和同步机制。

头文件提供了基本的线程支持库,允许开发者直接使用c++线程进行并行编程,而无需依赖操作系统特定的 API

#include <iostream>
#include <thread>
using namespace std; 
void hello() { 
  cout << "Hello World from new thread." << endl;
}

int main() {
  thread t(hello); 
  t.join(); 
  return 0;
}
  • 为了使用多线程的接口,我们需要#include 头文件。新建线程的入口是一个普通的函数,它并没有什么特别的地方。

  • 创建线程的方式就是构造一个thread对象,并指定入口函数。与普通对象不一样的是,此时编译器便会为我们创建一个新的操作系统线程,并在新的线程中执行我们的入口函数。

int main() {
  thread t([] {
    cout << "Hello World from lambda thread." << endl;
  });

  t.join();

  return 0;
}
  • 也可以直接使用lambda表达式

join & detach

API 说明
join 等待线程完成其执行
detach 不等待,允许线程独立执行
  • join:调用此接口时,当前线程会一直阻塞,直到目标线程执行完成(当然,很可能目标线程在此处调用之前就已经执行完成了,不过这不要紧)。因此,如果目标线程的任务非常耗时,你就要考虑好是否需要在主线程上等待它了,因此这很可能会导致主线程卡住

  • detach:detach是让目标线程成为守护线程(daemon threads)。一旦detach之后,目标线程将独立执行,即便其对应的thread对象销毁也不影响线程的执行。并且,你无法再与之通信
    joinable() 是 C++11 中 std::thread 类的一个成员函数,用于检查一个线程是否可以被 join()。如果线程可以被 join(),则返回 true,否则返回 false。
    joinable() 返回 true 的条件:线程对象必须表示一个有效的、尚未被 join() 或 detach() 的线程。

管理当前线程

API C++标准 说明
yield C++11 让出处理器,重新调度各执行线程
get_id C++11 返回当前线程的线程 id
sleep_for C++11 使当前线程的执行停止指定的时间段
sleep_until C++11 使当前线程的执行停止直到指定的时间点

上面是一些在线程内部使用的API,它们用来对当前线程做一些控制。

  • yield 通常用在自己的主要任务已经完成的时候,此时希望让出处理器给其他任务使用。
  • get_id 返回当前线程的id,可以以此来标识不同的线程。
  • sleep_for 是让当前线程停止一段时间。
  • sleep_until 和sleep_for类似,但是是以具体的时间点为参数。这两个API都以chrono API(由于篇幅所限,这里不展开这方面内容)为基础。
    下面是一个代码示例:
void print_time() {
  auto now = chrono::system_clock::now();
  auto in_time_t = chrono::system_clock::to_time_t(now);

  std::stringstream ss;
  ss << put_time(localtime(&in_time_t), "%Y-%m-%d %X");
  cout << "now is: " << ss.str() << endl;
}

void sleep_thread() {
  this_thread::sleep_for(chrono::seconds(3));
  cout << "[thread-" << this_thread::get_id() << "] is waking up" << endl;
}

void loop_thread() {
  for (int i = 0; i < 10; i++) {
    cout << "[thread-" << this_thread::get_id() << "] print: " << i << endl;
  }
}

int main() {
  print_time();

  thread t1(sleep_thread);
  thread t2(loop_thread);

  t1.join();
  t2.detach();

  print_time();
  return 0;
}

这段代码应该还是比较容易理解的,这里创建了两个线程。它们都会有一些输出,其中一个会先停止3秒钟,然后再输出。主线程调用join会一直卡住等待它运行结束。

这段程序的输出如下:

now is: 2019-10-13 10:17:48
[thread-0x70000cdda000] print: 0
[thread-0x70000cdda000] print: 1
[thread-0x70000cdda000] print: 2
[thread-0x70000cdda000] print: 3
[thread-0x70000cdda000] print: 4
[thread-0x70000cdda000] print: 5
[thread-0x70000cdda000] print: 6
[thread-0x70000cdda000] print: 7
[thread-0x70000cdda000] print: 8
[thread-0x70000cdda000] print: 9
[thread-0x70000cd57000] is waking up
now is: 2019-10-13 10:17:51

线程如何一次调用?
主要API

API C++标准 说明
call_once C++11 即便在多线程环境下,也能保证只调用某个函数一次
once_flag C++11 与call_once配合使用

在一些情况下,我们有些任务需要执行一次,并且我们只希望它执行一次,例如资源的初始化任务。
这个时候就可以用到上面的接口。这个接口会保证,即便在多线程的环境下,相应的函数也只会调用一次。

下面就是一个示例:有三个线程都会使用init函数,但是只会有一个线程真正执行它。

void init() {
  cout << "Initialing..." << endl;
  // Do something...
}

void worker(once_flag* flag) {
  call_once(*flag, init);
}

int main() {
  once_flag flag;

  thread t1(worker, &flag);
  thread t2(worker, &flag);
  thread t3(worker, &flag);

  t1.join();
  t2.join();
  t3.join();

  return 0;
}

我们无法确定具体是哪一个线程会执行init。而事实上,我们也不关心,因为只要有某个线程完成这个初始化工作就可以了

竞争条件与临界区

当多个进程或者线程同时访问共享数据时,只要有一个任务会修改数据,那么就可能会发生问题。

此时结果依赖于这些任务执行的相对时间,这种场景称为竞争条件(race condition)。

访问共享数据的代码片段称之为临界区(critical section)。要避免竞争条件,就需要对临界区进行数据保护。

mutex

mutex 是 mutual exclusion(互斥)的简写。

开发并发系统的目的主要是为了提升性能:将任务分散到多个线程,然后在不同的处理器上同时执行。这些分散开来的线程通常会包含两类任务:

  1. 独立的对于划分给自己的数据的处理
  2. 对于处理结果的汇总

第1项任务因为每个线程是独立的,不存在竞争条件的问题。

而第2项任务,由于所有线程都可能往总结果(例如 sum 变量)汇总,这就需要做保护了。
在某一个具体的时刻,只应当有一个线程更新总结果,即:保证每个线程对于共享数据的访问是“互斥”的。mutex 就提供了这样的功能。

mutex 是 mutual exclusion(互斥)的简写。

API C++标准 说明
mutex C++11 提供基本互斥设施
timed_mutex C++11 提供互斥设施,带有超时功能
recursive_mutex C++11 提供能被同一线程递归锁定的互斥设施
recursive_timed_mutex C++11 提供能被同一线程递归锁定的互斥设施,带有超时功能
shared_timed_mutex C++14 提供共享互斥设施并带有超时功能
shared_mutex C++17 提供共享互斥设施

在这些类中,mutex 是最基础的 API。其他类都是在它的基础上的改进。

这些类都提供了下面三个方法,并且它们的功能是一样的

基本方法

方法 说明
lock 锁定互斥体,如果不可用,则阻塞
try_lock 尝试锁定互斥体,如果不可用,直接返回
unlock 解锁互斥体

这三个方法提供了基础的锁定和解除锁定的功能。

使用 lock 意味着你有很强的意愿一定要获取到互斥体,而使用 try_lock 则是进行一次尝试。

在这些基础功能之上,其他的类分别在下面三个方面进行了扩展:

超时:timed_mutex,recursive_timed_mutex,shared_timed_mutex的名称都带有timed,这意味着它们都支持超时功能。
它们都提供了try_lock_for和try_lock_until方法,这两个方法分别可以指定超时的时间长度和时间点。如果在超时的时间范围内没有能获取到锁,则直接返回,不再继续等待。
可重入:recursive_mutex和recursive_timed_mutex的名称都带有recursive。可重入或者叫做可递归,是指在同一个线程中,同一把锁可以锁定多次。这就避免了一些不必要的死锁。
共享:shared_timed_mutex和shared_mutex提供了共享功能。对于这类互斥体,实际上是提供了两把锁:一把是共享锁,一把是互斥锁。一旦某个线程获取了互斥锁,任何其他线程都无法再获取互斥锁和共享锁;但是如果有某个线程获取到了共享锁,其他线程无法再获取到互斥锁,但是还有获取到共享锁。这里互斥锁的使用和其他的互斥体接口和功能一样。而共享锁可以同时被多个线程同时获取到(使用共享锁的接口见下面的表格)。共享锁通常用在读者写者模型上

方法 说明
lock_shared 获取互斥体的共享锁,如果无法获取则阻塞
try_lock_shared 尝试获取共享锁,如果不可用,直接返回
unlock_shared 解锁共享锁

使用 mutex 的并发系统

通过将锁保护的范围缩小到只在汇总时锁定,可以提升性能:

#include <iostream>
#include <cmath>
#include <thread>
#include <vector>
#include <mutex>
#include <chrono>

static const int MAX = 10e8;
static double sum = 0;
static std::mutex exclusive;

void concurrent_worker(int min, int max) {
    double tmp_sum = 0;
    for (int i = min; i <= max; i++) {
        tmp_sum += std::sqrt(i);  // ① 计算单独结果
    }
    exclusive.lock();  // ② 只在汇总时加锁
    sum += tmp_sum;
    exclusive.unlock();
}

输出结果

hardware_concurrency: 16
Concurrent task finish, 451 ms consumed, Result: 2.10819e+13

可以看到,性能得到了极大的提升。

锁的粒度

  • 细粒度(fine-grained):锁保护较小的范围,性能更好。
  • 粗粒度(coarse-grained):锁保护较大的范围,容易导致性能瓶颈。

最佳实践尽量减少锁的范围,不要在持有锁的情况下执行耗时操作。

> In general, a lock should be held for only the minimum possible time needed to perform the required operations.
> –《C++ Concurrency in Action》

通用锁定算法

主要API

API C++标准 说明
lock C++11 锁定指定的互斥体,若任何一个不可用则阻塞
try_lock C++11 试图通过重复调用 try_lock 获得互斥体的所有权

要避免死锁,需要仔细思考和设计业务逻辑。

有一个比较简单的原则可以避免死锁:对所有的锁进行排序,每次一定要按照顺序来获取锁,不允许乱序。

例如:要获取某个玩具,一定要先拿到锁A,再拿到锁B,才能玩玩具。这样就不会死锁了。

这个原则虽然简单,但却不容易遵守,因为数据常常是分散在很多地方的。

不过好消息是,C++11标准中为我们提供了一些工具来避免因为多把锁而导致的死锁。我们只要直接调用这些接口就可以了。

这个就是上面提到的两个函数。它们都支持传入多个 Lockable 对象。

接下来我们用它来改造之前死锁的转账系统:

// 10_improved_bank_transfer.cpp

bool transferMoney(Account* accountA, Account* accountB, double amount) {
    lock(*accountA->getLock(), *accountB->getLock());    // ①
    lock_guard lockA(*accountA->getLock(), adopt_lock);  // ②
    lock_guard lockB(*accountB->getLock(), adopt_lock);  // ③

    if (amount > accountA->getMoney()) {
        return false;
    }

    accountA->changeMoney(-amount);
    accountB->changeMoney(amount);
    return true;
}

这里只改动了3行代码。

  • 这里通过lock函数来获取两把锁,标准库的实现会保证不会发生死锁。
  • lock_guard在下面我们还会详细介绍。这里只要知道它会在自身对象生命周期的范围内锁定互斥体即可。
    创建lock_guard的目的是为了在transferMoney结束的时候释放锁,lockB也是一样。
    但需要注意的是,这里传递了 adopt_lock表示:现在是已经获取到互斥体了的状态了,不用再次加锁(如果不加adopt_lock就是二次锁定了)。

通用互斥管理

互斥体(mutex相关类)提供了对于资源的保护功能,但手动锁定(调用 locktry_lock)和解锁(调用 unlock)互斥体需要耗费较大的精力。

我们需要精心设计代码以确保解锁和加锁配对,因为如果某个路径导致获取锁后没有正常释放,会影响整个系统。此外,异常抛出也会使代码复杂化。

鉴于此,标准库提供了上述这些API。它们使用了RAII编程技巧,简化了手动加锁和解锁的过程。
RAII 可总结如下:
将每个资源封装入一个类,其中:
构造函数请求资源,并建立所有类不变式,或在它无法完成时抛出异常,
析构函数释放资源并决不抛出异常;
始终经由 RAII 类的实例使用满足要求的资源,该资源自身拥有自动存储期或临时生存期,或具有与自动或临时对象的生存期绑定的生存期

API C++标准 说明
lock_guard C++11 实现严格基于作用域的互斥体所有权包装器
unique_lock C++11 实现可移动的互斥体所有权包装器
锁定策略 C++标准 说明
try_to_lock C++11 类型为 try_to_lock_t,尝试获得互斥的所有权而不阻塞
#include <thread>
#include <mutex>
#include <iostream>
 
int g_i = 0;
std::mutex g_i_mutex;  // ①
 
void safe_increment()
{
  std::lock_guard<std::mutex> lock(g_i_mutex);  // ②
  ++g_i;

  std::cout << std::this_thread::get_id() << ": " << g_i << '\n';
  // ③
}
 
int main()
{
  std::cout << "main: " << g_i << '\n';
 
  std::thread t1(safe_increment); // ④
  std::thread t2(safe_increment);
 
  t1.join();
  t2.join();
 
  std::cout << "main: " << g_i << '\n';
}

这段代码中:
全局的互斥体g_i_mutex用来保护全局变量g_i这是一个设计为可以被多线程环境使用的方法。因此需要通过互斥体来进行保护。
这里没有调用lock方法,而是直接使用lock_guard来锁定互斥体。在方法结束的时候,局部变量std::lock_guard<std::mutex> lock会被销毁,它对互斥体的锁定也就解除了。

条件变量

API C++标准 说明
condition_variable C++ 11 提供与 std::unique_lock 关联的条件变量

条件变量提供了一个可以让多个线程间同步协作的功能。这对于生产者-消费者模型很有意义。在这个模型下:

生产者和消费者共享一个工作区。这个区间的大小是有限的。
生产者总是产生数据放入工作区中,当工作区满了。它就停下来等消费者消费一部分数据,然后继续工作。
消费者总是从工作区中拿出数据使用。当工作区中的数据全部被消费空了之后,它也会停下来等待生产者往工作区中放入新的数据。

从上面可以看到,无论是生产者还是消费者,当它们工作的条件不满足时,它们并不是直接报错返回,而是停下来等待,直到条件满足。

void changeMoney(double amount) {
    unique_lock lock(mMoneyLock); // ②
    mConditionVar.wait(lock, [this, amount] { // ③
      return mMoney + amount > 0; // ④
    });
    mMoney += amount;
    mConditionVar.notify_all(); // ⑤
}

在调用 wait() 函数之前需要加锁,主要是因为:
如果没有加锁,多个线程可能在没有同步的情况下同时检查条件****,从而导致多个线程错误地认为条件已经满足并继续执行。

  1. 加锁操作 (unique_lock lock(mMoneyLock);)
    在第②行中,创建了一个 std::unique_lock 对象 lock,并且立即对 mMoneyLock 进行加锁。这是标准的加锁操作,它确保后续的临界区代码(即对共享资源 mMoney 的修改)是线程安全的。

  2. wait() 函数 (mConditionVar.wait(lock, ...);)
    在第③行,wait() 函数是用来等待某个条件满足的,在条件满足之前,当前线程会阻塞并释放 lock 持有的锁,以允许其他线程获得锁。

    • 锁释放wait() 函数在阻塞当前线程时,会自动释放 unique_lock 所持有的 mMoneyLock,从而避免其他线程无法获取锁并造成死锁。
    • 重新加锁:一旦条件满足(即 lambda 函数返回 true,条件为 mMoney + amount > 0),wait() 会重新加锁 mMoneyLock,然后继续执行后续的代码。
  3. 临界区代码
    在条件变量 wait() 成功返回之后,unique_lock 再次持有 mMoneyLock,这时线程进入临界区(即对 mMoney 的修改),并且执行对 mMoney 的操作。

  4. 通知操作 (notify_all())
    在第⑤行,执行 mConditionVar.notify_all() 通知其他等待线程,告诉它们某个条件可能已经发生了变化,让它们重新检查条件。

  • 加锁解锁的行为std::condition_variable::wait() 函数在等待期间会释放锁,只有在条件满足时才会重新加锁并继续执行。因此,不存在重复上锁的情况。
  • 锁的管理std::unique_lock 管理了整个过程的加锁和解锁行为,避免了手动管理锁的复杂性,并确保在需要时锁是正确的被持有。

异步机制

API C++标准 说明
async C++11 异步运行一个函数,并返回保有其结果的 std::future
future C++11 等待被异步设置的值
很多语言都提供了异步的机制。异步使得耗时的操作不影响当前主线程的执行流。

std::async线程 (std::thread) 都是 C++ 中的并发机制,但它们有不同的使用方式、灵活性和处理方式。让我们来详细比较一下它们之间的区别。

  • 抽象层级
  • std::async:更高层次的抽象。它封装了线程的创建和管理,并且返回一个 std::future,允许用户轻松地获取异步任务的结果。async 不需要直接与线程打交道,主要关注任务的执行和结果。
  • std::thread:较低层次的抽象。它直接代表一个独立的线程。你需要手动创建线程,并决定如何管理它的生命周期(通过 join()detach() 等操作)。它更灵活,但需要更多的管理工作。
posted @ 2024-09-29 18:15  牛马chen  阅读(19)  评论(0编辑  收藏  举报