第一个问题:
引发bug的可能性有很多,形形色色的debug方法也有很多,它们各有各的优势,并不存在通用的最优解,我目前用过的调试方法有下面几种:
1. 人肉调试:
对于某些bug,直接根据程序的异常表现,就可以知道问题代码的具体位置,心里逆推演一下相关代码,就可以找到问题产生的原因。
例:刚给客户端加了个多线程模块,F5运行,等了30s。。。咦?客户端界面怎么还没显示出来?任务管理器一看,客户端进程CPU占用为0:八成是刚写的代码死锁了,直接Review代码吧。【Problem Solved】
2. 中断调试
依赖于IDE的调试方法(写C++一般用的都是VS吧),在可能出问题的代码位置打个断点,或者等程序自己出异常中断,或者手动加判断中断。程序中断后,追溯函数调用堆栈,找到产生异常数据的代码。这是最方便的定位bug的方法,但前提是能够在开发环境重现bug。
例:策划突然跑过来说:“新做的技能怎么没伤害啊,是不是代码里的伤害计算公式写错了?balabalabala”。。。计算技能伤害的代码位置打个断点,一看数据,有个乘积因子加载以后的数值是0,“卧槽,你自己回去查下技能表是不是漏填了数据。” 【Problem Solved】
3. Log调试
在经常出错、或极有可能出错的代码位置打印log,从而定位问题的原因。如果bug产生的代码没有被log覆盖到,可以通过临时log排查可能导致出错的问题模块。
例:测试:“刚发布的测试客户端怎么XX界面打不开啊,程序看下呗!”程序猿:“log文件发过来”。看完log:“界面里有个资源文件找不到,是不是美术没上传到SVN?”【Problem Solved】
4. Dump调试
利用Windows API,在程序运行不正常时中断,将此时的程序的内存镜像输出到一个dump文件里,然后利用WinDbg获取中断时的函数调用堆栈,从而定位出bug的代码,使用的前提是bug不会导致程序闪退,否则无法保存dump文件。
例:客服:“刚才有外网玩家反映切换地图的时候程序报错了。”程序猿:“有dump文件吗?”。分析完客服收集的dump文件:“new内存的时候失败了,加个内存池吧。”【Problem Solved】
5. 工具调试
除了上面几种通用的调试方法以外,对于某些特定的问题,可以使用特定的工具进行调试。
例:PIX可以用来调试着色器;LeakDiag可以用来调试内存泄漏;Vtune可以用来调试性能。
Ps:由于这些工具通常会对程序性能产生比较明显的影响,大型程序(比如游戏引擎)通常会直接在代码层面集成相应的模块,并通过log将结果打印出来。
上面的五种方法基本是按照使用的困难程度升序排列的,对于具体的bug,在可以解决问题的情况下采用难度最低的debug方法才是最优解。而题目没有给出bug的具体表现,所以这是一个开放性问题。
不过根据问题的四个关键词:
关键词一、 “多线程”
关键词二、 “大量并发”
关键词三、 “一百万次出现一次”
关键词四、“很难重现”
可以看出面试官为这个bug设定的属性是:
那么基本可以pass掉人肉调试、中断调试和工具调试。所以此时只能通过收集外网环境中log或者dump文件来分析。
Ps:产生bug的原因有很多,问题中并没给出bug的具体表现,根本没有办法判断bug产生的具体原因。题主和部分答主将答题思路往“临界区”与“多线程同步”之类的方向靠,我觉得有点答非所问了,毕竟面试官的问题不是“造成bug的原因”,而是“如何debug”。
================================================================
第二个问题:
这个问题中,面试官明确提到了虚函数表被破坏,而虚函数表是放在程序的只读数据段的,根本无法修改,所以只有可能是虚函数表指针被修改了。因此,我觉得对象崩溃的可能原因有下面几个:
1. 访问对象时,对象的构造函数尚未执行完毕。
例:对象是在某个函数内定义的静态对象,比如某个单例函数:
static Type* GetInstance()
{
static Type instance;
return &instance;
}
多线程调用时,第一个线程会等待instance的构造函数执行完毕后再返回instance的地址,但由于static的存在,instance的构造函数只会执行一次,如果instance的构造函数尚未执行完毕,有第二个线程使用了GetInstance()函数,此时便会访问一个尚未构造完成的对象。(Ps:据说新的C++标准修复了这个问题,但至少VS2013中这个问题是存在的。)
这个错误会造成程序中断,此时直接根据函数调用堆栈定位到出错的指针或对象即可。
2. 访问对象前,某处代码手动执行了对象的析构函数。
在多态的情况下,手动调用对象的析构函数,虽然对象的内存并不会被释放,但成员变量中的指针会变为野指针,此时访问它们自然会造成程序崩溃。此外,执行析构函数时,C++还会做一些额外的工作:当执行完派生类的析构函数后,C++会将对象的虚函数表指针从派生类的虚函数表指向基类的虚函数表,因此,虽然派生类的内存空间还没有被释放,但此时已经无法再访问派生类中定义的虚函数了。
这个错误会造成程序中断,此时直接根据函数调用堆栈定位到出错的指针或对象即可。
3. 内存越界访问
虚函数表指针是由编译器管理的,正常情况下开发者不会访问或修改它的值。因此,若虚函数表指针被破坏了,说明程序中发生了内存越界访问,造成了虚函数表指针被意外修改。
这个错误虽然会造成程序中断,但出错的位置往往不是错误发生的位置:
A. 如果对象不是通过new创建的
此时需要排查出错对象声明处的代码,观察在它附近声明的对象是否发生了内存越界访问。(尤其要关注数组对象,内存越界访问通常是由于数组下标越界导致的)。
B. 如果对象是通过new创建的
此时需要排查整个程序中所有的指针是否发生了越界访问(仍然优先关注数组)。
一种做法是重载new / delete操作符,分配内存时在返回的内存前后各自多分配一个int作为首尾标记,并将它们各自设定为一个特殊的值。每次释放内存时,检查首尾标记的值是否合法。如果不合法,说明该内存指针发生了越界访问。