记一则 cfs 硬限引发的 cgroup_mutex 死锁

cgroup_mutex 是内核实现 cgroup 子系统而重度依赖的一把全局锁,这把全局锁在很多场景下会带来非常多的性能问题。具体这里不展开讲了,google 一下 cgroup_mutex deadlock,可以看到一堆 bugs report

最近线上恰好遇到一次机器死机的场景,后来分析发现和 cgroup_mutex 死锁有关(严格来说不叫死锁,叫夯死更合适一些),以此记录分析过程

1. 现象

机器存在大量的 D 进程,监控发现系统负载缓慢持续增高,应该是由于D进程持续不断地堆积,导致负载越来越高

所有与 cgroup_mutex 打交道的地方,都会卡死,比如简单来说 cat /proc/self/cgroup 会立即进入夯死状态,进程无法 kill -9 杀掉

我们通过一些艰难的内核栈跟踪,终于捕捉到了内核临死前的锁持有的状态(此处省略掉1万字跟踪过程)

2. 内核栈分析

读过内核源码或者了解内核原理的应该知道,vfs_write 是内核文件系统的抽象层。vfs_write 接着调用 cgroup_file_write,这个说明当前进程正在写 cgroup 文件系统,然后在写过程中,陷入 synchronize_sched,进程被换出,然后应该是一直卡在这里了。

但是真正导致系统死机的,并不是 synchronize_sched 这个地方,二是一个很复杂的链,触发场景:

  1. 进程持有 cgroup_mutex
  2. 进程尝试获取其他锁,或者进入睡眠态
  3. 进程 cfs 时间片被 throt,导致进程无法重新获得 cpu 的控制权,cgroup_mutex 无法释放

cgroup_mutex 是一把极大的锁,几乎任何 cgroup 操作都会涉及到这把锁的操作。在我们这个场景里,进程持有 cgroup_mutex 之后陷入 __wait_rcu,其他进程在尝试持有 cgroup_mutex 的时候几乎全部都夯住了

我们通过内核代码,来回顾一下整个过程:

1)首先是进程修改 cgroup 文件系统

vfs_write 是内核文件系统抽象层的接口,我们直接看 cgroup_file_write 即可,不同的文件系统,有不同的 ->write() 函数实现,既然我们操作的是 cgroup 文件系统,当然入口函数是 cgroup_file_write 了


static struct kernfs_ops cgroup_kf_ops = {
        .open                   = cgroup_file_open,
        .release                = cgroup_file_release,
        .write                  = cgroup_file_write,
        ......
};

static ssize_t cgroup_file_write(struct kernfs_open_file *of, char *buf,
                                 size_t nbytes, loff_t off)
{
        ......
        if (cft->write)
                return cft->write(of, buf, nbytes, off);
        ......                                                                                                                                                                              
        if (cft->write_u64) {                                                                                                                                                 
                unsigned long long v;                                                                                                                                         
                ret = kstrtoull(buf, 0, &v);                                                                                                                                  
                if (!ret)                                                                                                                                                     
                        ret = cft->write_u64(css, cft, v);                                                                                                                    
        } else if (cft->write_s64) {                                                                                                                                          
                long long v;                                                                                                                                                  
                ret = kstrtoll(buf, 0, &v);                                                                                                                                   
                if (!ret)                                                                                                                                                     
                        ret = cft->write_s64(css, cft, v);                                                                                                                    
        }
        ......                                                                                                                                                 
}

我们知道 cgroup 有很多子系统,比如 cpu、memory、io,不同的 cgroup 子系统又会有不同的 write 实现方式,比如 memory 的 write 操作会触发内存回收,比如 cpuset 的 write 操作,会触发 domain balance。甚至同一个子系统下,不同的cgroup接口,都有不同的 write 实现,比如 memory 子系统,memory.drop_cache 和 memory.limit_in_bytes 对应的内核实现显然是不一样的

我们从上面的 mem_cgroup_move_task() 可以看出当前正在操作的是内存子系统。从内核代码分析来看,mem_cgroup_move_task() 函数是 memory_cgrp_subsys 的 post_attach 接口实现,只有在写 cgroup.procs 接口时触发的

void cgroup_procs_write_finish(struct task_struct *task)
        __releases(&cgroup_threadgroup_rwsem)
{
        struct cgroup_subsys *ss;
        int ssid;

        /* release reference from cgroup_procs_write_start() */
        put_task_struct(task);

        percpu_up_write(&cgroup_threadgroup_rwsem);
        for_each_subsys(ss, ssid)
                if (ss->post_attach)
                        ss->post_attach();
}

struct cgroup_subsys memory_cgrp_subsys = {
        .css_alloc = mem_cgroup_css_alloc,
        .css_released = mem_cgroup_css_released,
        .can_attach = mem_cgroup_can_attach,
        .post_attach = mem_cgroup_move_task,
        .bind = mem_cgroup_bind,
        ......
};

再来看下 mem_cgroup_move_task() 函数的实现,mem_cgroup_move_task会调用 mem_cgroup_move_charge 来迁移不同 cgroup 之间的计数。比如当你把一个进程的 pid,从一个 cgroup 迁移到另外一个 cgroup 的时候,这个 pid 在原 cgroup 里面的内存使用,会迁移到目标 cgroup

mem_cgroup_move_charge() 里面会调用 synchronize_rcu,也就是最终会调到 synchronize_sched 里面

static inline void synchronize_rcu(void)
{
        synchronize_sched();
}

static void mem_cgroup_move_charge(void)
{
        struct mm_walk mem_cgroup_move_charge_walk = {
                .pmd_entry = mem_cgroup_move_charge_pte_range,
                .mm = mc.mm,
        };

        lru_add_drain_all();
        /*
         * Signal lock_page_memcg() to take the memcg's move_lock
         * while we're moving its pages to another memcg. Then wait
         * for already started RCU-only updates to finish.
         */
        atomic_inc(&mc.from->moving_account);
        synchronize_rcu();
retry:
        ......
        atomic_dec(&mc.from->moving_account);                                                                                                                                 
}

到目前为止,整个调用栈的过程就基本清楚了

3. 其他隐患

首先这个问题非常不好解,我们尝试在用户态针对内核实现做了一些规避方案,但是最终效果不理想。极端情况下 cfs 硬限会概率性引发 cgroup_mutex 死锁

除了写 memory 子系统的 cgroup.procs 接口会导致这个问题之外,我们还发现写 cpuset.procs 接口也会引发同样的问题,因为 cpuset 变更会触发内核进程的 domain balance,这个地方有同样的风险

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注