您的位置:首页 > 运维架构 > Linux

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

2010-04-12 11:34 274 查看
一个程序问题

之前写过这样一个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()函数。

【本文首发于:百度互联网技术官方博客】http://blog.csdn.net/baiduforum/article/details/5475284
关注百度技术沙龙】
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息