ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

VC6运行库std::string线程安全缺陷引发的崩溃排查与修复

VC6运行库std::string线程安全缺陷引发的崩溃排查与修复 “VC6 这个坑我踩了差不多半个月才爬出来。” 如果你手头还维护着那些年用 Visual C 6.0 写的遗留系统或者公司里还有跑在 Windows Server 2003 时代的服务程序下面这个案例我强烈建议你读完。它不是什么高深算法问题也不是并发锁没写对而是 VC6 自带的运行库在特定场景下会悄悄埋下一颗定时炸弹——程序平时跑得稳稳当当等到运行一段时间后毫无征兆地“啪”一下没了。最气人的是你单独跑测试、跑调试器、加日志全都不复现一上生产环境就闹脾气。这个问题的价值在于很多人遇到“长时间运行后崩溃”都习惯性去查内存泄漏、多线程竞态却很少往编译器版本和运行库实现上想。如果你也在维护老 C 项目看完这篇至少能少走一周弯路。1. 问题现象跑得好好的怎么突然就崩了1.1 典型的故障现场先说说当时的环境。那是一个运行在 Windows Server 2003 上的数据采集服务VC6 编译Release 版负责从网络端口接收数据解析后存入本地数据库。进程需要 7×24 小时运行平时内存占用稳定在 80MB 左右CPU 占用也不高。故障很典型进程运行 2 到 5 天后会在某个完全没有业务特征的时刻直接消失。不是 Windows 弹“xxx.exe 遇到问题需要关闭”的窗口而是连窗口都没有进程就像被人杀了一样凭空蒸发。事件查看器里偶而能捞到一条Application Error错误代码是0xc0000005也就是访问违例。但问题是同一段代码开发环境跑一周没问题客户现场却三两天就崩一次。这种“稳定运行一段时间后异常中止”的故障最让程序员头疼。原因很简单它不满足可复现条件。没有固定的操作路径没有稳定的复现步骤日志也往往停在最近一次正常业务附近崩溃点和日志滞后之间存在时间差导致你完全不知道从哪里下手。1.2 第一轮排查的常见误判我们第一轮排查犯了几乎所有团队都会犯的错误——怀疑自己的代码。先看内存。用性能监视器盯了三天物理内存没有持续增长虚拟内存也平稳说明不存在典型的内存泄漏。又怀疑是不是线程同步问题于是把可疑的共享锁全部 Review 了一遍甚至加了更细粒度的临界区结果崩溃依旧。再怀疑第三方库比如数据库驱动或者网络库但把这些模块单独摘出来做压力测试连续跑一周也没事。后来我们把视线转向崩溃现场。要让崩溃现场说话就必须先解决“抓现场”的问题。当时生产环境是 Release 版没有调试信息也没有配置 WER 转储。我们只能决定先在测试环境长时间压测同时挂一个守护进程在目标进程崩溃时记录退出码和最后日志时间。这个方法很笨但也算有效。压测到第 22 个小时服务崩了一次。退出码是0xC0000005最后日志停在一个字符串拼接操作之后的下一行——注意不是正在做字符串拼接那行崩溃而是拼接完成后进入另一个函数的入口处崩了。这个细节一开始没引起我们注意直到后来拿到了真实调用栈才意识到这就是关键线索。1.3 从“病灶”到“病根”崩溃点为什么飘忽不定访问违例的典型特征就是崩溃点飘忽不定。因为真正损坏的是内存结构比如堆上的链表指针、对象的虚函数表指针只要你碰巧访问到那片被破坏的地方就会崩。这就是为什么同样的错误可能表现在字符串拷贝、容器的push_back、甚至一个纯 printf 里。所以排查这种问题时千万别被崩溃点骗了。崩溃点只是“受害者”真正的“凶手”往往在几分钟甚至几小时之前就完成了破坏。我一直跟团队说碰到0xc0000005先别急着看当前调用栈往前翻翻历史索引看看有没有内存被踩踏的迹象。这一步成了我们最后定位到 VC6 运行库 bug 的重要契机。2. 排查过程从崩溃转储到嫌疑锁定2.1 给程序装上“黑匣子”抓取崩溃现场既然手工盯日志效率太低我们决定给程序加一个“黑匣子”通过SetUnhandledExceptionFilter注册顶层异常过滤器在进程崩溃前用MiniDumpWriteDump把完整转储写下来。这样即使进程没了也会留下包含堆、栈、寄存器、加载模块列表的 dump 文件。核心代码大概是这样的#include windows.h #include dbghelp.h #pragma comment(lib, dbghelp.lib) LONG WINAPI CrashFilter(EXCEPTION_POINTERS* pException) { HANDLE hFile CreateFileA( crash.dmp, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL ); if (hFile ! INVALID_HANDLE_VALUE) { MINIDUMP_EXCEPTION_INFORMATION dumpInfo; dumpInfo.ThreadId GetCurrentThreadId(); dumpInfo.ExceptionPointers pException; dumpInfo.ClientPointers TRUE; MiniDumpWriteDump( GetCurrentProcess(), GetCurrentProcessId(), hFile, MiniDumpWithFullMemory, dumpInfo, NULL, NULL ); CloseHandle(hFile); } return EXCEPTION_EXECUTE_HANDLER; } void InitCrashHandler() { SetUnhandledExceptionFilter(CrashFilter); }别嫌这代码老它救了我们好多次。在 Debug 下可能没什么感觉但 Release 版一旦连上了这个过滤器等于给进程装了个事后尸检设备。在测试环境加上这个黑匣子后我们又压测了一周。中间崩溃了三次每次 dump 文件都及时生成。拿到 dump 后用 Windbg 执行!analyze -v崩溃线程的调用栈清清楚楚地指着一个函数msvcp60.dll!std::basic_stringchar,std::char_traitschar,std::allocatorchar ::assign()都是老朋友了std::string的赋值操作。但诡异的是崩溃线程栈里并没有我们自己写的那段业务代码的直接调用关系而是在一个线程的启动函数里执行std::string赋值时崩的。那个线程做的事非常简单从全局事件队列里取一个字符串然后赋给本地变量。2.2 崩溃栈中的蛛丝马迹用kb查看调用栈发现崩溃线程是在执行标准库的_Copy内部函数时访问了已释放的内存。再继续往下查栈上还残留着一个之前已经被删除的字符串对象的地址。到这里基本可以确定某个字符串对象在被赋值时内部数据指针指向的内存已经失效了。但奇怪的是我们在这个线程里并没有主动释放过任何字符串。所有字符串都是从队列里拷出来的生命周期也足够长。唯一可疑的地方是队列里的字符串是另一个线程在生产。换句话说生产线程在构造字符串 A消费线程在拷贝字符串 A。而 A 的内部数据块是共享的。这个共享数据块什么时候被释放由引用计数决定。如果引用计数错了释放就会提前发生。我马上意识到这与 VC6 的std::string实现有关。于是我查了 VC6 头文件里的 string 实现果然是写时复制Copy-On-Write简称 COW加引用计数。标准的 COW 本身不是啥新鲜事问题在于 VC6 的 STL 在实现 COW 时引用计数的增减不是原子操作。这个 bug 的直接后果就是只要两个线程同时操作同一个字符串内部数据块的引用计数计数器就会丢失更新。轻则内存泄漏重则提前释放。我们遇到的是后者。2.3 缩小范围写个最小复现程序为了确认是不是运行库的锅我写了个最小复现程序在 VC6 环境下编译 Release 版#include string #include windows.h DWORD WINAPI ThreadA(LPVOID) { std::string s1 this is a long string that will be allocated on heap; while (true) { std::string s2 s1; // 共享内部数据refcount Sleep(1); } return 0; } DWORD WINAPI ThreadB(LPVOID) { while (true) { std::string s3 another long string ......; s3 hello world; // 可能触发 release 和 deallocate Sleep(1); } return 0; } int main() { CreateThread(NULL, 0, ThreadA, NULL, 0, NULL); CreateThread(NULL, 0, ThreadB, NULL, 0, NULL); Sleep(100000); return 0; }这个程序看似简单但已经模拟了多线程对std::string的共享引用计数交叉增减。用 VC6 Release 编译后跑多数机器上数小时内就会崩溃用 VS2019 编译同样的逻辑跑几天都没事。当然最小复现需要多次尝试因为触发条件依赖线程切换的时机只要两个线程几乎同时操作同一个字符串数据块引用计数就会出错。我那个例子里线程 A 持续拷贝同一字符串线程 B 不断创建、覆盖新字符串两个线程频繁竞争堆内存和引用计数崩只是时间问题。这一下嫌疑范围就缩小到了 VC6 运行库而不是我们自己的业务代码了。3. 罪魁祸首VC6 运行库的那些陈年旧账3.1 VC6 早就该退休了Visual C 6.0 发布于 1998 年比现在还在上大学的那批程序员岁数都大。它自带的运行库是msvcrt.dll和msvcp60.dll对应的是那个年代的标准模板库实现。作为开发工具它承载了一代人包括我的青春回忆但作为运行环境它的问题实在太多了。VC6 运行库的 STL 实现尤其是std::string采用的是写时复制技术。写时复制的初衷很美好多个字符串对象共享同一个内部字符缓冲区只有当某个对象要修改内容时才真正复制一份从而减少拷贝开销。这在单线程下确实高效但在多线程环境下共享意味着需要同步。而 VC6 的std::string在引用计数的增减上完全没有使用 Interlocked 系列原子操作就是普通的和--。3.2 核心Bugstd::string 的线程不安全引用计数我们来看一下 VC6std::string的内部布局简化版class string { struct _Rep { size_t _Len; // 字符长度 size_t _Cap; // 容量 long _Refs; // 引用计数VC6 里就是普通 long char _Data[1]; // 数据区 }; _Rep* _Myrep; };拷贝构造和赋值时不会复制字符数组而是复制_Myrep指针然后执行_Myrep-_Refs。析构时执行--_Myrep-_Refs如果减到 0 就释放缓冲。关键问题就在这个/--上。它不是原子的。假设线程 A 和线程 B 同时持有一个指向同一个_Rep的指针线程 A 执行_Refs读取到 1还没写回线程 B 也执行_Refs也读取到 1两个线程各自把 2、3 或 1 写回去取决于时序。如果最终写回的是 1那明明有两个对象引用这块内存引用计数却只等于 1。等其中一个线程析构时--_Refs变成 0内存被释放。另一个线程下次再去访问_Data就是访问已经释放的堆内存——轻则读到脏数据重则直接访问违例崩溃。这个 bug 为什么难复现因为多线程竞争窗口极小两个线程必须同时对该内存块进行引用计数增减并且刚好被打断。只要系统负载高一点、线程切换频繁一点“运气好”就撞上了。这跟我们“运行两天后崩溃”的现象完全吻合前期线程调度平缓后期系统缓存增大、磁盘 IO 频繁上下文切换机会多了雷就踩响了。3.3 还有哪些同类“定时炸弹”其实 VC6 运行库里不只std::string一个坑。维护老代码这些年我还见过其他几个典型的“定时炸弹”问题模块具体表现触发条件std::stringCOW 引用计数提前释放、偶发崩溃多线程并发拷贝/赋值std::vectorbool位引用导致操作怪异使用了 vectorbooliostream 全局对象未定义静态初始化顺序引用std::cout、std::cin的静态对象格式化浮点数浮点环境被破坏长时间反复使用printf/sprintfDebug 堆填充Release 正常Debug 崩用了不匹配的 CRT 版本其中std::vectorbool在 VC6 里是著名的“代理对象”问题本来标准委员会为了省空间搞了个特化结果把迭代器和引用的语义搞坏了iostream的全局对象的构造和析构顺序在跨模块时容易出问题浮点格式化倒是没直接引发崩溃但会让数值结果偏离预期间接导致业务异常。至于运行库版本冲突更是家常便饭。一台机器上可能同时存在多个版本的msvcp60.dll、msvcp71.dll、msvcr80.dll等如果程序用了一个老系统目录里的 CRT而另一个 DLL 用了新 CRT两边操作同一个FILE*或者malloc就可能因为堆不匹配而崩溃。这类问题最容易在“运行一段时间后”爆发因为你根本不知道哪个模块在什么时间点加载了哪个版本的 CRT。4. 解决方案要么改代码要么换运行库要么升级编译器4.1 最稳妥的短期方案绕开不安全的共享数据确认是 VC6 运行库的 bug 后短期不可能立刻重写整个系统所以先做规避设计。第一个思路避免多线程共享同一份std::string内部数据。具体来说所有进入队列的字符串不要直接拷贝赋值而是使用.c_str()转成const char*再重新构造强制深拷贝// 原代码s2 和 s1 共享内部缓冲 std::string s2 s1; // 规避写法强制深拷贝 std::string s2(s1.c_str(), s1.size());这样的“深拷贝”每次都会分配新内存并复制字符虽然性能下降但安全。在数据量不大的场景下性能损失可以接受。第二个思路给所有std::string的拷贝和赋值操作加一个全局锁。在关键入口处统一加临界区确保引用计数的增减串行化class StringLock { public: StringLock() { EnterCriticalSection(cs); } ~StringLock() { LeaveCriticalSection(cs); } static CRITICAL_SECTION cs; }; CRITICAL_SECTION StringLock::cs; // 在每个字符串拷贝处 std::string SafeCopy(const std::string src) { StringLock lock; return src; }注意这样只能保护我们自己代码里的拷贝操作如果第三方库内部也在拷贝同一个字符串那就还是可能有风险。所以更彻底的方法是避免跨线程传递未加保护的std::string对象。第三个思路彻底替换字符串类型。在关键线程模块里不再使用std::string改用自己写的一个小型字符串类内部就是char* size capacity 原子引用计数或者干脆用固定大小的字符缓冲区。这个方案改动量稍大但最可控。我当时为了快速上线在核心队列模块里把std::string换成了自定义的SafeString类改动范围只涉及队列存取接口大约三天时间就切换完了。4.2 根治路线升级编译器和运行库短期方案能止血但根治还是要升级。VC6 是 1998 年的东西2024 年的今天还在生产环境用它本身就已经是极大的技术债务。让团队下决心升级也是这次事故带给我们最大的收获。升级路径通常有两条一条是升级到更高版本的 Visual C比如 VS2010、VS2013、VS2015 甚至 VS2022。新版本的 STL 在 C11 之后基本废弃了 COW 实现转而采用移动语义引用计数的设计从根本上不同了。尤其是 VS2015 之后的 STL 对并发安全有了更明确的支持不同线程各自持有不同对象副本时共享底层数据的行为是安全的事实上新版已不使用COW。迁移的工作量主要在三方面头文件包含、编译选项、以及std::名称空间中对某些过时接口的调整。如果项目是纯 Win32 API STL 写的迁移成本其实没有想象中那么高。另一条是使用 MinGW-w64 或 Clang 编译。背后用的是 libstdc 或 libc这两个库的std::string实现默认是深拷贝libstdc 虽然早期也是COW但在 GCC 5.1 之后改为深拷贝而且内部使用原子引用计数或直接深拷贝线程安全特性更好。如果项目对依赖库要求不高换编译器比换 IDE 更直接。升级过程中有几个坑要注意运行库选择VS2015 以后统一为Universal CRT程序发布时需要带上vc_redist.x64.exe或相关 DLL。如果服务器是离线环境需要提前整合到安装包。编译选项/GX、/GR在新版本中默认开启如果有异常处理相关的方言代码行为可能有差异。平台 SDK 要匹配。VC6 时代常用的Socket、Winsock接口在后续版本中大多兼容但少数 API 的行为变了比如WSACleanup的调用时机要求更严格。4.3 如果短期无法升级替换关键代码库还有一种情况项目体量太大或依赖了太多只在 VC6 下才能编译的第三方库短期内根本无法升级。这时候只能对运行库本身做局部替换。比如单独针对std::string的 COW bug可以使用一个更安全的字符串库来替代标准库——只要接口兼容性好改动量就可以控制在搜索引擎可枚举的范围内。我见过有些团队直接引用了开源 STL 库的替代实现比如EASTL或自研容器但这需要一定的编译能力。还有更野的路子自己重写msvcp60.dll中std::string的底层函数用汇编级别补丁来强制关闭 COW。这个方法我不推荐除非你对 ABI 非常熟否则可能引发更隐蔽的问题。当时我们没有走到这一步因为把业务代码里的字符串换成SafeString已经足够。如果你也遇到“必须继续用 VC6 且无法尽快升级”的困境我的建议优先级是先加锁/深拷贝规避再精简业务模块最后考虑系统层面升级。安全稳定永远是第一位的。5. 排查清单与避坑指南5.1 遇到“长时间运行后崩溃”的排查顺序经历过这次故障我总结了一套针对老 C 项目出现“运行一段时间后崩溃”的排查顺序分享给你步骤检查项工具/方法说明1崩溃转储MiniDump Windbg必须第一时间抓现场2调用栈近几层!analyze -v、kb别只看崩溃点往上翻找释放点3堆完整性!heap -p -a检查是否堆损坏4内存趋势性能计数器、Umdh排除持续泄漏5线程同步静态审查 压力测试排查锁相关竞态6运行库版本lm查看加载模块确认多份 CRT 并存7编译器/STL实现最小复现程序验证 COW 等已知缺陷这个顺序能让你在最短时间内锁定问题层面。如果第 1、2 步就发现崩溃栈在标准库内部而且不是你自己的代码里主动调用的函数那就要警惕是不是运行库或 STL 实现的问题。5.2 VC6 时代的独有坑如果你还在用 VC6以下坑基本是“踩一个准一个”不要在 Debug 版下压测。VC6 的 Debug 运行库会填充0xCC、0xCD而且容器迭代器检查基本没有压测结果和生产环境差异巨大。/GX异常处理开关要全局一致。VC6 默认是/GX-意味着 C 异常不会展开栈如果某些源文件开了/GX另一些没开异常跨越模块边界时会直接崩。MFC 运行库的自动初始化。MFC 程序里的CString和std::string一样也有 COW 问题同时还要注意资源泄漏。如果 MFC 和msvcp60.dll的版本不匹配也会出现无法解释的崩溃。平台 SDK 别乱换。VC6 最高能配的 Platform SDK 有限换得太新会导致链接时出现.obj与.lib不兼容的报错换得太旧又可能缺少 API 声明。正确做法是固定一套经过验证的 SDK 版本并在所有构建机器上保持一致。5.3 从这次事故中我总结的几条经验这次排障过程历时两周期间走了不少弯路。复盘下来有几条经验值得记住第一条日志要写到“最后一刻”。我们当时的日志是异步写的崩溃前最后一条日志常常会丢。如果能在业务逻辑关键节点用fflush强制刷盘或者干脆采用同步日志崩溃点定位会快很多。后来我给程序加了std::endl强制刷新和崩溃钩子问题重现概率就高了不少。第二条崩溃转储是排查诡异问题的第一帮手。没有 dump后面一切分析都是猜。不要依赖用户从现场复制截图或者“又崩了”三个字一定要在程序里预埋SetUnhandledExceptionFilter。第三条排查“时间炸弹”类问题要优先怀疑基础库而不是自己的业务代码。当代码审查、内存检查都找不到问题时停下来想一想你的工具链是不是已经太老了编译器在 20 多年前写下的问题凭什么要求它适配今天的操作系统和并发环境6. 后续我们最终是怎么收场的6.1 短期规避队列模块换风险周一早上我们在测试环境验证了“强制深拷贝 队列锁”的组合方案。跑了一周没再复现崩溃于是决定先把这个临时方案部署到客户现场。两周观察崩溃没有再次出现。虽然每秒字符串拷贝次数多了不少但业务量不大CPU 和内存都没有明显上涨。这次成功的本质并不是把 bug 修好了而是让 bug 失去了触发条件——让std::string内部那块共享数据不再被多个线程交叉操作。只要没有共享数据块引用计数就不会因竞态而错误自然也就没有提前释放的问题。6.2 中期计划整体迁移到 VS2019因为项目里的代码总量大约 30 万行不算特别大而且大量使用了 Win32 API 和标准库不重度依赖 MFC我们评估后认为整体迁移到 VS2019 是可行的。迁移花了大约两个星期主要工作包括修正了一些编译器不再接受的旧语法比如for循环里声明变量需要在循环之前声明早期 VC6 不支持 C99 风格把旧的std::map、std::deque用法稍微调整新 STL 的异常规格不同重新编译并更新所有第三方库的依赖统一使用/MD动态链接运行库在 VS2019 下开启/W4警告把大量潜在隐患一次性揪出来。迁移后原来那个std::string的最小复现代码在 VS2019 下怎么跑都不崩。运行库版本也换成了VCRUNTIME140.dll这不仅修复了 COW 问题还带来了更好的内存分配器和异常处理支持。6.3 长期认知遗留系统不是不能用但必须有边界现在很多公司还在跑 VC6 时代的代码常见于银行、证券、工业控制这些对稳定性要求极高的场景。不是说 VC6 完全不能用而是你必须清楚地知道它的边界在哪里。比如VC6 的 STL 不适合跨界线程传递数据VC6 的 iostream 和异常处理对现代操作系统兼容性不好VC6 编译的程序必须配套对应版本的 CRT 运行库否则容易出诡异问题。如果你没法立刻升级那就把“不安全的库函数”用封装层隔离起来同时做好监控和崩溃转储。但说到底编译器不是古董越老越值钱——对于生产系统工具链的新旧往往决定了你能达到的稳定上限。我在代码注释里给后来人留了一句这段SafeString是为了避开 VC6 COW bug 死的等你升完 VS2019 第一件事就是把它删掉。第二年春天我们公司的遗留服务程序迁移完成我把那句话删掉的时候长舒了一口气。最后一个个人心得遇到诡异崩溃别把锅甩给操作系统也别急着跟同事说“这代码我跑了十年都没事”。先想办法把现场留下来再看崩溃点前面发生了什么。如果崩溃在标准库里翻翻编译器版本的发布历史往往比盯着自己的代码找三天都有效。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进