From b21a2c861130479ba03394abac90a2c9b7068d9e Mon Sep 17 00:00:00 2001 From: shzhxh Date: Mon, 17 Feb 2020 19:36:30 +0800 Subject: [PATCH] add translation of xv6-riscv-book --- Languages/CLang/builtin-function.md | 3 +- .../专题分析/xv6之重新审视并发.md | 0 OS/XV6/专题分析/xv6的文件系统.md | 278 ++++++++++++++++++ OS/XV6/专题分析/xv6的调度.md | 138 +++++++++ OS/XV6/专题分析/xv6的锁.md | 136 +++++++++ Software/git命令/git-submodule.md | 37 ++- Software/iconv.md | 16 + Software/vim.md | 13 + Software/进程管理/ps.md | 55 +++- 9 files changed, 670 insertions(+), 6 deletions(-) create mode 100644 OS/XV6/专题分析/xv6之重新审视并发.md create mode 100644 OS/XV6/专题分析/xv6的文件系统.md create mode 100644 OS/XV6/专题分析/xv6的调度.md create mode 100644 OS/XV6/专题分析/xv6的锁.md create mode 100644 Software/iconv.md diff --git a/Languages/CLang/builtin-function.md b/Languages/CLang/builtin-function.md index 70cf89b..b4eea75 100644 --- a/Languages/CLang/builtin-function.md +++ b/Languages/CLang/builtin-function.md @@ -1,7 +1,8 @@ #### 用于原子内存访问的内建函数 ``` -__sync_synchronize(...) // 产生完整的内存屏障(issues a full memory barrier) +__sync_synchronize(...) // 产生完整的内存屏障(issues a full memory barrier)。即,告诉编译器和处理器不要把load和store放在此点之后。(对于自旋锁来说,以保证临界区的内存引用发生在获取锁之后) +__sync_lock_test_and_set(type *ptr, type value, ...) // 这个内建函数完成的,不是传统的test-and-set操作,而是原子交换操作。它把value写入*ptr,并返回*ptr之前的值。许多平台只对这样的锁提供最低限度的支持,并且不支持完整的交换操作。即,平台支持的有效value可能只有1。实际存储在*ptr里的值取决于具体的实现。这个内建函数不是完整的屏障(full barrier),而是获取屏障(aquire barrier)。这意味着内建函数之后的引用不能移动到内建函数之前,但之前的内存store可能还不是全局可见的,且之前的内存load可能还没有得到满足。 ``` diff --git a/OS/XV6/专题分析/xv6之重新审视并发.md b/OS/XV6/专题分析/xv6之重新审视并发.md new file mode 100644 index 0000000..e69de29 diff --git a/OS/XV6/专题分析/xv6的文件系统.md b/OS/XV6/专题分析/xv6的文件系统.md new file mode 100644 index 0000000..3549fed --- /dev/null +++ b/OS/XV6/专题分析/xv6的文件系统.md @@ -0,0 +1,278 @@ +文件系统的目标是组织和保存数据。文件系统应支持在用户和程序之间共享数据,它也应该支持持久性(persistence)使得在重启之后数据也是可用的。 + +xv6的文件系统支持像Unix那样的文件、目录和路径名,并把数据保存在一个virtio硬盘上以支持持久性。文件系统解决了许多挑战: + +- 文件系统需要磁盘上的数据结构来代表文件和目录的树,记录块的标识(块上记录了所有文件的内容),记录硬盘的哪些区域是空闲的。 +- 文件系统必须支持故障恢复(crash recovery)。即,如果发生了故障(如,电源错误),文件系统必须在重启之后仍然能正确工作。风险在于故障可能中断一系列的更新并使硬盘上的数据结构不一致(如,一个块可能即被一个文件使用也被标记为空闲)。 +- 可能会有不同进程同时操作文件系统,因此文件系统的代码必须协调以维护不变量。 +- 访问磁盘要比访问内存慢几个数量级,因此文件系统必须维护常用块在内存里的缓冲区。 + +#### 概述 + +xv6的文件分为七层。如下图所示: + +| 名称 | 描述 | +| ------------ | ------------------------------------------------------------ | +| 文件描述符层 | 用文件系统抽象了许多Unix资源(管道、设备、文件等),应用程序员好过多了 | +| 路径名层 | 提供分层的路径名且用递归查找来解析它们 | +| 目录层 | 目录是特殊的inode,它的内容是一些目录的入口,每个入口包含了文件名和i-number | +| inode层 | 提供用`inode`代表的独立文件,且有唯一的i-number和一些保存了文件数据的块 | +| 日志层 | 让上层在一个事务中封装对多个块的更新,且确保发生故障时这些块是原子性地更新(如,全更新或全不更新) | +| 缓冲区缓存层 | 缓存硬盘块并同步对它们的访问,确保每次只有一个内核进程可以修改块上的数据 | +| 硬盘层 | 读写virtio硬盘上的块 | + +文件系统必须有一个在磁盘上存储inode和内容块的方案。xv6的作法是把磁盘分为多个区。文件系统不使用0号块(它是启动区)。块1是超级块(superblock),包含了文件系统的元数据(文件系统里块的数量,数据块的数量,inode的数量,日志区里块的数量)。从2号块开始是日志区。日志区之后是inode区,每个块有多个inode。之后是位图区,用于追踪哪个块被使用了。剩下的是数据区,数据区里的块要么在位图区被标记为空闲,要么保存文件或目录的内容。超级块由一个叫`mkfs`的单独的程序填充,这个程序构建了一个初始的文件系统。 + +#### 缓冲区缓存层 + +缓冲区缓存有两个工作(代码在bio.c): + +1. 同步访问磁盘块以确保内存里每个块只有一份复制,且每次只有一个内核线程可以使用那份提制。 +2. 缓存常用块使得不必每次都从硬盘上读取它们。 + +缓冲区缓存的主要接口是`bread`和`bwrite`:`bread`获取一个`buf`来包含一个块的复制,这样就可以在内存里进行读写;`bwrite`把缓冲区中的内容写入到相应的块里。内核线程必须通过调用`brelse`来释放一个缓冲区。缓冲区缓存使用每个缓冲区的睡眠锁来确保一次只有一个线程可以使用每个缓冲区(进而每个硬盘块);`bread`返回一个上锁的缓冲区,然后`brelse`释放那个锁。 + +我们再回到缓冲区缓存。缓冲区缓存有一个确定数量的缓冲区来保存磁盘块,这意味着如果文件系统请求了不在缓存中的磁盘块,缓冲区缓存必须回收一个已经保存了其它块的缓冲区。缓冲区缓存回收的是最近最少使用的缓冲区。 + +#### 代码:缓冲区缓存 + +缓冲区缓存是缓冲区的双向链表。`main`调用`binit`来初始化这个列表,列表中的项来自于静态数组`buf`里的`NBUF`个缓冲区。所有对缓冲区缓存的其它访问都是通过`bcache.head`引用链表来实现的,而不是`buf`数组。 + +与缓冲区相关的状态字段有两个。字段`valid`的意思是那个缓冲区包含了一个块的复制。字段`disk`的意思是缓冲区的内容已经被提交到了磁盘,这可能会改变缓冲区(如,把磁盘中的数据写到`data`)。 + +`bread`调用`bget`来为给定的扇区分配缓冲区。如果缓冲区需要从磁盘读取数据,在返回前`bread`会调用`virtio_disk_rw`从磁盘把数据读出来。 + +`bget`通过设备和扇区号在缓冲区列表里扫描对应的缓冲区。如果有那样的缓冲区,`bget`请求那个缓冲区的睡眠锁,然后把带锁的缓冲区返回给调用者。如果没有对应的缓冲区,`bget`必须生成一个,可能还需要新使用一个已保存了数据的缓冲区。它会第二次扫描缓冲区列表,查找没有被使用的缓冲区(`b->refcnt == 0`);任何一个那样的缓冲区是都用的。`bget`编辑缓冲区的元数据来记录新设备、扇区号并请求它的睡眠锁。注意赋值语句`b->valid = 0`,它确保了`bread`将从磁盘读取块数据,而不是错误地使用缓冲区里之前的数据。 + +每个磁盘扇区最多只有一个缓冲区是十分重要的,为了确保读者可以看到写操作,并且因为文件系统在缓冲区上使用了锁来进行同步。通过从第一个循环里检查这个块是否被缓存,到第二循环里声明这个块已经被缓存(设置`dev`,`blockno`和`refcnt`),`bget`连续地持有`bcache.lock`来确保这个不变量。这使得对块存在性的检查和(如果不存在)为保存那个块而进行的缓冲区分配是原子的。 + +`bget`在`bcache.lock`临界区之处请求缓冲区的睡眠锁是安全的,因为`b->refcnt`非0使得那个缓冲区不会被重新使用。睡眠锁保护了对块缓冲区内容的读写,而`bcache.lock`保护了哪个块被缓存的信息。 + +如果所有的缓冲区都忙,表明有太多的进程并发地执行文件系统调用,`bget`就会panic。一个更优雅的响应可能是睡眠来等待一个缓冲区空闲,尽管那样可能会引发死锁。 + +一旦`bread`读取了磁盘(如果需要的话)并把缓冲区返回给它的调用者,调用者就独占了缓冲区的使用,进行读写数据了。如果调用者修改了缓冲区,它在释放缓冲区之前必须调用`bwrite`来把改变的数据写入到磁盘。`bwrite`调用`virtio_disk_rw`来与磁盘硬件对话。 + +如果调用者完成了对缓冲区的操作,它必须调用`brelse`来释放它。(`brelse`是b-release的缩写,它名字有点怪但值得学习一下:源自于Unix,在BSD、Linux和Solaris也都是这么用的)。 + +`brelse`释放睡眠锁,并把缓冲区移到链表的前面。对缓冲区的移动引发了列表按照最近使用(释放)进行排序:列表里的第一个缓冲区是最近被使用的,最后一个则是最近最少被使用的。`bget`里的两个循环利用了这点:在最坏的情况下,扫描一个已经存在的缓冲区必须处理整个列表,当引用处于良好的位置的时候,先检查最近使用的缓冲区将减少扫描时间。通过反向扫描(跟随`prev`指针),对要重新使用的缓冲区的扫描查找到了最近使用的缓冲区。 + +#### 日志层 + +在文件系统的设计里最有趣的问题之一是故障恢复。许多文件系统的操作包含了多次写磁盘的操作,在一串写操作之后的崩溃使得磁盘上的文件系统处于不一致的状态。比如,发生在文件裁切时的崩溃(设置文件长度为0且释放它的内容块)。依赖于写磁盘的次序,崩溃可能使引用到一个内容块的inode被标记为空闲,也可能使一个内容块被分配但未被引用。 + +后者相对来说是良性的,但引用到一个空闲块的inode在重启后可能会引发严重的问题。重启后,内核可能把那个块分配给了其它文件,现在两个不同的文件都指向了同一个块。如果xv6支持多用户,这就会产生安全问题,因为旧文件的属主可以读写新文件的块,而这个新文件的拥有者是不同的用户。 + +xv6通过简单的日志记录解决了文件系统操作期间的崩溃问题。一个xv6系统调用不直接写硬盘上文件系统的数据结构。相反,它把一个描述放在磁盘上,这个描述是它在一个`log`里所期望的所有磁盘写操作。一旦系统调用日志记录了所有的写操作,它往磁盘上写入一个特殊的`commit`记录用来表示那个日志包含了一个完整的操作。那时系统调用才会把复制写入到磁盘文件系统里的数据结构。当那些写操作都完成后,系统调用删除磁盘上的日志。 + +如果系统崩溃并重启,在运行任何进程之前,文件系统代码按如下描述从崩溃中恢复。如果日志被标记为包含一个完整的操作,则恢复代码把写操作复制到磁盘文件系统。如果日志不是标记为包含完整的操作,恢复代码忽略这个日志。恢复代码最后删除日志完成所有的操作。 + +为什么xv6的日志解决了在文件系统操作期间的崩溃问题?如果崩溃发生在操作提交之前,则磁盘上的日志不会标记为已完成,恢复代码会忽略它,磁盘状态就像操作从来没有开始过一样。如果崩溃发生在磁盘操作提交之后,则恢复代码将会重新执行所有的写操作,如果已经开始往磁盘数据结构中执行写操作,则可能会重新执行这些操作。不管是哪种情况,相对于崩溃来说日志都使得操作原子化了:恢复之后,要么所有的写操作都出现在磁盘上,要么它们都不出现在磁盘上。 + +#### 日志设计 + +日志驻留在固定的位置,这是在超级块里指定的。它是由一个头块(header block)和随后跟着的一些要更新的块的复制(updated block copies)组成的,要更新的块的复制也叫日志块(logged blocks)。头块由扇区编号的数组和日志块的计数组成,其中每个扇区对应着一个日志块。头块中的计数如果是0则表示日志中没有事务,如果非0则表示日志中包含一个完整的已提交事务。xv6在事务提交的时候(不是之前)写头块,在把日志块复制到文件系统之后将计数置0。因此事务进行到一半的崩溃将导致头块里计数为0;而提交之后的崩溃将导致非0的计数。 + +每个系统调用代码都意味着,相对于崩溃来说,写序列的开始和结束是原子的。为了允许不同进程对文件系统操作的并发执行,日志记录系统可以把多个多个系统调用的写操作累积到一个事务中。即一个提交可能包含多个完整系统调用的写操作。为了避免在事务之间分割系统调用,日志系统只有在没有文件系统的系统调用发生时才提交。 + +把多个事务一块提交的想法被称为组提交(group commit)。组提交减少了磁盘操作的数量,因为它把一次提交的固定代价分摊到了多个操作上。组提交还有助于磁盘系统进行并发的写操作,这使得磁盘在一次旋转期间就可能把它们全部写入。xv6的virtio驱动不支持这样的批处理(batching),但xv6文件系统的设计允许这样做。 + +xv6在磁盘上占用固定的空间来保存日志。在一个事物中系统调用要写入的块的数量必须适合那个空间。这有两个后果。 + +- 不允许单个的系统调用写入比日志空间更多的块。对于大多数系统调用来说不存在这样的问题,但是`write`和`unlink`可能会写许多块。一个大文件的写操作可能会许多数据块、许多位图块和一个inode块;取消到一个大文件的连接可能会写许多位图块和一个inode块。xv6的`write`系统调用把大的写操作分解为多个小的写操作以适合日志,`unlink`不会产生问题因为xv6的文件系统实际上只使用一个位图块。 +- 日志系统不可以允许系统调用启动,除非确定系统调用的写操作符合日志的剩余空间。 + +#### 代码:日志 + +在系统调用里log的典型用法如下所示: + +```c +begin_op(); +... +bp = bread(...); +bp->data[...] = ...; +log_write(bp); +... +end_op(); +``` + +`begin_op`一直在等待,直到日志系统当前没在提交,且直到有足够的日志空间来保存调用的写操作。`log.outstanding`记录保留了日志空间的系统调用的数量;总的保留空间等于`log.outstanding`乘以`MAXOPBLOCKS`。递增的`log.outstanding`即保留了空间也防止了在这个系统调用期间发生提交。代码里谨慎地假设每个系统调用都可能写入超过`MAXOPBLOCKS`个块。 + +`log_write`是`bwrite`的代理。它在内存中记录块的扇区号,在硬盘上的日志中为它保留一个位置,并把缓冲区固定在块缓存里以防止块缓存驱逐它。块必须待在缓存里,直到提交:在那之前,被缓存的拷贝是修改的唯一记录;在提交之前,不得写入磁盘;且同一事物中的其它读操作必须可以看到这个修改。当一个块在单个事物中被多次写入时,`log_write`会发现它,并在日志里为那个块分配相同的位置。这个优化被称为**合并**(absorption)。这很常见,比如,在一个事务里包含了多个文件的inode的磁盘块被多次写入。通过把多个磁盘写操作合并到一个,文件系统可以节省日志空间并获得更好的性能,因为只需将一个磁盘块的拷贝写到磁盘里。 + +`end_op`首先对未完成的系统调用的计数进行递减操作。如果记数为0,则调用`commit()`提交当前事务。这个过程分为四步。 + +1. `write_log()`把在事务中修改过的每个块从缓冲区缓存复制到硬盘上日志分区对应的位置里。 +2. `write_head()`把头块写到硬盘上。这是提交点,写操作之后的崩溃将导致从日志中恢复事务的写操作。 +3. `install_trans`从日志中读取每个块并把它写入到文件系统的对应位置。 +4. 把计数0写到日志的头块。必须在下一个事务写日志头块之前做这件事,这样当崩溃发生的时候,就不会产生头块属于一个事务而后续的日志块属于另一个事务的问题。 + +在第一个用户进程运行之前,`fsinit`调用`initlog`,`initlog`调用`recover_from_log`。它读取日志头,如果日志头表示如果日志包含了一个已提交的事务,则模仿`end_op`的动作。 + +在`filewrite`里有一个使用日志的例子。这个事务像下面这样: + +```c +begin_op(); +ilock(f->ip); +r = writei(f->ip, ...); +iunlock(f->ip); +end_op(); +``` + +这个代码被包装在一个循环中,把大的写操作拆分成多个独立的事务,以避免日志溢出。对`writei`的调用写了许多块,这也作为事务的一部分:文件的inode,一个或多个位图块,和一些数据块。 + +#### 代码:块分配器 + +文件和目录的内容保存在磁盘块上,必须从空闲池中分配这些磁盘块。xv6的块分配器管理着一个磁盘上的空闲位图,每个位对应着一个块。值为0代表着对应的块是空闲的,值为1代表着对应的块在被使用。程序`mkfs`设置各种块对应的位,这些块有启动扇区、超级块、日志块、inode块,和位置块。 + +块分配器提供了两个函数:`balloc`分配一个新的磁盘块,`bree`释放一个块。 + +- `balloc`里的循环考虑到了所有的块,从0到`sb.size`,文件系统里所有的块。它查找位图里的位为0的块,一旦找到就更新位图并返回这个块。为提高效率,这个循环被分为两个部分。外层循环读取位图区里的每个块。内层循环检查单个位图块里的所有`BPB`个比特。如果两个进程同时分配同一个块,可能会发生竞争,但缓冲区缓存一次只允许一个进程使用一个位图块,这就避免了这种竞争。 +- `bfree`找到正确的位图块,并清空正确的位。此外,`bread`和`brelse`里暗含的排它性,避免了显示地使用锁。 + +就像在本章其余部分描述的大部分代码一样,`balloc`和`bfree`必须在一个事务的内部调用。 + +#### inode层 + +术语`inode`有两个相关的含义。 + +- 磁盘上的数据结构,包括文件大小和磁盘块编号的列表。 +- 内存里的数据结构,包含一份磁盘上inode的拷贝以及内核需要的额外信息。 + +磁盘上的inode被填充在一个被称为inode块的连续磁盘区域上。每个inode都是一样大小,所以给定一个编号n,很容易就找到磁盘上的第n个inode。事实上,编号n(被称为inode编号或i-number),是在具体实现中inode的标识方式。 + +磁盘上的inode通过`struct dinode`来定义。`type`字段区分了文件、目录、和特殊文件(设备)。如类型值为0则表示磁盘inode是空闲的。字段`nlink`记录了引用当前inode的目录条目的数量,用以识别这个磁盘inode和它的数据块何时应该被释放。`size`字段记录了文件内容的字节数。`addrs`数组记录了保存了文件内容的磁盘块的块号。 + +内核把活动inode的集合保存在内存中,即`struct inode`。只有在一个C指针指向一个inode的时候内核才会把那个inode保存到内存里。`ref`字段记录了C指针引用内存里inode的次数,当那个计数降为0的时候内核就会从内存中丢弃这个inode。`iget`和`iput`函数请求和释放到一个inode的指针,这会修改这个引用计数。到inode的指针可能来自于文件描述符、当前的工作目录,和事件内核代码(如`exec`)。 + +xv6的inode代码里有四种锁或类似于锁的机制。 + +- `icache.lock`保护的不变量是inode最多在缓存中出现一次,以及`inode.ref`计数的是到被缓存的inode的指针数量。 +- `inode.lock`是inode的睡眠锁,它用来确保对inode的字段、inode的文件或目录内容块的独占访问。 +- `inode.ref`如大于0,则使系统保留缓存里的这个inode,并且不会让其它的inode重新使用这个缓存条目。 +- `inode.nlink`,用于计数引用了一个文件的目录条目的数量,如果一个inode的连接数量(link count)大于0,xv6是不会释放这个inode的。 + +`iget()`返回的`struct inode`指针保证是有效的,直到调用相应的`iput()`;这个inode不会被删除,指针所引用的内存也不会被也不会其它的inode重新使用。`iget()`提供对inode的非独占访问,所以可以有多个指针指向同一个inode。文件系统代码里的许多部分都依赖于`iget()`的这个行为,即可以保持对inode的长期引用(当打开文件和当前目录),又可以避免竞争,同时还可以在操作多个inode(如路径名查找)的时候避免死锁。 + +`iget()`返回的`struct inode`可能没有任何有用的内容。为了确保它确实保存了磁盘inode上的拷贝,代码必须调用`ilock`。这会锁定inode(所以其它进程就无法`ilock`它了),并从磁盘读取尚未读取的inode。`iunlock`则释放inode上的锁。在某些情况下,把inode指针的获取与锁定分享有助于避免死锁,例如在目录查找期间。多个进程可以持有`iget()`返回的inode的C指针,但一次只有一个进程可以锁定这个inode。 + +inode的缓存里缓存的,是内核代码或数据结构持有C指针的inode。它的主要工作是支持多进程的并行访问,缓存只是次要的功能。如果一个inode被频繁使用,且它不是由inode缓存保存,它可能会被缓冲区缓存保存在内存里。inode缓存是直写的(write-through),这意味着修改了被缓存的inode的代码必须用`iupdate`立即写入磁盘。 + +#### 代码:inode + +xv6调用`ialloc`来分配一个新的inode(如创建文件)。`ialloc`类似于`balloc`:它遍历磁盘上的inode结构,每次一个块,查找标记为空闲的块。当它找到空闲块,它就通过把新的`type`写入磁盘的方式来声明这个块,然后通过调用`iget`返回一个inode缓存的条目。`ialloc`的正确操作依赖于这样的事实,一次只有一个进程可以持有对`bp`的引用:`ialloc`就可以确保其它进程不会同时发现这个inode的存在并尝试声明它。 + +`iget`遍历inode缓存来查找一个活动的入口(`ip->ref > 0`),这个入口要满足设备和inode编号两个要求。如果找到了这样的入口,它返回对那个inode的新的引用。当`iget`扫描的时候,它会记录第一个空位的位置,这个空位用于分配一个缓存条目(如果需要的话)。 + +在读写inode的元数据或内容之前,必须使用`ilock`来锁定inode。`ilock`使用一个睡眠锁来实现此目的。一旦`ilock`可以独占地访问这个inode,它会根据需要从磁盘(更有可能是缓冲区缓存)读取这个inode。函数`iunlock`释放这个睡眠锁,这可能导致任何正在睡眠的进程被唤醒。 + +`iput`通过检查引用计数(reference count)释放一个inode的C指针。如果这是最后一次引入,在inode缓存里这个inode的位置就是空闲的并且可以被其它inode重新使用了。 + +如果`iput`发现一个inode没有C指针引用了,也没有到它的连接(不存在于任何目录中),则这个inode和它的数据块必须被释放。`iput`调用`itrunc`来把文件截为0字节,释放数据块;把inode的类型设为0(未分配);并把inode写入磁盘。 + +在`iput`释放inode的时候,它里面的锁定规则(the locking protocol)值得仔细研究。 + +- 其中一个风险是一个并发线程为了使用这个inode可能会等待在`ilock`里(如,读取一个文件或列出一个目录),并且不会发现这个inode已经不再被分配了。这不会发生,如果一个缓存的inode的连接数为0而引用数为1,一个系统调用是没有办法获取到它的指针的。这1个引用被调用`iput`的线程所拥有。`iput`确实是会在它的`icache.lock`临界区之外检查引用计数为1,但那时连接数已经是0,所以没有线程会尝试获取新的引用。 +- 另一个主要的风险是对`ialloc`并发的调用可能会关闭inode,而这个inode已经被`iput`释放掉了。在`iupdate`写磁盘之后,从而inode的类型为0时,这个风险才有可能发生。这个竞争是良性的;在读写inode之前,分配线程会礼貌地等待以请求inode的睡眠锁,此时`iput`已经完成了它。 + +`iput()`可以写入磁盘。这意味着任何使用了文件系统的系统调用都可以写磁盘,因为系统调用可能是最后一个到文件的引用。即使是像`read()`那样看上去只读的系统调用,也有可能会最后调用`iput()`。这意味着,任何使用了文件系统的系统调用,即使它们是只读的,也必须封装在事务中。 + +`iput()`和崩溃之间的交互具有挑战性。当文件的连接数降为0的时候,`iput()`不会立即的截断文件,因为仍然可能有进程在内存中持有到这个inode的引用:一个进程可能仍然在读写这个文件,因为它成功打开了这个文件。但是,如果在最后一个进程关闭这个文件的文件描述符之前发生了崩溃,这个文件就会标记为分配到了磁盘上,却没有目录条目指向它。 + +文件系统有两种方法来处理这种情况。 + +1. 简单点的方案是,在重启之后恢复的时候,寻找那些已经标记为分配但没有目录条目指向它们的文件。如果找到那样的文件就把它们释放。 +2. 这个方案不必扫描整个文件系统。如果一个文件的连接数降为0而引用数不为0,则把这个文件的inode编号记录到磁盘上(比如,超级块上)。当引用记数降为0的时候删除这个文件,再更新这个磁盘列表以从列表中删除那个inode。恢复的时候,文件系统释放列表中的所有文件。 + +xv6以上两个方案都没有使用,这意味着inode标记为在磁盘上已分配,即使它们不再被使用。这意味着随着时间的推移,xv6面临着耗尽磁盘空间的风险。 + +#### 代码:inode的内容 + +硬盘上的inode结构`struct dinode`,其中`size`字段用以表明文件大小,`addrs`数组用以保存块号。那些块号所对应的块里保存着这个inode的数据。`addrs`里的前`NDIRECT`个块被称为**直接块**(direct blocks);第`NDIRECT+1`个块记录了`NINDIRECT`个块的数据,它被称为**间接块**(indirect block)。由于一个块的大小`BSIZE`是1024字节,且`NDIRECT`是12,所以一个文件可以直接载入的内容为12k字节。由于`NINDIRECT`是256,所以读取间接块后可以载入的内容是256k字节。这有利于在磁盘上的表达,但对于用户程序来说比较复杂。函数`bmap`管理这个表达。`bmap`用于返回第`bn`个数据块的硬盘块号,参数`ip`用于表示inode的指针。如果`ip`里没有那个块,`bmap`会给它分配一个。 + +函数`bmap`先从简单的情况开始:前`NDIRECT`个块已经在inode它自己里列出了。接下来的`NINDIRECT`个块在间接块`ip->addrs[NDIRECT]`里列出。`bmap`读取间接块,然后再从间接块里读取对应的块号。如果块号超过了`NDIRECT+NINDIRECT`,`bmap` 会panic;`writei`会进行检查以避免这种情况的发生。 + +`bmap`按照需要来分配块。如果直接块或间接块的某个条目为0,则意味着那个条目没有分配块。当`bmap`发现条目的值为0,它就把新块的编号填充进去,按需分配。 + +`itrunc`释放一个文件的块,把inode的大小重置为0。`itrunc`先释放直接块,再释放间接块里所列出的那些块,最后再释放间接块自身。 + +对于`readi`和`writei`,`bmap`让它们获取inode的数据变的简单。`readi`首先确保位移和大小没有超出文件的限制。读取的位移超出文件的限制会返回一个错误,读取的大小超出文件的限制则会返回少一些的字节。主循环处理文件的所有块,把缓冲区的数据复制到`dst`。`writei`和`readi`相同,但有三个地方不一样: + +1. 写入的大小超出文件的限制会返回错误。 +2. 循环里不是读出,而是把数据复制进缓冲区。 +3. 如果写操作扩展了文件,`writei`必须更新它的大小。 + +在xv6-public的代码里,`readi`和`writei`都是从检查`ip->type==T_DEV`开始。但在xv6-riscv的代码里,没有进行这样的检查。 + +函数`stati`把inode的元数据复制进`stat`结构体,`stat`结构体最终会通过`stat`系统调用暴露给用户程序。 + +#### 代码:目录层 + +目录的内部实现非常类似于文件。它的inode的类型是`T_DIR`,它的数据是一些目录条目。每个条目都是一个`struct dirent`,包含了一个名字和一个inode号。名字最多可以有`DIRSIZ`(14)个字符;如果低于14个字符,则以`NUL`(0)结尾。inode号为0的目录条目是空闲的目录条目。 + +函数`dirlookup`通过名字来查找目录里的条目。如果找到一个,它返回对应inode的指针(未锁定),并把`*poff`设置为目录里对应条目的位移,以防调用者期望能编辑它。如果`dirlookup`找到了正确名称的条目,它更新`*poff`并返回未上锁的inode(通过`iget`获取)。`iget`返回不上锁的inode就是因为`dirlookup`。调用者锁定了`dp`,所以如果查找的是`.`(当前目录的别名),返回之前锁定inode会导致尝试重新锁定`dp`从而导致死锁。还有更复杂的死锁涉及多进程和`..`(上级目录的别名)。调用者可以解锁`dp`然后锁定`ip`,确保它一次只持有一个锁。 + +函数`dirlink`向目录`dp`里写入一个新的目录条目,参数`*name`是新目录条目的名称,参数`inum`是inode号。如果名称已经存在,`dirlink`返回一个错误。主循环读取目录条目查找一个未分配的条目。当它找到一个,它就先停止循环,此时`off`被设置为已知条目的位移。否则,`off`将被设置为`dp->size`。不管是哪种情况,`dirlink`都把新的条目写入位移`off`,这样新条目就填加到了目录里。 + +#### 代码:路径名 + +路径名查找包含了一系列对`dirlookup`的调用,一个`dirlookup`对应了路径名的一个部分。`namei`计算`path`并返回对应的`inode`。函数`nameiparent`是一个变量:它在最后一个元素之前停止,返回上级目录的inode并把最后的元素复制进`name`。它们都是调用`namex`来完成实际的工作的。 + +`namex`首先决定路径计算从哪里开始。如果路径是从斜杠开始的,计算从根目录开始;否则,就从当前目录开始。然后它使用`skipelem`来依次得到路径里的每个元素。每次循环都必须在当前inode(`ip`)查找`name`。循环首先锁定`ip`并检查它是否是一个目录。如果不是,查找失败。(锁定`ip`是必要的,不是因为`ip->type`可以改变,它改变不了,而是因为在`ilock`运行之前,不能保证已经从磁盘加载了`ip->type`。)如果这个调用是`nameiparent`并且这是最后一个路径元素,按照`nameiparent`的定义,循环终止;最后的路径元素已经复制进了`name`,所以`namex`只需返回未锁定的`ip`即可。最后,使用`dirlookup`查找路径元素,并通过设置`ip = next`为下一次循环做好准备。当循环遍历完所有的路径元素退出后,`namex`返回`ip`。 + +例程`namex`可能需要很长的时间才能完成:它可能包含了多个磁盘操作来读取inode和目录块。xv6经过仔细的设计,使得如果一个内核线程调用`namex`的时候被阻塞在磁盘I/O上,其它内核线程可以并发的查找不同的路径名。`namex`给不同路径下的目录分别上锁,使得在不同目录下的查找可以并发地进行。 + +这样的并发带来一些挑战。例如,一个内核线程正在查找一个路径名,而其它内核线程可能正在改变这个目录树(通过取消链接一个目录)。一个潜在的风险是,一个查找可能在搜索已经被其它内核线程删除的目录,并且这个目录的块已经被其它目录或文件重新使用了。 + +xv6避免了那样的竞争。例如,在`namex`里执行`dirlookup`的时候,查找线程持有了目录的锁,并且`dirlookup`返回一个来自于`iget`的inode。`iget`递增这个inode的引用计数。只有在从`dirlookup`接收到这个inode之后,`namex`才会释放这个目录的锁。现在其它线程可能会从目录里取消链接这个inode,但是xv6不会删除这个inode,因为这个inode的引用计数仍然大于0。 + +另一个风险是死锁。例如,当查询`.`目录的时候,`next`指向与`ip`相同的inode。在释放对`ip`的锁之前锁定`next`应该会引发死锁。为了避免这种死锁,`namex`在获取`next`锁之前会先解锁当前目录。在这里我们再次看到了`iget`和`ilock`分离的重要性。 + +#### 文件描述符层 + +Unix接口一个酷的地方在于一切皆是文件,包括设备(如控制台)、管道、和真正的文件。文件描述符层实现了这个特性。 + +每个进程有自己的打开文件(或者说是文件描述符)的表。每个打开文件都是一个`struct file`,它是对inode或管道的封装,再加上一个I/O位移。每次对`open`的调用都创建一个新的打开文件(一个新的`struct file`):如果不同的进程各自打开同一个文件,不同的实例将会有不同的I/O位移。另一方面,一个单独的打开文件(相同的`struct file`)可能在一个进程的文件表里多次出现,也可以出现在多个进程的文件表里。如果一个进程使用`open`打开文件,然后使用`dup`创建别名,或者使用`fork`与子进程共享这个文件,就会发生这种情况。引用计数追踪一个特定的打开文件的引用数量。`readable`和`writable`字段追踪的是一个文件以什么方式打开(读,写,或读写)。 + +系统里的所有打开文件保存在一个全局文件表里,即`ftable`。可以操作这个文件表的函数有:分配文件(`filealloc`),创建一个重复的引用(`filedup`),释放一个引用(`fileclose`),读写数据(`fileread`和`filewrite`)。 + +- `filealloc`扫描文件表查找未引用的文件(`f->ref == 0`)并返回一个新的引用。 +- `filedup`递增引用计数。 +- `fileclose`递减引用计数。当引用计数降为0,释放底层对应的管道或inode。 +- `filestat`实现了对文件的`stat`操作。它只允许操作inode,然后调用`stati` +- `fileread`实现了对文件的`read`操作。首先检查是否允许`readable`模式,然后把调用传递给管道或inode的实现。如果文件是一个inode,它使用I/O位移作为读操作的位移,然后增加这个位移。管道没有位移的概念。 +- `filewrite`实现了对文件的`write`操作。首先检查是否允许`writeable`模式,然后把调用传递给管道或inode的实现。如果文件是一个inode,它使用I/O位移作为写操作的位移,然后增加这个位移。管道没有位移的概念。要记得inode函数需要调用者管理锁定。inode的锁定有一个方便的附加效果,即读写位移是自动更新的,因此同时对一个文件的多个写操作不会覆盖彼此的数据,但这些写操作可能会交错在一起。 + +#### 代码:系统调用 + +底层实现了大部分功能,系统调用这一级的实现是微不足道的。只有少数的系统调用值得研究一下。 + +函数`sys_link`和`sys_unlink`编辑目录,创建或删除对inode的引用。它们是好的例子来展现使用事务的强大。`sys_link`从获取它的两个字符串参数`old`和`new`开始。如果`old`存在且不是目录,递增它的`ip->nlink`计数。然后调用`nameiparent`来找到`new`的父目录和最终的路径元素,并创建一个新的目录条目来指向`old`的inode。新的上级目录必须存在且和inode在同一设备上:inode号在单独的磁盘上有唯一的意义。如果发生了这样的错误,`sys_link`必须回退且递减`ip->nlink`。 + +事务简化了这个实现,因为它需要更新多个磁盘块,但我们不必担心更新的次序。要么全部更新,要么全不更新。如果没有事务,在创建一个连接之前更新`ip->nlink`,可能会让文件系统临时进入一个不安全的状态,此时的崩溃可能导致严重的破坏。如果有了事务我们就不必担心这点了。 + +`sys_link`为一个存在的inode创建了一个新的名称。函数`create`为一个新inode创建一个新的名称。它是三个系统调用的通用操作:`open`使用`O_CREATE`标志创建一个新的普通文件,`mkdir`创建一个新的目录,`mkdev`创建一个新的设备文件。像`sys_link`一样,`create`首先调用`nameiparent`来获取上级目录的inode。然后调用`dirlookup`检查名称是否存在。如果名称存在,`create`的行为依赖于它是被哪个系统调用使用:对于`mkdir`和`mkdev`来说`open`有不同的语义。如果`create`是代表`open`(`type == T_FILE`)使用的,并且名称存在且是一个常规文件,那么`open`就算是成功了,`create`也是。否则,就是一个错误。如果名称不存在,`create`使用`ialloc`创建一个分配个新的inode。如果新的inode是一个目录,`create`使用`.`和`..`条目初始化它。最后,数据被正确初始化了,`create`把它链接进上级目录。`create`像`sys_link`一样,同时持有了两个inode锁:`ip`和`dp`。没有死锁的可能,因为inode`ip`是最新分配的:系统中没有其它进程会持有`ip`的锁,所以也就不会尝试锁定`dp`。使用`create`,可以轻松实现`sys_open`、`sys_mkdir`和`sys_mknod`。`sys_open`是最复杂的,因为创建一个新文件只是它的工作的一小部分。如果给`open`传递`O_CREATE`,它会调用`create`。否则,它调用`namei`。`create`返回一个上锁的inode,但`namei`不会,所以`sys_open`必须自己锁定这个inode。这可以方便的检查目录是为读而打开的,不是写。假定inode是这样或那样获得的,`sys_open`分配一个文件和一个文件描述符,然后填满这个文件。注意没有其它进程可以访问部分初始化的文件,因为它只存在于当前进程的表中。 + +在调度那一章里,在没有文件系统前,就研究了管道的实现。函数`sys_pipe`通过提供创建管道对的方法连接到文件系统的实现。它的参数是一个数组指针,数组里有两个整数,它将在里面记录两个新的文件描述符。然后它分配管道并保存文件描述符。 + + + + + + + + + + + + + + + + + + + + + + + diff --git a/OS/XV6/专题分析/xv6的调度.md b/OS/XV6/专题分析/xv6的调度.md new file mode 100644 index 0000000..afaff3b --- /dev/null +++ b/OS/XV6/专题分析/xv6的调度.md @@ -0,0 +1,138 @@ +任何操作系统的进程都可能比CPU多,所进程需要分时共享(time-share)CPU。理想状态下,共享对用户进程应当是透明的。常用的方式的把进程多路复用到硬件CPU上,使每个进程产生拥有自己的虚拟CPU的假象。本章描述xv6如何实现的多路复用。 + +#### 多路复用 + +xv6的多路复用是让进程在所有CPU之间切换,它分两种情况。一,当进程等待设备或管道I/O的完成,或等待子进程退出,或在`sleep`系统调用中等待,使用`sleep`和`wakeup`机制切换。二,定时强制切换进程。多路复用使得每个进程看上去都拥有自己的CPU,就像内存分配器和硬件页表的使用使得每个进程看上去都拥有自己的内存一样。 + +实现多路复用会有一些挑战。一,怎么来让进程切换呢?虽然上下文切换的想法很简单,它的实现却是xv6里最不透明的代码。二,如何让强制切换对用户进程透明?xv6使用计时器中断驱动上下文切换,这是标准技术。三,多个CPU并发地在进程间切换,需要锁来避免竞争。四,进程退出时它的内存和其它资源必须被释放,但它不能自己完成全部的过程,比如它不能在运行的时候释放自己的内核栈。五,多核系统的每个核都必须记住它运行的是哪个进程,这样系统调用才能影响正确进程的内核状态。最后,`sleep`和`wakeup`允许放弃CPU并睡眠以等待一个事件,并允许其它的进程唤醒先前的进程。要小心避免竞争,否则会导致唤醒通知的丢失。xv6尝试尽可能简单地解决这些问题,但最后的代码依然很复杂。 + +#### 代码:上下文切换 + +从一个进程切换到另一个进程的步骤是:用户进程通过系统调用或中断进入内核态,通过上下文切换进到当前CPU的调度线程,再通过上下文切换进入到新进程的内核线程,返回到用户态的进程。xv6调度器在每个CPU上都有一个专用的线程(保存的寄存器和栈),因为它运行在任何进程的内核栈上都是不安全的。本节中将研究内核线程和调度器线程之间的切换机制。 + +从一个线程切换到另一个包含了保存旧线程的寄存器,和恢复新线程的寄存器。切换sp寄存器意味着要切换栈,切换pc寄存器意味着切换要执行的代码。 + +函数`swtch`实现了线程切换时保存和恢复两个过程。`swtch`并不知道线程,它只是保存和恢复寄存器的集合,这个寄存器的集合叫做**上下文**。当进程要让出CPU的时候,进程的内核线程调用`swtch`来保存它自己的上下文并返回到调度器的上下文。每个上个文都包含在`struct context`里,而它自己又被包含在进程的`struct proc`里或CPU的`struct cpu`里。`swtch`有两个参数:`struct context *old`指当前的寄存器组,`struct context *new`指要载入的寄存器组。 + +我们来看看一个进程是怎么通过`swtch`进到调度器的。中断导致`usertrap`执行,`usertrap`调用`yield`。`yield`再调用`sched`,`sched`调用`swtch`将当前上下文保存在`p->context`,并切换到之前保存在`cpu->scheduler`的调度器上下文。 + +`swtch`只保存被调用者要保存的寄存器,调用者的的寄存器由调用的C代码保存在栈上(如果需要的话)。`swtch`知道`struct context`里每个寄存器字段的位置。它不保存pc寄存器。相反,`swtch`保存`ra`寄存器,这个寄存器保存着调用`swtch`的返回值。现在`swtch`从新上下文里恢复寄存器,新上下文里保存的是上一个`swtch`的寄存器。当`swtch`返回,它返回到`ra`所指向的指令处,也就是新线程之前调用`swtch`的指令。除此之外,它返回到新线程的栈上。 + +在我们的例子里,`sched`调用`swtch`以切换到`cpu->scheduler`。上下文通过`scheduler`对`swtch`的调用而得以保存。当我们追踪的`swtch`返回时,它返回到`scheduler`而不是`sched`,并且它的sp寄存器将指向当前CPU的调度器的栈。 + +#### 代码:调度 + +上一节讨论了`swtch`的底层细节,本节研究一个进程是怎么通过调度器切换到另一个进程的。调度器就是每个CPU上运行的`scheduler`函数。这个函数的任务就是选择下一个要运行的进程。一个进程要让出CPU首先要获取它自己的进程锁`p->lock`,释放它持有的其它锁,更新它自己的状态`p->state`,然后调用`sched`。当我们在做`sleep`和`exit`的时候,`yield`就遵循了这个约定。`sched`再次检查了这些条件,并检查这些条件隐含的结果:如果持有锁,中断应当是关闭的。最后,`sched`调用`swtch`保存`p->context`的上下文并切换到`cpu->scheduler`里调度器的上下文。`swtch`返回到调度器的栈,就像`scheduler`的`swtch`已经返回了。调度器继续`for`循环,找到一个进程来运行,切换到它,重复这个过程。 + +我们在调用`swtch`的过程中xv6持有了`p->lock`:`swtch`的调用者必须持有锁,并且对锁的控制传递给了要切换过去的代码。这个约定在锁上是不常见的,通常哪个线程获取锁,哪个线程就负责释放锁,这有利于判断正确性。对于上下文切换来说,则要打破这个约定,因为`p->lock`保护的是进程的`state`和`context`字段的不变量,那些字段在执行`swtch`的时候是不正确的。如果在`swtch`期间不持有`p->lock`可能会有这样的问题:对于在`yield`之后已经把自己的状态设置为`RUNNABLE`的进程,可能会有其它CPU决定运行它,但此时`swtch`可能还没有使它停止使用它自己的内核栈。这导致两个CPU运行在同样的栈上,这是错误的。 + +内核线程总是在`sched`里让出CPU,并且总是在调度器里切换到同样的位置,调度器几乎总是切换到之前调用`sched`的内核线程上。线程切换实际上就是`scheduler`和`sched`的`swtch`交替执行的过程。这种在两个线程间程式化切换的过程叫做**协程**(coroutines)。`sched`和`scheduler`就互为协程。 + +在一种情况下,调度器调用`swtch`但不在`sched`中结束。当一个新的进程第一次调度的时候,它在`forkret`开始。`forkret`的存在就是为了释放`p->lock`,否则新进程就应从`usertrapret`开始。 + +`scheduler`运行的是一个简单的循环:找到一个进程来运行,直到这个进程让出CPU,然后一直重复这样的过程。调度器在进程表里找到一个可运行的进程,那个进程是`p->state==RUNNABLE`。一旦它找到这样的进程,它就设置当前CPU的当前进程`c->proc`,将进程标记为`RUNNING`,然后调用`swtch`。 + +可以把调度代码的结构认为是强制每个进程的一组不变量,一旦这些不变量是不正确的就持有`p->lock`。一个不变量是,如果一个进程是`RUNNING`的,中断计时器的`yield`必须可以安全地切换出去;这意味着CPU的寄存器必须保留着进程寄存器的值(如`swtch`没有把它们移动到一个`context`),且`c->proc`必须指向这个进程。另一个不变量是,如果一个进程是`RUNNABLE`的,它必须是安全的以便一个空闲CPU的`scheduler`可以运行它;这意味着`p->context`必须保留进程的寄存器(如,它们不是在真实的寄存器里),没有CPU运行在进程的内核栈上,没有CPU的`c->proc`指向此进程。显然当持有`p->lock`这些特性都不正确。 + +xv6之所以在一个线程里请求`p->lock`而在另一个线程里释放它,就是为了保持上面所提的不变量,比如在`yield`请求而在`scheduler`释放。一旦`yield`开始一个运行进程的状态使它`RUNNABLE`,必须持有锁直到不变量被恢复:最早的正确的释放点在`scheduler`清空`c->proc`之后,那时`scheduler`运行在它自己的栈上。同样地,一旦`scheduler`开始把一个可运行的进程转换为`RUNNING`,直到内核线程完全运行的时候才可以释放锁(对于`yield`来说就是在`swtch`之后)。 + +`p->lock`也保护了其它的东西:`exit`和`wait`的相互作用,避免唤醒(wakeup)的缺失,避免一个进程退出时其它进程读写它的状态的竞争(如,`exit`系统调用查找`p->pid`并设置`p->killed`)。出于清晰和性能的考虑,也许应该把`p->lock`的不同功能分开。 + +#### 代码:mycpu和myproc + +xv6经常需要一个指针来指向当前进程的`proc`结构体。单处理器上可以用一个全局变量来指向当前`proc`。然而在多核机器上却不行,因为每个核都在执行不同的进程。解决这个问题的方法是利用每个核自己的寄存器;可以利用其中的一个寄存器来查询那个核的信息。 + +xv6为每个CPU维护了一个`struct cpu`,它记录了在那个CPU上正在运行的进程(如果存在的话),为CPU的调度器线程保存的寄存器,用于管理中断关闭的自旋锁嵌套层数。函数`mycpu`返回当前CPU的`struct cpu`的指针。RISC-V给所有CPU进行了编号,即`hartid`。xv6确保每个CPU的hartid保存在那个CPU的`tp`寄存器里。这使得`mycpu`可以使用`tp`来索引`cpu`结构体的数组以找到正确的那个。 + +确保一个CPU的`tp`总是保存着这个CPU的hartid有点复杂。在CPU启动的初期`start`设置了`tp`寄存器,当时仍在机器模式。`usertrapret`把`tp`保存在trampoline页,因为用户进程可能修改`tp`。最后,当从用户空间进到内核的时候`uservec`恢复已保存的`tp`。编译器保证绝不使用`tp`寄存器。如果RISC-V允许xv6直接读取当前hardid会更方便,但只能是在机器模式,在管理员模式是不允许的。 + +`cpuid`和`mycpu`的返回值是脆弱的:如果发生计时器中断使得线程让出CPU,返回值将不再正确。为避免这个问题,xv6需要调用者关闭中断,只有在使用完返回的`struct cpu`后才可以开启中断。 + +函数`myproc`返回`struct proc`,它指向的是在当前CPU正在运行的进程。`myproc`关闭中断,调用`mycpu`,从`struct cpu`获取当前进程的指针`c->proc`,然后使能中断。即使开启中断`myproc`的返回值也是安全的:如果记时器中断把调用的进程移动了其它CPU,它的`struct proc`指针仍然是不变的。 + +#### 睡眠与唤醒 + +调度和锁有助于一个进程相对于其它进程隐藏自己的存在性,但到目前为止还没有抽象方式可以帮助进程之间的交互。许多机制被发明出来解决这个问题。xv6使用的机制叫睡眠和唤醒,这允许一个进程睡眠以等待一个事件,一旦这个事件发生其它进程会把这个进程唤醒。睡眠和唤醒被称为**序列协调**(sequence coordination)或**条件同步**(conditional synchronization)。 + +为了说明我们的意思,考虑一个简单的同步机制**信号量**(semaphore),它被用来协调生产者和消费者。信号量包含了一个记数,并提供了两个操作。V操作递增记数(用于生产者),P操作等待记数为0,然后递减记数并返回(用于消费者)。如果生产者线程和消费者线程都只有一个,且它们在不同CPU上运行,且编译器没有进行太积极的优化,如下实现是正确的: + +```c +struct semaphore { + struct spinlock lock; + int count; +} + +void V(struct semaphore *s){ + acquire(&s->lock); + s->count += 1; + release(&s->lock); +} + +void P(struct semaphore *s){ + while(s->count == 0) + ; + acquire(&s->lock); + s->count -= 1; + release(&s->lock); +} +``` + +如上实现代价高昂。如果生产者很少活动,消费者就要花大量的时间在`while`循环里自旋。相对于忙等,消费者的CPU应该去做一些更有意义的工作。要想避免忙等,就需要消费者让出CPU且只在V操作后恢复。 + +让我们来考虑一对调用`sleep`和`wakeup`。`sleep(chan)`睡在任意值`chan`上,被**等待频道**(wait channel)调用。`sleep`让调用的进程睡眠,释放CPU来干其它的事情。`wakeup`唤醒所有睡眠在`chan`上的进程(如果存在的话),引发它们的`sleep`调用返回。如果没有进程等待在`chan`上,`wakeup`就什么也不做。可以把`wakeup`加到V操作,且把`sleep`加到P操作,来改变信号量的实现。 + +P操作不再自旋而是放弃了CPU,这很好。但是,设计`sleep`和`wakeup`这样的接口并不容易,因为它会带来所谓的**唤醒丢失**(lost wake-up)问题。假定P操作发现`s->count == 0`。当P操作还没有执行`sleep`的时候,V运行在了其它CPU上:它改变了`s->count`为非0的值并调用了`wakeup`,它发现没有睡眠的进程所以什么都没有做。现在P操作继续执行`sleep`并进程睡眠。这就引发了一个问题:P进入睡眠等待一个已经发生了的V操作。除非我们很幸运生产者又调用了V操作,否则消费者将永远等待(被死锁了)即使记数是非0的。 + +问题的根源在于,当`s->count == 0`时P才能睡眠的不变量被破坏了,因为V运行在错误的时候。可以在P里使用自旋锁让计数检查和`sleep`调用是原子的。这样的P确实可以避免唤醒丢失,但它依然会死锁:P在睡眠的时候持有锁,导致V等待这个锁而被永远阻塞。 + +我们通过修改`sleep`接口修复之前的方案:调用者必须把锁传递给`sleep`,这样在调用进程被标记为睡眠且等待在睡眠频道之后,`sleep`就可以把锁释放。锁将强制并发的V等待直到P完成自己的睡眠,这样`wakeup`将发现睡眠的消费者并把它唤醒。一旦消费者被再次唤醒,`sleep`在返回前会请求锁。 + +P持有`s->lock`避免了P在检查`c->count`和调用`sleep`期间,V唤醒它的尝试。我们需要`sleep`来原子性地释放锁并让消费者进程睡眠。 + +#### 代码:睡眠与唤醒 + +我们来看看`sleep`和`wakeup`的实现,它们都在kernel/proc.c。基本思路是:让`sleep`把当前进程标记为`SLEEPING`并调用`sched`让出CPU;`wakeup`查找在等待频道上睡眠的进程并把它标记为`RUNNABLE`。`sleep`和`wakeup`的调用者可以使用任何方便的数字作为频道。xv6经常使用与等待相关的内核数据结构的地址。 + +`sleep`请求`p->lock`。现在准备睡眠的进程持有`p->lock`和`lk`。调用者持有`lk`是必需的(在本例中是P):它确保没有其它进程(在本例中是运行的V)可以调用`wakeup(chan)`。现在`sleep`持有`p->lock`,释放`lk`就是安全的:其它进程可能开始调用`wakeup(chan)`,但`wakeup`将会等待获取`p->lock`,这样就一直等到`sleep`完成让进程睡眠,使得`wakeup`不会丢失`sleep`。 + +有一个小问题:如果`lk`是像`p->lock`一样的锁,当`sleep`尝试获取`p->lock`的时候会把自己死锁。但如果调用`sleep`的进程已经持有了`p->lock`,它并不用做其它的事来避免丢失并发的`wakeup`了。当`wait`带着`p->lock`调用`sleep`时会出现这种情况。 + +现在`sleep`仅持有`p->lock`,它可以通过记录睡眠频道、改变进程状态和调用`sched`来让进程睡眠。 + +稍后,某个进程将调用`wakeup(chan)`。`wakeup`在进程表里循环。它获取它检查的每个进程的`p->lock`,即是因为它可以管理进程的状态,也是因为`p->lock`不会让`sleep`和`wakeup`彼此错过。当它发现一个`SLEEPING`状态的进程与`chan`匹配的时候,它把进程状态改为`RUNNABLE`。下次调度器运行的时候,它会发现这个进程已经准备好运行了。 + +xv6代码持有"条件锁"的时候总是会调用`wakeup`;在信号量的例子里那个锁是`s->lock`。严格来说如果`wakeup`总是跟在`acquire`之后就足够了(即,应该在调用`release`后调用`wakeup`)。为什么`sleep`和`wakeup`的锁的规则要确保睡眠的进程不会丢失它需要的唤醒呢?睡眠的进程,从它检查条件之前到它标记为睡眠之后,要么持有条件锁,要么持有自己的`p->lock`,要么持有两者。如果一个并发的线程使得条件为真,那个线程必需持有这个条件锁,或在睡眠的线程请求条件锁之前,或在睡眠的线程在`sleep`里释放它之后。如果是在之前,睡眠线程必须看到新的条件值,并且不管怎样决定睡眠,所以它不担心丢失唤醒。如果是在之后,唤醒者可以请求条件锁的最早时机是在`sleep`请求`p->lock`之后,所以`wakeup`对`p->lock`的请求必须等待直到`sleep`让睡眠者彻底睡眠。然后`wakeup`将看到睡眠的进程并把它唤醒(除非有什么东西先把它唤醒)。 + +可能会有多个进程睡在同一个频道上的情况;例如,多个进程读取一个管道。一旦调用`wakeup`将会把它们全部唤醒。其中一个进程将首先执行并请求`sleep`被调用时所请求的锁,并读取所有等待在管道中的数据。其它进程会发现,尽管被唤醒了,但无数据可读。从它们的角度来看,这个唤醒是“假的”,它们必须再次睡眠。因此,`sleep`总是在检查条件的循环中被调用。 + +如果在两次使用“睡眠/唤醒”的时候不小心选择了同样的频道,不会造成任何伤害:它们会看到假的唤醒,但上面所述的循环可以容忍这样的问题。“睡眠/唤醒”的魅力在于它们都是轻量级的(不需要创建特殊的数据结构来充当睡眠频道),并提供了一个间接层(调用者不需要知道它在和哪个进程交互)。 + +#### 代码:管道 + +使用`sleep`和`wakeup`来同步生产者和消费者的一个复杂的例子,是xv6里对管道的实现。数据从管道的一端写入,复制进内核里的缓冲区,然后从管道的另一端读出。我们先来看看`pipewrite`和`piperead`的实现。 + +每个管道都代表了一个`struct pipe`,它包含了一个`lock`和一个`data`缓冲区。字段`nread`和`nwrite`记录读出或写入缓冲区的字节数。缓冲区的封装:`buf[PIPESIZE-1]`的下一个字节是`buf[0]`。计数不封装。这个约定让一个满的缓冲区(`nwrite == nread + PIPESIZE`)和一个空的缓冲区(`nwrite == nread`)区分开来,但是它也使得缓冲区的索引必须是`buf[nread % PIPESIZE]`而不是简单的`buf[nread]`(对于`nwrite`来说也是一样)。 + +假定两个不同的CPU同时调用了`piperead`和`pipewrite`。`pipewrite`从请求管道的锁开始,这样可以保护记数、数据和相关的不变量。`piperead`也试着请求锁,但请求不到。它在`acquire`上自旋等待这个锁。当`piperead`等待的时候,`pipewrite`循环遍历要写入的字节(`addr[0...n-1]`),依次将每个字节添加到管道中。在循环的过程中,缓冲区可能被填满。在这种情况下,`pipewrite`调用`wakeup`来提醒睡眠的读者有数据等待在缓冲区,并睡在`&pi->nwrite`上来等待一些读者从缓冲区中取走一些数据。`sleep`释放`pi->lock`,这是让`pipewrite`的进程进入睡眠的一部分。 + +现在`p->lock`是可用的,`piperead`可以请求它并进到它的临界区:它发现`pi->nread != pi->nwrite`(`pipewrite`因为`pi->nwrite == pi->nread+PIPESIZE`而进入睡眠),所以它进到`for`循环里,从管道中复制出数据,按复制出的字节数递增`nread`。现在有许多字节可以写入了,所以`piperead`在它返回前调用`wakeup`唤醒睡眠的写者。`wakeup`找到睡在`&pi->nwrite`上的进程,这个进程就是那个因为满了而中断的`pipewrite`。它把那个进程标记为`RUNNABLE`。 + +管道代码为读者和写者使用了不同的睡眠频道(`pi->nread`和`pi->nwrite`);当有大量的读者和写者等在相同的管道上时(当然这种情况不太可能发生),这可能会使系统更高效。在检查睡眠状态的循环里,管道代码睡眠了;如果有多个读者或写者,除了第一个进程外,所有被唤醒的进程都发现条件是假的并继续睡眠。 + +#### 代码:wait, exit和kill + +`sleep`和`wakeup`可以用在多种类型的等待中。一个有趣的例子是,一个子进程的`exit`和它的父进程的`wait`交互。在子进程死亡时,父进程可能已经在`wait`里睡眠了,或者在做其它的事;在后一种情况下,一个随后的对`wait`的调用必须发现子进程死亡了,可能在子进程调用`exit`很久之后。xv6记录子进程死亡的方式,是调用`exit`让子进程进入`ZOMBIE`状态,直到父进程的`wait`发现它才把状态改为`UNUSED`,复制子进程的退出状态,并把子进程的进程号返回给父进程。如果父进程先于子进程退出了,父进程把子进程给到`init`进程,`init`不停地调用`wait`;因此,每个子进程都有一个父进程来清理。代码实现的最大挑战是可能的竞争和死锁。 + +`wait`使用调用进程的`p->lock`作为条件锁来避免唤醒丢失,它在一开始的时候就请求了那个锁。然后它描述进程表。如果它发现了处于`ZOMBIE`状态的子进程,它释放那个子进程的资源和它的`proc`结构体,把子进程的退出状态复制到提供给`wait`的地址(如果它不是0的话),并返回子进程的进程号。如果`wait`发现了有子进程但这些子进程都没有退出,它会调用`sleep`等待其中一个退出,然后重新开始描述。在这里,`sleep`释放的条件锁是`p->lock`,即上面提到的特殊情况。注意`wait`经常会持有两个锁,为避免死锁,它使用的锁序是先父进程后子进程。 + +`wait`使用`np->parent`的时候不持有`np->lock`,这违反了通常的规则,即共享变量必须用锁保护起来。因为`np`有可能是当前进程的祖先,在这种情况下请求`np->lock`可能会引起死锁(违背了先父进程后子进程的锁序)。在这种情况下,不使用锁检查`np->parent`看起来是安全的;一个进程的`parent`字段只能被它的父进程改变,所以如果`np->parent==p`为真,这个值不会改变除非当前进程改变它。 + +`exit`记录退出状态,释放一些资源,把子进程给到`init`进程,唤醒处于`wait`中的父进程,将调用者标记为僵尸进程,并永久性地让出CPU。最后的顺序有点复杂。退出的进程在把自己的状态设为`ZOMBIE`的时候必须持有它的父进程的锁,并且把父进程唤醒,因为父进程的锁是条件锁,这个条件锁用来防止在`wait`里的唤醒丢失。子进程还必须持有它自己的`p->lock`,否则父进程可能发现它处于`ZOMBIE`状态从而在它仍然运行的时候释放它。锁请求的次序对于避免死锁来说是非常重要的:因为`wait`先请求父进程的锁再请求子进程的锁,所以`exit`必须使用同样的次序。 + +`exit`调用一个特殊的唤醒程序`wakeup1`,它只唤醒睡在`wait`上的父进程。看上去子进程在把自己的状态设为`ZOMBIE`之前就唤醒父进程是不正确的,但实际上这很安全:虽然`wakeup1`可能引发父进程的运行,`wait`里的循环不会检测子进程直到子进程的`p->lock`被`scheduler`释放,所以`wait`不会看到退出的进程直到`exit`把它的状态设为`ZOMBIE`。 + +`exit`允许一个进程终止自己,`kill`则允许一个进程终止其它的进程。`kill`如果直接毁掉目标进程将会非常复杂:因为目标进程可能正在其它CPU上运行,可能正在对内核的数据结构进行一系列的敏感更新。`kill`只做了少量的工作就解决了这些挑战:它只是设置目标进程的`p->killed`,如果目标进程在睡眠就唤醒它。最终目标进程会进入或离开内核,那时如果设置了`p->killed`,在`usertrap`会调用`exit`。如果目标进程运行在用户空间,它也会因为系统调用或计时器中断而进入内核。 + +如果目标进程在`sleep`,`kill`调用`wakeup`会让目标进程从`sleep`返回。这有潜在的风险,因为正在等待的条件可能不是真的。然而xv6调用`sleep`问题包装在一个`while`循环中,这个循环会在`sleep`返回后重新测试条件。一些对`sleep`的调用也会在循环里测试`p->killed`,如果设置了则终止当前的活动。只有在那种终止是正确的情况下才可以这么做。例如,如果设置了killed标志管道的读写代码会返回;最终会返回到trap,然后又开始检查标志并退出。 + +一些xv6的`sleep`循环不检查`p->killed`,因为代码正在进行多步的系统调用,那是原子性的。virtio驱动就是一个例子:它不检查`p->killed`,因为为了让文件系统保持正确的状态,操作操作可能是所有必需的写操作里的其中一个。一个标记为killed的进程在等待磁盘I/O的时候不会退出,直到它完成当前的系统调用,`usertrap`看到killed标记。 diff --git a/OS/XV6/专题分析/xv6的锁.md b/OS/XV6/专题分析/xv6的锁.md new file mode 100644 index 0000000..b67ce7f --- /dev/null +++ b/OS/XV6/专题分析/xv6的锁.md @@ -0,0 +1,136 @@ +大多数内核,包括xv6,交错执行多个活动。其中一个交错的资源就是多处理器硬件:多个CPU独立运行的计算机,比如xv6赖以运行的RISC-V。这些CPU共享物理内存,xv6利用这个共享来管理所有CPU读写的数据结构。这个共享提升了如下可能性:一个CPU在读取数据结构而另一个CPU在更新它,多CPU同时更新相同的数据。对那样并行访问的情况如不仔细设计,很可能会产生错误的结果或被破坏的数据结构。即使是单处理器,内核也可能让CPU在多个线程间切换,导致它们交错执行。最后,中断处理例程也可能修改中断代码的数据,这也会导致数据的损坏。**并发**(concurrency)的意思就是指多个指令流交错的情况,由于多处理器并行、线程切换或中断。 + +内核中充满了并发访问的数据。如,两个CPU可能并发地调用`kalloc`,于是并发地从空闲列表里弹出了元素。内核的设计者倾向于允许大量的并发,因为它可以导致性能和响应能力的提升。然而,内核设计者不得不花大量的努力来证明引入并发后代码的正确性。有很多方法达到正确的代码,一些比较好解释,但另一些不好解释。让并发正确的策略和支持它们的抽象,被称为**并发控制**(concurrency control)。xv6根据情况使用了大量并发控制的技术,更多的并发控制是可能的。本章聚焦于一个广泛使用的技术:锁。 + +锁提供的是互斥,保证一个时间段内只有一个CPU可以持有锁。如果程序员把所有共享数据都关联了一个锁,且代码在使用那条数据的时候始终持有关联的锁,那么那条数据在一个时间段内只能被一个CPU使用。这种情况下,我们说锁保护了这条数据。 + +本章其余部分解释了为什么需要锁,怎么实现它,怎么使用它。 + +#### 竞争条件 + +为什么需要锁呢?考虑一个链表,它可以被多处理器的任意CPU访问。链表支持push和pop操作,这些操作可能是并发的。xv6的内存分配器很大程度上就是这样工作的,`kalloc`从列表里弹出空闲页,`kfree`把页加到列表里。 + +在没有处理并发的情况下,如果两个CPU都在执行`push`操作,有可能两个元素都往同一个位置加,后加的元素会覆盖先加的元素。 + +上面的情况就是竞争条件(race condition)的一个例子。竞争条件就是并发地访问内存位置,且至少有一个访问是写操作。竞争经常标志着产生了错误,可能是写操作丢失了更新,也可能是读了未完全更新的数据结构。竞争的结果取决于两个CPU的时机和它们在内存系统里的内存操作次序,这使得竞争引起的错误难以复现和调试。比如调试`push`的时候添加打印语句足以改变执行的时机而使竞争消失。 + +避免竞争的常用方法就是使用锁。锁保证了互斥,这样一个时间段内只有只有一个CPU可以执行`push`里的敏感操作,从而使上述设想成为可能。在`acquire`和`release`之间的代码被称为 **临界区**(critical section)。 + +所谓的锁保护了数据,就是指保护了加到数据里的一些不变量。不变量就是整个操作过程中数据的属性。一般来说,操作开始的时候一个操作的正确性依赖于不变量的正确性。操作可能临时修改不变量但必须在完成前恢复它们。比如,链表例子里的不变量是指向第一个链表元素的链表,和每个元素里指向下一个元素的`next`字段。`push`的实现临时修改了不变量,但随后又消除了这个影响。如上的竞争条件能发生,是因为第二个CPU执行的代码依赖于列表的不变量。正确使用锁以确保一个时间段内只有一个CPU可以操作临界区的数据结构,这样当数组结构的不变量没有被持有的情况下,没有CPU可以执行数据结构的操作。 + +可以把锁认为是序列化的并发临界区,这样就可以一个时间段只运行一个,并保护了不变量(假定临界区的分隔是正确的)。也可以认为被同一个锁保护的临界区相互之间是原子的,这样只能看到完整修改后的临界区。我们说进程之间相互 **冲突**(conflict),如果它们同时想要相同的锁,或那个锁发生争用(contention)。 + +注意在`push`里把`acquire`往前移是正确的。这只是会降低并发性。 + +#### 代码:锁 + +xv6里有两种锁:自旋锁和睡眠锁。xv6里的自旋锁是`struct spinlock`。其中最重要的字段是`locked`,为0时表示锁存在,为非0时表示锁被持有。 + +为避免多CPU同时持有一个锁,需要在临界区内执行原子性的步骤。 + +RISC-V提供了原子指令`amoswap`,用于原子性地交换内存地址和寄存器的值。当内存地址正在读写时,它使用了特殊硬件来防止CPU使用那个内存地址,从而实现了指令的原子性。 + +xv6的`acquire`定义在kernel/spinlock.c。`acquire`使用了内建函数`__sync_lock_test_and_set`,这个函数最终使用了`amoswap`指令,它返回的是`lk->locked`的原值。`acquire`在循环里实现的交换,不断的重试直到获得锁。每个循环都把1交换进`lk->locked`并检查之前的值。如果之前的值是0则获得锁,并且交换使得`lk->locked`的值为1。如果之前的值是1,表明有其它CPU持有这个锁,把1交换进`lk->locked`的操作不生效。 + +一旦获得锁,`acquire`为方便调试会记录获得锁的CPU。`lk->cpu`字段被锁保护,且只能在持有锁的时候被修改。 + +`release`函数是`acquire`的反操作,它清空`lk->cpu`字段并释放锁。从概念上说,释放锁只需给`lk->locked`赋值为0即可。C语言的赋值语句是多条指令完成的,所以它是不且有原子性的。`release`使用了内建函数`__sync_lock_release`来实现原子性地赋值。这个内建函数最终也使用了`amoswap`指令。 + +#### 代码:使用锁 + +xv6在很多地方使用了锁。关于它的简单例子,可以看看`kalloc`和`kfree`。如果去掉锁也很难触发错误的行为,这意味着锁的错误和竞争是难以测试的。xv6里应该是没有竞争的。 + +使用锁的难点在于,使用多少锁,锁应该保护哪些数据和不变量。有一些基本原则:一,当一个CPU写变量的时候,其它CPU也可以读或写这个变量,应该使用锁;二,记住锁保护的是不变量,如果一个不变量包含了多个内存位置,所有这些位置都需要被单一的锁保护起来。 + +上述规则说了什么时候使用锁,但没说什么时候可以不用锁,为了效率不应大量使用锁,因为锁会降低并发性。在并发性不重要的情况下,可以只安排一个线程而不使用锁。简单的内核可以在多处理器上通过只持有一个锁的方式来实现这点,即在进入内核的时候获得锁,在退出内核的时候释放锁(但在系统调用的时候可能会有问题,如读管道或`wait`)。许多单处理器操作系统通过这个方法在多处理器上运行,有时被叫做"大内核锁"(big kernel lock),但这种方法牺牲了并行性,一个时间段内只有一个CPU可以运行。如果内核要进行大型运算,那么使用大量细粒度的锁要高效的多,因为内核是在多个CPU上并行执行的。 + +xv6的内存分配器是一个粗粒度锁的例子,它只用了一个锁来保护空闲列表。如果不同CPU上的进程在同时分配物理页,大家都要等待在`acquire`上的自旋(spinning)。自旋降低了性能,因为它是无效的工作。如果对锁的争用浪费了大量的CPU时间片段,可以把分配器设计为有多个空闲列表来提升性能,每个都有自己的锁,这样就可以真正的并行分配了。 + +作为细粒度锁的例子,xv6为每个文件都分配了锁,这样进程在处理多个文件的时候就不必等待彼此的锁了。文件锁的粒度可以更细,如果想并行地写文件的不同区域的话。最终,锁的粒度需要考虑性能和复杂性两方面的情况。 + +xv6中的所有锁如下表所示。详细的解释分散在各章之中。 + +| 锁 | 描述 | +| ----------- | ---------------------------------- | +| bcache.lock | 保护对块缓冲区缓存的分配 | +| cons.lock | 序列化访问控制台硬件,避免混合输出 | +| ftable.lock | 序列化访问文件列表里的结构体file | +| icache.lock | 保护对inode缓存入口的分配 | +| vdisk_lock | 序列化访问磁盘硬件和DMA描述符队列 | +| kmem.lock | 序列化内存访问 | +| log.lock | 序列化事务日志的操作 | +| pi->lock | 序列化管道的操作 | +| pid_lock | 序列化next_pid的增长 | +| p->lock | 序列化进程状态的改变 | +| tickslock | 序列化ticks计数器的操作 | +| ip->lock | 序列化每个inode和它的内容的操作 | +| b->lock | 序列化每个块缓冲区的操作 | + + + +#### 死锁和锁序 + +如果到内核的代码路径必须同时持有多个锁,则所有的代码路径以同样的次序获取这些锁是十分重要的。否则就会有死锁的危险。假定有两个代码路径需要锁A和B,代码路径一按从A到A的次序获取锁,代码路径二按从B到A获取锁。假定线程一执行代码路径一并获取了锁A,线程二执行代码路径二并获取了锁B。接下来,线程一尝试获取锁B,线程二尝试获取锁A。两个获取都会被永远阻塞,因为它们分别持有了对方需要的锁,并且在获取所需的锁之前都不会释放自己的锁。全局性的锁获取次序意味着,锁是函数规范的一部分:调用者在调用函数的时候,必须使锁按规定的次序被获取。 + +xv6包含很多长度为2的锁序链(lock-order chains),包含每个进程的锁(`struct proc`里的锁),这是由于`sleep`的工作方式。例如,`consoleintr`是处理输入字符的中断例程。当新行到达时,等待控制台输入的所有进程都应被唤醒。为实现此目的,`consoleintr`在调用`wakeup`的时候持有了锁`cons.lock`,获取等待进程的锁是为了唤醒它。因此,全局的避免死锁的锁序包含了一个规则,即必须在任何进程锁之前先获取`cons.lock`。xv6里最长的锁链(lock chains)在文件系统的代码里。例如,创建文件需要同时目录持有锁,新文件的inode持有锁,磁盘块缓冲区持有锁,磁盘驱动的`vdisk_lock`,和调用进程的`p->lock`。为避免死锁,文件系统代码总是按前述的次序获取锁。 + +实现一个全局性的避免死锁的序列可能极为困难。有时锁序和逻辑程序结构矛盾,比如代码模块M1调用M2,但锁序要求M2的锁在M1的锁之前获取。有的时候锁的身份无法预先知道,有可能是因为先持有一个锁才能发现这个锁的身份。这个情况出现在文件系统中,当它在路径名中查找连续组件的时候,在`exit`和`wait`的代码里当在进程表里搜索子进程的时候。最后,死锁的风险经常是对细粒度锁制定锁方案的限制,因为更多的锁意味着更多的死锁机会。避免死锁是内核实现的一个重要因素。 + +#### 锁和中断处理例程 + +一些xv6自旋锁保护的数据被线程和中断处理例程两者使用。例如,在`clockintr`增加`ticks`的时候内核正在`sys_sleep`里读取`ticks`。锁`tickslock`序列化了这两个访问。 + +自旋锁和进程的交互提升了潜在的危险性。考虑`sys_sleep`持有`tickslock`,且它的CPU发生了时器中断。`clockintr`尝试获取`tickslock`,看见它被持有了,一直等待这个锁的释放。这种情况下,`tickslock`永远不会被释放,只有`sys_sleep`能释放它,但`clockintr`不返回`sys_sleep`就不能继续执行。所以,CPU会死锁,任何需要锁的代码都会被冻结。 + +为避免这种情况,如果中断处理例程使用了自旋锁,当中断发生的时候CPU一定不能持有那个锁。xv6更保守一点,CPU获取锁之前会关闭那个CPU上的所有中断。其它CPU仍然能发生中断,所以一个中断的`acquire`仍然可以等待一个线程来释放自旋锁,只是不在同一个CPU而已。 + +当CPU不持有任何自旋锁的时候,xv6会重新开启中断,它必须做点簿记(book-keeping)来复制嵌套的临界区。`acquire`调用`push_off`,`release`调用`pop_off`来记录当前CPU上锁的嵌套层级。当记数为0,`pop_off`将恢复临界区最外层的中断状态。`intr_off`和`intr_on`分别执行RISC-V执行来关闭和开启中断。 + +`acquire`在设置`lk->locked`之前直接调用`push_off`是非常重要的。如果颠倒了两者的次序,当中断使能的情况下持有锁会有一个明显的窗口,这时如果发生计时器中断将锁死系统。同样地,`release`也要在释放锁后调用`pop_off`。 + +#### 指令与访存排序 + +认为程序执行的次序就如源代码所显示的那样是很自然的。但是很多编译器和CPU为了实现更高的性能不按次序执行代码。对于需要多个周期才能完成的指令,CPU可能会提前发射这个指令,让它与其它指令重叠,以避免空等。例如,CPU在一个指令序列里可能会发现A和B不相互依赖。CPU可能会先执行指令B,也许是因为A的输入之前它的输入已经就绪,也许是为了重叠执行A和B。编译器也可能进行类似的重新排序。 + +编译器和CPU按其它次序执行要遵守的规则是,不能改变正常次序下执行的结果。但是,这个规则会改变并发代码的结果,并且很容易引发多处理器上的错误行为。CPU的次序规则被称为**内存模型**(memory model)。 + +如果编译器或CPU把临界区的代码放到临界区外执行,这就会引发灾难。 + +为了避免那样的问题,xv6在`acquire`和`release`里使用了`__sync_synchronize()`。`__sync_synchronize()`是一个内存屏障(memory barrier):它告诉编译器不要跨屏障重新排序load和store。xv6的`acquire`和`release`的屏障在几乎所有重要的情况下强制了次序,因为xv6作用锁访问共享数据。 + +#### 睡眠锁 + +有时xv6需要长时间持有锁。例如文件系统在读写一个文件在硬盘上的内容的时候要保持它的锁,这样的磁盘操作可能需要几十个毫秒。那么长时间持有自旋锁将导致浪费,因为其它想获取这个锁的进程会长时间地浪费CPU。自旋锁的另一个缺点是当进程持有锁的时候不可以让出CPU,我们会希望持有锁的进程在等待磁盘的时候其它进程可以使用这个CPU。持有自旋锁的时候让出CPU是非法的,因为其它线程想获取这个自旋锁的时候会触发死锁;因为`acquire`不让出CPU,第二个线程的自旋可能会阻止第一个线程运行和释放锁。持有锁的时候让出CPU也违背了持有自旋锁的时候必须关闭中断这一规定。所以我们就需要有一种锁,当等待获取的时候让出CPU,且持有锁的时候也允许yield和中断。 + +xv6提供了那样的锁,即**睡眠锁**。`acquiresleep`在等待的时候让出CPU,它使用的技术详见“调度”那一章。从较高的层面来看,睡眠锁有一个通过自旋锁保护的`locked`字段,`acquiresleep`调用`sleep`自动让出CPU并释放自旋锁。结果是`acquiresleep`等待的时候其它线程得以执行。 + +因为睡眠锁让中断打开了,它们不可以在中断处理例程中使用。因为`acquiresleep`可能让出CPU,睡眠锁不以在自旋锁的临界区内使用(但自旋锁可以在睡眠锁的临界区内使用)。 + +自旋锁适合于短的临界区,因为等待它们要浪费CPU时间;睡眠锁适合于长的操作。 + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Software/git命令/git-submodule.md b/Software/git命令/git-submodule.md index c10682f..16d15a3 100644 --- a/Software/git命令/git-submodule.md +++ b/Software/git命令/git-submodule.md @@ -1,4 +1,39 @@ -#### 子模块 +``` +git submodule [options] # 初始化,更新或分析子模块 +``` + +#### 命令 + +##### add + +##### status + +##### init + +##### deinit + +##### update + +``` +update [options] [--] [...] +# 更新已注册的模块,使用的方法是克隆子模块并更新子模块的工作树。 +# 如果子模块还没有初始化,并且只是使用.gitmodules里的设置,可使用--init选项自动初始化子模块。 +# --recursive的作用是,递归地更新子模块里的子模块。 +``` + + + +##### summary + +##### foreach + +##### sync + +##### absorbgitdirs + +#### 选项 + +#### 示例 可以查看.gitmodules文件查看文件夹与子模块的对应关系 diff --git a/Software/iconv.md b/Software/iconv.md new file mode 100644 index 0000000..a800adf --- /dev/null +++ b/Software/iconv.md @@ -0,0 +1,16 @@ +``` +iconv [options] [files] # 把文本从一种字符编码轮换到另一种字符编码。如不指定[files]或指定的是破折号(-),则从标准输入读取文本。如不使用-o选项,则写到标准输出。如不使用-f选项,则使用当前locale的字符编码。如不使用-t选项,则使用当前locale的字符编码。 +``` + +#### 选项 + +``` +-f, --from-code= # 指定输入字符的字符编码 +-t, --to-code= # 指定输出字符的字符编码 +-l, --list +-c +-o, --output= # 输出到 +-s, --silent +--verbose +``` + diff --git a/Software/vim.md b/Software/vim.md index 4187ab5..ea1c643 100644 --- a/Software/vim.md +++ b/Software/vim.md @@ -184,6 +184,19 @@ V # 选择,以行为单位 作用于整个系统的配置文件在`/etc/vimrc`,作用于单个用户的配置文件在`~/.vimrc`。 +##### 字符编码 + +四个与字符编码相关的选项: + +| 名称 | 意义 | +| ------------- | ------------------------------------------------------------ | +| encoding | vim内部使用的编码方式,默认为locale的值,只有在.vimrc中设置它的值才有意义 | +| fileencoding | 当前编辑的文件的字符编码 | +| fileencodings | 一个字符编码的列表,启动时将依据列表自动探测文件的编码方式 | +| termencoding | vim所工作的终端的字符编码方式 | + + + ##### 配色方案 可选的配色方案详见`/usr/share/vim/vim81/colors` diff --git a/Software/进程管理/ps.md b/Software/进程管理/ps.md index 3239799..31c0692 100644 --- a/Software/进程管理/ps.md +++ b/Software/进程管理/ps.md @@ -1,4 +1,4 @@ -个人猜测ps应该是进程选择(process selection)的缩写。 +ps(process status),用于列出系统中运行的进程。 ``` ps [options] # 显示活动的进程 @@ -19,20 +19,36 @@ a # 列出当前终端(tty)的所有进程,与x联用列出所有进程 x # 列出用户拥有的所有进程,与a联用列出所有进程 -A或-e # 列出全部进程 --a # 列出teminal的全部进程,但不包括session leaders --d # 列出全部进程,但不包括session leaders +-a # 列出终端的全部进程,但不包括session leaders +-d # 列出全部进程,但不包括session leaders +-N, --deselect # 选择不满足条件的所有进程(取消选择) ``` ##### 通过列表选择进程 +``` +-g, --group # 通过有效组ID(EGID)或名称来选择进程 +-G, --Group # 通过真实组ID(RGID)或名称来选择进程 +p, -p, --pid # 通过进程ID选择进程 +U, -u, --user # 通过有效用户ID(EUID)或名称来选择进程。 +-U, --User # 通过真实用户ID(RUID)或名称来选择进程。 +``` + + + ##### 输出格式控制 ``` u # 以面向用户的格式显示 +o # 等价于-o或--format +s # 显示信号格式 +v # 显示虚拟内存格式 --f # 显示较多的信息 +-f # 显示全部格式的列表。通过与其它选项联用。 -F # 比-f显示更多的信息 +-j # 作业格式 -l # 长格式,常与-y一起使用 +-o, --format # 用户自定义格式 -y # 不显示flags,显示rss来取代addr。只能与-l联用 ``` @@ -40,10 +56,41 @@ u # 以面向用户的格式显示 ##### output modifiers +``` +e # 命令之后显示环境 +h # 不显示header + +-H # 显示进程的层次(以树的方式) +``` + + + ##### 显示线程 +``` +H # 像显示进程一样显示线程 +m, -m # 在进程后显示线程 +``` + + + ##### 其它信息 +#### 进程状态码 + +``` +D 不可中断的睡眠(一般是在进行IO) +R 运行中或可运行(在运行队列上) +S 可中断的睡眠(在等待一个事件) +T 被作业控制信号停止 +t 在tracing的时候被调试器停止 +W 分页(2.6内核之后已无效) +X 死亡(应该再也看不到了) +Z 僵尸进程 +``` + + + #### 示例 ```