1. 优先级反转:一个必须解决的经典问题
如果你写过带锁的多线程程序,很可能遇到过一种让人头疼的情况:一个高优先级的线程,因为一个低优先级的线程占着锁,结果被“卡”在那里,眼睁睁看着一堆中等优先级的线程在CPU上欢快地跑。这种现象,在操作系统里有个专门的名字,叫优先级反转。我第一次在Pintos里实现线程调度时,就踩过这个坑。当时我写了个测试,高优先级线程H等着锁L,锁L被低优先级线程L拿着,结果一个中优先级线程M一直霸占CPU,导致H和L都动弹不得,系统就像死锁了一样。这显然违背了我们设计优先级调度的初衷——高优先级的任务应该被优先执行。
Pintos的原始调度器很朴素,就是个简单的轮转调度,压根没考虑优先级。我们实验三实现了基本的优先级抢占,也就是高优先级线程一旦就绪,就能立刻抢占正在运行的低优先级线程。但这还不够,因为它解决不了上面说的“锁竞争”导致的优先级反转。比如,H要拿锁,但锁在L手里,L因为优先级低,根本抢不到CPU去执行释放锁的代码,H也就永远等不到锁。这时候,优先级捐赠机制就该登场了。它的核心思想非常直观:既然H因为L拿着锁而阻塞,那我就临时把H的高优先级“借”给L,让L能尽快执行、释放锁,等锁释放了,再把优先级“还”回去。这样,H的等待时间就被大大缩短了。
听起来是不是有点像“劫富济贫”?但这里的“富”是高优先级,“贫”是低优先级,而且这只是临时借用,目的是为了整体效率。这个机制是很多实时操作系统(RTOS)的标配,因为它能保证高优先级任务的响应时间。在Pintos里实现它,不仅能帮你通过测试,更能让你深刻理解操作系统中资源竞争和调度是如何相互影响的。下面,我们就从最简单的场景开始,一步步拆解这个机制的实现。
2. 核心数据结构改造:让线程和锁“记住”更多
在动手写代码之前,我们得先想好怎么表示“捐赠”这个关系。Pintos原始的thread结构和lock结构太简单了,存不下我们需要的信息。这就好比你要管理一个借书系统,原来只记录谁借了书,现在还得记录谁在等这本书,以及这本书当前最高的“需求优先级”是多少。
首先,打开threads/thread.h,找到struct thread,我们需要添加几个成员。这里我直接给出我修改后的关键部分,并解释每个字段的用途:
struct thread
{
/* ... 原有的成员,如 tid, status, stack 等 ... */
int base_priority; /* 线程的原始(基础)优先级。 */
struct list locks; /* 此线程当前持有的所有锁的列表。 */
struct lock *lock_waiting; /* 线程正在等待的锁(最多一个)。 */
/* 用于优先级捐赠的字段 */
int donated_priority; /* 当前被捐赠的优先级(临时)。 */
struct list donors; /* 正在向本线程捐赠优先级的所有线程的列表。 */
};
我来解释一下:
base_priority:这是线程“与生俱来”的优先级,通过thread_set_priority设置的就是它。它是优先级变化的基准。locks:一个链表,记录这个线程目前手里拿着哪些锁。这很重要,因为一个线程可能持有多个锁,每个锁都可能被不同优先级的线程等待。lock_waiting:指针,指向这个线程当前正在苦苦等待的那个锁。一个线程同一时刻只能等一个锁,所以一个指针就够了。donated_priority:这是线程当前实际生效的优先级。它等于max(base_priority, 所有捐赠者中的最高优先级)。调度器看的正是这个值。donors:一个链表,里面是所有因为想拿本线程持有的锁而被阻塞的线程。我们需要这个列表来管理多个捐赠者。
接着,修改锁的结构体struct lock(在threads/synch.h中):
struct lock
{
struct thread *holder; /* 锁的持有者。 */
struct semaphore semaphore; /* 控制访问的二值信号量。 */
struct list_elem elem; /* 用于将锁插入持有者的 `locks` 列表。 */
int max_waiter_priority; /* 所有等待此锁的线程中的最高优先级。 */
};
新增的max_waiter_priority字段是关键。它记录了所有想获取这把锁的线程里,优先级最高的那个是多少。当持有者线程的donated_priority低于这个值时,我们就需要触发捐赠,把持有者的优先级提升到这个值。
数据结构定义好了,别忘了在threads/thread.c的init_thread()函数里初始化这些新字段。把t->base_priority和t->donated_priority都设为传入的优先级,list_init(&t->locks)和list_init(&t->donors)初始化链表,t->lock_waiting设为NULL。锁的初始化则在lock_init()里,把max_waiter_priority设为PRI_MIN(最低优先级)。
3. 简单捐赠:一对一的基础场景
简单捐赠是最直接的情况:一个高优先级线程H,一个低优先级线程L,一把锁。L拿着锁,H来申请,发现锁被占,于是H阻塞,并触发捐赠。
我们主要修改两个函数:lock_acquire(获取锁)和lock_release(释放锁)。先看lock_acquire,在调用sema_down(P操作)尝试获取信号量之前,我们需要加入捐赠逻辑:
void lock_acquire (struct lock *lock) {
ASSERT (lock != NULL);
ASSERT (!intr_context ());
ASSERT (!lock_held_by_current_thread (lock));
struct thread *current = thread_current();
struct thread *holder = lock->holder;
/* 如果锁已被占用,且当前线程优先级高于持有者,则准备捐赠 */
if (holder != NULL && current->priority > holder->donated_priority) {
/* 设置当前线程正在等待这把锁 */
current->lock_waiting = lock;
/* 将当前线程加入持有者的捐赠者列表 */
list_push_back(&holder->donors, ¤t->donor_elem); // 假设在thread结构里定义了donor_elem
/* 更新锁的最大等待者优先级 */
if (current->priority > lock->max_waiter_priority) {
lock->max_waiter_priority = current->priority;
}
/* 触发对持有者线程的优先级更新 */
thread_update_donated_priority(holder);
}
/* 执行P操作,可能会阻塞 */
sema_down(&lock->semaphore);
/* 成功获取锁后 */
lock->holder = thread_current();
/* 将锁加入当前线程的持有锁列表 */
list_push_back(&thread_current()->locks, &lock->elem);
/* 当前线程不再等待任何锁 */
thread_current()->lock_waiting = NULL;
}
这里的thread_update_donated_priority函数是我们需要实现的,它负责重新计算一个线程的donated_priority。它的逻辑是:遍历该线程持有的所有锁(locks列表),找出所有等待这些锁的线程中的最高优先级,然后和线程自己的base_priority比较,取最大值作为新的donated_priority。如果优先级发生了变化,还需要调用thread_yield_if_should(),看看是否需要立即让出CPU给更高优先级的线程。
那么,捐赠什么时候结束呢?在lock_release里。当线程释放一个锁时,它可能不再持有任何锁,或者它持有的其他锁的等待者优先级没那么高了,这时它的donated_priority应该降下来。
void lock_release (struct lock *lock) {
ASSERT (lock != NULL);
ASSERT (lock_held_by_current_thread (lock));
struct thread *current = thread_current();
/* 从当前线程的持有锁列表中移除这个锁 */
list_remove(&lock->elem);
/* 清除这个锁的等待者信息 */
lock->max_waiter_priority = PRI_MIN;
/* 重要:处理因为这个锁而发生的捐赠 */
/* 我们需要从当前线程的donors列表中,移除所有在等待这个锁的捐赠者 */
struct list_elem *e;
for (e = list_begin(¤t->donors); e != list_end(¤t->donors); ) {
struct thread *donor = list_entry(e, struct thread, donor_elem);
if (donor->lock_waiting == lock) {
/* 这个捐赠者是因为等这个锁而捐赠的,现在锁释放了,移除它 */
e = list_remove(e);
donor->lock_waiting = NULL;
} else {
e = list_next(e);
}
}
/* 更新当前线程的捐赠后优先级 */
thread_update_donated_priority(current);
/* 释放锁(V操作) */
lock->holder = NULL;
sema_up(&lock->semaphore);
}
这样,简单捐赠的流程就闭环了。H阻塞时把优先级捐给L,L快速执行完释放锁,H被唤醒并获得锁,L的优先级恢复原样。你可能会问,如果H在等锁的时候,又来了个更高优先级的线程H2也在等同一把锁怎么办?这就是我们接下来要讨论的多重捐赠。
4. 多重捐赠与递归捐赠:复杂依赖关系的处理
现实情况往往比一对一复杂。多重捐赠指的是,一个线程持有多把锁,每把锁都有不同的高优先级线程在等待。比如线程L持有锁A和锁B,线程H1等待锁A,线程H2等待锁B,且H2的优先级 > H1的优先级 > L的原始优先级。这时,L应该被提升到多少?显然是H2的优先级,因为它是所有等待者中最高的。我们的thread_update_donated_priority函数已经通过遍历所有锁处理了这种情况。
更棘手的是递归捐赠(也叫嵌套捐赠)。这是优先级捐赠机制中最精妙也最容易出错的部分。考虑这个链:线程L(低优先级)持有锁A,线程M(中优先级)持有锁B并等待锁A,线程H(高优先级)等待锁B。这就形成了一个捐赠链:H -> M -> L。H的优先级需要先捐赠给M,但因为M也在等锁A,所以M被捐赠后的新优先级(等于H的优先级)需要进一步捐赠给L。
这意味着捐赠操作不能只做一次,而需要沿着等待链递归地进行。我们之前lock_acquire里的代码只处理了直接捐赠(H捐给L)。为了处理递归捐赠,我们需要一个函数,能沿着lock_waiting指针构成的链,一路更新上去。
我通常实现一个叫donate_priority_chain的函数,它从一个线程开始,递归地更新其捐赠者链上所有线程的优先级。在lock_acquire中,当我们发现当前线程需要捐赠给持有者时,就不只是更新持有者,而是调用这个链式更新函数:
void donate_priority_chain(struct thread *t) {
int new_priority = t->priority; // 当前线程的优先级
struct lock *waiting_lock = t->lock_waiting;
struct thread *holder;
while (waiting_lock != NULL && new_priority > waiting_lock->holder->donated_priority) {
holder = waiting_lock->holder;
// 将当前线程加入持有者的捐赠者列表(如果尚未加入)
if (!list_elem_is_in_list(&t->donor_elem, &holder->donors)) {
list_push_back(&holder->donors, &t->donor_elem);
}
// 更新锁的最大等待者优先级
if (new_priority > waiting_lock->max_waiter_priority) {
waiting_lock->max_waiter_priority = new_priority;
}
// 更新持有者的捐赠后优先级
thread_update_donated_priority(holder);
// 继续向上追溯:持有者是否也在等别的锁?
waiting_lock = holder->lock_waiting;
// 下一轮循环,用holder的(可能已被提升的)优先级去捐赠给它的持有者
new_priority = holder->donated_priority;
}
}
然后在lock_acquire中,如果当前线程需要捐赠,就调用donate_priority_chain(current)。这样,无论捐赠链有多长,都能一次性地将最高优先级传递到链的源头。
处理递归捐赠时,数据结构的设计尤为重要。每个线程的lock_waiting指针构成了一个等待图(实际上是一个森林,因为一个线程只能等一个锁)。我们必须小心避免循环依赖,不过在Pintos的锁机制下,循环等待会导致死锁,这是另一回事,优先级捐赠机制本身不会引入循环。
5. 捐赠的撤销与优先级恢复
有借有还,再借不难。优先级捐赠是临时的,当锁被释放,捐赠关系解除,线程的优先级应该恢复。但恢复到哪里?不是简单地回到base_priority。因为一个线程可能还持有其他锁,这些锁可能还有其他的等待者。
这就是为什么我们需要thread_update_donated_priority函数。它在两种情况下被调用:一是当有新的捐赠发生时(在donate_priority_chain中),二是当锁被释放、捐赠者被移除时(在lock_release中)。它的职责是根据当前状态,重新计算正确的donated_priority。
它的算法可以这样描述:
- 初始化
effective_priority = base_priority。 - 遍历该线程持有的每一把锁(
locks列表)。 - 对于每把锁,查看它的
max_waiter_priority。 - 如果
max_waiter_priority > effective_priority,则更新effective_priority = max_waiter_priority。 - 遍历该线程的捐赠者列表(
donors),找出其中优先级最高的(这一步其实和遍历锁是等价的,因为捐赠者信息也反映在锁的max_waiter_priority上,但双重检查更安全)。 - 将
donated_priority设置为计算出的effective_priority。 - 如果
donated_priority发生了变化,并且当前线程是就绪或运行状态,调用thread_yield_if_should()检查是否需要重新调度。
在lock_release中,我们移除了因该锁而捐赠的线程后,调用thread_update_donated_priority,线程的优先级就会自动下降到剩余锁所要求的最高优先级,或者其原始优先级。
这里有个细节:当线程通过thread_set_priority设置新的base_priority时,如果新设置的优先级比当前的donated_priority还高,那么donated_priority应该立即更新为这个更高的值。如果新设置的优先级比当前donated_priority低,则不能立即降低donated_priority,因为可能还有捐赠在生效。必须等到所有捐赠解除(即释放所有锁)后,donated_priority才能降回新的base_priority。这需要在thread_set_priority函数中妥善处理。
6. 与调度器的整合:何时触发重新调度
优先级捐赠改变了线程的优先级,但这只是更新了数据。要让它真正起作用,必须和调度器联动,确保CPU上运行的一直是当前所有就绪线程中优先级最高的那一个。
Pintos的调度点主要有:线程主动让出(thread_yield)、线程阻塞(sema_down)、线程被唤醒(sema_up)、以及线程创建完成时。我们修改了优先级,就需要在这些关键点检查是否需要重新调度。
我通常在thread_update_donated_priority函数的最后,如果发现优先级被提升,就调用一个内部函数thread_check_preemption。这个函数会比较当前运行线程的优先级和就绪队列中第一个线程的优先级(因为我们的就绪队列在实验三已经改造成按优先级排序的)。如果就绪队列头部的线程优先级更高,就调用thread_yield()主动让出CPU。
static void thread_check_preemption(void) {
if (!list_empty(&ready_list)) {
struct thread *highest_ready = list_entry(list_front(&ready_list), struct thread, elem);
if (highest_ready->donated_priority > thread_current()->donated_priority) {
thread_yield();
}
}
}
特别要注意sema_up(V操作)的修改。当一个锁被释放,信号量增加,会唤醒等待队列中的一个线程。在原始的Pintos中,这个等待队列是FIFO。但现在我们实现了优先级调度和捐赠,这个等待队列也必须按优先级排序,确保每次唤醒的都是等待者中优先级最高的那个。这需要修改sema_up和sema_down中操作waiters列表的部分,使用list_insert_ordered按优先级插入,并在sema_up中从队列头部取出线程来唤醒。
7. 实战:代码修改步骤与调试技巧
理论说了一大堆,最后我们来点实在的,看看具体要在哪些文件里动刀子。主要修改集中在三个文件:threads/thread.h, threads/thread.c, 和 threads/synch.c。
在thread.h中:
- 扩展
struct thread,添加前面提到的base_priority,locks,lock_waiting,donated_priority,donors等字段。别忘了在thread.c的init_thread里初始化它们。 - 声明新的辅助函数,如
thread_update_donated_priority(struct thread *t),thread_donate_to(struct thread *donor, struct thread *donee)(如果你单独实现这个),以及thread_check_preemption(void)。
在synch.h中:
- 扩展
struct lock,添加max_waiter_priority字段和用于插入holder->locks列表的elem。
在synch.c中:
- 修改
lock_acquire:在sema_down前,如果锁被占用,调用捐赠逻辑(可能是donate_priority_chain)。 - 修改
lock_release:释放锁时,清理相关捐赠信息,并调用thread_update_donated_priority。 - 修改
sema_up和sema_down:确保信号量的waiters列表按优先级排序。这需要你写一个比较函数,比如sema_priority_cmp。 - 实现
cond_signal时,如果需要,也要考虑等待队列的优先级排序。
在thread.c中:
- 实现
thread_update_donated_priority函数。这是核心逻辑,务必仔细。 - 修改
thread_set_priority:设置base_priority,并可能触发thread_update_donated_priority和重新调度。 - 修改
thread_get_priority:让它返回donated_priority而不是原始的priority。 - 确保
thread_yield和thread_unblock在将线程插入就绪队列时,使用的是按donated_priority排序的函数。
调试这种涉及并发和状态变化的代码非常挑战。我的经验是:
- 从简单测试开始:先跑
priority-donate-one这个最简单的测试,确保简单捐赠能过。 - 善用
printf和ASSERT:在捐赠、更新优先级、释放锁的关键路径上打印日志,观察优先级是如何传递和恢复的。使用ASSERT来检查不变式,比如一个线程的donated_priority不应该小于其base_priority,或者一个锁的holder必须在其locks列表中。 - 理解每一个测试用例:Pintos提供的测试用例(如
priority-donate-multiple,priority-donate-nest,priority-donate-chain)分别覆盖了多重捐赠、递归捐赠等场景。仔细阅读测试代码,理解它期望的线程执行顺序和优先级变化,这能帮你快速定位逻辑错误。 - 注意递归深度:递归捐赠的实现要小心栈溢出。Pintos的线程栈不大,如果捐赠链很长(测试一般不会太长),递归函数可能有问题。可以考虑用迭代循环代替递归来实现
donate_priority_chain。
实现优先级捐赠是Pintos项目里一个里程碑。它迫使你深入思考锁、优先级和调度三者之间微妙的相互作用。当你看到所有优先级捐赠测试都通过时,那种成就感是实实在在的。这不仅仅是完成了一个课程实验,更是亲手构建了一个能处理真实世界并发问题的调度器核心机制。

216

被折叠的 条评论
为什么被折叠?



