插件崩溃拖垮主程序?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最后检查 | 可追溯,可排查 |
没有什么魔法——就是把插件当成一个有独立生命周期的进程来管理,只是这个"进程"跑在主进程的地址空间里。
彩蛋环节
看到这里,细心的读者可能注意到了:对象池用了 QReadWriteLock,getObject 用 QReadLocker,addObject/removeObject 用 QWriteLocker。
为什么要读写锁而不是互斥锁?
答案是:对象池的典型访问模式是读多写少。 插件运行期间,大部分时间都是在用 getObject 查服务,只有启动和关闭时才写。读写锁在这种场景下性能比 QMutex 好一个数量级。
那是不是所有插件操作都是线程安全的?
不是。状态机本身的转换不是线程安全的——它假设所有状态变迁发生在主线程。如果有人从工作线程直接调 PluginManager::addObject,而你恰好又在主线程遍历对象池,race condition就来了。
这个问题怎么解决?下一篇会拆:Aether的工作线程模型,和跨线程调用插件的安全姿势。
当然,如果你等不及,翻翻 pluginmanager.cpp 里那些被注释掉的 QMutexLocker 和 profilingReport——那些是Qt Creator早期版本的遗迹,它们走过的弯路,就是最好的学习材料。
💬 评论区聊聊:
你的插件项目遇到过"卸载后崩溃"的问题吗?是怎么排查和解决的?欢迎分享你的血泪史。
🔄 觉得有用?
点个在看让更多人看到,也转发给团队里正在做插件化架构的同事——这篇能帮他少踩几个坑。
这篇的核心源码在 common/core/extensionsystem/ 下,3个头文件3个cpp,总共不到1500行。挖得下去的读者可以直接去看,代码写得相当干净。
下一篇预告:工作线程 vs 主线程——插件跨线程通信的血泪史。
更多推荐

所有评论(0)