MIT6.S081之lock
Lock
说起锁,大家都应该不陌生吧,在资源竞争(race condition)的时候,我们会习惯性的给资源加上锁,以达到正确访问和正确修改的目的。 本次记录主要是以问答形式
why lock?
当我们的应用程序想要在multi-core的os上跑,并且想利用多核来提升性能的时候,我们就需要一些机制来保证多核访问不会对程序的正确性产生影响,也就是说这种机制应该是透明的。如何保证多核访问不会对程序的正确性产生影响呢,这里我们自然而然的想到,什么时候多核访问会导致程序的错误呢?
其实说起来,你要会用锁的前提是你要知道os是怎么调度process(thread),你要知道并行到底是一个怎么样的一个概念。
并行,在我看来可以分成两种,一种是进程间的并行,另外一种是线程中的并行。如果是进程中的并行,也需要一些锁来保证对共享数据结构(shared data structure)的正确访问,还有就是IPC(inter-Process communication)对缓冲区的读写问题。但是我们更关心的是单进程多线程的并行,这里就提及一下线程模型
简单聊聊线程模型的问题,我们可能在书上看到过,什么一对一线程模型,一对多线程模型,多对多线程模型,可能很多人都不理解为什么只有1:1模型才能充分利用多核cpu的优势,这里我来简单聊聊。
所谓一对一线程模型,就是指每一个user level thread 都有一个kernel level thread与其对应。Linux的thread实现就是一对一模型(),每一个用户线程都有一个task_struct结构(记住我们需要有task_struct结构cpu才能调度一个thread),这个时候在内核的视野中,他是看得到多个threads,并且有能力调度该进程的不同thread在不同的cpu上,并行执行。
(也就是说当其中一个用户thread被阻塞,或者睡眠是不影响其他thread执行的,cpu仍然可以调度其他的线程),其实在Linux内核的眼里,thread和process的概念其实是差不多的(可以说Linux里面没有thread,其实thread只是一个抽象的概念,指一个指令流),只是Linux支持了light-wight process 共享memory(不同的task_struct共享同一个进程空间),体现了thread的思想,所以我们习惯性地称这种抽象的封装为thread。 只有这种情况下,我们来聊一聊多线程模型,什么是多线程呢?我们先得说一下单线程模型,所谓单线程,在我眼里就是指程序的入口只有一个,程序必须串行的执行下去,而多线程模型就可以有多个入口,可以并行地执行多个执行流,这就是所谓的多线程模型。
所谓多对一线程模型,就是指多个user level threads 对应一个kernel level thread,在Linux的眼里,整个用户程序就只有一个thread,所以也只有一个kernel thread去与其对应,一旦用户的某一个thread被阻塞掉,整个kernel thread也会阻塞,所以就算该进程有其他可以执行的线程也会同时被阻塞掉。(用户的thread 对于内核来说是不可见的)
然后就是多对多模型 就是mix上述两个模型的,但其实应用起来会比较难,我没有太深的了解(不然的话Linux为什么不用multi->multi呢?
这里聊一下不同线程模型的优缺点,
- 1:1模型很棒!可以充分发挥多cpu的优势,就算某个thread 被阻塞掉也不影响其他thread的运行(除非thread之间有拓扑关系),缺点就在于需要内核来进行调度,因为对内核来说调度是按照一定规则执行的,用户thread无法干涉内核的调度策略,这就导致了内核不可能知道什么时候去调度是一个perfect time ,还有就是thread切换需要陷入内核,开销会比较大。
- 多对一模型,缺点:无法充分利用多cpu的优势,同一个时刻只有一个thread被执行,受限于单个cpu的执行效率。优点:被用于user level thread的实现,thread switch代价低,不需要陷入内核,基本上可以看成没有任何的switch的代价,其次就是可以自定义调度策略,因为user level thread是由用户指定调度策略的,所以你可以按照实际需求个性化制定调度策略 ,以满足快速响应等场景。
回到刚刚的问题,什么时候多核访问会导致程序的错误运行呢,或者说用锁可以避免什么错误呢?
- 锁可以避免丢失更新,也就是说当一个thread更新一个shared data structure 的时候,上锁可以保证这次更新是原子性的,要么更新成功,要么更新失败。不存在说更新一半,data structure就被别的thread更新的这种情况。回想我们之前在kalloc.c中的例子,丢失更新是指我们丢失了对于某个内存page在kfree函数中的更新。如果没有锁,在出现race condition的时候,内存page不会被加到freelist中。但是加上锁之后,我们就不会丢失这里的更新。
- 锁可以打包多种操作,使其具有原子性,这种区域我们称之为critical section,如果往critical section加锁的话,整个critical section的操作都会是原子性的。
- 锁可以维护共享数据结构的不变性。共享数据结构如果不被任何进程修改的话是会保持不变的。如果某个进程acquire了锁并且做了一些更新操作,共享数据的不变性暂时会被破坏,但是在release锁之后,数据的不变性又恢复了。
When to use lock
一个保守的规则来说就是,当一个数据结构可以被多个进程访问的时候,其中有一个进程会更新数据的时候,就会可能产生race condition的情况,这时候我们就应该使用锁来避免这种情况的发生。 其实类似于printf等函数也是需要锁来执行序列化输出的过程。
When you have multi locks in One section
这里的解决方案是,如果你有多个锁,你需要对锁进行排序,所有的操作都必须以相同的顺序获取锁。所以对于一个系统设计者,你需要确定对于所有的锁对象的全局的顺序
Lock type
Spinlock 自旋锁
先来聊一下自旋锁实现原理是怎么样的,我们要知道单纯依靠软件是无法实现原子性的(依靠软件实现的原子性代价极高),因为任何一个语句都可能被拆分为多个micro instruction执行,也就是说在一条语句执行到一半的时候就可能被打断,从而导致程序无法达到预期的效果。
1 2 3 4 5 6 7 8
// Mutual exclusion lock. struct spinlock { uint locked; // Is the lock held? // For debugging: char *name; // Name of lock. struct cpu *cpu; // The cpu holding the lock. };
这是xv6里面关于spinlock锁的结构,非常的简洁,但是我们更关心的是spinlock如何实现acquire和release的操作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
// Check whether this cpu is holding the lock. // Interrupts must be off. int holding(struct spinlock *lk) { int r; r = (lk->locked && lk->cpu == mycpu()); return r; } void acquire(struct spinlock *lk) { push_off(); // disable interrupts to avoid deadlock. if(holding(lk)) panic("acquire"); // On RISC-V, sync_lock_test_and_set turns into an atomic swap: // a5 = 1 // s1 = &lk->locked // amoswap.w.aq a5, a5, (s1) while(__sync_lock_test_and_set(&lk->locked, 1) != 0) ; // Tell the C compiler and the processor to not move loads or stores // past this point, to ensure that the critical section's memory // references happen strictly after the lock is acquired. // On RISC-V, this emits a fence instruction. __sync_synchronize(); // Record info about lock acquisition for holding() and debugging. lk->cpu = mycpu(); }
RISCV提供了硬件级别的swap原子操作指令(amoswap.w.aq),也就是说有一个swap的原子指令,swap只需要一条指令就能完成.从而保证了while循环的原子性。 (Test And Set CAS)procedure below
1 2 3 4 5
1. lock address 2. tmp <- *addr 3. *addr <- r1 4. r2<- tmp 5. unlock address
Sleep lock
Sleep lock的实现相对来说就比较简单一点,简单来说是利用sleep&&wakeup这两个system call来完成,
|
|
其实你只要了解p->chan && p->state 这两个成员的属性就可以了解sleep lock是怎么实现的了, 首先要理解sleep的函数签名 sleep(void* chan,struct spinlock * lk); 首先要清楚一点的是,这里的void* chan,可以是任意一个地址!比如一个structure process实例 的地址,一个structure sleeplock的地址,为什么用的是void* 这里要明白。你可以简单理解chan为导致sleep的原因,当你理解了这个概念之后,你就可以明白sleep && wakeup的精髓。 wakeup就是遍历所有的process,如果是因为chan的原因导致的sleep,则唤醒该进程(change p->state to runnable).More interesting things,其实sleep lock的实现是有借助spinlock的
**所谓的锁,在计算机里本质上就是一块内存空间。**当这个空间被赋值为1的时候表示加锁了,被赋值为0的时候表示解锁了,仅此而已。多个线程抢一个锁,就是抢着要把这块内存赋值为1。在一个多核环境里,内存空间是共享的。每个核上各跑一个线程,那如何保证一次只有一个线程成功抢到锁呢?你或许已经猜到了,这必须要硬件的某种guarantee。具体的实现如下。
Lock problem
1.Dead lock
one process acquire one lock(spinlock) twice!
two processes acquire two lock .
2. Locks VS Modularity
3.Locks VS performance
解决方法,先用全局锁,测试,慢慢将锁的范围变小。
Memory Ordering
instruction reordering is wrong in concurrent execution.__sync_synchronize(); 防止指令重排!提供了类似于barrier的区域,保证指令的顺序执行。