有10天没有更新博客了, 主要是因为博主最近忙于工作内部调动的事情. 到新公司按照博主我的要求, 电脑是肯定要好好重新折腾一番的. 写这篇博客的时候, 还有两天就摇号了, 相比内部调动, 还是女儿摇号的事情大.
在之前知道了操作系统的互斥锁的实现. 不过使用这些锁的线程, 行为目的都一样, 就是为了争抢. 在现实中, 线程不光争抢同一个变量, 可能还会出现线程协作的情形, 即一个线程不是等待去读写共享变量, 而是等待某一个或者某些线程工作完毕, 然后再执行工作.
对于需要协作的变量, 使用之前的互斥锁显然就不太合理了.
- 没有条件变量之前的协作 - 主线程等待子线程
- 条件变量
- 再看生产者与消费者问题
没有条件变量之前的协作 - 主线程等待子线程
一种比较常见的操作就是主线程要执行某个任务的时候, 启动一个子线程或者若干个子线程去执行任务, 然后自己继续执行其他的任务, 到了一定的时刻, 就需要等待子线程执行任务完成之后再继续向下执行.
由于主线程可能还负责其他工作, 不能简单的使用一个共享变量锁, 大家都去修改这个状态来表示工作是否完成, 这样可能会让主线程阻塞, 在没有条件变量的时候, 假如只有一主一副两个线程如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
void *mythread(void *arg) {
int i = 0;
for (i = 0; i < 100; i++) {
printf("This is child thread working...\n");
}
return NULL;
}
int main(int argc, char *argv[]) {
printf("parent thread starts\n");
pthread_t c;
printf("parent thread starts a child thread to do something\n");
pthread_create(&c, NULL, mythread, "A");
// need to wait child thread
printf("child threads ends\n");
return 0;
};
这里代码的目的是主线程需要等待副线程的工作完成之后才能继续工作. 如果直接执行这个代码, 很显然主线程不会等待副线程. 要实现这个效果, 可以发现需要设置某个条件, 很容易就可以想到, 加上一个标记, 副线程干完活之后设置这个标记, 主线程去检查就可以了:
代码就可以修改成:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
void *mythread(void *arg) {
int i = 0;
for (i = 0; i < 100; i++) {
printf("This is child thread working...\n");
}
*(int*) arg = 1;
return NULL;
}
int main(int argc, char *argv[]) {
int completed = 0;
printf("parent thread starts\n");
pthread_t c;
printf("parent thread starts a child thread to do something\n");
pthread_create(&c, NULL, mythread, &completed);
// need to wait child thread
while (completed != 1) {}
printf("child threads ends\n");
return 0;
};
观察代码红色部分, 子线程在完成工作后会将completed设置为1. 主线程会一直自旋在判断completed是否为1上, 只有通过了才会继续执行. 这个代码可以实现主线程等待副线程的效果.
在之前已经说过, 不加控制的自旋等待共享变量, 其实是会出问题的. 这里很显然只有副线程会修改completed变量, 这样父线程即使在读取while的时候出现失误, 下一次读取的时候还是会读到正确结果. 因此可以保证子线程任务执行完之后父线程才会执行.
但是如果有多个线程呢? 如果只有一个锁共享变量, 则难以知道到底谁完成了工作, 或者可以使用锁共享变量, 将其设置为一个数量, 计数每次完成工作的线程数量. 然而如果多个线程不是像文中这样一次性运行, 而是每到某个阶段就必须同步一次或者协作一次, 那么就要设置不固定的多个变量.
外加还有一个自旋, 自旋非常浪费CPU时间, 而且不加锁的自旋就是错误的多线程方式. 所以就需要引入更好的方式, 就是子线程工作的时候, 如果父线程需要等待子线程了, 就干脆让父线程休眠, 子线程完成工作了再去叫醒父线程.
条件变量
条件变量最早还是D老大提出的私有信号量的概念. 后来变成了条件变量.
条件变量可以像上一篇文章里提到的成熟的锁, 即自旋几下就去休眠的锁自带的队列一样, 条件变量内部带有一个队列和一个互斥变量.
当某些条件满足的时候, 线程就可以在条件变量中的互斥变量上进行操作, 然后发一个信号, 启动队列中的等待中(休眠)的线程; 如果条件没满足, 线程就会让自己休眠然后将自己加入到条件变量的队列中.
回想一下最开始的线程API, 主线程等待副线程的时候并没有像刚才一样使用自旋, 而是使用了pthread_join(new_thread, NULL);
, 其实我们就是在学习这个join的机制.
有了条件变量之后, 子线程在完成工作之后, 就需要在条件变量上发一个信号. 而父线程在启动子线程之后, 就需要等待条件变量. 因此会多出两个方法. 这两个方法的C伪代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
//额外标记, 用于可能产生冲突的地方
int done = 0;
//互斥变量
pthraed_mutex_t m = PTHREAD_MUTEX_INITIALIZER
//条件变量
pthread_cond_t c = PTHREAD_COND_INITIALIZER
void job_complete(){
lock(&m);
done = 1;
signal(&c);
unlock(&m);
}
void join(){
lock(&m);
while (done == 0) {
wait(&c, &m);
}
unlock(&m);
}
首先由一个互斥锁保护了一个done变量, 子线程在完成工作的时候, 会去获取互斥锁, 然后修改done=1, 之后会执行 signal(&c) 方法, 这个方法会唤醒条件变量内部队列上所有休眠的线程.
join()方法也是先获取锁, 如果条件没有满足, 就同时等待c和m两个锁, 根据上一篇文章的内容, 我猜到了这个方法会释放互斥锁m, 然后将自己加入到条件变量的队列中, 同时让出CPU. 当从其中恢复的时候, 又会持有互斥锁m.
这个方法与上一章讲的等待锁的实现有些类似, 可以看到, 条件变量的底层实际上还是依赖更底层的锁机制, 包括一个由互斥锁保护的done变量.
这只是伪代码, 在多线程API中, pthread_join内部的本质, 就是一个条件变量, 所以才会等待子线程结束. 要等待哪个, 就传入等待的线程号即可, 这就说明, 当执行这个方法的时候, 一定在哪里有一个互斥变量及一个队列来放入自己和某个线程, 然后执行类似伪代码这样的代码.
再看生产者与消费者问题
我当时读APP的时候, 关于并发其实没有介绍锁的实现内容, 当然也就不知道其底层的汇编指令, 只是介绍了信号量与P操作和V操作. 现在看来, CSAPP这书对于边界的把握是非常准确的. 因为CSAPP面向的就是应用程序员, 虽然介绍了计算机的硬件基础, 但是对于实际代码部分, 很好的控制了边界, 没有深入到操作系统.
如果说使用互斥锁的线程可以看成是"同类"的话, 生产者和消费者中的两类线程, 显然就不是同类, 如果仅仅对缓冲区的操作使用互斥锁, 等待线程互相竞争的话, 如果两类线程的比例悬殊, 可能会产生长时间等待的问题.
思考下列问题: 一个长度为100的缓冲区, 有99个生产者线程和1个消费者线程. 假如没有条件变量, 大家全靠争抢缓冲区的一把大锁来读写.
当生产者写满之后, 消费者线程得到的机会依然很小, 这样就可能导致总是生产者来争抢锁, 但是抢到锁之后又让出去, 而消费者一直无法消费的情况.
有了条件变量之后, 生产者和消费者就可以等待两个不同的条件变量, 在更新共享缓冲区之后, 消费者去唤醒生产者, 生产者唤醒消费者; 如果无法更新缓冲区, 就在自己的条件变量上睡眠, 这样就可以实现二者的协作了, 即使两者的数量悬殊, 也绝对不会产生问题.
关于这个代码其实没有什么好放的, 就是在原本对共享缓冲区的操作代码中, 两个类型的线程在wait()的时候等待在自己的条件变量上, 在signal()的时候唤醒对方的条件变量中等待的线程即可.
之后锁和条件变量, 就抽象成了信号量.
如果信号量在0-1之间变化, 单个信号量就是一个互斥锁. 如果信号量在0-N之间变化, 那么一组信号量就可以用来当成一组线程的条件变量, 使用多个信号量, 就可以来实现线程协作. 之后就来看看信号量.