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

时间:2016-07-02 21:15:08

标签: c++ multithreading c++11 concurrency mutex

蒂姆哈里斯说:

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

  

也许最基本的反对意见是基于锁定的   程序不构成:组合时正确的片段可能会失败。对于   例如,考虑具有线程安全插入和删除的哈希表   操作。现在假设我们要从表中删除一个项目A.   t1,并将其插入表t2;但中间状态(其中   任何表都不包含该项目)其他线程不得显示。   除非哈希表的实现者预见到这种需要,否则就有了   根本无法满足这一要求。 [...] 简而言之,   单独正确的操作(插入,删除)不能   组成更大的正确操作。 -Tim Harris等人,   "可组合内存事务",第2部分:背景,第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的线程安全代码和完全顺序一致性。

但确实说过这个案例?

  

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

2 个答案:

答案 0 :(得分:4)

你的程序完全没问题。

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

的东西
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而不是锁将始终允许这种类型的组合(以某种性能价格)。

答案 1 :(得分:4)

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

在您的情况下,您改为暴露互斥体。这需要每个用户正确地纠缠互斥锁,否则就会中断。

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

这种问题 - 您必须经常了解交易所需的互斥量,以及您持有的互斥量 - 不会分解成小的,易于确定的正确部分。正确性变得非本地化,然后复杂性爆炸,错误比比皆是。