65.9K
CodeProject 正在变化。 阅读更多。
Home

悬空指针:病因、预防和治愈

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.83/5 (49投票s)

2012年5月4日

CPOL

12分钟阅读

viewsIcon

88212

downloadIcon

424

悬空指针过去是个问题,但现在我们应该找不到了吧?再想想……

前言

语言可能冒犯:本文包含可能冒犯某些人的语言,特别是那些强烈偏爱不暴露指针的语言的程序员。建议读者谨慎阅读。

简介     

你在工作。你的工作包括维护一个遗留的庞然大物,其中积累了几十年的全局变量、C 风格字符串、C 风格数组、自制容器、名称晦涩的函数以及在人们想到“反模式”之前编写的许多其他东西。

电话铃响了。你接了。你认得那个声音。 

“它有时会崩溃。”

你知道“它”指的是什么:产品核心业务逻辑中的一个 DLL。你知道它崩溃在哪里:在你公司最有价值的客户的最新机器上。  

你也知道“有时”是什么意思,但你需要几秒钟来评估一下对策。

“请定义‘有时’”,你说。 

十次中有九次,当你被告知一个程序“有时会崩溃”时,这些崩溃是随机的,并且显然不取决于用户的特定操作。这次也不例外。

打电话的人是你的一位顶级客户支持人员。你们俩已经经历过这次对话了:这个程序在数千个安装中运行良好,但有些机器似乎对此过敏,并且在少数机器上,程序会以七次运行崩溃三次的频率崩溃。付费购买产品的客户会生气。

你在脑海中列出了你的选择

  • 让客户打电话给驱魔师。由于计算机是确定性的,而程序崩溃是随机的,因此它们的根本原因显然是超自然的:也许服务器机房建在一座古老墓地的上面,或者诸如此类的事情。这也许可以,如果你不介意人们认为你相信鬼魂的话。
  • 归咎于病毒。当客户注意到所谓的“病毒”似乎只影响一个程序时,你看起来会像个小丑。
  • 建议升级硬件。该程序在几乎所有其他安装的地方都运行良好,因此硬件一定对崩溃有贡献。由于硬件要花钱,企业客户将不得不经过某种批准流程:这将把球踢给客户。有些人实际上认为这是一件好事。
  • 卷起袖子,真正去解决问题。

如果我在你,我会排除前三种选择。如果我不喜欢通过修复代码中的问题来解决问题,我就不会成为一名程序员:如果程序崩溃了,代码总有问题。我会首先实现一个结构化错误翻译器,将相关信息写入应用程序事件日志,并审查代码。

让我们假设你的选择和我的相同。

随机崩溃通常是资源管理问题等导致的。

  1. 内存分配(malloc, calloc, strdup, 和 realloc)返回 NULL,并且忽略了这个返回值。
  2. 资源分配(例如 fopenCreateFile)返回 NULL(或 INVALID_HANDLE_VALUE),并且忽略了这个返回值。
  3. 竞态条件:一个线程试图使用另一个线程已释放的资源。
  4. 内存损坏,例如缓冲区溢出(参见“损坏研究”)。
  5. 悬空指针。 

如果你想玩转悬空指针,请继续阅读。与任何其他指针问题一样,如果你使用的是不暴露指针的语言(Java、Visual Basic、COBOL、C#),你就不会遇到它。

背景

什么是悬空指针?

让我们从源头开始:http://en.wikipedia.org/wiki/Dangling_pointer 

悬空指针是更大主题的一个方面:资源所有权。
当所有权规则清晰时,悬空指针很少见。
当你拥有遗留的 C 风格代码,其中充斥着全局变量、栈分配时,所有权规则很少(如果有的话)是清晰的。

简单情况

假设你有一个这样的函数

 // This is pseudo-code. 
int foo() 
{ 
    MyClass pClass = new MyClass; 
 
    int x = pClass->bar(); 
    int y = pClass->baa(); 
 
    // delete before the switch, to make sure it happens. 
    delete pClass; 
    // From here down, pClass is in scope but points to garbage. 
    // You may think it's no big deal, since the pointer is no longer used. 
    // Someone else may disagree... 
    switch (x) 
    { 
         case 1:  
             return y; 
         case 2:  
             return y * 2; 
         case 3:  
         default:  
             return x + y; 
    } 
} 

经过一些例行维护后,有人(严格遵守开放/封闭原则)为 switch 添加了一个新案例

// This is pseudo-code.
int foo()
{
    MyClass pClass = new MyClass;

    int x = pClass->bar();
    int y = pClass->baa();

    // delete before the switch, to make sure it happens.
    delete pClass;
    // From here down, pClass is in scope but points to garbage.
    
    switch (x)
    {
         case 1: 
             return y;
         case 2: 
             return y * 2;
         case 4: 
             // New code, nice and crashy!
             if (17 == rand() % 99 )
             {
                 y = pClass->baz(x);
             }
             return y / 3;
         case 3: 
         default: 
             return x + y;
    }
}

许多编译器会欣然接受这一点。

你可能期望这段代码一百次中崩溃一次。在这种情况下,你就错了。运行附件中的项目(以发布模式编译)看看。

像这样的代码最简单的修复方法是:使用栈变量(见下文)。所有权很清楚:当栈展开时,并且仅当栈展开时,栈变量 mClass 的析构函数才会被调用。

// This is pseudo-code.
int foo()
{
    MyClass mClass; // Not a pointer.

    int x = mClass.bar();
    int y = mClass.baa();

    switch (x)
    {
         case 1: 
             return y;
         case 2: 
             return y * 2;
         case 4: 
             if (17 == rand() % 99 )
             {
                 y = mClass.baz(x);
             }
             return y / 3;
         case 3: 
         default: 
             return x + y;
    }
}

有时你不能使用栈变量。例如,当你使用工厂方法时,它返回一个实现接口(实际上是继承抽象类)的指针。

在这种情况下,简单的修复方法是使用智能指针(如 std::auto_ptrboost::shared_ptr、C++11 的 std::unique_ptr 等)。仔细选择你的智能指针:如果你能使用 std::unique_ptr,它是最便宜、最快的。

// This is pseudo-code.
MyAbstractClass *FactoryMethod()
{
    return new SomethingDerivedFromMyAbstractClass();
}

int foo()
{
    // Link to the const std::auto_ptr idiom below. 
    const std::auto_ptr<myabstractclass> pClass = std::auto_ptr<myabstractclass>(FactoryMethod());

    int x = pClass->bar();
    int y = pClass->baa();

    switch (x)
    {
         case 1: 
             return y;
         case 2: 
             return y * 2;
         case 4: 
             if (17 == rand() % 99 )
             {
                 // The pointer is still valid here.
                 y = pClass->baz(x);
             }
             return y / 3;
         case 3: 
         default: 
             return x + y;
    }
}
 

悬空指针是显式堆内存分配的结果。远离显式内存分配,你就安全了。

我知道有三种方法可以避免显式堆内存分配

  1. 使用栈。
  2. 使用 RAII(如 auto_ptrshared_ptrunique_ptr 以及所有其他智能指针)。
  3. 使用自动管理内存的语言(Java、C#、VB、COBOL)。

如果你不能选择你的语言,那么就只有两种了。

“那么,我们结束了,是吧?我们可以休息一下喝咖啡了吗?”

“嗯,如果你渴了,尽管去;但我们还没结束。远未结束:谷歌搜索‘悬空指针’大约有 1,340,000 个结果;必应找到了 298,000 个结果。对于一个非问题来说,这可是很多结果……”

真正的混乱开始于函数开始传递指针,并且某个接收指针作为参数的函数删除了它。const 在这里没有帮助,在遗留代码中,你有时会发现美丽、绝妙的发明——有时也会发现可怕、糟糕的东西,你只能希望它们没有受到惩罚。

一些不那么简单的情况

简而言之,管理资源所有权就是弄清楚程序的哪个部分负责分配资源,哪个部分负责释放它们。
你的程序越大,这可能就会越混乱:通常,我们将从他人的经验中学习,并使用其他人已经找到的模式。
有几种所有权模式,我最常遇到的有

  1. 单一所有者:程序(类、函数)的同一部分负责释放它分配的资源。RAII 是一个很好的例子:在初始化时分配,在析构函数中释放;还有 const std::auto_ptr 惯用法,Sutter 也提到了
  2. 生产者-消费者(或源-汇)模式:程序的一部分分配资源,另一部分释放它。我在多线程编程中经常使用此模式:一个线程分配一个 new Job 实例,然后将其添加到队列中;一个工作线程从队列中弹出 Job,调用 pJob->Execute() 并删除 Job 指针。
  3. 共享资源(如 shared_ptr 或 COM 的 AddRef()/Release())有多个所有者:最后一个释放资源的会实际删除指针、关闭句柄等。如果存在循环引用(a -> b -> c -> a),可能会有泄漏的风险,这通常意味着指针永远不会被释放。

在实际生活中,我很少使用显式内存分配。我使用 STL 容器和智能指针,它们为我管理那些棘手的小细节。我无法避免的 new() 的使用如下:

  1. 生产者-消费者模式,如上所述:生产者是或使用一个工厂,该工厂会调用 new
  2. 遗留 API 需要一个 char *,并且缓冲区大小在运行时才能确定。
  3. 运行时多态,这通常需要运行时选择接口的实现。

让我们看一些例子。

调用遗留 API

例如,ShFileOperation 在其参数 pFrom 中接收一个零分隔的字符串列表,以双零结尾。
问题是:该参数的分配和释放。

面对如此丑陋的东西,我们程序员不会掩盖它:我们会将其封装在一个类中。

在这种情况下,有一个相对简单的解决方案:将遗留 API 的调用封装在一个类中,该类将缓冲区作为实例成员来管理。该成员可以是 auto_ptr;该类甚至可以公开一个接收 std::list<std::string> 的方法,并在内部完成所有分配和资源管理。

生产者-消费者模式

源/生产者通常是一个分配指针、将其传递给另一个函数,然后退出而不删除它的函数。直到在维护过程中,有人发现原始作者“忘记了 delete”,并“修复了那个泄漏”,这都不是问题。
幸运的是,重复删除同一个指针两次会立即引发异常,因此这种特定的错误可以在测试中捕获。
另一方面,避免比修复更好:让我们看看是否能找到解决方案。

方法 1.1:在代码中添加大量注释,明确所有权。

在生产者中

    Job *pShootAndForget = new Job(params);
    m_workerThread.AddToQueue(pShootAndForget);
    // DO NOT DELETE pShootAndForget! Will be deleted by m_workerThread whenever it's done with it!
    pShootAndForget = NULL; // DO NOT DELETE pShootAndForget! Will be deleted by m_workerThread whenever it's done with it!
    // DO NOT DELETE pShootAndForget! Will be deleted by m_workerThread whenever it's done with it!

优雅而精致,对吧?

方法 1.2:不要声明临时变量,而是内联。

   // The pointer will be deleted by m_workerThread whenever it's done with it!
   m_workerThread.AddToQueue(new Job(params));

这里的风险再次在于,有人可能会发现“泄漏”并“修复”它。

方法 2:将创建的负担转移给 m_worker。丑陋。

m_worker.AddToQueue(params);

出于几个原因,我不喜欢这种方法

  • 它违反了单一职责原则(m_workerThread 现在既是作业的工厂又是消费者);以前,它只负责消耗(执行和删除)它们。
  • 增加了耦合:在原始版本中,Job 可以是任何具有虚拟 Execute() 方法的抽象类,而 m_worker 对其创建一无所知。你可以使用相同的 worker 类与任何数量的不同客户端,而无需更改一行代码。

方法 3:使用 std::auto_ptr,它会转移所有权。

如果你的开发环境支持 unique_ptr(C++11),你可以使用 unique_ptr 代替 auto_ptr

那些仍然使用不支持 C++11 的编译器,并且在不允许使用 Boost 库的企业环境中工作的人,别无选择,只能坚持使用 auto_ptr。 

    // In WorkerThread.h
    class WorkerThread
    {
       // ...
       void AddToQueue(std::auto_ptr<job> pJob)
       {
           // Add the job to the actual queue. Ownership is passed to the queue.
           m_queue.push(pJob.release()); 
       }
       // ...
    };

    // In the client CPP file
    std::auto_ptr<job> pShootAndForget ( new Job(params) );
    m_workerThread.AddToQueue(pShootAndForget); // m_worker takes ownership of the auto_ptr.
    // The code 'looks' right: if there is an auto_ptr, there is no leak to 'fix'. 

这种方法并非万无一失,但它不会轻易出错。

运行时选择实现

进入运行时多态。
假设你的程序连接到数据库,并且假设你的程序可能根据配置使用 SQL Server、Oracle 或 MySQL:你实现了一个数据访问层,该层连接到正确的数据库,生成并执行所有必需的 SQL,并存储和检索对象,使客户端代码对所有棘手的数据库细节一无所知。
你通过定义一个数据访问接口来实现这一点,并为每个数据库引擎实现一个不同的具体类。
你还实现了一个工厂类,其中有一个静态方法可以返回你的接口的正确实现的实例。
嘿,你做得很好!让我们看一些伪代码。

std::string databaseEngine = Configuaration::GetInstance().GetDatabaseEngine(); // may be "oracle", "mySql", "mssql", "mock"
// Pointer to interface; the factory will throw if the engine string is invalid. 
IDatabaseAccess *pDatabaseAccess = DatabaseAccessFactory::GetIDatabaseAccess(databaseEngine.c_str());
由于调用配置和工厂可能会产生可衡量的开销,因此有人可能会通过缓存数据库访问指针(例如,在静态变量中)来“改进”程序。
// Pointer to interface
static IDatabaseAccess *pDatabaseAccess = NULL;
if (NULL == pDatabaseAccess)
{
    std::string databaseEngine = Configuaration::GetInstance().GetDatabaseEngine(); // may be "oracle", "mySql", "mssql", "mock"
    // The factory will throw if the engine string is invalid. 
    pDatabaseAccess = DatabaseAccessFactory::GetIDatabaseAccess(databaseEngine.c_str());
}
问题是,在其他某个遥远的代码中,有人可能会删除这个指针。在这里,使用 shared_ptr 而不是原始指针会有帮助。

如何用 auto_ptr 制造混乱

现在,这里有一个谜题:这段程序的输出会是什么?(它是示例项目的一部分)。

namespace mess_with_auto_ptr
{
struct X
{
    void Foo()
    {
        printf("X::Foo(%#p);\n", this);
        fflush(stdout);
    }
    virtual ~X()
    {
        printf("X::~X(%#p);\n", this);
        fflush(stdout);
    }
};

void Consumer(std::auto_ptr<x> px)
{
    px->Foo();
// The px destructor deletes the encapsulated pointer.
}

void Producer()
{
    printf("Producer();\n");
    std::auto_ptr<x> px(new X);
    px->Foo(); // OK
    Consumer(px); // Deletes the encapsulated pointer
    px->Foo(); // Function called on a dangling pointer.
    // The destructor of px does not try to delete the pointer again, since ownership was neatly passed to Consumer(). 
    printf("Ready to exit Producer();\n");
}

void Producer2()
{        
    printf("Producer2();\n");
    std::auto_ptr<x> px(new X);
    px->Foo(); // OK
    Consumer(std::auto_ptr<x>(px.get())); // Oh, the horror! 'Consumer' deletes the encapsulated pointer
    px->Foo(); // Function called on a dangling pointer.
    // The destructor of px tries to delete the pointer again. Crash here.
    printf("Ready to exit Producer2();\n");
    fflush(stdout);
}
} // namespace mess_with_auto_ptr


int main(int argc, char* argv[])    
{
    mess_with_auto_ptr::Producer();
    mess_with_auto_ptr::Producer2();
    return 0;
}

这是输出

Producer();
X::Foo(0X00347B08);
X::Foo(0X00347B08);
X::~X(0X00347B08);
X::Foo(00000000);
Ready to exit Producer();
Producer2();
X::Foo(0X00347B08);
X::Foo(0X00347B08);
X::~X(0X00347B08);
X::Foo(0X00347B08);
Ready to exit Producer2();

最后,它会产生一个故障——并弹出“请告知 Microsoft”对话框。

看看输出的第五行:X::Foo(00000000);
我们实际上看到了对 (NULL)->Foo() 的调用。而且它似乎有效(至少,只要你在 Foo() 中不使用‘this’指针)。
这有意义吗?实际上,是的。
C++ 就是这样工作的:成员函数实际上是一个常规的、全局的 C 函数,它接收一个指向“this”的指针作为它的第一个参数。

如何用栈变量制造混乱

现在,这是另一个谜题:你认为这会做什么?

void Producer3()
{        
    X x;
    Consumer(&x); // Tries to delete a pointer to a stack variable. Will this compile? 
}

它编译得很好。直到运行时你才会知道有问题。
在调试模式下(VS2008),你会收到一个断言,内容如下:

在发布模式下,它会顺利运行到结束。

Linux 呢?

在 Linux 上,情况差不多。使用 Eclipse/GCC 以发布模式编译代码,并在终端上运行,我们会看到: 

A Linux terminal 

我们在这里看到

  • 它打印 X::Foo(nil) 而不是 X::Foo(00000000) 
  • 消息 Segmentation fault 被写入 stderr,而不是显示消息框。

尝试删除指向栈上变量的指针会导致立即崩溃。

这种行为的相似性并不令人惊讶:只有一个 C++ 标准。 

值得关注的点  

  • 悬空指针不会立即导致程序崩溃。如果它们曾经占据的内存仍然存在,它们甚至可能工作。根据墨菲定律之一,崩溃将在最需要的时候、最不希望发生的地方发生。
  • 尽可能避免显式内存分配可以为我们省去不少麻烦。
  • 标准智能指针是一个有用的工具。聪明人花时间为我们设计、编写和测试它们。不使用它们简直是粗鲁。
  • 当被迫使用显式内存分配时(例如调用 ShFileOperationFormatMessage 时),将每个函数封装在一个类中并彻底测试该类可以省去一些痛苦。 

我至今发现的避免和修复悬空指针的方法是:对于新代码,良好的设计(具有清晰的所有权);对于遗留代码,代码审查。并且总是,在任何地方,进行单元测试。
代码审查尤其能照亮代码中否则晦涩的区域,并让非原始作者的程序员熟悉它。关于代码审查有很多误解,其中之一是审查代码的唯一方法是让高级程序员检查初级程序员的代码,但这个问题属于另一篇文章。

感谢阅读,请分享您的观点。

历史

2012年5月4日 - 初版。

2012年5月18日 - 添加了几行关于 Linux 的内容。 

© . All rights reserved.