为了操作系统项目,研读了一下Operating Systems: Three Easy Pieces中关于锁的那一章,记录笔记!
首先要明确,成为一个锁所需具备的条件有:
1.提供互斥功能
2.进程调度公平
3.使用锁时的性能
下面看一下关于锁的实现的迭代过程
1 禁用临界区中断
void lock() {
DisableInterrupts();
}
void unlock() {
EnableInterrupts();
}
是用在单处理器操作系统中的,一个进程在执行就禁止其他进程来打断。很简单,但是缺点也很多:
1.若贪心程序或恶意程序占着锁不放,唯一能做的就是重启系统了;
2.不适用于多处理器系统,不同进程运行在不同CPU上,若进程1和进程2都想进入同一个临界区,CPU1上的进程1禁用了中断,但CPU2上并没有被禁用,还是可以进入临界区;
3.长时间禁止中断,可能会丢失中断现场;
4.效率低下。
2 基于标志变量的锁
typedef struct __lock_t { int flag; } lock_t;
void init(lock_t *mutex) {
// 0 -> lock is available, 1 -> held
mutex->flag = 0;
}
void lock(lock_t *mutex) {
while (mutex->flag == 1) // TEST the flag ;
// spin-wait (do nothing)
mutex->flag = 1;
}
void unlock(lock_t *mutex) {
mutex->flag = 0;
}
现在假设进程1进入了临界区,占用了锁。进程2此时也想进入临界区,只能在while循环中等待,直到进程1调用unlock把标志置为0,才跳出循环,进入临界区。
这种方法不能正确提供互斥功能,在效率上也不高。
1.互斥功能
从flag=0开始。进程1调用lock,在跳出循环,还没有把flag写成1的那一刻,被进程2打断;进程2调用lock(),把flag写为1,返回中断;进程1继续把flag写为1,这样两个进程都进入临界区了。
2.效率
CPU一直在执行循环,其他什么事也做不了。
3 Test and Set
int TestAndSet(int *old_ptr, int new) {
int old = *old_ptr; // 获取标志的旧值
*old_ptr = new; // 把新值写到内存里
return old; // 返回旧值
}
typedef struct __lock_t { int flag;} lock_t;
void init(lock_t *lock) {
// 0: lock is available, 1: lock is held
lock->flag = 0;
}
void lock(lock_t *lock) {
while (TestAndSet(&lock->flag, 1) == 1) ; // spin-wait (do nothing)
}
void unlock(lock_t *lock) {
lock->flag = 0;
}
为什么叫Test and Set呢,是因为让你在Set新值的时候可以Test一下旧值!(注意:TestAndSet是属于硬件支持,以原子方式执行,这里只是通过C语言代码片段去解释TestAndSet的作用。)
从flag=0开始,进程1调用lock(),首先获取到旧值为0,再把新值1赋给标志变量,把旧值0返回,这样进程1就占用了锁。进程2想要获取锁,拿到的旧值是1,就会一直循环等待。
注意在单处理器操作系统中,自旋锁需要搭配抢占式的调度程序使用,不然的话单处理器会一直沉迷循环,无法自拔。
对自旋锁的特性进行分析
1.满足提供互斥功能
2.无法保证公平性(如果一个进程一直占着锁不放,会出现饥饿现象)
3.在单处理器系统下效率极低;在进程数和CPU数相同的情况下,效率还可以接受
4 基于睡眠唤醒的锁
typedef struct __lock_t {
int flag;
int guard;
queue_t *q;
} lock_t;
void lock_init(lock_t *m) {
m->flag = 0;
m->guard = 0;
queue_init(m->q);
}
void lock(lock_t *m) {
while (TestAndSet(&m->guard, 1) == 1) ; // 以防下面的操作还没整完,就有新的进程来打断
if (m->flag == 0) {
m->flag = 1; // lock is acquired
m->guard = 0;
}
else { // 若锁已经被占用了
queue_add(m->q, gettid()); // 加入等待队列
/* 下面两句的先后顺序重要 */
m->guard = 0;
park(); // 让等待进程睡眠
}
}
void unlock(lock_t *m) {
while (TestAndSet(&m->guard, 1) == 1) ; // 作用同上
if (queue_empty(m->q))
m->flag = 0; // 没有进程在占用锁了
else
unpark(queue_remove(m->q)); // 唤醒睡眠进程
m->guard = 0;
}
在进程调度公平性方面,在此处通过等待队列来控制下一个获取锁的进程,以此避免了饥饿现象;在性能方面,一套操作做完了就把m->guard置零,CPU不会一直被自旋锁占用,但是无法完全避免自旋,如果你当前一整套拿锁的操作还没做完,就被另一个进程打断了的话,那个来打断的进程还是会在那里自旋的。