本篇文章为笔者在完成 lab4 - challenge 后写的一篇教程,整体上遵循了 OS 指导书的风格,引导式地完成任务。这里只是实现线程与信号量机制的一种方法,当然还可通过其他设计实现。
在操作系统中进程是资源分配的基本单位,因此在创建、切换进程时需要很大的时空开销,这就限制了系统中并发进程的数量。为了满足更多并发场景的要求,还需要线程(thread)机制。在 lab3 和 lab4 中,我们已经比较完整地实现了进程机制,因此可以基于 lab4 的架构进行增量开发,实现线程机制。同时,为了解决线程并发带来的竞态问题,还需要实现信号量(semaphore)机制,以控制线程的同步互斥。
其实有了 lab0 - lab6,OS的大厦已经落成,但接下来的任务会让你的 OS 大厦锦上添花!
Note 1 POSIX 是 Portable Operating System Interface 的缩写,X则表明其对UNIX API的传承,指的是可移植操作系统接口,是 IEEE 为要在各种 UNIX 操作系统上运行软件而定义API 的一系列互相关联的标准的总称。POSIX 线程常缩写为 pthread,是 POSIX 的线程标准,定义了创建和操纵线程的一套 API。
API 规定了线程库的接口,但不限定具体实现方法,可在用户级或者内核级实现。
内核级线程 | 用户级线程 | |
---|---|---|
内核是否可感知 | 可感知 | 内核并不知道用户线程的存在 |
线程的操作 | OS 内核完成 | 不需要 OS 内核支持,在用户空间实现 |
系统调用 | 只导致该线程被中断 | 导致其所属进程被中断 |
表2.1 内核级线程与用户级线程对比
由内核级线程与用户级线程的对比可知,在只有用户级线程的系统内,CPU调度还是以进程为单位,处于运行状态的进程中的多个线程,由用户程序控制线程的轮换运行;在内核级线程的系统内,CPU调度则以线程为单位,由OS的线程调度程序负责线程的调度。内核级线程和用户级线程各有优缺点,由于内核级线程的阻塞发生在线程级别,内核本身的处理可以通过多线程实现,较为简单,所以下面实现的线程机制为内核级线程。
Thinking 1: 思考如果要实现用户级线程,需要在用户空间增加哪些操作?
与进程类似,线程机制同样需要用来管理线程、记录线程特征的数据结构——线程控制块(TCB)。在 include 文件夹下新建 thread.h,定义实现线程需要的结构体、宏、函数声明等。不难想到,TCB 中需要有记录线程基本信息的成员:
//TCB status: #define TCB_FREE 0 #define TCB_RUNNABLE 1 #define TCB_NOT_RUNNABLE 2 struct TCB { struct Trapframe tcb_tf; // Saved registers u_int tcb_id; u_int tcb_status; u_int tcb_pri; LIST_ENTRY(TCB) tcb_sched_link; struct Env *tcb_env; // 记录线程所属的进程 //.... };
这些成员与进程控制块的意义相同,但 TCB 中仅有这些成员是远不够支持线程有关函数的,其他成员可以在后续的实现过程中逐步添加。
既然已经有了 TCB 结构体,下一步就该考虑 TCB 保存在哪里。回顾 lab2 的mips_vm_init,我们巧妙设计让内核和用户空间都有一片区域映射到保存 env 的物理地址, 用户空间的 ENVS 区域有 4MB,系统中最多有 1024 个env,那么一个 env 最大可以占 4KB 的内存,这么大空间对于原来的 env 可以算是十分富裕,于是很自然想到我们可以规定一个进程允许拥有的线程数量,在进程控制块中用数组保存其拥有的 TCB。
修改 include/env.h 中的 struct env,在其中增加 TCB 信息:
u_int env_tcb_count; struct TCB env_threads[8]; // 这里规定一个进程的最大线程数为8
Thinking 2: 思考除这种方法外,线程控制块还可以用什么方式保存?
在 include 文件下新建 thread.c,这部分内容与 include/env.c 对应。
与进程运行相同,内核中同样需要记录线程的运行信息,所以在 thread.c 中定义当前运行现场 curtcb 和线程调度队列 tcb_sched_list。
接下来思考运行一个线程的流程:
第一步 为进程申请一个空闲的 TCB,这时候的 TCB 就像白纸一样。
第二步 手动初始化线程控制块,为线程分配栈等必要的内存空间。
第三步 设置线程的入口地址和结束方式,确保线程能够从正确的地址开始运行并且正常结束。
第四步 设置线程的运行状态,把线程加入调度队列,等待调度。
首先,每个线程都有独一无二的 id,我们需要实现 mktcbid 和 tcbid2tcb 函数。为了让 tcbid 保存更多的信息,一种可行的构造方式是高位保存线程所属进程的 envid,低位保存线程在进程中的序号。
u_int mktcbid(u_int envid, u_int no) { return (envid << LOG2NTCB) | no; }
Thinking 3: 宏定义可以使代码更加清晰,思考上述代码中 LOG2NTCB 的含义(参考 LOG2NENV)。
完成 tcbid2tcb 函数(可以参考 envid2env),tcbid 中包括了进程 id、线程序号等信息,思考在 include/thread.h 中还可以增加哪些与 tcbid 有关的宏定义?
有了获取 tcbid 的方法后,就可以 alloc 一个 tcb 了!由于线程控制块中还要记录所属的进程信息,所以 tcb_alloc 的参数中应该有要分配新线程的进程控制块。在 tcb_alloc 中初始化线程控制块,这里也有一部分内容需要在后续实现中补全。
一个重要的初始化操作就是为线程分配栈,再次回顾 mmu.h 中的内存布局图,我们可以从 USTACKTOP 往下,按序为每个线程分配固定大小空间(如 4 BY2PG)作为线程栈。
int tcb_alloc(struct Env *e, struct TCB **new) { // step 1 // 检查线程数量是否超过最大容量,如果超过则返回错误值,并 *new = 0 // 可以在 include/error.h 中定义新的错误类型 struct TCB *t; int i; // step 2 // 在进程控制块中找到一个空闲的线程控制块 // step 3 // 初始化 tcb_id、tcb_status、tcb_env、tcb_pri等 // step 4 // 初始化状态寄存器,为线程分配栈,初始化栈指针 // cancel // .... // exit // .... // join // .... *new = t; e->env_tcb_count = e->env_tcb_count + 1; // 不要忘记增加线程数量 printf("alloc a tcb for env[0x%x], tcbid: [0x%x]\n", e->env_id, t->tcb_id); return 0; }
完成这一步后,其实就可以开始考虑线程的运行了。但是此时你应该有疑惑,当初在实现进程机制时,我们费了好大功夫去加载 ELF 文件内容,之后设置了 entry point,这样才保证进程能够正常运行起来。既然要实现多线程,那应该保证在创建了进程之后,能让这个进程的第一个线程跑起来。于是我们引入主线程,每次在调用 env_alloc 之后都要调用 tcb_alloc,为进程分配一个主线程,把主线程加入线程调度队列。在为进程 load_icode 后,设置主线程的 pc 等于进程的 pc。
补充 lib/env.c 中的 env_create_priority 函数,使得每次创建一个进程之后,能创建它的主线程并运行。
与线程运行有关,我们需要实现 tcb_run、tcb_free、tcb_destroy 函数,这几个函数都与进程对应函数差别不大。还需要修改进程调度函数。
tcb_run 可以直接对应 env_run,除 lcontext 处仍需要进程的页目录外,其余的进程信息都换成线程信息即可,还要同时设置 curenv 和 curtcb。
tcb_destroy 释放一个线程控制块,并且调度其他线程,可以直接对应到 env_destroy,只需要把进程信息都换成线程信息即可。
tcb_free 不能直接对应到 env_free,因为释放进程控制块会释放进程占用的全部的内存空间,包括整个页表,而 tcb_free 只是释放进程的一个线程。进而考虑释放线程占用的空间需要进行哪些操作:线程独自占用的空间其实只有线程栈,我们只需设置 tcb 的状态为 free,那么下次 alloc 时会重新分配线程栈。最后要注意,如果释放了这个线程之后进程再无其他线程可运行,那么要释放进程控制块。
void tcb_free(struct TCB *t) { struct Env *e = t->tcb_env; printf("env[%08x] free tcb[%08x]\n", e->env_id, t->tcb_id); // 减少进程 e 的线程数量 // 如果这个线程是进程的最后一个线程,则释放了 tcb 之后还要释放进程控制块 env // hint: env_free // 修改 tcb_status // 把 tcb 移出线程调度队列 // hint: LIST_REMOVE }
在经过一番分析后,你应该可以正确完成 tcb_run、tcb_free、tcb_destroy 这三个函数(*▽*)。
最后修改 lib/sched.c 的 sched_yield 函数,就可以调度线程了。我们原来的 yield 函数负责调度进程,实现多线程,那么只要把其中的 env 都换成 tcb 即可。
分析我们准备在用户空间实现的功能:线程的创建、撤销、等待、终止,像 writef、fork 等函数一样,需要通过系统调用来完成,下面看看几个有用的系统调用。
首先回顾添加一个系统调用需要修改哪些文件:lib/syscall.S 增加 syscall table,include/unistd.h 增加系统调用号,lib/syscall_all.c 实现系统调用,user/syscall_lib.c 和 user/lib.h。
int sys_get_thread_id(int sysno, struct TCB **nowtcb, int a2, int a3, int a4, int a5) { int r; if (nowtcb != NULL) { *nowtcb = curtcb; } return curtcb->tcb_id; }
int sys_thread_alloc(int sysno, int a1, int a2, int a3, int a4, int a5) { // step 1 检查curtcb是否有效 int r; struct TCB *t; // 调用 tcb_alloc 为当前进程分配一个线程 // 与 sys_env_alloc 类似,设置新线程的 pri 和 status return (TCBID2TCBNO(t->tcb_id)); // 返回线程序号 }
与 sys_env_alloc 相同,分配线程后,还不能立刻让线程运行,所以需要设置线程状态为不可运行,并且不能加到调度队列。
这个函数可以直接对应 sys_set_env_status,即需要①检查 status 有效性,②通过 id 获取线程控制块 tcb,③根据线程的前后状态把线程加入/移出调度队列,④修改线程状态。
提到修改运行状态,这里还需要修改 sys_set_env_status,这个函数只有在 fork 中用到了,既然是多线程,那么应该设置进程的主线程运行状态。
之后要完成的两个系统调用与线程的阻塞有关。我们先熟悉一下 pthread_join 函数。
pthread_join 是一个线程阻塞的函数,调用它的线程将一直等到被等待的线程结束为止,当函数返回时,被等待线程的资源被收回。一个线程的结束有两种途径,一种是函数正常运行结束了;另一种方式是通过函数pthread_exit来实现。通俗地说,pthread_join 用于等待一个线程的结束,也就是主线程中如果加了这段代码,就会在加代码的位置卡主,直到这个线程执行完毕才往下运行。pthread_exit 用于强制退出一个线程(非执行完毕退出),一般用于线程内部。
join 和 exit 的综合用法是:pthread_exit 在线程内退出,然后返回一个值 retval。当调度到主线程时会从 pthread_join 后开始执行,这个返回值会直接送到pthread_join,实现了主与分线程的通信。
void pthread_exit(void *retval) int pthread_join(pthread_t thread, void **retval)
"被等待线程的资源被收回",这句话是实现主、分线程通信的关键。观察这两个函数的声明,实际上回收资源指的就是 join 函数的 **retval 指向了 exit 函数中的 *retval,之后在主线程中可以通过 p = *retval
来获取这个"资源";。但这只是两个在不同线程中调用的函数,如何能实现参数的传递呢?
相信聪明的你已经注意到了线程控制块是连接这两个函数的桥梁,所以我们需要在线程控制块中增加保存 exit 信息(void *tcb_exit_ptr;
)和 join 信息(void **tcb_join_ptr;
)成员,在主线程调用 pthread_join 时, 将 **retval 暂存在线程控制块中,并把等待线程加入被等待线程的等待队列中,在被等待线程退出时,将 exit 的参数也暂存到线程控制块中,从其等待队列中取出等待线程,并将 exit 信息 tcb_exit_ptr 赋值给等待线程的 *tcb_join_ptr,实际上是令 join 的参数指向了 exit 的参数,从而实现参数的传递。
这里还有一个问题,如果 join 一个线程时,这个线程没有通过调用 pthread_exit 结束,或者 exit 的参数是 NULL ,那么 retval 应该指向什么呢?为了解决这个问题,我们可以在线程控制块中增加一个记录线程退出方式的成员 u_int tcb_exit_value
,具体的退出方式可以用宏定义描述,包括执行完毕退出、调用 pthread_exit 退出和其他线程调用 pthread_cancel 强制退出三种方式,每次线程退出时,都根据具体情况设置 tcb_exit_value 的值,并且默认线程的 tcb_exit_ptr 指向 tcb_exit_value,这样即使没有正确的 pthread_exit 信息,也能保证 pthread_join 可以获取到有效资源。
到这里就需要完善线程控制块和 tcb_alloc 的内容了,并且增加线程结束方式的宏定义。
tips: 实现线程等待队列可以使用 LIST_HEAD 和 LIST_ENTRY 两个宏,分别定义 tcb_joined_list 和 tcb_joined_link。
经过一番分析,下面两个系统调用就变得简单多了。
int sys_thread_destroy(int sysno, u_int tcbid, int a2, int a3, int a4, int a5) { int r; struct TCB *t; if ((r = tcbid2tcb(tcbid, &t)) < 0) { return r; } if (t->tcb_status == TCB_FREE) { return -E_INVAL; } struct TCB *wait; while (!LIST_EMPTY(&(t->tcb_joined_list))) { // step 1 // 取出等待队列队首进程 wait // step 2 // 将 wait 移出等待队列 // step 3 // 实现 join 和 exit 间信息的传递!!! // step 4 // 设置 wait 状态为可运行 } printf("env[0x%08x] destroying tcb[0x%08x]\n", curenv->env_id, t->tcb_id); tcb_destroy(t); return 0; }
int sys_thread_join(int sysno, u_int tcbid, void **retval, int a3, int a4, int a5) { int r; struct TCB *t; if ((r = tcbid2tcb(tcbid, &t)) < 0) { return r; } // 如果 join 的线程已经运行结束 // .... return 0; // step 1 // 把当前线程加入到等待线程的等待队列中 // step 2 // 暂存 **retval // step 3 // 阻塞当前线程 // step 4 // 调度其他线程 return 0; }
tips: 注意涉及到指针内容的读取要判断指针是否为空!
接下来终于可以来到用户空间,完成 pthread_ 系列函数啦!首先在 user 文件下新建文件 pthread.c,还需要在 include/types.h 中定义 pthread_t 类型 (*▽*)
typedef unsigned int pthread_t;
tcb_alloc 已经完成了绝大部分初始化,但通过 pthread_create 函数创建的线程本质上是一个函数调用,所以还需要设置线程的执行入口、传递函数参数、如何结束等。既然线程的本质是调用函数,那不如顺水推舟,设置函数的返回地址为 exit,需要修改 user/libos.c 中的 exit 函数,调用 syscall_thread_destroy。
int pthread_create(pthread_t * thread, const pthread_attr_t * attr, void * (*start_routine)(void *), void *arg) { int tcbno, r; // step 1 // 通过系统调用 alloc 一个线程控制块 // step 2 // 检查 start_routine 有效性 struct TCB *t = &(env->env_threads[tcbno]); // step 3 // 设置线程的 pc、参数、返回地址等 // hint: regs[4]和regs[31] // step 4 // 通过系统调用修改线程运行状态 *thread = t->tcb_id; return 0; }
thinking 4: 思考还有什么方式可以让线程执行完函数后可以自动结束?
void pthread_exit(void *retval) { struct TCB *t; u_int tcbid = syscall_get_thread_id(&t); // 设置 tcb_exit_calue 和 tcb_exit_ptr // hint: 小心NULL syscall_thread_destroy(tcbid); }
实现线程的撤销,首先要了解一些预备知识:
1、cancel point
取消点的简单意思是:在一个时间段内,线程被挂起时,可以被取消的一个时间点。也就是说,当线程出现阻塞时,这个被阻塞的地方就是一个取消点。
更通俗的来说:就是线程A执行过程中,如果遇到其他线程B执行cancel函数,线程继续运行,直到线程某一行代码出现阻塞(如: pthread_testcancel、pthread_join、pthread_cond_wait、printf、sleep、read、write、等都是可以产生阻塞的函数)此时就会退出。
这里为了降低难度,我们只选取 pthread_testcancel 一个取消点,感兴趣的同学可以实现其它取消点。
2、cancelstate
pthread_setcancelstate可以设置线程遇到cancel信号的状态。一共有两种状态:
- 对cancel信号有反应(默认)
- 忽略cancel信号,即拒绝收到 cancel 信息
3、canceltype
pthread_setcanceltype 可以改变线程撤销类型,前提是cancelstate为 enable
一共两个类型:
- 等到取消点再终止(默认)
- 直接立即终止
显然,为实现撤销,我们需要在线程控制块中增加新成员,还需要在 thread.h 中补充与 cancelstate、canceltype 有关的宏定义,并在 tcb_alloc 中设置默认状态。
//thread cancel state: #define PTHREAD_CANCEL_ENABLE 0 //default #define PTHREAD_CANCEL_DISABLE 1 //thread cancel type: #define PTHREAD_CANCEL_DEFERRED 0 //default run to next cancel point #define PTHREAD_CANCEL_ASYNCHRONOUS 1 //exit immediately //tcb_canceled: #define NOT_RECV_CANCEL 0 #define RECV_CANCEL 1 u_int tcb_cancel_state; u_int tcb_cancel_type; u_int tcb_canceled; //标记是否接收到 cancel 信息
为实现线程的撤销,需要实现 pthread_setcancelstate、pthread_setcanceltype 函数,用来设置线程的取消方式。
int pthread_setcancelstate(int state,int *oldstate); int pthread_setcanceltype(int type,int *oldtype); // old 保存线程之前的 state/type
testcancel 函数像一个试探性的操作,如果线程条件符合,则销毁线程。
void pthread_testcancel() { struct TCB *t; u_int tcbid = syscall_get_thread_id(&t); if (/* 补全需要满足的条件 */) { t->tcb_exit_value = THREAD_CANCEL; syscall_thread_destroy(tcbid); } }
真正实现撤销的是 cancel 函数:
int pthread_cancel(pthread_t thread) { struct TCB *t = &(env->env_threads[thread & 0x7]); // step 1 // 检查线程的 cancel state 是否有效,否则返回错误值 if (t->tcb_cancel_type == PTHREAD_CANCEL_ASYNCHRONOUS) { // 立即结束线程 } else { // 标记线程已接收到 cancel 信息,在取消点结束 } return 0; }
int pthread_join(pthread_t thread, void **retval) { return syscall_thread_join(thread, retval); }
最后不要忘记系统调用的流程,在 user/lib.h 和 user/syscall_lib.c 中添加对应的系统调用!
现在我们的 MOS 已经可以支持多线程啦!回顾上述流程,我们实际上只是完成了多个系统调用。各个函数的调用关系如下图所示,现在不妨换个方向,从用户态到内核态的过程再理解一下内核中各个函数的作用~
信号量的作用是保证线程并发执行的安全性,无名信号量,由于其没有名字,所以适用范围要小于有名信号量。只有将无名信号量放在多个进程或线程都共同可见的内存区域时才有意义,否则协作的进程无法操作信号量,达不到同步或互斥的目的。一般而言,无名信号量多用于线程之间,因为线程会共享进程的地址空间。
信号量的 P操作、V操作
P 操作:信号量中的等待(wait)操作还有其他常用名字:Edsger Dijkstra 称它为 P 操作,代表荷兰语单词 Proberen(意思是尝试)。它也称为递减(down,因为信号量的值被减掉1)或上锁 (lock),不过我们使用 Posix 术语等待(wait)。
V 操作:信号量的挂出操作还有其他常用名字:最初称为 V 操作,代表荷兰语单词 Verhogen(意思是增加)。它也称为递增(up,因为信号量的值被加 1)、解锁(unlock)或发信号(signal)。我们使用 Posix 术语挂出(post)。
在 include 文件下新建 semaphore.h,定义信号量结构体。根据信号量的作用,结构体中应该有信号量值、是否共享、是否有效、等待信号量的线程、信号量所属进程等信息。
#define SEM_VALID 1 #define SEM_INVALID 0 LIST_HEAD(Sem_wait_list, TCB); typedef struct { int sem_value; // 信号量的值 int sem_shared; // 1 在进程间共享 0 not shared int sem_status; // 信号量是否有效,及是否经过 sem_init 初始化 int sem_wait_count; // 等待该信号量的线程数量 struct Sem_wait_list sem_wait_list; // 等待该信号量的线程队列 u_int sem_envid; // 信号量所属进程 } sem_t;
由于线程因为获取信号量而等待时需要把线程加入信号量的等待队列,所以需要在 tcb 中添加新成员。hint: tcb_sem_wait_link。
由于信号量机制要实现同步互斥,有关信号量的操作都应该是原子操作,不能中断,所以所有信号量函数都直接通过系统调用来完成。
在 user 文件下新建 sem.c 文件,完成信号量机制的用户接口。
int sem_*(...) { return syscall_sem_*(...); }
最后不要忘记系统调用的流程,在 user/lib.h 和 user/syscall_lib.c 中添加对应的系统调用!
信号量机制实现起来比较简单,除 syscall_all.c 中的具体实现外,注意添加系统调用的其他流程(*╹▽╹*)
根据传入的参数初始化 sem 结构体中的成员即可。初始化后则可以确定信号量有效性的检查方法:
if ((sem == NULL) || (sem->sem_status != SEM_VALID)) { return -E_SEM_INVALID; } if ((sem->sem_sem_shared == 0) && (sem->sem_envid != curenv->env_id)) { return -E_SEM_WRONG_ENV; }
先对信号量进行有效性检查,但这里还需额外检查,保证目前没有线程正在等待将被销毁的信号量,否则要返回错误值。最后直接设置 sem_status 为 SEM_INVALID 即可。
我们给出 V 操作的流程图,实现起来比较简单,在 sem_wait_count-- 后可能会用到 LIST_FIRST、LIST_REMOVE 和 sys_set_thread_status。
我们给出 P 操作的流程图,实现起来也比较简单,在 sem_wait_count++ 后可能会用到 LIST_INSERT_TAIL、sys_set_thread_status 和 sys_yield。
trywait只是 wait 的不阻塞版本,如果信号量值大于0,则正常获取信号量,否则不阻塞线程,直接返回错误值。
进行有效性检查,最后直接返回 sem->sem_value 即可。
到这里我们的任务就基本完成啦!