This lab will familiarize you with multithreading. You will implement switching between threads in a user-level threads package, use multiple threads to speed up a program, and implement a barrier.
实现在用户态线程库切换线程;用多线程来为程序提速;实现一个同步屏障
Uthread: switching between threads [moderate]
需要做的是创建线程并且在线程切换时恢复/保存相应寄存器
这个实验其实相当于在用户态重新实现一遍 xv6 kernel 中的 scheduler() 和 swtch() 的功能,所以大多数代码都是可以借鉴的
提示
- thread_switch needs to save/restore only the callee-save registers. Why?
- You can see the assembly code for uthread in user/uthread.asm, which may be handy for debugging.
实验过程
1.uthread_switch.S 中需要实现上下文切换的代码,这里借鉴 swtch.S:
a0,a1寄存器用来保存函数参数,分别保存着下面的context *old 和context *new
.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 已经被调用者保存到栈帧中了,所以这里无需保存这一部分寄存器
引申:内核调度器无论是通过时钟中断进入(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。
2.从proc.h中借鉴context结构体,用于保存ra,sp以及callee-saved registers
// uthread.c
// 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 结构体
};
struct thread all_thread[MAX_THREAD];
struct thread *current_thread;
extern void thread_switch(struct context* old, struct context* new); // 修改 thread_switch 函数声明
3.在thread_schedule中调用thread_switch进行上下文切换
//user/uthread.c
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(&t->ctx, &next_thread->ctx); //switch thread
} else
next_thread = 0;
}
4.最后实现thread_create函数
//user/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;
// YOUR CODE HERE
//+
//thread_switch will return ra at its end
t->ctx.ra = (uint64)func; //save the return address to ra
//stack grow from high addr to low addr,so sp is set to stack's highest addr
t->ctx.sp = (uint64)&t->stack + (STACK_SIZE - 1);
}
添加的部分为设置上下文中 ra 指向的地址为线程函数的地址,这样在第一次调度到该线程,执行到 thread_switch 中的 ret 之后就可以跳转到线程函数从而开始执行了。设置 sp 使得线程拥有自己独有的栈,也就是独立的执行流。
实验结果
$ 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 98
thread_a 98
thread_b 98
thread_c 99
thread_a 99
thread_b 99
thread_c: exit after 100
thread_a: exit after 100
thread_b: exit after 100
thread_schedule: no runnable threads
Using threads[moderate]
多线程时为什么会造成数据丢失(missing keys)?
假设现在有两个线程T1和T2,两个线程都走到put函数,且假设两个线程中key%NBUCKET相等,即要插入同一个散列桶中。两个线程同时调用insert(key, value, &table[i], table[i]),insert是通过头插法实现的。如果先insert的线程还未返回另一个线程就开始insert,那么前面的数据会被覆盖
背景知识
- pthread.h线程库
//互斥锁
pthread_mutex_t lock; // declare a lock
pthread_mutex_init(&lock, NULL); // initialize the lock
pthread_mutex_lock(&lock); // acquire lock
pthread_mutex_unlock(&lock); // release lock
//一些重要函数
//1.创建线程
int pthread_create(pthread_t* thread, const pthread_attr_t* attr, void*(*start_routine)(void*), void* arg);
thread 是一个指向线程标识符的指针,线程调用后,改值被设置为线程ID
attr 用来设置线程属性
start_routine 是线程函数的其实地址,即线程函数体,线程创建成功后,thread 指向的内存单元从该地址开始运行
arg 是传递给线程函数体的参数
返回值:若线程创建成功,则返回0,失败则返回错误码,并且 thread 内容是未定义的。
//2.线程阻塞函数,调用该函数则等到线程结束才继续运行
int pthread_join(pthread_t thread, void **retval);
thread 是线程表示符
retval 用来获取线程的返回值,一般是 pthread_join 方法传递出来的值
实验过程
数据丢失的原因已经知道是由于没有上锁保护数据。如果只用全局的互斥锁,可以发现,多线程执行的版本也不会丢失 key 了,说明加锁成功防止了 race-condition 的出现。
但是仔细观察会发现,加锁后多线程的性能变得比单线程还要低了,虽然不会出现数据丢失,但是失去了多线程并行计算的意义:提升性能。
这里的原因是,我们为整个操作加上了互斥锁,意味着每一时刻只能有一个线程在操作哈希表,这里实际上等同于将哈希表的操作变回单线程了,又由于锁操作(加锁、解锁、锁竞争)是有开销的,所以性能甚至不如单线程版本。
这里的优化思路,是多线程效率的一个常见的优化思路,就是降低锁的粒度。由于哈希表中,不同的 bucket 是互不影响的,一个 bucket 处于修改未完全的状态并不影响 put 和 get 对其他 bucket 的操作,所以实际上只需要确保两个线程不会同时操作同一个 bucket 即可,并不需要确保不会同时操作整个哈希表。
所以可以将加锁的粒度,从整个哈希表一个锁降低到每个 bucket 一个锁
。
// ph.c
//+
pthread_mutex_t locks[NBUCKET];
int
main(int argc, char *argv[])
{
pthread_t *tha;
void *value;
double t1, t0;
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, 4.940 seconds, 20241 puts/second
0: 0 keys missing
100000 gets, 4.934 seconds, 20267 gets/second
$ ./ph 2
100000 puts, 3.489 seconds, 28658 puts/second
0: 0 keys missing
1: 0 keys missing
200000 gets, 6.104 seconds, 32766 gets/second
$ ./ph 4
100000 puts, 1.881 seconds, 53169 puts/second
0: 0 keys missing
3: 0 keys missing
2: 0 keys missing
1: 0 keys missing
400000 gets, 7.376 seconds, 54229 gets/second
Barrier[moderate]
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. You’ll use pthread condition variables(条件变量), which are a sequence coordination technique similar to xv6’s sleep and wakeup.
在上个实验的基础上,再增加一些新特性:
1.先到达barrier的线程先睡眠等待其他还没到达的
2.如果全部都到达了,再唤醒线程,同时更新结构体的线程数和轮数
这两步操作分别用到下面的两个函数
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex); // go to sleep on cond, releasing lock mutex, acquiring upon wake up
int pthread_cond_broadcast(pthread_cond_t *cond); // wake up every thread sleeping on cond
实验过程
补全barrier()函数中,实现上述两个新特性的内容
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) {
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);
}
思考
可能出现的[lost-wake-up]:
线程 1 即将睡眠前,线程 2 调用了唤醒,然后线程 1 才进入睡眠,导致线程 1 本该被唤醒而没被唤醒
解决方法:
1.线程数递增2.递增后的结果与总线程数比较3.睡眠到达的线程。这三步操作必须是原子的
所以使用一个互斥锁 barrier_mutex 来保护这一部分代码。pthread_cond_wait 会在进入睡眠的时候原子性的释放 barrier_mutex,从而允许后续线程进入 barrier,防止死锁
实验结果
$make grade
多个进程阻塞测试: