c++ 多线程

基本概念

  • 线程(Thread):是程序中独立执行的一个指令序列。
  • 并发(Concurrency):指的是多个线程在同一个时间段内执行,但不一定同时执行。
  • 并行(Parallelism):指的是多个线程在同一时刻执行。
  • 互斥(Mutex):用于保护共享资源,确保在同一时刻只有一个线程可以访问该资源。
  • 条件变量(Condition Variable):用于线程间的通信,一个线程等待特定的条件发生,而另一个线程负责满足这个条件。

2. 多线程库

C++提供了多线程支持的标准库 <thread><mutex><condition_variable> 等。以下是一个简单的例子,展示如何创建和管理线程:

#include <iostream>
#include <thread>

void function1() {
    std::cout << "Hello from thread!" << std::endl;
}

int main() {
    std::thread t1(function1); // 创建一个新线程,并执行function1函数
    t1.join(); // 等待线程t1执行完毕

    return 0;
}
#include 
#include 
using namespace std;
 
 
void thread_1()
{
    cout<<"子线程1"<<endl;
}
 
 
void thread_2(int x)
{
    cout<<"x:"<<x<<endl</x<<;
    cout<<"子线程2"<<endl;
}
 
 
int main()
{
  thread first ( thread_1); // 开启线程,调用:thread_1()
  thread second (thread_2,100); // 开启线程,调用:thread_2(100)
  //thread third(thread_2,3);//开启第3个线程,共享thread_2函数。
  std::cout << "主线程\n";
 
 
  first.join(); //必须说明添加线程的方式            
  second.join(); 
  std::cout << "子线程结束.\n";//必须join完成
  return 0;
}

join与detach方式

当线程启动后,一定要在和线程相关联的thread销毁前,确定以何种方式等待线程执行结束。比如上例中的join。

  • detach方式,启动的线程自主在后台运行,当前的代码继续往下执行,不等待新线程结束。

  • join方式,等待启动的线程完成,才会继续往下执行。

(1)join举例

下面的代码,join后面的代码不会被执行,除非子线程结束。 

#include
#include
using namespace std;
void thread_1()
{
  while(1)
  {
  //cout<<"子线程1111"<<endl;< span=""></endl;<>
  }
}
void thread_2(int x)
{
  while(1)
  {
  //cout<<"子线程2222"<<endl;< span=""></endl;<>
  }
}
int main()
{
    thread first ( thread_1); // 开启线程,调用:thread_1()
    thread second (thread_2,100); // 开启线程,调用:thread_2(100)
 
 
    first.join(); // pauses until first finishes 这个操作完了之后才能destroyed
    second.join(); // pauses until second finishes//join完了之后,才能往下执行。
    while(1)
    {
      std::cout << "主线程\n";
    }
    return 0;
}

(2)detach举例
下列代码中,主线程不会等待子线程结束。如果主线程运行结束,程序则结束。移动或按值复制线程函数的参数。如果需要传递引用参数给线程函数,那么必须包装它。移动或按值复制线程函数的参数。如果需要传递引用参数给线程函数,那么必须包装它(例如用 std::ref 或 std::cref

#include
#include
using namespace std;
 
 
void thread_1()
{
  while(1)
  {
      cout<<"子线程1111"<<endl;< span=""></endl;<>
  }
}
 
 
void thread_2(int x)
{
    while(1)
    {
        cout<<"子线程2222"<<endl;< span=""></endl;<>
    }
}
 
 
int main()
{
    thread first ( thread_1);  // 开启线程,调用:thread_1()
    thread second (thread_2,100); // 开启线程,调用:thread_2(100)
 
 
    first.detach();                
    second.detach();            
    for(int i = 0; i < 10; i++)
    {
        std::cout << "主线程\n";
    }
    return 0;
}

3. 互斥和锁

互斥量(Mutex)用于保护共享资源,确保在同一时刻只有一个线程可以访问该资源。C++标准库提供了 std::mutex 类来实现互斥。

类型

说明

std::mutex

最基本的 Mutex 类。

std::recursive_mutex

递归 Mutex 类。

std::time_mutex

定时 Mutex 类。

std::recursive_timed_mutex

定时递归 Mutex 类。

lock与unlock

mutex常用操作:

  • lock():资源上锁

  • unlock():解锁资源

  • trylock():查看是否上锁,它有下列3种类情况:

(1)未上锁返回false,并锁住;

(2)其他线程已经上锁,返回true;

(3)同一个线程已经对它上锁,将会产生死锁。

死锁:是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。

std::lock_guard

lock_guard是一个互斥量包装程序,它提供了一种方便的RAII(Resource acquisition is initialization )风格的机制来在作用域块的持续时间内拥有一个互斥量。
创建lock_guard对象时,它将尝试获取提供给它的互斥锁的所有权。当控制流离开lock_guard对象的作用域时,lock_guard析构并释放互斥量。

1、创建即加锁,作用域结束自动析构并解锁,无需手工解锁
2、不能中途解锁,必须等作用域结束才解锁
3、不能复制

#include <iostream>       // std::cout
#include <thread>         // std::thread
#include <mutex>          // std::mutex, std::lock_guard
#include <stdexcept>      // std::logic_error

std::mutex mtx;

void print_even (int x) {
  if (x%2==0) std::cout << x << " is even\n";
  else throw (std::logic_error("not even"));
}

void print_thread_id (int id) {
  try {
    // using a local lock_guard to lock mtx guarantees unlocking on destruction / exception:
    std::lock_guard<std::mutex> lck (mtx);
    print_even(id);
  }
  catch (std::logic_error&) {
    std::cout << "[exception caught]\n";
  }
}

int main ()
{
  std::thread threads[10];
  // spawn 10 threads:
  for (int i=0; i<10; ++i)
    threads[i] = std::thread(print_thread_id,i+1);

  for (auto& th : threads) th.join();

  return 0;
}

std::unique_lock

unique_lock是lock_guard的升级版,它允许延迟锁定,限时深度锁定,递归锁定,锁定所有权的转移以及与条件变量一起使用。

1、创建时可以不锁定(通过指定第二个参数为std::defer_lock),而在需要时再锁定
2、可以随时加锁解锁
3、作用域规则同 lock_grard,析构时自动释放锁
4、不可复制,可移动
5、条件变量需要该类型的锁作为参数(此时必须使用unique_lock)

lock_guard 和unique_lock 并不管理 std::mutex 对象的生命周期,在使用 lock_guard 的过程中,如果 std::mutex 的对象被释放了,那么在 lock_guard 析构的时候进行解锁就会出现空指针错误。

#include <mutex>
#include <thread>
#include <chrono>
 
struct Box {
    explicit Box(int num) : num_things{num} {}
 
    int num_things;
    std::mutex m;
};
 
void transfer(Box &from, Box &to, int num)
{
    // 仍未实际取锁
    std::unique_lock<std::mutex> lock1(from.m, std::defer_lock);
    std::unique_lock<std::mutex> lock2(to.m, std::defer_lock);
 
    // 锁两个 unique_lock 而不死锁
    std::lock(lock1, lock2);
 
    from.num_things -= num;
    to.num_things += num;
 
    // 'from.m' 与 'to.m' 互斥解锁于 'unique_lock' 析构函数
}
 
int main()
{
    Box acc1(100);
    Box acc2(50);
 
    std::thread t1(transfer, std::ref(acc1), std::ref(acc2), 10);
    std::thread t2(transfer, std::ref(acc2), std::ref(acc1), 5);
 
    t1.join();
    t2.join();
}

用于指定锁定策略的标签类型

defer_lock_t不获得互斥的所有权

try_to_lock_t尝试获得互斥的所有权而不阻塞

adopt_lock_t假设调用方线程已拥有互斥的所有权 

#include <mutex>
#include <thread>
 
struct bank_account {
    explicit bank_account(int balance) : balance(balance) {}
    int balance;
    std::mutex m;
};
 
void transfer(bank_account &from, bank_account &to, int amount)
{
    // 锁定两个互斥而不死锁
    std::lock(from.m, to.m);
    // 保证二个已锁定互斥在作用域结尾解锁
    std::lock_guard<std::mutex> lock1(from.m, std::adopt_lock);
    std::lock_guard<std::mutex> lock2(to.m, std::adopt_lock);
 
// 等价方法:
//    std::unique_lock<std::mutex> lock1(from.m, std::defer_lock);
//    std::unique_lock<std::mutex> lock2(to.m, std::defer_lock);
//    std::lock(lock1, lock2);
 
    from.balance -= amount;
    to.balance += amount;
}
 
int main()
{
    bank_account my_account(100);
    bank_account your_account(50);
 
    std::thread t1(transfer, std::ref(my_account), std::ref(your_account), 10);
    std::thread t2(transfer, std::ref(your_account), std::ref(my_account), 5);
 
    t1.join();
    t2.join();
}

 

 

#include <iostream>
#include <thread>
#include <mutex>

std::mutex mtx;

void function1() {
    std::unique_lock<std::mutex> lock(mtx); // 独占锁,保护共享资源
    std::cout << "Hello from thread!" << std::endl;
}

int main() {
    std::thread t1(function1);
    t1.join();

    return 0;
}

条件变量

条件变量用于线程间的通信,一个线程等待特定的条件发生,而另一个线程负责满足这个条件。C++标准库提供了 std::condition_variable 类来实现条件变量。4.1 介绍:
头文件 #include <condition_variable>

std::condition_variable 是 C++11 提供的一种线程同步原语,它可以用于在线程之间等待和通知事件的发生。

通常情况下,线程需要等待某个条件变量被满足后才能继续执行,这个等待的过程通常使用 std::unique_lock 和std::condition_variable 一起使用。

std::condition_variable 类提供了两个主要的方法:

  1. wait(lock):等待条件变量的通知,如果条件变量没有被满足,线程将被阻塞并释放关联的互斥锁 lock。当条件变量被满足时,线程会重新获取互斥锁并继续执行。
  2. notify_one() 或 notify_all():通知等待在条件变量上的线程条件已经被满足,等待的线程将被唤醒,继续执行。

使用 std::condition_variable 的一般流程如下 :

  • 首先定义一个互斥量(std::mutex)和一个条件变量(std::condition_variable),用来保证线程安全和线程之间的同步。
std::mutex _queueMutex;
std::condition_variable _queueCond;
  • 在往任务队列中添加任务时,需要先获取互斥锁,并检查队列是否已满。如果已满,则使用条件变量等待有空闲位置。
// 添加任务
void addTask(Task task) {
    // 获取互斥锁
    std::unique_lock<std::mutex> lock(_queueMutex);
    
    // 如果队列已满,则等待有空闲位置
    _queueCond.wait(lock, [this](){ return _taskQueue.size() < _maxQueueSize; });
    
    // 将任务添加到队列中
    _taskQueue.push_back(std::move(task));
}
  • 在从任务队列中取出任务时,也需要获取互斥锁,并检查队列是否为空。如果为空,则使用条件变量等待有任务可取。
// 取出任务
Task takeTask() {
    // 获取互斥锁
    std::unique_lock<std::mutex> lock(_queueMutex);
    
    // 如果队列为空,则等待有任务可取
    _queueCond.wait(lock, [this](){ return !_taskQueue.empty(); });
    
    // 从队列中取出一个任务
    Task task = std::move(_taskQueue.front());
    _taskQueue.pop_front();
    
    return task;
}

wait 

阻塞当前线程,直到条件变量被唤醒

#include <iostream>
#include <string>
#include <thread>
#include <mutex>
#include <condition_variable>
 
std::mutex m;
std::condition_variable cv;
std::string data;
bool ready = false;
bool processed = false;
 
void worker_thread()
{
    // 等待直至 main() 发送数据
    std::unique_lock<std::mutex> lk(m);
    cv.wait(lk, []{return ready;});
 
    // 等待后,我们占有锁。
    std::cout << "Worker thread is processing data\n";
    data += " after processing";
 
    // 发送数据回 main()
    processed = true;
    std::cout << "Worker thread signals data processing completed\n";
 
    // 通知前完成手动解锁,以避免等待线程才被唤醒就阻塞(细节见 notify_one )
    lk.unlock();
    cv.notify_one();
}
 
int main()
{
    std::thread worker(worker_thread);
 
    data = "Example data";
    // 发送数据到 worker 线程
    {
        std::lock_guard<std::mutex> lk(m);
        ready = true;
        std::cout << "main() signals data ready for processing\n";
    }
    cv.notify_one();
 
    // 等候 worker
    {
        std::unique_lock<std::mutex> lk(m);
        cv.wait(lk, []{return processed;});
    }
    std::cout << "Back in main(), data = " << data << '\n';
 
    worker.join();
}

 wait_for

阻塞当前线程,直到条件变量被唤醒,或到指定时限时长后

#include <iostream>
#include <atomic>
#include <condition_variable>
#include <thread>
#include <chrono>
using namespace std::chrono_literals;
 
std::condition_variable cv;
std::mutex cv_m;
int i;
 
void waits(int idx)
{
    std::unique_lock<std::mutex> lk(cv_m);
    if(cv.wait_for(lk, idx*100ms, []{return i == 1;})) 
        std::cerr << "Thread " << idx << " finished waiting. i == " << i << '\n';
    else
        std::cerr << "Thread " << idx << " timed out. i == " << i << '\n';
}
 
void signals()
{
    std::this_thread::sleep_for(120ms);
    std::cerr << "Notifying...\n";
    cv.notify_all();
    std::this_thread::sleep_for(100ms);
    {
        std::lock_guard<std::mutex> lk(cv_m);
        i = 1;
    }
    std::cerr << "Notifying again...\n";
    cv.notify_all();
}
 
int main()
{
    std::thread t1(waits, 1), t2(waits, 2), t3(waits, 3), t4(signals);
    t1.join(); t2.join(), t3.join(), t4.join();
}

wait_until

阻塞当前线程,直到条件变量被唤醒,或直到抵达指定时间点 

#include <iostream>
#include <atomic>
#include <condition_variable>
#include <thread>
#include <chrono>
using namespace std::chrono_literals;
 
std::condition_variable cv;
std::mutex cv_m;
std::atomic<int> i{0};
 
void waits(int idx)
{
    std::unique_lock<std::mutex> lk(cv_m);
    auto now = std::chrono::system_clock::now();
    if(cv.wait_until(lk, now + idx*100ms, [](){return i == 1;}))
        std::cerr << "Thread " << idx << " finished waiting. i == " << i << '\n';
    else
        std::cerr << "Thread " << idx << " timed out. i == " << i << '\n';
}
 
void signals()
{
    std::this_thread::sleep_for(120ms);
    std::cerr << "Notifying...\n";
    cv.notify_all();
    std::this_thread::sleep_for(100ms);
    i = 1;
    std::cerr << "Notifying again...\n";
    cv.notify_all();
}
 
int main()
{
    std::thread t1(waits, 1), t2(waits, 2), t3(waits, 3), t4(signals);
    t1.join(); 
    t2.join();
    t3.join();
    t4.join();
}

c++ memory order

概念

在 cpp11 标准原子库中(std::atomic),大多数函数都接受一个参数:std::memory_order:

typedef enum memory_order {
    memory_order_relaxed,
    memory_order_consume,
    memory_order_acquire,
    memory_order_release,
    memory_order_acq_rel,
    memory_order_seq_cst
} memory_order;

enum class memory_order : /*unspecified*/ {
    relaxed, consume, acquire, release, acq_rel, seq_cst
};
inline constexpr memory_order memory_order_relaxed = memory_order::relaxed;
inline constexpr memory_order memory_order_consume = memory_order::consume;
inline constexpr memory_order memory_order_acquire = memory_order::acquire;
inline constexpr memory_order memory_order_release = memory_order::release;
inline constexpr memory_order memory_order_acq_rel = memory_order::acq_rel;
inline constexpr memory_order memory_order_seq_cst = memory_order::seq_cst;
(C++20 起)

std::memory_order 指定内存访问,包括常规的非原子内存访问,如何围绕原子操作排序。在没有任何制约的多处理器系统上,多个线程同时读或写数个变量时,一个线程能观测到变量值更改的顺序不同于另一个线程写它们的顺序。其实,更改的顺序甚至能在多个读取线程间相异。一些类似的效果还能在单处理器系统上出现,因为内存模型允许编译器变换。

库中所有原子操作的默认行为提供序列一致顺序(见后述讨论)。该默认行为可能有损性能,不过可以给予库的原子操作额外的 std::memory_order 参数,以指定附加制约,在原子性外,编译器和处理器还必须强制该操作。

cpp原子库中所有原子操作的默认行为是序列一致的顺序(memory_order_seq_cst, 见后述讨论)。该默认行为可能有损性能,不过也可以传递给线程对原子操作额外的 std::memory_order 参数,以指定附加制约,在原子性外,编译器和处理器还必须强制该操作。

内存模型基础

  1. 我们都知道为了避免 race condition线程就要规定代码语句(语句块)的执行顺序。通常我们都是使用 mutex 加锁,后一线程必须等待前一线程解锁才能继续执行。第二种方式是使用原子操作来避免竞争访问同一内存位置。
  2. 所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直执行到结束,在执行完毕之前不会被任何其它任务或事件中断。原子操作是不可分割的操作,要么做了要么没做,不存在做一半的状态。如果读取对象值的加载操作是原子的,那么对象上的所有修改操作也是原子的,读取的要么是初始值,要么是某个修改完成后的存储值。因此,原子操作不存在修改过程中值被其他线程看到的情况,也就避免了竞争风险。
  3. 每个对象从初始化开始都有一个修改顺序,这个顺序由来自所有线程对该对象的写操作组成。通常这个顺序在运行时会变动,但在任何给定的程序执行中,系统中所有线程都必须遵循此顺序。如果对象不是原子类型,就要通过同步来保证线程遵循每个变量的修改顺序。如果一个变量对于不同线程表现出不同的值序列,就会导致数据竞争和未定义行为。使用原子操作就可以把同步的责任抛给编译器
  4. 内存模型,准确来说应该是多线程内存模型,也叫动态内存模型多个线程对同一个对象同时读写时所做的约束,该模型理解起来要复杂一些,涉及了内存、Cache、CPU各个层次的交互,尤其是在多核系统中为了保证多线程环境下执行的正确性,需要对读写事件加以严格限制。std::memory_order就是用来做这事的,它实际上是程序员、编译器以及CPU之间的契约,遵守契约后大家各自优化,从而可能提高程序性能。 std::memory_order,它表示机器指令是以什么样的顺序被处理器执行的 ,一般现代的处理器不是逐条处理机器指令的。下面来简单举个例子说明一下(初始:x=y=0):

线程1线程2
① x = 1;③ y=2;
② r1 = y;④ r2 = x

假设编译器、CPU不对指令进行重排,也没有使用std::memory_order,且两个线程交织执行(假设以上四条语句都是原子操作)时共有4!/(2!*2!)=6种情况:

  • ①②③④ -> r1=0,r2=1
  • ③④①② -> r1=2,r2=0
  • ①③④② -> r1=2,r2=1
  • ①③②④ -> r1=2,r2=1
  • ③①②④ -> r1=2,r2=1
  • ③①④② -> r1=2,r2=1


从上面看,最终r1和r2的最终结果共有3种,r1= r2 = 0的情况不可能出现。但是当四条语句不是原子操作时。有一种可能是CPU的指令预处理单元看到‘线程1’的两条语句没有依赖性(不管哪条语句先执行,在两条指令语句完成后都会得到一样的结果),会先执行r1=y再执行x=1,或者两条指令同时执行,这就是CPU的多发射和乱序执行。对于线程2也一样。这样一来就有可能出来r1= r2= 0的结果。执行顺序可能就是:②④①③

另外一种r1= r2 = 0的情况是:线程1和线程线程2分别在不同的CPU核上执行,大家都知道CPU中是有Cache和RAM的,简单的理解一下程序的执行都是从RAM->Cache->CPU,执行完后CPU->Cache->RAM,有一种可能是当Core1和Core2都将x,y更新到L1 Cache中,而还未来得及更新到RAM时,两个线程都已经执行完了第二条语句,此时也会出现r1= r2= 0。

另外一个就是,从编译器层面也一样,为了获取更高的性能,它也可能会对语句进行执行顺序上的优化(类似CPU乱序)。

因此,在编译器优化+CPU乱序+缓存不一致的多重组合下,情况不止以上三种。但不管哪一种,都不是我们希望看到的。那么如何避免呢?最简单的,也是首选的,方案当然是std::mutex。因为使用mutex对比atomic更容易分析程序出现的各种错误,对于mutex的错误,大多数都是漏了加锁,或者加锁次序错乱等问题,但是如果使用atomic就比较难排查问题那std::atomic是不是就没啥用呢,当然不是,当程序对代码执行效率要求很高,std::mutex不满足时,就需要std::atomic上场了,因为std::atomic主要是为了提高性能而生的。

现在的编译器基本都支持指令重排,上述的现象也只是理想情况下的执行情况,因为顺序一致性代价太大不利于程序的优化。但是有时候你需要对编译器的优化行为做出一定的约束,才能保证你的程序行为和你预期的执行结果保持一致,那么这个约束就是内存模型。 如果想要写出高性能的多线程程序必须理解内存模型,编译器会给你的程序做静态优化,CPU 为了提升性能也有动态乱序执行的行为。总之,实际编程中程序不会完全按照你原始代码的顺序来执行,因此内存模型就是程序员、编译器、CPU 之间的契约。编程、编译、执行都会在遵守这个契约的情况下进行,在这样的规则之上各自做自己的优化,从而提升程序的性能。

原子操作间的关系

多线程中要保证race-condition情况下的正确运行,mutex或者atomic限制是必要的,mutex我们前面的文章已经介绍过可以理解为就是synchronizes-with的关系,而atomic的限制有两种关系:synchronizes-with和happens-before的限制,确保在线程之间运行的顺序保证

Synchronized-with

synchronizes-with关系是在原子类型的操作之间获得的关系。如果数据结构包含原子类型,并且对该数据结构的操作在内部执行适当的原子操作,则对数据结构的操作(例如锁定互斥体)可能提供这种关系,但基本上它仅来自对原子类型的操作。如果线程A存储一个值,而线程B读取该值,则线程A中存储和线程B中的加载之间存在同步关系。
从synchronizes-with的定义中我们可以看出,这种关系讲的就是线程之间的原子操作关系。

Happens-before

它指定哪些操作看到哪些其他操作的效果。如果一个线程上操作A在另一个线程上的操作B之前发生,则A在B之前发生。

  • 对于单线程之间的执行顺序当然是很直观的,如果一个操作 A 排列在另一个操作 B 之前,那么这个操作 A happens-before B,一般单线程叫A sequenced-before B。但如果多个操作发生在一条声明中(statement),那么通常他们是没有 happens-before 关系的,因为他们是未排序的。
  • 对于多线程而言, 如果一个线程中的操作A先于另一个线程中的操作B, 那么 A happens-before B(一般多线程间操作叫A inter-thread happens-before B)。一般来说inter-thread happens-before才是用来表示两个线程中两个操作被执行的先后顺序的一种描述。happens-before具有可传递性。如果Ahappens-beforeB,Bhappens-beforeC,则有Ahappens-beforeC;而且当store操作A与load操作B发生同步时,则Ahappens-beforeB;
  • Inter-thread happens-before 可以与 sequenced-before 关系结合:如果 A sequenced-before B, B inter-thread happens-before C, 那么 A inter-thread happens-before C. 这揭示了如果你在一个线程中对数据进行了一系列操作,那么你只需要一个 synchronized-with 关系, 即可使得数据对于另一个执行操作 C 的线程可见。
     

我们举个例子来简单说明一下这几种关系,看下面的程序,write_x_then_y()与read_y_then_x()运行在不同的thread:

atd::atomic<bool> x(false);
atd::atomic<bool> y(false);

void write_x_then_y()
{
    x.store(true,std::memory_order_relaxed);  // 1
    y.store(true,std::memory_order_release);  // 2
}
void read_y_then_x()
{
    while(!y.load(std::memory_order_acquire));  // 3
    if(x.load(std::memory_order_relaxed)){  // 4
        // do something
     }
}
复制代码

上面的例子中,x原子变量采用的是relaxed order, y原子变量采用的是acquire-release order

  • 两个线程中的y原子存在synchronizes-with的关系,也就是语句②和③属于synchronizes-with关系,因为它们本身就是原子间的操作store和load。
  • read_y_then_x的load与 write_x_then_y的y.store存在一种happens-before的关系,虽然relaxed并不保证happens-before关系,但是在同一线程里,release会保证在其之前的原子store操作都能被看见(语句①一定在语句②前完成)acquire能保证通线程中的后续的load都能读到最新指(语句③也一定在语句④前完成)。所以当y.load为true的时候,x肯定可以读到最新值(true)。所以即使这里x用的是relaxed操作,所以其也能达到acquire-release的作用。

std::memory_order

内存的顺序描述了计算机CPU获取内存的顺序,内存的排序可能静态也可能动态的发生:

  • 静态内存排序:编译器期间,编译器对内存重排
  • 动态内存排序:运行期间,CPU乱序执行

静态内存排序是为了提高代码的利用率和性能,编译器对代码进行了重新排序;同样为了优化性能CPU也会进行对指令进行重新排序、延缓执行、各种缓存等等,以便达到更好的执行效果。虽然经过排序确实会导致很多执行顺序和源码中不一致,但是你没有必要为这些事情感到棘手足无措。任何的内存排序都不会违背代码本身所要表达的意义,并且在单线程的情况下通常不会有任何的问题。 但是在多线程场景中,无锁的数据结构设计中,指令的乱序执行会造成无法预测的行为。所以我们通常引入内存栅栏这一概念来解决可能存在的并发问题。内存栅栏是一个令 CPU 或编译器在内存操作上限制内存操作顺序的指令,通常意味着在 barrier 之前的指令一定在 barrier 之后的指令之前执行。 在 C11/cpp11 中,引入了六种不同的 memory order,可以让程序员在并发编程中根据自己需求尽可能降低同步的粒度,以获得更好的程序性能。这六种 order 分别是:
 

typedef enum memory_order {
  memory_order_relaxed,  // 无同步或顺序限制,只保证当前操作原子性
  memory_order_consume,  // 标记读操作,依赖于该值的读写不能重排到此操作前
  memory_order_acquire,  // 标记读操作,之后的读写不能重排到此操作前
  memory_order_release,  // 标记写操作,之前的读写不能重排到此操作后
  memory_order_acq_rel,  // 仅标记读改写操作,读操作相当于 acquire,写操作相当于 release
  memory_order_seq_cst   // sequential consistency:顺序一致性,不允许重排,所有原子操作的默认选项
} memory_order;
memory_order_relaxed	宽松操作:没有同步或顺序制约,仅对此操作要求原子性(见下方宽松顺序)。
memory_order_consume	有此内存顺序的加载操作,在其影响的内存位置进行消费操作:当前线程中依赖于当前加载的该值的读或写不能被重排到此加载前。其他释放同一原子变量的线程的对数据依赖变量的写入,为当前线程所可见。在大多数平台上,这只影响到编译器优化(见下方释放消费顺序)。
memory_order_acquire	有此内存顺序的加载操作,在其影响的内存位置进行获得操作:当前线程中读或写不能被重排到此加载前。其他释放同一原子变量的线程的所有写入,能为当前线程所见(见下方释放获得顺序)。
memory_order_release	有此内存顺序的存储操作进行释放操作:当前线程中的读或写不能被重排到此存储后。当前线程的所有写入,可见于获得该同一原子变量的其他线程(见下方释放获得顺序),并且对该原子变量的带依赖写入变得对于其他消费同一原子对象的线程可见(见下方释放消费顺序)。
memory_order_acq_rel	带此内存顺序的读修改写操作既是获得操作又是释放操作。当前线程的读或写内存不能被重排到此存储前或后。所有释放同一原子变量的线程的写入可见于修改之前,而且修改可见于其他获得同一原子变量的线程。
memory_order_seq_cst	有此内存顺序的加载操作进行获得操作,存储操作进行释放操作,而读修改写操作进行获得操作和释放操作,再加上存在一个单独全序,其中所有线程以同一顺序观测到所有修改(见下方序列一致顺序)。
memory_order_relaxed宽松操作:没有同步或顺序约束,仅对此操作要求原子性(见下方宽松顺序)。
memory_order_consume有此内存顺序的加载操作,在其影响的内存位置进行消费操作当前线程中依赖于当前值的读或写不能被重排到此加载前其他释放同一原子变量的线程的对数据依赖变量的写入,为当前线程所可见。在大多数平台上,这只影响到编译器优化(见下方释放消费顺序)。
memory_order_acquire有此内存顺序的加载操作,在其影响的内存位置进行获得操作当前线程中读或写不能被重排到此加载前。其他释放同一原子变量的线程的所有写入,能为当前线程所见(见下方释放获得顺序)。
memory_order_release有此内存顺序的存储操作进行释放操作:当前线程中的读或写不能被重排到此存储后。当前线程的所有写入,可见于获得该同一原子变量的其他线程(见下方释放获得顺序),并且对该原子变量的带依赖写入变得对于其他消费同一原子对象的线程可见(见下方释放消费顺序)。
memory_order_acq_rel带此内存顺序的读修改写操作既是获得操作又是释放操作当前线程的读或写内存不能被重排到此存储前或后。所有释放同一原子变量的线程的写入可见于修改之前,而且修改可见于其他获得同一原子变量的线程。
memory_order_seq_cst有此内存顺序的加载操作进行获得操作,存储操作进行释放操作,而读修改写操作进行获得操作释放操作,再加上存在一个单独全序,其中所有线程以同一顺序观测到所有修改(见下方序列一致顺序)。

除非为操作指定一个特定的序列,否则原子类型操作的内存序列默认都是memory_order_seq_cst。上面的六个类型可以统分为三大内存模型:

  • 顺序一致性:memory_order_seq_cst,这个是默认的内存序,我们上面也多次提到,顺序一致性不能很好的利用硬件的资源才有了其他的内存序,大家都知道,在多核情况下,每个CPU核都有自己的cache,一个变量可能被多个CPU核读到自己的cache中运算,顺序一致性要求每一个CPU核修改了变量就要与其他CPU核的cache进行同步,这样就牵扯到CPU核之间的通信。所以顺序一致会增加CPU之间通信逻辑,因此相对消耗更多指令。
  • 获取-释放序:memory_order_consume, memory_order_acquire, memory_order_release和memory_order_acq_rel;acquire 和 release虽然并没有sequence consistency那样强的约束,但是比relaxed order约束要强一个等级,另外acquire 和 release也引入了synchronize的关系。同步是成对的,在执行释放的线程和执行获取的线程之间。释放操作与读取写入值的获取操作同步。这意味着不同的线程仍然可以看到不同的排序,但这些排序是受限的。
  • 自由序:memory_order_relaxed,不保证任何的指令执行顺序

Relaxed ordering

  • 标记为 memory_order_relaxed 的原子操作不是同步操作,不强制要求并发内存的访问顺序,只保证原子性和修改顺序一致性
  • Relaxed ordering的限定范围是同线程,在同一线程内对同一原子变量的访问不可以被重排,仍保持happens-before关系
#include <atomic>
#include <thread>
#include <iostream>

std::atomic<int> x = 0;
std::atomic<int> y = 0;

void f() {
  int i = y.load(std::memory_order_relaxed);  // 1 happen before 2
  x.store(i, std::memory_order_relaxed);  // 2
}

void g() {
  int j = x.load(std::memory_order_relaxed);   // 3
  y.store(42, std::memory_order_relaxed);  // 4
}

int main() {
  std::thread t1(f);
  std::thread t2(g);
  t1.join();
  t2.join();
  std::cout << x << " - " << y << std::endl;
  // 可能执行顺序为 4123,结果 x == 42, y == 42
  // 可能执行顺序为 1234,结果 x == 0, y == 42
}
复制代码
  • Relaxed ordering 不允许循环依赖,下面例子由于使用relaxed,线程f和g之间的操作是没办法保证依赖关系的:
#include <atomic>
#include <thread>
#include <iostream>

std::atomic<int> x = 0;
std::atomic<int> y = 0;

void f() {
  std::cout << "1\n";
  int i = y.load(std::memory_order_relaxed);  // 1
  std::cout << "2\n";
  if (i == 42) {
    x.store(i, std::memory_order_relaxed);  // 2
  }
}

void g() {
  std::cout << "3\n";
  int j = x.load(std::memory_order_relaxed);  // 3
  std::cout << "4\n";
  if (j == 42) {
    y.store(42, std::memory_order_relaxed);  // 4
  }
}


int main() {
  std::thread t1(f);
  std::thread t2(g);
  t1.join();
  t2.join();
  std::cout << x << " - " << y << std::endl;
  // 一般的顺序是1234
  // 结果不允许为 x = 42, y = 42
  // 因为要产生这个结果,1 依赖 4,4 依赖 3,3 依赖 2,2 依赖 1
}
复制代码

Relaxed ordering一般适用于只要求原子操作,不需要其它同步保障的情况。典型使用场景是自增计数器,比如 std::shared_ptr 的引用计数器,它只要求原子性,不要求顺序和同步

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>

std::atomic<int> x = 0;

void f() {
  for (int i = 0; i < 1000; ++i) {
    x.fetch_add(1, std::memory_order_relaxed);
  }
}

int main() {
  std::vector<std::thread> v;
  for (int i = 0; i < 10; ++i) {
    v.emplace_back(f);
  }
  for (auto& x : v) {
    x.join();
  }
  std::cout << x;  // 一定是输出 10000
}
复制代码

Release-Consume ordering

  • 对于标记为 memory_order_consume 原子变量 x 的读操作 R,当前线程中依赖于 x 的读写不允许重排到 R 之前,其他线程中对依赖于 x 的变量写操作对当前线程可见
  • 如果线程 A 对一个原子变量x的写操作为 memory_order_release,线程 B 对同一原子变量的读操作为 memory_order_consume,带来的副作用是,线程 A 中所有 dependency-ordered-before 该写操作的其他写操作(non-atomic和relaxed atomic),在线程 B 的其他依赖于该变量的读操作中可见
  • 典型使用场景是访问很少进行写操作的数据结构(比如路由表),以及以指针为中介的 publisher-subscriber 场景,即生产者发布一个指针给消费者访问信息,但生产者写入内存的其他内容不需要对消费者可见,这个场景的一个例子是 RCU(Read-Copy Update)。该顺序的规范正在修订中,并且暂时不鼓励使用 memory_order_consume
#include <atomic>
#include <cassert>
#include <thread>

std::atomic<int*> x;
int i;

void producer() {
  int* p = new int(42);
  i = 42;
  x.store(p, std::memory_order_release);
}

void consumer() {
  int* q;
  while (!(q = x.load(std::memory_order_consume))) {
  }
  assert(*q == 42);  // 一定不出错:*q 带有 x 的依赖
  assert(i == 42);   // 可能出错也可能不出错:i 不依赖于 x
}

int main() {
  std::thread t1(producer);
  std::thread t2(consumer);
  t1.join();
  t2.join();
}
复制代码


consume语义是一种弱的acquire,它只对关联变量进行约束,这个实际编程中基本不用,且在某些情况下会自动进化成acquire语义(比如当用consume语义修饰的load操作在if条件表达式中时)。另外,cpp17标准明确说明这个语义还未完善,建议直接使用acquire。

Release-Acquire ordering

  • 在release之前的所有store操作绝不会重排到(不管是编译器对代码的重排还是CPU指令重排)此release对应的操作之后,也就是说如果release对应的store操作完成了,则cpp标准能够保证此release之前的所有store操作肯定已经先完成了,或者说可被感知了;
  • 在acquire之后的所有load操作或者store操作绝对不会重排到此acquire对应的操作之前,也就是说只有当执行完此acquire对应的load操作之后,才会执行后续的读操作或者写操作。
  • 我们看下面的例子,由于①使用 了 memory_order_release的写操作 ,②对同一原子变量x进行了memory_order_acquire 的读操作,所以当②的while读到q不为null退出后,则往下的两个assert一定不出错,因为②的while退出,说明①一定执行完了,release-acquire对同一原子的WR操作规定了,release前的操作必定先发生(下面的01和02必定先与①执行)。
#include <atomic>
#include <cassert>
#include <thread>

std::atomic<int*> x;
int i;

void producer() {
  int* p = new int(42);  // 01
  i = 42;  // 02
  x.store(p, std::memory_order_release);  // 1 happens-before 2(由于 2 的循环)
}

void consumer() {
  int* q;
  while (!(q = x.load(std::memory_order_acquire))) { // 2
  }
  assert(*q == 42);  // 一定不出错
  assert(i == 42);   // 一定不出错
}

int main() {
  std::thread t1(producer);
  std::thread t2(consumer);
  t1.join();
  t2.join();
}
复制代码

  • 对于标记为 memory_order_release 的写操作 W,有以下一些限制(作用)
  • 当前线程中的其他读写操作不允许重排到W之后;
  • 若其他线程 acquire 该原子变量,则当前线程所有 happens-before 的写操作在其他线程中可见;
  • 若其他线程 consume 该原子变量,则当前线程所有 dependency-ordered-before W 的其他写操作在其他线程中可见;
  • 对于标记为 memory_order_acq_rel 的读改写(read-modify-write)操作,相当于写操作是 memory_order_release,读操作是 memory_order_acquire,当前线程的读写不允许重排到这个写操作之前或之后,其他线程中 release 该原子变量的写操作在修改前可见,并且此修改对其他 acquire 该原子变量的线程可见

Release-Acquire ordering 并不表示 total ordering

#include <atomic>
#include <thread>

std::atomic<bool> x = false;
std::atomic<bool> y = false;
std::atomic<int> z = 0;

void write_x() {
  x.store(true, std::memory_order_release);  // 1 happens-before 3(由于 3 的循环)
}

void write_y() {
  y.store(true, std::memory_order_release);  // 2 happens-before 5(由于 5 的循环)
}

void read_x_then_y() {
  while (!x.load(std::memory_order_acquire)) {  // 3 happens-before 4,因为3使用了acquire
  }
  if (y.load(std::memory_order_acquire)) {  // 4
    ++z;
  }
}

void read_y_then_x() {
  while (!y.load(std::memory_order_acquire)) {  // 5 happens-before 6,因为5使用了acquire
  }
  if (x.load(std::memory_order_acquire)) {  // 6
    ++z;
  }
}

int main() {
  std::thread t1(write_x);
  std::thread t2(write_y);
  std::thread t3(read_x_then_y);
  std::thread t4(read_y_then_x);
  t1.join();
  t2.join();
  t3.join();
  t4.join();
  // z可能为0:134执行则y为false,256执行则x为false,但1,2之间没有顺序关系
}
复制代码


为了使两个写操作有序,将其放到一个线程里

#include <atomic>
#include <cassert>
#include <thread>

std::atomic<bool> x = false;
std::atomic<bool> y = false;
std::atomic<int> z = 0;

void write_x_then_y() {
  x.store(true, std::memory_order_relaxed);  // 1 happens-before 2,因为2使用了release
  y.store(true, std::memory_order_release);  // 2 happens-before 3(由于 3 的循环)
}

void read_y_then_x() {
  while (!y.load(std::memory_order_acquire)) {  // 3 happens-before 4,因为3使用了acquire
  }
  if (x.load(std::memory_order_relaxed)) {  // 4
    ++z;
  }
}

int main() {
  std::thread t1(write_x_then_y);
  std::thread t2(read_y_then_x);
  t1.join();
  t2.join();
  assert(z.load() != 0);  // 顺序一定为 1234,z一定不为 0
}
复制代码


利用 Release-Acquire ordering 可以传递同步,我们看下面的例子,最终实现1234这样的执行顺序,1 happens-before 2,3 happens-before 4,因为2:happens-before 3,所以1234顺序是必然的:

#include <atomic>
#include <cassert>

std::atomic<bool> x = false;
std::atomic<bool> y = false;
std::atomic<int> v[2];

void f() {
  // v[0]、v[1] 的设置没有先后顺序,但都 happens-before 1,因为1使用了release
  v[0].store(1, std::memory_order_relaxed);
  v[1].store(2, std::memory_order_relaxed);
  x.store(true, std::memory_order_release);  // 1 happens-before 2(由于 2 的循环)
}

void g() {
  while (!x.load(std::memory_order_acquire)) {  // 2:happens-before 3,因为2使用了acquire
  }
  y.store(true, std::memory_order_release);  // 3 happens-before 4(由于 4 的循环)
}

void h() {
  while (!y.load(std::memory_order_acquire)) {  // 4 happens-before v[0]、v[1] 的读取
  }
  assert(v[0].load(std::memory_order_relaxed) == 1);
  assert(v[1].load(std::memory_order_relaxed) == 2);
}
复制代码


使用读改写操作(memory_order_acq_rel)可以将上面的两个标记合并为一个,也能得到一样的效果,看下面的例子,也是必然安装123顺序执行:

#include <atomic>
#include <cassert>

std::atomic<int> x = 0;
std::atomic<int> v[2];

void f() {
  v[0].store(1, std::memory_order_relaxed);
  v[1].store(2, std::memory_order_relaxed);
  x.store(1, std::memory_order_release);  // 1 happens-before 2(由于 2 的循环)
}

void g() {
  int i = 1;
  // 如果x当前的值等于i则将x改写为2,返回true,如果不等则让i=x,并返回false
  while (!x.compare_exchange_strong(i, 2, std::memory_order_acq_rel)) {  // 2 happens-before 3(由于 3 的循环)
    // x 为 1 时,将 x 替换为 2,返回 true
    // x 为 0 时,将 i 替换为 x,返回 false
    i = 1;  // 返回 false 时,x 未被替换,i 被替换为 0,因此将 i 重新设为 1,再继续while
  }
}

void h() {
  while (x.load(std::memory_order_acquire) < 2) {  // 3
  }
  assert(v[0].load(std::memory_order_relaxed) == 1);
  assert(v[1].load(std::memory_order_relaxed) == 2);
}
复制代码


Sequentially-consistent ordering


memory_order_seq_cst 是所有原子操作的默认选项,可以省略不写。对于标记为 memory_order_seq_cst 的操作,大概行为就是对每一个变量都进行上面所说的Release-Acquire操作,读操作相当于 memory_order_acquire,写操作相当于 memory_order_release,读改写操作相当于 memory_order_acq_rel,此外还附加一个单独的 total ordering,即所有线程对同一操作看到的顺序也是相同的。这是最简单直观的顺序,但由于要求全局的线程同步,因此也是开销最大的

#include <atomic>
#include <cassert>
#include <thread>

std::atomic<bool> x = false;
std::atomic<bool> y = false;
std::atomic<int> z = 0;

// 要么 1 happens-before 2,要么 2 happens-before 1
void write_x() {
  x.store(true);  // 1 happens-before 3(由于 3 的循环)
}

void write_y() {
  y.store(true);  // 2 happens-before 5(由于 5 的循环)
}

void read_x_then_y() {
  while (!x.load()) {  // 3 happens-before 4
  }
  if (y.load()) {  // 4 为 false 则 1 happens-before 2
    ++z;
  }
}

void read_y_then_x() {
  while (!y.load()) {  // 5 happens-before 6
  }
  if (x.load()) ++z;  // 6 如果返回false则一定是2 happens-before 1
}

int main() {
  std::thread t1(write_x);
  std::thread t2(write_y);
  std::thread t3(read_x_then_y);
  std::thread t4(read_y_then_x);
  t1.join();
  t2.join();
  t3.join();
  t4.join();
  assert(z.load() != 0);  // z 一定不为0
  // z 可能为 1(134256) 或 2(123456),
  // 1和2 之间必定存在 happens-before 关系,顺序要么12,要么21
}
复制代码

向大佬致敬
C++ 多线程12:内存模型(stdmemory_order)_c++ 多线程内存模型_uManBoy的博客-CSDN博客
std::memory_order - cppreference.com
 

 

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值