经历了系统调用和页表的毒打后,我们迎来了《操作系统》实验中最考验并发逻辑的一环:多线程 (Thread)

本次实验分为三个任务:实现用户态线程切换(Uthread)、解决多线程哈希表的竞态条件(Using threads)、以及实现线程同步屏障(Barrier)。看似不难,但里面的“环境陷阱”和“死锁危机”坑哭了无数新手。今天,这篇万字长文带你用最优雅的代码一次性通关!

🛠️ 一、 环境匹配与踩坑预警(极度重要)

踩坑点 1:到底在哪运行?(QEMU vs 宿主机)

这是本实验最大的陷阱!很多同学在做任务 2 和任务 3 时,疯狂在 make qemu 里面找 phbarrier 命令,结果根本找不到。 避坑指南: * 任务 1 (Uthread) 是在 XV6 模拟器(QEMU)里运行的。

  • 任务 2 (ph) 和 任务 3 (barrier) 是直接在你的宿主机操作系统(比如 Ubuntu 或 银河麒麟 Kylin Linux)原生运行的!它们用的是真实的 Linux pthread 库!

踩坑点 2:本地 Linux 环境的 pthread 缺失

在银河麒麟等国产 Linux 或精简版 Ubuntu 中,编译后两个任务时可能会报 pthread 相关的未定义引用错误。 避坑指南: 确保你的宿主机安装了完整的 C/C++ 编译环境:

sudo apt-get install build-essential

🧠 二、 核心难点剖析

  1. 汇编层面的上下文切换 (Context Switch) 在任务 1 中,我们需要手写 RISC-V 汇编。难点在于:到底该保存哪些寄存器? 答案是:只需要保存 Callee-saved(被调用者保存) 的寄存器,即 ra, sp, s0s11。不要去保存 a0-a7t0-t6,因为 C 语言的函数调用约定会自动处理它们。

  2. 并发哈希表的“丢失更新” (Lost Updates) 任务 2 中,多个线程同时向哈希表的同一个“桶(Bucket)”里头插法链表时,如果两个线程同时读取了旧的头节点,后写入的会直接覆盖掉前一个,导致数据丢失。必须给每个桶单独加一把锁

🚀 三、 任务一:Uthread (用户态线程切换)

目标: 在用户态实现线程的创建和上下文切换。

1. 定义 Context 结构体

打开 user/uthread.c,在文件顶部加入 struct context。只需要保存 RISC-V 规定的 14 个寄存器:

// 放在 struct thread 定义的上方
struct context {
  uint64 ra;
  uint64 sp;
  // callee-saved registers
  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,把 context 加进去
struct thread {
  char       stack[THREAD_SIZE]; /* the thread's stack */
  int        state;              /* FREE, RUNNING, RUNNABLE */
  struct context context;        /* 线程上下文 */
};

2. 完善 thread_create (初始化新线程)

user/uthread.c 中找到 thread_create 函数,为其分配栈指针并设置返回地址:

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;
  // --- 新增代码 ---
  // ra 指向线程函数的入口
  t->context.ra = (uint64)func;
  // sp 指向栈底 (注意栈是向下生长的,所以要加 THREAD_SIZE)
  t->context.sp = (uint64)t->stack + THREAD_SIZE;
  // --- 新增结束 ---
}

3. 编写核心汇编:uthread_switch.S

打开 user/uthread_switch.S,用 sd 保存当前线程寄存器,用 ld 恢复下一个线程寄存器: (注意 a0 寄存器存放的是 current_thread->context 的地址,a1 存放的是 next_thread->context 的地址)

    .text
    .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 */

4. 调用上下文切换

回到 user/uthread.c,在 thread_schedule 函数里调用汇编函数:

  // 找到这句注释: /* YOUR CODE HERE */
  // Invoke thread_switch to switch from t to next_thread:
  thread_switch((uint64)&t->context, (uint64)&next_thread->context);

⚡ 四、 任务二:Using threads (并发哈希表)

目标: 在宿主机运行,利用 pthread 库解决哈希表的并发写冲突。

打开 notxv6/ph.c

  1. 定义和初始化锁:我们给 5 个桶(Bucket)每个分配一把锁,提高并发效率。

pthread_mutex_t locks[NBUCKET]; // 全局定义 5 把锁

int main(int argc, char *argv[]) {
  // 在 main 函数的开头,创建线程之前,初始化锁:
  for(int i = 0; i < NBUCKET; i++) {
    pthread_mutex_init(&locks[i], NULL);
  }
  // ... 
  1. 在 put 函数中加锁:只在多线程真正修改链表指针的那几行加锁。

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

  // 根据当前 key 所属的 bucket 请求对应的锁
  pthread_mutex_lock(&locks[i]);

  // 这里的链表插入操作受到保护
  struct entry *e = 0;
  for (e = table[i]; e != 0; e = e->next) {
    if (e->key == key)
      break;
  }
  if(e){
    e->value = value;
  } else {
    insert(key, value, &table[i], table[i]);
  }

  // 释放锁
  pthread_mutex_unlock(&locks[i]);
}

🛡️ 五、 任务三:Barrier (线程同步屏障)

目标: 实现一个同步屏障,所有线程都必须到达屏障处,才能一起被唤醒进入下一轮。

打开 notxv6/barrier.c,修改 barrier() 函数。我们需要用到互斥锁(保护计数器)和条件变量(用于休眠和唤醒):

static void 
barrier()
{
  // 1. 获取锁,准备操作全局变量
  pthread_mutex_lock(&bstate.barrier_mutex);
  
  // 2. 到达的线程数 +1
  bstate.nthread++;

  // 3. 判断是否所有线程都到了
  if(bstate.nthread == nthread) {
    // 如果全到了:
    bstate.round++;       // 轮数 +1
    bstate.nthread = 0;   // 计数器清零,为下一轮做准备
    // 广播唤醒所有正在沉睡等待的线程!
    pthread_cond_broadcast(&bstate.barrier_cond);
  } else {
    // 如果没全到:
    // 进入沉睡,同时自动释放 mutex 锁;被唤醒时会自动重新获取 mutex 锁
    pthread_cond_wait(&bstate.barrier_cond, &bstate.barrier_mutex);
  }

  // 4. 释放锁
  pthread_mutex_unlock(&bstate.barrier_mutex);
}

🎉 六、 测试与验收

最后也是最激动人心的时刻!咱们依次运行官方的评分验证脚本:

验证任务 1 (QEMU中):

make clean && make qemu
# 进入 xv6 系统后执行:
$ uthread

验证任务 2 & 3 (直接在你的主机/麒麟终端执行):

make ph
./ph 1
./ph 2

make barrier
./barrier 2

或者直接祭出最终大杀器:

./grade-lab-thread

看到满屏绿色的 OKScore 60/60,你的多线程大闯关就完美落幕了!

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐