Lab地址:pdos.csail.mit.edu/6.828/2021/…
git代码地址:github.com/cardchoosen…
Lab: Multithreading
Uthread: switching between threads
完善用户态下的线程切换能力,这种用户态的线程切换其实类似于协程,多个线程运行在一个CPU上,且不包含时钟中断强制的调度。需要线程主动调用yield释放CPU,所以实现更像coroutine。
uthread_switch.S:
直接借鉴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 */
调用uthread_switch()时,caller-saved registers已经被调用者保存在栈帧中了,所以无需保存这部分。
内核的schedule无论是时钟中断(usertrap)还是线程主动放弃CPU(sleep,exit)最终都会通过yield走到swtch,执行的指令流都被停在swtch里。所以这种上下文切换都发生在函数调用的边界——swtch的末尾,恢复这种线程上下文继续执行,总是先在swtch中返回,这一段的返回过程就会从堆栈中恢复caller-saved registers,所以我们无需保存这部分。
但是在trapframe中不同,中断可能在任何位置发生,它的返回位置不只是函数边界,也可能是正常的执行过程中,所以恢复时需要pc寄存器重新定位指令地址,且几乎所有寄存器都要保存并恢复,包括caller-saved 和 callee-saved,这也导致了在内核代码中,struct trapframe中保存的寄存器比struct context更多。
无论进程是主动sleep还是被时钟中断,都是通过trampoline跳转到内核态usertrap,再调用switch的,恢复时回到swtch中,此时仍然处于内核态,跳转返回usertrap,运行到usertrapret跳转到trampoline读取trapframe,这才返回用户态。所以这种线程切换总会有个 用户态=>内核态=>在swtch中切换线程=>恢复线程运行=>回到swtch(内核态)=>用户态 的过程。
继续完善代码,在uthread.c中的thread结构中新增ctx,用于保存sp、ra和callee-saved registers:
// Saved registers for thread context switches.
struct context {
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;
};
struct thread {
char stack[STACK_SIZE]; /* the thread's stack */
int state; /* FREE, RUNNING, RUNNABLE */
struct context ctx; // 在 thread 中添加 context 结构体
};
uthread.c thread_schedule():
void
thread_schedule(void)
{
...
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)&next_thread->ctx);
} else
next_thread = 0;
}
和thread_create():
将上下文的ra指向线程函数的地址,之后在调度到该线程时,执行到thread_switch中的ret后就可以跳转到线程函数开始执行。设置sp让线程拥有自己的栈,这里sp指向栈底,增加 STACK_SIZE - 1设置为最大栈空间。
void
thread_create(void (*func)())
{
...
// YOUR CODE HERE
t->ctx.ra = (uint64)func;
t->ctx.sp = (uint64)&t->stack + (STACK_SIZE - 1);
}
make qemu 然后执行 $uthread 有如下输出,验证正确。
$ uthread
thread_a started
thread_b started
thread_c started
thread_c 0
thread_a 0
thread_b 0
thread_c 1
thread_a 1
thread_b 1
...
thread_c: exit after 100
thread_a: exit after 100
thread_b: exit after 100
thread_schedule: no runnable threads
Using threads
在这个作业中,将使用线程和锁以及哈希表来探索并行编程。。这个作业使用 UNIX 的 pthread 线程库,使用“man pthreads”,文件 notxv6/ph.c 包含一个简单的哈希表,如果从单个线程使用是正确的,但从多个线程使用时是不正确的。在 xv6 目录中(也许是~/xv6-labs-2021),输入以下内容:./ph 1。为了构建 ph,Makefile 使用操作系统的 gcc,而不是 6.S081 工具。传递给 ph 的参数指定了在哈希表上执行 put 和 get 操作的线程数量。运行一小段时间后,ph 1 将产生类似于这样的输出:100000 次 put,3.991 秒,25056 次 put/秒。0:0 个键缺失。100000 次 get,3.981 秒,25118 次 get/秒。
你看到的数字可能与这个示例输出相差两倍或更多,这取决于你的计算机速度有多快、它是否有多个核心以及它是否在忙于做其他事情。
ph 运行两个基准测试。首先,它通过调用 put () 向哈希表中添加大量键,并打印实现的每秒输入速率。然后,它使用 get () 从哈希表中获取键。它打印由于 put 操作本应在哈希表中但却缺失的键的数量(在这种情况下为零),并打印它实现的每秒获取次数。
通过给 ph 一个大于一的参数来告诉它同时从多个线程使用其哈希表。试试 ph 2:
$./ph 2
100000 puts,1.885 秒,53044 puts / 秒
1:16579 个键缺失
0:16579 个键缺失
200000 gets,4.322 秒,46274 gets / 秒
这个 ph 2 输出的第一行表明,当两个线程同时向哈希表中添加条目时,它们实现了每秒 53044 次插入的总速率。这大约是运行 ph 1 的单线程速率的两倍。这是一个非常好的约 2 倍的 “并行加速”,这是人们可能期望的最大值(即两倍的核心在单位时间内产生两倍的工作量)。然而,显示 16579 个键缺失的两行表明,本应在哈希表中的大量键并不在那里。也就是说,put 操作本应将这些键添加到哈希表中,但出了问题。
查看 notxv6/ph.c,特别是 put () 和 insert ()。
修改你的代码,使一些插入操作并行运行,同时保持正确性。当 “make grade” 显示你的代码通过 “ph_safe” 和 “ph_fast” 测试时,你就完成了。“ph_fast” 测试要求两个线程每秒产生的插入次数至少是一个线程的 1.25 倍。
answers-thread.txt:
thread 1和thread 2同时往一个bucket设置键k1、k2
thread 1: 发现k1不存在,继续执行
-- schedule --
thread 2: 发现k2不存在,继续执行
thread 2: 分配entry,在bucket末尾插入k2
-- schedule --
thread 1: 分配entry,在bucket末尾插入k1(覆盖了k2)
暂不考虑速度,使用加锁的方式保证put和get的并发安全:
notxv6/ph.c:
...
int keys[NKEYS];
int nthread = 1;
pthread_mutex_t lock;
static
void put(int key, int value)
{
pthread_mutex_lock(&lock);
...
pthread_mutex_unlock(&lock);
}
static struct entry*
get(int key)
{
pthread_mutex_lock(&lock);
...
pthread_mutex_unlock(&lock);
return e;
}
int
main(int argc, char *argv[])
{
pthread_t *tha;
void *value;
double t1, t0;
pthread_mutex_init(&lock, NULL);
...
}
再次尝试./ph 2,没有key missing了
$ ./ph 2
100000 puts, 1.821 seconds, 54927 puts/second
0: 0 keys missing
1: 0 keys missing
200000 gets, 3.642 seconds, 54917 gets/second
多线程下不会丢失key,说明加锁成功阻止了race condition的出现,但是这时我们再尝试./ph1
$ ./ph 1
100000 puts, 1.805 seconds, 55390 puts/second
0: 0 keys missing
100000 gets, 1.693 seconds, 59071 gets/second
可以发现加锁后的多线程性能比单线程还要低,即使不会出现数据丢失,仍然失去了多线程的意义:并行带来性能上的提升。这里性能劣化的原因也很简单,我们只指定了一个锁,每一时刻只有一个线程能获取锁并操作哈希表,所以实际上仍是一个单线程的执行流,加上获取锁、释放锁的开销,性能就比单线程更差了。
优化的思路很简单,也是多线程优化的常见思路,就是降低锁的粒度。在哈希表中,划分出来的bucket间是互不影响的,所以只需要确保多个线程不会同时操作一个bucket就行,不需要拿到整个哈希表的锁,这里把锁的粒度细化到bucket:
pthread_mutex_t *locks;
int
main(int argc, char *argv[])
{
...
locks = malloc(sizeof(pthread_mutex_t) * NBUCKET);
for(int i = 0; i < NBUCKET; i++) {
pthread_mutex_init(&locks[i], NULL);
}
...
}
static
void put(int key, int value)
{
int i = key % NBUCKET;
pthread_mutex_lock(&locks[i]);
...
pthread_mutex_unlock(&locks[i]);
}
static struct entry*
get(int key)
{
int i = key % NBUCKET;
pthread_mutex_lock(&locks[i]);
...
pthread_mutex_unlock(&locks[i]);
return e;
}
再次验证,可以发现性能显著提升:
$ ./ph 1
100000 puts, 1.801 seconds, 55515 puts/second
0: 0 keys missing
100000 gets, 1.619 seconds, 61766 gets/second
$ ./ph 2
100000 puts, 1.095 seconds, 91318 puts/second
1: 0 keys missing
0: 0 keys missing
200000 gets, 1.953 seconds, 102426 gets/second
Barrier
完善代码实现用户态的多线程屏障同步机制,允许多个线程在执行时协调彼此的进度,确保它们在某个点上同时到达并继续执行。Barrier就是其中一种同步原语,允许调用线程在某个点等待,直到所有参与的线程都到达这个点,当所有线程都到达屏障时,才会继续执行。
barrier.c
static void
barrier()
{
// YOUR CODE HERE
//
// Block until all threads have called barrier() and
// then increment bstate.round.
//
pthread_mutex_loc(&bstate.barrier_mutex);
if(++bstate.nthread < nthread) {
pthread_cond_wait(&bstate.barrier_cond, &bstate.barrier_mutex);
} else {
bstate.nthread = 0;
bstate.round++;
pthread_cond_broadcast(&bstate.barrier_cond);
}
pthread_mutex_unlock(&bstate.barrier_mutex);
}
触发屏障,首先获取屏障的锁,将已进入屏障的线程数量加一,判断是否达到需求的线程总数,若未达到,则调用pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex),这是POSIX线程库中的一个函数,调用前
必须先获得并持有mutex,调用后会释放mutex,并进入等待状态,直到条件变量cond被其他线程唤醒;若已经达到,则调用pthread_cond_broadcast唤醒所有在barrier中等待的线程,同时将屏障轮数加一。最后释放屏障的锁,确保这些操作是原子性的。
在1个、2个以及多个线程下测试barrier:
$ make barrier
gcc -o barrier -g -O2 -DSOL_THREAD -DLAB_THREAD notxv6/barrier.c -pthread
$ ./barrier 1
OK; passed
$ ./barrier 2
OK; passed
$ ./barrier 3
OK; passed
实验完成,make grade验证:
== Test uthread ==
$ make qemu-gdb
uthread: OK (4.7s)
== Test answers-thread.txt == answers-thread.txt: OK
== Test ph_safe == gcc -o ph -g -O2 -DSOL_THREAD -DLAB_THREAD notxv6/ph.c -pthread
ph_safe: OK (3.5s)
== Test ph_fast == make[1]: `ph' is up to date.
ph_fast: OK (6.6s)
== Test barrier == gcc -o barrier -g -O2 -DSOL_THREAD -DLAB_THREAD notxv6/barrier.c -pthread
barrier: OK (2.3s)
== Test time ==
time: OK
Score: 60/60