一次任务状态"失常"的踩坑记

发布时间 2023-11-02 22:53:28作者: 温暖的电波

0 背景

    业务场景中需要统计任务处于"D"状态offcpu的时长,为此和队友一起尝试使用ebpf在trace_sched_switch()这个trace点挂上自己的ebpf处理函数。这个ebpf处理函数使用SEC("tp/sched/sched_switch")来声明,然后在处理函数中判断"prev_state"是否带有"TASK_UNINTERRUPTIBLE"标志,如果有则记录下即将切换出去的任务的prev_pid;后面这个任务再切换回来的时候,再取出来。

    刚开始一切work well,直到发现kworker这种类型的任务经常无法被统计到--我们同时在trace_sched_stat_blocked()这个tracepoint添加了ebpf探针函数,奇怪的是trace_sched_stat_blocked()可以检测到的kwoker经历了"blocked"状态,但是使用trace_sched_switch()就无法捕获到。

    这究竟是怎么回事呢?

一 ebpf代码自检

    重新梳理了一下trace_sched_switch()的处理函数,发现是判断"D"状态的方式有点问题:

if (prev_state ==  TASK_UNINTERRUPTBLE) {
   /* got it */
}

    因为我们从内核源码观察到kworker在进入睡眠时会将状态设置为“TASK_IDLE”,这是种复合状态,即:

#define TASK_IDLE                       (TASK_UNINTERRUPTIBLE | TASK_NOLOAD)

    如果使用前面的 "=="来进行判断,势必会失败。于是我们赶紧将其修改为与操作"&"来进行判断,即:

if (prev_state &  TASK_UNINTERRUPTBLE) {
   /* got it */
} 

    改完之后我们感觉已经胜券在握,代码重新运行时,已经开始准备开香槟庆祝,but....状况仍然没有一丁点改善。

    "What hanppend?"我和队友都陷入了沉思。

二 利用trace event佐证

2.1 令人震惊的结论

    为了确保我们的判定条件没有问题,队友在判断条件前面讲prev_state的值给"打印"出来,另我们惊讶的是,这个状态居然是0x80,也就是TASK_DEAD:

#define TASK_DEAD                       0x0080

    这又再一次刷新了我的认知。因为在我有限的Linux调度知识范围内,我理解任务的状态要变为TASK_DEAD只有两个地方:1) 新任务创建失败;2) 任务退出的最后一丁点阶段。这两种状态都是不可逆的,一旦进入这种状态就宣布任务死翘翘了,没有机会再参与调度了。然而我们愣是看到prev_state==TASK_DEAD的kworker任务一次又一次的被唤醒、调度、再唤醒、调度......

 2.2 唠叨唠叨论证过程

    我冷静的喝了一杯5个核桃强压心中的惊慌。

    "人类会说谎,但是机器不会,一定是有哪里我没有搞清楚的地方",在绝望的时候这个坚定的声音总是这样震耳溃聋。

    这次我不再依赖ebpf程序,我直接在机器上使用trace event来检查,简化后的脚本如下:

echo 1 > /sys/kernel/debug/tracing/events/sched/sched_switch/enable
echo 1 > /sys/kernel/debug/tracing/events/sched/sched_stat_blocked/enable
echo 1 > /sys/kernel/debug/tracing/tracing_on

sleep $TIMEOUT

echo 0 > /sys/kernel/debug/tracing/tracing_on
echo 0 > /sys/kernel/debug/tracing/events/sched/sched_switch/enable
echo 0 > /sys/kernel/debug/tracing/events/sched/sched_stat_blocked/enable

cat /sys/kernel/debug/tracing/trace  > $HOME/trace.log

echo "" > /sys/kernel/debug/tracing/trace

    从采集的日志trace.log中我复现了ebpf中遇到的问题,且看下面3行:

1】<...>-111117 [003] d... 4674198.005896: sched_switch: prev_comm=kworker/3:2 prev_pid=111117 prev_prio=120 prev_state=I ==> next_comm=swapper/3 next_pid=0 next_prio=1202】<idle>-0     [003] d.s. 4674198.213880: sched_stat_blocked: comm=kworker/3:2 pid=111117 delay=207983755 [ns]
【3】<idle>-0     [003] d... 4674198.213885: sched_switch: prev_comm=swapper/3 prev_pid=0 prev_prio=120 prev_state=R ==> next_comm=kworker/3:2 next_pid=111117 next_prio=120

    这3行专门截取了一个kworker任务111117经过了 调度切换离开CPU、就绪进入队列、调度切换调入CPU的3个阶段。

  • 阶段【1】

    kworker 111117调度切换离开CPU,离开时trace信息中的”prev_state=I“,从内核代码可知,prev_state=I也就意味着prev_state的值为TASK_DEAD,这个结论和前面ebpf的代码结论一致。

    而这段内核代码截取自TRACE_EVENT(sched_switch,....)如下所示:

        TP_printk("prev_comm=%s prev_pid=%d prev_prio=%d prev_state=%s%s ==> next_comm=%s next_pid=%d next_prio=%d",
                __entry->prev_comm, __entry->prev_pid, __entry->prev_prio,

                (__entry->prev_state & (TASK_REPORT_MAX - 1)) ?
                  __print_flags(__entry->prev_state & (TASK_REPORT_MAX - 1), "|",
                                { TASK_INTERRUPTIBLE, "S" },
                                { TASK_UNINTERRUPTIBLE, "D" },
                                { __TASK_STOPPED, "T" },
                                { __TASK_TRACED, "t" },
                                { EXIT_DEAD, "X" },
                                { EXIT_ZOMBIE, "Z" },
                                { TASK_PARKED, "P" },    
                                { TASK_DEAD, "I" }) :   /* 如果输出位I,则反推出prev_state值为TASK_DEAD */
                  "R",
  • 阶段【2】

     kworker 111117唤醒后进入到就绪队列并经过了trace_sched_stat_block()这个trace点;而要经过这个trace点,从内核源码来看一个必要条件就是观察到之前这个kworker睡眠时的任务状态有TASK_UNINTERRUPTIBLE标志。这段内核代码如下所示:

/* dequeue */
update_stats_dequeue()
{
    if (tsk->state & TASK_UNINTERRUPTIBLE)
        /* 需要切换出去的任务必须要有TASK_UNINTERRUPTIBLE标记才会设置block_start */
        __schedstat_set(se->statistics.block_start, rq_clock(rq_of(cfs_rq)));    
}

/* enqueue */
update_stats_enqueue_sleeper()
{
    block_start = schedstat_val(se->statistics.block_start);
    ……
    if (block_start) {     /* block_start只有在dequeue时tsk->state有TASK_UNINTERRUPTIBLE标记才会设置 */
         …… 
        trace_sched_stat_blocked(tsk, delta);
    }
}
  • 【阶段3】

    kworker 111117从就绪队列得到CPU调度、执行。

    用trace event的实验结论和排查1中使用ebpf的结论一致,那就是kworker偶尔会出现:1) 在 trace_sched_switch中prev_state这个参数为TASK_DEAD(理论上这个prev_state来自于task->state);2) 在切换出去、dequeue时task->state会被检查到有TASK_UNINTERRUPTIBLE标记;3) kworker切换出去后仍然有机会切换回来重新调度。

 

    为了解开上面的疑惑又重新整理了一下内核代码,确认了一下任务切换出去的流程:

  • 1) deactivate_task()任务dequeue出队,这里会检查task->state是否有TASK_UNINTERRUPTIBLE标记,如果有会走到trace_sched_stat_block()这个trace点;
  • 2) 任务切换 context_switch(),在执行切换前有trace_sched_switch()这个trace点,我们前面就是拿这个trace点的数据。

    而这两个步骤之间并没有明确的task->state状态改变的情况。

    因此我甚至开始怀疑trace_sched_switch中的prev_state参数和task->state值并不相同了。

2.3 大胆怀疑,小心验证 

    为了验证我的怀疑点,我又凑到内核代码中寻找trace_sched_switch的代码,不看不知道,一看吓一跳:

/*
 * Tracepoint for task switches, performed by the scheduler:
 */
TRACE_EVENT(sched_switch,
。。。。。。
        TP_fast_assign(
                memcpy(__entry->next_comm, next->comm, TASK_COMM_LEN);
                __entry->prev_pid       = prev->pid;
                __entry->prev_prio      = prev->prio;
//__entry->prev_state是在这里计算得到的,并非直接取自task->state
                __entry->prev_state     = __trace_sched_switch_state(preempt, prev);
                memcpy(__entry->prev_comm, prev->comm, TASK_COMM_LEN);
                __entry->next_pid       = next->pid;
                __entry->next_prio      = next->prio;
                /* XXX SCHED_DEADLINE */
        ),

        TP_printk("prev_comm=%s prev_pid=%d prev_prio=%d prev_state=%s%s ==> next_comm=%s next_pid=%d next_prio=%d",
                __entry->prev_comm, __entry->prev_pid, __entry->prev_prio,
//根据prev_state的值打印不同的字符标记
                (__entry->prev_state & (TASK_REPORT_MAX - 1)) ?
                  __print_flags(__entry->prev_state & (TASK_REPORT_MAX - 1), "|",
                                { TASK_INTERRUPTIBLE, "S" },
                                { TASK_UNINTERRUPTIBLE, "D" },
                                { __TASK_STOPPED, "T" },
                                { __TASK_TRACED, "t" },
                                { EXIT_DEAD, "X" },
                                { EXIT_ZOMBIE, "Z" },
                                { TASK_PARKED, "P" },
                                { TASK_DEAD, "I" }) :

。。。。。。
);

    这个代码得到两个信息:

  • 1) prev_state不是直接来自于task->state的,
  • 2) prev_state确实有对TASK_DEAD的判断,说明我们前面就正好触发到这种场景。

    对于prev_state状态的计算,简单展开出来是这样的:

static inline long __trace_sched_switch_state(bool preempt, struct task_struct *p)
{
        unsigned int state;
        ......
        state = task_state_index(p);  /* 这个函数计算 */
        ......
       /* 计算的state如果不为0 需要做(1<<(state-1))运算 */
        return state ? (1 << (state - 1)) : state;
}


/* kworker线程切换出去时task->state会设置为TASK_IDLE */
#define TASK_IDLE                       (TASK_UNINTERRUPTIBLE | TASK_NOLOAD)

/* 这里TASK_REPORT的值为0x7F */
#define TASK_REPORT                     (TASK_RUNNING | TASK_INTERRUPTIBLE | \
                                         TASK_UNINTERRUPTIBLE | __TASK_STOPPED | \
                                         __TASK_TRACED | EXIT_DEAD | EXIT_ZOMBIE | \
                                         TASK_PARKED)

/* TASK_REPORT_IDLE = TASK_REPORT + 1 ,即0x7F + 1 = 0x80 */
#define TASK_REPORT_IDLE        (TASK_REPORT + 1)
#define TASK_REPORT_MAX         (TASK_REPORT_IDLE << 1)

static inline unsigned int task_state_index(struct task_struct *tsk)
{
        unsigned int tsk_state = READ_ONCE(tsk->state);
        unsigned int state = (tsk_state | tsk->exit_state) & TASK_REPORT;

        BUILD_BUG_ON_NOT_POWER_OF_2(TASK_REPORT_MAX);

        /* 这里是一个关键地方,kworker线程在无work、调度出去的时候会设置为这个状态,然后schedule()调度出去 */
        if (tsk_state == TASK_IDLE)
                state = TASK_REPORT_IDLE; /* 由上面宏定义可知,这为0x80 */
       /* 返回最高位为1的位置,例如state=0x80则返回8 */
        return fls(state);  
}

     这段代码有两个信息需要说明:

  • 1) kworker的睡眠

kworker线程在没有work做时,会将task->state设置TASK_IDLE状态,这是一个复合状态,包含了TASK_UNINTERRUPTIBLE状态;

  • 2) prev_state的计算

    在trace_sched_siwtch中计算prev_state时,如果task->state状态为TASK_IDLE,则会经过3次转换,最终转换为0x80。这3次转换时这样的:(1) state = TASK_REPORT_IDLE,即0x80; (2) fls(state)转换为8; (3)  (1<<(state-1))再次转换为0x80。

    而0x80在task状态中正好对应着TASK_DEAD,what a fuck!!

 

    也就是说如果task->state为TASK_IDLE的话,则trace_sched_switch()中prev_state的是TASK_DEAD,而trace buffer中输出的字符串就是"I" !!!

    这样一来,前面的诡异逻辑就说的通了。

  • 首先,不论通过ebpf还是trace event接口我们看到的prev_state值是0x80,是因为trace_sched_switch()中将TASK_IDLE值转换为了TASK_DEAD;
  • 其次,任务出队时,dequeue直接检查的是task->state,针对kworker线程而言,其值为TASK_IDLE,这是一个复合状态,里面包含了TASK_UNINTERRUPTIBLE,所以trace_sched_stat_blocked()能够trace到kworker
  • 最后,虽然prev_state值等于TASK_DEAD,但这只是一种内核代码借用TASK_DEAD标志的"巧合",在trace_switch_context上下文中不能单纯用prev_state值来进行任务状态判断。

2.4 刨根问底,连根拔起

    为什么内核会这样呢?这其实和两个补丁有关:

sched/debug: Add explicit TASK_IDLE printing

sched/debug: Add explicit TASK_PARKED printing

    在Linux系统中每个任务(idle除外)都有一个/proc/$pid/status接口,这个接口里面有一个字段叫做”State“,表示任务的状态,如运行状态"R":

# cat /proc/self/status
Name:	cat
Umask:	0022
State:	R (running)
Tgid:	18364
Ngid:	0
Pid:	18364
PPid:	18350
。。。。。。  

     而这里展示的"State"信息是通过内核中的一个叫做task_state_array的数组来提供的:

/*
 * The task state array is a strange "bitmap" of
 * reasons to sleep. Thus "running" is zero, and
 * you can test for combinations of others with
 * simple bit tests.
 */
static const char * const task_state_array[] = {

        /* states in TASK_REPORT: */
        "R (running)",          /* 0x00 */
        "S (sleeping)",         /* 0x01 */
        "D (disk sleep)",       /* 0x02 */
        "T (stopped)",          /* 0x04 */
        "t (tracing stop)",     /* 0x08 */
        "X (dead)",             /* 0x10 */
        "Z (zombie)",           /* 0x20 */
        "P (parked)",           /* 0x40 */

        /* states beyond TASK_REPORT: */
        "I (idle)",             /* 0x80 */
};

    从注释上可以看到这个数组是一个"strange bitmap",数组的第0个元素表示"R (running)",即就绪状态;更重要的是这个数组分为了两个部分

  • 第一部分:

    ”in TASK_REPORT“,其对应数组index范围[0, 7]对应的取值分别为 (1<<index-1),即在宏TASK_REPORT(0x7f)以内,代表着这些TASK_RUNNING , TASK_INTERRUPTIBLE, TASK_UNINTERRUPTIBLE , __TASK_STOPPED, __TASK_TRACED , EXIT_DEAD ,  EXIT_ZOMBIE , TASK_PARKED 标志。

  • 第二部分:

    "beyond TASK_REPORT",其值为0x80,代表的是TASK_IDLE(但是从值来看其实也等于TASK_DEAD,但是在这个上下文环境不代表TASK_DEAD)。

 

    这里的"I (idle)"是最特殊的,因为虽然它代表的是TASK_IDLE状态,但是它的值和这里的数组的index是没有对应起来的。TASK_IDLE值是0x402,但是通过(1<<index-1)计算的值是0x80;

    而不论是在/proc/pid/status的"State"的计算中,还是在trace_sched_siwtch的"prev_state"计算中都是按照(1<<index-1)的方式来计算的。因此内核实际上对TASK_IDLE状态在task_state_array中做了一个映射,刚好映射到TASK_DEAD值上,这才造成了之前的误会。

 

    那为什么不直接把TASK_IDLE的值作为数组中的index呢?

  • 首先,task_state_array是一个bitmap数组,因此只能够表示一个元素只能够代表一个bit位,而TASK_IDLE是一个组合标志,占用了两个bit位,导致task_state_array这个bitmap无法表示;
  • 其次,如果直接取TASK_IDLE的high bit,也就是0x400,会造成这个数组中间的"空洞",这个空洞一会浪费空间,第二还会导致通用的index计算公式失效。

    这一点也可以从前面的两个补丁看出来,最开始TASK_IDLE对应值是0x40,在bitmap引入TASK_PARKED后,又才变为0x80。

    到此,这个不痛但痒的奇怪问题终于搞清楚了,它的来龙去脉也终于搞清楚了,天空又蓝了,云朵又白了,风儿也甜了。