易飞滔Todd | 次生进化

记一次AccessViolationException异常的排查

前几天,同事出差说程序遇到一个偶发的bug,程序会报告内存错误然后崩溃掉。今天终于有时间仔细排查了一下。

背景:这是一个C#和C++/CLI混合编程的程序。

1 复现

首先按同事描述的操作方法复现了bug,发现虽然是偶发的,单其实很有规律,在操作的某个时间点后必然出现。

2 基本原理

内存错误显然是非托管代码带来的,在.Net环境中被封装为AccessViolationException。

首先看看AccessViolationException的MSDN文档,它是”在试图读写受保护内存时引发的异常”。具体说明如下:

当非托管或不安全代码尝试读写未分配或不具有访问权限的内存空间时,就会产生访问冲突。 这种情况通常因为指针具有错误的值而发生。 并非所有通过无效指针的读或写操作都会导致访问冲突,所以访问冲突通常指示已经通过无效指针进行多次读或写操作,并且内存内容可能已损坏。 因此,访问冲突几乎总是指示存在严重的编程错误。 AccessViolationException 明确标识这些严重错误。

在完全由可验证托管代码组成的程序中,所有引用都有效或者为空,因而不会产生访问冲突。 AccessViolationException 只在可验证托管代码与非托管代码或非安全托管代码交互时才会引发。

如果发生异常在公共语言运行时,会保留的内存不足 .NET Framework 4开始,公共语言运行时本身引发的 AccessViolationException 异常没有被在结构化异常处理程序的 catch 语句处理。 为了处理这种 AccessViolationException 异常,则应用 HandleProcessCorruptedStateExceptionsAttribute 特性到引发异常的方法。 用户代码不影响 AccessViolationException 异常的此更改,可以继续用 catch 语句捕获。 对于针对要重新编译并运行,而无需修改.NET Framework 4.NET Framework 的早期版本编写的代码,则可以添加元素到 应用的配置文件。 注意也可能收到异常的通知,如果定义 AppDomain.FirstChanceException 或 AppDomain.UnhandledException 事件的处理程序。

看完先给发生问题的函数加上了HandleProcessCorruptedStateExceptions,捕获到异常了,但是没什么实际作用,这种错误明显不应该发生的。

3 发现错误源头

刚开始用VS2010调试,对C++/CLI太不友好了,看不到太多有用的信息,后来改用VS2015调试,能在崩溃时看到C++/CLI代码的详细信息了,然后发现了一个空指针,程序试图在空指针上进行结构体的访问,必然会崩溃啊。

对空指针的来源进行了仔细的回溯排查,发现在某个查找操作中返回了空指针,这个空指针还传递了好几个函数,衍生出好几个空指针,直到最后试图使用指针时崩溃了。

到这里发现了第一个问题,C++/CLI代码中的托管对象封装了传统C++的指针,对托管对象的操作,实际上会转发到指针上去,如果指针为空,这个托管对象实际上没有存在的意义,但是之前的代码忽略了这个问题。

在构建托管对象时,应当检验其封装的指针,如果为空指针,那么对象也应当初始化未null。

此时,AccessViolationException已经变成了C#程序员天天见的NullReferenceException,如果封装时注意一点,就不会这么辛苦找源头了。

4 出现空值的原因

产生空值的查找操作,按理说不应当出现,而且它的出现也是有一些偶发性的,如果把异常截获,程序还能继续跑下去也没什么问题,也就是,待查询的对象集合在大部分时间都是正常的,但在某个瞬间出了问题。

仔细排查了一下哪些操作可能更动这个对象集合,发现了一个重启操作,包含了清空与加载两步,而且这个重启操作与出问题的查询操作在不同的线程中。

到这里,发现原来是典型的操作原子性未保持导致的多线程错误,清空后还没加载时去查询,自然会出现空值,给对象集合所在对象加了个锁总算没有异常了。当然,后来我发现这个重启的必要性很可疑,那是另外的话题了。

5 小结

总计一下:

  1. 应当让错误早点暴露,限制空指针的传播范围,具体到C++/CLI中,要把它封装成null。
  2. 多线程的坑层出不穷,行为难以预料,要谦虚谨慎。