[计算机操作系统] —— 用户级线程

857 阅读3分钟

考虑两个进程的切换除了把指令切过去,还要切换内存映射的关系,我们能不能只切换指令序列呢?线程保留了并发的优点,避免了进程切换的代价(多个执行序列、一个地址空间)。

create让多个线程同时出发,yield让多个线程交替执行。用户级线程不会进入内核,因此线程切换不是交由操作系统调度,需要用户程序主动让出。

		线程1								   线程2
---------------------				---------------------
|	100: A() {		|				|	300: C() {		|
|		B();		|				|		D();		|
|		104:		|				|		304:		|
|	}				|				|	}				|
|	200: B() {		|				|	400: D() {		|
|		yield();	|				|		yield();	|
|		204:		|				|		404:		|
|	}				|				|	}				|
---------------------				---------------------

初始从A函数开始执行,每次函数调用都需要将函数返回地址压栈。我们这里假设两个yield分别执行jmp 300jmp 204

  1. A()调用B(),104入栈。
  2. B()调用yield(),204入栈。
  3. C()调用D(),304入栈。
  4. D()调用yield(),404入栈。
  5. B()返回,404弹出。
  6. D()返回,304弹出。
  7. C()返回,204弹出。
  8. B()返回,104弹出。
  9. A()返回,到调用A()的地址。

很明显上述执行顺序并不是我们想要的,两个线程共用一个栈会出现问题,那如果每个进程都有自己的栈呢?

  1. A()调用B(),104入栈1。
  2. B()调用yield(),204入栈1。
  3. C()调用D(),304入栈2。
  4. D()调用yield(),404入栈2。
  5. B()返回,204弹出。
  6. B()返回,104弹出。
  7. A()返回,到调用A()的地址。

虽然第5、6步看上去还是有点问题,但起码执行顺序是我们期望的了。还有一点需要注意的是,在第2、3步,esp已经改变了。这里我们考虑从线程1切换到线程2的yield的函数。

// 伪代码
yield() {
	tcb1.esp = cpu.esp;
    cpu.esp = tcb2.esp;
    jmp 300;			// 不应该有这一条指令
}

同进程类似,线程有自己的线程控制块(TCB),线程切换的核心是TCB和栈相互配合。

jmp 300绕开了yield的ret,也就不会从栈中弹出返回地址,因此调用yield压入栈中的返回地址会从其他函数返回,不妨不让yield执行jmp,而是通过返回地址跳转。

  1. A()调用B(),104入栈1。
  2. B()调用yield(),204入栈1。
  3. C()调用D(),304入栈2。
  4. D()调用yield(),404入栈2。
  5. D()返回,204弹出。
  6. B()返回,104弹出。
  7. A()返回,到调用A()的地址。

如此一来,执行顺序符合我们的预期,我们可以得出yield函数是通过栈的切换,实现了线程间的切换。

create函数就是做出TCB和栈。

// 伪代码
create(func) {
    stack = Stack();
    tcb = Tcb();
    stack.push(func);
    tcb.esp = stack.top();
}

create、yield都是用户程序,因此线程切换包括TCB全部在用户态,假设线程需要进入内核(例如网卡IO),如果阻塞了,内核并不能感知到其他线程,就会切到另一个进程,因此其他的线程也会阻塞。如果没有其他进程了,CPU就会慢慢的等待IO完成。就好像如果浏览器的每个标签都是用户级线程,那么一个标签页卡住,其他标签页也会卡住。

有时候我们也将用户级线程称为协程。