程序的核心是逻辑,没有正确逻辑的代码算不上是程序。人脑是物理上的单核,写程序和看代码讲求一个流程,流程其实就是单核顺序执行的过程。怎么保证单核顺序的人脑写出来的多线程程序,在物理上的多核CPU上执行正确的逻辑呢?答案是根本保证不了。多线程程序运行起来就像是开跑的赛马场,谁先跑完,谁会落后,完全无法预测;有时候相互踩踏在所难免。代码里到处充斥锁和共享的内存片段,过多的随机分支和状态导致全路径覆盖的测试case几乎是不可能的。所以多线程程序最痛苦的就是运行中爆出了core,发生了逻辑中认为不可能的事情,而你要在短时间内将其重现,定位,修复,验证。

重现 &&定位
        这是最耗时间的一步,也是最重要的,而且重现和定位是不分先后的。多线程的问题最常见的现象就是发生了不该发生的事情,线程之间产生了冲突,其中一个 assert 掉了。当你听到一个码农在调试程序的时候在嘟囔:“不能够啊,这不科学。”,他一定是在定位多线程的问题。所以第一步是找到矛盾的冲突点,基于此来做进一步的分析。
    1. 内存状态在线程间不一致:
thread1
图1

        看图1 左边的情况:116行判断如果mem->hash_del为false,就分配entry,如果116行分配了entry,118行就不应该判断为true。但多线程环境就会跑出116行和118行同时为true的情况,因为mem是多线程可见的内存区,在thread1 执行116行和118行之间,thread2会执行mem->hash_del = 1; 这条语句,这样就导致了前后状态不一致,出现逻辑错误。
        这种情况的另一个版本:
thread2
图2
        图2 左边的线程在使用mem->old_mem指针在给mem_obj和first_mem赋值,但右边的线程却执行了mem->old_mem = NULL; 这一行,所以结果就是,左边的mem_obj是mem->old_mem的指针,而first_mem却是NULL,但在程序中这两个值应该是一样的。这种问题不会core在当前位置,但后续左边的thread一旦对first_mem的指针解引用,就会出现大家熟悉的“Segmentation fault”。
        对于内存状态不一致引起的多线程问题,正确的作法应该是图1右边的写法:先将mem->hash_del读到一个局部变量里面,然后再根据hash_del当前的状态执行逻辑。
        2. 线程间锁竞争
        这种情况是最复杂,也是最容易出错的地方。只能说一句,哪个线程先抢到锁完全随机,每个情况都要考虑到,否则逻辑就会跑到没有处理的空白区域。
thread3
图3
        图3是左边是对hash table的对象做delete操作,右边是对hash table做lookup操作,如果thread1判断对象不可用,应该delete,然后新建对象,加入到hash table,让后续的请求都lookup新的对象,但总会有请求在thread1执行delete之前,就在另一个thread2中lookup到了该对象,所以请求访问到过期对象的情况一定要处理。
        复杂场景,一个流程里面多次申请和释放锁:
thread4
thread5
图4
        这段代码走完需要抢两次锁,但这两次抢锁都会和下面一段代码竞争,而且每次抢到锁的顺序不同,当前请求看到的mem->status都不一样,这也直接影响了这个请求应该怎么处理。
thread6
图5
    所以看图4的代码段会有如下的注释:
    // 这里lock mem和在forward准备返回mem之前的lock有竞争;而forward中,status置为IN_MEMORY和move_list是原子的;
    // 对于miss流程而言:
    //  a) 如果miss流程先抢到lock,走pending == 1的流程:
    //       i) 如果miss第二次先抢到lock,将http加入到waiting_list,统一在do_reply_list处理;
    //       ii) 如果miss第二次后抢到lock,此次status为IN_MEMORY,move_list已经执行,新http不能执行dispatch,应该直接在本线程处理;
    // b) 如果miss流程在IN_MEMORY之后抢到lock,此时为mem_hit,所以需要在返回时判断no_fwd_comb标志(重新回源)。
        上面的情况少处理一种,就会出现请求不能正确处理的情况。
        3. 死锁
        这种情况的简单场景一般是锁的申请和释放顺序不对,排查一下代码就可以了,但如果一些异常流程考虑不周全,在线上运行一段时间悄悄的死锁了,影响会非常严重。请求都会haul住,超时,但进程还是活着的。
        如果是spin lock死锁,可以看到某个线程CPU占用率100%,可以通过pstack看每个线程的堆栈,会有如下的情景:
thread7
图6
        上面的线程为啥start_thread之后就死锁了?因为真正引起死锁的原因在下面:
thread8
图7
逻辑大致如下:
    1. store_hash_free时assert,程序core了需要写磁盘索引;
    2. 写磁盘索引需要起一组线程;
    3. 写磁盘索引的线程会抢hid->glock;
    但看了store_hash_free代码就会明白,core在了glock里面。
thread9
图8
修复&&验证:
        其实问题如果定位清楚了,可以重现了,修复只是分分钟的事情,但修复后的反复验证以及全面的回归一定要做的充分,否则就会引入新的问题,系统始终稳定不下来。