前情提要
我们前面在实现线程时导致了对临界区资源的访问问题,现在我们实现一个锁来保证数据的一致性。本节的代码较为简单,但是理解上还是需要一点时间的,也建议看一下相关的操作系统课程。
一、锁的性质
- 互斥性(Mutual Exclusion):锁能够确保在同一时刻只有一个线程能够持有它。这意味着当一个线程获取了锁之后,其他线程必须等待该线程释放锁之后才能继续执行。
- 可重入性(Reentrancy):一个线程可以多次获取同一个锁,而不会出现死锁或其他异常情况。这使得线程在持有锁的情况下可以再次获取同一个锁,而不会被自己所持有的锁所阻塞。
- 互斥锁还具有阻塞特性,即当线程尝试获取一个已被其他线程持有的锁时,它会被阻塞,直到锁可用。
- 公平性:某些锁实现可能具有公平性,即它会按照请求锁的顺序来分配锁的所有权,以避免某些线程长期饥饿的情况。
二、几种典型的锁
2.1、读写锁
- 多个读者可以同时进行读
- 写者必须互斥(只允许一个写者写,也不能读者写者同时进行)
- 写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)
2.2、互斥锁
一次只能一个线程拥有互斥锁,其他线程只有等待
互斥锁是在抢锁失败的情况下主动放弃CPU进入睡眠状态直到锁的状态改变时再唤醒,而操作系统负责线程调度,为了实现锁的状态发生改变时唤醒阻塞的线程或者进程,需要把锁交给操作系统管理,所以互斥锁在加锁操作时涉及上下文的切换。互斥锁实际的效率还是可以让人接受的,加锁的时间大概100ns左右,而实际上互斥锁的一种可能的实现是先自旋一段时间,当自旋的时间超过阀值之后再将线程投入睡眠中,因此在并发运算中使用互斥锁(每次占用锁的时间很短)的效果可能不亚于使用自旋锁
2.3、条件变量
互斥锁一个明显的缺点是他只有两种状态:锁定和非锁定。而条件变量通过允许线程阻塞和等待另一个线程发送信号的方法弥补了互斥锁的不足,他常和互斥锁一起使用,以免出现竞态条件。当条件不满足时,线程往往解开相应的互斥锁并阻塞线程然后等待条件发生变化。一旦其他的某个线程改变了条件变量,他将通知相应的条件变量唤醒一个或多个正被此条件变量阻塞的线程。总的来说互斥锁是线程间互斥的机制,条件变量则是同步机制。
2.4、自旋锁
如果进线程无法取得锁,进线程不会立刻放弃CPU时间片,而是一直循环尝试获取锁,直到获取为止。如果别的线程长时期占有锁,那么自旋就是在浪费CPU做无用功,但是自旋锁一般应用于加锁时间很短的场景,这个时候效率比较高。
实际上,在单核心处理器上,自旋锁通常是没有意义的,单核心处理器只能同时执行一个执行流,不会存在并发问题
三、信号量
我们的锁是用信号量来实现的,因此必须讲清楚信号量。信号量的概念是计算机界著名巨佬Dijkstra提出的。在计算机中,信号量就是个0以上的整数值,当为0时表示已经没有可用的信号,或者说条件不在允许有信号通过。信号量是一种同步机制。
我们之前的多个线程导致的打印错误就是由于多个线程之间没有实现同步。如果可以规定有线程在访问公共资源,线程就无法访问,就不会出任何问题。
信号量就是个计数器,它的计数值是自然数,用来记录所积累信号的数量。这里的信号是个泛指,取决于信号量的实际应用环境。可以认为是商品的剩余量、假期剩余的天数、账号上的余额等,总之,信号的意义取决于您用信号量来做什么,信号量仅仅是一种程序设计构造方法。
Dijkstra是荷兰人,他用P、V操作来表示信号量的减、增,这两个都是荷兰语中的单词的缩写。P是指Proberen,表示减少,V是指单词Verhogen,表示增加。不过我们也可以用up和down
增加操作up包括两个微操作。
(1)将信号量的值加1。
(2)唤醒在此信号量上等待的线程。
减少操作down包括三个子操作。
(1)判断信号量是否大于0。
(2)若信号量大于0,则将信号量减1。
(3)若信号量等于0,当前线程将自己阻塞,以在此信号量上等待。
信号量的初值代表是信号资源的累积量,也就是剩余量,若初值为1的话,它的取值就只能为0和1,这便称为二元信号量,我们可以利用二元信号量来实现锁。
在二元信号量中,down操作就是获得锁,up操作就是释放锁。我们可以让线程通过锁进入临界区,可以借此保证只有一个线程可以进入临界区,从而做到互斥。大致流程为:
1、线程A进入临界区前先通过down操作获得锁(我们有强制通过锁进入临界区的手段),此时信号量的值便为0。
2、后续线程B再进入临界区时也通过down操作获得锁,由于信号量为0,线程B便在此信号量上等待,也就是相当于线程B进入了睡眠态。
3、当线程A从临界区出来后执行up操作释放锁,此时信号量的值重新变成1,之后线程A将线程B唤醒。
4、线程B醒来后获得了锁,进入临界区。
四、线程的阻塞与唤醒
使用信号量就涉及线程的阻塞与唤醒,我们看看怎么实现
c
/* 当前线程将自己阻塞,标志其状态为stat. */
void thread_block(enum task_status stat) {
ASSERT(((stat == TASK_BLOCKED) || (stat == TASK_WAITING) || (stat == TASK_HANGING)));
enum intr_status old_status = intr_disable();
struct task_struct* cur_thread = running_thread();
cur_thread->status = stat; // 置其状态为stat
schedule(); // 将当前线程换下处理器
intr_set_status(old_status);
}
/* 将线程pthread解除阻塞 */
void thread_unblock(struct task_struct* pthread) {
enum intr_status old_status = intr_disable();
if (pthread->status != TASK_READY) {
pthread->priority = 4; // 将当前线程的优先级置位4,使其优先得到调度
pthread->status = TASK_READY;
mlfq_push_wspt(pthread);
}
intr_set_status(old_status);
}
阻塞就是修改当前线程的pcb中的线程状态,然后开启调度。
解除阻塞就是将阻塞的线程状态修改为就绪态,修改其优先级,使其先得到调度。
五、互斥锁
这里我们实现两种锁,第一种是互斥锁
我们先看第一部分,信号量的实现
c
/* 信号量结构 */
struct semaphore {
uint8_t value;
struct list waiters;
};
/* 初始化信号量 */
void sema_init(struct semaphore* psema, uint8_t value) {
psema->value = value; // 为信号量赋初值
list_init(&psema->waiters); // 初始化信号量的等待队列
}
/* 信号量down操作 */
void sema_down(struct semaphore* psema) {
enum intr_status old_status = intr_disable();
// value == 0 表示被别人持有了这个锁,如果被别人持有,那么阻塞当前线程,将其加入到信号等待队列
while (psema->value == 0) {
// 确保当前线程不在等待队列中
ASSERT(!elem_find(&psema->waiters, &running_thread()->general_tag));
// 加入等待队列,阻塞自己
list_append(&psema->waiters, &running_thread()->general_tag);
thread_block(TASK_BLOCKED);
}
// value为1,则表示当前线程获得了锁
psema->value--;
intr_set_status(old_status);
}
/* 信号量的up操作 */
void sema_up(struct semaphore* psema) {
enum intr_status old_status = intr_disable();
// 执行up操作相当于释放锁,如果释放的过程中发现线程队列里面还有等待的线程,那么直接唤醒他
if (!list_empty(&psema->waiters)) {
struct task_struct* thread_blocked = elem2entry(struct task_struct, general_tag, list_pop(&psema->waiters));
thread_unblock(thread_blocked);
}
psema->value++;
intr_set_status(old_status);
}
首先是down操作,down操作是获得锁的过程,我们看下面的这个部分
c
while (psema->value == 0) {
// 确保当前线程不在等待队列中
ASSERT(!elem_find(&psema->waiters, &running_thread()->general_tag));
// 加入等待队列,阻塞自己
list_append(&psema->waiters, &running_thread()->general_tag);
thread_block(TASK_BLOCKED);
}
// value为1,则表示当前线程获得了锁
psema->value--;
这一部分是精髓,第一次进到while,如果当前的信号量是0,那么直接将当前的线程加入等待队列,阻塞当前线程,并开始调度其他线程。记住,当线程被唤醒时,是从 thread_block
处继续执行的。
然后我们看up操作,up操作是释放锁的过程,如果当前等待队列中有线程,那么解除这个线程的阻塞态,并将信号量加1。
举个例子
1、假设A线程获得锁的时候,B线程正在占有锁,所以,A线程被阻塞,A线程执行到了 thread_block(TASK_BLOCKED);
,此时信号量为0
2、B线程执行完毕,B线程释放锁,释放锁的过程中发现等待队列中还有A线程,于是将A线程解除阻塞,放入等待队列,B线程将信号量+1,此时信号量为1
3、B线程从就绪态变为运行态时,是从thread_block(TASK_BLOCKED);
处继续运行的,下一步是判断信号量,此时信号量为1,跳出循环,信号量再减1,B线程从此获得锁。
信号量的实现将清楚了就很好看单纯的锁了
c
/* 锁结构 */
struct lock {
struct task_struct* holder; // 锁的持有者
struct semaphore semaphore; // 用二元信号量实现锁
uint32_t holder_repeat_nr; // 锁的持有者重复申请锁的次数
};
/* 初始化锁plock */
void lock_init(struct lock* plock) {
plock->holder = NULL;
plock->holder_repeat_nr = 0;
sema_init(&plock->semaphore, 1);
}
/* 获取锁plock */
void lock_acquire(struct lock* plock) {
/* 排除曾经自己已经持有锁但还未将其释放的情况。*/
if (plock->holder != running_thread()) {
sema_down(&plock->semaphore); // 对信号量P操作,原子操作
plock->holder = running_thread();
plock->holder_repeat_nr = 1;
}
else {
plock->holder_repeat_nr++;
}
}
/* 释放锁plock */
void lock_release(struct lock* plock) {
ASSERT(plock->holder == running_thread());
if (plock->holder_repeat_nr > 1) {
plock->holder_repeat_nr--;
return;
}
ASSERT(plock->holder_repeat_nr == 1);
plock->holder = NULL; // 把锁的持有者置空放在V操作之前
plock->holder_repeat_nr = 0;
sema_up(&plock->semaphore); // 信号量的V操作,也是原子操作
}
结束语
我们本节实现了一个互斥锁,下一节将使用这个锁写一个键盘和屏幕的驱动程序,用来使得程序对临界区资源的访问有序。
老规矩,本节的代码地址:https://github.com/lyajpunov/os