1.问题背景
运行于一个进程中的多个线程,彼此之间使用相同的地址空间,共享大部分数据,因此启动一个线程所花费的空间远远小于启动一个进程所花费的空间,线程间彼此切换所需的时间也远远小于进程间切换所需要的时间,一个线程的开销大约是一个进程的开销1/30左右,但是其公共数据共享可能会带来灾难性的后果,最常见的是共享变量的互斥、变量同步问题。测试中通过对比单线程与多线程执行结果进行的一致性,来发现多线程环境下的程序问题。
当单、多线程运行结果出现不一致,即结果存在diff时,说明程序本身存在多线程问题,而测试的本质是为了发现、解决程序设计中的缺陷,不仅是要发现问题,还能解决问题。然而从不一致的结果分析到最终问题的解决,还有很长一段路要走。
2. 问题追查困难,手段有限
当出现多线程问题时,测试人员往往无从着手,如临大敌,这意味着需要消耗巨大的人力物力投入,其困难体现在一下几个方面:
一、 从结果分析原因困难
结果diff往往是整个模块运行的最终产出,只能说明可能存在问题,而我们需要从结果出发,逐步缩小可疑代码范围,定位问题逻辑原因,这就要求在缩小范围的过程中,可以稳定或者高概率复现diff,然而单、多线程结果不一致问题复现具有随机特性,往往需要多次运行才会出现一次,无疑增加了所追查问题复现的难度。
二、 diff具有小概率特性
diff出现通常是小概率事件,所以需要输入大数据量触发,导致单次复现所需的时间成本太大,如8小时,往往无法忍受。通过打印、对比日志或中间结果的方式,大数据量输入意味着需要从数百G级的日志或结果数据中,才可能筛选出仅有的几条有用信息,保存中间结果的文件过大,存储磁盘空间倍受考验,信息达到T级别或更大时,两份数据存储和处理就会异常困难,工作开销也很难想象,如果当输出的信息太少,又会导致定位的范围太宽泛,起不到缩小范围的目的或增加定位的次数。
三、 多线程输出需要附加处理
多线程输出的结果乱序,与单线程对比之前,需要对单、多线程结果进行预处理,如排序、过滤,当输出信息不是单行时,将无法简单使用linux命令,需要做特殊处理。
多线程运行输出信息时也需要特殊处理,由于程序以多线程方式运行时,为保证输出结果的独立性,需要在输出信息时加上互斥锁,另外为打印格式化的输出信息,也需修改程序本身,这样可能会导致问题被间接屏蔽,diff不再触发。
四、 可供选择的检测工具有限
vlagrind工具对多线程的扫描存在很多的误报信息,从而需要耗费大量的时间从逻辑上分析、排除这些误报警。
五、 linux环境下动态跟踪调试困难
目前比较流行的调试工具是gdb,尽管gdb功能强大,但用法繁琐,熟练灵活运用需要很大的投入成本。虽然它提供对多线程调试的支持,但使用起来与实际环境不一致会使调试结果与实际运行时有偏差。
六、 问题原因多样
导致diff的原因多种多样,直接原因:如全局变量操作互斥问题、变量未初始化、字符串末尾未置0,间接原因:如数组溢出、系统时间依赖等在小数据量可以复现,日志、动态调试可以跟踪的理想情况下,仍然需要人工借助日志、调试信息,辅助分析代码逻辑的漏洞,对程序本身和测试经验的要求很高。
3. 问题的原因分析
从多线程diff问题的本质的视角来分析,才能从根本上寻找到解决问题的方式,进而从根源上探索解决问题的方法。
一、 共享变量互斥问题
如果线程之间的公共变量未加锁,变量值在线程内部被写乱,这是最常见的情况,然而变量互斥问题由于关注度比较高,互斥技术作为对开发者的基本技术要求,开发人员普遍比较重视,出现问题的概率不大。
二、 数组访问越界
通常要对数组单元做循环处理,而控制循环退出的条件为下标,边界处理不当往往会造成数组1个单元越界,如将for(i = 0; i< LEGNTH; i++)循环的退出条件多加了一个”=”号:i 三、 变量未显式或隐式地手工初始化
由于分配给变量的物理内存区域不确定,可能存在内存未经由系统进行初始化情况,如果该内存恰好被使用过,而通常使用过的内存又不会被清理干净,继续使用则会导致变量值与前一次遗留内容相关,单线程一次执行无法发现,多次运行结果或多线程运行结果会出现不一致。变量初始化值与系统初始化方式紧密相关,操作系统有可能使用随机值进行初始化。变量未初始化也包含数组单元未被初始化的情况,如函数功能是处理某一结果后将结果保存到一个未被初始化接收数组,当某些异常分支直接返回而未将数组置空,而函数无返回信息确定对数组单元的使用情况:如已使用的长度,此时数组的内容和已使用长度值都是不确定的。
四、 字符串数组结尾未加’ ′
程序处理最多的就是字符串,它们通常由数组来存储。依次对数组单元进行处理时,如果末尾未加结束符’ ′,数组中字符串的长度实际上与其后内存中第一次出现’ ′的位置相关(内存里有大量的’ ’),这段字符串内存中的内容存在不确定性。最典型的就是strncpy使用问题,借助strncpy进行字符串拷贝时,当拷贝的限制长度小于源字符串的长度或者源字符串大于目标buffer长度,拷贝时不会自动添加’ ’字符,拷贝完成后需要在末尾手工添加’ ’’结束符,否则会造成字符串本身的不确定,而当长度限制大于目标数组时会发生拷贝越界破坏栈数据,也可能将脏数据写到其它变量中而造成单、多线程diff 。
五、 程序结果依赖系统当前时间
单线程运行时间与多线程运行时间不同,导致不同的结果,如判断网页的时效性时,程序计算网页中提取的时间与当前系统时间的差值,小于30天才判定其具有时效性,如果单多线程运行时间恰好在该时间边界两侧,就会出现单、多线程结果diff。
4. 通用的追查方式与手段
尽管追查方式与手段有限,通过平时的追查问题的经验总结,结合模块本身特定和应用环境需求,熟练组合、应用如下七种手段寻找突破口:
一、 人工review代码
程序中大约1/3的bug由codereview发现,所以人工检查名副其实的简单可依赖,在review代码过程中,重点关注全局变量使用情况、确保写操作前加锁或写操作初始化只有一次;检查数组下标是否有越界可能,尤其是边界使用是否合理,数组循环操作的退出条件是否与真实使用长度相符;重点关注未被初始化的变量或数组。
二、 log日志方式
日志的方式需要预估日志量,在实际使用过程中需要很多技巧、同时在磁盘、系统处理等方面会有很多限制,但在大数据量时的宽泛定位和较小数据可复现时的详细定位相当有效。
1) 对于日志比较规范的模块,降低日志级别,打印模块日志,对重要的分支以及数据进行打印,如果模块本身日志不规范,日志稀少的情况比较多见,需要根据diff倒推,先随机的对重要分支、变量值进行日志记录,再根据问题逐步细化。
2) 对于数据比较复杂的,或日志结果太大,不便于日志的模块,可以使用签名方式,对参数和变量值进行签名,将前面结果写入日志,定位出问题的位置。
3) 日志记录时必须要加入输入数据标识,如网页url、term值等,便于该条日志与输入数据对应,不同的结果数据也需要明显的分割符加以区分;多线程时需要考虑输出结果与线程id相对应,便于区分多线程结果。
4) 排查与输入数据顺序序列相关的diff,由于使用规范的日志系统,会记录当前的线程id, 首先检查diff的网页所在的线程id,然后将该线程顺序处理所有数据按该顺序组织,作为单线程输入数据,如有id号位1、2 、3的3个线程,输入数据a b c d e f g h i j k:
a b c d e f g h i j k …
1 2 2 3 2 1 3 2 1 3 2 …
数据e产生diff,则取线程2的输入序列:b c e h k …,如果是顺序相关即可触发diff,数据已经被缩小到2号线程处理的这个小序列了。日志文件比较大时,需要修改ullib日志设置,拆分日志文件,但日志文件总量不宜过大:
#define UL_LOGSIZESPLIT 0×10
ul_logstat_tlog_state;
log_state.spec = UL_LOGSIZESPLIT; // 0x10,拆分日志文件,单个默认为2G大小,以时间戳命名。
三、 缩小复现数据集是根本
可以在diff的数据附近,抽取前后若干个数据,检验是否可以复现diff,可将输入数据量从大到小或从小范围到大范围试探性筛选,最终确定最小复现数据集合。
四、 工具扫描,尽管linux中工具使用可供选择的不多,但会为问题的追查找准方向
1) valgrind内存检查工具memcheck,使用动态运行方式,检查未初始化的变量非常有效,存在这种报警时,程序一定有问题:Conditional jump or move depends on uninitialised value(s),很有可能就是diff问题的根源所在;它的另外一个工具helgrind可以检查多线程竞争,会有很多误报,如对pthread_once支持不友好,但对全局变量使用问题的检测还有一定的参考简直,通过人工逻辑分析、筛选,能发现一些问题;
2) errhunter工具属于静态代码检查工具,对编码规范、可能存在的风险,检查比较有效,可以有效发现编码中如变量未初始化和数据格式不一致等问题;
3) gdb,在linux环境下提供以命令行方式调试,尽管用法原始、学习、使用成本较高,但实际上gdb本身分析、调试功能非常强大,可以提供运行时的各种信息,而其多线程程序运行的调试,提供了一个接触多线程运行内部逻辑的机会;
4) 脚本代码扫描的也有工具hubble等现成工具可以参考;
五、 单线程问题
追查时往往是多线程问题导致的数据不一致,对比数据时通常将单线程数据作为基准数据。某些情况下单线程输入序列本身会触发问题,而多线程运行输入数据打乱顺序后,其单个子线程的输入序列正常的而多线程运行正常,故单线程运行结果也需要关注。
六、 分析输入数据
分析产生diff的输入数据,会有一些规律发现,如输入网页是某个站点下的网页或者包含某个特定词、特定词语组合的query……,这样可以快速缩小复现数据集。
七、 分析diff结果
比如说某些结果字段,或在某些其它字段位于某个特定范围时,才会出现diff,即从diff结果数据倒推也可以发现一些端倪。
在二进制模块的单多线程问题分析定位中,最常用、最有效的方式是日志和工具扫描方式。
5. 防微杜渐
一、 RD层面
从源头上杜绝可能发生的危险,培养RD良好的编码习惯是首选。
1) 编码规范:
- a) 全局共享变量需以g_开头,这样便于检查;对它们的写操作必须加上互斥锁或保证只初始化一次;
- b) 变量及小数组必须在定义同时立即初始化;当数组较大,是通过第一个单元置0的方式初始化,使用它时要注意在字符串末尾加结束符0;
- c) 使用拷strncpy贝字符串处理需要注意,拷贝完毕后末尾加’ ′,建议使用snprintf替代,它会自动在结果末尾加上‘ ’;
- d) 注意数组边界使用限制,已经对循环操作数组的退出条件检查是否与真实使用情况相符合;
- 2) 日志打印规范
按照日志规范日志打印日志,遵循日志规范即可,要做到仅从日志信息即可判断问题原因,并能很好地控制打印日志量,打印日志的位置异常关键。
二、 QA层面
- 1) 使用工具例行扫描:vagrind扫描内存使用问题重点关注变量未初始化问题,以及对多线程报警进行帅筛选,关注errhunter工具的error和warning信息,gdb的使用做得心应手,《软件调试的艺术》介绍比较详细。
- 2) code review习惯培养:关注全局变量使用,数组下标、边界使用问题,变量和数组是否初始化。
- 3) 数据保证:小概率的数据触发,测试数据量需要保证,另外需要特殊数据才会触发,比如有些分支是英文相关分支,只有大数据量的英文网页才会触发。
- 4) 数据输入顺序:多线程运行需要保证输入数据的乱序,这样才能保证输入数据序列的多样性,更好的触发问题。
- 5) 多次运行一致性测试:多次单线程运行可以有效发现变量未初始化导致的问题。
转载请注明:爱开源 » 单线程和多线程diff问题追查