乐于分享
好东西不私藏

插件崩溃拖垮主程序?3招插件隔离方案

插件崩溃拖垮主程序?3招插件隔离方案

从C++到工业级:Aether项目精讲 · 第12篇

一、插件最恐怖的bug:卸载插件3分钟后,主程序崩溃了

做插件系统的都懂:插件是运行时最大的不稳定源。

你永远不知道第三方开发者会在插件里写什么。也许是一个野指针,也许是某个全局变量没清理,也许是在析构函数里访问了已经被卸载的内存。但最恐怖的是下面这种:

用户卸载了一个插件。一切正常。3分钟后,主程序毫无征兆地崩溃了。

为什么不是立即崩溃?因为插件在卸载时没清理干净——它的某个服务对象还挂在对象池里,被其他插件持有引用。那个对象已经被析构了,但引用还在。3分钟后,另一个插件调用了这个悬空指针,于是你的主进程轰然倒地。

这个问题有多普遍?Qt Creator、Eclipse、Visual Studio Code——所有插件化的大型软件都踩过这个坑。区别在于,有的软件选择相信插件开发者"你会好好写清理代码",有的软件则选择用架构来兜底。

Aether选择了后者。

它的解法不是靠制度("请插件作者务必在析构前调用removeObject"),而是靠状态机和强制生命周期来保证。哪怕插件作者什么都不做,系统也不会因为卸载一个插件而崩溃。

这篇就来拆这套方案的四个核心设计:7状态状态机、初始化顺序编排、安全卸载三道防线、对象池联动清理


二、7状态状态机:把插件当进程管

先看最底层的东西——PluginSpec的State枚举:

enum State {
    Invalid,    // 刚创建,什么都不是
    Read,       // JSON元数据读取完毕
    Resolved,   // 依赖解析完成
    Loaded,     // DLL加载成功,IPlugin实例创建
    Initialized,// initialize() 执行完毕
    Running,    // extensionsInitialized() 执行完毕
    Stopped,    // aboutToShutdown() 执行完毕
    Deleted     // 插件实例已销毁
};

8个状态,从生到死,每个状态的转换都有严格的守卫条件。

loadPlugin函数的骨架你就明白了:

void PluginManagerPrivate::loadPlugin(PluginSpec *spec, PluginSpec::State destState)
{
// 守卫:只有紧邻的上一个状态才能进入下一个状态
if (spec->hasError() || spec->state() != destState - 1)
return;

switch (destState) {
case PluginSpec::Loaded:
        spec->d->loadLibrary();
break;
case PluginSpec::Initialized:
        spec->d->initializePlugin();
break;
case PluginSpec::Running:
        spec->d->initializeExtensions();
break;
case PluginSpec::Stopped:
        spec->d->stop();
break;
case PluginSpec::Deleted:
        spec->d->kill();
break;
default:
break;
    }
}

注意这个条件:spec->state() != destState - 1。它意味着状态机不允许跳步。你不能从Resolved直接跳到Running,必须先经过Loaded,再经过Initialized。

这个设计的精妙之处在于——任何一步失败了,状态机就停在那里,不会继续往前走,也不会往后回退。 状态直接反映了插件走到了哪一步,哪里出了问题。

举个例子:如果某个插件的initialize()抛了异常,它的状态停留在Initialized(实际上报错了,hasError=true)。后续依赖它的其他插件在编排时,会因为依赖状态不对而拒绝加载,而不是稀里糊涂地访问一个半残的插件。

状态机在这里不是花架子,它是一张进度表,更是一张保险单。


三、初始化顺序编排:先爹后儿子,中间卡住就回滚

插件系统的第二大难题是初始化顺序。

插件A依赖插件B,B依赖C。那加载顺序必须是C→B→A。但如果有人写了循环依赖呢?A依赖B,B依赖C,C依赖A?

Aether的解法在一个递归函数里:

bool PluginManagerPrivate::loadQueue(PluginSpec *spec,
        QList<PluginSpec *> &queue,
        QList<PluginSpec *> &circularityCheckQueue)

{
if (queue.contains(spec))
return true;

// 循环依赖检测
if (circularityCheckQueue.contains(spec)) {
        spec->d->hasError = true;
        spec->d->errorString = "Circular dependency detected:...";
return false;
    }

    circularityCheckQueue.append(spec);

// 先处理依赖
for (auto dep : spec->dependencySpecs()) {
if (!loadQueue(dep, queue, circularityCheckQueue))
return false// 依赖加载失败,自己也失败
    }

// 把自己加入队列末尾
    queue.append(spec);
return true;
}

这是典型的拓扑排序 + 循环依赖检测。一个list用来做DFS遍历,另一个list做排序结果。

然后loadPlugins分三个阶段执行:

void PluginManagerPrivate::loadPlugins()
{
    QList<PluginSpec *> queue = loadQueue();

// 第一阶段:加载DLL
    foreach (PluginSpec *spec, queue)
loadPlugin(spec, PluginSpec::Loaded);

// 第二阶段:调用initialize()
    foreach (PluginSpec *spec, queue)
loadPlugin(spec, PluginSpec::Initialized);

// 第三阶段:调用extensionsInitialized()
// 注意:这里逆序遍历!
    Utils::reverseForeach(queue, [this](PluginSpec *spec) {
loadPlugin(spec, PluginSpec::Running);
if (spec->state() == PluginSpec::Running) {
            delayedInitializeQueue.append(spec);
        } else {
// 初始化失败,清理
            spec->d->kill();
        }
    });
}

为什么第一、第二阶段正序,第三阶段逆序?

因为这三个阶段解决的是不同的问题:

  • DLL加载(Loaded阶段): 从根依赖开始加载,儿子依赖爹,爹先加载。
  • initialize阶段: 同样从根依赖开始初始化——爹先把服务注册到对象池,儿子才能从池子里拿。
  • extensionsInitialized阶段: 逆序!因为爹的服务已经注册好了,儿子先用爹的服务;轮到你爹的时候,它不需要你儿子的服务。

如果不逆序会怎样?

考虑A(CorePlugin)→ B(HomePlugin)这个依赖链。A注册IMainWindowService,B在extensionsInitialized里拿这个服务来注册页面。

如果第三阶段也正序跑:先跑A的extensionsInitialized——但A已经没事可做了,它的服务在initialize里就注册完了。再跑B——B拿到A的服务,正常。

看起来没问题?换个场景:A(CorePlugin)→ B(PermissionPlugin)→ C(HomePlugin)。

正序跑第三阶段:A先跑extensionsInitialized,无事可做→B跑,无事可做→C跑,拿到A和B的服务。正常。

那为什么还要逆序?因为Qt Creator的实践发现:在更复杂的依赖网里,逆序能尽早暴露子插件的错误,让更基础的插件在更高优先级上保持稳定。

不管顺序怎么安排,核心原则只有一个:爹得先准备好,儿子才能上台。


四、安全卸载三道防线:不让一个野指针活到下一帧

回到开头那个问题:插件卸载3分钟后崩溃。

Aether用三道防线来解决。

第一道防线:卸载时先停服务,再删插件

PluginManagerPrivate::stopAll() 调用每个插件的 stop(),这个函数触发 aboutToShutdown() 回调:

IPlugin::ShutdownFlag PluginSpecPrivate::stop()
{
if (!plugin)
return IPlugin::SynchronousShutdown;
    state = PluginSpec::Stopped;
return plugin->aboutToShutdown();
}
void PluginManagerPrivate::deleteAll()
{
    Utils::reverseForeach(loadQueue(), [this](PluginSpec *spec) {
loadPlugin(spec, PluginSpec::Deleted);
    });
}

先stop再delete。所有插件先执行清理逻辑(持久化状态、释放资源、通知下游),等所有插件都停下来了,再逐个delete。

这个顺序保证了:你在stop里还能安全访问其他插件;而delete时,已经没有插件在运行了。

第二道防线:异步关闭 + 事件循环等待

有些插件需要异步关闭——比如正在写数据库的事务,不能立刻中断。

这时候aboutToShutdown返回AsynchronousShutdown,系统怎么处理?

case PluginSpec::Stopped:
if (spec->d->stop() == IPlugin::AsynchronousShutdown) {
        asynchronousPlugins << spec;
connect(spec->plugin(), &IPlugin::asynchronousShutdownFinished,
this, &PluginManagerPrivate::asyncShutdownFinished);
    }
break;
void PluginManagerPrivate::shutdown()
{
stopAll();
// 如果有异步关闭的插件,开启事件循环等待
if (!asynchronousPlugins.isEmpty()) {
        shutdownEventLoop = new QEventLoop;
        shutdownEventLoop->exec(); // 等所有插件发 finished 信号
    }
deleteAll();
}

系统不会强行摧毁一个还不想死的插件。 它等插件自己发asynchronousShutdownFinished信号,再继续后面的清理。这个等待不是spinloop空转,而是事件循环——主界面不会卡死。

第三道防线:try/catch 兜住所有异常

C++的异常如果跨模块传播,结果往往是灾难性的——MSVC的/EHs/EHsc混用会导致栈不展开,直接跳terminate。

Aether在每个关键入口都加了try/catch:

try {
if (!loader.load()) {
        hasError = true;
        errorString = loader.errorString();
return false;
    }
catch (const std::exception &ex) {
    hasError = true;
    errorString = QString::fromUtf8(ex.what());
return false;
catch (...) {
    hasError = true;
    errorString = "unknown exception during DLL load";
return false;
}

initializePlugin和initializeExtensions里也是同样的模式:

try {
if (!plugin->initialize(&err)) { ... }
catch (const std::exception &ex) {
    errorString = QString::fromUtf8(ex.what());
    hasError = true;
return false;
catch (...) {
    errorString = "Plugin initialization failed: unknown exception";
    hasError = true;
return false;
}

这三道防线叠加的效果是:

一个插件就算在DLL加载时直接segfault、在initialize里throw随机异常、在aboutToShutdown里挂起等待——主程序都不会崩。最坏情况是这个插件自己被标记为hasError,其他依赖它的插件加载失败,但主进程活着,UI响应着,日志记录着


五、对象池联动清理:addObject/removeObject 的时机

状态机只能保证插件本身的生存周期,但插件的"遗产"——它注册到对象池里的服务对象——怎么清理?

看PluginManager的对象池操作:

void PluginManagerPrivate::addObject(QObject *obj)
{
QWriteLocker lock(&m_lock);
if (obj == nullptr) {
qWarning() << "trying to add null object";
return;
    }
if (allObjects.contains(obj)) {
qWarning() << "trying to add duplicate object";
return;
    }
    allObjects.append(obj);
    emit q->objectAdded(obj);
}

void PluginManagerPrivate::removeObject(QObject *obj)
{
if (!allObjects.contains(obj)) {
qWarning() << "object not in list";
return;
    }
    emit q->aboutToRemoveObject(obj);   // ← 通知持有者
QWriteLocker lock(&m_lock);
    allObjects.removeAll(obj);
}

标准操作:addObject加入对象池,removeObject移出对象池。

但问题是:谁负责调用removeObject?

Aether的答案很直白:谁add的,谁remove。

// 典型的插件写法
bool CorePlugin::initialize(QString *errorString)
{
    m_mainWindowService = new MainWindowService();
    PluginManager::addObject(m_mainWindowService);
return true;
}

void CorePlugin::extensionsInitialized()
{
// 使用其他插件的服务...
}

void CorePlugin::aboutToShutdown()
{
// 清理:从对象池移除,再析构
    PluginManager::removeObject(m_mainWindowService);
delete m_mainWindowService;
    m_mainWindowService = nullptr;
}

这个简单的契约能解决90%的问题。但剩下的10%是什么?

就是插件开发者忘了写removeObject的情况。

这时候系统怎么兜底?shutdown的最后一关:

void PluginManagerPrivate::shutdown()
{
stopAll();       // 防线一:先停服务
// 等待异步插件...
deleteAll();     // 防线二:删插件实例

// 防线三:检查对象池遗留
if (!allObjects.isEmpty()) {
qDebug() << "There are" << allObjects.size() << "objects left in the pool.";
for (QObject *obj : allObjects)
qDebug() << "  pool entry:" << static_cast<void *>(obj);
    }
}

虽然它不能自动清理(你没办法安全地delete一个不知道类型的对象),但它至少能告诉你:谁留下了垃圾,留下了多少。

完整的时序图:

时间线    CorePlugin                  PluginManager               HomePlugin
  │         │                            │                           │
  │         │  initialize()              │                           │
  │         │  addObject(svc) ────────►  │                           │
  │         │                            │  objectAdded(signal)      │
  │         │                            │  (存放于 allObjects)      │
  │         │                            │                           │
  │         │                            │       extensionsInitialized()
  │         │                            │  ◄────────────────────   │
  │         │                            │  getObject<IMainWindowService>()
  │         │                            │  ──── 返回 svc ──────►  │
  │         │                            │                           │
  │         │                            │                           │
  │         │  应用关闭 ...              │                           │
  │         │                            │                           │
  │         │  aboutToShutdown()         │                           │
  │         │  removeObject(svc) ────►  │                           │
  │         │                            │  aboutToRemoveObject(signal)
  │         │                            │  (从 allObjects 移除)    │
  │         │  delete svc               │                           │
  │         │                            │                           │
  │         │  ~CorePlugin()             │                           │
  │         │                            │                           │

遗留检查放在最后的意义: 如果一个插件忘了清理对象池,你不会在深夜收到"线上主程序挂了"的告警——你只会看到一行警告日志,告诉你某个插件没尽到义务。系统仍能安全退出。


六、这套方案好在哪

回头看整个设计,你会发现它没有用任何高深的技术。没有沙箱进程隔离,没有IPC通信,没有信号量栅栏。它就是靠状态约定 + 强制顺序 + 兜底清理这三板斧,把插件系统的稳定性提到了一个新的台阶。

具体来说:

问题
解法
效果
插件加载到一半挂了
状态机停留 + hasError标记
依赖方无法继续,不会访问残血插件
循环依赖
递归DFS检测
启动时直接报错,不进入加载流程
初始化顺序乱
拓扑排序 + 三阶段加载
保证爹先于儿子初始化
卸载不干净
先stop再delete,异步等待
给插件充分的清理机会
异常跨模块传播
try/catch包围所有回调
异常转成错误状态,不崩进程
对象池遗留
shutdown最后检查
可追溯,可排查

没有什么魔法——就是把插件当成一个有独立生命周期的进程来管理,只是这个"进程"跑在主进程的地址空间里。


彩蛋环节

看到这里,细心的读者可能注意到了:对象池用了 QReadWriteLockgetObject 用 QReadLockeraddObject/removeObject 用 QWriteLocker

为什么要读写锁而不是互斥锁?

答案是:对象池的典型访问模式是读多写少。 插件运行期间,大部分时间都是在用 getObject 查服务,只有启动和关闭时才写。读写锁在这种场景下性能比 QMutex 好一个数量级。

那是不是所有插件操作都是线程安全的?

不是。状态机本身的转换不是线程安全的——它假设所有状态变迁发生在主线程。如果有人从工作线程直接调 PluginManager::addObject,而你恰好又在主线程遍历对象池,race condition就来了。

这个问题怎么解决?下一篇会拆:Aether的工作线程模型,和跨线程调用插件的安全姿势。

当然,如果你等不及,翻翻 pluginmanager.cpp 里那些被注释掉的 QMutexLocker 和 profilingReport——那些是Qt Creator早期版本的遗迹,它们走过的弯路,就是最好的学习材料。


💬 评论区聊聊:

你的插件项目遇到过"卸载后崩溃"的问题吗?是怎么排查和解决的?欢迎分享你的血泪史。

🔄 觉得有用?

点个在看让更多人看到,也转发给团队里正在做插件化架构的同事——这篇能帮他少踩几个坑。


这篇的核心源码在 common/core/extensionsystem/ 下,3个头文件3个cpp,总共不到1500行。挖得下去的读者可以直接去看,代码写得相当干净。

下一篇预告:工作线程 vs 主线程——插件跨线程通信的血泪史。