本次lab的核心是xv6系统的线程调度的过程,首先在视频课程中讲解了用户进程之间切换的流程,是通过时钟中断机制来实现的,具体的流程如下:
1、假设现在有两个用户进程A和B,首先A进程因为时钟中断由traponline进入usertrap函数,执行进程A的内核线程(视频中一直把每个进程划分为内核线程和用户线程,其实这有点不符合我们学习的关于进程和线程的定义。事实上,xv6系统中的每个进程只有一个线程,视频中所谓的线程,其实是一种抽象的概念,为了区分不同进程直接用户态的内核态执行的程序)。通常情况下一个线程对应了一个入口函数,多个线程在不同的CPU或同一个CPU上分时运行,实现了CPU的分时复用,由于时间片很短,在宏观上来看,就像是多个线程之间在并行运行。当然如果是多核CPU,确实有可能实现真正的并发。
2、进入usertrap函数之后,根据时钟中断的处理程序,会调整进程的执行状态、保存当前的寄存器信息,寄硬件上下文,这里只需要保存employee saved寄存器,而不需要保存所有的寄存器,因为其余的employer saved寄存器已经被调用程序保存在自己的栈中。
3、线程的切换本质上是CPU寄存器的切换,改变了相关寄存器的值,就可以改变程序的执行流,从而控制程序在指定的位置开始执行。其中有两个重要的寄存器ra 和 sp ,分别保存了返回的地址和栈指针,因为每个线程应该执行在自己的栈上面,而不应该覆盖原来的栈。
4、在用户进程切换的时候,会经历一个中间节点,scheduler内核线程,该线程会找到runalbe的进程,并将其恢复到CPU上面,每一个cpu核会有自己的context用于保存scheduler线程的上下文。
swith函数:该函数由汇编语言编写,用于将当前的CPU的employee save寄存器保存在old进程的context属性中,并将new进程的context内容恢复到CPU的employee save寄存器。最后通过ret指令,返回到ra寄存器所保存的地址。
# Context switch
#
# void swtch(struct context *old, struct context *new);
#
# Save current registers in old. Load from new.
.globl swtch
swtch:
sd ra, 0(a0)
sd sp, 8(a0)
sd s0, 16(a0)
sd s1, 24(a0)
sd s2, 32(a0)
sd s3, 40(a0)
sd s4, 48(a0)
sd s5, 56(a0)
sd s6, 64(a0)
sd s7, 72(a0)
sd s8, 80(a0)
sd s9, 88(a0)
sd s10, 96(a0)
sd s11, 104(a0)
ld ra, 0(a1)
ld sp, 8(a1)
ld s0, 16(a1)
ld s1, 24(a1)
ld s2, 32(a1)
ld s3, 40(a1)
ld s4, 48(a1)
ld s5, 56(a1)
ld s6, 64(a1)
ld s7, 72(a1)
ld s8, 80(a1)
ld s9, 88(a1)
ld s10, 96(a1)
ld s11, 104(a1)
ret
yield、sched函数:调整进程的运行状态,调用swith函数,保存进程的上下文,载入当前CPU的线程调度器上下文,接着进入调度器线程scheduler
sheduler函数:该函数在进程数组中寻找runable的进程,并调用swith函数进入该进程上一次终止的位置,并继续执行。
补全 uthread.c,完成用户态线程功能的实现。
这里的线程相比现代操作系统中的线程而言,更接近一些语言中的“协程”(coroutine)。原因是这里的“线程”是完全用户态实现的,多个线程也只能运行在一个 CPU 上,并且没有时钟中断来强制执行调度,需要线程函数本身在合适的时候主动 yield 释放 CPU。这样实现起来的线程并不对线程函数透明,所以比起操作系统的线程而言更接近 coroutine。
这个实验其实相当于在用户态重新实现一遍 xv6 kernel 中的 scheduler() 和 swtch() 的功能,所以大多数代码都是可以借鉴的。
引申:内核调度器无论是通过时钟中断进入(usertrap),还是线程自己主动放弃 CPU(sleep、exit),最终都会调用到 yield 进一步调用 swtch。
由于上下文切换永远都发生在函数调用的边界(swtch 调用的边界),恢复执行相当于是 swtch 的返回过程,会从堆栈中恢复 caller-saved 的寄存器,
所以用于保存上下文的 context 结构体只需保存 callee-saved 寄存器,以及 返回地址 ra、栈指针 sp 即可。恢复后执行到哪里是通过 ra 寄存器来决定的(swtch 末尾的 ret 转跳到 ra)
而 trapframe 则不同,一个中断可能在任何地方发生,不仅仅是函数调用边界,也有可能在函数执行中途,所以恢复的时候需要靠 pc 寄存器来定位。
并且由于切换位置不一定是函数调用边界,所以几乎所有的寄存器都要保存(无论 caller-saved 还是 callee-saved),才能保证正确的恢复执行。
这也是内核代码中 struct trapframe 中保存的寄存器比 struct context 多得多的原因。
另外一个,无论是程序主动 sleep,还是时钟中断,都是通过 trampoline 跳转到内核态 usertrap(保存 trapframe),然后再到达 swtch 保存上下文的。
恢复上下文都是恢复到 swtch 返回前(依然是内核态),然后返回跳转回 usertrap,再继续运行直到 usertrapret 跳转到 trampoline 读取 trapframe,并返回用户态。
也就是上下文恢复并不是直接恢复到用户态,而是恢复到内核态 swtch 刚执行完的状态。负责恢复用户态执行流的其实是 trampoline 以及 trapframe。
有个小坑就是在下面的函数,给栈指针赋值时,直接写了栈数组名,也就是栈内存的低地址,但由于栈的从高地址向下生长的,所以应该把栈指针移动到栈内存的最高地址也就是(uint64)&t->stack + STACK_SIZE
// uthread.c
void
thread_create(void (*func)())
{
struct thread *t;
for (t = all_thread; t < all_thread + MAX_THREAD; t++) {
if (t->state == FREE) break;
}
t->state = RUNNABLE;
t->ctx.ra = (uint64)func; // 返回地址
// thread_switch 的结尾会返回到 ra,从而运行线程代码
t->ctx.sp = (uint64)&t->stack + (STACK_SIZE - 1); // 栈指针
// 将线程的栈指针指向其独立的栈,注意到栈的生长是从高地址到低地址,所以
// 要将 sp 设置为指向 stack 的最高地址
}
这个 也比较简单,就是有添加锁,解决哈希表插入数据的race-condition导致数据丢失的问题。
对于一个哈希表,可以直接用一个表锁来控制,但这样会限制性能,多线程性能退化为单线程。因此需要降低锁的粒度即采用行级锁,每行拥有自己的锁,当关键字key不相同时,两个写入线程互不干扰。 另外在get读取线程数据时,通常情况下不需要加锁,因为只读情况下是线程安全的。
这里的优化思路,也是多线程效率的一个常见的优化思路,就是降低锁的粒度。由于哈希表中,不同的 bucket 是互不影响的,一个 bucket 处于修改未完全的状态并不影响 put 和 get 对其他 bucket 的操作,所以实际上只需要确保两个线程不会同时操作同一个 bucket 即可,并不需要确保不会同时操作整个哈希表。
所以可以将加锁的粒度,从整个哈希表一个锁降低到每个 bucket 一个锁。
这个考察的是条件变量的使用,当未达到条件时,线程在条件变量上挂起,并释放锁。当条件满足后,唤醒挂起在条件变量上的所有线程。
本题中的满足条件为:已经运行的线程数量 是否等于 总的线程数量,若不等于则挂起等待,若等于则将round加一,并唤醒所有线程。
static void
barrier()
{
// YOUR CODE HERE
//
// Block until all threads have called barrier() and
// then increment bstate.round.
//
pthread_mutex_lock(&bstate.barrier_mutex);
bstate.nthread++;
if (bstate.nthread != nthread)
// 等待其他线程 调用cond_wait时,mutex必须持有
pthread_cond_wait(&bstate.barrier_cond, &bstate.barrier_mutex);
else
{
// 所有线程已经到达,轮数加一 nthread置零
bstate.round++;
bstate.nthread = 0;
// 唤醒所有线程
pthread_cond_broadcast(&bstate.barrier_cond);
}
pthread_mutex_unlock(&bstate.barrier_mutex);
}
线程进入同步屏障 barrier 时,将已进入屏障的线程数量增加 1,然后再判断是否已经达到总线程数。
如果未达到,则进入睡眠,等待其他线程。
如果已经达到,则唤醒所有在 barrier 中等待的线程,所有线程继续执行;屏障轮数 + 1;
「将已进入屏障的线程数量增加 1,然后再判断是否已经达到总线程数」这一步并不是原子操作,并且这一步和后面的两种情况中的操作「睡眠」和「唤醒」之间也不是原子的,如果在这里发生 race-condition,则会导致出现 「lost wake-up 问题」(线程 1 即将睡眠前,线程 2 调用了唤醒,然后线程 1 才进入睡眠,导致线程 1 本该被唤醒而没被唤醒,详见 xv6 book 中的第 72 页,Sleep and wakeup)
解决方法是,「屏障的线程数量增加 1;判断是否已经达到总线程数;进入睡眠」这三步必须原子。所以使用一个互斥锁 barrier_mutex 来保护这一部分代码。pthread_cond_wait 会在进入睡眠的时候原子性的释放 barrier_mutex,从而允许后续线程进入 barrier,防止死锁。