Threadsafe,在C ++中有序映射/散列?

时间:2015-10-26 21:39:51

标签: c++ multithreading c++11 stdmap tbb

在C ++中实现线程安全 有序(note1)映射/散列的最佳方法是什么? 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可能会尝试陪审团找到最近的元素。但是std :: unordered_map不被认为是线程安全的(虽然它可能被包装为线程安全)。 tbb :: concurrent_hash_map是线程安全的(受上述约束限制),但它不允许足够的存储桶访问来执行此操作。
  • std :: map,通过键而不是迭代器访问,如果某个键被另一个线程删除,则可以在地图中找到下一个最近的元素。但每次查找都是O(log N)而不是O(1)。
  • 另外,如上所述,std :: map是非原子的。它必须被包裹。使用键而不是迭代器也需要包装整个类,因为必须更改通常采用或返回迭代器的所有函数来取代或返回键。
  • 我读过的一个网站上有人说std :: map在线程环境中不能很好地工作(与哈希相比),因为线程不能很好地与树重新平衡,尽管我不知道为什么是或者它是否普遍适用/准确。

您认为最好的是什么?

**注1:当我写“有序”时,我的意思是只从“具有可以迭代的可靠排序”的角度来看,而不是“迭代必须按照键的顺序进行”。在我写完之后,我意识到我的几个用例实际上关心迭代的顺序(大多数都没有)。但无论哪种方式,我都可以通过在Value类型中使用链表来进行正确的排序。只是更缓慢的丑陋和潜在的问题/疏忽......

**注2:新想法。太糟糕的地图不会因其迭代器类型而被模板化...改变迭代器std :: map有多难建立?我之前一直在讨论迭代器是引用计数的想法(比如std :: shared_ptr),但是我一直错误地想到在迭代器本身内部用二级数据结构实现引用计数,这总是太难看了/慢/不切实际的。但现在我发现,可以在地图的键值:值对中包含引用计数。也就是说,每个值都包括A)一个引用计数器(默认值= 0),每个交互器在到达它时递增(operator =,operator ++,operator--等),并在它离开时递减;和B)擦除功能设置的擦除标志(默认=假)。每当迭代器将引用计数器递减到零时,如果设置了擦除标志,那么它将然后实际擦除它。

在我看来,虽然它是性能损失(额外的增量/减量/检查),但是每次你想要逐步完成结构时,它肯定不需要进行完整的地图查找。谁能想到实现这个的实用方法呢?

3 个答案:

答案 0 :(得分:3)

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

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

如果有某些限制和注意事项,您可以同时执行this blog中所述的遍历和删除操作。基本上,它表示只要您可以交错(相互排除)遍历和擦除,tbb::concurrent_hash_map可以与find& insert一起用于并发遍历。该博客建议使用双重检查模式进行额外优化。但它可以简化为以下内容:

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,因为最大的开销不是来自查找(对于邻居元素,缓存未命中的可能性较小),而是来自与两个锁的同步(内部存储桶的锁定和元素的访问者。)

但是如果这种遍历方法的速度太慢或者太过于hacky(依赖于实现细节),但是您仍然迫切需要能够删除并发哈希表的元素,请考虑使用RW像tbb::spin_rw_mutex一样锁定tbb::concurrent_unordered_map。您需要找到一个最佳位置,其中可以不太频繁地获取读锁,以启用迭代,查找和放大。没有太多开销的插入也不会经常在写锁定下进行擦除。可能它需要额外的方案来标记和收集足够的元素才能真正删除它们。例如。这是一个这样的哈希表类的伪代码:

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++;
    }
};

答案 1 :(得分:0)

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

警告未经测试的代码

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,否则我认为这不会起作用。

  

只能有一个。

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

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

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

现在有大量不同的无锁,无等待实现来解决性能问题。

根据实际负载,某些spin_lock而不是互斥锁可能有所帮助,直到某一点。

答案 2 :(得分:0)

好的,所以这花了很多时间......而且我做了很多工作,我决定用它做一个github项目;)但我终于有了一个线程安全的地图类。配备完整的测试套件和许多可配置选项。希望如果其他人需要这个,他们会利用它!

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