学习笔记:并发
自己构思一个场景,一群人去拿桌上的梨,规定一人拿一个。
一开始一群人蜂拥而上一起去拿,很容易出现两人同时摸到同一个梨的情况。
我们会想办法解决这种混乱,规定排队来取,一人拿完以后后面的人再拿。
科兴的早餐供应点就是如此,牛奶整体罗列,行政人员摆上一台刷卡机,然后不断从食物箱里把食桌摆满,同学们排队取。
但是为了整齐,我们牺牲了效率,后面排队的人明明可以做拿走牛奶刷卡的事情,但是却不得不在等待中玩手机。
并发的问题,解决的是类似的场景——要让事情变得有序而高效,快速又正确
这是并发最朴素的诉求。
我们学习一个技术时,务必搞清楚问题之背景和任务目标。技术的演进常常和环境,用户需求密切相关,环境可以是依赖的其它技术设施。
并发这个问题产生的背景大约是多任务系统和多核机器的出现。过去的单核进程调度策略已经无法满足多核情况,在多核情况,多个人同时伸手拿牛奶导致有人拿空的情况,是并发面对的资源竞争问题的典型场景。
由此并发要解决的问题:
- 资源竞争问题
- 多进程/线程的协调调度
线程(Thread) 单个进程的运行单元的抽象。
常常有人问进程和线程的区别。如果罗列特性,定义之类的区别,貌似区别甚大,但是本质上线程和进程只有一点细微的差别:
线程共享地址空间,能够访问相同的数据。而进程的地址空间是隔离的
执行点(程序计数器): 内存中存放程序指令的地方。一个线程对应一个执行点。
线程对应一个程序计数器,记录程序的起点。
PCB(Process Control Block)
TCB(Thread Control Block)
线程切换不需要切换当前使用的页表(内存虚拟化的概念)
** 第二个区别——栈
简单的进程一般只有一个栈,位于底部,多线程进程中,每个线程都有一个自己的栈,用来存储什么?——变量参数返回值之类的所谓 thread-local 的东西
工具 objdump 反汇编程序 valgrind purify
多线程竞争条件的一个例子
static volatile int counter = 0;
void * mythread(void *arg)
{
int i = 0;
for (int i = 0; i < 1e6; ++i) {
counter = counter + 1;
}
return NULL;
}
int main(int argc, char *argv[])
{
pthread_t p1, p2;
// 创建 p1
// 创建p2
pthread_join(p1, NULL);
pthread_join(p2, NULL);
printf("main: done with both (counter=%d)\n!, counter");
}
上面的过程里 counter = counter + 1;会出现竞争条件
如:
t1时刻,counter的值是100, 线程 p1 进入代码区。
t2时刻,counter加1
t3时刻,某中断发生,导致p1被调度切换,这时候是线程p2进入同样的代码区,并访问counter变量,因为上一步加1的结果还没有加到counter中,counter还是100
t4时刻,p2把counter加1后填到counter中,导致counter的值是101线程结束后操作系统调度运行 p1
t5时刻,p1继续将counter加1(在p1的栈内它还是100 + 1 = 101)后的值放到counter变量中,counter还是等于101
以上是一个race condition的例子
这个过程虽然两个线程都将共享数据 counter的值自增一次,但最终的结果并没有预期的加了 2 次,而是只有一次。
不可控的调度
中断随时发生,调度算法运行哪个线程有时候是无法预料的。
故 临界区的代码一定不可以由多个线程同时执行*
保证这点的方法叫做互斥
简而言之,互斥就是为了防止多线程同时访问临界区。
多线程编程一般性的协议
创建(create) 几乎所有语言必须支持,只是它们的语法各异
销毁(destroy)创建销毁是一对兄弟
等待完成 join
锁: 锁的运用一般是控制临界区代码只有一个线程执行,锁也有一对兄弟api,获取和释放,由于锁是一种系统资源,必须在用完之后归还
条件变量:线程之间处理竞争条件时一种互相交互的方式,线程等待另一个线程执行一些操作,就要用到条件变量
实现互斥的方案
- 原子操作。原子操作的概念很广,从操作系统到数据库,分布式系统都会看到。它的含义是指操作要么成功,要么失败,不能有中间状态。联想数据库的事务。
锁的实现
通常应用程序员不用太关心。但了解一下不是坏处。
锁的基本质量目标:
1.能够提供互斥作用
2.高效
最原始的方案:因为多线程在临界区出事故,总是由于操作系统的时钟中断引起了线程切换,很自然有人会想到在进到临界区的时候关闭时钟中断,这样线程切换就不会发生了。
操作系统控制资源的最高权力来自哪里?时钟中断,因此开关时钟中断的能力是一项很高权限的操作,应用程序中有大量临界区,意味着你要反复去动一个敏感操作。
设想,lock操作关闭时钟中断,恶意应用会借此操纵操作系统,它总是不调用unlock归还控制权该怎么办?
多处理器机器怎么办?时钟关闭也没有作用,调度会将线程切换到另一个CPU上
效率低。这个操作很慢是个事实
因此 关闭时钟中断不是一个可行方案。只有极少的情况会用这种方法来做互斥,而是操作系统本身自己才会这么做。
- 原子交换方法
- 自旋锁(spin lock)
自旋锁没有公平性保证,可能会导致饿死现象,性能也较差
-比较并交换 - 链接加载和条件式存储指令
- yield 让出cpu 避免自旋
- 队列:休眠+自旋
- 两段锁 Linux 这是自旋和休眠的折衷方案。
锁实现的内容相对比较底层。这里掌握一点索引型的知识就好。
我们知道主流的系统怎么实现锁就行。
总结,锁的实现方式一般是软硬结合的方式。
条件变量
线程需要等到一个条件(condition,通常是一个变量的值)满足之后就会运行,或者干点别的。
这个等待的场景就是条件变量的由来。
问题:多线程程序如何等待一个条件?
很朴素的想法:轮询,以一定间隔不断去看一下。就像一个小孩期待桃子长熟了可以吃,他每隔一小时就去看一下。这种方式有术语——成为自旋
volatile int done = 0;
void func(void *) {
// do something ;
done = 1; //done 改成 1
}
int main(int argc, char* argv[]) {
pthread_t c;
pthread_create(&c, NULL, func, NULL); //创建线程
while (done == 0) ; // 等
// func 越过上面的循环到了这里
}
非常低效 ! 而且 while (done == 0); 有希望运行很久无法结束,CPU被长时间占用
条件变量的实现:一个队列,当某些状态不满足时,线程将自己加入到队列里,等待这个条件,另外的线程当它改变了上面那个被等待的条件时它就唤醒一个或多个在等待的线程,通过发送信号的方式)
条件变量的两个基本操作: wait(), signal()
当线程睡眠时,调用wait()
当线程要唤醒其他线程的时候,调用signal
条件变量的使用场景,一般是多个线程具有协作的关系。
问题:发送信号时为什么持有锁比较好?
生产——消费模型(有界缓冲区)
经典问题。
producer 往一个公共区域放东西
comsumer 从公共区域拿东西。
如果 producer 和comsumer 分别被两个线程来处理,那么公共区域就是一个临界区,这里必须要做成线程安全。
cond_t cond; // 声明条件变量
mutex_t mutex;
void *producer(void *arg) {
int i = 0;
for (int i = 0; i < loops; ++i) {
pthread_mutex_lock(&mutex);
if (cond == 1)
pthread_cond_wait(&cond, &mutex);
put(i);
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);
}
}
void *consumer(void *arg) {
int i;
for (i = 0; i < loops; ++i) {
pthread_mutex_lock(&mutex);
if (count == 0) {
pthread_cond_wait(&cond, &mutex); // 没有数据就等数据
}
int tmp = get();
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);
}
}
以上代码有点问题,用while 代替 if 可以修复。是什么问题?
Mesa原则,条件变量等待变量变化总是用 while 而不是 if
死锁问题
死锁产生的条件
- 互斥。 意味着有资源的竞争,需要,需要加锁
- 持有并等待。指线程持有锁之后又在等待其它资源
- 非抢占。 线程获得的资源不能被抢,比如锁,线程一旦拿到,除非它自己释放,否则别的线程不能夺走
- 循环等待。 线程与线程之间存在一个环路在互相等待
上面四个条件可以产生死锁。有一个不满足就不能发生。
这提示我们两点:
编程中一旦创造了这四个条件,就要考虑会不会发生死锁。
另一方面启示我们怎么去回避死锁。
避免死锁的方案:
- 避免循环等待。1-3的条件常常看起来是必须的,至少很难有别的更好的方法取代,第一想到的可能是怎么避免循环等待。
全序锁-安排锁的顺序,总是先申请 L1, 再申请 L2