最平凡日子 最卑微梦想

《C++ Concurrency In Action 2ed》(第3章 共享数据)

《C++ Concurrency In Action 2ed》(第3章 共享数据)

第2 章 线程管理


3.1 使用互斥量

当多个线程同时访问共享数据时,如果数据只读,则不会出现问题;但如果有线程需要修改共享数据,则可能引发复杂问题。

1. 不变量的破坏

  • 不变量是数据结构在某一状态下的规则或约束。例如,双链表中每个节点的前后指针必须保持一致。
  • 在修改共享数据时,不变量可能会暂时被破坏,直到修改完成后才恢复稳定。
  • 如果在不变量被破坏的过程中,其他线程访问了共享数据,就可能导致错误。

2. 条件竞争

  • 条件竞争是指多个线程对共享数据的访问顺序影响最终结果。
  • 良性条件竞争:线程执行顺序不同,但结果仍然可接受。
  • 恶性条件竞争:线程执行顺序导致不变量被破坏,可能导致数据损坏或程序崩溃。
  • 数据竞争是条件竞争的一种特殊形式,指多个线程同时修改一个独立对象,可能引发未定义行为。

3. 恶性条件竞争的特性

  • 通常发生在对多个数据块的修改中,如双链表中两个指针的更新。
  • 错误难以复现,因为其发生概率较低,且与执行时间敏感。
  • 在系统负载高或执行次数增加时,问题出现的概率会提高。

4. 避免恶性条件竞争的方法

  • 保护机制:使用某种机制(如互斥量)保护数据结构,确保其他线程无法看到不变量的中间状态。
  • 无锁编程:通过修改数据结构和不变量,使其变化过程不可分割,但这种方法复杂且容易出错。
  • 事务机制:通过事务日志记录和提交数据更新,避免条件竞争(如软件事务内存,STM)。但C++标准目前未直接支持这种方法。
  • 互斥量:C++标准库提供的最基本的方式,用于保护共享数据结构。

5. 调试难点

  • 条件竞争问题可能在调试模式下消失,因为调试模式会影响程序的执行时间。

通过这些方法和工具,可以有效避免恶性条件竞争,确保多线程程序的正确性和稳定性。

[![top] Goto Top](#table-of-contents)


3.2 使用互斥量

3.2.1 互斥量

核心概念

互斥量(std::mutex)是C++中保护共享数据的最基础机制。它的作用是确保同一时刻只有一个线程可以访问共享数据,从而避免条件竞争和数据不一致的问题。

使用方式

  • 手动锁与解锁
    通过std::mutex的成员函数lock()unlock(),可以手动对互斥量进行加锁和解锁操作,但这种方式容易出错,尤其是在代码中有多个出口(如异常处理)时,可能会导致忘记解锁。
  • RAII机制:std::lock_guard
    为了避免手动管理锁的复杂性,C++标准库提供了std::lock_guard,它是一种RAII(资源获取即初始化)机制的封装类。在构造时自动加锁,在析构时自动解锁,从而确保互斥量在任何情况下都能正确解锁。

示例代码:使用std::mutexstd::lock_guard

#include <list>
#include <mutex>
#include <algorithm>

std::list<int> some_list;    // 1. 全局共享数据
std::mutex some_mutex;       // 2. 保护共享数据的互斥量

// 添加元素到列表
void add_to_list(int new_value)
{
    std::lock_guard<std::mutex> guard(some_mutex);  // 3. 自动加锁
    some_list.push_back(new_value);
}

// 检查列表中是否包含某个值
bool list_contains(int value_to_find)
{
    std::lock_guard<std::mutex> guard(some_mutex);  // 4. 自动加锁
    return std::find(some_list.begin(), some_list.end(), value_to_find) != some_list.end();
}
  • 代码说明

    1. some_list 是一个全局共享的std::list
    2. some_mutex 是保护some_list的互斥量。
    3. add_to_list()list_contains()函数中,使用std::lock_guardsome_mutex进行加锁,确保线程安全。
    4. 当线程访问共享数据时,互斥量会将其他线程阻塞,直到当前线程完成操作并解锁。

C++17 的改进

  • 模板参数推导
    在C++17中,std::lock_guard支持模板参数推导,因此可以省略类型声明,代码更加简洁。例如:

    std::lock_guard guard(some_mutex);
  • std::scoped_lock
    C++17引入了std::scoped_lock,它是std::lock_guard的增强版,可以同时锁住多个互斥量,适用于需要同时操作多个共享数据的场景。例如:

    std::scoped_lock guard(some_mutex1, some_mutex2);

面向对象的设计

  • 在大多数情况下,互斥量通常与需要保护的数据一起放在同一个类中,而不是定义为全局变量。这种设计符合面向对象的封装原则。
  • 示例:

    class ProtectedList {
    private:
        std::list<int> some_list;
        std::mutex some_mutex;
    
    public:
        void add_to_list(int new_value) {
            std::lock_guard<std::mutex> guard(some_mutex);
            some_list.push_back(new_value);
        }
    
        bool list_contains(int value_to_find) {
            std::lock_guard<std::mutex> guard(some_mutex);
            return std::find(some_list.begin(), some_list.end(), value_to_find) != some_list.end();
        }
    };

注意事项

  • 不要返回受保护数据的指针或引用
    如果成员函数返回受保护数据的指针或引用,调用者可以绕过互斥量直接操作数据,从而破坏数据的安全性。

总结

  • std::mutex 是保护共享数据的核心机制。
  • 使用std::lock_guard可以简化锁的管理,避免忘记解锁。
  • C++17 提供了std::scoped_lock,进一步增强了多互斥量场景的支持。
  • 面向对象的设计可以将互斥量与数据绑定在一起,方便封装和管理。

3.2.2 保护共享数据

核心概念

使用互斥量保护共享数据时,仅仅在每个成员函数中添加一个std::lock_guard对象并不总是足够的。需要仔细检查代码,确保没有通过指针或引用绕过互斥量的保护。否则,即使看似加锁了共享数据,实际上却可能存在安全隐患。

主要问题

  1. 指针或引用泄露:

    • 如果某个成员函数返回了受保护数据的指针或引用,调用者可以直接操作数据,而不受互斥量的保护。
    • 这种情况下,互斥量的保护形同虚设。
  2. 通过外部代码间接访问:

    • 某些成员函数可能会将受保护的数据传递给外部函数或代码(通过参数或返回值),从而导致数据暴露在未加锁的情况下。
  3. 运行时参数传递问题:

    • 如果受保护数据被传递到一个用户提供的函数中,可能会导致该函数在没有互斥量保护的情况下访问数据,从而引发条件竞争。

示例代码:无意中泄露受保护数据

以下代码展示了一个常见的错误:将受保护数据的引用传递给外部函数,导致保护失效。

#include <mutex>
#include <string>
#include <iostream>

class some_data {
    int a;
    std::string b;

public:
    void do_something() {
        std::cout << "Doing something with data" << std::endl;
    }
};

class data_wrapper {
private:
    some_data data;    // 受保护的数据
    std::mutex m;      // 互斥量

public:
    template<typename Function>
    void process_data(Function func) {
        std::lock_guard<std::mutex> lock(m);  // 加锁保护数据
        func(data);                           // 传递数据给用户提供的函数
    }
};

// 恶意函数:将受保护数据的地址泄露
some_data* unprotected = nullptr;

void malicious_function(some_data& protected_data) {
    unprotected = &protected_data;  // 保存受保护数据的地址
}

void example_usage() {
    data_wrapper x;
    x.process_data(malicious_function);  // 传递恶意函数
    unprotected->do_something();         // 在没有互斥量保护的情况下访问数据
}

问题分析

  1. 问题描述:

    • process_data函数通过模板参数允许用户传入任意函数,并将受保护数据data传递给该函数。
    • 恶意函数malicious_functiondata的地址保存到全局指针unprotected中。
    • 之后,程序可以通过unprotected直接访问data,而无需加锁,破坏了互斥量的保护。
  2. 根本原因:

    • process_data函数没有限制用户提供的函数func的行为。
    • 数据data在互斥量的作用范围之外被泄露。
  3. 后果:

    • 其他线程可以在没有互斥量保护的情况下访问或修改data,导致数据竞争和未定义行为。

解决方案

  1. 不要返回指针或引用:

    • 确保成员函数不会将受保护数据的指针或引用通过返回值或参数传递给调用者。
    • 例如:

      // 错误:返回受保护数据的引用
      some_data& get_data() {
          std::lock_guard<std::mutex> lock(m);
          return data;  // 这样调用者可以直接操作受保护数据
      }
  2. 限制外部访问:

    • 避免将受保护数据直接暴露给外部函数或代码。
    • 如果必须传递数据,考虑使用只读的副本或深拷贝。
  3. 封装逻辑:

    • 将所有对受保护数据的操作封装在类的成员函数中,避免外部代码直接操作数据。

示例代码:改进后的设计

以下代码展示了如何避免泄露受保护数据的引用或指针:

#include <mutex>
#include <string>
#include <iostream>

class some_data {
    int a;
    std::string b;

public:
    void do_something() const {
        std::cout << "Doing something with data" << std::endl;
    }
};

class data_wrapper {
private:
    some_data data;    // 受保护的数据
    std::mutex m;      // 互斥量

public:
    void safely_process_data() {
        std::lock_guard<std::mutex> lock(m);  // 加锁保护数据
        data.do_something();                  // 在锁的保护下操作数据
    }
};
  • 改进点:

    • 数据data的操作被封装在data_wrapper的成员函数中。
    • 外部代码无法直接访问data,只能通过data_wrapper提供的接口进行操作。

总结

  • 使用互斥量保护数据时,需要特别注意不要泄露受保护数据的指针或引用。
  • 确保所有对受保护数据的访问都在互斥量的作用范围内。
  • 封装数据访问逻辑,将互斥量与数据绑定在同一个类中,以减少错误发生的可能性。

[![top] Goto Top](#table-of-contents)