防止或检测 "this" 在使用过程中被删除

Prevent or detect "this" from being deleted during use

我经常看到的一个错误是容器在循环访问时被清除。我试图整理一个小示例程序来演示这种情况。需要注意的一件事是,这经常会发生很多函数调用,因此很难检测到。

注意:此示例故意显示一些设计不佳的代码。我正在尝试找到一种解决方案来检测由编写此类代码引起的错误,而无需仔细检查整个代码库(~500 个 C++ 单元)

#include <iostream>
#include <string>
#include <vector>

class Bomb;

std::vector<Bomb> bombs;

class Bomb
{
  std::string name;

public:
  Bomb(std::string name)
  {
    this->name = name;
  }

  void touch()
  {
    if(rand() % 100 > 30)
    {
      /* Simulate everything being exploded! */
      bombs.clear();

      /* An error: "this" is no longer valid */
      std::cout << "Crickey! The bomb was set off by " << name << std::endl;
    }
  }
};

int main()
{
  bombs.push_back(Bomb("Freddy"));
  bombs.push_back(Bomb("Charlie"));
  bombs.push_back(Bomb("Teddy"));
  bombs.push_back(Bomb("Trudy"));

  for(size_t i = 0; i < bombs.size(); i++)
  {
    bombs.at(i).touch();
  }

  return 0;
}

谁能提出保证不会发生这种情况的方法? 我目前可以检测到这种事情的唯一方法是用 mmap 替换全局 newdelete / mprotect 并在空闲内存访问后检测使用。然而,如果向量不需要重新分配(即只删除了一些元素或新大小还不是保留大小),这和 Valgrind 有时无法拾取它。理想情况下,我不想克隆很多 STL 来制作一个 std::vector 版本,它总是在调试/测试期间重新分配每个 insertion/deletion。

一种几乎可行的方法是,如果 std::vector 改为包含 std::weak_ptr,则使用 .lock() 来创建临时引用可防止在 类 方法中执行时删除它。但是,这不能与 std::shared_ptr 一起使用,因为您不需要 lock() 并且与普通对象相同。仅仅为此创建一个弱指针容器是一种浪费。

还有谁能想出一种方法来保护自己免受这种情况的影响。

最简单的方法是 运行 您的单元测试与 Clang MemorySanitizer 链接。 让一些持续集成 Linux 框在每次推送时自动执行 进入回购协议。

MemorySanitizer 具有 "Use-after-destruction detection"(标志 -fsanitize-memory-use-after-dtor + 环境变量 MSAN_OPTIONS=poison_in_dtor=1),因此它会破坏执行代码的测试,并使您的持续集成变红。

如果您既没有单元测试也没有持续集成,那么您也可以使用 MemorySanitizer 手动调试您的代码,但与最简单的方法相比,这很难。所以最好开始使用持续集成和编写单元测试。

请注意,在析构函数运行但内存尚未释放后,可能存在内存读写的合理原因。例如 std::variant<std::string,double>。它允许我们分配它 std::string 然后 double 所以它的实现可能会破坏 string 并为 double 重用相同的存储。不幸的是,目前过滤此类案例是手动工作,但工具在不断发展。

最后我使用了一个自定义迭代器,如果所有者 std::vector 在迭代器仍在范围内时调整大小,它将记录错误或中止(给我程序的堆栈跟踪)。这个例子有点复杂,但我尽量简化它并从迭代器中删除未使用的功能。

这个系统已经标记了大约 50 个这种性质的错误。有些可能是重复的。然而 Valgrind 和 ElecricFence 在这一点上是干净的,这令人失望(他们总共标记了大约 10 个,自代码清理开始以来我已经修复了)。

在此示例中,我使用 clear(),Valgrind 将其标记为错误。然而,在实际的代码库中,它是随机访问擦除(即 vec.erase(vec.begin() + 9)),我需要检查它,不幸的是 Valgrind 错过了很多很少。

main.cpp

#include "sstd_vector.h"

#include <iostream>
#include <string>
#include <memory>

class Bomb;

sstd::vector<std::shared_ptr<Bomb> > bombs;

class Bomb
{
  std::string name;

public:
  Bomb(std::string name)
  {
    this->name = name;
  }

  void touch()
  {
    if(rand() % 100 > 30)
    {
      /* Simulate everything being exploded! */
      bombs.clear(); // Causes an ABORT

      std::cout << "Crickey! The bomb was set off by " << name << std::endl;
    }
  }
};

int main()
{
  bombs.push_back(std::make_shared<Bomb>("Freddy"));
  bombs.push_back(std::make_shared<Bomb>("Charlie"));
  bombs.push_back(std::make_shared<Bomb>("Teddy"));
  bombs.push_back(std::make_shared<Bomb>("Trudy"));

  /* The key part is the lifetime of the iterator. If the vector
   * changes during the lifetime of the iterator, even if it did
   * not reallocate, an error will be logged */
  for(sstd::vector<std::shared_ptr<Bomb> >::iterator it = bombs.begin(); it != bombs.end(); it++)
  {
    it->get()->touch();
  }

  return 0;
}

sstd_vector.h

#include <vector>

#include <stdlib.h>

namespace sstd
{

template <typename T>
class vector
{
  std::vector<T> data;
  size_t refs;

  void check_valid()
  {
    if(refs > 0)
    {
      /* Report an error or abort */
      abort();
    }
  }

public:
  vector() : refs(0) { }

  ~vector()
  {
    check_valid();
  }

  vector& operator=(vector const& other)
  {
    check_valid();
    data = other.data;

    return *this;
  }

  void push_back(T val)
  {
    check_valid();
    data.push_back(val);
  }

  void clear()
  {
    check_valid();
    data.clear();
  }

  class iterator
  {
    friend class vector;

    typename std::vector<T>::iterator it;
    vector<T>* parent;

    iterator() { }
    iterator& operator=(iterator const&) { abort(); }

  public:
    iterator(iterator const& other)
    {
      it = other.it;
      parent = other.parent;
      parent->refs++;
    }

    ~iterator()
    {
      parent->refs--;
    }

    bool operator !=(iterator const& other)
    {
      if(it != other.it) return true;
      if(parent != other.parent) return true;

      return false;
    }

    iterator operator ++(int val)
    {
      iterator rtn = *this;
      it ++;

      return rtn;
    }

    T* operator ->()
    {
      return &(*it);
    }

    T& operator *()
    {
      return *it;
    }
  };

  iterator begin()
  {
    iterator rtn;

    rtn.it = data.begin();
    rtn.parent = this;
    refs++;

    return rtn;
  }

  iterator end()
  {
    iterator rtn;

    rtn.it = data.end();
    rtn.parent = this;
    refs++;

    return rtn;
  }
};

}

这个系统的缺点是我必须使用迭代器而不是.at(idx)[idx]。我个人不太介意这个。如果需要随机访问,我仍然可以使用 .begin() + idx

它有点慢(尽管与 Valgrind 相比没什么)。完成后,我可以用 std::vector 搜索/替换 sstd::vector 并且应该没有性能下降。

在您的特定示例中,苦难归结为不少于两个设计缺陷:

  1. 你的向量是一个全局变量。尽可能限制所有对象的范围,这样的问题就不太可能发生。
  2. 考虑到单一责任原则,我很难想象如何想出需要的class ] 有一些方法可以直接或间接(可能通过 100 层调用堆栈)删除可能恰好是 this.
  3. 的对象

我知道你的例子是人为的,故意不好,所以请不要误会我的意思:我敢肯定,在你的实际情况下,坚持一些基本的设计规则如何防止你不要这样做。但正如我所说,我坚信良好的设计会减少出现此类错误的可能性。事实上,我不记得我曾经遇到过这样的问题,但也许我的经验还不够 :)

然而,如果尽管坚持了一些设计规则,这仍然是一个问题,那么我有一个想法如何检测它:

  1. 在您的 class 中创建一个成员 int recursionDepth 并使用 0
  2. 对其进行初始化
  3. 在每个非私有方法的开头递增它。
  4. 使用 RAII 确保在每个方法结束时再次递减
  5. 在析构函数中检查为0,否则说明析构函数被this.
  6. 的某种方法直接或间接调用
  7. 您可能想要 #ifdef 所有这些并仅在调试版本中启用它。这实际上会使它成为一个调试断言,有些人喜欢他们:)

请注意,这在多线程环境中不起作用。