Lab7 多线程
目录
预备知识
进程切换的情况
Xv6在以下两种情况在CPU上进行进程切换:
- 当进程等待设备或者管道I/O完成、或者等待进程退出、或者通过sleep和wakeup机制进行切换
- Xv6周期性的切换进程,这是为了处理长时间占用CPU的进程
进程切换的实现
类似于系统调用和trap,用户级进程的切换原理依然是上下文切换。这一类的难点在于:
- 如果以对用户进程透明的方式切换;
- 进程退出时如何释放进程的内存和其他资源,进程本身无法完成这些事;
- 多核心机器的每个核心都必须记住它正在执行哪个进程;
- sleep和wakeup机制需要避免 导致唤醒通知丢失的竞争
从一个用户线程切换到另一个用户进程的步骤如下:
- 转换到旧进程的内核线程(系统调用或中断)
- 到当前CPU的调度线程的上下文切换(swtch)
- 到新进程的内核线程的上下文切换(swtch)
- 返回到新进程的trap
相关代码
上下文结构体:struct context
// kernel/proc.h
struct context {
uint64 ra; //返回地址
uint64 sp; //栈顶指针
// 被调用者负责保存的寄存器
uint64 s0;
...
uint64 s11;
};
上下文切换函数:swtch.S
swtch由汇编代码实现,负责执行内核线程切换的保存和恢复。swtch接受两个参数:context 结构体 old 和 new。当进程需要放弃CPU时,进程的内核线程调用swtch保存自己的上下文到 old 中,再把 new 中的上下文写入CPU,完成切换。
Context switch
#
# void swtch(struct context *old, struct context *new);
#
# Save current registers in old. Load from new.
.globl swtch
swtch:
#保存当前CPU的上下文到old,这里a0代表第一个参数
sd ra, 0(a0) #保存的是 调用swtch的返回地址
sd sp, 8(a0) #栈顶指针
sd s0, 16(a0)
...
sd s11, 104(a0)
#读取new中的上下文到cpu中
ld ra, 0(a1)
ld sp, 8(a1)
ld s0, 16(a1)
...
ld s11, 104(a1)
ret#这里返回到新进程的ra指定的地址
第一次调度:sched()
执行后会转到scheduler().
// kernel/proc.c
void
sched(void)
{
int intena;
struct proc *p = myproc();
//在放弃cpu之前,进程必须持有自己的锁p->lock
if(!holding(&p->lock))
panic("sched p->lock");
//并且释放了它持有的其他锁
//noff记录了当前CPU上 push_off 被调用的次数
//调用 push_off 时,会禁用中断并noff+1.
//noff等于1说明只有当前进程持有自己的锁
if(mycpu()->noff != 1)
panic("sched locks");
//并且改变了自己的状态
if(p->state == RUNNING)
panic("sched running");
//中断是否关闭
//当进程锁被持有时就应该关中断
if(intr_get())
panic("sched interruptible");
//记录了CPU原始的开关中断状态
//noff==0时根据intena来恢复状态
intena = mycpu()->intena;
//第一次swtch,换出当前进程的上下文,装入cpu调度线程的上下文
swtch(&p->context, &mycpu()->context);
//返回时恢复原始中断状态
mycpu()->intena = intena;
}
第二次调度:sheduler()
// 每个 CPU 的进程调度器。
// 每个 CPU 在完成初始化后调用 scheduler() 函数。
// 调度器函数不会返回。它会循环执行以下操作:
// 选择一个要运行的进程。
// 切换到该进程并开始运行。
// 最终,该进程通过切换操作将控制权交还给调度器。
void
scheduler(void)
{
struct proc *p;
struct cpu *c = mycpu();
c->proc = 0;
for(;;){
//开中断
intr_on();
int nproc = 0;
for(p = proc; p < &proc[NPROC]; p++) {
//在调度之前就要获取进程的锁
acquire(&p->lock);
if(p->state != UNUSED) {
nproc++;
}
//当第一次发现runnable的进程
if(p->state == RUNNABLE) {
//切换到选中的进程
//在返回到本函数之前释放锁和再次获取锁都由进程本身负责
p->state = RUNNING;
c->proc = p;
swtch(&c->context, &p->context);//第二次swtch
// Process is done running for now.
// It should have changed its p->state before coming back.
c->proc = 0;
}
release(&p->lock);
}
if(nproc <= 2) { // only init and sh exist
intr_on();
asm volatile("wfi");
}
}
}
Uthread: switching between threads
实现用户级线程的调度。模仿进程调度即可。
线程上下文
和进程上下文的结构体一样
//user/uthread.c
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 uth_context; //线程上下文
};
线程初始化
当thread_schedule() 首次运行某个线程时,该线程会在自己的栈上执行传递给 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;
//将返回地址设为线程函数,这样切换后就会直接执行func
t->uth_context.ra = (uint64)func;
//栈是高地址往低地址增长,因此初始化stack的最高地址
t->uth_context.sp = (uint64)&t->stack + (STACK_SIZE-1);
}
线程调度
// user/uthread.c:thread_schedule
...
next_thread->state = RUNNING;
t = current_thread;
current_thread = next_thread;
//调度
thread_switch( (uint64)&t->uth_context, (uint64)&next_thread->uth_context);
完成汇编代码
//user/uthread_switch.S
...
#保存当前线程的寄存器
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
运行uthread可以看到三个线程的输出
Using threads
在示例中,当使用两个线程进行插入时会出现大量的缺少键情况。原因也很简单,当两个线程一起执行put,同时计算 key%NBUCKET 得到了相同的结果。这是就会去同一个 table执行头插法插入。两个线程同时进入insert函数插入头结点时,这个时候先进行的线程就会被第二个线程覆盖,从而丢失数据。
解决的方法也很简单,为每个NBUCKET设置单独的锁。保证不同的线程不会操作同一个table即可,既保证了线程安全也降低了锁粒度。
// notxv6/ph.c
pthread_mutex_t lock[NBUCKET];
// main
//
// first the puts
//
//在put前初始化锁
for(int i = 0; i < NBUCKET; ++i)
pthread_mutex_init(&lock[i], NULL);
//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;
...
else {
// the new is new.
insert(key, value, &table[i], table[i]);
}
pthread_mutex_unlock(&lock[i]);//释放锁
}
可以通过ph_safe 和 ph_fast
== Test ph_safe == make[1]: 进入目录“/home/gqsys/Desktop/xv6-labs-2020”
gcc -o ph -g -O2 notxv6/ph.c -pthread
make[1]: 离开目录“/home/gqsys/Desktop/xv6-labs-2020”
ph_safe: OK (7.7s)
== Test ph_fast == make[1]: 进入目录“/home/gqsys/Desktop/xv6-labs-2020”
make[1]: “ph”已是最新。
make[1]: 离开目录“/home/gqsys/Desktop/xv6-labs-2020”
ph_fast: OK (17.8s)
Barrier
实现一个barrier,所有的线程都需要在 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);
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);
}
顺利通过。
== Test barrier == make[1]: 进入目录“/home/gqsys/Desktop/xv6-labs-2020”
make[1]: “barrier”已是最新。
make[1]: 离开目录“/home/gqsys/Desktop/xv6-labs-2020”
barrier: OK (3.1s)

浙公网安备 33010602011771号