考虑两个进程的切换除了把指令切过去,还要切换内存映射的关系,我们能不能只切换指令序列呢?线程保留了并发的优点,避免了进程切换的代价(多个执行序列、一个地址空间)。
create让多个线程同时出发,yield让多个线程交替执行。用户级线程不会进入内核,因此线程切换不是交由操作系统调度,需要用户程序主动让出。
线程1 线程2
--------------------- ---------------------
| 100: A() { | | 300: C() { |
| B(); | | D(); |
| 104: | | 304: |
| } | | } |
| 200: B() { | | 400: D() { |
| yield(); | | yield(); |
| 204: | | 404: |
| } | | } |
--------------------- ---------------------
初始从A函数开始执行,每次函数调用都需要将函数返回地址压栈。我们这里假设两个yield分别执行jmp 300和jmp 204。
- A()调用B(),104入栈。
- B()调用yield(),204入栈。
- C()调用D(),304入栈。
- D()调用yield(),404入栈。
- B()返回,404弹出。
- D()返回,304弹出。
- C()返回,204弹出。
- B()返回,104弹出。
- A()返回,到调用A()的地址。
很明显上述执行顺序并不是我们想要的,两个线程共用一个栈会出现问题,那如果每个进程都有自己的栈呢?
- A()调用B(),104入栈1。
- B()调用yield(),204入栈1。
- C()调用D(),304入栈2。
- D()调用yield(),404入栈2。
- B()返回,204弹出。
- B()返回,104弹出。
- 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,而是通过返回地址跳转。
- A()调用B(),104入栈1。
- B()调用yield(),204入栈1。
- C()调用D(),304入栈2。
- D()调用yield(),404入栈2。
- D()返回,204弹出。
- B()返回,104弹出。
- A()返回,到调用A()的地址。
如此一来,执行顺序符合我们的预期,我们可以得出yield函数是通过栈的切换,实现了线程间的切换。
create函数就是做出TCB和栈。
// 伪代码
create(func) {
stack = Stack();
tcb = Tcb();
stack.push(func);
tcb.esp = stack.top();
}
create、yield都是用户程序,因此线程切换包括TCB全部在用户态,假设线程需要进入内核(例如网卡IO),如果阻塞了,内核并不能感知到其他线程,就会切到另一个进程,因此其他的线程也会阻塞。如果没有其他进程了,CPU就会慢慢的等待IO完成。就好像如果浏览器的每个标签都是用户级线程,那么一个标签页卡住,其他标签页也会卡住。
有时候我们也将用户级线程称为协程。