Finish ch8

This commit is contained in:
rcy 2022-05-21 17:57:45 +08:00
parent 15e820726e
commit ab9be7e7fd
9 changed files with 1714 additions and 84 deletions

View File

@ -1,78 +0,0 @@
chapter8练习
=======================================
编程作业
--------------------------------------
银行家算法——分数更新
+++++++++++++++++++++++++++++++
.. note::
本实验为用户态实验,请在 Linux 环境下完成。
背景:在智能体大赛平台 `Saiblo <https://www.saiblo.net>`_ 网站上每打完一场双人天梯比赛后需要用 ELO 算法更新双方比分。由于 Saiblo 的评测机并发性很高,且 ELO 算法中的分值变动与双方变动前的分数有关,因此更新比分前时必须先为两位选手加锁。
作业:请模拟一下上述分数更新过程,简便起见我们简化为有 p 位选手参赛(编号 [0, p) 或 [1, p] ),初始分值为 1000 分,有 m 个评测机线程(生产者)给出随机的评测结果(两位不同选手的编号以及胜负结果,结果可能为平局),有 n 个 worker 线程消费者获取结果队列并更新数据库全局变量等共享数据记录的分数。m 个评测机各自模拟 k 场对局结果后结束线程,全部对局比分更新完成后主线程打印每位选手最终成绩以及所有选手分数之和。
上述参数 p、m、n、k 均为可配置参数命令行传参或程序启动时从stdin输入
简便起见不使用 ELO 算法,简化更新规则为:若不为平局,当 胜者分数 >= 败者分数 时胜者 +20败者 -20否则胜者 +30败者 -30若为平局分高者 -10分低者+10若本就同分保持则不变
消费者核心部分可参考如下伪码:
获取选手A的锁
获取选手B的锁
更新A、B分数
睡眠 1ms模拟数据库更新延时
释放选手B的锁
释放选手A的锁
tips:
- 由于 ELO 以及本题中给出的简化更新算法均为零和算法,因此出现冲突后可以从所有选手分数之和明显看出来,正确处理时它应该永远为 1000p
- 将一个 worker 线程看作哲学家,将 worker 正在处理的一场对局的两位选手看作两根筷子,则得到了经典的哲学家就餐问题
实现 eventfd
+++++++++++++++++++++++++++++++
在 Linux 中有一种用于事件通知的文件描述符,称为 eventfd 。其核心是一个 64 位无符号整数的计数器,在非信号量模式下,若计数器值不为零,则 `read` 函数会从中读出计数值并将其清零,否则读取失败; `write` 函数将缓冲区中的数值加入到计数器中。在信号量模式下,若计数器值非零,则 `read` 操作将计数值减一,并返回 1 `write` 将计数值加一。我们将实现一个新的系统调用: `sys_eventfd2`
**eventfd**
* syscall ID: 290
* 功能:创建一个 eventfd `eventfd 标准接口 <https://linux.die.net/man/2/eventfd>`_
* C 接口: ``int eventfd(unsigned int initval, int flags)``
* Rust 接口: ``fn eventfd(initval: u32, flags: i32) -> i32``
* 参数:
* initval: 计数器的初值。
* flags: 可以设置为 0 或以下两个 flag 的任意组合(按位或):
* EFD_SEMAPHORE (1) :设置该 flag 时,将以信号量模式创建 eventfd 。
* EFD_NONBLOCK (2048) :若设置该 flag ,对 eventfd 读写失败时会返回 -2 ,否则将阻塞等待直至读或写操作可执行为止。
* 说明:
* 通过 `write` 写入 eventfd 时,缓冲区大小必须为 8 字节。
* 进程 `fork` 时,子进程会继承父进程创建的 eventfd ,且指向同一个计数器。
* 返回值:如果出现了错误则返回 -1否则返回创建成功的 eventfd 编号。
* 可能的错误
* flag 不合法。
* 创建的文件描述符数量超过进程限制
.. note::
还有一个 `sys_eventfd` 系统调用(调用号 284`sys_eventfd2` 的区别在于前者不支持传入 flags 。
Linux 中的原生异步 IO 接口 libaio 就使用了 eventfd 作为内核完成 IO 操作之后通知应用程序的机制。
实验要求
+++++++++++++++++++++++++++++++++++++++++
- 完成分支: ch8。
- 实验目录要求不变。
- 通过所有测例。
你的内核必须前向兼容,能通过前一章的所有测例。
报告要求
-------------------------------
- 简单总结你实现的功能200字以内不要贴代码
- 完成问答题。
- (optional) 你对本次实验设计及难度/工作量的看法,以及有哪些需要改进的地方,欢迎畅所欲言。

198
source/chapter8/0intro.rst Normal file
View File

@ -0,0 +1,198 @@
引言
=========================================
本章导读
-----------------------------------------
到本章开始之前,我们完成了组成应用程序执行环境的操作系统的三个重要抽象:进程、地址空间和文件,
操作系统基于处理器的时间片不断地切换进程可以实现宏观的多应用并发,但仅限于进程间。
下面我们引入线程Thread提高一个进程内的并发性。
为什么有了进程还需要线程呢?因为对于很多单进程应用,逻辑上存在多个可并行执行的任务,
如果没有多线程,其中一个任务被阻塞,将会引起不依赖该任务的其他任务也被阻塞。
譬如我们用 Word 时,会有一个定时自动保存功能,如果你电脑突然崩溃关机或者停电,已有的文档内容或许已被提前保存。
假设没有多线程,自动保存时由于磁盘性能导致写入较慢,可能导致整个进程被操作系统挂起,
对我们来说便是 Word 过一阵子就卡一会儿,严重影响体验。
.. _term-thread-define:
线程定义
~~~~~~~~~~~~~~~~~~~~
简单地说线程是进程的组成部分进程可包含1 -- n个线程属于同一个进程的线程共享进程的资源
比如地址空间、打开的文件等。基本的线程由线程ID、执行状态、当前指令指针 (PC)、寄存器集合和栈组成。
线程是可以被操作系统或用户态调度器独立调度Scheduling和分派Dispatch的基本单位。
在本章之前,进程是程序的基本执行实体,是程序关于某数据集合上的一次运行活动,是系统进行资源(处理器、
地址空间和文件等)分配和调度的基本单位。在有了线程后,对进程的定义也要调整了,进程是线程的资源容器,
线程成为了程序的基本执行实体。
同步互斥
~~~~~~~~~~~~~~~~~~~~~~
在上面提到了同步互斥和数据一致性,它们的含义是什么呢?当多个线程共享同一进程的地址空间时,
每个线程都可以访问属于这个进程的数据(全局变量)。如果每个线程使用到的变量都是其他线程不会读取或者修改的话,
那么就不存在一致性问题。如果变量是只读的,多个线程读取该变量也不会有一致性问题。但是,当一个线程修改变量时,
其他线程在读取这个变量时,可能会看到一个不一致的值,这就是数据不一致性的问题。
.. note::
**并发相关术语**
- 共享资源shared resource不同的线程/进程都能访问的变量或数据结构。
- 临界区critical section访问共享资源的一段代码。
- 竞态条件race condition多个线程/进程都进入临界区时,都试图更新共享的数据结构,导致产生了不期望的结果。
- 不确定性indeterminate 多个线程/进程在执行过程中出现了竞态条件,导致执行结果取决于哪些线程在何时运行,
即执行结果不确定,而开发者期望得到的是确定的结果。
- 互斥mutual exclusion一种操作原语能保证只有一个线程进入临界区从而避免出现竞态并产生确定的执行结果。
- 原子性atomic一系列操作要么全部完成要么一个都没执行不会看到中间状态。在数据库领域
具有原子性的一系列操作称为事务transaction
- 同步synchronization多个并发执行的进程/线程在一些关键点上需要互相等待,这种相互制约的等待称为进程/线程同步。
- 死锁dead lock一个线程/进程集合里面的每个线程/进程都在等待只能由这个集合中的其他一个线程/进程
(包括他自身)才能引发的事件,这种情况就是死锁。
- 饥饿hungry指一个可运行的线程/进程尽管能继续执行,但由于操作系统的调度而被无限期地忽视,导致不能执行的情况。
在后续的章节中,会大量使用上述术语,如果现在还不够理解,没关系,随着后续的一步一步的分析和实验,
相信大家能够掌握上述术语的实际含义。
实践体验
-----------------------------------------
获取本章代码:
.. code-block:: console
$ git clone https://github.com/LearningOS/uCore-Tutorial-Code-2022S.git
$ cd uCore-Tutorial-Code-2022S
$ git checkout ch8
$ git clone https://github.com/LearningOS/uCore-Tutorial-Test-2022S.git user
或者你也可以在自己原来的仓库里 fetch 它,记得更新测例仓库的代码。
在 qemu 模拟器上运行本章代码:
.. code-block:: console
$ make BASE=1 test
内核初始化完成之后就会进入 shell 程序,我们可以体会一下线程的创建和执行过程。在这里我们运行一下本章的测例 ``ch8b_threads``
.. code-block::
>> ch8b_threads
aaa....bbb...ccc...
thread#1 exited with code 1
thread#2 exited with code 2
thread#3 exited with code 3
threads test passed!
Shell: Process 2 exited with code 0
>>
它会有4个线程在执行等前3个线程执行完毕并输出大量 a/b/c 后,主线程退出,导致整个进程退出。
此外,在本章的操作系统支持通过互斥来执行“哲学家就餐问题”这个应用程序:
.. code-block::
>> ch8b_mut_phi_din
Here comes 5 philosophers!
Phil threads created
time cost = 720 ms
'-' -> THINKING; 'x' -> EATING; ' ' -> WAITING
#0:-------- xxxxxxxx----------- xxxx------ xxxxxx---xxx
#1:----xxxxx--- xxxxxxx----------- x----xxxxxx
#2:------ xx----------x-----xxxxx------------- xxxxx
#3:------xxxxxxxxx-------xxxx--------- xxxxxx--- xxxxxxxxxx
#4:------- x------- xxxxxx--- xxxxx------- xxx
#0:-------- xxxxxxxx----------- xxxx------ xxxxxx---xxx
Shell: Process 2 exited with code 0
>>
我们可以看到5个代表“哲学家”的线程通过操作系统的 **信号量** 互斥机制在进行 “THINKING”、“EATING”、“WAITING” 的日常生活。
没有哲学家由于拿不到筷子而饥饿,也没有两个哲学家同时拿到一个筷子。
.. note::
**哲学家就餐问题**
计算机科学家 Dijkstra 提出并解决的哲学家就餐问题是经典的进程同步互斥问题。哲学家就餐问题描述如下:
有5个哲学家共用一张圆桌分别坐在周围的5张椅子上在圆桌上有5个碗和5只筷子他们的生活方式是交替地进行思考和进餐。
平时,每个哲学家进行思考,饥饿时便试图拿起其左右最靠近他的筷子,只有在他拿到两只筷子时才能进餐。进餐完毕,放下筷子继续思考。
本章代码树
-----------------------------------------
.. code-block::
:linenos:
.
├── bootloader
│ └── rustsbi-qemu.bin
├── LICENSE
├── Makefile
├── nfs
│ ├── fs.c
│ ├── fs.h
│ ├── Makefile
│ └── types.h
├── os
│ ├── bio.c
│ ├── bio.h
│ ├── console.c
│ ├── console.h
│ ├── const.h
│ ├── defs.h
│ ├── entry.S
│ ├── fcntl.h
│ ├── file.c
│ ├── file.h
│ ├── fs.c
│ ├── fs.h
│ ├── kalloc.c
│ ├── kalloc.h
│ ├── kernel.ld
│ ├── kernelld.py
│ ├── kernelvec.S
│ ├── loader.c修改更改了加载用户程序的逻辑此时不再为进程分配用户栈
│ ├── loader.h
│ ├── log.h修改log头中新增了线程号打印
│ ├── main.c
│ ├── pipe.c
│ ├── plic.c
│ ├── plic.h
│ ├── printf.c
│ ├── printf.h
│ ├── proc.c修改为每个线程而非进程分配栈空间和trapframe更改进程初始化逻辑为其分配主线程任务调度粒度从进程改为线程新增线程id与线程指针转换的辅助函数新增线程分配、释放逻辑exit由退出进程改为退出线程
│ ├── proc.h修改新增线程相关结构体和状态枚举在PCB中新增线程相关变量增改部分函数签名
│ ├── queue.c修改由进程专用队列改为通用队列初始化时需指定数组地址和大小
│ ├── queue.h修改同 queue.c
│ ├── riscv.h
│ ├── sbi.c
│ ├── sbi.h
│ ├── string.c
│ ├── string.h
│ ├── switch.S
│ ├── sync.c新增实现了mutex、semaphore、condvar相关操作
│ ├── sync.h新增声明了mutex、semaphore、condvar相关操作
│ ├── syscall.c修改增加 sys_thread_create、sys_gettid、sys_waittid 以及三种同步互斥结构所用到的系统调用)
│ ├── syscall.h修改同syscall.c
│ ├── syscall_ids.h修改为新增系统调用增加了调号号的宏定义
│ ├── timer.c
│ ├── timer.h
│ ├── trampoline.S
│ ├── trap.c修改将进程trap改为线程trap新增用户态虚存映射辅助函数uvmmap
│ ├── trap.h修改同trap.c
│ ├── types.h
│ ├── virtio_disk.c
│ ├── virtio.h
│ ├── vm.c
│ └── vm.h
├── README.md
└── scripts
└── initproc.py

View File

@ -0,0 +1,390 @@
内核态的线程管理
=========================================
线程概念
---------------------------------------------
这里会结合与进程的比较来说明线程的概念。到本章之前,我们看到了进程这一抽象,操作系统让进程拥有相互隔离的虚拟的地址空间,
让进程感到在独占一个虚拟的处理器。其实这只是操作系统通过时分复用和空分复用技术来让每个进程复用有限的物理内存和物理CPU。
而线程是在进程内中的一个新的抽象。在没有线程之前,一个进程在一个时刻只有一个执行点(即程序计数器 (PC)
寄存器保存的要执行指令的指针)。但线程的引入把进程内的这个单一执行点给扩展为多个执行点,即在进程中存在多个线程,
每个线程都有一个执行点。而且这些线程共享进程的地址空间,所以可以不必采用相对比较复杂的 IPC 机制(一般需要内核的介入),
而可以很方便地直接访问进程内的数据。
在线程的具体运行过程中,需要有程序计数器寄存器来记录当前的执行位置,需要有一组通用寄存器记录当前的指令的操作数据,
需要有一个栈来保存线程执行过程的函数调用栈和局部变量等,这就形成了线程上下文的主体部分。
这样如果两个线程运行在一个处理器上,就需要采用类似两个进程运行在一个处理器上的调度/切换管理机制,
即需要在一定时刻进行线程切换,并进行线程上下文的保存与恢复。这样在一个进程中的多线程可以独立运行,
取代了进程,成为操作系统调度的基本单位。
由于把进程的结构进行了细化,通过线程来表示对处理器的虚拟化,使得进程成为了管理线程的容器。
在进程中的线程没有父子关系,大家都是兄弟,但还是有个老大。这个代表老大的线程其实就是创建进程(比如通过
``fork`` 系统调用创建进程建立的第一个线程它的线程标识符TID``0``
线程模型与重要系统调用
----------------------------------------------
目前,我们只介绍本章实现的内核中采用的一种非常简单的线程模型。这个线程模型有三个运行状态:
就绪态、运行态和等待态共享所属进程的地址空间和其他共享资源如文件等可被操作系统调度来分时占用CPU执行
可以动态创建和退出;可通过系统调用获得操作系统的服务。我们实现的线程模型建立在进程的地址空间抽象之上:
每个线程都共享进程的代码段和和可共享的地址空间(如全局数据段、堆等),但有自己的独占的栈。
线程模型需要操作系统支持一些重要的系统调用:创建线程、等待子线程结束等,来支持灵活的多线程应用。
接下来会介绍这些系统调用的基本功能和设计思路。
线程创建系统调用
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
在一个进程的运行过程中进程可以创建多个属于这个进程的线程每个线程有自己的线程标识符TIDThread Identifier
系统调用 ``thread_create`` 的原型如下:
.. code-block:: C
:linenos:
/// 功能:当前进程创建一个新的线程
/// 参数entry 表示线程的入口函数地址
/// 参数arg表示线程的一个参数
int sys_thread_create(uint64 entry, uint64 arg)
当进程调用 ``thread_create`` 系统调用后,内核会在这个进程内部创建一个新的线程,这个线程能够访问到进程所拥有的代码段,
堆和其他数据段。但内核会给这个新线程分配一个它专有的用户态栈,这样每个线程才能相对独立地被调度和执行。
另外,由于用户态进程与内核之间有各自独立的页表,所以二者需要有一个跳板页 ``TRAMPOLINE``
来处理用户态切换到内核态的地址空间平滑转换的事务。所以当出现线程后,在进程中的每个线程也需要有一个独立的跳板页
``TRAMPOLINE`` 来完成同样的事务。
相比于创建进程的 ``fork`` 系统调用,创建线程不需要要建立新的地址空间,这是二者之间最大的不同。
另外属于同一进程中的线程之间没有父子关系,这一点也与进程不一样。
等待子线程系统调用
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
当一个线程执行完代表它的功能后,会通过 ``exit`` 系统调用退出。内核在收到线程发出的 ``exit`` 系统调用后,
会回收线程占用的部分资源,即用户态用到的资源,比如用户态的栈,用于系统调用和异常处理的跳板页等。
而该线程的内核态用到的资源,比如内核栈等,需要通过进程/主线程调用 ``waittid`` 来回收了,
这样整个线程才能被彻底销毁。系统调用 ``waittid`` 的原型如下:
.. code-block:: C
:linenos:
/// 参数tid表示线程id
/// 返回值:如果线程不存在,返回-1如果线程还没退出返回-2其他情况下返回结束线程的退出码
int sys_waittid(int tid);
一般情况下进程/主线程要负责通过 ``waittid`` 来等待它创建出来的线程(不是主线程)结束并回收它们在内核中的资源
(如线程的内核栈、线程控制块等)。如果进程/主线程先调用了 ``exit`` 系统调用来退出,那么整个进程
(包括所属的所有线程)都会退出,而对应父进程会通过 ``waitpid`` 回收子进程剩余还没被回收的资源。
进程相关的系统调用
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
在引入了线程机制后,进程相关的重要系统调用: ``fork````exec````waitpid`` 虽然在接口上没有变化,
但在它要完成的功能上需要有一定的扩展。首先,需要注意到把以前进程中与处理器执行相关的部分拆分到线程中。这样,在通过
``fork`` 创建进程其实也意味着要单独建立一个主线程来使用处理器,并为以后创建新的线程建立相应的线程控制块向量。
相对而言, ``exec````waitpid`` 这两个系统调用要做的改动比较小,还是按照与之前进程的处理方式来进行。总体上看,
进程相关的这三个系统调用还是保持了已有的进程操作的语义,并没有由于引入了线程,而带来大的变化。
应用程序示例
----------------------------------------------
我们刚刚介绍了 thread_create/waittid 两个重要系统调用,我们可以借助它们和之前实现的系统调用,
开发出功能更为灵活的应用程序。下面我们通过描述一个多线程应用程序 ``threads`` 的开发过程来展示这些系统调用的使用方法。
系统调用封装
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
同学可以在 user/lib/syscall.c 中看到系统调用的封装
.. code-block:: C
:linenos:
int thread_create(void *entry, void *arg)
{
// on first thread create enable, here must be single thread
if (!buffer_lock_enabled) {
enable_thread_io_buffer();
}
return syscall(SYS_thread_create, (uint64)entry, (uint64)arg);
}
int waittid(int tid)
{
int ret = syscall(SYS_waittid, tid);
while (ret == -2) {
sched_yield();
ret = syscall(SYS_waittid, tid);
}
return ret;
}
``waittid`` 等待一个线程标识符的值为 tid 的线程结束。在具体实现方面,我们看到当 sys_waittid 返回值为 -2 ,即要等待的线程存在但它却尚未退出的时候,主线程调用 yield_ 主动交出 CPU 使用权,待下次 CPU 使用权被内核交还给它的时候再次调用 sys_waittid 查看要等待的线程是否退出。这样做是为了减小 CPU 资源的浪费。这种方法是为了尽可能简化内核的实现。
特别的,注意这里 ``thread_create`` 中有一个全局变量 ``buffer_lock_enabled`` 的判断,这是因为我们的输出使用了输出缓冲区,在旧版本里它没有考虑线程安全性,为了在多线程模式下正常使用缓冲区,我们新增了一个互斥锁用于输出,这一模式会在主线程首次调用 ``thread_create`` 时被启用。
多线程应用程序 -- threads
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
多线程应用程序 -- threads 开始执行后,先调用 ``thread_create`` 创建了三个线程加上进程自带的主线程其实一共有四个线程。每个线程在打印了1000个字符后会执行 ``exit`` 退出。进程通过 ``waittid`` 等待这三个线程结束后,最终结束进程的执行。下面是多线程应用程序 -- threads 的源代码:
.. code-block:: c
:linenos:
//user/src/ch8b_threads.c
...
#define LOOP 1000
#define NTHREAD 3
void thread_a()
{
for (int i = 0; i < LOOP; ++i) {
putchar('a');
}
exit(1);
}
void thread_b()
{
for (int i = 0; i < LOOP; ++i) {
putchar('b');
}
exit(2);
}
void thread_c()
{
for (int i = 0; i < LOOP; ++i) {
putchar('c');
}
exit(3);
}
int main(void)
{
int tids[NTHREAD];
tids[0] = thread_create(thread_a, 0);
tids[1] = thread_create(thread_b, 0);
tids[2] = thread_create(thread_c, 0);
for (int i = 0; i < NTHREAD; ++i) {
int tid = tids[i];
int exit_code = waittid(tid);
printf("thread %d exited with code %d\n", tid, exit_code);
assert_eq(tid, exit_code);
}
puts("threads test passed!");
return 0;
}
线程管理的核心数据结构
-----------------------------------------------
为了在现有进程管理的基础上实现线程管理,我们需要改进一些数据结构包含的内容及接口。
基本思路就是把进程中与处理器相关的部分分拆出来,形成线程相关的部分。
本节将按照如下顺序来进行介绍:
- 线程控制块 thread :表示线程的核心数据结构。
- 调度器 scheduler :用于线程调度。
线程控制块
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
在内核中,每个线程的执行状态和线程上下文等均保存在一个被称为线程控制块
的结构中,它是内核对线程进行管理的核心数据结构。在内核看来,它就等价于一个线程。
.. code-block:: c
:linenos:
struct thread {
enum threadstate state; // Thread state
int tid; // Thread ID
struct proc *process;
uint64 ustack; // Virtual address of user stack
uint64 kstack; // Virtual address of kernel stack
struct trapframe *trapframe; // data page for trampoline.S
struct context context; // swtch() here to run process
uint64 exit_code;
};
之前进程中的定义不存在的:
- tid线程标识符
- process线程所属的进程
与之前进程中的定义相同/类似的部分:
- ``trapframe`` 线程的 Trap 上下文在内核中的地址。
- ``context`` 保存暂停线程的线程上下文,用于线程切换。
- ``state`` 维护当前线程的执行状态。
- ``exit_code`` 线程退出码。
包含线程的进程控制块
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
把线程相关数据单独组织成数据结构后,进程的结构也需要进行一定的调整:
.. code-block:: c
:linenos:
// Per-process state
struct proc {
...
uint64 ustack_base; // Virtual address of user stack base
struct thread threads[NTHREAD];
};
从中可以看出,进程把与处理器执行相关的部分都移到了 ``thread`` 中,并组织为一个线程控制块数组,
这就自然对应到多个线程的管理上了。此外由于多个线程拥有各自不同的用户栈空间,
需要在进程控制块中记录用户栈的基地址,每个线程的用户栈基地址为 ustack_base + tid * USTACK_SIZE 。
线程管理机制的设计与实现
-----------------------------------------------
在上述线程模型和内核数据结构的基础上,我们还需完成线程管理的基本实现,从而构造出一个完整的“达科塔盗龙”操作系统。
本节将分析如何实现线程管理:
- 线程创建、线程退出与等待线程结束
- 线程执行中的特权级切换
线程创建、线程退出与等待线程结束
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
线程创建
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
当一个进程执行中发出了创建线程的系统调用 ``sys_thread_create`` 后,操作系统就需要在当前进程的基础上创建一个线程了,
这里重点是需要了解初始化线程控制块的各个成员变量,建立好进程和线程的关系等。只有建立好这些成员变量,
才能给线程建立一个灵活方便的执行环境。这里列出支持线程正确运行所需的重要的执行环境要素:
- 线程的用户态栈:确保在用户态的线程能正常执行函数调用;
- 线程的内核态栈:确保线程陷入内核后能正常执行函数调用;
- 线程的跳板页:确保线程能正确的进行用户态<-->内核态切换;
- 线程上下文:即线程用到的寄存器信息,用于线程切换。
线程创建的具体实现如下:
.. code-block:: c
:linenos:
// os/syscall.c
int sys_thread_create(uint64 entry, uint64 arg)
{
struct proc *p = curr_proc();
int tid = allocthread(p, entry, 1);
if (tid < 0) {
errorf("fail to create thread");
return -1;
}
struct thread *t = &p->threads[tid];
t->trapframe->a0 = arg;
t->state = RUNNABLE;
add_task(t);
return tid;
}
上述代码主要完成了如下事务:
- 第 4 行,找到当前正在执行的线程所属的进程 ``p``
- 第 5 行,调用 ``allocthread`` 函数,创建一个新的线程,线程编号 ``tid`` 即为该函数的返回值。在创建过程中,
建立与进程 ``p`` 的所属关系,分配了线程用户态栈、内核态栈、用于异常/中断的跳板页,设置线程的函数入口点、
任务上下文、内核栈和用户栈地址。
- 第 11-12 行,设置线程的传入参数,并将其状态设为可被调度运行。
- 第 13 行,把线程挂到调度队列中。
线程退出
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
当一个非主线程的其他线程发出 ``sys_exit`` 系统调用时,内核会调用 ``exit``
函数退出当前线程并切换到下一个线程,但不会导致其所属进程的退出。当 **主线程** 即进程发出这个系统调用,
内核会回收整个进程(这包括了其管理的所有线程)资源,并退出。具体实现如下:
.. code-block:: c
:linenos:
// os/proc.c
void exit(int code)
{
struct proc *p = curr_proc();
struct thread *t = curr_thread();
t->exit_code = code;
t->state = EXITED;
int tid = t->tid;
debugf("thread exit with %d", code);
freethread(t);
if (tid == 0) {
p->exit_code = code;
freeproc(p);
debugf("proc exit");
if (p->parent != NULL) {
// Parent should `wait`
p->state = ZOMBIE;
}
// Set the `parent` of all children to NULL
struct proc *np;
for (np = pool; np < &pool[NPROC]; np++) {
if (np->parent == p) {
np->parent = NULL;
}
}
}
sched();
}
上述代码主要完成了如下事务:
- 第 10 行,调用 ``freethread`` 回收线程的各种资源。
- 第 11-26 行,如果是主线程发出的退出请求,则回收整个进程的部分资源,并退出进程。第 20-25
行所做的事情是将当前进程的所有子进程的父进程置为 NULL。
- 第 27 行,进行线程调度切换。
上述实现中很大一部分与第五章讲解的 **进程的退出** 的功能实现大致相同。
等待线程结束
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
主线程通过系统调用 ``sys_waittid`` 来等待其他线程的结束。具体实现如下:
.. code-block:: c
:linenos:
// os/syscall.c
int sys_waittid(int tid)
{
if (tid < 0 || tid >= NTHREAD) {
errorf("unexpected tid %d", tid);
return -1;
}
struct thread *t = &curr_proc()->threads[tid];
if (t->state == T_UNUSED || tid == curr_thread()->tid) {
return -1;
}
if (t->state != EXITED) {
return -2;
}
memset((void *)t->kstack, 7, KSTACK_SIZE);
t->tid = -1;
t->state = T_UNUSED;
return t->exit_code;
}
上述代码主要完成了如下事务:
- 第 4-11 行,如果是线程等待一个不存在的 tid 或自己,则返回错误.
- 第 12-14 行,如果 ``tid`` 对应的线程还未退出,则返回错误。
- 第 16-18 行,对应的线程已经退出,则将该线程的线程控制块标记为已释放,并返回该线程的退出码。
线程执行中的特权级切换和调度切换
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
线程执行中的特权级切换与第三章中 **多道程序与协作式调度** 小节中讲解的过程是一致的,
而线程执行中的调度切换过程与第五章的 **进程管理的核心数据结构** 小节中讲解的过程是一致的。
这里就不用再赘述一遍了。

399
source/chapter8/2lock.rst Normal file
View File

@ -0,0 +1,399 @@
锁机制
=========================================
本节导读
-----------------------------------------
到目前为止,我们已经实现了进程和线程,也能够理解在一个时间段内,会有多个线程在执行,这就是并发。
而且,由于线程的引入,多个线程可以共享进程中的全局数据。如果多个线程都想读和更新全局数据,
那么谁先更新取决于操作系统内核的抢占式调度和分派策略。在一般情况下,每个线程都有可能先执行,
且可能由于中断等因素,随时被操作系统打断其执行,而切换到另外一个线程运行,
形成在一段时间内,多个线程交替执行的现象。如果没有一些保障机制(比如互斥、同步等),
那么这些对共享数据进行读写的交替执行的线程,其期望的共享数据的正确结果可能无法达到。
所以,我们需要研究一种保障机制 --- 锁 ,确保无论操作系统如何抢占线程,调度和切换线程的执行,
都可以保证对拥有锁的线程,可以独占地对共享数据进行读写,从而能够得到正确的共享数据结果。
这种机制的能力来自于处理器的指令、操作系统系统调用的基本支持,从而能够保证线程间互斥地读写共享数据。
下面各个小节将从为什么需要锁、锁的基本思路、锁的不同实现方式等逐步展开讲解。
为什么需要锁
-----------------------------------------
上一小节已经提到,没有保障机制的多个线程,在对共享数据进行读写的过程中,可能得不到预期的结果。
我们来看看这个简单的例子:
.. code-block:: c
:linenos:
:emphasize-lines: 4
// 线程的入口函数
int a=0;
void f() {
a = a + 1;
}
对于上述函数中的第 4 行代码,一般人理解处理器会一次就执行完这条简单的语句,但实际情况并不是这样。
我们可以用 GCC 编译出上述函数的汇编码:
.. code-block:: shell
:linenos:
$ riscv64-unknown-elf-gcc -o f.s -S f.c
可以看到生成的汇编代码如下:
.. code-block:: asm
:linenos:
:emphasize-lines: 18-23
//f.s
.text
.globl a
.section .sbss,"aw",@nobits
.align 2
.type a, @object
.size a, 4
a:
.zero 4
.text
.align 1
.globl f
.type f, @function
f:
addi sp,sp,-16
sd s0,8(sp)
addi s0,sp,16
lui a5,%hi(a)
lw a5,%lo(a)(a5)
addiw a5,a5,1
sext.w a4,a5
lui a5,%hi(a)
sw a4,%lo(a)(a5)
nop
ld s0,8(sp)
addi sp,sp,16
jr ra
.. chyyuu 可以给上面的汇编码添加注释???
从中可以看出对于高级语言的一条简单语句C 代码的第 4 行,对全局变量进行读写),很可能是由多条汇编代码
(汇编代码的第 18~23 行)组成。如果这个函数是多个线程要执行的函数,那么在上述汇编代码第
18 行到第 23 行中的各行之间,可能会发生中断,从而导致操作系统执行抢占式的线程调度和切换,
就会得到不一样的结果。由于执行这段汇编代码(第 18~23 行))的多个线程在访问全局变量过程中可能导致竞争状态,
因此我们将此段代码称为临界区critical section。临界区是访问共享变量或共享资源的代码片段
不能由多个线程同时执行,即需要保证互斥。
下面是有两个线程T0、T1在一个时间段内的一种可能的执行情况
===== ===== ======= ======= =========== =========
时间 T0 T1 OS 共享变量a 寄存器a5
===== ===== ======= ======= =========== =========
1 L18 -- -- 0 a的高位地址
2 -- -- 切换 0 0
3 -- L18 -- 0 a的高位地址
4 L20 -- -- 0 1
5 -- -- 切换 0 a的高位地址
6 -- L20 -- 0 1
7 -- -- 切换 0 1
8 L23 -- -- 1 1
9 -- -- 切换 1 1
10 -- L23 -- 1 1
===== ===== ======= ======= =========== =========
一般情况下,线程 T0 执行完毕后,再执行线程 T1那么共享全局变量 ``a`` 的值为 2 。但在上面的执行过程中,
可以看到在线程执行指令的过程中会发生线程切换,这样在时刻 10 的时候,共享全局变量 ``a`` 的值为 1
这不是我们预期的结果。出现这种情况的原因是两个线程在操作系统的调度下(在哪个时刻调度具有不确定性),
交错执行 ``a = a + 1`` 的不同汇编指令序列,导致虽然增加全局变量 ``a`` 的代码被执行了两次,
但结果还是只增加了 1 。这种多线程的最终执行结果不确定indeterminate取决于由于调度导致的、
不确定指令执行序列的情况就是竞态条件race condition
如果每个线程在执行 ``a = a + 1`` 这个 C 语句所对应多条汇编语句过程中,不会被操作系统切换,
那么就不会出现多个线程交叉读写全局变量的情况,也就不会出现结果不确定的问题了。
所以,访问(特指写操作)共享变量代码片段,不能由多个线程同时执行(即并行)或者在一个时间段内都去执行
(即并发)。要做到这一点,需要互斥机制的保障。从某种角度上看,这种互斥性也是一种原子性,
即线程在临界区的执行过程中,不会出现只执行了一部分,就被打断并切换到其他线程执行的情况。即,
要么线程执行的这一系列操作/指令都完成,要么这一系列操作/指令都不做,不会出现指令序列执行中被打断的情况。
锁的基本思路
-----------------------------------------
要保证多线程并发执行中的临界区的代码具有互斥性或原子性,我们可以建立一种锁,
只有拿到锁的线程才能在临界区中执行。这里的锁与现实生活中的锁的含义很类似。比如,我们可以写出如下的伪代码:
.. code-block:: C
:linenos:
lock(mutex); // 尝试取锁
a = a + 1; // 临界区,访问临界资源 a
unlock(mutex); // 是否锁
... // 剩余区
对于一个应用程序而言,它的执行是受到其执行环境的管理和限制的,而执行环境的主要组成就是用户态的系统库、
操作系统和更底层的处理器,这说明我们需要有硬件和操作系统来对互斥进行支持。一个自然的想法是,这个
``lock/unlock`` 互斥操作就是CPU提供的机器指令那上面这一段程序就很容易在计算机上执行了。
但需要注意,这里互斥的对象是线程的临界区代码,而临界区代码可以访问各种共享变量(简称临界资源)。
只靠两条机器指令,难以识别各种共享变量,不太可能约束可能在临界区的各种指令执行共享变量操作的互斥性。
所以,我们还是需要有一些相对更灵活和复杂一点的方法,能够设置一种所有线程能看到的标记,
在一个能进入临界区的线程设置好这个标记后,其他线程都不能再进入临界区了。总体上看,
对临界区的访问过程分为四个部分:
1. 尝试取锁: 查看锁是否可用,即临界区是否可访问(看占用临界区标志是否被设置),如果可以访问,
则设置占用临界区标志(锁不可用)并转到步骤 2 ,否则线程忙等或被阻塞;
2. 临界区: 访问临界资源的系列操作
3. 释放锁: 清除占用临界区标志(锁可用),如果有线程被阻塞,会唤醒阻塞线程;
4. 剩余区: 与临界区不相关部分的代码
根据上面的步骤可以看到锁机制有两种让线程忙等的忙等锁spin lock以及让线程阻塞的睡眠锁
sleep lock。锁的实现大体上基于三类机制用户态软件、机器指令硬件、内核态操作系统。
下面我们介绍来 rCore 中基于内核态操作系统级方法实现的支持互斥的锁。
我们还需要知道如何评价各种锁实现的效果。一般我们需要关注锁的三种属性:
1. 互斥性mutual exclusion即锁是否能够有效阻止多个线程进入临界区这是最基本的属性。
2. 公平性fairness当锁可用时每个竞争线程是否有公平的机会抢到锁。
3. 性能performance即使用锁的时间开销。
内核态操作系统级方法实现锁 --- mutex 系统调用
------------------------------------------------------------------
使用 mutex 系统调用
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
如何能够实现轻量的可睡眠锁?一个自然的想法就是,让等待锁的线程睡眠,让释放锁的线程显式地唤醒等待锁的线程。
如果有多个等待锁的线程,可以全部释放,让大家再次竞争锁;也可以只释放最早等待的那个线程。
这就需要更多的操作系统支持,特别是需要一个等待队列来保存等待锁的线程。
我们先看看多线程应用程序如何使用mutex系统调用的
.. code-block:: C
:linenos:
:emphasize-lines: 10,16,24,35,45,50
// user/src/ch8b_mut_race.c
...
int mutex_id;
int a;
int threads[thread_count];
void fun(long i)
{
int t = i + 1;
for (int i = 0; i < per_thread; i++) {
assert_eq(mutex_lock(mutex_id), 0);
int old_a = a;
for (int i = 0; i < 500; i++) {
t = t * t % 10007;
}
a = old_a + 1;
assert_eq(mutex_unlock(mutex_id), 0);
}
exit(t);
}
int main()
{
int64 start = get_mtime();
assert((mutex_id = mutex_blocking_create()) >= 0);
for (int i = 0; i < thread_count; i++) {
threads[i] = thread_create(fun, (void *)i);
assert(threads[i] > 0);
}
...
}
// usr/lib/syscall.c
int mutex_create()
{
return syscall(SYS_mutex_create, 0);
}
int mutex_blocking_create()
{
return syscall(SYS_mutex_create, 1);
}
int mutex_lock(int mid)
{
return syscall(SYS_mutex_lock, mid);
}
int mutex_unlock(int mid)
{
return syscall(SYS_mutex_unlock, mid);
}
- 第24行创建了一个ID为 ``mutex_id`` 的互斥锁对应的是第35行 ``SYS_mutex_create`` 系统调用;
- 第10行尝试获取锁对应的是第45行 ``SYS_mutex_lock`` 系统调用),如果取得锁,
将继续向下执行临界区代码;如果没有取得锁,将阻塞;
- 第16行释放锁对应的是第50行 ``SYS_mutex_unlock`` 系统调用),如果有等待在该锁上的线程,
则唤醒这些等待线程。
mutex 系统调用的实现
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
操作系统如何实现这些系统调用呢?首先考虑一下与此相关的核心数据结构,
然后考虑与数据结构相关的相关函数/方法的实现。
.. note::
互斥锁根据 lock 一个已被占用的锁时的行为也分阻塞互斥锁(blocking mutex)与自旋互斥锁(spinning mutex)
下文我们主要聚焦阻塞互斥锁,但是 ucore 里两者都通过结构体 ``struct mutex`` 与几个函数的不同分支实现。
在线程的眼里, **互斥** 是一种每个线程能看到的资源,且在一个进程中,可以存在多个不同互斥资源,
所以我们可以把所有的互斥资源放在一起让进程来管理,如下面代码第 4 行所示。这里需要注意的是:
``struct mutex mutex_pool[LOCK_POOL_SIZE]`` 表示的是实现了 ``mutex`` 的一个“互斥资源”的内存池。而
``struct mutex`` 是会实现 ``mutex`` 的内核数据结构,它就是我们提到的 **互斥资源**
**互斥锁** 。操作系统需要显式地施加某种控制,来确定当一个线程释放锁时,等待的线程谁将能抢到锁。
为了做到这一点,操作系统需要有一个等待队列来保存等待锁的线程,如下面代码的第 10 行所示。
.. code-block:: C
:linenos:
:emphasize-lines: 4,10
struct proc {
...
uint next_mutex_id;
struct mutex mutex_pool[LOCK_POOL_SIZE];
};
struct mutex {
uint blocking;
uint locked;
struct queue wait_queue;
// "alloc" data for wait queue
int _wait_queue_data[WAIT_QUEUE_MAX_LENGTH];
};
这样,在操作系统中,需要设计实现几个核心成员变量。互斥锁的成员变量有四个:表示是阻塞锁还是自旋锁的 ``blocking``,
是否锁上的 ``locked``,和(阻塞锁会用到的)管理等待线程的等待队列 ``wait_queue`` 及其内存单元 ``_wait_queue_data``
进程的成员变量:锁内存池 ``mutex_pool`` 及记录互斥锁分配情况的 ``next_mutex_id``
首先需要创建一个互斥锁,下面是应对 ``SYSCALL_MUTEX_CREATE`` 系统调用的创建互斥锁的函数:
.. code-block:: C
:linenos:
:emphasize-lines: 16-19
// os/syscall.c
int sys_mutex_create(int blocking)
{
struct mutex *m = mutex_create(blocking);
if (m == NULL) {
return -1;
}
int mutex_id = m - curr_proc()->mutex_pool;
return mutex_id;
}
// os/sync.c
struct mutex *mutex_create(int blocking)
{
struct proc *p = curr_proc();
if (p->next_mutex_id >= LOCK_POOL_SIZE) {
return NULL;
}
struct mutex *m = &p->mutex_pool[p->next_mutex_id];
p->next_mutex_id++;
m->blocking = blocking;
m->locked = 0;
if (blocking) {
// blocking mutex need wait queue but spinning mutex not
init_queue(&m->wait_queue, WAIT_QUEUE_MAX_LENGTH,
m->_wait_queue_data);
}
return m;
}
- 第16~19行互斥锁池还没用完就找下一个可行的锁否则返回NULL。
有了互斥锁,接下来就是实现 ``Mutex`` 的内核函数:对应 ``SYSCALL_MUTEX_LOCK`` 系统调用的
``sys_mutex_lock`` 。操作系统主要工作是,在锁已被其他线程获取的情况下,把当前线程放到等待队列中,
并调度一个新线程执行。主要代码如下:
.. code-block:: C
:linenos:
:emphasize-lines: 7,14,15-16,18,23,25-26
// os/syscall.c
int sys_mutex_lock(int mutex_id)
{
if (mutex_id < 0 || mutex_id >= curr_proc()->next_mutex_id) {
return -1;
}
mutex_lock(&curr_proc()->mutex_pool[mutex_id]);
return 0;
}
// os/sync.c
void mutex_lock(struct mutex *m)
{
if (!m->locked) {
m->locked = 1;
return;
}
if (!m->blocking) {
...
}
// blocking mutex will wait in the queue
struct thread *t = curr_thread();
push_queue(&m->wait_queue, task_to_id(t));
// don't forget to change thread state to SLEEPING
t->state = SLEEPING;
sched();
// here lock is released (with locked = 1) and passed to me, so just do nothing
}
- 第 7 行,以 ID 为 ``mutex_id`` 的互斥锁指针 ``m`` 为参数调用 ``mutex_lock`` 方法,具体工作由该方法来完成。
- 第 18 行,分类讨论了自旋互斥锁的实现细节,这里我们聚焦阻塞互斥锁,省略了这一分支的细节;
- 第 14 行,如果互斥锁 ``m`` 已经被其他线程获取了,那么在第 23 行,将把当前线程放入等待队列中;
在第 25~26 行,让当前线程处于等待状态,并调度其他线程执行。
- 第 15~16 行,如果互斥锁 ``m`` 还没被获取,那么当前线程会获取给互斥锁,并返回系统调用。
最后是实现 ``Mutex`` 的内核函数:对应 ``SYSCALL_MUTEX_UNLOCK`` 系统调用的 ``sys_mutex_unlock``
操作系统的主要工作是,如果有等待在这个互斥锁上的线程,需要唤醒最早等待的线程。主要代码如下:
.. code-block:: C
:linenos:
:emphasize-lines: 7,18,21-22
// os/syscall.c
int sys_mutex_unlock(int mutex_id)
{
if (mutex_id < 0 || mutex_id >= curr_proc()->next_mutex_id) {
return -1;
}
mutex_unlock(&curr_proc()->mutex_pool[mutex_id]);
return 0;
}
// os/sync.c
void mutex_unlock(struct mutex *m)
{
if (m->blocking) {
struct thread *t = id_to_task(pop_queue(&m->wait_queue));
if (t == NULL) {
// Without waiting thread, just release the lock
m->locked = 0;
} else {
// Or we should give lock to next thread
t->state = RUNNABLE;
add_task(t);
}
} else {
m->locked = 0;
}
}
- 第 7 行,以 ID 为 ``mutex_id`` 的互斥锁 ``m`` 为参数调用 ``mutex_unlock`` 方法,具体工作由该方法来完成的。
- 第 18 行,如果等待队列为空,直接释放锁。
- 第 21-22 行,如果等待队列非空,则唤醒等待最久的线程 ``t``,注意这里不改变 ``m->locked``,可以想想为什么(实验问答题之一)。

View File

@ -0,0 +1,255 @@
信号量机制
=========================================
本节导读
-----------------------------------------
在上一节中我们介绍了互斥锁mutex 或 lock的起因、使用和实现过程。通过互斥锁
可以让线程在临界区执行时,独占临界资源。当我们需要更灵活的互斥访问或同步操作方式,如提供了最多只允许
N 个线程访问临界资源的情况,让某个线程等待另外一个线程执行完毕后再继续执行的同步过程等,
互斥锁这种方式就有点力不从心了。
在本节中,将介绍功能更加强大和灵活的同步互斥机制 -- 信号量Semaphore它的设计思路、
使用和在操作系统中的具体实现。可以看到,信号量的实现需要互斥锁和处理器原子指令的支持,
它是一种更高级的同步互斥机制。
信号量的起源和基本思路
-----------------------------------------
1963 年前后当时的数学家其实是计算机科学家Edsger Dijkstra 和他的团队在为 Electrologica X8
计算机开发一个操作系统(称为 THE multiprogramming systemTHE 多道程序系统)的过程中,提出了信号量
Semphore是一种变量或抽象数据类型用于控制多个线程对共同资源的访问。
信号量是对互斥锁的一种巧妙的扩展。上一节中的互斥锁的初始值一般设置为 1 的整型变量,
表示临界区还没有被某个线程占用。互斥锁用 0 表示临界区已经被占用了,用 1 表示临界区为空,再通过
``lock/unlock`` 操作来协调多个线程轮流独占临界区执行。而信号量的初始值可设置为 N 的整数变量, 如果 N
大于 0 表示最多可以有 N 个线程进入临界区执行,如果 N 小于等于 0 ,表示不能有线程进入临界区了,
必须在后续操作中让信号量的值加 1 ,才能唤醒某个等待的线程。
Dijkstra 对信号量设计了两种操作PProberen荷兰语尝试操作和 VVerhogen荷兰语增加操作。
P 操作是检查信号量的值是否大于 0若该值大于 0则将其值减 1 并继续(表示可以进入临界区了);若该值为
0则线程将睡眠。注意此时 P 操作还未结束。而且由于信号量本身是一种临界资源(可回想一下上一节的锁,
其实也是一种临界资源),所以在 P 操作中,检查/修改信号量值以及可能发生的睡眠这一系列操作,
是一个不可分割的原子操作过程。通过原子操作才能保证,一旦 P 操作开始,则在该操作完成或阻塞睡眠之前,
其他线程均不允许访问该信号量。
V 操作会对信号量的值加 1 ,然后检查是否有一个或多个线程在该信号量上睡眠等待。如有,
则选择其中的一个线程唤醒并允许该线程继续完成它的 P 操作;如没有,则直接返回。注意,信号量的值加 1
并可能唤醒一个线程的一系列操作同样也是不可分割的原子操作过程。不会有某个进程因执行 V 操作而阻塞。
如果信号量是一个任意的整数通常被称为计数信号量Counting Semaphore或一般信号量General
Semaphore如果信号量只有0或1的取值则称为二值信号量Binary Semaphore。可以看出
互斥锁是信号量的一种特例 --- 二值信号量,信号量很好地解决了最多允许 N 个线程访问临界资源的情况。
信号量的一种实现伪代码如下所示:
.. code-block:: c
:linenos:
void P(S) {
if (S >= 1)
S = S - 1;
else
<block and enqueue the thread>;
}
void V(S) {
if <some threads are blocked on the queue>
<unblock a thread>;
else
S = S + 1;
}
在上述实现中S 的取值范围为大于等于 0 的整数。S 的初值一般设置为一个大于 0 的正整数,
表示可以进入临界区的线程数。当 S 取值为 1表示是二值信号量也就是互斥锁了。
使用信号量实现线程互斥访问临界区的伪代码如下:
.. code-block:: C
:linenos:
static struct semaphore S = {1};
// Thread i
void foo() {
...
P(S);
execute Cricital Section;
V(S);
...
}
信号量的另一种用途是用于实现同步synchronization。比如把信号量的初始值设置为 0
当一个线程 A 对此信号量执行一个 P 操作,那么该线程立即会被阻塞睡眠。之后有另外一个线程 B
对此信号量执行一个 V 操作,就会将线程 A 唤醒。这样线程 B 中执行 V 操作之前的代码序列 B-stmts
和线程 A 中执行 P 操作之后的代码 A-stmts 序列之间就形成了一种确定的同步执行关系,即线程 B 的
B-stmts 会先执行,然后才是线程 A 的 A-stmts 开始执行。相关伪代码如下所示:
.. code-block:: C
:linenos:
static struct semaphore S = {1};
//Thread A
...
P(S);
Label_2:
A-stmts after Thread B::Label_1;
...
//Thread B
...
B-stmts before Thread A::Label_2;
Label_1:
V(S);
...
实现信号量
------------------------------------------
使用 semaphore 系统调用
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
我们通过例子来看看如何实际使用信号量。下面是面向应用程序对信号量系统调用的简单使用,
可以看到对它的使用与上一节介绍的互斥锁系统调用类似。
在这个例子中,主线程先创建了信号量初值为 0 的信号量 ``SEM_SYNC`` ,然后再创建两个线程 First
和 Second 。线程 First 会先睡眠 10ms而当线程 Second 执行时,会由于执行信号量的 P
操作而等待睡眠;当线程 First 醒来后,会执行 V 操作,从而能够唤醒线程 Second。这样线程 First
和线程 Second 就形成了一种稳定的同步关系。
.. code-block:: C
:linenos:
:emphasize-lines: 5,10,16,23,28,33
const int SEM_SYNC = 0; //信号量ID
void first() {
sleep(10);
puts("First work and wakeup Second");
semaphore_up(SEM_SYNC); //信号量V操作
exit(0);
}
void second() {
puts("Second want to continue,but need to wait first");
semaphore_down(SEM_SYNC); //信号量P操作
puts("Second can work now");
exit(0);
}
int main() {
// create semaphores
assert_eq(semaphore_create(0), SEM_SYNC); // 信号量初值为0
// create first, second threads
...
}
int semaphore_create(int res_count)
{
return syscall(SYS_semaphore_create, res_count);
}
int semaphore_up(int sid)
{
return syscall(SYS_semaphore_up, sid);
}
int semaphore_down(int sid)
{
return syscall(SYS_semaphore_down, sid);
}
- 第 16 行,创建了一个初值为 0 ID 为 ``SEM_SYNC`` 的信号量,对应的是第 23 行
``SYS_semaphore_create`` 系统调用;
- 第 10 行,线程 Second 执行信号量 P 操作(对应第 33 行 ``SYS_semaphore_down``
系统调用),由于信号量初值为 0 ,该线程将阻塞;
- 第 5 行,线程 First 执行信号量 V 操作(对应第 28 行 ``SYS_semaphore_up`` 系统调用),
会唤醒等待该信号量的线程 Second。
实现 semaphore 系统调用
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
操作系统如何实现信号量系统调用呢?我们还是采用通常的分析做法:数据结构+方法,
即首先考虑一下与此相关的核心数据结构,然后考虑与数据结构相关的相关函数/方法的实现。
在线程的眼里,信号量是一种每个线程能看到的共享资源,且在一个进程中,可以存在多个不同信号量资源,
所以我们可以把所有的信号量资源放在一起让进程来管理,如下面代码第 4 行所示。这里需要注意的是:
``struct semaphore semaphore_pool[LOCK_POOL_SIZE]`` 表示的是信号量资源的列表。而 ``struct semaphore``
是信号量的内核数据结构,由信号量值和等待队列组成。操作系统需要显式地施加某种控制,来确定当一个线程执行
P 操作和 V 操作时如何让线程睡眠或唤醒线程。在这里P 操作是由 ``semaphore_down``
方法实现,而 V 操作是由 ``semaphore_up`` 方法实现。
.. code-block:: c
:linenos:
:emphasize-lines: 4,9-12,31-35,42-47
struct proc {
int pid; // Process ID
uint next_semaphore_id;
struct semaphore semaphore_pool[LOCK_POOL_SIZE];
...
};
struct semaphore {
int count;
struct queue wait_queue;
// "alloc" data for wait queue
int _wait_queue_data[WAIT_QUEUE_MAX_LENGTH];
};
struct semaphore *semaphore_create(int count)
{
struct proc *p = curr_proc();
if (p->next_semaphore_id >= LOCK_POOL_SIZE) {
return NULL;
}
struct semaphore *s = &p->semaphore_pool[p->next_semaphore_id];
p->next_semaphore_id++;
s->count = count;
init_queue(&s->wait_queue, WAIT_QUEUE_MAX_LENGTH, s->_wait_queue_data);
return s;
}
void semaphore_up(struct semaphore *s)
{
s->count++;
if (s->count <= 0) {
// count <= 0 after up means wait queue not empty
struct thread *t = id_to_task(pop_queue(&s->wait_queue));
t->state = RUNNABLE;
add_task(t);
}
}
void semaphore_down(struct semaphore *s)
{
s->count--;
if (s->count < 0) {
// s->count < 0 means need to wait (state=SLEEPING)
struct thread *t = curr_thread();
push_queue(&s->wait_queue, task_to_id(t));
t->state = SLEEPING;
sched();
}
}
首先是核心数据结构:
- 第 4 行,进程控制块中管理的信号量列表。
- 第 9~12 行,信号量的核心数据成员:信号量值和等待队列。
然后是重要的三个成员函数:
- 第 15 行,创建信号量,信号量初值为参数 ``res_count`` ,信号量池的使用原理同互斥锁。
- 第 28 行,实现 V 操作的 ``up`` 函数,第 31 行,当信号量值小于等于 0 时,
将从信号量的等待队列中弹出一个线程放入线程就绪队列。
- 第 39 行,实现 P 操作的 ``down`` 函数,第 225 行,当信号量值小于 0 时,
将把当前线程放入信号量的等待队列,设置当前线程为挂起状态并选择新线程执行。
Dijkstra, Edsger W. Cooperating sequential processes (EWD-123) (PDF). E.W. Dijkstra Archive.
Center for American History, University of Texas at Austin. (transcription) (September 1965)
https://www.cs.utexas.edu/users/EWD/transcriptions/EWD01xx/EWD123.html
Downey, Allen B. (2016) [2005]. "The Little Book of Semaphores" (2nd ed.). Green Tea Press.
Leppäjärvi, Jouni (May 11, 2008). "A pragmatic, historically oriented survey on the universality
of synchronization primitives" (pdf). University of Oulu, Finland.

View File

@ -0,0 +1,312 @@
条件变量机制
=========================================
本节导读
-----------------------------------------
到目前为止,我们已经了解了操作系统提供的互斥锁和信号量。但应用程序在使用这两者时需要非常小心,
如果使用不当,就会产生效率低下、竞态条件、死锁或者其他一些不可预测的情况。为了简化编程、避免错误,
计算机科学家针对某些情况设计了一种更高层的同步互斥原语。具体而言,在有些情况下,
线程需要检查某一条件condition满足之后才会继续执行。
我们来看一个例子,有两个线程 first 和 second 在运行,线程 first 会把全局变量 A 设置为
1而线程 second 在 ``A != 0`` 的条件满足后,才能继续执行,如下面的伪代码所示:
.. code-block:: C
:linenos:
static int A = 0;
void first() {
A=1;
...
}
void second() {
while (A==0) {
// 忙等或睡眠等待 A==1
};
//继续执行相关事务
}
在上面的例子中,如果线程 second 先执行,会忙等在 while 循环中,在操作系统的调度下,线程
first 会执行并把 A 赋值为 1 后,然后线程 second 再次执行时,就会跳出 while 循环,进行接下来的工作。
配合互斥锁,可以正确完成上述带条件的同步流程,如下面的伪代码所示:
.. code-block:: C
:linenos:
static int A = 0;
void first() {
mutex.lock();
A=1;
mutex.unlock();
...
}
void second() {
mutex.lock();
while A == 0 {
mutex.unlock();
// give other thread chance to lock
mutex.lock();
}
mutex.unlock();
//继续执行相关事务
}
这种实现能执行,但效率低下,因为线程 second 会忙等检查,浪费处理器时间。我们希望有某种方式让线程
second 休眠,直到等待的条件满足,再继续执行。于是,我们可以写出如下的代码:
.. code-block:: C
:linenos:
static int A = 0;
void first() {
mutex.lock();
A=1;
wakup(second);
mutex.unlock();
...
}
void second() {
mutex.lock();
while (A==0) {
wait();
};
mutex.unlock();
//继续执行相关事务
}
粗略地看,这样就可以实现睡眠等待了。但请同学仔细想想,当线程 second 在睡眠的时候, ``mutex``
是否已经上锁了? 确实,线程 second 是带着上锁的 ``mutex`` 进入等待睡眠状态的。
如果这两个线程的调度顺序是先执行线程 second再执行线程first那么线程 second 会先睡眠且拥有
``mutex`` 的锁;当线程 first 执行时,会由于没有 ``mutex`` 的锁而进入等待锁的睡眠状态。
结果就是两个线程都睡了,都执行不下去,这就出现了 **死锁**
这里需要解决的两个关键问题: **如何等待一个条件?****在条件为真时如何向等待线程发出信号**
我们的计算机科学家给出了 **管程Monitor****条件变量Condition Variables**
这种巧妙的方法。接下来,我们就会深入讲解条件变量的设计与实现。
条件变量的基本思路
-------------------------------------------
管程有一个很重要的特性,即任一时刻只能有一个活跃线程调用管程中的过程,
这一特性使线程在调用执行管程中过程时能保证互斥,这样线程就可以放心地访问共享变量。
管程是编程语言的组成部分,编译器知道其特殊性,因此可以采用与其他过程调用不同的方法来处理对管程的调用.
因为是由编译器而非程序员来生成互斥相关的代码,所以出错的可能性要小。
管程虽然借助编译器提供了一种实现互斥的简便途径,但这还不够,还需要一种线程间的沟通机制。
首先是等待机制:由于线程在调用管程中某个过程时,发现某个条件不满足,那就在无法继续运行而被阻塞。
其次是唤醒机制:另外一个线程可以在调用管程的过程中,把某个条件设置为真,并且还需要有一种机制,
及时唤醒等待条件为真的阻塞线程。为了避免管程中同时有两个活跃线程,
我们需要一定的规则来约定线程发出唤醒操作的行为。目前有三种典型的规则方案:
- Hoare 语义:线程发出唤醒操作后,马上阻塞自己,让新被唤醒的线程运行。注:此时唤醒线程的执行位置还在管程中。
- Hansen 语义:是执行唤醒操作的线程必须立即退出管程,即唤醒操作只可能作为一个管程过程的最后一条语句。
注:此时唤醒线程的执行位置离开了管程。
- Mesa 语义:唤醒线程在发出行唤醒操作后继续运行,并且只有它退出管程之后,才允许等待的线程开始运行。
注:此时唤醒线程的执行位置还在管程中。
一般开发者会采纳 Brinch Hansen 的建议,因为它在概念上更简单,并且更容易实现。这种沟通机制的具体实现就是
**条件变量** 和对应的操作wait 和 signal。线程使用条件变量来等待一个条件变成真。
条件变量其实是一个线程等待队列,当条件不满足时,线程通过执行条件变量的 wait
操作就可以把自己加入到等待队列中睡眠等待waiting该条件。另外某个线程当它改变条件为真后
就可以通过条件变量的 signal 操作来唤醒一个或者多个等待的线程(通过在该条件上发信号),让它们继续执行。
早期提出的管程是基于 Concurrent Pascal 来设计的,其他语言如 C 和 Rust 等,并没有在语言上支持这种机制。
我们还是可以用手动加入互斥锁的方式来代替编译器,就可以在 C 和 Rust 的基础上实现原始的管程机制了。
在目前的 C 语言应用开发中,实际上也是这么做的。这样,我们就可以用互斥锁和条件变量,
来重现上述的同步互斥例子:
.. code-block:: C
:linenos:
static int A = 0;
void first() {
mutex.lock();
A=1;
condvar.wakup();
mutex.unlock();
...
}
void second() {
mutex.lock();
while (A==0) {
condvar.wait(mutex); //在睡眠等待之前需要释放mutex
};
mutex.unlock();
//继续执行相关事务
}
有了上面的介绍,我们就可以实现条件变量的基本逻辑了。下面是条件变量的 wait 和 signal 操作的伪代码:
.. code-block:: C
:linenos:
void wait(mutex) {
mutex.unlock();
<block and enqueue the thread>;
mutex.lock();
}
void signal() {
<unblock a thread>;
}
条件变量的wait操作包含三步1. 释放锁2. 把自己挂起3. 被唤醒后,再获取锁。条件变量的 signal
操作只包含一步:找到挂在条件变量上睡眠的线程,把它唤醒。
注意,条件变量不像信号量那样有一个整型计数值的成员变量,所以条件变量也不能像信号量那样有读写计数值的能力。
如果一个线程向一个条件变量发送唤醒操作,但是在该条件变量上并没有等待的线程,则唤醒操作实际上什么也没做。
实现条件变量
-------------------------------------------
使用 condvar 系统调用
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
我们通过例子来看看如何实际使用条件变量。下面是面向应用程序对条件变量系统调用的简单使用,
可以看到对它的使用与上一节介绍的信号量系统调用类似。 在这个例子中,主线程先创建了初值为 1
的互斥锁和一个条件变量,然后再创建两个线程 First 和 Second。线程 First 会先睡眠 10ms而当线程
Second 执行时,会由于条件不满足执行条件变量的 wait 操作而等待睡眠;当线程 First 醒来后,通过设置
A 为 1让线程 second 等待的条件满足,然后会执行条件变量的 signal 操作,从而能够唤醒线程 Second。
这样线程 First 和线程 Second 就形成了一种稳定的同步与互斥关系。
.. code-block:: C
:linenos:
:emphasize-lines: 34,44,39
static int A = 0; //全局变量
const int CONDVAR_ID = 0;
const int MUTEX_ID = 0;
void first() {
sleep(10);
puts("First work, Change A --> 1 and wakeup Second");
mutex_lock(MUTEX_ID);
A = 1;
condvar_signal(CONDVAR_ID);
mutex_unlock(MUTEX_ID);
...
}
void second() {
puts("Second want to continue,but need to wait A=1");
mutex_lock(MUTEX_ID);
while (A == 0) {
condvar_wait(CONDVAR_ID, MUTEX_ID);
}
mutex_unlock(MUTEX_ID);
...
}
int main() {
// create condvar & mutex
assert_eq(condvar_create(), CONDVAR_ID);
assert_eq(mutex_blocking_create(), MUTEX_ID);
// create first, second threads
...
}
int condvar_create()
{
return syscall(SYS_condvar_create);
}
int condvar_signal(int cid)
{
return syscall(SYS_condvar_signal, cid);
}
int condvar_wait(int cid, int mid)
{
return syscall(SYS_condvar_wait, cid, mid);
}
- 第 26 行,创建了一个 ID 为 ``CONDVAR_ID`` 的条件量,对应第 34 行 ``SYSCALL_CONDVAR_CREATE`` 系统调用;
- 第 19 行,线程 Second 执行条件变量 ``wait`` 操作(对应第 44 行 ``SYSCALL_CONDVAR_WAIT`` 系统调用),
该线程将释放 ``mutex`` 锁并阻塞;
- 第 5 行,线程 First 执行条件变量 ``signal`` 操作(对应第 39 行 ``SYSCALL_CONDVAR_SIGNAL`` 系统调用),
会唤醒等待该条件变量的线程 Second。
实现 condvar 系统调用
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
操作系统如何实现条件变量系统调用呢?在线程的眼里,条件变量是一种每个线程能看到的共享资源,
且在一个进程中,可以存在多个不同条件变量资源,所以我们可以把所有的条件变量资源放在一起让进程来管理,
如下面代码第9行所示。这里需要注意的是 ``condvar_list: Vec<Option<Arc<Condvar>>>``
表示的是条件变量资源的列表。而 ``Condvar`` 是条件变量的内核数据结构,由等待队列组成。
操作系统需要显式地施加某种控制,来确定当一个线程执行 ``wait`` 操作和 ``signal`` 操作时,
如何让线程睡眠或唤醒线程。在这里, ``wait`` 操作是由 ``Condvar````wait`` 方法实现,而 ``signal``
操作是由 ``Condvar````signal`` 方法实现。
.. code-block:: C
:linenos:
:emphasize-lines: 5,13,17,29,38
// os/proc.h
struct proc {
int pid; // Process ID
uint next_condvar_id;
struct condvar condvar_pool[LOCK_POOL_SIZE];
...
};
// os/sync.h
struct condvar {
struct queue wait_queue;
// "alloc" data for wait queue
int _wait_queue_data[WAIT_QUEUE_MAX_LENGTH];
};
// os/sync.c
struct condvar *condvar_create()
{
struct proc *p = curr_proc();
if (p->next_condvar_id >= LOCK_POOL_SIZE) {
return NULL;
}
struct condvar *c = &p->condvar_pool[p->next_condvar_id];
p->next_condvar_id++;
init_queue(&c->wait_queue, WAIT_QUEUE_MAX_LENGTH, c->_wait_queue_data);
return c;
}
void cond_signal(struct condvar *cond)
{
struct thread *t = id_to_task(pop_queue(&cond->wait_queue));
if (t) {
t->state = RUNNABLE;
add_task(t);
}
}
void cond_wait(struct condvar *cond, struct mutex *m)
{
// conditional variable will unlock the mutex first and lock it again on return
mutex_unlock(m);
struct thread *t = curr_thread();
// now just wait for cond
push_queue(&cond->wait_queue, task_to_id(t));
t->state = SLEEPING;
sched();
mutex_lock(m);
}
首先是核心数据结构:
- 第 5 行,进程控制块中管理的条件变量列表。
- 第 13 行,条件变量的核心数据成员:等待队列。
然后是重要的三个成员函数:
- 第 17 行,创建条件变量,即创建了一个空的等待队列。
- 第 29 行,实现 ``signal`` 操作,将从条件变量的等待队列中弹出一个线程放入线程就绪队列。
- 第 38 行,实现 ``wait`` 操作,释放 ``m`` 互斥锁,将把当前线程放入条件变量的等待队列,
设置当前线程为挂起状态并选择新线程执行。在恢复执行后,再加上 ``m`` 互斥锁。
Hansen, Per Brinch (1993). "Monitors and concurrent Pascal: a personal history". HOPL-II:
The second ACM SIGPLAN conference on History of programming languages. History of Programming
Languages. New York, NY, USA: ACM. pp. 135. doi:10.1145/155360.155361. ISBN 0-89791-570-4.

View File

@ -0,0 +1,149 @@
chapter8 练习
=======================================
- 本节难度:助教自评为工作量最高一次,请尽早开始
本章任务
-----------------------------------------------------
- 本次任务对应 lab5也是本学期最后一次实验祝你好运。
- 老规矩,先 `make test BASE=1` 看下啥情况。
- 理解框架的多线程机制,了解几种锁的运行原理。在此基础上,实现本章编程作业死锁检测。
- 如果时间有限,多线程机制的一些细节大可跳过,但至少应知道多线程基本原理和本章在任务调度粒度上的调整
- 与实验息息相关的是互斥锁(mutex)与信号量(semaphore),条件变量(condvar)供阅读
- 框架包含 ``LAB5`` 字样的注释中给出了一个供参考的实现位置和顺序,你可以按顺序完成(下面的标号与注释中的一种):
- 1: 定义并初始化部分 PCB 的部分变量,包括控制死锁检测启动与死锁检测算法用到的变量,
你可以先定义一部分,后面发现有需要时再做添加;
- 2: 完成系统调用 ``sys_enable_deadlock_detect``,只需要修改变量,不必考虑是否正确实现了死锁。
完成这一步后你可以顺利跑完 ``ch8_sem2_deadlock``,这个测例开启了死锁检测但并没有死锁;
- 3: 尝试写一个函数实现下面提到的死锁检测算法,注释中给了供参考的函数签名。
这是一个和OS独立的函数你可以自行设计数据单独运行它以测试
- 4-1: 维护 mutex 相关的死锁检测变量,并调用死锁检测算法,完成后你可以顺利跑完测例 ``ch8_mut1_deadlock``
- 4-2: 维护 semaphore 相关的死锁检测变量,并调用死锁检测算法,完成后你可以顺利跑完测例 ``ch8_sem1_deadlock``
- 最终,完成实验报告并 push 你的 ch8 分支到远程仓库。push 代码后会自动执行 CI代码给分以 CI 给分为准。
编程作业
--------------------------------------
.. warning::
本次实验框架变动较大,且改动较为复杂,为降低同学们的工作量,本次实验不要求合并之前的实验内容,
只需通过 ch8 系列的测例和前面章节的基础测例即可。
.. note::
本次实验的工作量约为 100~200 行代码。
死锁检测
+++++++++++++++++++++++++++++++
目前的 mutex 和 semaphore 相关的系统调用不会分析资源的依赖情况,用户程序可能出现死锁。
我们希望在系统中加入死锁检测机制,当发现可能发生死锁时拒绝对应的资源获取请求。
一种检测死锁的算法如下:
定义如下三个数据结构:
- 可利用资源向量 Available :含有 m 个元素的一维数组,每个元素代表可利用的某一类资源的数目,
其初值是该类资源的全部可用数目,其值随该类资源的分配和回收而动态地改变。
Available[j] = k表示第 j 类资源的可用数量为 k。
- 分配矩阵 Allocationn * m 矩阵,表示每类资源已分配给每个线程的资源数。
Allocation[i,j] = g则表示线程 i 当前己分得第 j 类资源的数量为 g。
- 需求矩阵 Requestn * m 的矩阵,表示每个线程还需要的各类资源数量。
Request[i,j] = d则表示线程 i 还需要第 j 类资源的数量为 d 。
算法运行过程如下:
1. 设置两个向量: 工作向量 Work表示操作系统可提供给线程继续运行所需的各类资源数目它含有
m 个元素。初始时Work = Available ;结束向量 Finish表示系统是否有足够的资源分配给线程
使之运行完成。初始时 Finish[0~n-1] = false表示所有线程都没结束当有足够资源分配给线程时
设置 Finish[i] = true。
2. 从线程集合中找到一个能满足下述条件的线程 i
.. code-block::
:linenos:
Finish[i] == false;
Request[i,0~n-1] ≤ Work[0~n-1];
若找到,执行步骤 3否则执行步骤 4。
3. 当线程 i 获得资源后,可顺利执行,直至完成,并释放出分配给它的资源,故应执行:
.. code-block::
:linenos:
Work[0~n-1] = Work[0~n-1] + Allocation[i, 0~n-1];
Finish[i] = true;
跳转回步骤2
4. 如果 Finish[0~n-1] 都为 true则表示系统处于安全状态否则表示系统处于不安全状态即出现死锁。
出于兼容性和灵活性考虑,我们允许进程按需开启或关闭死锁检测功能。为此我们将实现一个新的系统调用:
``sys_enable_deadlock_detect``
**enable_deadlock_detect**
- syscall ID: 469
- 功能:为当前进程启用或禁用死锁检测功能。
- 接口: ``int enable_deadlock_detect(int is_enable)``
- 参数:
- is_enable: 为 1 表示启用死锁检测, 0 表示禁用死锁检测。
- 说明:
- 开启死锁检测功能后, ``mutex_lock````semaphore_down`` 如果检测到死锁,
应拒绝相应操作并返回 -0xDEAD (十六进制值)。
- 简便起见可对 mutex 和 semaphore 分别进行检测,无需考虑二者 (以及 ``waittid`` 等)
混合使用导致的死锁。
- 返回值:如果出现了错误则返回 -1否则返回 0。
- 可能的错误
- 参数不合法
问答作业
--------------------------------------------
1. 在我们的多线程实现中,当主线程 (即 0 号线程) 退出时,视为整个进程退出,
此时需要结束该进程管理的所有线程并回收其资源。
- 需要回收的资源有哪些?
- 其他线程的 TaskControlBlock 可能在哪些位置被引用,分别是否需要回收,为什么?
2. 对比以下两种 ``mutex_unlock`` 中阻塞锁的实现,二者有什么区别?这些区别可能会导致什么问题?
(假设无论 ``mutex_lock`` 均正确处理了 ``m->locked``
.. code-block:: C
:linenos:
void mutex_unlock_v1(struct mutex *m)
{
if (m->blocking) {
m->locked = 0;
struct thread *t = id_to_task(pop_queue(&m->wait_queue));
if (t != NULL) {
t->state = RUNNABLE;
add_task(t);
}
} else ...
}
void mutex_unlock_v2(struct mutex *m)
{
if (m->blocking) {
struct thread *t = id_to_task(pop_queue(&m->wait_queue));
if (t == NULL) {
m->locked = 0;
} else {
t->state = RUNNABLE;
add_task(t);
}
} else ...
}
报告要求
-------------------------------
注意目录要求,报告命名 ``lab5.md````lab5.pdf``,位于 reports 目录下。 后续实验同理。
- 简单总结你实现的功能200字以内不要贴代码及你完成本次实验所用的时间。
- 完成 ch8 问答题。
- (optional) 你对本次实验设计及难度/工作量的看法,以及有哪些需要改进的地方,欢迎畅所欲言。

View File

@ -4,5 +4,10 @@
.. toctree::
:maxdepth: 4
0exercise
0intro
1thread-kernel
2lock
3semaphore
4condition-variable
5exercise

View File

@ -1,4 +1,4 @@
.. rCore-Tutorial-Book-v3 documentation master file, created by
.. uCore-Tutorial-Guide-2022S documentation master file, created by
sphinx-quickstart on Thu Oct 29 22:25:54 2020.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
@ -30,12 +30,12 @@ uCore-Tutorial-Guide 2022 Spring
rest-example
log
欢迎来到 uCore-Tutorial-Guide 2022 Spring 2015
欢迎来到 uCore-Tutorial-Guide 2022 Spring!
指导书简介
----------------------------
该指导书为 `THU` `OS` 课程实验 `C` 版实验指导书,旨在帮助同学们快速熟悉框架并完成书面和编程任务, `配套代码 <https://github.com/LearningOS/uCore-Tutorial-v2>`_
该指导书为 `THU` `OS` 课程实验 `C` 版实验指导书,旨在帮助同学们快速熟悉框架并完成书面和编程任务, `配套代码 <https://github.com/learningos/ucore-Tutorial-Code-2022S>`_
此外,还推荐有余力同学们参考 `rCore-Tutorial 指导书 <https://rcore-os.github.io/rCore-Tutorial-Book-v3/index.html>`_。该书为一本从零开始写一个 OS 的教材,虽然是 rust 语言编写的,但对于 OS 的宏观特征和部分细节有更详细的描述。本指导书大量引用了该书的部分章节。
@ -46,7 +46,7 @@ uCore-Tutorial-Guide 2022 Spring
在正式进行实验之前,请先按照第零章章末的 :doc:`/chapter0/1setup-devel-env` 中的说明完成环境配置,确保能够正常运行 ch1 分支的代码。
此外需要注意指导书章节与实验提交要求的不一致,该指导书有 7 个章节,其中: ``ch1`` ``ch2`` ``ch3`` 对应课程要求 ``lab1``; ``ch4`` 对应 ``lab2``; ``ch5`` ``ch6`` 对应 ``lab3``; ``ch7`` 对应 ``lab4``。此外 ``ch8`` 对可选实验做了一定描述。
此外需要注意指导书章节与实验提交要求的不一致,该指导书有 8 个章节,其中: ``ch1`` ``ch2`` ``ch3`` 对应课程要求 ``lab1``; ``ch4`` 对应 ``lab2``; ``ch5`` ``ch6`` 对应 ``lab3``; ``ch7`` 对应 ``lab4`` ``ch8`` 对应 ``lab5``
项目协作
@ -54,7 +54,7 @@ uCore-Tutorial-Guide 2022 Spring
- :doc:`/setup-sphinx` 介绍了如何基于 Sphinx 框架配置文档开发环境,之后可以本地构建并渲染 html 或其他格式的文档;
- :doc:`/rest-example` 给出了目前编写文档才用的 ReStructuredText 标记语言的一些基础语法及用例;
- `该文档仓库文档仓库 <https://github.com/Exusial/uCore-Tutorial-Book>`_
- `该文档仓库文档仓库 <https://github.com/learningos/ucore-Tutorial-Guide-2022S>`_
- 时间仓促,本项目还有很多不完善之处,欢迎大家积极在每一个章节的评论区留言,或者提交 Issues 或 Pull Requests让我们
一起努力让这本书变得更好!