C++中的线程安全、有序映射/哈希

Threadsafe, ordered mapping/hash in C++?

本文关键字:映射 哈希 线程 安全 C++      更新时间:2023-10-16

在C++中实现线程安全有序(注1)映射/哈希的最佳方法是什么?Aka,一个不同线程可以迭代的快速查找数据结构(Aka,而不是队列),偶尔插入或删除元素,而不会干扰其他线程的活动?

  • std::map不是线程安全的,它的操作是非原子的——尽管只有擦除才会使迭代器无效

  • 包装整个map类中的每个函数并不能解决这个问题——可以让松散的迭代器指向被另一个线程擦除的节点。它应该锁定并阻止删除,直到当前线程是唯一引用它的线程,或者使用UNIX文件系统风格的"悬挂但删除时仍然有效的引用"方法

  • tbb::concurrent_hash_map被设计为线程安全的,但它的迭代器在删除其密钥时仍然无效。即使Value是智能指针,数据也不会被保留,因为迭代器中的引用会丢失
  • 可以通过迭代键而不是迭代器来使用tbb:concurrent_hash_map(可以将键查找为O(1),而不是像std::map那样的O(log N)),但由于它是一个散列,因此它缺少与顺序相关的函数,如upper_bound、lower_bound等。至关重要的是,如果一个键被另一个线程中的擦除挂起,没有明显的方法告诉代码跳回散列中离该键最近的元素
  • std::unordered_map有可能在您的密钥通过bucket访问函数被删除的情况下,尝试jury rig来查找最近的元素。但是std::unordered_map不被认为是线程安全的(尽管它可能被包装为线程安全的)。tbb::concurrent_hash_map是线程安全的(受上面的约束),但它不允许足够的bucket访问
  • std::map由键而不是迭代器访问,如果一个键被另一个线程删除,它可以在映射中找到下一个最近的元素。但是每个查找都是O(logN)而不是O(1)
  • 此外,如前所述,std::map是非原子的。它必须被包裹起来。使用键代替迭代器还需要包装整个类,因为必须将通常采用或返回迭代器的所有函数改为采用或返回键
  • 我读过的一个网站上有人说,std::map在线程环境中无论如何都不能很好地工作(与哈希相比),因为线程在树重新平衡方面不能很好的发挥作用,尽管我真的不知道为什么会这样,也不知道它是否普遍适用/准确

你认为什么是最好的?

**注1:当我写"有序"时,我的意思是,只有从"有一个可靠的顺序可以迭代"的角度来看,而不是"迭代必须按键的顺序进行"。在我写下这些之后,我意识到我的一些用例实际上关心迭代的顺序(大多数不关心)。但无论哪种方式,我都可能通过将链表转换为Value类型来对正确的排序进行评判。只是更缓慢的丑陋和潜在的问题/疏忽。。。

**注2:新思想。糟糕的映射没有在其迭代器类型上模板化。。。更改std::map构建的迭代器有多难?我以前一直在考虑让迭代器作为引用计数(比如std::shared_ptr),但我一直错误地认为在迭代器内部使用辅助数据结构来实现引用计数,结果总是太难看/太慢/不切实际。但我现在突然想到,可以在映射的键:值对的值中包含引用计数。也就是说,每个值将包括A)一个参考计数器(默认值=0),每个interator在到达它时递增(operator=、operator++、operator--等),在离开它时递减;以及B)擦除功能设置的擦除标志(默认值=false)。每当迭代器将引用计数器减为零时,如果设置了擦除标志,则它将,然后实际擦除它。

在我看来,虽然这是一个性能打击(额外的增量/减量/检查),但它肯定不是每次你想遍历结构时都必须进行全映射查找的顺序。有人能想出一个切实可行的方法来实现这一点吗?

由于某些原因,您错过了tbb::concurrent_unordered_map,它是一个支持线程安全迭代的哈希表。它基于拆分有序列表算法,其中除了哈希表之外,元素还以容器范围的列表结构连接,因此迭代是直接的。但它并不完全适合您的需求,因为它不支持并发擦除。

这是一个基本问题,即在没有内存回收机制的情况下,很难同时在一个并发数据结构中融合快速遍历和安全擦除属性,您必须在这里选择:安全性/一致性或速度。

在一定的限制和谨慎的情况下,您可以像本博客中描述的那样同时进行遍历和删除。基本上,它说只要你能交错(互斥)遍历和擦除,tbb::concurrent_hash_map就可以和find&插入博客建议使用双重检查模式进行额外的优化。但它可以简化为以下内容:

for(iterator = table.begin(); iterator != table.end(); iterator++ ) {
    accessor acc;
    // a key cannot be changed thus it is safe to read it without lock
    table.find( acc, iterator->first );   // now get the get the lock
    if( acc->second.market_for_deletion )
        table.erase( acc );               // erase only by accessor
}

它本质上类似于应用于concurrent_hash_map情况的注释2,因为最大的开销不是来自查找(对于相邻元素,缓存未命中的可能性较小),而是来自与两个锁(内部bucket的锁和元素的访问器)的同步。

但是,如果这种遍历方法的速度太慢,或者对您来说太麻烦(取决于实现细节),但您仍然迫切需要能够删除并发哈希表的元素,那么可以考虑使用像tbb::spin_rw_mutextbb::concurrent_unordered_map这样的RW锁。您需要找到一个最佳的位置,在那里可以不太频繁地获取读锁,以启用迭代、查找和;在没有太多开销的情况下插入,并且在写锁定下进行擦除也不太频繁。可能它需要额外的方案来标记和收集足够的元素,然后才能真正删除它们。例如,下面是这样一个哈希表类的伪代码:

class concurrent_hash_table_with_erase_and_traverse {
    tbb::concurrent_unordered_map my_map;
    tbb::spin_rw_mutex            my_lock; // acquired as writer for cleanup only
    tbb::atomic<size_t>           my_trash_count; // indicates # of items for erase
public:
    void init_thread_for_concurrent_ops() { my_lock.lock_read(); }
    void release_thread()                 { my_lock.unlock(); } // assuming reader lock
    mapped_type read(key_type k) {
        // assert: under read lock (thread is initialized)
        if(my_trash_count > threshold) {  // time to remove items
            my_lock.unlock(); // release reader
            // waiting all the threads to enter this container
            // TODO: re-implement with try_lock and checking the condition 
            my_lock.lock();   // acquire writer
            if(my_trash_count > threshold) { // double-check
                my_trash_count = 0;
                for( auto it = my_map.begin(); it != my_map.end(); ) {
                    auto _it = it++;
                    if( _it->is_marked_for_erase )
                        my_map.unsafe_erase( _it );
                }
            }
            my_lock.unlock();    // release writer
            my_lock.lock_read(); // acquire reader
        }
        return my_map[k]; // note: access is not protected like in concurrent_hash_map
    }
    void safe_erase(key_type k) {
        // assert: under read lock
        my_map[k].is_marked_for_erase = true;
        my_trash_count++;
    }
};

好吧,这花了很长时间。。。以及如此多的工作,以至于我决定用它做一个github项目;)但我终于有了一个线程安全的映射类。完整的测试套件和大量可配置选项。希望如果其他人需要这个,他们会利用它!

https://github.com/KarenRei/safe-map#

可能不是您真正想要的线程安全代码示例。

警告未经测试的代码

template<typename kType, typename dType>
class Locked {
  std::mutex mut;
  std::map<kType, dType> theMap; // change types as required
public:
  const dType get(const kType& key) const {
    std::lock_guard<std::mutex> g(mut);
    auto it = theMap.find(key);
    if (it != theMap.end())
      return *it;
    // throw or return buggy dType
    return dType(-1); // or whatever
  }
  void set(const kType& key, const dType& data) {
    std::lock_guard<std::mutex> g(mut);
    theMap[key] = data;
  }
  void delete(const kType& key) {
    std::lock_guard<std::mutex> g(mut);
    auto it = theMap.find(key);
    if (it != theMap.end()) {
      theMap.erase(it);
      return;
    }
    // throw?
  }
}

我认为如果dType是一个指针,除非它是一个shared_ptr,否则这将不起作用。

只能有一个。

它可以扩展为具有读取器计数器,因此只有设置/删除块,设置可以是tbb映射上的读取,因为它允许线程安全插入。

C++14具有std::shared_timed_mutex,这使得读取在性能上更容易一些。

C++17具有std::shared_mutex,它从中去除了时间元素

现在有很多不同的无锁、无等待等实现来解决性能问题。

根据实际负载,在一定程度上,一些spin_lock而不是互斥可能会有所帮助。