#Head BLog
MIT 6.S081 xv6-labs-2020 往期回顾,本人实验记录,欢迎参考阅读!
- MIT 6.S081 Lab2 system calls
- MIT 6.S081 Lab3 page tables
- MIT 6.S081 Lab4 traps
- MIT 6.S081 Lab5 lazy page allocation
- MIT 6.S081 Lab6 cow fork
#Source
- MIT-6.S081 2020 课程官网
- Lab7: multithreading 实验主页
- MIT-6.S081 2020 xv6 book
- MIT-6.S081 xv6 book Chapter 6 Scheduling 个人笔记
- B站 - MIT 6.S081 Lec11: Thread switching
#My Code
#Motivation
Lab7: multithreading 主要是想让我们熟悉一下 OS 中的多线程技术。多线程无论是在 OS 中,还是 Application 程序中都是简化业务流程和加速的重要手段。整个实验分为三个小实验,彼此之间有联系,但并不紧密
- Lab: Uthread: switching between threads 是让我们模拟 xv6 中的调度细节。此模拟,其实简化了很多。真正要了解 xv6 进程切换的细节还需好好阅读 MIT-6.S081 2020 xv6 book
- Lab: Using threads 是想考察我们运用锁的能力。其实解决的问题很简单,就是多个线程一起操作同一份数据。怎样保证不乱套?怎样确保有条不紊?怎样在有条不紊的情况下提高运行速率?
- Lab: Barrier 属于多线程常用且高级的技巧。说白了,就是条件变量。线程很多,但不能一股脑都开始运行。我们需要有一种机制来确保,只有当所有线程都满足就绪条件(一般都是自定义)之后,才开始运行。其中的意味只有通过实例才能体会
下面谈谈我自己的心得体会,在我看来,Lab7: multithreading 在处理多线程问题方面还是处于入门级的
想要熟练掌握多线程技巧,理论(无非就是竞争,核心:何时何地上锁放锁?)是一方面,但不是最重要的。最重要的是,要实践。C 也好,C++ 也好,以及后来的 Go ,可以说基本上,所有的编程语言在解决多线程问题方面的技巧都是相通的
Lab7: multithreading 姑且就当放松一下啦,真正要搞明白 OS 中的进程切换问题,是要好好阅读 MIT-6.S081 xv6 book's Chapter 6 - Scheduling 和 rtm 教授的讲课
我本人研读了 xv6 book 的相关章节后,做了些笔记在 MIT-6.S081 xv6 book Chapter 6 Scheduling 个人笔记 ,欢迎阅读,可能会给你带来一些思想碰撞的惊喜
在开始实验之前,一定要阅读 xv6-6.S081 的第六章节 Scheduling 及相关代码
#Uthread: switching between threads (moderate)
#Motivation
Lab: Uthread: switching between threads 想让我们在熟悉 xv6 进程切换的流程之后,在用户层通过 Linux C 的多线程技术模拟 xv6 内部的切换和调度细节
#Solution
这次我们的主要战场不再是 kernel/
目录下的 .c 文件了,而是 user/
目录下的 uthread.c 。并且,主要是添加并修改其中的关键函数
虽然 xv6 进程切换的细节我在 MIT-6.S081 xv6 book Chapter 6 Scheduling 个人笔记 中讲过,但在这里我还是简要地再重申一下,如下图,
#S1 - 进程切换要义
进程 shell 切换到进程 cat ,看似很简单,但这过程是有很多道道的。可以说是 xv6 中最晦涩难懂的部分了
进程切换,第一要义,就是要做到对用户层透明,让用户感觉不到 xv6 进入了 kernel ;其次,就是进入 kernel 之后,是如何切换到其他进程的?这个过程可一点都不随意
在进程的 kernel 线程切换的过程,会有一个叫 Scheduler 的 CPU 守护线程接手,它负责寻找新的 kernel 线程即进程 cat 对应的 kernel 线程,将其扶上位运行
以及进程 shell 的 kernel 线程是怎么找到 CPU 的 Scheduler ?并且在下一次切换回进程 shell 时,确保进程 shell 能够接着之前切换( yield )处的点继续运行?
以上的细节如何保证?其实就靠 ra ,sp 和一些通用寄存器来确保,简称上下文切换( Context swtiching )
#S2 - thread_schedule() 调度线程
首先,我们来解析一下 uthread.c 中的主要业务逻辑,
int
main(int argc, char *argv[])
{
a_started = b_started = c_started = 0;
a_n = b_n = c_n = 0;
thread_init();
thread_create(thread_a);
thread_create(thread_b);
thread_create(thread_c);
thread_schedule();
exit(0);
}
程序没做什么复杂的事情,就是启动三个线程 ABC ,然后调用 thread_schedule()
让它们切来切去
切来切去 = 上下文切换,其需要的 context(存档寄存器值)在给出的 uthread.c 并未声明,需要我们自己定义,在 user/uthread.c
中,
typedef struct {
uint64 ra;
uint64 sp;
// callee-saved
uint64 s0;
uint64 s1;
uint64 s2;
uint64 s3;
uint64 s4;
uint64 s5;
uint64 s6;
uint64 s7;
uint64 s8;
uint64 s9;
uint64 s10;
uint64 s11;
} context;
和 kernel/proc.h
中的 struct context
中的内容相同,照抄过来就行,并在 struct thread
中添加该字段,
struct thread {
char stack[STACK_SIZE]; /* the thread's stack */
int state; /* FREE, RUNNING, RUNNABLE */
context ctx;
};
之后,就可以在 thread_create()
中指定,线程被 Scheduler 选中后继续执行的点及运行该线程所必需的堆栈地址,
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;
// YOUR CODE HERE
t->ctx.ra = (uint64)func;
t->ctx.sp = (uint64)t->stack + STACK_SIZE;
}
现在可以考虑一下 thread_schedule()
啦,因为无论该线程是主动 yield 还是被选中调度,流程都包含这最关键的一步,
void
thread_schedule(void)
{
struct thread *t, *next_thread;
/* Find another runnable thread. */
next_thread = 0;
t = current_thread + 1;
for(int i = 0; i < MAX_THREAD; i++){
if(t >= all_thread + MAX_THREAD)
t = all_thread;
if(t->state == RUNNABLE) {
next_thread = t;
break;
}
t = t + 1;
}
if (next_thread == 0) {
printf("thread_schedule: no runnable threads\n");
exit(-1);
}
if (current_thread != next_thread) { /* switch threads? */
next_thread->state = RUNNING;
t = current_thread;
current_thread = next_thread;
/* YOUR CODE HERE
* Invoke thread_switch to switch from t to next_thread:
* thread_switch(??, ??);
*/
thread_switch((uint64)&t->ctx, (uint64)¤t_thread->ctx);
} else
next_thread = 0;
}
其中的 thread_switch()
和 xv6 kernel 中的 kernel/swtch.S
内容相同,都是保存和恢复寄存器值,
.text
/*
* save the old thread's registers,
* restore the new thread's registers.
*/
.globl thread_switch
thread_switch:
/* YOUR CODE HERE */
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 /* return to ra */
要明白 swtch 其中的奥秘,必定要好好研究此段汇编代码,给个提示,ra + ret = pc
每个时钟中断,正在 CPU 上运行的线程都会主动 yield ,让出 CPU ,这是时间片轮转策略的核心,thread_yield()
无需多言,
void
thread_yield(void)
{
current_thread->state = RUNNABLE;
thread_schedule();
}
就是将该线程状态从 RUNNING 回退到 RUNNABLE ,并且将控制权还给 Scheduler
至此,已经完成了用户层线程切换的大致工作
#Result
手动进入 qemu
make qemu
$uthread
也可以执行,
./grade-lab-thread uthread
进行单项测试
#Using threads (moderate)
#Motivation
Lab: Using threads 的目的,就是想训练我们:在多线程的情况,如何确保数据安全?具体表现为,多个线程同时操作同一份数据(实验中表现为哈希表)。怎样确保不会发生错写、漏写等一些列不安全的 case ?并且在正确的情况下,提高多线程的运行速率
#Solution
在这个子实验中,战场转移到 notxv6/ph.c
。要运行 ph.c ,不再是 xv6 qemu 编译了,而是用 Linux 或 Mac 的真实工具链
其实,这个问题很好解决。多线程竞争,确保有序,没有好的手段,就是上锁放锁。何时何地上锁放锁,这才是关键
首先,我们根据 Lab7: multithreading 实验主页 的提示,定义并初始化 locks ,
/** 全局变量 */
pthread_mutex_t lks[NBUCKET];
/** 在 main 中初始化 locks */
int
main(int argc, char *argv[])
{
...
for (int i = 0; i < NKEYS; i++) {
keys[i] = random();
}
/** 初始化locks */
for(int i=0; i<NBUCKET; i++)
pthread_mutex_init(&lks[i], NULL);
// first the puts
...
}
在这里,我直接一步到位,给哈希表的每个篮子都分配了锁,意在减小锁的颗粒度,减少不必要的上锁,从而提高多线程的运行速率
然后在 notxv6/ph.c:put()
处,为写操作上锁,
static
void put(int key, int value)
{
int i = key % NBUCKET;
...
/** 写操作应上锁 */
pthread_mutex_lock(&lks[i]);
if(e){
// update the existing key.
e->value = value;
} else {
// the new is new.
insert(key, value, &table[i], table[i]);
}
/** 写完放锁 */
pthread_mutex_unlock(&lks[i]);
}
多线程情况下,修改同一份数据,是必须上锁的;读同一份数据,却不需要上锁。所以,不必改动 notxv6/ph.c:get()
#Result
进入 Lab7: multithreading 的根目录,执行,
make ph
编译完成之后,执行,
./ph 2
查看正确性,也可以通过,
./grade-lab-thread ph_safe
./grade-lab-thread ph_fast
进行单项测试
#Barrier (moderate)
#Motivation
Lab: Barrier 是想让我们熟悉一下条件变量,了解条件变量的应用场景
实验的大致情形是这样的,假设现在系统有4个线程。通过 pthread_create()
拉起了这 4 个线程,紧接着就进入了线程的业务循环中
实验设想:在开启业务循环时,我们并不想线程刚被创建出来就立马执行业务逻辑,而是先阻塞一会,等队友(其他线程)都上线了,再一起执行业务逻辑。对应 Lab7: multithreading 实验主页 的原话,
In this assignment you'll implement a barrier: a point in an application at which all participating threads must wait until all other participating threads reach that point too.
其中的阻塞 = notxv6/barrier.c:barrier()
,试想创建完 4 个线程后,有 3 个线程在第一时刻进入了各自的业务循环中,但还有一个线程迟迟还未就位。我们希望已经就位的 3 个线程能够等等这慢悠悠的4号线程
于是,我们将这3个线程先挂起,等 4 号线程上线后,再唤醒它们,一起行动
#Solution
就是这么简单的想法,在 C 中用条件变量实现( Go 也有别的方法)
等待 4 号线程上线的过程,在硬件层面来看,就是 CPU 轮询,一直观察是否满足条件。在 Lab: Barrier 中,就是 CPU 一直在判断 4 号线程是否已经进入业务循环中。反应到代码中,
static void
barrier()
{
// YOUR CODE HERE
//
// Block until all threads have called barrier() and
// then increment bstate.round.
//
if(++bstate.nthread == nthread) {
...
}
else {
...
}
}
当线程都上线了,就执行唤醒操作;如果还有没上线的,那就先挂起,
static void
barrier()
{
// YOUR CODE HERE
//
// Block until all threads have called barrier() and
// then increment bstate.round.
//
pthread_mutex_lock(&bstate.barrier_mutex);
if(++bstate.nthread == nthread) {
bstate.round++;
bstate.nthread = 0;
pthread_cond_broadcast(&bstate.barrier_cond);
}
else {
pthread_cond_wait(&bstate.barrier_cond, &bstate.barrier_mutex);
}
pthread_mutex_unlock(&bstate.barrier_mutex);
}
这是扩展完的代码。其中,在线程都上线的情况下,bstate.round++
和 bstate.nthread = 0
是为下一次业务循环做准备。该实验的业务循环,其实就是重复上面提到的 3 线程等 4 号线程这么一件非常质朴的事
记得,在用条件变量时需要上锁放锁。别问为什么,目前还没到需要了解其工作原理的层次,当成语法记住就可以
还有,在 notxv6/barrier.c:barrier_init()
中初始化 bstate.round
,
static void
barrier_init(void)
{
assert(pthread_mutex_init(&bstate.barrier_mutex, NULL) == 0);
assert(pthread_cond_init(&bstate.barrier_cond, NULL) == 0);
bstate.nthread = 0;
bstate.round = 0;
}
#Result
进入 Lab7: multithreading 的根目录,执行,
make barrier
编译完成之后,执行,
./barrier 4
不一定为 4 ,大于 1 的数都可以。或者进入单项测试,
./grade-lab-thread barrier