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



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

注意:此示例特意显示一些设计不佳的代码。我正在尝试找到一种解决方案来检测编写此类代码引起的错误,而无需仔细检查整个代码库(~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/mprotect替换全局newdelete,并检测空闲内存访问后的使用情况。然而,如果向量不需要重新分配(即只删除了一些元素或新大小还不是保留大小),这和 Valgrind 有时无法拾取它。理想情况下,我不想克隆大部分 STL 来制作一个在调试/测试期间始终重新分配每个插入/删除的 std::vector 版本。

一种几乎有效的方法是,如果std::vector包含std::weak_ptr,那么使用.lock()创建临时引用可以防止在类方法内执行时将其删除。但是,这不适用于std::shared_ptr,因为您不需要lock()并且与普通对象相同。为此创建一个弱指针容器将是浪费。

其他人能想到一种方法来保护自己免受这种情况的影响。

最简单的方法是在链接Clang MemorySanitizer的情况下运行单元测试。 让一些持续集成的 Linux 盒子在每次推送时自动完成 进入回购。

MemorySanitizer具有"销毁后使用检测"(标志-fsanitize-memory-use-after-dtor+环境变量MSAN_OPTIONS=poison_in_dtor=1),因此它将炸毁执行代码并将持续集成变为红色的测试。

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

请注意,在析构函数运行但内存尚未释放后,内存读取和写入可能有正当理由。例如std::variant<std::string,double>.它使我们能够将其分配给std::string然后double,因此它的实现可能会破坏string并重复使用相同的存储以进行double。不幸的是,过滤掉这种情况目前是手动工作,但工具在发展。

在你的特定例子中,痛苦归结为不少于两个设计缺陷:

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

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

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

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

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

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

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

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

主.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

它有点慢(虽然与瓦尔格林德相比没什么)。完成后,我可以搜索/替换sstd::vector 与std::vector,并且应该不会有性能下降。

最新更新