调度
上下文切换
参考[mit6.s081] 笔记 Lab7: Multithreading | 多线程,xv6book
进程切换时包括以下几个步骤
- 用户内核转换(系统调用或者终端)到旧进程的内核线程
- 上下文切换到当前CPU的调度程序线程
- 上下文切换到新进程的内核线程
- 返回用户级进程的trap
xv6调度程序为每个CPU有一个专用线程(保存的寄存器和堆栈),因为在旧进程的内核堆栈上执行是不安全的。
几个功能 - swtch:执行内核线程切换时的保存和回复,他只是保存和恢复32RISC-V寄存器组,称之为context即上下文,在
proc.h
中定义struct context
,这个结构体包含在进程的struct proc
或者CPU的struct cpu
。swtch有两个参数:struct context *old
和struct context *new
。它将当前寄存器保存在 old ,加载寄存器 new ,然后返回。 - C 编译器在调用者中生成代码以将caller-save的寄存器保存在堆栈上。swtch。它不保存程序计数器。反而,swtch 保存 ra 寄存器,保存返回地址 swtch 被调用。现在 swtch 从新上下文中恢复寄存器,该上下文保存了先前保存的寄存器值 swtch 。swtch返回到恢复后的指令所指向的 ra 寄存器,即新线程之前调用 swtch 的指令。此外,它返回新线程的堆栈,因为那是恢复的 sp 所指向的位置。
- 内核调度器无论是通过时钟中断进入(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。
一个可能的切换流程是
- 中断结束时
usertrap
调用yield
。yield
依次调用sched
,它调用 swtch 将当前上下文保存在p->context
并切换到先前保存的调度程序上下文cpu->context
Lab
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.
本实验将使您熟悉多线程。您将在用户级线程包中实现线程之间的切换,使用多个线程来加速程序,并实现一个屏障。
Before writing code, you should make sure you have read “Chapter 7: Scheduling” from the xv6 book and studied the corresponding code.
在编写代码之前,你应该确保你已经阅读了xv6书中的“第7章:调度”,并研究了相应的代码。
Uthread: switching between threads
In this exercise you will design the context switch mechanism for a user-level threading system, and then implement it. To get you started, your xv6 has two files user/uthread.c and user/uthread_switch.S, and a rule in the Makefile to build a uthread program. uthread.c contains most of a user-level threading package, and code for three simple test threads. The threading package is missing some of the code to create a thread and to switch between threads.
Your job is to come up with a plan to create threads and save/restore registers to switch between threads, and implement that plan. When you’re done, make grade should say that your solution passes the uthread test.
在本练习中,您将为用户级线程系统设计上下文切换机制,然后实现它。为了开始,您的xv6有两个文件user/uthread.c和user/uthread_switch。S、 以及Makefile中用于构建uthread程序的规则。uthread.c包含大部分用户级线程包,以及三个简单测试线程的代码。线程包缺少创建线程和在线程之间切换的一些代码。
你的工作是制定一个创建线程和保存/恢复寄存器以在线程之间切换的计划,并实施该计划。当你完成时,make grade应该表明你的解决方案通过了uthread测试。
You will need to add code to thread_create() and thread_schedule() in user/uthread.c, and thread_switch in user/uthread_switch.S. One goal is ensure that when thread_schedule() runs a given thread for the first time, the thread executes the function passed to thread_create(), on its own stack. Another goal is to ensure that thread_switch saves the registers of the thread being switched away from, restores the registers of the thread being switched to, and returns to the point in the latter thread’s instructions where it last left off. You will have to decide where to save/restore registers; modifying struct thread to hold registers is a good plan. You’ll need to add a call to thread_switch in thread_schedule; you can pass whatever arguments you need to thread_switch, but the intent is to switch from thread t to next_thread.
您需要向user/uthread.c中的thread_create()和thread_schedule()以及user/uthread_switch.S中的thread_switch添加代码。一个目标是确保当thread_schedule()首次运行给定线程时,该线程在其自己的堆栈上执行传递给thread_create()的函数。另一个目标是确保thread_switch保存被切换离开的线程的寄存器,恢复被切换到的线程的寄存器,并返回到后一个线程指令中最后停止的点。您必须决定在哪里保存/恢复寄存器;修改struct线程来保存寄存器是一个很好的计划。您需要在thread_schedule中添加对thread_switch的调用;您可以将所需的任何参数传递给threadswitch,但目的是从thread t切换到nextthread。
提示
- thread_switch 只需要保存/恢复被callee-save寄存器
- 在uthread.asm中查看汇编代码,方便debug
- 使用riscv64-linux-gnu-gdb单步调试thread_switch
-
thread_switch需要保存以及恢复寄存器,给thread添加结构体context,thread_switch传入的参数,分别应该是当前进程上下文和新进程上下文(整形参数,存储在寄存器a0与a1中),在asm文件定义thread_switch
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; /* the thread's saved context */ }; extern void thread_switch(uint64, uint64);
.text /* * save the old thread's registers, * restore the new thread's registers. */ .globl thread_switch thread_switch: /* a0 = &thread_current.context */ /* a1 = &thread_to_run.context */ /* * save the old thread's registers */ 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) /* * restore the new thread's registers */ 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 */
-
在thread_schedule添加线程切换
thread_switch((uint64)&t->context, (uint64)&next_thread->context);
-
thread_create添加堆栈信息
t->context.ra = (uint64)func; t->context.sp = (uint64)&t->stack + STACK_SIZE;
Using threads
在linux或者macos中编译notxv6/pc
ph: notxv6/ph.c
gcc -o ph -g -O2 notxv6/ph.c -pthread
运行./ph 1
,参数指定执行在哈希表上put和get操作的线程数目,ph运行了两个基准测试。首先,它通过调用put()将大量键添加到哈希表中,并以每秒的put为单位打印实现的速率。它使用get()从哈希表中获取密钥。它打印出由于put而应该在哈希表中但缺失的数字键(在这种情况下为零),并打印出每秒获得的次数。
$ ./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
./ph 2
输出的第一行表示,当两个线程同时向哈希表添加条目时,它们的总插入速率为每秒53044次。这大约是运行ph 1的单线程速率的两倍。这是一个极好的“并行加速”,大约是2倍,正如人们可能希望的那样(即两倍的内核,每单位时间产生两倍的工作量)。然而,表示缺少16579个键的两行表示哈希表中本应存在的大量键不存在。也就是说,本应将这些键添加到哈希表中,但出现了问题。看看notxv6/ph.c,特别是put()和insert()。
Why are there missing keys with 2 threads, but not with 1 thread? Identify a sequence of events with 2 threads that can lead to a key being missing. Submit your sequence with a short explanation in answers-thread.txt
为什么有2个线程的密钥缺失,而没有1个线程的?用2个线程标识一系列可能导致密钥丢失的事件。在answers-thread.txt中提交您的序列,并附上简短的解释
在程序中有NBUCKET
个数组用于存储entry
struct entry {
int key;
int value;
struct entry *next;
};
struct entry *table[NBUCKET];
int keys[NKEYS];
put函数会往key%NBUCKET
编号的链表添加entry,这里问题在put函数中。
static
void put(int key, int value)
{
int i = key % NBUCKET;
pthread_mutex_lock(&lock[i]);
// 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(&lock[i]);
}
如果两个线程分别往同一个链表末端准备插入元素,在线程1插入未完成时,切换到线程2,这个时候线程2插入了元素,切换到线程1,线程1持有的链表尾部指针,指向的元素就不是链表末尾,在插入,引起线程2插入的元素丢失。
这里必然要求在put操作链表的时候进行加锁,并且ph_fast测试要求两个线程每秒的输出至少是一个线程的1.25倍。这意味加锁的范围不能太大,尽可能减少等待,所以这里应该对每个链表进行加锁,而不是对链表数组struct entry *table[NBUCKET]
加锁。
// ph.c
pthread_mutex_t lock[NBUCKET];
static
void put(int key, int value)
{
int i = key % NBUCKET;
pthread_mutex_lock(&lock[i]);
// 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(&lock[i]);
}
int
main(int argc, char *argv[]){
// ...
for(int i = 0; i < NBUCKET; i++){
pthread_mutex_init(&lock[i], NULL);
}
}
Barrier
在本任务中,您将实现一个屏障:应用程序中的一个点,所有参与的线程都必须等待,直到所有其他参与的线程也到达该点。您将使用pthread条件变量,这是一种类似于xv6的睡眠和唤醒的序列协调技术。除了在ph测试中用的原语,还需要这几个原语
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
需要实现barrier
函数,首先当其被调用时需要递增bstate.nthread
,判断是否等于线程数目,不是则睡眠等待pthread_cond_wait
,是则通知所有睡眠线程,这个结构体被所有线程共享,所以修改是需要加锁。
struct barrier {
pthread_mutex_t barrier_mutex;
pthread_cond_t barrier_cond;
int nthread; // Number of threads that have reached this round of the barrier
int round; // Barrier round
} bstate;
static void
barrier()
{
pthread_mutex_lock(&bstate.barrier_mutex);
bstate.nthread++;
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);
}
总结
以下是关于POSIX线程(pthread)中互斥锁(mutex)和条件变量(cond)相关原语的详细解释:
-
pthread_mutex_t lock;
作用:声明一个互斥锁变量。
功能:用于保护共享资源,确保同一时间只有一个线程可以访问临界区代码,防止数据竞争(Data Race)。
特点:- 互斥锁需要先初始化后使用。
- 静态初始化可使用宏
PTHREAD_MUTEX_INITIALIZER
,动态初始化需调用pthread_mutex_init()
。
-
pthread_mutex_init(&lock, NULL);
作用:初始化互斥锁。
参数:&lock
:指向互斥锁的指针。NULL
:表示使用默认属性(如非递归锁)。也可通过属性对象设置特殊行为(如递归锁、优先级继承等)。
注意:- 动态初始化的互斥锁需在不再使用时调用
pthread_mutex_destroy()
释放资源。
-
pthread_mutex_lock(&lock);
作用:尝试获取互斥锁。
行为:- 若锁未被持有,线程立即获得锁并继续执行。
- 若锁已被其他线程持有,当前线程阻塞,直到锁被释放。
用途:进入临界区前调用,确保独占访问共享资源。
-
pthread_mutex_unlock(&lock);
作用:释放互斥锁。
行为:- 释放锁后,其他阻塞在
pthread_mutex_lock()
的线程将有机会获取锁。
注意: - 必须在临界区结束时调用,否则会导致死锁。
- 释放锁后,其他阻塞在
-
pthread_cond_wait(&cond, &mutex);
作用:使线程进入等待状态,释放互斥锁并等待条件变量。
参数:&cond
:指向条件变量的指针。&mutex
:与条件变量关联的互斥锁。
行为:
- 原子操作:释放
mutex
,线程进入等待队列(睡眠)。 - 被唤醒后:重新获取
mutex
,然后函数返回。
使用模式:
pthread_mutex_lock(&mutex); while (条件不满足) { pthread_cond_wait(&cond, &mutex); // 等待期间释放锁,唤醒后重新获取 } // 处理临界区操作 pthread_mutex_unlock(&mutex);
注意:
- 必须使用
while
而非if
检查条件,防止虚假唤醒(Spurious Wakeup)。但是lab中不能这样做???我认为,是因为用的pthread_cond_broadcast而不是pthread_cond_signal,这里只需要用if或者不用即可 - 调用前必须已持有
mutex
,否则行为未定义。
-
pthread_cond_broadcast(&cond);
作用:唤醒所有等待在条件变量cond
上的线程。
行为:- 所有等待线程被移至互斥锁的竞争队列,需重新获取锁后才能继续执行。
- 与
pthread_cond_signal()
的区别:后者仅唤醒一个线程。
使用场景:
- 当条件变为真且需要唤醒所有等待线程时(如资源池状态变化)。
典型流程:
pthread_mutex_lock(&mutex); // 修改条件为真(如资源可用) condition = true; pthread_cond_broadcast(&cond); // 唤醒所有等待线程 pthread_mutex_unlock(&mutex);
- 互斥锁(Mutex):确保临界区互斥访问,避免数据竞争。
- 条件变量(Condition Variable):解决线程间的协作问题,允许线程在条件不满足时主动休眠,由其他线程唤醒。
- 关键原则:
- 操作条件变量时,必须持有对应的互斥锁。
- 使用
while
循环检查条件,防止虚假唤醒。 - 在修改条件后调用
pthread_cond_signal()
或pthread_cond_broadcast()
通知等待线程。