【问题标题】:Safety of std::unordered_map::merge()std::unordered_map::merge() 的安全性
【发布时间】:2017-11-15 17:59:14
【问题描述】:

在编写一些针对 C++17 的代码时,我在确定合并两个兼容的 std::unordered_map 的操作的异常安全性时遇到了障碍。根据当前的working draft,第 26.2.7 节,表 91 部分内容涉及a.merge( a2 ) 的条件:

要求: a.get_allocator() == a2.get_allocator()

尝试使用a 的散列函数和键相等谓词提取a2 中的每个元素并将其插入a。在具有唯一键的容器中,如果a 中的某个元素的键与a2 中的元素的键等效,则不会从a2 中提取该元素。

后置条件: 指向a2 的转移元素的指针和引用指的是那些相同的元素,但它们是a 的成员。引用被转移元素的迭代器和引用a 的所有迭代器将失效,但指向a2 中剩余元素的迭代器将保持有效。

抛出:除非散列函数或键相等谓词抛出。

值得注意的是,这些条件与普通关联容器 (std::map) 的要求非常相似,见 §26.2.6, table 90, a.merge( a2 ):

需要: a.get_allocator() == a2.get_allocator()

尝试使用a 的比较对象提取a2 中的每个元素并将其插入a。在具有唯一键的容器中,如果a 中的某个元素的键与a2 中的元素的键等效,则不会从a2 中提取该元素。

后置条件: 指向a2 的转移元素的指针和引用指的是那些相同的元素,但它们是a 的成员。引用被转移元素的迭代器将继续引用它们的元素,但它们现在表现为指向 a 的迭代器,而不是指向 a2 的迭代器。

抛出:除非比较对象抛出,否则什么都不会。

我需要合并两个具有相同数量元素的 std::unordered_maps,我可以确保这在两个容器中都是唯一的,这意味着包含合并结果的地图将具有两倍于以前的元素数量,并且合并的容器将为空。多亏了 C++17,这应该是完全安全的,对吧?

就性能而言,这是一个巨大的胜利……除了,我有这个挥之不去的疑问。有趣的是,后置条件语句没有说明合并映射中的前一个最大负载因子是否会得到尊重,虽然这似乎是一个安全的隐含假设,但它似乎与关于 unordered_map 异常安全性的语句发生了天真的冲突。如果您使用哈希表设计,其中存储桶是连续分配的缓冲区,那么保持负载因子似乎意味着重新散列,这似乎意味着重新分配存储桶缓冲区。

这似乎是一种极端的凝视练习,有充分的理由不去管它:可以想象,可以将更复杂的哈希表制作为完全基于节点的结构,类似于红黑树通常是 std::map 的基础,在这种情况下,规范似乎是合理的,因为重新散列并不意味着分配。

也许对我有利的是,我屈服于怀疑并深入研究了 gcc-7.1 的合并实现。这非常复杂,但总结一下我的发现,我发现存储桶确实是连续分配的缓冲区,并且重新散列确实意味着重新分配。也许,我想,我错过了一些更深层次的魔法(我盯着源代码看了将近一整天,仍然觉得我对它的理解很差),它只是在合并期间禁用了重新散列,这意味着所有指定的条件会得到支持,但在适当大的合并后可能会出现令人讨厌的性能回归,因为您的地图可能会超载。

我进行了反映我的用例的实际评估(如果可能的话,我会提出,对不起),而不是仅仅质疑我对 libstdc++ 的解释:

#include <memory>        // for std::shared_ptr<>
#include <new>           // for std::bad_alloc
#include <utility>       // for std::move(), std::pair<>
#include <type_traits>   // for std::true_type
#include <unordered_map> // for std::unordered_map<>
#include <functional>    // for std::hash<>, std::equal_to<>
#include <string>        // for std::string
#include <iostream>      // for std::cout
#include <cstddef>       // for std::size_t

template<typename T>
class PrimedFailureAlloc
{
public:
  using value_type = T;
  using propagate_on_container_copy_assignment = std::true_type;
  using propagate_on_container_move_assignment = std::true_type;
  using propagate_on_container_swap = std::true_type;

  PrimedFailureAlloc() = default;

  template<typename U>
  PrimedFailureAlloc( const PrimedFailureAlloc<U>& source ) noexcept
    : m_triggered{ source.m_triggered }
  { }

  template<typename U>
  PrimedFailureAlloc( PrimedFailureAlloc<U>&& source ) noexcept
    : m_triggered{ std::move( source.m_triggered ) }
  { }

  T* allocate( std::size_t n )
  {
    if ( *m_triggered ) throw std::bad_alloc{};
    return static_cast<T*>( ::operator new( sizeof( T ) * n ) );
  }

  void deallocate( T* ptr, std::size_t n ) noexcept
  {
    ::operator delete( ptr );
  }

  bool operator==( const PrimedFailureAlloc& rhs ) noexcept
  {
    return m_triggered == rhs.m_triggered;
  }

  void trigger() noexcept { *m_triggered = true; }

private:
  template<typename U>
  friend class PrimedFailureAlloc;

  std::shared_ptr<bool> m_triggered{ new bool{ false } };
};

template<typename T>
bool operator!=( const PrimedFailureAlloc<T>& lhs,
                 const PrimedFailureAlloc<T>& rhs ) noexcept
{
  return !(lhs == rhs);
}

template< typename Key
        , typename T
        , typename Hash = std::hash<Key>
        , typename KeyEqual = std::equal_to<Key>
        >
using FailingMap = std::unordered_map<
  Key,
  T,
  Hash,
  KeyEqual,
  PrimedFailureAlloc<std::pair<const Key, T>>
>;

template<typename Key, typename T>
void printMap( const FailingMap<Key, T>& map )
{
  std::cout << "{\n";
  for ( const auto& [str, index] : map )
    std::cout << "  { " << str << ", " << index << " }\n";
  std::cout << "}\n";
}

int main()
{
  PrimedFailureAlloc<std::pair<const std::string, int>> a;
  FailingMap<std::string, int> m1{ a };
  FailingMap<std::string, int> m2{ a };

  m1.insert( { { "Purple", 0 }, { "Green", 3 }, { "Indigo", 16 } } );
  m2.insert( { { "Blue", 12 }, { "Red", 2 }, { "Violet", 5 } } );

  // m1.reserve( m1.size() + m2.size() );
  a.trigger();
  m1.merge( m2 );

  std::cout << "map :=\n";
  printMap( m1 );

  return 0;
}

果然,在GCC-7.1下编译这段代码后,我得到:

terminate called after throwing an instance of 'std::bad_alloc'
  what():  std::bad_alloc
[1]    10944 abort      ./a.out

而取消注释第 95 行 (m1.reserve( m1.size() + m2.size() );) 会产生预期的输出:

map :=
{
  { Red, 2 }
  { Violet, 5 }
  { Purple, 0 }
  { Green, 3 }
  { Blue, 12 }
  { Indigo, 16 }
}

了解 C++17 仍是尚未最终确定的标准草案,并且 gcc 的实现是实验性的,我想我的问题是:

  1. 我是否严重误解了标准中规定的合并操作的安全性?我错过了使用std::unordered_map::merge() 的最佳实践吗?我是否应该暗中负责确保桶的分配?当 clang、MSVC 和 Intel 最终支持 C++17 时,使用 std::unordered_map::reserve() 是否真的可以移植?我的意思是,当无法进行无异常合并时,我的程序中止确实遵循列出的后置条件……
  2. 这实际上是标准中的缺陷吗?如果文本被复制粘贴,无序关联容器和普通关联容器之间措辞的相似性可能会导致意外的保证。
  3. 这只是 gcc 错误吗?

值得注意的是,在写这篇文章之前,我确实检查了 gcc 的错误跟踪器,似乎没有发现与我的描述相符的开放错误,此外,我检查了 C++ 标准缺陷报告,同样似乎是空的(诚然,对该网站进行文本搜索会加重病情,我可能不够彻底)。后者并不令人惊讶,因为标准缺陷及其解决方法或后果通常会在 gcc 的源代码中注明,而我在检查期间没有发现这样的符号。我尝试在我最近一次检查 clang(超过一周)中编译我的示例代码,但是编译器出现了段错误,所以我没有进一步检查,也没有咨询 libc++。

【问题讨论】:

  • 在我看来是个明显的缺陷。
  • 谢谢,计划向 GCC 提交错误以讨论触发 std::terminate() 调用的noexcept,以及当前实现所涉及的一些不良行为。
  • @T.C.我相信,您的错误报告为时过早。看我的帖子。

标签: c++ c++17 exception-safety gcc7


【解决方案1】:

这只是标准中的一个缺陷,即你的可能性2。

LWG 刚刚将 LWG issue 2977 移至“就绪”状态,这将触发错误的 Throws 子句。

【讨论】:

    【解决方案2】:

    为了了解标准中的措辞是否正确,您需要研究底层函数。
    合并本身包含两个操作。

    • 设置指向添加元素的指针。由于已经分配好了,这里就不涉及分配了
    • 如果达到桶中元素数量的界限,则调用 rehash()。

    调用 rehash() 是点,您希望在该处引发异常。 Sol 让我们看看它的异常安全性。

    23.2.5.1 异常安全保证 [unord.req.except]
    1 对于无序关联容器,没有 clear() 函数抛出异常。擦除(k)不会抛出 异常,除非该异常是由容器的 Hash 或 Pred 对象(如果有)抛出的。
    2 对于无序的关联容器,如果容器的操作以外的任何操作抛出异常 从插入或 emplace 函数中插入单个元素的哈希函数,插入没有 效果。
    3 对于无序关联容器,除非抛出异常,否则交换函数不会抛出异常 通过交换容器的 Hash 或 Pred 对象(如果有)。
    4 对于无序关联容器,如果从 rehash() 函数中抛出异常,而不是 通过容器的hash函数或者比较函数,rehash()函数没有作用。

    正如您所见,rehash() 函数被定义为,如果在内部抛出异常,除了哈希或比较函数之外,它什么都不做。也就是说,在我看来,完全符合合并的定义:

    抛出:除非散列函数或键相等谓词抛出,否则什么都不会。

    我的理解是当bucket list的底层数据结构没有空间增加的时候,就保持原来的形式。这可能会导致对元素的访问效率稍低,因为单个存储桶中的元素可能比定义的要多。插入过程中也会发生同样的情况。

    您的问题出在哪里?可能在 rehash() 函数的实现中抛出它不应该抛出的地方。

    免责声明:我不是该主题的专家。这正是我所发现的。如果我错了,请随时纠正我。

    【讨论】:

    • “如果rehash() 抛出它没有效果”并不意味着“rehash() 不会抛出”,当然也不意味着“其他函数执行的重新哈希操作也不会抛出"。
    • @T.C.这意味着在重新散列中抛出一些东西。标准的措辞比我写的要好。
    • 更好的问题,如果我们接受您对标准的解释,用户如何知道合并中是否有任何问题?他们是否必须手动检查合并的容器?最合理的失败行为是只留下未合并的元素,但是你如何区分由于 rehash() 导致的相同键合并失败和分配失败?
    • 第一个@T.C.当分配器失败时 rehash() 抛出异常是正确的。至少当我在 gcc 或 msvc 下测试它时。那么这个合并操作的异常有多严重呢?会不会导致某些元素无法合并?不,只是重新分配到存储桶会失败。它会导致效率较低的 unordered_map 吗?是的。但是 unordered_map 仍然可以正常工作,只是效率可能有点低。当你受到最大的打击?通过尝试将非常大的地图合并到非常小的地图中。
    • 但是当你有数据时,这些低效率也会在一定程度上出现在你现有的代码中,这些数据不会均匀分布。所以我的观点是合并期间的重新散列不是硬错误,合并可以从中恢复并且可以使 unordered_map 保持一致状态。当没有抛出异常时,如何知道分配是否失败?您可以比较 load_factor 和 max_load_factor。您甚至可以在合并之前检查是否会调用 rehash(),因为负载因子只是 size()/bucket_count()。所以我会用问题来回答你的问题。
    猜你喜欢
    • 2023-03-30
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2012-03-29
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多