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()函数。

 













本文转自百度技术51CTO博客,原文链接:http://blog.51cto.com/baidutech/744765 ,如需转载请自行联系原作者
相关文章
|
24天前
|
Linux C++
LInux下Posix的传统线程示例
LInux下Posix的传统线程示例
19 1
|
2月前
|
Java Linux 调度
linux线程池浅谈
linux线程池浅谈
|
1月前
|
算法 Unix Linux
Linux与Qt线程优先级的对应关系:一次全面解析
Linux与Qt线程优先级的对应关系:一次全面解析
23 0
|
2月前
|
Linux 调度 数据库
Linux下的系统编程——线程同步(十三)
Linux下的系统编程——线程同步(十三)
52 0
Linux下的系统编程——线程同步(十三)
|
1月前
|
消息中间件 Linux 调度
【Linux 进程/线程状态 】深入理解Linux C++中的进程/线程状态:阻塞,休眠,僵死
【Linux 进程/线程状态 】深入理解Linux C++中的进程/线程状态:阻塞,休眠,僵死
67 0
|
1月前
|
资源调度 算法 Linux
Linux进程/线程的调度机制介绍:详细解析Linux系统中进程/线程的调度优先级规则
Linux进程/线程的调度机制介绍:详细解析Linux系统中进程/线程的调度优先级规则
91 0
|
1月前
|
存储 安全 数据管理
Linux系统编程教程之Linux线程函数的使用:讲解Linux线程函数
Linux系统编程教程之Linux线程函数的使用:讲解Linux线程函数
19 1
|
1月前
|
缓存 Linux C语言
Linux线程的创建过程
【2月更文挑战第10天】
|
3天前
|
固态存储 Ubuntu Linux
Linux(29) 多线程快速解压缩|删除|监视大型文件
Linux(29) 多线程快速解压缩|删除|监视大型文件
11 1
|
30天前
|
监控 Linux 调度
【Linux 应用开发 】Linux 下应用层线程优先级管理解析
【Linux 应用开发 】Linux 下应用层线程优先级管理解析
48 0