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

posted on 2013-05-11 19:30  胡永光  阅读(203)  评论(0编辑  收藏  举报

导航