MIT 6.S081 Lab7 multithreading

392 阅读9分钟

#Head BLog

MIT 6.S081 xv6-labs-2020 往期回顾,本人实验记录,欢迎参考阅读!

  1. MIT 6.S081 Lab2 system calls
  2. MIT 6.S081 Lab3 page tables
  3. MIT 6.S081 Lab4 traps
  4. MIT 6.S081 Lab5 lazy page allocation
  5. MIT 6.S081 Lab6 cow fork

#Source

  1. MIT-6.S081 2020 课程官网
  2. Lab7: multithreading 实验主页
  3. MIT-6.S081 2020 xv6 book
  4. MIT-6.S081 xv6 book Chapter 6 Scheduling 个人笔记
  5. B站 - MIT 6.S081 Lec11: Thread switching

#My Code

  1. Lab7: multithreading 的 GitHub
  2. xv6-labs-2020 的 GitHub 总目录

#Motivation

Lab7: multithreading 主要是想让我们熟悉一下 OS 中的多线程技术。多线程无论是在 OS 中,还是 Application 程序中都是简化业务流程和加速的重要手段。整个实验分为三个小实验,彼此之间有联系,但并不紧密

  1. Lab: Uthread: switching between threads 是让我们模拟 xv6 中的调度细节。此模拟,其实简化了很多。真正要了解 xv6 进程切换的细节还需好好阅读 MIT-6.S081 2020 xv6 book
  2. Lab: Using threads 是想考察我们运用锁的能力。其实解决的问题很简单,就是多个线程一起操作同一份数据。怎样保证不乱套?怎样确保有条不紊?怎样在有条不紊的情况下提高运行速率?
  3. Lab: Barrier 属于多线程常用且高级的技巧。说白了,就是条件变量。线程很多,但不能一股脑都开始运行。我们需要有一种机制来确保,只有当所有线程都满足就绪条件(一般都是自定义)之后,才开始运行。其中的意味只有通过实例才能体会

下面谈谈我自己的心得体会,在我看来,Lab7: multithreading 在处理多线程问题方面还是处于入门级的

想要熟练掌握多线程技巧,理论(无非就是竞争,核心:何时何地上锁放锁?)是一方面,但不是最重要的。最重要的是,要实践。C 也好,C++ 也好,以及后来的 Go ,可以说基本上,所有的编程语言在解决多线程问题方面的技巧都是相通的

Lab7: multithreading 姑且就当放松一下啦,真正要搞明白 OS 中的进程切换问题,是要好好阅读 MIT-6.S081 xv6 book's Chapter 6 - Schedulingrtm 教授的讲课

我本人研读了 xv6 book 的相关章节后,做了些笔记在 MIT-6.S081 xv6 book Chapter 6 Scheduling 个人笔记 ,欢迎阅读,可能会给你带来一些思想碰撞的惊喜

在开始实验之前,一定要阅读 xv6-6.S081 的第六章节 Scheduling 及相关代码

#Uthread: switching between threads (moderate)

#Motivation

Lab: Uthread: switching between threads 想让我们在熟悉 xv6 进程切换的流程之后,在用户层通过 Linux C 的多线程技术模拟 xv6 内部的切换和调度细节

#Solution

这次我们的主要战场不再是 kernel/ 目录下的 .c 文件了,而是 user/ 目录下的 uthread.c 。并且,主要是添加并修改其中的关键函数

虽然 xv6 进程切换的细节我在 MIT-6.S081 xv6 book Chapter 6 Scheduling 个人笔记 中讲过,但在这里我还是简要地再重申一下,如下图,

Figure 6.1 - Switching from one user process to another.png

#S1 - 进程切换要义

进程 shell 切换到进程 cat ,看似很简单,但这过程是有很多道道的。可以说是 xv6 中最晦涩难懂的部分了

进程切换,第一要义,就是要做到对用户层透明,让用户感觉不到 xv6 进入了 kernel ;其次,就是进入 kernel 之后,是如何切换到其他进程的?这个过程可一点都不随意

在进程的 kernel 线程切换的过程,会有一个叫 Scheduler 的 CPU 守护线程接手,它负责寻找新的 kernel 线程即进程 cat 对应的 kernel 线程,将其扶上位运行

以及进程 shell 的 kernel 线程是怎么找到 CPU 的 Scheduler ?并且在下一次切换回进程 shell 时,确保进程 shell 能够接着之前切换( yield )处的点继续运行?

以上的细节如何保证?其实就靠 ra ,sp 和一些通用寄存器来确保,简称上下文切换( Context swtiching )

#S2 - thread_schedule() 调度线程

首先,我们来解析一下 uthread.c 中的主要业务逻辑,

int 
main(int argc, char *argv[]) 
{
  a_started = b_started = c_started = 0;
  a_n = b_n = c_n = 0;
  thread_init();
  thread_create(thread_a);
  thread_create(thread_b);
  thread_create(thread_c);
  thread_schedule();
  exit(0);
}

程序没做什么复杂的事情,就是启动三个线程 ABC ,然后调用 thread_schedule() 让它们切来切去

切来切去 = 上下文切换,其需要的 context(存档寄存器值)在给出的 uthread.c 并未声明,需要我们自己定义,在 user/uthread.c 中,

typedef struct {
  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;
} context;

kernel/proc.h 中的 struct context 中的内容相同,照抄过来就行,并在 struct thread 中添加该字段,

struct thread {
  char       stack[STACK_SIZE]; /* the thread's stack */
  int        state;             /* FREE, RUNNING, RUNNABLE */
  context    ctx;
};

之后,就可以在 thread_create() 中指定,线程被 Scheduler 选中后继续执行的点及运行该线程所必需的堆栈地址,

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->ctx.ra = (uint64)func;
  t->ctx.sp = (uint64)t->stack + STACK_SIZE;
}

现在可以考虑一下 thread_schedule() 啦,因为无论该线程是主动 yield 还是被选中调度,流程都包含这最关键的一步,

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((uint64)&t->ctx, (uint64)&current_thread->ctx);
  } else
    next_thread = 0;
}

其中的 thread_switch() 和 xv6 kernel 中的 kernel/swtch.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 */

要明白 swtch 其中的奥秘,必定要好好研究此段汇编代码,给个提示,ra + ret = pc

每个时钟中断,正在 CPU 上运行的线程都会主动 yield ,让出 CPU ,这是时间片轮转策略的核心,thread_yield() 无需多言,

void 
thread_yield(void)
{
  current_thread->state = RUNNABLE;
  thread_schedule();
}

就是将该线程状态从 RUNNING 回退到 RUNNABLE ,并且将控制权还给 Scheduler

至此,已经完成了用户层线程切换的大致工作

#Result

手动进入 qemu

make qemu
$uthread

也可以执行,

./grade-lab-thread uthread

进行单项测试

#Using threads (moderate)

#Motivation

Lab: Using threads 的目的,就是想训练我们:在多线程的情况,如何确保数据安全?具体表现为,多个线程同时操作同一份数据(实验中表现为哈希表)。怎样确保不会发生错写、漏写等一些列不安全的 case ?并且在正确的情况下,提高多线程的运行速率

#Solution

在这个子实验中,战场转移到 notxv6/ph.c 。要运行 ph.c ,不再是 xv6 qemu 编译了,而是用 Linux 或 Mac 的真实工具链

其实,这个问题很好解决。多线程竞争,确保有序,没有好的手段,就是上锁放锁。何时何地上锁放锁,这才是关键

首先,我们根据 Lab7: multithreading 实验主页 的提示,定义并初始化 locks ,

/** 全局变量 */ 
pthread_mutex_t lks[NBUCKET];

/** 在 main 中初始化 locks */
int
main(int argc, char *argv[])
{
  ...
  for (int i = 0; i < NKEYS; i++) {
    keys[i] = random();
  }

  /** 初始化locks */
  for(int i=0; i<NBUCKET; i++) 
    pthread_mutex_init(&lks[i], NULL);

  // first the puts
  ...
}

在这里,我直接一步到位,给哈希表的每个篮子都分配了锁,意在减小锁的颗粒度,减少不必要的上锁,从而提高多线程的运行速率

然后在 notxv6/ph.c:put() 处,为写操作上锁,

static 
void put(int key, int value)
{
  int i = key % NBUCKET;

  ...

  /** 写操作应上锁 */
  pthread_mutex_lock(&lks[i]);
  if(e){
    // update the existing key.
    e->value = value;
  } else {
    // the new is new.
    insert(key, value, &table[i], table[i]);
  }
  /** 写完放锁 */
  pthread_mutex_unlock(&lks[i]);
}

多线程情况下,修改同一份数据,是必须上锁的;读同一份数据,却不需要上锁。所以,不必改动 notxv6/ph.c:get()

#Result

进入 Lab7: multithreading 的根目录,执行,

make ph

编译完成之后,执行,

./ph 2

查看正确性,也可以通过,

./grade-lab-thread ph_safe
./grade-lab-thread ph_fast

进行单项测试

#Barrier (moderate)

#Motivation

Lab: Barrier 是想让我们熟悉一下条件变量,了解条件变量的应用场景

实验的大致情形是这样的,假设现在系统有4个线程。通过 pthread_create() 拉起了这 4 个线程,紧接着就进入了线程的业务循环中

实验设想:在开启业务循环时,我们并不想线程刚被创建出来就立马执行业务逻辑,而是先阻塞一会,等队友(其他线程)都上线了,再一起执行业务逻辑。对应 Lab7: multithreading 实验主页 的原话,

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.

其中的阻塞 = notxv6/barrier.c:barrier() ,试想创建完 4 个线程后,有 3 个线程在第一时刻进入了各自的业务循环中,但还有一个线程迟迟还未就位。我们希望已经就位的 3 个线程能够等等这慢悠悠的4号线程

于是,我们将这3个线程先挂起,等 4 号线程上线后,再唤醒它们,一起行动

#Solution

就是这么简单的想法,在 C 中用条件变量实现( Go 也有别的方法)

等待 4 号线程上线的过程,在硬件层面来看,就是 CPU 轮询,一直观察是否满足条件。在 Lab: Barrier 中,就是 CPU 一直在判断 4 号线程是否已经进入业务循环中。反应到代码中,

static void 
barrier()
{
  // YOUR CODE HERE
  //
  // Block until all threads have called barrier() and
  // then increment bstate.round.
  //
  if(++bstate.nthread == nthread) {
    ...
  }
  else {
   ...
  }
}

当线程都上线了,就执行唤醒操作;如果还有没上线的,那就先挂起,

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) {
    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);
}

这是扩展完的代码。其中,在线程都上线的情况下,bstate.round++bstate.nthread = 0 是为下一次业务循环做准备。该实验的业务循环,其实就是重复上面提到的 3 线程等 4 号线程这么一件非常质朴的事

记得,在用条件变量时需要上锁放锁。别问为什么,目前还没到需要了解其工作原理的层次,当成语法记住就可以

还有,在 notxv6/barrier.c:barrier_init() 中初始化 bstate.round

static void
barrier_init(void)
{
  assert(pthread_mutex_init(&bstate.barrier_mutex, NULL) == 0);
  assert(pthread_cond_init(&bstate.barrier_cond, NULL) == 0);
  bstate.nthread = 0;
  bstate.round = 0;
}

#Result

进入 Lab7: multithreading 的根目录,执行,

make barrier

编译完成之后,执行,

./barrier 4

不一定为 4 ,大于 1 的数都可以。或者进入单项测试,

./grade-lab-thread barrier

#Reference

  1. CSDN - [MIT 6.S081] Lab 7: Multithreading