分析core,是从案发现场,推导案发经过

6次阅读

共计 3933 个字符,预计需要花费 10 分钟才能阅读完成。

分析 core 不是一件容易的事情。试想,一个系统运行了很长一段时间,在这段时间里,系统会积累大量正常、甚至不正常的状态。这个时候如果系统突然出现了一个问题,那这个问题十有八九跟长时间积累下来的状态有关系。分析 core,就是分析出问题时,系统产生的“快照”,追溯历史,找出问题发生源头。这有点像是从案发现场,推导案发经过一样。
soft lockup!
今天这个“案件”,我们从 soft lockup 说起。

soft lockup 是内核实现的夯机自我诊断功能。这个功能的实现,和线程的优先级有关系。
这里我们假设有三个线程 A、B、和 C。他们的优先级关系是 A <B<C。这意味着 C 优先于 B 执行,B 优先于 A 执行。这个优先级关系,如果倒过来叙述,就会产生一个规则:如果 C 不能执行,那么 B 也没有办法执行,如果 B 不能执行,那基本上 A 也没法执行。
soft lockup 实际上就是对这个规则的实现:soft lockup 使用一个内核定时器(C 线程),周期性地检查,watchdog(B 线程)有没有正常运行。如果没有,那就意味着普通线程(A 线程)也没有办法正常运行。这时内核定时器(C 线程)会输出类似上图中的 soft lockup 记录,来告诉用户,卡在 cpu 上的,有问题的线程的信息。
具体到这个“案件”,卡在 cpu 上的线程是 python,这个线程正在刷新 tlb 缓存。
老搭档 ipi 和 tlb
如果我们对所有夯机问题的调用栈做一个统计的话,我们肯定会发现,tlb 和 ipi 是一对形影不离的老搭档。其实这不是偶然的。系统中,相对于内存,tlb 是处理器本地的 cache。这样的共享内存和本地 cache 的架构,必然会提出一致性的要求。如果每个处理器的 tlb“各自为政”的话,那系统肯定会乱套。满足 tlb 一致性的要求,本质上来说只需要一种操作,就是刷新本地 tlb 的同时,同步地刷新其他处理器的 tlb。系统正是靠 tlb 和 ipi 这对老搭档的完美配合来完成这个操作的。
这个操作本身的代价是比较大的。一方面,为了避免产生竞争,线程在刷新本地 tlb 的时候,会停掉抢占。这就导致一个结果:其他的线程,当然包括 watchdog 线程,没有办法被调度执行(soft lockup)。另外一方面,为了要求其他 cpu 同步地刷新 tlb,当前线程会使用 ipi 和其他 cpu 同步进展,直到其他 cpu 也完成刷新为止。其他 cpu 如果迟迟不配合,那么当前线程就会死等。
不配合的 cpu
为什么其他 cpu 不配合去刷新 tlb 呢?理论上来说,ipi 是中断,中断的优先级是很高的。如果有 cpu 不配合去刷新 tlb,基本上有两种可能:一种是这个 cpu 刷新了 tlb,但是做到一半也卡住了;另外一种是,它根本没有办法响应 ipi 中断。
通过查看系统中所有占用 cpu 的线程,可以看到 cpu 基本上在做三件事情:idle,正在刷新 tlb,和正在运行 java 程序。其中 idle 的 cpu,肯定能在需要的时候,响应 ipi 并刷新 tlb。而正在刷新 tlb 的 cpu,因为停掉了抢占,且在等待其他 cpu 完成 tlb 刷新,所以在重复输出 soft lockup 记录。这里问题的关键,是运行 java 的 cpu,这个我们在下一节讲。

java 不是问题,踩到的坑才是问题
java 线程运行在 0 号 cpu 上,这个线程的调用栈,满满的都是故事。我们可以简单地把线程调用栈分为上下两部分。下边的是 system call 调用栈,是 java 从系统调用进入内核的执行记录。上边的是中断栈,java 在执行系统调用的时候,正好有一个中断进来,所以这个 cpu 临时去处理了中断。在 linux 内核中,中断和系统调用使用的是不同的内核栈,所以我们可以看到第二列,上下两部分地址是不连续的。

netoops 持有等待
分析中断处理这部分调用栈,从下往上,我们首先会发现,netoops 函数触发了缺页异常。缺页异常其实就是给系统一个机会,把指令踩到的虚拟地址,和真正想要访问的物理机之间的映射关系给建立起来。但是有些虚拟地址,这种映射根本就是不存在的,这些地址就是非法地址(坑)。如果指令踩到这样的地址,会有两种后果,segment fault(进程)和 oops(内核)。
很显然 netoops 踩到了非法地址,使得系统进入了 oops 逻辑。系统进入 oops 逻辑,做的第一件事情就是禁用中断。这个非常好理解。oops 逻辑要做的事情是保存现场,它当然不希望,中断在这个时候破坏问题现场。
接下来,为了保存现场的需要,netoops 再一次被调用,然后这个函数在几条指令之后,等在了 spinlock 上。要拿到这个 spinlock,netoops 必须要等它当前的 owner 线程释放它。这个 spinlock 的 owner 是谁呢?其实就是当前线程。换句话说,netoops 拿了 spinlock,回过头来又去要这个 spinlock,导致当前线程死锁了自己。
验证上边的结论,我们当然可以去读代码。但是有另外一个技巧。我们可以看到 netoops 函数在踩到非法地址的时候,指令 rip 地址是 ffffffff8137ca64,而在尝试拿 spinlock 的时候,rip 是 ffffffff8137c99f。很显然拿 spinlock 在踩到非法地址之前。虽然代码里的跳转指令,让这种判断不是那么的准确,但是大部分情况下,这个技巧是很有用的。
缺页异常,错误的时间,错误的地点
这个线程进入死锁的根本原因是,缺页异常在错误的时间发生在了错误的地点。对 netoops 函数的汇编和源代码进行分析,我们会发现,缺页发生在 ffffffff8137ca64 这条指令,而这条指令是 inline 函数 utsname 的指令。下图中框出来的四条指令,就是编译后的 utsname 函数。

而 utsname 函数的源代码其实就一行。
return &current->nsproxy->uts_ns->name;
这行代码通过当前进程的 task_struct 指针 current,访问了 uts namespace 相关的内容。这一行代码,之所以会编译成截图中的四条汇编指令,是因为 gs 寄存器的 0xcbc0 项,保存的就是 current 指针。这四条汇编指令做的事情分别是,取 current 指针,读 nsproxy 项,读 uts_ns 项,以及计算 name 的地址。第三条指令踩到非法地址,是因为 nsproxy 这个值为空值。
空值 nsproxy
我们可以在两个地方验证 nsproxy 为空这个结论。第一个地方是读取当前进程 task_sturct 的 nsproxy 项。另外一个是看缺页异常的时候,保存下来的 rax 寄存器的值。保存下来的 rax 寄存器值可以在图三中看到,下边是从 task_struct 里读出来的 nsproxy 值。

正在退出的线程
那么,为什么当前进程 task_struct 这个结构的 nsproxy 这一项为空呢?我们可以回头看一下,java 线程调用栈的下半部分内容。这部分调用栈实际上是在执行 exit 系统调用,也就是说进程正在退出。实际上参考代码,我们可以确定,这个进程已经处于僵尸(zombie)状态了。因而 nsproxy 相关的资源,已经被释放了。
namespace 访问规则
最后我们简单看一下 nsproxy 的访问规则。规则一共有三条,netoops 踩到空指针的原因,某种意义上来说,是因为它间接地违背了第三条规则。netoops 通过 utsname 访问进程的 namespace,因为它在中断上下文,所以并不算是访问当前的进程,也就是说它应该查空。另外我加亮的部分,进一步佐证了上一小节的结论。
`/*`
`* the namespaces access rules are:`
`*`
`* 1\. only current task is allowed to change tsk->nsproxy pointer or`
`* any pointer on the nsproxy itself`
`*`
`* 2\. when accessing (i.e. reading) current task’s namespaces – no`
`* precautions should be taken – just dereference the pointers`
`*`
`* 3\. the access to other task namespaces is performed like this`
`* rcu_read_lock();`
`* nsproxy = task_nsproxy(tsk);`
`* if (nsproxy != NULL) {`
`* / *`
`* * work with the namespaces here`
`* * e.g. get the reference on one of them`
`* * /`
`* } / *`
`* * NULL task_nsproxy() means that this task is`
`* * almost dead (zombie)`
`* * /`
`* rcu_read_unlock();`
`*`
`*/`
回顾
最后我们复原一下案发经过。开始的时候,是 java 进程退出。java 退出需要完成很多步骤。当它马上就要完成自己使命的时候,一个中断打断了它。这个中断做了一系列的动作,之后调用了 netoops 函数。netoops 函数拿了一个锁,然后回头去访问 java 的一个被释放掉的资源,这触发了一个缺页。因为访问的是非法地址,所以这个缺页导致了 oops。oops 过程禁用了中断,然后调用 netoops 函数,netoops 需要再次拿锁,但是这个锁已经被自己拿了,这是典型的死锁。再后来其他 cpu 尝试同步刷新 tlb,因为 java 进程关闭了中断而且死锁了,它根本收不到其他 cpu 发来的 ipi 消息,所以其他 cpu 只能不断的报告 soft lockup 错误。

本文作者:声东阅读原文
本文为云栖社区原创内容,未经允许不得转载。

正文完
 0