为啥基于锁的程序不能组成正确的线程安全片段?

Posted

技术标签:

【中文标题】为啥基于锁的程序不能组成正确的线程安全片段?【英文标题】:Why lock-based programs do not compose correct thread-safe fragments?为什么基于锁的程序不能组成正确的线程安全片段? 【发布时间】:2016-07-02 21:15:08 【问题描述】:

蒂姆哈里斯说:

https://en.wikipedia.org/wiki/Software_transactional_memory#Composable_operations

也许最基本的反对意见 [...] 是基于锁的 程序不组合:正确的片段在组合时可能会失败。为了 例如,考虑具有线程安全插入和删除的哈希表 操作。现在假设我们要从表中删除一项 A t1,并将其插入到表 t2 中;但中间状态(其中 两个表都不包含该项目)不得对其他线程可见。 除非哈希表的实现者预料到这种需要,否则 根本没有办法满足这个要求。 [...] 简而言之, 单独正确的操作(插入、删除)不能 组成更大的正确运算。 ——蒂姆·哈里斯等人, “可组合内存事务”,第 2 节:背景,pg.2[6]

这是什么意思?

如果我有 2 个哈希映射 std::unordered_map 和 2 个互斥锁 std::mutex(每个哈希映射一个),那么我可以简单地锁定它们:http://ideone.com/6RSNyN

#include <iostream>
#include <string>
#include <mutex>
#include <thread>
#include <chrono>
#include <unordered_map>

std::unordered_map<std::string, std::string> map1 ( "apple","red","lemon","yellow" );
std::mutex mtx1;

std::unordered_map<std::string, std::string> map2 ( "orange","orange","strawberry","red" );
std::mutex mtx2;

void func() 
    std::lock_guard<std::mutex> lock1(mtx1);
    std::lock_guard<std::mutex> lock2(mtx2);

    std::cout << "map1: ";
    for (auto& x: map1) std::cout << " " << x.first << " => " << x.second << ", ";
    std::cout << std::endl << "map2: ";
    for (auto& x: map2) std::cout << " " << x.first << " => " << x.second << ", ";
    std::cout << std::endl << std::endl;

    auto it1 = map1.find("apple");
    if(it1 != map1.end()) 
        auto val = *it1;
        map1.erase(it1);
        std::this_thread::sleep_for(std::chrono::duration<double, std::milli>(1000));
        map2[val.first] = val.second;
    


int main ()

    std::thread t1(func);
    std::this_thread::sleep_for(std::chrono::duration<double, std::milli>(500));
    std::thread t2(func);
    t1.join();
    t2.join();

    return 0;

如果我想实现自己的线程安全哈希映射my_unordered_map,那么我将实现这样的事情:

template<typename key, template val>
class my_unordered_map 
    std::recursive_mutex mtx_ptr;
    void lock()  mtx_ptr->lock(); 
    void unlock()  mtx_ptr->unlock(); 
    template<typename mutex_type> friend class std::lock_guard;
public:
 // .. all required public methods which lock recursive mutex before do anything
    void insert(key k, val v)  std::lock_guard<std::recursive_mutex> lock(mtx); /* do insert ... */ 
    // ...
;

并且会这样使用它:

my_unordered_map<std::string, std::string> map1 ( "apple","red","lemon","yellow" );

my_unordered_map<std::string, std::string> map2 ( "orange","orange","strawberry","red" );

void func() 
    std::lock_guard<my_unordered_map> lock1(map1);
    std::lock_guard<my_unordered_map> lock2(map2);

    // work with map1 and map2
    // recursive_mutex allow multiple locks in: lock1(map1) and map1->at(key)

类似地,我得到了 map1 和 map2 的线程安全代码和完全顺序一致性。

但是,这是针对哪些情况说的?

也许最基本的反对意见 [...] 是基于锁的 程序不组合:正确的片段在组合时可能会失败。

【问题讨论】:

大概是为了让操作在事务上失败:只有当 map2 不包含密钥 k 时,才从 map1 中提取密钥 k。你不能在每个映射中只使用一个私有互斥锁,因为事务需要将两个互斥锁锁定在一起。 【参考方案1】:

你的程序本身就很好。

另一个程序,对于另一个线程中的不同任务,可能会使用类似的东西

void func_other() 
    std::lock_guard<my_unordered_map> lock2(map2);
    std::lock_guard<my_unordered_map> lock1(map1);

    // work with map1 and map2

再一次,这很好,就其本身而言。

但是,如果我们同时运行这两个程序,可能会出现死锁。线程 1 锁定映射 1,线程 2 锁定映射 2,现在两个线程中的下一个锁定将永远等待。

因此我们不能天真地编写这两个程序。

使用 STM 而不是锁总是允许这样的组合(以某种性能为代价)。

【讨论】:

谢谢。是的,如果更改锁的顺序,基于锁的容器可能会出现死锁,即“组合时可能失败”意味着我们可以重写这个组合代码以避免任何失败。但是两个无锁容器一般不能使两个表同时保持一致。 IE。对于两张表的一致性,只有STM可以保证不出现故障。但是,像任何事务系统一样死锁 STM 可能会生成事务提交失败作为事务回滚(内部死锁或更新冲突),我们可以在软件中处理,不是吗? @Alex 是的。在大多数 STM 方法中,即使存在一些冲突,也会提交一个事务,而其他事务可能会回滚。然后 STM 引擎可以重试回滚的事务。 即我有 2 种方法:1. 我应该使用带有受控事务循环的 STM,直到它们完全执行。 2. 或者我应该自己解决基于锁的程序中的死锁,例如,通过在循环中为许多必需的互斥锁中的每一个使用mutex.try_lock(),也可以在每个一定数量的互斥锁之后使用std::this_thread::yield()重复,如果 mutex.try_lock() == false 在一定时间(1 微秒或 1 毫秒,...)之后抛出异常 throw my_roll_back_transaction();,然后捕获它,回滚所有所做的更改并再次重试循环以一起更改一些表。【参考方案2】:

插入和擦除的线程安全原子操作通常将互斥锁隐藏在其中。这可以防止未经锁定的访问。

在您的情况下,您改为公开互斥锁。这需要每个用户正确处理互斥锁,否则它会中断。

通过完全访问互斥锁,您可以安全地排除看到中间状态:您的代码失败,因为它没有使用std::lock 来保证互斥锁锁定顺序是全局一致的,如果其他代码使用这可能会导致死锁不同的锁定顺序。

这类问题(您必须不断了解您的交易需要哪些互斥锁以及您持有哪些互斥锁)不会分解为易于确定正确的小块。正确性变得非局部,然后复杂性爆炸,错误比比皆是。

【讨论】:

是的,我将std::recursive_mutex mtx_ptr; 隐藏为私人会员,只有std::lock_guard 作为朋友才能直接使用。我使用 RAII std::lock_guard 以任何方法锁定此互斥锁,例如:insert()erase()find()。此外,我建议使用std::lock_guard 手动锁定容器,以便在一个锁中进行许多操作:find-erase-insert、两表一致性……而且我只看到基于锁的程序的一个潜在问题:不同订单上的死锁锁。

以上是关于为啥基于锁的程序不能组成正确的线程安全片段?的主要内容,如果未能解决你的问题,请参考以下文章

Linux线程安全篇Ⅰ

Go Map 为啥是非线程安全的?

为啥我不能将带有互斥锁的仿函数传递给线程?

什么是线程安全?

线程共享数据的安全问题

线程安全与锁