一 背景
在某线上环境中,系统内存被用到所剩无几,通过/proc/meminfo观察到,大部分内存被anon匿名页消耗掉,如下所示:
cat /proc/meminfo 。。。。。。 MemFree: 60156128 kB 。。。。。。 Active(anon): 325964208 kB Inactive(anon): 1279428 kB 。。。。。。 AnonPages: 323464500 kB 。。。。。。
从上面的数据可以看出匿名页消耗掉了300多GB;按照我的惯性思维,这类匿名页一般都是被用户态分配的,因此我甚至有点飘了,轻敌了,并一度认为会在半天就把这个问题搞定。
二 套路排查
既然是匿名页,按照以往的套路那肯定和任务分配的有关系;为此我使用了如下两个脚本分别来进行观察各个进程的匿名页使用情况:
ps -e -o rss h | awk 'BEGIN{sum=0}{sum=sum+$1}END{print sum}'
通过这种方式统计到的内存只有100多GB,看到这个结果和原本的300GB缺口相差甚远,我甚至有点慌了; 心有不甘我又使用了第二种方法来排查各个进程的匿名页使用情况:
grep RssAnon /proc/[1-9]*/status | awk '{total += $2}; END {print total}'
结果,这种方式计算统计出来的你们内存也只有100多GB。
也就是说不论用哪种方式来统计所有进程的内存消耗,都只有100多GB,而还有200GB左右的匿名页居然成了无头冤魂,找不到属主了,这下我彻底慌了。
三 蛛丝马迹
正当我走投无路之际,突然想到之前有一些病毒会伪装自己,使得一些工具统计不到它的资源使用。
于是乎我就通过ps aux -T命令把所有的线程都给找出来,放到一个文件里面挨个分析,看看是否有比较可疑的任务。
这一分析不得了,虽然没有看到类似病毒的伪装任务,但是发现了一个非常显眼的现象:系统中有多个<defunct>状态的任务。更加显眼的是这些任务中一个线程组里面的leader是僵尸状态“Z”,而线程组下面的其他线程都是"D"状态,而更加让我倒吸一口凉气的是,"Z"状态的线程组leader的Rss是0,但是线程组里面其他"D"状态线程的Rss是非0。
也就是说这些线程组中的匿名内存有可能还没有完全释放出来,但是通过ps -e 或者 /proc/[1-9]*/stattus又观察不到,因为这些内存资源还在被线程组中的其他非leader线程所引用。
四 为什么会出现这种情况呢?
其实我的疑问有两个:
- 【1】线程组leader退出,线程组中的其他线程为什么没有一起退出?
- 【2】线程组中leader退出了,线程组的匿名内存资源会释放吗?
4.1 线程退出的分析与论证
为了搞清楚线程组中一个线程退出的表现,我找了一个环境做了一系列的实验,得到了一些结果如下:
退出方式 | 对应的内核系统调用 | 对线程组的影响 |
直接从主mian()函数return | syscall exit_group(),内核最终调用do_exit_group | 影响组内其他线程,尝试整组线程退出 |
调用exit() | syscall exit_group(), 内核最终调用do_exit_group | 影响组内其他线程,尝试整组线程退出 |
调用_exit() | syscall exit_group(), 内核最终调用do_exit_group | 影响组内其他线程,尝试整组线程退出 |
调用_Exit() | syscall exit_group(), 内核最终调用do_exit_group | 影响组内其他线程,尝试整组线程退出 |
pthread_exit |
syscall exit(), 内核最终调用do_exit | 不影响组内其他线程,只是本线程退出 |
可以看到,如果组内线程调用pthread_exit()这种方式退出是丝毫不会影响组内其他线程的,前面看到的线程组leader退出或许是通过这种方式退出的,这样组内其他任务就不受影响;
我们在来看看do_group_exit()的情况。因为僵尸状态的线程组中除了leader是"Z"状态,组内其他线程全是"D",而且有多个线程组呈现这种状态,这不太像是pthread_exit()方式退出的。因为那样的话,组内线程除了有D状态应该还有其他状态(比如S,R)。所以这里的现象让我疑惑的就是do_group_exit()会让组内的线程都退出吗?如果组内的其他线程是D状态会受到线程组leader退出的影响吗?
带着这个问题,我开始来刨根问底,分析内核代码。
4.1.1 do_group_exit对D状态任务的影响
/* * Take down every thread in the group. This is called by fatal signals * as well as by sys_exit_group (below). */ void do_group_exit(int exit_code) { struct signal_struct *sig = current->signal; BUG_ON(exit_code & 0x80); /* core dumps don't get here */ if (signal_group_exit(sig)) exit_code = sig->group_exit_code; else if (!thread_group_empty(current)) { /* 线程组有多个线程的情况 */ struct sighand_struct *const sighand = current->sighand; spin_lock_irq(&sighand->siglock); if (signal_group_exit(sig)) /* Another thread got here before we took the lock. */ exit_code = sig->group_exit_code; else { sig->group_exit_code = exit_code; sig->flags = SIGNAL_GROUP_EXIT; /* 尝试终止组内其他线程 */ zap_other_threads(current); } spin_unlock_irq(&sighand->siglock); } do_exit(exit_code); /* NOTREACHED */ }
do_group_exit()在线程组有多个线程的情况下会调用zap_other_threads(current)尝试去终止current组内其他线程:
int zap_other_threads(struct task_struct *p) { struct task_struct *t = p; int count = 0; p->signal->group_stop_count = 0; while_each_thread(p, t) { /* 遍历组内所有线程 */ task_clear_jobctl_pending(t, JOBCTL_PENDING_MASK); count++; /* Don't bother with already dead threads */ if (t->exit_state) continue; sigaddset(&t->pending.signal, SIGKILL); /* 为线程挂一个pending的SIGKILL信号 */ /* signal_wake_up_state(t, resume ? TASK_WAKEKILL : 0); */ signal_wake_up(t, 1); } return count; }
从上面的代码可以看到zap_other_thread会遍历所有线程,然后主要对各个线程做两件事:
- 首先,是调用sigaddset(&t->pending.signal, SIGKILL)为各个线程t在t->pending.signal挂一个pending的SIGKILL信号,表示有它有SIGKILL信号待处理,这样在一些关键点会进行检查,后面会讲到;
- 其次,调用signal_wake_up(t, 1)。这个函数展开后代码如下。这个代码会为任务设置信号待处理标记;并且在任务是TASK_WAKEKILL或者TASK_INTERRUPTIBLE状态下将其唤醒。注意了,这里只要任务有其中两个状态之一就会在此被唤醒,换言之如果任务状态不是这两者,比如TASK_UNINTERRUPTIBLE则不会被唤醒。
void signal_wake_up_state(struct task_struct *t, unsigned int state) { /* 为任务设置TIF_SIGPENDING 标记,表明这个任务有信号待处理 */ set_tsk_thread_flag(t, TIF_SIGPENDING); /* * TASK_WAKEKILL also means wake it up in the stopped/traced/killable * case. We don't check t->state here because there is a race with it * executing another processor and just now entering stopped state. * By using wake_up_state, we ensure the process will wake up and * handle its death signal. */ /* 这里的state是TASK_WAKEKILL,而wake_up_state就会去唤醒只有TASK_WAKEKILL | TASK_INTERRUPTIBLE标志的任务 */ if (!wake_up_state(t, state | TASK_INTERRUPTIBLE)) kick_process(t); }
因此结论就是,do_group_exit()会为组内线程挂一个SIGKILL信号,并设置TIF_SIGPENDING标志;然后唤醒有TASK_WAKEKILL | TASK_INTERRUPTIBLE状态的任务,而不会直接唤醒D状态的任务。
到此,我觉得真像快要浮出水面了,就差那么一点点。
4.1.2 TASK_WAKEKILL | TASK_INTERRUPTIBLE任务的余生
前面已经分析了,如果组内线程是TASK_WAKEKILL | TASK_INTERRUPTIBLE状态,则会被唤醒,唤醒后会立刻退出吗?还好遭遇什么?
为了解答我这些疑惑,我利用一些trace和kprobe点来进行验证。这其中包括sched_wakeup、sched_switch、exit_to_usermode_loop、signal_deliver、sys_enter_exit/_group、sched_process_exit;这些点可以观察到任务的唤醒、调度、返回用户态、信号处理、exit_group/exit()系统调用、以及任务终止 几个事件。
下面是验证脚本。脚本中的threads_exit是使用的二进制程序,这个程序包含一个主线程和一个子线程,主线程创建子线程后睡眠1秒退出;子线程睡眠10秒退出。
echo '(prev_comm == "threads_exit") || (next_comm == "threads_exit")' > /sys/kernel/debug/tracing/events/sched/sched_switch/filter echo '(comm == "threads_exit")' > /sys/kernel/debug/tracing/events/sched/sched_wakeup/filter echo '(comm == "threads_exit")' > /sys/kernel/debug/tracing/events/sched/sched_process_exit/filter echo 'p:exit_to_usermode_loop exit_to_usermode_loop' >> /sys/kernel/debug/tracing/kprobe_events echo 1 > /sys/kernel/debug/tracing/events/kprobes/exit_to_usermode_loop/enable echo 1 > /sys/kernel/debug/tracing/events/sched/sched_switch/enable echo 1 > /sys/kernel/debug/tracing/events/sched/sched_wakeup/enable echo 1 > /sys/kernel/debug/tracing/events/sched/sched_process_exit/enable echo 1 > /sys/kernel/debug/tracing/events/signal/signal_deliver/enable echo 1 > /sys/kernel/debug/tracing/events/syscalls/sys_enter_exit_group/enable echo 1 > /sys/kernel/debug/tracing/events/syscalls/sys_enter_exit/enable echo 1 > /sys/kernel/debug/tracing/tracing_on ./threads_exit echo 0 > /sys/kernel/debug/tracing/tracing_on echo 0 > /sys/kernel/debug/tracing/events/kprobes/exit_to_usermode_loop/enable echo 0 > /sys/kernel/debug/tracing/events/sched/sched_switch/enable echo 0 > /sys/kernel/debug/tracing/events/sched/sched_wakeup/enable echo 0 > /sys/kernel/debug/tracing/events/sched/sched_process_exit/enable echo 0 > /sys/kernel/debug/tracing/events/signal/signal_deliver/enable echo 0 > /sys/kernel/debug/tracing/events/syscalls/sys_enter_exit_group/enable echo 0 > /sys/kernel/debug/tracing/events/syscalls/sys_enter_exit/enable echo -:exit_to_usermode_loop >> /sys/kernel/debug/tracing/kprobe_events cat /sys/kernel/debug/tracing/trace > trace.log echo "" > /sys/kernel/debug/tracing/trace
上面trace抓取的结果放到trace.log中,下面是从trace.log中梳理出threads_exit任务相关事件的结果:
<...>-115036 [001] d... 5123829.796740: sched_switch: prev_comm=threads_exit prev_pid=115036 prev_prio=120 prev_state=S ==> next_comm=swapper/1 next_pid=0 next_prio=120 【1】线程115036睡眠 <...>-115035 [003] .... 5123829.796763: sys_exit_group(error_code: 0) 【2】leader 115035调用exit_group系统调用退出 <...>-115035 [003] d... 5123829.796772: sched_wakeup: comm=threads_exit pid=115036 prio=120 target_cpu=002 【3】线程115036被leader115035唤醒,这个阶段是在do_exit_group()中调用signal_wake_up(t, 1)来唤醒的; <...>-94050 [000] d... 5123829.796794: sched_switch: prev_comm=AliYunDun prev_pid=94050 prev_prio=110 prev_state=S ==> next_comm=threads_exit next_pid=115036 next_prio=120 【4】线程115036被唤醒后开始执行 <...>-115036 [003] d... 5123829.796799: exit_to_usermode_loop: (exit_to_usermode_loop+0x0/0xc7) 【5】线程115036退出到用户态 <...>-115036 [000] d... 5123829.796796: signal_deliver: sig=9 errno=0 code=0 sa_handler=0 sa_flags=0 【6】线程115036在exit_to_usermode_loop中检查是否有待处理的信号,这里发现前面有挂起的SIGKILL信号,紧着就处理 对于arm64 do_notify_resume-->do_signal-->get_signal()-->get_signal()-->trace_signal_deliver(signr, &ksig->info, ka)-->do_group_exit(ksig->info.si_signo); 对于x86_64 exit_to_usermode_loop()-->do_signal(regs)-->get_signal()-->get_signal()-->trace_signal_deliver(signr, &ksig->info, ka)-->do_group_exit(ksig->info.si_signo); <...>-115035 [003] .... 5123829.796800: sched_process_exit: comm=threads_exit pid=115035 prio=120 【7】leader 115035进入退出流程 <...>-115035 [003] d... 5123829.796814: sched_switch: prev_comm=threads_exit prev_pid=115035 prev_prio=120 prev_state=Z ==> next_comm=swapper/3 next_pid=0 next_prio=120 【8】leader 115035变为Z状态并切换出去 <...>-115036 [000] .... 5123829.796858: sched_process_exit: comm=threads_exit pid=115036 prio=120 【9】线程115036处理SIGKILL信号,进入exit()退出流程 <...>-115036 [000] d... 5123829.796881: sched_switch: prev_comm=threads_exit prev_pid=115036 prev_prio=120 prev_state=X ==> next_comm=swapper/0 next_pid=0 next_prio=120 【10】线程115036线程变为X状态,即真正退出
上面的每条日志下面都一行注释。我们把目光聚焦于主线程115035和子线程115036;可以看到在主线程调用exit_group()系统调用后,先唤醒了子线程115036;子线程被唤醒后得到一次调度的机会,然后再从内核态切换到用户态;在用户态切换到用户态过程中会去检查是否有信号pending,而子线程正好在主线程exit_group时设置了SIG_KILL的pengding信号,因而这会去处理这个信号;SIG_KILL信号的处理结果就是任务终止,因此子线程最终会退出。
4.1.3 What about TASK_UNINTERRUPTIBLE ?
前面在4.1.1中我们分析到TASK_UNINTERRUPTIBLE任务不会在do_group_exit()中立刻被唤醒,那这种状态的任务会后面会怎么样呢?
首先,处于TASK_UNINTERRUPTIBLE的任务会一直处于D状态,直到它等待的资源得到满足、被唤醒;如果资源一直得不到满足,就会一直处于D状态,而整个线程组也不会退出,线程组的资源也无法得到释放!!
反过来,如果D状态的线程被唤醒,就会在从内核态退出到用户态时检查信号挂起,然后进入任务终止流程,整个线程组在所有线程都终止后资源得到释放,线程组最终消逝。
五 总结
一个看似不起眼的匿名页消失问题,牵扯到了内存、调度、信号等等方面的知识,更不用说上面线程一直处于D状态原因的分析。
所以系统运维真的像医生,由表入里、千丝万缕,既要要求有丰富的经验,还需要有广阔的知识面,必要的时候还得在操作系统原理上有深厚的积累,这些都不仅仅是通过书本、看代码能够获取到的,还得长年累月的经历积累。