C++并发编程之四 内存模型和原子操作

在本章节中,我们会从内存模型的基本要点开始讲解;接着,说明原子操作及其类别;最后,介绍借助原子操作实现几种同步机制。

5.1 内存模型基础

内存模型是计算机程序中重要的概念之一,它描述了程序中的数据在内存中的存储方式以及多线程程序中的数据共享和同步方式。在理解并发编程和多线程程序的正确性时,了解内存模型的基本原理是非常重要的。

计算机的内存由一系列的字节组成,每个字节都有一个唯一的地址。程序可以通过变量来引用内存中的某个地址,从而读取或写入该地址的内容。内存模型定义了程序中变量在内存中的存储方式以及读写变量时的规则。

C++内存模型主要涉及以下两个方面:

  1. 原子操作:原子操作是指不能被中断的操作。在多线程程序中,多个线程可能同时对同一个内存地址进行读写操作,如果这些操作不是原子的,就会发生一些意料之外的错误。C++ 中提供了一些原子操作,比如 std::atomic,用于对变量进行原子操作,以保证多线程程序中的正确性。

  2. Memory Order:Memory Order 定义了读写操作之间的顺序,C++ 中引入了一些 Memory Order 的概念,用于约束多线程程序中的读写操作顺序。例如,C++ 中的 std::memory_order_acquire 和 std::memory_order_release 分别表示在执行读操作之前必须先执行一个同步操作,以及在执行写操作之后必须执行一个同步操作,以保证多线程程序中的顺序性。

除了原子操作和 Memory Order,C++ 内存模型还涉及以下几个方面:
3. 对象的生命周期:C++ 中的对象有生命周期,即对象的创建、使用和销毁过程。对于多线程程序,需要考虑对象的生命周期与线程的关系,以避免使用已经被销毁的对象或未被创建的对象。

  1. 内存分配:C++ 中的内存分配和释放也可能会影响多线程程序的正确性。例如,如果多个线程同时申请和释放内存,可能会导致内存泄漏或野指针等问题。因此,在多线程程序中,需要使用线程安全的内存分配和释放机制。

  2. 线程间通信:在多线程程序中,不同的线程可能需要进行通信,以共享数据或协调工作。C++ 中提供了一些线程间通信的机制,例如 mutex、condition_variable、future 等,用于实现线程间的同步和通信。

  3. 处理器的缓存:现代处理器通常会有多级缓存,对于多线程程序,不同的线程可能会访问同一块内存,但它们所在的处理器缓存可能不同。这可能会导致缓存一致性问题,需要使用一些技术来解决,例如缓存一致性协议、MESI 等。

总之,C++ 的内存模型涉及多个方面,包括原子操作、Memory Order、对象的生命周期、内存分配、线程间通信和处理器缓存等。程序员需要深入理解这些概念和机制,并编写出高效且正确的多线程程序。

5.2 C++中的原子操作及其类别

在 C++ 编程中引入原子操作是为了确保多个线程之间的并发访问共享变量时,能够保证操作的原子性和可见性,从而避免出现数据竞争和不一致的情况。

原子操作是指在执行期间不能被中断的操作。原子操作能够保证所有的操作在一个原子操作中执行,要么全部执行成功,要么全部失败,不会出现部分执行的情况。原子操作通常是由硬件提供支持的。

在多线程编程中,共享变量的访问可能存在竞争条件,导致数据不一致或程序崩溃等问题。通过使用原子操作,程序员可以确保每个线程都能够安全地访问共享变量,避免了这些问题。

C++11 引入了 std::atomic 类模板,用于实现原子操作。std::atomic 提供了各种操作,例如读取、写入和交换操作,以确保并发访问共享变量时的原子性和可见性。

5.2.1 标准原子类型

C++标准库提供了以下几种原子类型:
std::atomic: 布尔类型原子变量,支持原子读/写操作。
std::atomic: 字符类型原子变量,支持原子读/写操作。
std::atomic: 有符号字符类型原子变量,支持原子读/写操作。
std::atomic: 无符号字符类型原子变量,支持原子读/写操作。
std::atomic: 短整型原子变量,支持原子读/写操作。
std::atomic: 无符号短整型原子变量,支持原子读/写操作。
std::atomic: 整型原子变量,支持原子读/写操作。
std::atomic: 无符号整型原子变量,支持原子读/写操作。
std::atomic: 长整型原子变量,支持原子读/写操作。
std::atomic: 无符号长整型原子变量,支持原子读/写操作。
std::atomic: 长长整型原子变量,支持原子读/写操作。
std::atomic: 无符号长长整型原子变量,支持原子读/写操作。
std::atomic: 单精度浮点型原子变量,支持原子读/写操作。
std::atomic: 双精度浮点型原子变量,支持原子读/写操作。
std::atomic: 长双精度浮点型原子变量,支持原子读/写操作。
std::atomic<void*>: 指针类型原子变量,支持原子读/写操作。
以上的原子类型都有相应的成员函数,例如 load(), store(), exchange() 等,用于进行原子操作。

需要注意的是,std::atomic类型的对象一般是被声明为全局变量或静态变量,以便在多个线程间进行共享访问。在使用原子类型时,还需要注意保证操作的原子性,以避免竞争条件的出现。

大部分原子类型都有一个共同成员函数:is_lock_free()

  1. 该函数主要用于检查一个原子类型是否支持无锁操作(lock-free operation)。

  2. 所谓无锁操作是指一个原子类型的操作可以在不使用互斥锁的情况下进行,通常使用CPU的硬件支持来实现。在支持无锁操作的平台上,原子类型的操作可以比使用互斥锁更高效地进行。

  3. is_lock_free()成员函数返回一个bool值,表示一个原子类型是否支持无锁操作。如果返回true,则表示该原子类型支持无锁操作;如果返回false,则表示该原子类型不支持无锁操作。

  4. 需要注意的是,is_lock_free()成员函数的返回值不保证在所有平台上都相同,因此在编写跨平台代码时,需要谨慎使用该函数的返回值。此外,即使一个原子类型支持无锁操作,使用互斥锁进行操作仍然是一种安全的做法。

当使用std::atomic<>泛化模板时,其具备的操作包括:

  1. 加载(load)操作:用于从原子对象中读取当前值并返回。可以使用std::memory_order参数指定内存序(memory order)来控制原子操作的同步方式。

  2. 存储(store)操作:用于将给定的值存储到原子对象中。同样可以使用std::memory_order参数控制同步方式。

  3. 交换(exchange)操作:用于原子地交换原子对象中的值和给定的值,并返回原来的值。也可以使用std::memory_order参数来指定同步方式。

  4. 比较并交换(compare_and_exchange, CAS)操作:用于原子地比较原子对象的当前值和期望值,如果相等则将新值存储到原子对象中。CAS操作可以帮助解决多线程编程中的竞争条件。也可以使用std::memory_order参数来指定同步方式。

  5. 原子操作(compare_exchange_strong, compare_exchange_weak):与CAS操作类似,不过在比较和交换期间可能进行额外的检查,以避免不必要的CAS操作。compare_exchange_strong()保证CAS操作的强一致性,而compare_exchange_weak()则提供了弱一致性的保证。

  6. 原子递增(increment)和递减(decrement)操作:用于原子地增加或减少原子对象的值。也可以使用std::memory_order参数来指定同步方式。

除此之外,std::atomic<>泛化模板还支持一些其他的操作,如fetch_add、fetch_sub、fetch_and、fetch_or、fetch_xor等,这些操作可以原子地执行加法、减法、按位与、按位或和按位异或等运算,并返回操作前的值。同样可以使用std::memory_order参数来指定同步方式。

当使用原子类型进行多线程同步时,需要使用内存模型来指定操作之间的顺序关系。C++11 提供了五种内存模型,分别是 memory_order_relaxed、memory_order_acquire、memory_order_release、memory_order_acq_rel 和 memory_order_seq_cst。

  1. memory_order_relaxed
    该内存模型是最弱的一种内存模型。使用该内存模型,不保证原子操作的顺序和其他线程所见到的顺序一致,也不保证操作的可见性。该内存模型可以提高程序的性能,但是需要开发者自行处理数据的一致性。

  2. memory_order_acquire
    该内存模型用于读取操作,可以保证在读取 atomic 变量之前的所有内存访问都被完成。也就是说,其他线程对 atomic 变量的写入必须在当前线程进行读取之前完成,否则当前线程可能看不到最新的数据。

  3. memory_order_release
    该内存模型用于写入操作,可以保证在写入 atomic 变量之后的所有内存访问都被完成。也就是说,当前线程对 atomic 变量的写入必须在其他线程进行读取之前完成,否则其他线程可能看不到最新的数据。

  4. memory_order_acq_rel
    该内存模型是 memory_order_acquire 和 memory_order_release 的组合,即同时包含读取和写入的同步机制。这种内存模型可以保证在写入 atomic 变量之后的所有内存访问都被完成,并且其他线程对 atomic 变量的读取必须在当前线程进行写入之前完成,从而确保线程之间的数据同步。

  5. memory_order_seq_cst
    该内存模型是最严格的一种内存模型,可以保证所有的操作都按照程序中指定的顺序执行。在该内存模型下,所有的读取和写入都是按照全局的顺序进行的,因此可以保证所有线程所看到的数据都是一致的。但是,由于要保证全局的顺序,因此会带来一定的性能损失。

需要注意的是,使用较弱的内存模型可以提高程序的性能,但是会增加代码的复杂度和调试难度。在实际应用中,需要根据具体的需求和场景选择适当的内存模型。

5.2.2 操作std::atomic_flag

std::atomic_flag是最简单的标准原子类型,表示一个布尔标志。该类型的对象两种状态:成立或置零。二者必居其一。std::atomic_flag类型的对象必须由宏ATOMIC_FLAG_INIT初始化,他把标志初始化为置零状态:std::atomic_flag f = ATOMIC_FLAG_INIT。无论在哪里声明,也无论处于什么作用域,std::atomic_flag对象永远以置零状态开始。

我们无法从std::atomic_flag对象拷贝构造出另一个对象,也无法向另一个对象拷贝赋值,这两个限制并非std::atomic_flag独有,所有原子类型都同样受限。原因是按定义,原子类型上的操作全都是原子化的,但拷贝赋值和拷贝构造都涉及两个对象,而牵涉两个不同对象的单一操作却无法原子化。在拷贝构造或拷贝赋值的过程中,必须先从来源对象读取值,再将其写出到目标对象。这是在两个独立对象上的两个独立操作,其组合是不可能原子化的。所以原子对象禁止拷贝赋值和拷贝构造。

std::atomic_flag 对象只有两个状态:已设置(set)和未设置(clear),它提供了以下两个操作:

  1. test_and_set():将标志设置为已设置,并返回之前的状态(即返回之前是否已设置)。
  2. clear():将标志设置为未设置。

这两个操作都是原子操作,可以保证在多线程环境下安全地操作同一标志。
下面是一个简单的示例,演示了如何使用std::atomic_flag来实现基本的互斥机制:

#include <atomic>
#include <thread>

std::atomic_flag lock = ATOMIC_FLAG_INIT;

void foo()
{
    while (lock.test_and_set(std::memory_order_acquire))
    {
        // 等待锁被释放
    }
// 在这里执行临界区代码
    std::cout << "Hello, world!" << std::endl;
    lock.clear(std::memory_order_release);
}

int main()
{
    std::thread t1(foo);
    std::thread t2(foo);

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

    return 0;
}

在这个例子中,lock是一个std::atomic_flag对象,用来实现互斥。当一个线程需要访问受保护的资源时,它会调用test_and_set()方法来尝试获取锁,如果返回值为真,表示锁已经被其他线程获取,那么当前线程就会等待锁被释放;如果返回值为假,表示锁当前未被占用,那么当前线程就可以执行需要保护的代码。

当线程执行完需要保护的代码后,调用clear()方法来释放锁。

需要注意的是,在test_and_set()和clear()方法中,我们都使用了内存序参数std::memory_order_acquire和std::memory_order_release,这是为了确保对内存的访问是有序的。具体来说,std::memory_order_acquire确保在获取锁之前的所有读操作都在获取锁之前完成,而std::memory_order_release确保在释放锁之后的所有写操作都在释放锁之后完成。这样可以确保线程间的同步,避免出现不可预测的错误。

总之,std::atomic_flag可以用来实现简单的互斥机制,但是它并不适用于所有情况。如果需要更复杂的同步机制,可以考虑使用更高级的同步原语,如std::mutex。由于std::atomic_flag严格受限,甚至不支持单纯的无修改插值操作,无法用作普通的布尔标志,因此最好还是使用std::atomic 。下面我们来介绍一下std::atomic

5.2.3 操作std::atomic<bool>

std::atomic<bool>是一个模板类,它封装了一个布尔类型的原子变量,可以保证多线程环境下对它的操作是原子的,也就是说不会出现数据竞争或不一致的情况。它有以下几个成员函数:

  • 构造函数:可以用一个布尔值或另一个std::atomic对象来初始化原子变量。
  • operator=:可以用一个布尔值或另一个std::atomic对象来赋值给原子变量。
  • is_lock_free:可以检查原子变量是否是无锁的,也就是说是否不需要使用锁或其他同步机制来保证原子性。
  • store:可以用一个布尔值或另一个std::atomic对象来修改原子变量的值,并指定内存顺序。
  • load:可以读取原子变量的值,并指定内存顺序。 operator T:可以隐式地将原子变量转换为布尔类型,并返回其当前值。
  • exchange:可以用一个布尔值或另一个std::atomic对象来替换原子变量的值,并返回旧值,并指定内存顺序。
  • compare_exchange_weak和compare_exchange_strong:可以将原子变量与一个期望值进行比较,如果相等,就将其改为一个新值,并返回true;如果不相等,就将其当前值赋给期望值,并返回false。这两个函数的区别在于compare_exchange_weak可能会在没有必要时返回false,而compare_exchange_strong只有在确实不相等时才返回false。这两个函数都需要指定成功和失败时的内存顺序。

compare_exchange_weak和compare_exchange_strong函数都是用于原子地比较并交换std::atomic对象的值的12。它们的基本用法是:

bool compare_exchange_weak(T& expected, T desired,
                           std::memory_order success,
                           std::memory_order failure);

bool compare_exchange_strong(T& expected, T desired,
                             std::memory_order success,
                             std::memory_order failure); 
  1. 这两个函数都会将std::atomic对象的当前值与expected参数进行比较,如果相等,就将desired参数赋值给std::atomic对象,并返回true;如果不相等,就将当前值赋值给expected参数,并返回false。

  2. 它们的区别在于,compare_exchange_weak可能会在比较相等时失败(即产生伪失败),而compare_exchange_strong则不会.
    这是因为compare_exchange_weak只保证最终结果是正确的,而不保证每次操作都是正确的。这样可以提高性能,但也需要在循环中使用.

  3. compare_exchange_strong则保证每次操作都是正确的,但也可能更慢. 一般来说,当需要在循环中使用比较交换时,选择compare_exchange_weak会有更好的性能;当不需要循环时,选择compare_exchange_strong会更简单和安全.

  4. compare_exchange_weak可能会在比较相等时失败的原因是,它在某些平台上是用一系列的指令来实现的,而不是一个原子指令。这样可以提高性能,但也可能导致偶然的失败。这种失败并不会影响最终结果的正确性,只要我们在循环中重试就可以了。但是如果我们不需要循环,或者循环中有其他逻辑依赖于比较交换的结果,那么就应该使用compare_exchange_strong来避免伪失败。

std::atomic也支持一些基本的操作符重载,比如operator=()和operator bool()等,这些操作符可以像普通布尔类型一样使用。

下面通过一些实例进行理解:
一个使用compare_exchange_weak的例子是实现一个自旋锁。自旋锁是一种简单的同步原语,它用一个原子变量表示锁的状态,0表示未锁定,1表示已锁定。要获取锁,就要不断地尝试将变量从0改为1,直到成功为止。要释放锁,就要将变量改回0。代码如下:

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

std::atomic<bool> lock(false); // 初始化为未锁定

void f(int n) {
    while (lock.exchange(true)) {} // 获取锁
    std::cout << "Output from thread " << n << '\n';
    lock.store(false); // 释放锁
}

int main() {
    std::thread t1(f, 1);
    std::thread t2(f, 2);
    t1.join();
    t2.join();
}

这里使用了exchange函数来原子地设置变量为true,并返回之前的值。如果之前的值是false,说明获取到了锁;如果是true,说明没有获取到锁,需要继续循环。这里也可以用compare_exchange_weak来实现同样的功能:

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

std::atomic<bool> lock(false); // 初始化为未锁定

void f(int n) {
    bool expected = false;
    while (!lock.compare_exchange_weak(expected, true)) { // 获取锁
        expected = false; // 重置expected
    }
    std::cout << "Output from thread " << n << '\n';
    lock.store(false); // 释放锁
}

int main() {
    std::thread t1(f, 1);
    std::thread t2(f, 2);
    t1.join();
    t2.join();
}

这里使用了compare_exchange_weak来尝试将变量从false改为true,并返回比较结果。如果比较结果是true,说明获取到了锁;如果是false,说明没有获取到锁,并且expected被赋值为当前值(即true),需要重置expected并继续循环。这里使用compare_exchange_weak可能会产生伪失败,但不影响最终结果的正确性。

一个使用compare_exchange_strong的例子是实现一个单例模式。单例模式是一种设计模式,它保证一个类只有一个实例,并提供全局访问点。要实现一个线程安全的单例模式,就要用原子操作来保证只有一个线程能创建实例,并返回给其他线程。代码如下:

#include <atomic>
#include <memory>

class Widget {
public:
   static Widget* getInstance() {
      Widget* tmp = instance.load(); // 加载当前实例指针
      if (tmp == nullptr) { // 如果为空,则尝试创建新实例
         tmp = new Widget;
         if (instance.compare_exchange_strong(nullptr, tmp)) { // 将新实例赋值给原子指针,并检查是否成功
            return tmp; // 成功则返回新实例指针
         } else {
            delete tmp; // 失败则删除新实例,并返回已存在的实例指针
            return instance.load();
         }
      } else {
         return tmp; // 如果不为空,则直接返回当前实例指针
      }
   }

private:
   static std::atomic<Widget*> instance; // 原子指针存储唯一实例

   Widget() {} // 私有构造函数防止外部创建

   Widget(const Widget&) = delete; 
   Widget& operator=(const Widget&) = delete;
};

std::atomic<Widget*> Widget::instance(nullptr); 

这里使用了compare_exchange_strong来尝试将原子指针从nullptr改为新创建的实例,并检查是否成功。如果成功,说明这是第一个创建实例的线程,就返回新实例指针;如果失败,说明有其他线程已经创建了实例,并赋值给了原子指针,就删除新实例,并返回已存在的实例指针。这里使用compare_exchange_strong是为了避免伪失败,因为如果使用compare_exchange_weak,可能会导致多个线程都创建了新实例,并且只有一个线程成功赋值给原子指针,其他线程都需要删除自己的新实例,并重新加载原子指针。这样会浪费时间和空间。而使用compare_exchange_strong可以保证只有在原子指针确实不等于nullptr时才返回false,从而减少不必要的重试和删除操作。

5.2.4 操作std::atomic<T*>: 算术形式的指针运算

std::atomic<T*>是一个模板类,用于封装一个指针类型的原子对象。原子对象是指可以在多线程环境下安全地访问和修改的对象,不会引起数据竞争。std::atomic<T*>提供了一些成员函数和操作符,用于对指针进行原子操作,例如加载、存储、交换、比较和交换等。

std::atomic<T*>提供了一些重载的操作符,用于对指针进行原子的算术运算,例如自增、自减、加法、减法等。这些运算符都是读-修改-写操作,即先读取当前值,然后根据参数进行计算,最后将结果原子地替换当前值,并返回结果或旧值。这些运算符可以保证指针的修改不会被其他线程干扰或观察到中间状态。

std::atomic<T*>提供的新操作时算术形式的指针运算。成员函数fetch_add()和fetch_sub()给出了最基本的操作,分别就对象中存储的地址进行原子化加减。另外,该原子类型还具有包装成重载运算符的+=和-=,以及++和–的前后缀版本,用起来十分方便。

fetch_add()和fetch_sub()是std::atomic<T*>的成员函数,用于对指针进行原子的加法和减法运算。它们都接受一个偏移量参数arg和一个可选的内存顺序参数order,然后将当前值与arg相加或相减,并返回旧值。这些函数可以保证指针的修改不会被其他线程干扰或观察到中间状态。

fetch_add()和fetch_sub()与操作符+=和-=的区别在于:
fetch_add()和fetch_sub()与操作符+=和-=的区别是,前者返回旧值,后者返回新值
。例如,如果指针p指向一个整数数组a,那么下面的代码:

std::atomic<int*> p(a); 
int* q = p.fetch_add(1); // q = a 
p += 1; // p = a + 2 

会使得q指向数组的第一个元素,而p指向数组的第三个元素。
注意返回的时T类型,而不是std::atomic<T>类型。

5.2.5 操作标准整数原子类型

在 std::atomic<int> 和 std::atomic<unsigned long long> 这样的整数原子类型上,我们可以执行的操作颇为齐全,既包括常用的原子操作(load()、store()、exchange()、compare_exchange_weak() 和 compare_exchange_strong() ),也包括原子运算(fetch_add()、fetch_sub()、fetch_and()、fetch_or()、fetch_xor()),以及这些运算的符合赋值形式(+=、-=、&=、|=和^= ),还有前后缀形式的自增和自减(++x、x++、–x和x–)。

5.3 同步操作和强制次序

5.3.1 同步关系

同步关系是C++11中内存模型的一个重要概念,它描述了多线程之间如何保证数据的一致性和可见性。同步关系有三种:synchronize-with,happens-before和inter-thread happens-before。

  • synchronize-with关系是指一个线程对某个原子对象的release操作与另一个线程对同一个原子对象的acquire操作之间的关系。在这个关系下,线程A中所有发生在release x之前的值的写操作,对线程B的acquire x之后的任何操作都可见。
  • happens-before关系是指两个事件或操作之间的偏序关系,它可以是程序顺序(同一线程内),也可以是synchronize-with(不同线程间)。如果事件A happens-before 事件B,那么A对任何内存位置的写操作都会在B执行前完成,并且对B可见。
  • inter-thread happens-before关系是指两个不同线程中的事件或操作之间的偏序关系,它由happens-before和synchronize-with组成。如果事件A inter-thread happens-before 事件B,那么A对任何内存位置的写操作都会在B执行前完成,并且对B可见。

5.3.2 先行关系

简单来说,如果一个线程A的操作X先行于另一个线程B的操作Y,那么X对内存的修改对Y是可见的,即Y可以读取到X写入的值。反之,如果X和Y没有先行关系,那么它们可能发生数据竞争,即Y可能读取到旧值或者不确定的值。

C++11标准中定义了一些规则来判断两个操作之间是否存在先行关系,比如:

  • 同一个线程中按照程序顺序执行的操作具有先行关系。
  • 线程创建和销毁时与其它线程之间具有先行关系。
  • 原子操作(atomic operation)和同步原语(synchronization primitive)之间具有先行关系。

5.3.3 原子操作的内存次序

TODO

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值