道可叨

Free Will

DllMain 中的死锁问题

问题

某程序要在动态加载的 DLL 里创建线程,并希望 DLL 在被 FreeLibrary 时能自动销毁线程,以实现安全退出。DLL 的实现代码可简写为

class Worker
{
public:
    Worker()
        : quit_(true)
    {}

    ~Worker()
    {
        Stop();
    }

    void Work()
    {
        if (thread_)
        {
            return;
        }
        thread_.reset(
            new boost::thread(
                boost::bind(
                    &Worker::DoWork, this)));
    }

    void Stop()
    {
        quit_ = true;
        if (thread_)
        {
            thread_->join();
            thread_.reset();
        }
    }

private:
    void DoWork()
    {
        quit_ = false;
        while (! quit_)
        {
            DoSomething();
        }
    }

    boost::scoped_ptr<boost::thread> thread_;
    bool volatile quit_;
};

Worker gWorker;

__declspec(dllexport) void StartThread()
{
    gWorker.Work();
}

这里使用了 C++ 的全局变量和 boost::thread 库,并且没有显示定义 DllMain 函数。这种情况下,C++ Runtime 会提供一份隐藏的 DllMain,它会在 FreeLibrary 时销毁全局变量和静态变量,比如 gWorker 对象。 而 Worker 类又用 RAII 手法在析构函数中通知并等待线程退出。这样就能保证在 FreeLibrary 时 DLL 中创建的线程自动销毁了。该方法很简洁,也应该行的通。但在实际调用 FreeLibrary 时,程序在 C++ Runtime 的 DllMain 中发生死锁。

原因

死锁最常见的原因就是多个线程彼此拥有了对方需要的资源,大家都在等对方释放资源好继续运行。从代码层面上来说,就是多个锁的获取顺序不一致:

// A 和 B 拿刀叉的顺序不一样,如果只有一副刀叉,他俩可能都得饿死
Thread_A()
{
    lock_guard lock(mutex_for_fork);
    lock_guard lock(mutex_for_knife);
    eat();
}

Thread_B()
{
    lock_guard lock(mutex_for_knife);
    lock_guard lock(mutex_for_fork);
    eat();
}

太阳下面无新事, DllMain 死锁的原因也是一样。原来,微软为了保证多个动态库 load / unload 并发调用的多线程安全,在 DllMain 上加了把 loader lock 全局锁:在 LoadLibrary 时,为避免多个线程同时 load 同一个 DLL,系统会先获取 loader lock 锁再进行检查。只有当发现没有加载过该 DLL 时才真正加载这个 DLL 并调用它的 DllMain ,在这一切做完后再将 loader 锁释放。 FreeLibrary 的时候同样如此。唯有这样,才能保证并发 load/unload DLL 时不出乱子。

问题来了,DLL 中任意一个线程(包括主线程和派生线程)的退出系统都会调用 DllMain 进行通知。那如果 DllMain 中的代码要是再与其它线程进行同步的话,这就相当于两把锁,极有可能产生死锁。具体到我们的例子,在 FreeLibrary 时发生了什么呢

  • 主线程中
    1. 获取 loader lock,安全调用 DllMain
    2. 通知 worker thread 退出
    3. 使用 WaitForSingleObject 等待 worker thread 退出
  • Worker 线程
    1. 收到退出通知
    2. 获取 loader lock,安全调用 DllMain
    3. 线程退出

换句话说,主线程退出就是先拿了叉子(loader lock),再要拿刀( WaitForSingleObject )。而工作线程退出就是要先拿叉子(loader lock)才能给你刀(唤醒 WaitForSingleObject )。死锁发生了!

// 伪代码
Main_Thread()
{
    Lock(loader_lock);
    WaitForSingleObject(Worker_Thread);
}

Worker_Thread()
{
    Lock(loader_lock);
    ThreadExit(Worker_Thread);
}

微软充分预见到这个危险,在 Best Practices for Creating DLLs 中就详细说明了 loader lock 以及 DllMain 中的编码注意事项,包括禁止在 DllMain 中进行线程同步。

没有漂亮的解。建议不提供自动释放线程的功能。既然导出了创建工作线程的函数,那就再导出一个停止线程的函数。Client 要在 FreeLibrary 之前主动调用。

__declspec(dllexport) void StopThread()
{
    gWorker.Stop();
}

如果仍然希望提供自动清除线程这种安全的便利性,需要的改动是:在主线程 DllMain 中不等待工作线程退出,而是等待工作线程进行必要的清理动作后发出通知再强行终止工作线程;同时,工作线程在通知发出后还要进行无限等待以避免过早退出后进入 DllMain 导致死锁。

bool gQuit = false;
Event gEvent;

void WorkerThread()
{
    while (! quit_)
    {
        DoSomething();
    }
    FinishJob();
    CleanUp();
    SetEvent(gEvent); // 现在你可以强行终止我了

    while (true) // 等待强杀
    {
        Sleep(1);
    }
}

DllMain()
{
    gQuit = true;
    WaitForSingleObject(gEvent);
    TerminateThread(gThreadHandler);
}

这里不推荐这个方案。仅仅为了 client 调用方便就引入这么晦涩的逻辑,费效比低,也不利于代码维护。而且在强杀线程前一定要确认线程所用的资源都处于一致的状态,这个前置条件是颗定时炸弹,对代码维护人员是个考验。

教训

为了让代码简洁方便又安全,人们发明了很多东西。比如 C++ runtime 自动管理全局变量的生命周期;微软使用 loader lock 保证 DLL 并发加载的安全性;以及我们例子中希望在 FreeLibrary 时自动释放工作线程。这些确实方便了代码,但同时,每一个简化都是有代价的:

  • C++ 全局变量初始化和释放的自动管理让调试变的困难,也让初始化和释放的顺序不可控
  • 微软的 loader lock 让 DllMain 的编写多了一些潜规则和禁忌
  • 要正确实现 DLL 退出时自动释放工作线程的功能,这要化相当的精力,而且代码的可读性和可维护性都有潜在的问题

好设计必须充分权衡收益和代价。换言之,本就没有 好的 设计,只有 合适的 设计。