这个实验是用来帮助熟悉多线程的,要我们实现一个用户态线程。并使用多线程加速程序运行,实现同步屏障。
Uthread: switching between threads
实验要求设计用户态线程的上下文切换,实验提供了 user/uthread.c 和 user/uthread_switch.S 一些线程操作和切换的代码,这里缺少了创建线程和切换线程的代码需要完成。
用户态线程切换和内核的进程切换类似,都是通过先保存寄存器上下文,切换到调度线程,再由调度线程切换到另一线程。实验中要求完成的主要有以下几点:
- 确保线程从 thread_create 函数中传入的函数地址处开始执行,并为线程分配栈空间。
- thread_switch 需要保存之前被切换线程的寄存器,恢复要切换的线程的寄存器,并跳转到接下来需要切换的线程之前停止执行的位置恢复执行。
- 在 thread_schedule 中添加 thread_switch 调用。
实验中提出了一个问题是在 thread_switch 里为什么只需要保存 callee-saved 寄存器?
原因是 thread_swith 是函数调用,所以 caller-saved 寄存器已经被主调保存在自己的栈中了,函数返回时可以恢复。所以在 thread_switch 这里只需要保存 callee-saved 寄存器。
实现:
这里我们可以把内核进程切换的代码复制过来,为用户态 thread 增加一个 context 成员变量,其中包含了当前的返回地址和栈指针以及 callee-saved 寄存器。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
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 context; // swtch() here to run process
};
|
在 thread_create 函数里为线程分配栈空间并设置函数返回地址为传入函数的地址。注意栈空间是向低地址增长的。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
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->context.sp = (uint64)&t->stack[STACK_SIZE-1];
t->context.ra = (uint64)func;
}
|
最后在 thread_schedule 中调用 thread_switch 切换线程,uthread_switch.S 的实现直接复制内核进程切换的代码即可,都是保存寄存器恢复寄存器的操作。
1
2
3
4
5
6
7
8
9
10
11
|
//thread_schedule
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->context, (uint64)&next_thread->context);
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
//uthread_switch.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 */
|
Using threads
这里使用的是多线程操作哈希表的例子,要使用真实的 linux 或 macos 进行实验。首先我们先来看看实验给出的哈希表的基本结构。
1
2
3
4
5
6
|
struct entry {
int key;
int value;
struct entry *next;
};
struct entry *table[NBUCKET];
|
可以看到是一个使用链表法的实现。
实验里有给出一个问题是在多线程操作这个哈希表的过程中会出现 key 丢失,让我们分析一下为什么会丢失?
我们可以看一下插入到哈希表的方式,首先会对 key 哈希得到其属于哪个桶,假如两个 key:k1, k2 被分到了同一个桶,在一个桶中的插入就变成了单链表的插入,实验给出的链表插入方式是头插法,上图中标明了插入的步骤,如果插入 k1 和 k2 是并行的,在步骤 5, 6 完成之前其他的所有步骤都完成了,此时无论 5, 6 谁先执行,都有一个节点会丢失,因为 5, 6 的操作会相互覆盖,导致桶头节点的指向要么指向 k1 要么指向 k2,这样就会丢失一个节点。
避免出现 key 丢失的方法当然是加锁了,我们可以对哈希表的插入操作加互斥锁,并且根据上面的情况分析,只有在一个桶内的操作才会出现竞态条件,所以我们可以降低锁的粒度,每个桶都分配一把锁,只有在同一桶内的并发操作才加锁,不同桶间的并发无需加锁解锁。
实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
pthread_mutex_t locks[NBUCKET]; // declare a lock
static
void put(int key, int value)
{
int i = key % NBUCKET;
pthread_mutex_lock(&locks[i]); // acquire lock
// is the key already present?
struct entry *e = 0;
for (e = table[i]; e != 0; e = e->next) {
if (e->key == key)
break;
}
if(e){
// update the existing key.
e->value = value;
} else {
// the new is new.
insert(key, value, &table[i], table[i]);
}
pthread_mutex_unlock(&locks[i]); // release lock
}
static struct entry*
get(int key)
{
int i = key % NBUCKET;
pthread_mutex_lock(&locks[i]); // acquire lock
struct entry *e = 0;
for (e = table[i]; e != 0; e = e->next) {
if (e->key == key) break;
}
pthread_mutex_unlock(&locks[i]); // release lock
return e;
}
//main
for(int i = 0; i < NBUCKET; i++)
pthread_mutex_init(&locks[i], NULL); // initialize the lock
|
Barrier
这里要我们利用 pthread 的并发原语实现同步屏障,首先看一下两个同步原语。
1
2
|
pthread_cond_wait(&cond, &mutex); // go to sleep on cond, releasing lock mutex, acquiring upon wake up
pthread_cond_broadcast(&cond); // wake up every thread sleeping on cond
|
实验的要求是利用两个并发原语实现同时有 n 个线程时所有线程才开始执行,否则 sleep 等待,并且可以一直执行多轮。
实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
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) { // 未满足条件 wait
pthread_cond_wait(&bstate.barrier_cond, &bstate.barrier_mutex);
} else { // 满足条件唤醒所有线程,并进入下一轮
bstate.round++;
bstate.nthread = 0;
pthread_cond_broadcast(&bstate.barrier_cond);
}
pthread_mutex_unlock(&bstate.barrier_mutex); // 解锁互斥锁
}
|
总结
这个实验通过实现用户态线程,使用多线程加速程序,使用线程同步原语等方式让我们熟悉线程的基本使用。实验都是一些工作都会用到的经典场景,做完感觉对线程,并行等概念的了解更加深刻了一些。
Author
Hao
LastMod
2022-02-03
License
本文采用知识共享署名-非商业性使用 4.0 国际许可协议进行许可