XV6

这个实验是来自 User Level threads。学习用户级线程可以为内核级线程的实现作准备。

实验步骤

用户级线程是利用用户程序的线程库实现的,这里并没有涉及系统调用,每个线程都有自己的栈。

本节实验我们将实现代码完成线程之间的上下文切换,从而完成一个简单的用户级线程库。

相比于 xv6 源码,我们添加了 uthread.cuthread_switch.S。确保 uthread_switch.S 是以 .S 结尾。并在 Makefile 中添加相应的生成规则(在 _forktest 之后),内容如下:

_uthread: uthread.o uthread_switch.o
	$(LD) $(LDFLAGS) -N -e main -Ttext 0 -o _uthread uthread.o uthread_switch.o $(ULIB)
	$(OBJDUMP) -S _uthread > uthread.asm

注意是以 tab 缩进,而不是空格。

然后添加 _uthreadMakefile 中的 UPROGS 变量中。

运行 xv6,然后在 xv6 shell 中运行 uthread,接着你会看到如下信息:

~ make CPUS=1 qemu-nox
qemu-system-i386 -nographic -drive file=fs.img,index=1,media=disk,format=raw -drive file=xv6.img,index=0,media=disk,format=raw -smp 1 -m 512 
xv6...
cpu0: starting 0
sb: size 1000 nblocks 941 ninodes 200 nlog 30 logstart 2 inodestart 32 bmap start 58
init: starting sh
$ uthread
my thread running
my thread 0x2DA8
my thread running
my thread 0x4DB0
my thread 0x2DA8
my thread 0x4DB0
my thread 0x2DA8

uthread 创建两个线程并在它们之间来回切换,每个线程输出 my thread ... 后让出 CPU 给另一个线程。

在研究 uthread_switch.S 之前,你得理解 uthread.c 是如何使用 thread_switch

uthread.c 有两个全局变量:current_threadnext_thread,分别指向一个 thread 结构体。这个结构体有一个给线程使用的栈和一个栈指针(sp,指向线程的栈顶)。thread_switch 的工作是通过 current_thread 保存当前线程的状态,并通过 next_thread 恢复下一个线程的状态,并让 current_thread 指向 next_thread 所指的地方,因此当 uthread_switch 返回的时候,next_thread 开始运行,也就是 current_thread

你应该学习 thread_create,它给新的线程设置用户栈,这也暗示了你 thread_switch 的功能。thread_switch 使用汇编指令 popalpushal 来恢复和保存 8 个通用 x86 寄存器。注意 thread_create 在栈中模拟了此过程。

为了实现 thread_switch,你需要知道 C 编译器如何在内存中展开 thread 结构体,如下

    --------------------
    | 4 bytes for state|
    --------------------
    | stack size bytes |
    | for stack        |
    --------------------
    | 4 bytes for sp   |
    --------------------  <--- current_thread
         ......

         ......
    --------------------
    | 4 bytes for state|
    --------------------
    | stack size bytes |
    | for stack        |
    --------------------
    | 4 bytes for sp   |
    --------------------  <--- next_thread

next_threadcurrent_thread 分别包含了 thread 结构体的起始地址。

若要写 thread 结构体的 sp 域,你的汇编应该为:

movl current_thread %eax
movl %esp, (%eax)

上述指令将 %esp 保存到 current_thread->sp,因为 sp 就在 thread 结构体的 0 偏移处。你可以通过编译器编译 uthread.c 时产生了 uthread.asm 学习汇编。

你可以通过 gdb 单步调试你的 thread_switch,调试步骤如下:

(gdb) symbol-file _uthread
Load new symbol table from "_uthread"? (y or n) y
Reading symbols from _uthread...done.
(gdb) b thread_switch
Breakpoint 1 at 0x2c8: file uthread_switch.S, line 11.
(gdb) c

断点会在你运行 uthread 时触发。然后你可以通过以下指令查看 uthread 的状态

(gdb) p/x next_thread->sp
$1 = 0x4d88
(gdb) x/9x next_thread->sp
0x4d88 <all_thread+16360>:	0x00000000	0x00000000	0x00000000    0x00000000
0x4d98 <all_thread+16376>:	0x00000000	0x00000000	0x00000000    0x00000000
0x4da8 <all_thread+16392>:	0x00000190

什么地址是 0x190,哪里是 next_thread 的栈顶?

其他

用户级线程在操作系统中有时表现差劲。例如,如果一个用户级线程由于调用系统调用而阻塞,另一个用户级线程也不会运行,因为用户级线程调度器不知道哪个线程被 xv6 内核调出去了。再举一个例子,两个用户级线程不能同时运行在两个处理核上,因为 xv6 调度器意识不到用户级线程的存在,xv6 调度器是以 PCB 进行调度的,用户级线程是公用一个 PCB 的。

本实验的代码不能实现并行执行线程,因为多核处理器有可能同时调用 thread_schedule,选择同一个线程,导致临界资源竞争问题。

有如下几个方法解决这个问题:

  1. 使用调度器激活。
  2. 实现内核级线程(Linux 内核实现)。

添加锁、条件变量、障碍到你的线程包中。


一个网页浏览器的一次网页访问可以分为以下步骤:

  1. 一个线程用来从服务器接受数据。
  2. 一个线程用来显示文本。
  3. 一个线程用来处理图片(如解压)。
  4. 一个线程用来显示图片。

下载线程下载完文本后,切换到文本线程显示文本;显示文本后又回到下载线程下载图片,然后切换到解压线程解压图片,最后切换到图片线程显示图片。

如果没有线程概念,那么文本和图片一起处理,导致前期浏览器一片空白,影响交互。

这些线程共享资源,包括缓冲区,下载线程将数据放到缓冲区,显示线程可以直接读取数据,而不需要切换页表。

文本线程和图片线程公用 IO,因此输出到屏幕都不需要切换 IO。


  1. 用户线程和函数调用有什么区别呢?

    用户线程体现在并发这个特性,而函数只是顺序执行流的封装。用户线程要主动调用 yield() 释放 CPU 使用权,切换到其他线程,而函数是被调用者调用。

    用户线程有自己的栈,而函数调用只是普通的压栈。

  2. 用户线程一阻塞,同个进程的全部线程都阻塞。因为阻塞进入了内核,转去了其他进程。