问题终于处理清楚了,如此坑爹的问题,陆陆续续的搞了有近月的时间,现在有时间写一个过程与总结。

问题现象:进程H需要每隔10s发消息给M(类似watchdog的功能),否则就会有功能异常的告警,业务发现了异常的告警,恰好OS监控日志中记录下了进程H当时是D状态,持续了约20s就恢复过来了,然后就没有然后,啥日志也没有。对于这个问题,各位老大进行亲切友好的交谈,双方达成一致意见:放到攻关团队中搞,OS重点投入。

拿到问题的时候,一堆日志里面翻来翻去,除了知道进程H当时D状态(而且进程H拆出了大约70~120个线程),就没有其他有效信息。问题也不重现。本着“没有搞不定的问题,只有不重现的问题”的原则,构造条件准备重现问题

在增大业务量做压力重现的时候,神奇的事情发生了,H主线程没有发生D状态,另外一个K进程则进入了D状态,最关键的是杀掉H进程后,K进程就恢复正常了,否则就会一直D状态长时间不恢复。看来H进程和K进程是有关系的,那就先入手必现的问题,把K进程的问题搞定。 由于K D状态是必现问题,就可以通过

 cat /proc/<code>{{EJS0}}</code>/stack

将进程的调用栈打印出来,根据栈的信息看到看到K进程D状态的过程:(其实proc的栈缺的太多,很多中间过程全靠脑补) K进程调用了mlock用来锁定内存(使其不置换到SWAP上,降低效率),mlock干活之前使用lru_add_drain_all回刷pagevec(pagevec是什么),lru_add_drain_all对每个CPU下发work函数lru_add_drain_per_cpu。

int lru_add_drain_all(void)
{
 return schedule_on_each_cpu(lru_add_drain_per_cpu);
}

int schedule_on_each_cpu(work_func_t func)
{
int cpu;
struct work_struct __percpu *works;

works = alloc_percpu(struct work_struct);
if (!works)
return -ENOMEM;

get_online_cpus();
        //对每个CPU循环
for_each_online_cpu(cpu) {
struct work_struct *work = per_cpu_ptr(works, cpu);
                //将func添加到work结构体中
INIT_WORK(work, func);
                //将work结构体添加到任务(work)队列里面
schedule_work_on(cpu, work);
}
        //等待每一个CPU的任务队列kworker上的work func完成
for_each_online_cpu(cpu)
flush_work(per_cpu_ptr(works, cpu));

put_online_cpus();
free_percpu(works);
return 0;
}

flush_work中wait_for_completion使用

void __sched wait_for_completion(struct completion *x)
{       //D状态入参
        wait_for_common(x, MAX_SCHEDULE_TIMEOUT, TASK_UNINTERRUPTIBLE);
}

static inline long __sched
do_wait_for_common(struct completion *x, long timeout, int state)
{
if (!x->done) {
DECLARE_WAITQUEUE(wait, current);

__add_wait_queue_tail_exclusive(&x->wait, &wait);
                //do 循环,此次K进程D状态挂死的代码段
do {
if (signal_pending_state(state, current)) {
timeout = -ERESTARTSYS;
break;
}
                        //进程状态设置
__set_current_state(state);
spin_unlock_irq(&x->wait.lock);
timeout = schedule_timeout(timeout);
spin_lock_irq(&x->wait.lock);
                //!x->done D状态一直解除不了的判断条件,timeout忽略不计
} while (!x->done && timeout);
__remove_wait_queue(&x->wait, &wait);
if (!x->done)
return timeout;
}
x->done--;
return timeout ?: 1;
}

K进程D状态住的原因就是kworker上的lru_add_drain_per_cpu函数一直没有完成,目前看就是任务队列中的调度有问题。 然后将所有的CPU的调用栈搞出来,发现虽然K进程对每个CPU下发了work任务,但只有一个核的CPU调用栈是如上描述,其他CPU都是正常的。排查所有的进程发现,同一个核上还有一个H线程在跑,后来发现H进程都是实时线程,采用FIFO的调度模式(具体信息请先了解实时进程),而且它的实时优先级大于kworker优先级,在H线程承接大业务时,一直抢占CPU不释放,导致kworker一直得不到调度,直接导致K进程D状态,一直挂死。手工写两个程序模拟了H与K进程,问题确实如此。

但是H进程D状态的问题也没有解决,只能靠重现了,终于,问题重现了,在H进程D状态的20s,将它所有线程的调用栈打印出来,可以先到 /proc/<code>pidof H/task下,查到所有的子线程。发现H有7,8个线程处于D状态,且它的D状态函数流程是: do_page_fault下调用down_read(&mm->mmap_sem)加锁,down_read调用__down_read,其在内联汇编中调用call_rwsem_down_read_failed,还是汇编:

ENTRY(call_rwsem_down_read_failed)
 CFI_STARTPROC
 save_common_regs
 pushq_cfi %rdx
 CFI_REL_OFFSET rdx, 0
 movq %rax,%rdi
 call rwsem_down_read_failed
 popq_cfi %rdx
 CFI_RESTORE rdx
 restore_common_regs
 ret
 CFI_ENDPROC
 ENDPROC(call_rwsem_down_read_failed)

实际上proc调用栈只到call_rwsem_down_read_failed,后面就没了,只好考脑补推测代码。 rwsem_down_read_failed调用rwsem_down_failed_common

static struct rw_semaphore __sched *
rwsem_down_failed_common(struct rw_semaphore *sem,
 unsigned int flags, signed long adjustment)
{       .......
        //在此处设置了D状态
set_task_state(tsk, TASK_UNINTERRUPTIBLE);
         ........
        //应该就是死在下面的循环里面
for (;;) {
if (!waiter.task)
break;
schedule();
set_task_state(tsk, TASK_UNINTERRUPTIBLE);
}
tsk->state = TASK_RUNNING;
}

rwsem_down_failed_common中存在将当前进程置成D状态的行为,而且在进程解状态前有循环,嫌疑就是它了。

rwsem_down_failed_common是内核读写锁的一部分,(具体细节请自行了解)。

struct rw_semaphore {
longcount;
spinlock_twait_lock;
struct list_headwait_list;//信号量等待列表
#ifdef CONFIG_DEBUG_LOCK_ALLOC
struct lockdep_mapdep_map;
#endif
};

在缺页中断中使用up_read获取线性区的读写信号量的读锁,发现rw_semaphore.count值为-1(有写锁),则获取读锁失败,(下划线并不完全正确,后面解释)进入rwsem_down_failed_common,把当前进程加入rwsem_waiter中,添加到队列中,进入死循环,等待waiter.task为空,即当前进程不在等待列表中,已经获取了读锁。

static struct rw_semaphore __sched *
rwsem_down_failed_common(struct rw_semaphore *sem,
 unsigned int flags, signed long adjustment)
{      //等待列表项
struct rwsem_waiter waiter;
struct task_struct *tsk = current;
signed long count;

set_task_state(tsk, TASK_UNINTERRUPTIBLE);

/* set up my own style of waitqueue */spin_lock_irq(&sem->wait_lock);
waiter.task = tsk;
waiter.flags = flags;
get_task_struct(tsk);

if (list_empty(&sem->wait_list))
adjustment += RWSEM_WAITING_BIAS;

        //添加到等待列表中
list_add_tail(&waiter.list, &sem->wait_list);
        .........
spin_unlock_irq(&sem->wait_lock);

/* wait to be given the lock */for (;;) {
                //已经获取读锁,跳出死循环
if (!waiter.task)
break;
schedule();//否则,调度出去,放弃CPU
                //调度回来后重设状态
set_task_state(tsk, TASK_UNINTERRUPTIBLE);
}
        //获取锁后改成R状态
tsk->state = TASK_RUNNING;
return sem;
}

排查了所有H线程,问题点没有线程占用写锁,然后写工具获取H进程线性区读写信号量的等待队列内容,将整个过程录下来,发现如下:

khugepaged获取写锁--->H多个线程因为缺页中断获取读锁,被阻塞--->khugepaged释放写锁,H多线程集体获取读锁---->khugepaged再次申请写锁,被阻塞--->H多线程集体获取读锁,除一个外(记作H1)其他都释放--->其他H多线程申请读锁,排队在khugepaged后面,等待占有读锁的H线程释放,等待超过20s后H释放--->一切恢复正常。

问题就出在H1线程占有读锁后没释放,也是由缺页中断触发,翻看up_read和down_read中间没有耗时操作,而且栈的挂死点在up_read内,再次写工具将该H线程的详细的内核调用栈导出来,对应内核反汇编代码,比较内存地址发现,代码挂死在schedule()行,也就是H1线程没有调用回来。

翻看运行在同一个CPU核上进程有一个H2,而且H1和H2都是被cpu绑定到5核上,H2处理主要业务,H1辅助,H2实时优先级高,H1实时优先级低,当H2压力大占用100% CPU时,H1就得不到调度了。OK,问题原因基本清楚了。

重现梳理一下各个进程过程。

高业务压力下,H2一直占用CPU,是R状态,H1得不到调度,是S状态。首先是khugepaged获取写锁,同时包含H2的多个H线程因缺页中断申请读锁,H2处于D状态被schedule让出CPU,H1也恰好缺页中断,获得CPU后申请读锁,排在H2后面,也是D状态。khugepaged释放写锁后,khugepaged先将所有等待读锁的进程拉入读写锁,并将其置成TASK_WAKING状态(参考__rwsem_do_wake函数)。H2先调度回来获取了读锁并完全占用了5核CPU,本来读锁支持并发,但H1此时没有了CPU可供使用,没有调度,一直在schedule打转,虽然进程没有调度,但H1已经被khugepaged拉到读写锁,占用了读锁,一直不释放,khugepaged申请写锁也不能完成,后续更多H缺页中断申请读锁也被阻塞住,D状态达到20s。至于后续能恢复,是因为H2再次因为缺页要申请读锁,排队到队列,进入D状态,schedule让出CPU,H1才能再次得到CPU,完成工作后释放读锁。

整个过程中,进程来线程往,川流不息,各种条件相互叠加,缺一不可,造成一个类似珍珑棋局般死锁。

亚马逊的蝴蝶扇一下翅膀,德州则起了一场风暴,编程之美,确是如此。


读写信号量与实时进程阻塞挂死问题来自于OenHan

链接为:http://oenhan.com/rwsem-realtime-task-hung

3 thoughts on “读写信号量与实时进程阻塞挂死问题”

发表回复