首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >两个模块互相依赖会怎样:能加载起来,却不一定能正确工作

两个模块互相依赖会怎样:能加载起来,却不一定能正确工作

原创
作者头像
PC电脑医生
发布于 2026-09-29 11:09:00
发布于 2026-09-29 11:09:00
240
举报

模块之间的依赖关系,通常被理解成一棵树:主程序在顶上,往下分出各个依赖。

但实际的依赖关系经常不是树,而是一张网——甚至可能绕成环。

两个模块互相依赖,或者三个模块首尾相连成一个圈:A 用到 B,B 用到 C,C 又回来用到 A。

这种结构会遇到一些不太直观的情况。最反直觉的一点是:它通常能加载成功——但能不能正确工作,是另一回事。

一、循环依赖长什么样

直接的循环:甲模块的导入表里有乙,乙的导入表里又有甲。

间接的循环:甲依赖乙,乙依赖丙,丙又依赖甲。三个模块未必都意识到自己在一个环里。

还有一种更隐蔽的:静态层面(导入表)不构成环,但初始化时互相调用构成环。

示意一下后面这种:

代码语言:cpp
复制
// a.cpp: A_Init calls into B
void A_Init() { B_DoSomething(); }

// b.cpp: B_Init calls back into A
void B_Init() { A_DoSomething(); }

两个模块在编译期只看到对方的声明,链接层面也许能通过;但一运行,两边都在等对方先准备好。

二、加载器怎么处理:它不会失败,而是"跳过"

先看最容易被误解的一点。

加载模块时,系统要递归地解析依赖:解析甲的导入,发现需要乙;于是去加载乙;解析乙的导入,又看到甲。

如果这里不做处理,就会无限递归下去。

所以加载器需要一份"当前正在加载中"的记录:解析过程中遇到一个已经在处理中的模块时,它不会重新走一遍,而是直接跳过——因为那个模块已经在处理队列里了。

这个保护带来的结果是:静态层面的循环依赖通常不会让加载失败。 环会被"绕开",所有模块最后都能进到进程里。

所以"程序能启动"这件事,不能用来证明依赖关系是健康的。

三、真正的代价:初始化顺序无法同时满足

加载能完成,但接下来还有一步:每个模块都要执行自己的初始化。

而这一步上,循环依赖的代价就显现了。

假设甲和乙互相依赖,那么:

  • 如果先初始化甲,甲初始化时要用到乙的东西——而乙还没有初始化
  • 如果先初始化乙,同样的问题反过来
  • 两种顺序都有问题,因为"两边都要求对方先就绪"本身就是一个无法同时满足的条件

于是出现的结果是:这次能跑,下次未必。

这不是"偶发故障",而是"顺序没被保证"的必然表现。

它可能表现为:读到还没赋值的全局状态、某个初始化只做了一半、或者在特定情况下直接崩掉。而错误的位置往往离真正的原因很远。

四、为什么"在我这儿能跑"

这是循环依赖最误导人的地方。

因为实际顺序受好几件事影响:

  • 谁先被加载(搜索路径、以及是谁先请求的)
  • 依赖的解析顺序
  • 运行时第一次用到的时机

这些条件在开发机上往往恰好凑成一个"能跑"的组合,而到了另一台机器、或者换一条启动路径,组合就变了。

所以这类问题的特征非常明确:偶发、与机器或启动路径相关、单独测各个模块都正常。

顺带说一句:这和前面讲过的"一个进程里同名模块只能有一份"是两类不同的问题——那个是"名字被占",这个是"顺序无法同时满足"。两者的共同点是:都不能靠替换文件解决。

五、要打断环,有三条路

第一条,把公共部分抽出去。

如果两个模块互相依赖,通常意味着它们共用了某些东西。把这部分独立成第三个模块,让原来两边都依赖它,环就断了。

这是最干净的解法,但它要求代码结构允许重构。

第二条,把其中一条边改成延迟加载。

让一方在真正用到时才去加载另一方。这样在加载阶段,环就不成立了。

代价有两个:第一次调用时会有一次加载开销;以及失败时机被推迟了——原本加载阶段就能发现的缺失,变成了运行到某一步才出问题。

第三条,改成"按名字取函数"。

不在编译期建立依赖,而是运行时去取对方的函数地址。

最灵活,但代价最高:名字写错、函数不存在、签名对不上,这些编译期本该拦住的问题,全都变成了运行期问题,需要自己处理。

三条路的取舍是一致的:越干净的解法越需要重构,越省事的解法把风险推得越晚。

六、怎么判断遇到的是循环依赖

两个特征,凑齐就值得怀疑:

  • 两个功能在互相调用时才出问题,而单独使用各自都正常
  • 换一台机器、或者换一种启动方式,结果就不一样

确认的方法是看依赖关系:分别列出参与的两个模块各自的依赖,看对方的名字是否出现在里面;间接的环需要多看一层。

这里有一个判断上的提醒:这类问题和"文件缺失"的表现完全不同——文件一个不少,也不会有"找不到模块"的报错。它表现出来的是"行为不对",而不是"启动不了"。

所以 DLL修复工具 在这里没有可以下手的对象:它能检查的是清单是否齐全、常见组件有没有缺、登记是否异常;而这里的每个文件都在,版本也对,出问题的是它们之间的依赖结构与顺序——这也是前面几篇反复提到的那条边界:工具的射程止于文件层。

这类问题要在代码结构上解决——抽公共部分、改延迟加载、或者改成运行期获取。补文件改变不了两个模块“互相等对方”这个事实,这也是 DLL修复工具 在这一层帮不上忙的原因。

七、按现象定位

  • 程序能启动,但某个功能行为不对(原因方向:初始化顺序得不到保证;处理方向:查两个模块的依赖关系)
  • 单独测各自正常,一起用时出问题(原因方向:循环依赖;处理方向:抽公共部分或改延迟加载)
  • 换机器、换启动路径结果不同(原因方向:顺序受环境影响;处理方向:按顺序性问题排查)
  • 读到的全局状态像是没初始化(原因方向:对方尚未就绪;处理方向:同上)
  • 崩溃位置与原因相距很远(原因方向:问题发生在初始化阶段;处理方向:查初始化阶段的互相调用)
  • 文件齐全、版本正确、却行为异常(原因方向:不是文件层的问题;处理方向:转向依赖结构)
  • 工具检查一切正常但故障依旧(原因方向:不在它的检查范围;处理方向:见上)

八、小结

关于 DLL修复工具 处理不了的那一类"文件都在却行为不对",记住四条:

  • 依赖关系可能是环:直接的、间接的,以及"静态不构成环但初始化互相调用"的
  • 静态循环通常能加载成功——加载器会用"正在加载中"的记录跳过重复,所以"能启动"不能证明依赖健康
  • 真正的代价在初始化顺序上:两边都要求对方先就绪,这个条件无法同时满足,于是结果取决于环境,表现为偶发
  • 打断环有三条路:抽公共部分、改延迟加载、改成运行期获取——越省事的做法,把风险推得越晚

这里可以带走的经验是关于"顺序"的:很多设计隐含着"某件事一定在另一件事之前发生"这个前提,而这个前提往往没有被任何机制保证。

正常的时候它刚好成立,于是没人注意到;一旦环境变了,它就变成了一个偶发故障。 循环依赖只是把这种隐含前提暴露得最明显的一种写法——遇到"有时对有时不对"的问题时,先找一找有没有这种没人保证的顺序假设。

https://www.ijinshan.com/functions/repairdll.html?channel=4098

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 一、循环依赖长什么样
  • 二、加载器怎么处理:它不会失败,而是"跳过"
  • 三、真正的代价:初始化顺序无法同时满足
  • 四、为什么"在我这儿能跑"
  • 五、要打断环,有三条路
  • 六、怎么判断遇到的是循环依赖
  • 七、按现象定位
  • 八、小结
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档