Uthread: switching between threads (moderate)
要求
在本练习中,您将为用户级线程系统设计上下文切换机制,然后实现它。为了帮助您入门,您的 xv6 有两个文件 user/uthread.c
和 user/uthread_switch.S
,以及Makefile中用于构建 uthread 程序的规则。uthread.c
包含大部分用户级线程包,以及三个简单测试线程的代码。线程包缺少一些用于创建线程和在线程之间切换的代码。
您的工作是制定一个计划来创建线程并保存/恢复寄存器以在线程之间切换,并实现。
当你完成后,在xv6上运行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 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
$
此输出来自三个测试线程,每个测试线程都有一个循环,该循环打印一行,然后将 CPU 提供给其他线程。
但是,此时,如果没有上下文切换代码,则不会看到任何输出。
- 你将需要在
user/uthread.c
中的thread_create()
、thread_schedule()
以及user/uthread_switch.S
中的thread_switch
中添加代码。 - 一个目标是确保当
thread_schedule()
首次运行给定线程时,线程在其自己的堆栈上执行传递给thread_create()
的函数。 - 另一个目标是确保
thread_switch
保存被切换的线程的寄存器,恢复正在切换到的线程的寄存器,并返回到后一个线程指令中上次中断的位置。 - 您必须决定在何处保存/恢复寄存器;修改
struct thread
以保存寄存器是一个很好的计划。 - 您需要在
thread_schedule
中添加对thread_switch
的调用; 您可以传递您需要的任何参数到thread_switch
中,但目的是从线程t
切换到next_thread
。
提示
thread_switch
只需要保存/恢复被调用方保存寄存器。为什么?- 您可以在user/uthread.asm中看到uthread的汇编代码,这对于调试可能很方便。
- 关于gdb调试
实现
线程实现的思路:根据提示、参考内核中进程的切换,可确定思路:
- 线程状态与上下文:创建一个结构体记录线程自己的上下文和状态。在一个全局的该结构体数组中维护所有线程
- 线程调度:在全局数组中寻找下一个可运行的线程,然后进行线程的切换操作
- 线程切换:取出上一个线程寄存器中的值(状态)保存,将要执行的线程上一次保存的状态放入寄存器
线程创建(线程初始化):需要让线程执行传入的函数指针所指的函数、并完成线程堆栈初始化
- 通过ra寄存器实现线程的命令初始化:ra寄存器表示函数返回地址,因此可用此寄存器保存要执行的函数地址,在
thread_switch
后将会返回thread_schedule()
函数,若将ra更改为传入函数地址,则程序也会跳到传入函数的地址去执行命令 - 堆栈初始化:每个线程都应当有自己独立的堆栈,线程运行时通过寄存器sp来读写堆栈。寄存器sp保存指向堆栈顶部的指针,但线程的堆栈增长方向是从下向上增长的(即从大内存编号向向内存编号增长),因此sp中的指针应当指向
struct thread
中堆栈数组的最后一个元素。
- 通过ra寄存器实现线程的命令初始化:ra寄存器表示函数返回地址,因此可用此寄存器保存要执行的函数地址,在
线程状态和上下文:创建结构体
struct context
保存线程上下文,并在线程结构体中加入该字段struct context { uint64 ra; // return address uint64 sp; // stack pointer // 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; };
线程初始化:在
thread_create()
中完成线程初始化:①将函数地址放入保存函数返回地址的ra寄存器;②将堆栈数组最后一个元素的指针放入保存堆栈顶指针的sp寄存器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.ra = (uint64)func; t->context.sp = (uint64)(t->stack + STACK_SIZE - 1); }
线程调度:源码已给出调度的方式,这里只需要在指定位置调用
thread_switch
即可。但是传入的参数需要自己确定,这里传入了切换前后两个线程保存上下文结构体的地址。(下一点说明)void thread_schedule(void) { ... if (current_thread != next_thread) { // 需要切换线程的情况 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)¤t_thread->context); } ... }
线程切换:首先可以确定这一步操作可以分为两小步:①保存上一个线程上下文;②将下一个结构体上次保存的上下文依次放入寄存器。参考内核中用于切换进程的
switch.S
,可得到如下机器码。不难发现它通过传入参数寄存器a0中的地址,来把不同寄存器放入a0地址从0到104的偏移后地址来保存上下文,因此传入参数应当是指向上下文结构体的指针;恢复下一个线程的方法同理。thread_switch: /* YOUR CODE HERE */ // 先把寄存器内容保存到上一个thread的context中 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) // 再把寄存器内容换成下一个thread的context 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 */
遇到的问题
无法切换到c之外的线程
- 根据调试理论,failure如下图所示
- Error:通过gdb调试我找到了第一个观测到的error状态:在每个线程中只要一
printf
,该线程前边的线程的状态就会被改变成奇怪的数字。因此在thread_schedule()
中无法找到下一个状态是RUNNABLE
的线程,也就无法切换到c之外的其他线程。 - Bug:这部分还是很难在Bug-Error之间建立联系。想了半天突然想起那句“堆栈的增长方向是向上”,即堆栈增长方向与常识不太一样,是从内存编号大往小的地方增长。于是在初始化时尝试改变堆栈指针,果然修复了bug。
结果
- 运行结果
- 测试结果
Using threads (moderate)
要求
在本作业中,您将探索使用哈希表的线程和锁的并行编程。您应该在具有多个内核的真实 Linux 或 MacOS 计算机(不是 xv6,不是 qemu)上执行此分配。最新的笔记本电脑都有多核处理器。
文件 notxv6/ph.c
包含一个简单的哈希表,如果从单个线程使用,则正确,但在从多个线程使用时不正确。在您的主 xv6 目录中,键入此内容
$ make ph
$ ./ph 1
请注意,要构建ph,Makefile使用操作系统的gcc,而不是6.S081工具。ph 参数指定对哈希表执行放置和获取操作的线程数。运行一段时间后,ph 1 将产生类似于以下内容的输出:
100000 puts, 3.991 seconds, 25056 puts/second
0: 0 keys missing
100000 gets, 3.981 seconds, 25118 gets/second
您看到的数字可能与此示例输出相差两倍或更多,具体取决于您的计算机的速度、它是否具有多个内核以及它是否忙于执行其他操作。
ph
运行两个基准。首先,它通过调用 put()
向哈希表添加大量键,并以每秒 put
的数量为单位打印实现的速率。然后它使用 get()
从哈希表中获取key。它打印由于put
而应该在哈希表中但却没有的键的数量(在本例中为零),并打印每秒get
的数。
你可以告诉 ph
同时使用来自多个线程的哈希表,方法是给它一个大于 1 的参数。尝试 ph 2
:
$ ./ph 2
100000 puts, 1.885 seconds, 53044 puts/second
1: 16579 keys missing
0: 16579 keys missing
200000 gets, 4.322 seconds, 46274 gets/second
输出的第一行指示当两个线程同时向哈希表添加条目时,它们的总速率为每秒 53,044 次插入。这大约是运行 ph 1 的单线程速率的两倍。这是一个出色的“并行加速”,大约是人们所希望的 2 倍(即两倍的内核,每单位时间产生两倍的工作量)。
但是,缺少 16579 键的两行表示哈希表中有大量键应当存在但实际却不存在。也就是说,put
应该将这些键添加到哈希表中,但出了点问题。看看notxv6/ph.c
尤其是put()
和insert()
.
问题:为何2个线程会产生missing一个线程就不会?Identify可能导致key丢失的两个线程的时序图。
要避免这一系列事件,请在
put
和get
中插入 lock 和 unlock 语句notxv6/ph.c
,以便缺少的键数始终为 0,并且有两个线程。当make grade
表示您的代码通过ph_safe
测试时,您就完成了,这需要两个线程的零缺失键。此时,无法通过ph_fast
测试是正常的。
不要忘记调用 pthread_mutex_init()
。首先使用 1 个线程测试代码,然后使用 2 个线程进行测试。它是否正确(即您是否消除了丢失的键?)相对于单线程版本,双线程版本是否实现了并行加速(即每单位时间的总工作量更多)?
在某些情况下,并发 put()
在它们在哈希表中读取或写入的内存中没有重叠,因此不需要锁来相互保护。您能否更改 ph.c
以利用这种情况来获得某些 put()
的并行加速?提示:考虑每个哈希桶的锁
修改代码,以便某些put
操作并行运行,同时保持正确性。当make grade
说你的代码通过了ph_safe
和ph_fast
测试时,你就完成了。ph_fast
测试要求两个线程每秒的put
数量至少是一个线程的 1.25 倍。
实现
根据提示,可为哈希表的每个桶创建一个锁,因为不同桶中的键值对存取不存在并发冲突。因此在
ph.c
开头的定义全局数组:struct entry { ... }; struct entry* table[NBUCKET]; pthread_mutex_t locks[NBUCKET]; ...
在
main()
中,在创建线程前添加锁的初始化:int main() { ... // init locks for (int i = 0; i < NBUCKET; ++i) { pthread_mutex_init(locks + i, 0); } // 创建线程...... }
为涉及临界区内存读写的
put
和get
添加互斥锁:static void put(int key, int value) { int i = key % NBUCKET; // is the key already present? struct entry* e = 0; pthread_mutex_lock(locks + i); // lock! 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); // unlock! } static struct entry* get(int key) { int i = key % NBUCKET; struct entry* e = 0; pthread_mutex_lock(locks + i); // lock! for (e = table[i]; e != 0; e = e->next) { if (e->key == key) break; } pthread_mutex_unlock(locks + i); // unlock! return e; }
结果
- 运行结果
- 测试结果
- 注意:如果没有按照提示,只用一把互斥锁的话,速度会大大减小导致无法通过
ph_fast
。
原因就是,分桶的锁可以使不同的桶的存取操作依然保持并发,而一把锁则使得所有的存取操作都必须非并发。
Barrier(moderate)
要求
在此作业中,您将实现一个屏障:应用程序中所有参与线程都必须等待,直到所有其他参与线程也达到该点。您将使用 pthread 条件变量,这是一种类似于 xv6 的睡眠和唤醒的序列协调技术。
您应该在真实计算机上(不是 xv6,不是 qemu)上执行此操作。
文件 notxv6/barrier.c
包含一个不完整的屏障。
$ make barrier
$ ./barrier 2
barrier: notxv6/barrier.c:42: thread: Assertion `i == t' failed.
参数 2
指定在屏障上同步的线程数(barrier.c
中的 nthread
)。每个线程执行一个循环。在每次循环迭代中,线程调用 barrier()
,然后休眠随机微秒。断言触发是因为一个线程在另一个线程到达屏障之前离开屏障。期望的行为是每个线程在 barrier()
中阻塞,直到它们的所有 nthreads
个线程都调用了 barrier
。
您的目标是实现所需的屏障行为。除了您在
ph
任务中看到的互斥锁原语之外,您还需要以下新的pthread
原语。// go to sleep on cond, releasing lock mutex, acquiring upon wake up pthread_cond_wait(&cond, &mutex); // wake up every thread sleeping on cond pthread_cond_broadcast(&cond);
pthread_cond_wait
调用时释放互斥锁,并在返回之前重新获取互斥锁。
我们已经给了你barrier_init
。你的工作是实现 barrier()
,这样就不会发生panic。我们已经为您定义了struct barrier
;其字段供您使用。
有两个问题使您的任务复杂化:
- 你必须处理一连串
barrier
的调用,每一波我们称之为一轮。bstate.round
记录当前轮次。每次所有线程都达到障碍时,都应递增bstate.round
。 - 您必须处理一个线程在其他线程退出障碍之前绕循环运行的情况。尤其是,您正在从一轮到下一轮重用
bstate.nthread
变量。确保离开障碍并围绕循环运行的线程在上一轮仍在使用它时不会增加bstate.nthread
。
使用一个、两个和两个以上的线程测试代码。
实现
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) {
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);
}
结果
- 运行结果
- 测试结果
总结
make grade
个人收获
- 实际上线程的实现与进程十分相似,基本思路可总结为保存/恢复上下文、线程创建、线程调度、线程切换四个基本部分。(如前所述)
互斥锁和条件变量的使用有API,相对简单。但原理很重要:
- 自旋锁:若能获取🔒就执行;若不能就一直循环直到获取锁。占用CPU
- 互斥锁:切换到内核态,若能获取🔒就返回用户态执行;若不能获取锁就休眠。
- pthread中的mutex:开始是自旋锁,若能获取🔒就执行;若不能获取,循环一段时间后还不行就切换到内核使该线程睡眠
条件变量原理:在获取锁的情况下判断是否符合条件
- 若符合条件则继续向下执行,出临界区要释放锁;
- 若不符合条件,则释放锁然后进入睡眠,等待唤醒;醒来后再次获取锁,继续判断是否符合条件...
- 可通过广播or随机通知一个线程的方式唤醒因不符合某条件睡眠的使用同一条件变量的线程
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。