Skip to content

Latest commit

 

History

History
158 lines (99 loc) · 14.1 KB

easy-fs关中断锁修复.md

File metadata and controls

158 lines (99 loc) · 14.1 KB

问题见这里

初步尝试了一下把easy-fs里面全部换成和tutorial里面一样的关中断锁,结果出现了Multiple borrow的现象。感觉上应该是:

  1. 进入文件系统处理(目前观察应该是sys_exec->open_file->ROOT_INODE.find,里面是对整个EFS上了一把锁)
  2. 这把锁是关中断锁,持有锁的时候会关中断,

那为什么之前是没问题的?之前的话文件系统里面是简单的自旋锁,假设在某次系统调用的时候抢到了这把锁,然后进入内核设施...

可能需要重新思考一下引入关中断锁之后,阻塞机制有了什么变化。

现在的话底层机制应该不能再是这种RefCell,而是需要改成自旋锁(单核上可以加一个让权等待优化),为什么?

至少现在的同步设施是有问题的,内核里面的条件变量和锁没有关系,所以也不会释放并获取锁。


另外,据说ch8上面的一个问题是要不断的按回车才能有输出...?这个我倒是还没碰到过。


同时,ch9即使在内核中关中断也仍然不能解决问题。但是如果块设备换成轮询式访问就能跑。


easy-fs中涉及到read_blockwrite_block的位置需要小心设计调用exclusive_access的位置。这样其实也就是所说的“不要在持有锁的情况下进入阻塞。”其实我们并没有认真考虑引入了阻塞机制之后,之前的同步原语需要有哪些变化。

首先来看一下给内核使用的CondVar是什么东西吧。在内核里面read/write_block的时候会直接sched切换(其实我在想是否某些系统调用执行期间不应该打开中断?至少现在应该没这个问题)。这样的话,一旦在此之前在拿了Cell的情况下进入阻塞,在相应的中断到来唤醒该线程之前,另一个线程尝试拿这个Cell,就会panic了。

一个说法是:“引入阻塞之后,就不能使用Cell而需要换成自旋锁(尽管是带有让权等待优化的)了”。因为在阻塞的时候,被阻塞的线程可能已经持有了一些Cell。之后,如果有其他线程也需要获取Cell,这个时候它应该让权等待或者自旋(这里内核态时钟中断就一定要打开了,不然显然会直接死在内核里。)这里有必要深入分析一下:其实我们可以在内核态时钟中断的时候进行任务切换,目前想来应该没有什么问题,只有这样的话才能用自旋锁,但是单核上其实仍没有意义;让权等待就是发现被占用的时候直接yield,或者阻塞-唤醒。这也许像是个睡眠锁?

所以,看起来我们可以有两种锁:自旋锁和条件变量(其实就是睡眠锁,一会看看xv6研究一下是不是一个东西。)

那么Cell这种方式为什么不行,为什么之前没问题,在什么条件下不行呢?**其实是引入阻塞之后,exclusive_access语义就不能保证了!**具体来说,一旦在拿着某个exclusive_access的情况下进入阻塞则会导致后续的panic。其实之前用yield也有这样的情况,注意在yield之前是需要把手里拿着的所有exclusive_access释放的。然而现在引入阻塞之后可能却没有这样做过。

关中断锁和Trap return的交互问题:这个无所谓,反正一开始进入内核态的时候,sstatus.sie都是0。

带着关中断锁阻塞会有什么情形:在系统调用的时候,如果当前关中断锁的层数大于0,是否可以打开中断?至少如果是Cell肯定不行,如果是有让权等待的锁也肯定不行,因为无论怎么样这次中断都卡死了!也就是说,在系统调用中准备打开中断之前需要检查关中断锁层数!让我们看看xv6是否存在类似的处理。


学习xv6指导书并发部分。

锁嵌套的原因:多种共享资源之间存在依赖关系。为了防止死锁,其中的关键是各种共享资源也需要是有层次的。

提到有很多长度为2的锁的链是跟sleep有关的:比如在串口中断服务例程中,首先需要获取串口结构的锁,然后有可能需要唤醒等待串口输入的进程,这里显然就需要拿到进程锁。这对应到一条防死锁规则:串口锁必须先于任何进程锁。(这是偏序吗?有传递性吗?)文件子系统中,锁的链条则是比较长的。

完全避免死锁可能是极其困难的。比如说模块a调用模块b,但是却要求模块b的锁必须先于模块a的锁被获取;有的时候锁的获取情况甚至是编译期无法预测、运行期可变的。这里可能涉及到各种情况。总的来说,死锁是制约锁的粒度减小、并发性能提高的一个限制条件,因为拆分成更多种类的锁总是会更容易导致死锁

碎碎念:存在程序分析方法自动生成内核的锁依赖关系图,或者对于一个给定的程序位置输出此时持有的锁的所有可能情况吗?以及,所有锁的生命周期一定构成一颗树吗(也即,任意两把锁要么为outlive关系,要么不相交)?

关中断锁:一些自旋锁被线程和中断服务例程同时使用,如果使用不当(中断介入时)容易导致死锁。~~特别是,在中断时遇到无法获取锁这种情况无法继续向下执行是无法接受的。首先是在中断里面不能yield或者阻塞;其次是这不太符合中断的定位。但是如果中断里面尝试获取一把其他线程正在拿着的锁是否可以呢?还是不行,因为其他线程能拿意味着当前线程也能拿。~~于是要如何做?指导书里面的说法是:如果中断handler中会用到某把锁L,那么在开启中断的情况下CPU不能获取这把锁。所以就需要识别哪些可能是在中断handler中会被获取的锁(可能需要程序分析),在CPU执行线程的时候,在获取这些锁之前需要先关中断(这里有个顺序问题,是先关中断再尝试获取,还是获取到了之后再关中断?)。本段删除线的说法是错误的,因为关中断只影响当前CPU,我们不可能去把所有CPU的中断都关掉。所以,中断handler可能需要等待其他线程释放锁,但这是能做到的,不会构成死锁。

xv6针对关中断锁采用的是一种较为保守的方案:在spinlock前后分别使用push_offpop_off来维护当前关中断锁层数。上段问题:获取到之后再关中断显然不行,因为可能获取到之后、关中断之前触发中断然后GG。这里在从0变1层的时候会记录当前锁是否启用。然后回到0层的时候会按照之前记录的情况恢复。这中间都是关中断状态吗?这大概要讨论这样几个问题:在变成1层之前是在执行系统调用还是在中断handler中?中间是否涉及到返回用户态或者进行任务切换?这里指导书并没有强调,我们需要自己考虑一下。

睡眠锁:xv6给出其应用场景是当持有一把锁的时间可能很长,导致另一个线程忙等浪费CPU时间很长的情形。指导书中说:“不能在持有锁的情况下交出CPU,不然会导致死锁。”这应该只在xv6这种设计中成立。因为spinlock都带有关中断功能,假设一个线程持有锁的情况下交出CPU,另一个线程在相同CPU上执行,在acquire该锁的时候显然已经是关中断的,这就死锁了。所以说就需要一种锁满足:在尝试获取该锁的时候可以yield,在持有该锁之后也可以yield或者允许中断进入。数据结构如下:

// Long-term locks for processes
struct sleeplock {
  uint locked;       // Is the lock held?
  struct spinlock lk; // spinlock protecting this sleep lock
  
  // For debugging:
  char *name;        // Name of lock.
  int pid;           // Process holding lock
};

// 尝试获取一把sleeplock,在等待的时候可以让出CPU,这其实就是一个条件变量wait操作
void
acquiresleep(struct sleeplock *lk)
{
  acquire(&lk->lk);
  // 这里是关中断的
  while (lk->locked) {
    sleep(lk, &lk->lk);
  }
  lk->locked = 1;
  lk->pid = myproc()->pid;
  release(&lk->lk);
}
// 释放一把sleeplock,也是典型的条件变量signal
void
releasesleep(struct sleeplock *lk)
{
  acquire(&lk->lk);
  lk->locked = 0;
  lk->pid = 0;
  wakeup(lk);
  release(&lk->lk);
}

// 这个好像只是检查一下当前进程有没有拿着某个sleeplock
int
holdingsleep(struct sleeplock *lk)
{
  int r;
  
  acquire(&lk->lk);
  r = lk->locked && (lk->pid == myproc()->pid);
  release(&lk->lk);
  return r;
}

文件系统可以改成trylock,在拿文件系统的锁的时候,假如发现已经被占用那么可以直接返回错误,这样的话就不会死锁了。这样也许可以应对拿锁阻塞的情形。但是这似乎并不能从根本上解决问题。此外,该上读写锁的时候也应该上了。

如何从更一般的意义上解决持锁陷入阻塞的问题呢?看起来另一个线程拿锁的时候可以等一会,等到中断把持锁线程unblock,但还是有问题啊,并没有避免死锁。

所以如果可能在拿着某把锁的情况下阻塞,那么这把锁看起来就不太能自旋了。


20231119

假设在某条lock chain上遇到了阻塞事件,那么这个时候应当如何处理?

有一种方法可以将syscall分为上下两个半部:上半部就是遇到阻塞事件之前,这时只是把caller tcb放入阻塞队列,然后内核的这条syscall路径就可以正常返回,也可以释放掉上半部之前持有的锁。但是在即将回到user mode之前触发sched切换到其他任务。下半部就是阻塞条件满足之后,将之前没做完的事情做完,然后唤醒之前的caller tcb,让它能够回到user mode。

这样的上下部划分看上去就有点像基于状态机的异步编程了。假如整个处理流程中只有单个阻塞点,那么可以简单粗暴的进行上下部的划分;但是如果有多个的话,每来一个ready相当于状态机要往下走一步(另外,这个任务也可以丢到其他CPU上面跑)。但是,这其实并没有很好的回答锁的问题。一把锁代表了一个临界区,那么假如临界区里面要出现阻塞操作,应该如何处理呢?

xv6的文件系统:整个buffer cache层有一把spinlock;buffer cache层的每个slot有一把sleeplock;

比如说,bget的语义是获取一个sector对应的buffer cache并加上sleeplock。那么首先获取子系统锁bcache.lock,然后找到一个空闲的slot,获取sleeplock并返回(这里是假如找不到就直接panic)。bread函数中,会首先调用一下bget,然后如果sector cache没有从磁盘读取过,就会调用virtio_disk_rw。忽略desp的分配过程,这里就是把请求写入ring buffer,然后sleeplock,channel是sector cache地址,锁是disk子系统锁。那么调用sleep的结果是:当前任务被sched,同时disk子系统锁被释放。那么在这之后,其他进程还仍然可以进行磁盘读写操作。可以同时有多个进程被这样挂起,反正到时候就一起被唤醒就行。

在virtio_blk intr的时候,就可以从另一个ring buffer中唤醒所有挂在上面的进程继续执行了。注意它们之间也是有竞争的(所以这个有点像是管程?):只有一个进程能第一个拿到disk子系统锁返回临界区。但不管怎么说,这里的确是持锁进临界区的,至少我们还拿着sector cache的锁。

感觉这个代码写起来越来越像异步了...展开到sys_read读regular file的场景,可以看到这个处理流程中有各种各样的阻塞点,也就有不同的阻塞队列。某一个时刻拿的锁可能有:首先是inode的lock,对应于文件级别的互斥;然后可能会访问inode所在的sector或者数据所在的sector。但是感觉,这一种要写成Rust风格的wrapper型数据结构定义会是十分麻烦甚至很奇怪,要想个办法表意清晰?C里面会更加灵活但是也更加缺乏类型系统的保护。

单核来说自旋锁也有可能是成立的(所以可以用这个东西来代替之前的独占cell)?因为在自旋的时候,也有可能触发外部中断而唤醒之前阻塞的进程,而当前的进程时间片用完之后就可以切换到那个进程了。整个过程中其实并不会deadlock。但是这有一个前提就是kernel执行syscall的时候要打开中断,这个之前倒是实现了。如果能有trace的话就可以看到cpu的利用率恐怕很低...

另外,我们什么时候才支持kthread这种东西呢?

20231212

看起来在单核上也可以搞sleeplock和spinlock了?spinlock相当于只有一个内存位置,这样在单核上应该没什么问题,在单核上就是不应该考虑这样的问题。然而sleeplock是多个内存位置了,它里面还有一个阻塞队列。这个一致性如何保证呢?

在单核不开中断的情况下容易保证,不开中断隐含了原子性。比如还是exclusive_access这个接口,它需要是可被阻塞的。在单核上可以保证那个阻塞队列没人动。多核情况下,首先必须基于原子变量和内存一致性模型,也就是自旋锁这种东西来保证阻塞队列自身访问的一致性。然后怎么搞都行了。

20240122

文件系统需要3把sleeplock。但是每把sleeplock在访问的时候需要套关中断锁。其实关中断这个东西单核上就是一组enable/disable接口,可以直接封装在sleeplock里面。

注意此sleeplock非彼sleeplock。其实原先的文件系统那里应该搞一个sleeplock,这个sleeplock就是block在一个virtio blk chan上,然后在intr的时候唤醒,这个能叫sleeplock吗?

无论单核还是多核,持spinlock的时候都有可能被打断。

20241009

接口分析:目前kernel侧的使用方法是:传入一个block device来构造一个BlockCacheMgr,通过BlockCacheMgr来构造EasyFileSystem。然后通过Inode::root_inode传入EasyFileSystem来得到root_inode,后续操作都通过root_inode为媒介来进行。

假设我们想做到文件系统的所有操作全部互斥,那么可以考虑&Inode提供内部可变性,