linux线程同步浅析——睡眠与唤醒的秘密

一个程序问题
之前写过这样一个C程序:模块维护一个工作线程、提供一组调用接口(分同步调用和异步调用)。用户调用模块提供的接口后,会向工作队列添加一个任务。然后任务由工作线程来处理。在同步调用情况下,接口调用后调用者被阻塞,等待工作线程处理完成后,将调用者唤醒。伪代码如下:
[调用接口]
add_command(cmd, pid); /* 1 */
raise(SIGSTOP); /* 2 */
get_response(cmd); /* 6 */

[工作线程]
wait_for_command(&cmd, &pid); /* 3 */
do_command(cmd); /* 4 */
kill(pid, SIGCONT); /* 5 */

调用接口向工作队列添加命令以后,向自己发送一个SIGSTOP信号,把自己挂起;工作线程处理命令完成,通过向调用者进程发送SIGCONT信号,将调用者唤醒。
流程上还是比较清晰的,但是有点想当然了。测试发现,程序的执行流程可能变成下面的情况:
[调用接口]
add_command(cmd, pid); /* 1 */
raise(SIGSTOP); /* 5 ... */
get_response(cmd);

[工作线程]
wait_for_command(&cmd, &pid); /* 2 */
do_command(cmd); /* 3 */
kill(pid, SIGCONT); /* 4 */

调用者在添加命令后,发生调度,工作线程在调用者进入睡眠之前,先处理了命令并发出唤醒信号。之后,调用者再睡眠,就没办法被唤醒了。
解决方法
直接使用信号来实现睡眠和唤醒看来是不可取的,于是想到了使用pthread的互斥机制。改写后的程序如下:
[调用接口]
add_command(cmd); /* 1 */
pthread_cond_wait(cond); /* 2 */
get_response(cmd); /* 6 */

[工作线程]
wait_for_command(&cmd, &pid); /* 3 */
do_command(cmd); /* 4 */
pthread_cond_signal(cond); /* 5 */

测试发现,这样做就不会出现由于调度而出现"先唤醒、后睡眠"的问题了。
但是,pthread条件变量是如何避免"先唤醒、后睡眠"的呢?实际上,它依然无法避免调用者在添加命令后,由于调度,造成pthread_cond_signal先于pthread_cond_wait发生的问题。但是条件变量内部记录了信号是否已发生,如果pthread_cond_signal先于pthread_cond_wait,则pthread_cond_wait将看到条件变量中记录的"信号已发生",于是放弃睡眠。
man一下pthread_cond_signal可以看到如下流程:
[pthread_cond_wait(mutex, cond)]
value = cond->value; /* 1 */
pthread_mutex_unlock(mutex); /* 2 */
pthread_mutex_lock(cond->mutex); /* 10 */
if (value == cond->value) { /* 11 */
me->next_cond = cond->waiter;
cond->waiter = me;
pthread_mutex_unlock(cond->mutex); /* X */
unable_to_run(me); /* Y */
} else
pthread_mutex_unlock(cond->mutex); /* 12 */
pthread_mutex_lock(mutex); /* 13 */
[pthread_cond_signal(cond)]
pthread_mutex_lock(cond->mutex); /* 3 */
cond->value++; /* 4 */
if (cond->waiter) { /* 5 */
sleeper = cond->waiter; /* 6 */
cond->waiter = sleeper->next_cond; /* 7 */
able_to_run(sleeper); /* 8 */
}
pthread_mutex_unlock(cond->mutex); /* 9 */
这份伪代码中的cond->value就是用于记录"信号已发生"的变量。
深入一点
如果你足够细心,可能已经发现上面的pthread的伪代码是有问题的。在‘X'处,cond->value已经判断过了,cond->mutex也已经释放了,而unable_to_run(将进程挂起)还没运行机制。那么此时如果发生调度,pthread_cond_signal先运行了呢?是不是able_to_run(唤醒)又将发生在unable_to_run之前,而导致"先唤醒、后睡眠"呢?
这就变成了下面的流程:

[pthread_cond_wait(mutex, cond)]
value = cond->value; /* 1 */
pthread_mutex_unlock(mutex); /* 2 */
pthread_mutex_lock(cond->mutex); /* 3 */
if (value == cond->value) { /* 4 */
me->next_cond = cond->waiter;
cond->waiter = me;
pthread_mutex_unlock(cond->mutex); /* 5 */
unable_to_run(me); /* 13 ... */
} else
pthread_mutex_unlock(cond->mutex);
pthread_mutex_lock(mutex);
[pthread_cond_signal(cond)]
pthread_mutex_lock(cond->mutex); /* 6 (注意:5已经释放锁了) */
cond->value++; /* 7 */
if (cond->waiter) { /* 8 */
sleeper = cond->waiter; /* 9 */
cond->waiter = sleeper->next_cond; /* 10 */
able_to_run(sleeper); /* 11 */
}
pthread_mutex_unlock(cond->mutex); /* 12 */

这个问题实际上和文章最开始的代码一样,在"睡眠前的准备"和"进入睡眠"之间可能发生调度,从而存在"先唤醒、后睡眠"的可能性。
真的会有问题吗?其实不会,否则pthread提供这么一个不能做到同步的同步接口,实在没什么意义。其实able_to_run和unable_to_run的实现还是有讲究的,简单的睡眠和唤醒显然不能满足需要。
同步的实现
当时写程序的时候是在嵌入式linux下,uClibc库使用的pthread线程库是linuxthreads(现在主流的线程库是NPTL)。在linuxthreads中,上面提到的unable_to_run是基于sigsuspend系统调用来实现的。
在linux中,每个进程(线程)都有一个信号掩码,如果某个信号被mask掉,那么收到的这个信号就不会被处理,而是作为一个未决信号,记录在进程的控制信息(task_struct结构)中。默认情况下,linuxthreads把SIGUSER1给mask掉了。而sigsuspend的功能就是使用新的mask,并等待一个信号。收到不被mask的信号后,sigsuspend返回,并且信号掩码被还原。
这样一来,如果出现"先唤醒、后睡眠"(able_to_run先于unable_to_run被执行),则:
1.able_to_run:SIGUSER1信号被发送到目标进程上,而目标进程的SIGUSER1信号被mask掉了,于是该信号被记录在目标进程的task_struct结构中,并不被立刻处理
2.unable_to_run:调用sigsuspend,新的mask不包含SIGUSER1信号,于是记录在task_struct结构中的SIGUSER1信号被取出,sigsuspend直接返回,并不会进入睡眠
可见,sigsuspend之所以能够实现同步,就是因为它避免了"睡眠前的准备"和"进入睡眠"之间可能发生的调度("睡眠前的准备"中的最后一步----取消mask,和"进入睡眠",都是在这个调用中完成的),把这两个操作统一成了一个"原子操作"(对于用户态程序来说是原子的)。
再深入一点
那么,由内核实现的系统调用sigsuspend,它本身也是一个函数呀,它还是得面对"在‘睡眠前的准备'和‘进入睡眠'之间可能发生调度"的问题呀!其实不然,因为调度其本身是由内核来实现的,内核大不了就在一小段时间内不调度。
但是,上面只提到由于调度引起的"先唤醒、后睡眠"问题。然而在多处理器条件下,即将睡眠的进程和唤醒进程可能运行在不同的CPU上,即便不发生调度还是可能出现"先唤醒、后睡眠"的问题。
为了解决这个问题,内核还必须用到锁。内核通过锁来保证"睡眠前的准备"和"进入睡眠"是"原子的"。然而,锁总是要释放的,释放锁是不是应该放在睡眠以前?是不是该归为"睡眠前的准备"?于是乎,是不是又存在"在‘睡眠前的准备'和‘进入睡眠'之间被插入唤醒操作"的问题呢?
没错,如果锁一定要在睡眠以前释放,那么肯定还是存在这样的问题。但是内核不一定要在进程睡眠以前释放锁,内核可以让这个进程带着锁去睡眠。然后,当上下文切换到另一个进程之后(注意,这时还是在内核态),内核还可以为上一个进程执行一些代码,做一些切换后的清理工作。锁的释放实际上可以放在这里来做。
具体到linux内核代码,我们来看看用于唤醒的try_to_wake_up函数和用于睡眠的schedule函数(实际上该函数用于触发一次调度,在调度前如果发现当前进程状态不是RUNNING,则将其移出可执行队列,于是当前进程就睡眠了)。
[try_to_wake_up]
1.锁住被唤醒进程对应的可执行队列
2.将被唤醒进程加入该队列
3.将被唤醒进程状态设为RUNNING
4.释放锁
[schedule]
1.锁住当前进程对应的可执行队列
2.如果进程状态不为RUNNING,则将其移出队列
3.进行进程切换
4.释放锁
调用schedule函数之前,当前进程已经被设置为非RUNNING状态,很容易通过锁机制保证这个动作发生在try_to_wake_up函数被调用之前。那么,可以看到,即使是"先唤醒、后睡眠",睡眠的进程也能被唤醒。因为"唤醒"动作将进程状态设为RUNNING了,而"睡眠"动作发现进程状态是RUNNING,则并不会真正睡眠(不会将进程移出可执行队列)。可执行队列锁保证了"唤醒"和"睡眠"两个动作是原子的,不会交叉执行。而在"睡眠"过程中,是在完成了进程切换后才释放锁。这个动作可参阅sched.c:context_switch()函数最后部分调用的finish_task_switch()函数。

你可能感兴趣的:(linux,工作,command,cmd,Signal,linux内核)