Lecture 13 - Sleep & Wakeup

线程切换的回顾与关键点

在本节课中,我们首先回顾了上节课关于线程切换的重要内容,随后会讨论在XV6操作系统中通过Sleep & Wakeup实现的协调机制,最后探讨lost wake-up问题。

1. 线程切换的流程

在XV6中,线程切换是一个关键的过程,通常涉及用户进程的内核线程与调度器线程之间的切换。线程切换的典型流程如下:

  1. 获取进程锁:当一个进程准备进入休眠或需要放弃CPU时,它会首先获取自身的锁。
  2. 状态更新:进程将自己的状态从RUNNING设置为RUNNABLE,表示它现在可以被调度器再次调度。
  3. 调用swtch函数:进程调用sched函数,而sched函数再调用swtch函数,执行线程的切换。
  4. 切换至调度器线程swtch函数完成后,当前线程的上下文切换到调度器线程。
  5. 调度器线程恢复执行:调度器线程在执行时,会从之前调用swtch的地方恢复。
  6. 释放进程锁:调度器线程恢复后,释放当前不再运行的进程的锁。

2. 获取进程锁的原因

获取进程锁的主要原因是为了避免多核处理器中的调度器线程在进程状态切换期间错误地认为进程是RUNNABLE并尝试运行它。具体来说:

  • 每个CPU核都有一个调度器线程遍历进程表,当某一核的调度器线程发现某进程状态为RUNNABLE时,可能会立即调度该进程。
  • 如果未能在进程切换的最初阶段获取进程锁,可能导致在进程尚未完全停止时被另一个核的调度器线程调度,从而使得多个CPU核同时运行同一进程的线程,这将导致系统崩溃。

3. 锁的释放时机

  • 进程在调用swtch之前保持其锁不释放,确保在切换过程中调度器线程不会错误地调度该进程。
  • 当调度器线程完成切换并确认该进程的线程已停止使用其栈时,才会释放锁。此时,其他CPU核可以安全地调度并运行该进程的线程。

提问:多个CPU核能看到同一个锁对象是否因为它们共享物理内存?

多个CPU核共享同一物理内存系统,因此可以访问相同的锁对象。如果是不同的计算机,它们不共享内存,因此不会出现这类问题。

这个锁机制是保证多核系统中线程安全切换的重要手段,也为后续讨论Sleep & Wakeup机制中的限制条件奠定了基础。

接下来,我们将深入探讨Sleep & Wakeup机制及其在XV6中的实现,并讨论相关的lost wake-up问题。

线程切换中的锁管理限制

在XV6操作系统中,线程切换过程中存在一个重要的限制:进程在调用swtch函数时,必须仅持有p->lock(进程对应的proc结构体中的锁),且不能持有任何其他的锁。这一规则是避免死锁的关键,也影响了包括Sleep & Wakeup机制在内的多个设计。

1. 锁管理限制的场景与原因

为了理解这个限制的必要性,我们首先构建一个不满足该限制条件的场景:

  • 场景描述:假设进程P1的内核线程在持有p->lock之外的其他锁(例如与磁盘、UART或控制台相关的锁)的情况下,通过调用switch函数出让CPU。这时,进程P1持有了一些锁,但进程本身已经停止运行。

  • 潜在问题:在一个单核机器上,当P1调用swtch后,调度器线程会切换到另一个进程P2。如果P2需要访问磁盘、UART或控制台,并且尝试获取P1持有的锁,P2将无法成功获取该锁。此时,如果锁是自旋锁,那么P2将进入一个忙等待的循环,不停地尝试获取锁。然而,P2无法成功获取锁,导致它无法继续执行,同时它也无法出让CPU,因为自旋锁的获取操作不会返回。这种情况下,P1持有的锁无法释放,导致系统进入死锁状态。

  • 多核情况:虽然上面的描述基于单核系统,但在多核系统中,类似的死锁也可能发生。例如,如果不同的进程分别持有多个锁并尝试在不同核上运行,则可能出现多个CPU核同时进入忙等待的情况,导致全局性的死锁。

2. 定时器中断无法解决死锁问题

有学生提问是否可以通过定时器中断将CPU控制切换回P1,从而解决死锁问题。Robert教授对此进行了详细解释:

  • 内核上下文中的中断处理:所有进程切换过程都发生在内核中,所有的锁操作(acquirerelease)也是在内核中执行的。虽然在内核中可以触发中断,但在XV6中,acquire函数会在等待锁之前关闭中断。这是因为,如果在等待锁时允许中断处理,可能会导致复杂的死锁场景。

  • 关闭中断的必要性:在acquire函数中,关闭中断的操作是为了避免在锁定期间发生中断,从而引发死锁。因此,当进程P2在忙等待中尝试获取锁时,中断已经被关闭,定时器中断也无法触发,进而阻止了P2出让CPU控制权回给P1,导致死锁无法被打破。

3. 死锁的避免策略

在XV6中,通过以下策略避免上述死锁情况:

  • 限制锁的持有:严格禁止进程在调用swtch函数时持有除p->lock以外的其他锁。这一限制是通过在sched函数中添加检查代码来实现的,确保进程在切换时只持有p->lock

这一规则非常重要,因为它确保了在Sleep & Wakeup机制中不会因为持有不当的锁而导致死锁。程序员在编写XV6代码时必须遵循这一规则,以确保系统的稳定性和避免潜在的死锁问题。

通过Sleep & Wakeup实现线程协调(Coordination)

在编写多线程程序时,线程之间的协调是一个常见的问题。线程往往需要等待特定事件的发生,才能继续执行其后续操作。为了有效地管理这种等待,XV6操作系统提供了Sleep & Wakeup机制。这种机制是一种重要的协调工具,类似于锁的作用,它使得线程能够在不浪费CPU资源的情况下等待事件的发生。

1. 锁的局限性与线程协调的需求

锁的主要作用是保护共享资源,确保对共享数据的操作是按顺序进行的。然而,锁并不能解决所有的并发问题,特别是在某些场景下,线程需要等待特定事件的发生,例如:

  • 等待I/O事件:例如一个进程等待从Pipe中读取数据,而此时Pipe为空,需要等待数据到达。
  • 等待磁盘操作:一个进程请求读取磁盘上的数据,但由于磁盘读取需要一定时间,进程需要等待读取完成的事件。
  • 进程等待子进程退出:父进程调用wait函数等待子进程的退出事件。

这些场景中的等待属于一种“协调”(Coordination)问题,是在锁之外的一种高级并发控制机制。

2. Busy-Wait的局限性

最简单的等待事件的方式是busy-wait,即通过一个循环不断检查条件是否满足:

  • Busy-Wait:假设我们在等待一个Pipe的缓冲区中有数据到来,可以通过一个循环不断检查缓冲区是否非空。

    while (pipe->buffer_empty) {
        // busy-wait
    }
    

busy-wait在某些情况下是有效的,例如当等待的事件预计会在极短时间内发生(如0.1微秒以内),特别是在硬件操作中,这种方式可能是最优选择。然而,在等待时间较长的情况下,busy-wait显然是不合适的,因为它会浪费大量的CPU时间。

3. 通过Sleep & Wakeup实现有效的等待

为了避免busy-wait带来的CPU资源浪费,XV6采用了Sleep & Wakeup机制:

  • Sleep:当线程发现需要等待某个事件时,它会调用sleep函数进入睡眠状态,释放CPU的使用权。这时,线程不再占用CPU资源,直到等待的事件发生。
  • Wakeup:当等待的事件发生时,另一个线程(或者是中断处理程序)会调用wakeup函数,唤醒之前进入睡眠的线程,使其能够重新获取CPU并继续执行。

这一机制的核心思想是在等待事件发生期间不占用CPU,从而提高系统整体效率。在这种模型下,线程协调得到了有效的实现,避免了busy-wait造成的资源浪费。

Sleep & Wakeup机制为XV6提供了一种高效的线程协调手段,使得线程能够在等待事件时合理地出让CPU资源,直到事件发生时被唤醒继续执行。这种机制广泛应用于操作系统的各个部分,与锁共同构成了操作系统并发控制的基础。

XV6中的UART驱动实现与Sleep & Wakeup机制

重写的 UART(通用异步收发传输器)驱动,使用Sleep & Wakeup机制来高效地处理字符的传输。下面我们详细分析uartwrite函数和uartintr中断处理程序的实现。

1. uartwrite函数分析

// transmit buf[].
void uartwrite(char buf[], int n)
{
  acquire(&uart_tx_lock);

  int i = 0;
  while(i < n){
    while(tx_done == 0){
      // UART is busy sending a character.
      // wait for it to interrupt.
      sleep(&tx_chan, &uart_tx_lock);
    }
    WriteReg(THR, buf[i]);
    i += 1;
    tx_done = 0;
  }

  release(&uart_tx_lock);
}

功能描述

  • uartwrite函数用于将字符数据写入UART硬件。当系统需要输出字符(例如shell输出),最终会调用到这个函数。
  • 由于UART硬件一次只能处理一个字符,且每个字符的传输需要一定时间,因此这个函数通过循环逐个字符地发送。

流程分析

  1. 获取锁:函数开始时调用acquire(&uart_tx_lock)获取锁uart_tx_lock,确保对UART资源的独占访问。
  2. 循环发送字符
    • 外层循环遍历需要发送的字符(buf[i])。
    • 内层循环检查tx_done标志位,这个标志位表示UART是否准备好接收下一个字符。如果tx_done为0,说明UART仍在忙碌,线程进入睡眠状态,等待UART硬件完成当前字符的传输。
    • 当UART硬件准备好时(通过中断处理程序uartintr唤醒),线程被唤醒,tx_done被设置为1,外层循环继续,将下一个字符写入UART。
  3. 释放锁:所有字符发送完毕后,调用release(&uart_tx_lock)释放锁。

关键点

  • 睡眠与唤醒机制:在等待硬件完成字符传输期间,线程不会使用busy-wait,而是通过sleep函数进入睡眠,释放CPU资源。这种机制避免了CPU的无谓消耗,提高了系统效率。

2. uartintr中断处理程序分析

// handle a uart interrupt, raised because input has
// arrived, or the uart is ready for more output, or
// both. called from trap.c.
void uartintr(void)
{
  acquire(&uart_tx_lock);
  if(ReadReg(LSR) & LSR_TX_IDLE){
    // UART finished transmitting; wake up any sending thread.
    tx_done = 1;
    wakeup(&tx_chan);
  }
  release(&uart_tx_lock);

  // read and process incoming characters.
  while(1){
    int c = uartgetc();
    if(c == -1)
      break;
    consoleintr(c);
  }
}

功能描述

  • uartintr是UART的中断处理程序,当UART硬件完成一个字符的传输或接收到新输入时,该程序会被trap.c调用。

流程分析

  1. 处理传输完成事件
    • 首先,获取uart_tx_lock锁。
    • 读取UART的LSR寄存器,检查LSR_TX_IDLE标志位以判断是否完成了字符的传输。如果传输完成,设置tx_done为1,并调用wakeup函数唤醒等待中的线程(即在uartwrite函数中睡眠的线程)。
    • 释放锁。
  2. 处理接收输入
    • 通过循环不断读取UART接收到的字符,调用consoleintr函数处理这些输入(如将其显示在控制台)。

关键点

  • 中断与线程协调:中断处理程序uartintr在检测到UART完成字符传输时,通过wakeup函数通知等待的线程,使其继续执行。这种中断驱动的方式确保了线程只有在必要时才被唤醒,从而提高了系统响应效率。

3. Sleep & Wakeup机制的作用

在这个重写的 UART驱动示例中,Sleep & Wakeup机制的作用尤为明显:

  • Sleep:在uartwrite中,当线程需要等待某个事件(如UART硬件准备好接收下一个字符)时,它会调用sleep进入睡眠状态。这时,线程不占用CPU资源。
  • Wakeup:当该事件发生时(由UART硬件触发中断),中断处理程序uartintr调用wakeup函数,唤醒睡眠中的线程,使其继续执行。

这种机制确保了CPU资源的高效利用,避免了busy-wait的资源浪费,是XV6实现高效线程协调的关键手段。

Sleep & Wakeup机制中的Sleep Channel与Lost Wakeup问题

在XV6中,Sleep & Wakeup机制通过sleep channel实现线程间的精确协调。下面我们将深入探讨sleep channel的作用、接口设计的灵活性以及为什么需要传入锁作为sleep函数的参数。

1. Sleep Channel的作用

  • Sleep Channel的定义sleepwakeup函数都接受一个称为sleep channel的参数,这是一个64位数值,用来标识线程等待或被唤醒的特定事件。这个参数的作用是将调用sleep函数的线程与调用wakeup函数的线程关联起来,使得wakeup只会唤醒那些正在等待特定事件的线程。

  • 工作原理

    1. 当一个线程调用sleep(chan, lock)时,它进入睡眠状态并与指定的sleep channel关联。
    2. 另一个线程调用wakeup(chan)时,wakeup函数会检查是否有线程在等待相同的sleep channel,如果有,就唤醒这些线程。

    通过这种方式,sleepwakeup函数实现了线程间的精确协调。

2. Sleep & Wakeup接口的灵活性

  • 接口的简单性和通用性sleepwakeup函数只接受一个简单的64位数值作为sleep channel,不关心这个数值代表什么。这种设计使得接口非常灵活,可以用于各种不同的同步场景,而不需要对具体的事件类型有任何预先定义。

  • 灵活性带来的挑战:尽管这种灵活性非常强大,但它也引入了一些复杂性和潜在的问题,尤其是在处理lost wakeup问题时。lost wakeup是指当某个事件已经发生,但相关的线程还没有进入睡眠状态,这时如果触发了wakeup,可能导致该线程永远无法被唤醒。

3. 为什么需要传入锁?

  • 锁的作用:在sleep(chan, lock)函数中,传入的锁用于在线程进入睡眠前确保对共享资源的正确访问。

  • 避免lost wakeup
    • 如果没有传入锁,可能会导致一个竞争条件,即一个线程在决定进入睡眠后但尚未实际进入睡眠时,另一个线程已经触发了wakeup。这将导致wakeup调用没有效果,从而引发lost wakeup问题。
    • 通过在sleep函数中传入锁,保证在进入睡眠和触发唤醒之间没有空隙,避免了这种竞争条件。这个机制确保了wakeup在适当的时候唤醒正确的线程。
  • 设计的权衡:虽然传入锁使接口略显复杂和“丑陋”,但这是为了在实际操作系统中避免死锁和lost wakeup问题的必要措施。由于线程在等待事件时,往往还需要保护对共享资源的访问,因此引入锁的设计也符合并发编程中的实际需求。

提问: UART驱动程序中是否每传输一个字符就会唤醒一次。

在这个简化的UART驱动程序中,传输每个字符都会触发一次中断,从而唤醒等待中的线程。对于每个字符的传输,都会经历sleepwakeup的循环过程。

实际的UART硬件通常支持一次传输多个字符(如4或16个字符),因此可以优化驱动程序,让每次循环传输多个字符,并在一次中断时唤醒线程。这种优化能减少中断和sleep/wakeup调用的频率,提高系统效率。

Lost Wakeup问题与缺乏锁的Sleep函数问题

在讨论sleep函数为何需要传入锁作为参数之前,我们可以设想一个更简单的、不带锁参数的sleep函数。这种设计虽然看似简化了接口,但实际上会引发严重的问题,尤其是lost wakeup问题。为了说明这一点,我们假设一个仅接收sleep channel作为参数的简化版sleep函数,称之为broken_sleep

1. broken_sleep的实现及问题

broken_sleep的设想

  • broken_sleep函数设想中,仅接收一个sleep channel参数。它的操作步骤如下:
    1. 将当前进程的状态设置为SLEEPING,表示进程进入睡眠,等待特定的事件。
    2. 记录该进程对应的sleep channel,以便之后的wakeup函数能够找到并唤醒这个进程。
    3. 调用switch函数,出让CPU的控制权,使得其他进程可以运行。

wakeup函数的设想

  • wakeup函数遍历系统中所有进程的进程表,寻找状态为SLEEPINGsleep channel匹配的进程,然后将这些进程的状态设置为RUNNABLE,使它们可以被调度执行。

潜在问题:Lost Wakeup

  • Lost Wakeup的定义:Lost wakeup是指当事件已经发生,但由于某种竞争条件或时序问题,相关的线程并没有被正确唤醒,导致该线程无法继续执行。例如,wakeup函数在broken_sleep设置进程状态之前就运行,导致wakeup无法正确识别并唤醒等待的进程。
  • 问题产生的原因:由于broken_sleep没有锁保护,在sleep设置进程为SLEEPING状态并记录sleep channel期间,可能已经有另一个线程或中断触发了wakeup,但此时该进程尚未完全进入睡眠状态,结果导致wakeup未能正确唤醒进程,最终该进程会无限期地等待,形成死锁或未响应的状态。

2. UART驱动中的broken_sleep问题示例

UART驱动使用broken_sleep的场景

  • 在使用broken_sleep的场景中,我们定义了一个done标志位,用于表示UART硬件是否完成了字符传输。uartwrite函数会检查这个标志位,如果done为0,则调用sleep函数进入睡眠,等待UART硬件准备好接收下一个字符。中断处理函数uartintr在传输完成后会设置done为1,并调用wakeup函数唤醒等待的进程。

缺乏锁的问题

  • 共享数据的竞争访问done标志位是共享数据,因此必须通过锁保护,以防止uartwriteuartintr函数同时访问和修改done,导致数据不一致。
  • 硬件资源的竞争访问uartwriteuartintr函数同时访问UART硬件的寄存器,这种并发访问没有锁的保护,可能导致不可预知的行为,例如数据损坏或硬件操作失败。

没有锁的设计下可能的执行顺序

  1. uartwrite线程准备调用sleep,将进程状态设置为SLEEPING
  2. sleep函数尚未记录sleep channel之前,uartintr中断发生,设置done为1,并调用wakeup
  3. wakeup函数由于找不到对应的睡眠线程,因此什么都不做。
  4. uartwrite线程继续执行,调用sleep,但此时中断已经触发过,wakeup已经被错过,导致uartwrite线程永久进入睡眠。

3. 为什么需要锁保护

锁的作用

  • 确保操作的原子性:锁的主要作用是确保sleep函数内的操作是原子性的,即在整个sleep过程中不会发生中断或其他线程的干扰。这样可以避免在sleep进入睡眠状态和wakeup唤醒操作之间发生时序问题,确保正确的线程能够被唤醒。
  • 保护共享资源:在多线程环境中,所有共享资源(如done标志位和UART硬件寄存器)都应受到锁的保护,以防止数据竞争和不一致。

解决方案

  • sleep(chan, lock)函数中传入锁,确保在sleep设置进程状态并记录sleep channel时,不会有其他线程或中断干扰这一过程。这可以避免lost wakeup问题,同时确保wakeup函数在正确的时机唤醒对应的线程。

锁的使用位置与broken_sleep函数的问题

在实现多线程协调时,正确管理锁的使用位置是至关重要的。为了说明问题,我们假设了一个不带锁参数的sleep函数,即broken_sleep。下面继续分析在UART驱动中使用broken_sleep时可能会发生的问题,以及正确的锁使用方式。

1. 锁在uartintr中的使用

uartintr中断处理程序中,锁的使用较为简单。通常,我们会在进入中断处理程序的最开始获取锁,执行完操作后在退出时释放锁。这是为了确保中断处理程序访问共享资源时不与其他线程发生冲突。

2. 锁在uartwrite中的使用

uartwrite函数中,锁的使用稍显复杂。我们希望在处理共享资源时保护它们,但在等待done标志位时,我们需要释放锁以允许中断处理程序执行,否则会引发死锁。

错误的锁使用方式

一种看似合理但实际上有问题的方式是对整个字符发送过程加锁。如下所示:

acquire(&uart_tx_lock);

int i = 0;
while(i < n){
    while(tx_done == 0){
        // 锁保护整个等待过程
        sleep(&tx_chan, &uart_tx_lock);
    }
    WriteReg(THR, buf[i]);
    i += 1;
    tx_done = 0;
}

release(&uart_tx_lock);

为什么这样会出问题?

  • uartwrite函数持有锁进入sleep等待tx_done标志位变化,中断处理程序无法获取锁来更新tx_done,这导致中断处理程序无法唤醒正在等待的线程。
  • 结果是uartwrite线程永远无法被唤醒,导致系统进入死锁状态。

正确的锁使用方式

为了避免死锁,我们需要在sleep之前释放锁,并在sleep返回后重新获取锁。如下所示:

acquire(&uart_tx_lock);

int i = 0;
while(i < n){
    while(tx_done == 0){
        release(&uart_tx_lock);      // 在sleep之前释放锁
        sleep(&tx_chan, &uart_tx_lock); // sleep函数负责重新获取锁
        acquire(&uart_tx_lock);      // 重新获取锁
    }
    WriteReg(THR, buf[i]);
    i += 1;
    tx_done = 0;
}

release(&uart_tx_lock);

为什么这样可行?

  • sleep被调用时,锁已经被释放,因此中断处理程序可以正常执行,并更新tx_done标志位。更新后,中断处理程序调用wakeup唤醒uartwrite,此时sleep返回并重新获取锁,uartwrite可以继续执行。

3. broken_sleep的使用及其问题

假设我们用broken_sleep代替sleep,并手动管理锁的获取和释放:

void uartwrite(char buf[], int n)
{
  acquire(&uart_tx_lock);

  int i = 0;
  while(i < n){
    while(tx_done == 0){
      release(&uart_tx_lock);
      broken_sleep(&tx_chan);  // 调用不带锁的broken_sleep
      acquire(&uart_tx_lock);
    }
    WriteReg(THR, buf[i]);
    i += 1;
    tx_done = 0;
  }

  release(&uart_tx_lock);
}

broken_sleep的实现:

void broken_sleep(void* chan)
{
  p->state = SLEEPING; // 设置进程状态
  p->chan = chan; // 记录`chan`
  swtch(); // 切换进程
}

问题分析:

  • broken_sleep执行期间,锁已经被释放。但由于broken_sleep没有保护关键的sleep过程(例如设置进程状态、记录chan等操作),在sleep的关键时刻可能会发生竞争条件。
  • 如果中断在broken_sleep将进程状态设置为SLEEPING前发生,wakeup将无法找到匹配的进程进行唤醒,导致lost wakeup问题,进程将永远处于等待状态。

Lost Wakeup问题的实际运行结果与分析

在编译和运行修改后的代码时,我们遇到了lost wakeup问题。让我们来分析导致该问题的代码段,并理解它在实际运行中的表现。

1. 代码运行现象

在XV6启动时,系统会打印“init starting”,但在修改后的代码中,输出一些字符后系统挂起。如果在此时手动输入任意字符,剩余的字符才会被继续输出。

2. 代码中导致问题的关键部分

以下是代码中可能引发问题的部分:

release(&uart_tx_lock);
// 这里可能会发生中断
broken_sleep(&tx_chan);
acquire(&uart_tx_lock);

3. 问题产生的原因:Lost Wakeup

中断的时机

  • uartwrite函数在释放锁(release(&uart_tx_lock))和进入broken_sleep之间,中断可能会发生。这是因为释放锁后,中断被重新打开,其他CPU核有可能执行UART的中断处理程序。

中断处理程序行为

  • 其他CPU核可能在这个时间点获取了锁,发现UART硬件已经完成了字符传输,并设置tx_done为1。
  • 接着,中断处理程序调用wakeup(&tx_chan),但此时uartwrite线程还没有进入SLEEPING状态。因此,wakeup没有唤醒任何进程,因为还没有任何线程在tx_chan上睡眠。

结果

  • uartwrite线程继续执行,调用broken_sleep(&tx_chan)时,它将状态设置为SLEEPING,但此时中断已经发生且wakeup也已经被调用。这导致uartwrite线程进入了睡眠状态,等待一个已经错过的事件。这就是典型的lost wakeup问题。

提问:是不是一旦wakeup丢失,下一次wakeup时,之前的数据就会继续输出?

这完全依赖于具体实现。在这个例子中,由于输入和输出都共用同一个中断处理程序,因此在输入字符时,偶然地触发了wakeup,解决了lost wakeup问题,导致数据继续输出。然而,这种行为是偶然的,并不可靠。如果UART使用了不同的中断处理程序来分别处理接收和发送事件,那么这种问题就无法通过后续的输入操作来修复,系统可能会继续挂起。

4. 总结与解决办法

  • Lost Wakeup问题:在多核系统中,如果wakeup在线程进入睡眠状态之前被触发,就可能导致lost wakeup问题,使得等待的线程永远不会被唤醒,系统可能会挂起。
  • 解决办法:要避免这种情况,应该使用带锁参数的sleep函数。通过在sleep函数内部管理锁的获取和释放,可以确保wakeup函数在正确的时机唤醒正确的线程,避免lost wakeup问题。

关于tx_done标志位和Lost Wakeup问题的深入讨论

1. tx_done标志位的作用

  • 通信的桥梁tx_done标志位是uartintr(中断处理程序)和uartwrite函数之间的一种简单通信手段。它用于指示UART硬件是否已经完成了当前字符的传输。
  • 传输状态指示:当tx_done为1时,表示UART已经完成了前一个字符的传输,uartwrite函数可以安全地传输下一个字符。这种标志位机制允许uartwrite函数和uartintr之间通过共享状态来协调操作。

2. tx_done标志位是否多余?

既然sleep函数唤醒时已经知道是来自UART的中断处理程序调用wakeup,为什么还需要tx_done标志位?

  • 睡眠与唤醒的非精确匹配tx_done标志位并非多余,它解决了一个更为普遍的问题:在多线程环境中,sleep函数和wakeup函数通常不能精确匹配。也就是说,当sleep函数返回时,等待的事件可能已经被另一个线程处理了。
  • 避免竞态条件:考虑到多线程并发的可能性,可能有多个线程在尝试写入UART。一个线程进入sleep状态后,另一个线程可能已经处理了相应的事件,导致第一个线程被唤醒时,事件已经发生,但该线程并不具备继续操作的条件。因此,使用while(tx_done == 0)循环来再次检查状态是确保线程正确协调的一种方式。
  • 普遍的解决方案:这种模式不仅适用于UART传输,还广泛应用于操作系统中的其他场景。实际上,XV6中的大多数sleep函数调用都会被一个while循环包围,以确保唤醒的线程在实际处理事件之前重新检查条件。

3. 为什么没有更多的lost wakeup

在输入字符后系统继续输出剩余字符时,为什么没有再次发生lost wakeup

  • lost wakeup的偶发性lost wakeup问题是由于中断处理程序和sleep函数之间的竞争条件引发的。这种情况需要特定的时序条件:中断在锁释放和sleep之间发生。这种情况虽然有可能发生,但并不总是出现。
  • 演示lost wakeup的条件:在实际操作中,例如执行cat README,由于大量字符输出,lost wakeup更容易发生。当系统输出数千个字符时,你会注意到每隔一段时间系统就会挂起,要求再次输入字符来继续。这是因为在长时间运行期间,系统更可能遇到多个lost wakeup事件。
  • 巧合与概率:在之前的演示中,之所以没有频繁出现lost wakeup,是因为需要多个条件巧合同时发生。这解释了为什么在某些情况下,lost wakeup问题表现得不那么明显。

消灭 Lost Wakeup:确保安全的 Sleep 与 Wakeup 机制

为了消除 lost wakeup 问题,我们需要确保在释放锁和将进程状态设置为 SLEEPING 之间没有时间窗口。下面详细解释如何通过稍微复杂的 sleep 函数设计,避免这个问题。

1. 问题分析:窗口时间的消除

在原始代码中,存在一个关键的时间窗口:

release(&uart_tx_lock);
// 这里可能会发生中断
broken_sleep(&tx_chan);
acquire(&uart_tx_lock);

问题描述

  • 我们必须释放 uart_tx_lock,因为中断处理程序需要获取这个锁。
  • 然而,释放锁之后、线程将自身标记为 SLEEPING 之前,如果发生中断,可能导致 wakeup 在没有任何线程处于 SLEEPING 状态时被调用,进而导致 lost wakeup

解决方案

  • 通过让 sleep 函数在原子操作中同时释放锁并将进程设置为 SLEEPING,我们可以消除这个时间窗口。

2. Sleep 函数的改进设想

为了实现上述目标,我们需要让 sleep 函数接受一个锁作为参数,并在内部原子性地处理两个操作:

  1. 将进程状态设置为 SLEEPING
  2. 释放锁。

这样可以确保 wakeup 在中断处理程序中检查进程状态时,不会看到锁已经被释放但进程尚未进入 SLEEPING 状态的情况。

接口层面的承诺

  • sleep 函数承诺在释放锁的同时,将进程状态设置为 SLEEPING,这是一个原子操作。这样可以确保在调用 wakeup 时,进程已经处于可被唤醒的状态。

3. Wakeup 函数的实现

wakeup 函数在操作系统的进程表中查找所有处于 SLEEPING 状态并匹配指定 channel 的进程,并将这些进程的状态设置为 RUNNABLE。在这个过程中,确保对每个进程的操作都被锁保护,以避免竞态条件。

// 唤醒所有在 chan 上睡眠的进程。
// 调用时必须未持有任何 p->lock。
void wakeup(void *chan)
{
  struct proc *p;

  for(p = proc; p < &proc[NPROC]; p++) {
    if(p != myproc()){
      acquire(&p->lock); // 获取进程的锁
      if(p->state == SLEEPING && p->chan == chan) {
        p->state = RUNNABLE; // 将进程状态设置为 RUNNABLE
      }
      release(&p->lock); // 释放锁
    }
  }
}

关键点

  • 在操作进程状态前,wakeup 函数会获取进程的锁,确保在修改进程状态时没有其他线程能够干扰。
  • 只有在进程的状态为 SLEEPING 且其 channelwakeup 的参数匹配时,进程的状态才会被设置为 RUNNABLE,这样该进程就可以被调度执行。

4. 遵守规则避免 Lost Wakeup

规则总结

  • Sleep 函数:当调用 sleep 函数时,必须传入保护条件的锁。sleep 函数负责在原子操作中释放锁并将进程状态设置为 SLEEPING
  • Wakeup 函数:调用 wakeup 函数时,必须持有与之关联的锁,以确保唤醒操作的一致性。

带锁的 sleep 函数实现

在我们深入了解了 sleep 函数的实现后,可以清楚地看到它是如何通过一系列原子操作和锁机制来避免 lost wakeup 问题的。以下是对 sleep 函数及其在 UART 代码中的应用的详细分析。

1. sleep 函数的实现与关键步骤

// Atomically release lock and sleep on chan.
// Reacquires lock when awakened.
void sleep(void *chan, struct spinlock *lk)
{
  struct proc *p = myproc();
  
  // Must acquire p->lock in order to
  // change p->state and then call sched.
  // Once we hold p->lock, we can be
  // guaranteed that we won't miss any wakeup
  // (wakeup locks p->lock),
  // so it's okay to release lk.

  acquire(&p->lock);  // 获取进程锁,确保接下来的操作是安全的
  release(lk);  // 释放传入的条件锁,允许其他线程获取该锁

  // 进入睡眠状态
  p->chan = chan;
  p->state = SLEEPING;

  sched();  // 调用 sched 函数,放弃 CPU,切换到调度器线程

  // 线程被唤醒后,清理工作
  p->chan = 0;

  // 重新获取传入的条件锁
  release(&p->lock);
  acquire(lk);
}

关键步骤

  1. 获取进程锁 p->lock
    • 在修改进程状态(如设置为 SLEEPING)之前,必须先获取进程的锁 p->lock
    • 这一步确保在 sleep 函数执行的过程中,wakeup 函数无法在没有获取进程锁的情况下唤醒进程。
  2. 释放条件锁 lk
    • 释放传入的条件锁 lk,使得其他线程(如中断处理程序)能够获取该锁进行操作。
    • 这一操作是在持有进程锁 p->lock 的前提下进行的,从而确保在锁释放后,如果 wakeup 被调用,它无法在进程未进入 SLEEPING 状态之前唤醒该进程。
  3. 设置进程状态为 SLEEPING 并记录 sleep channel
    • 在持有进程锁的情况下,安全地将进程状态设置为 SLEEPING,并记录 chan,标识该进程在等待的事件。
  4. 调用 sched 切换进程
    • 调用 sched 函数,进行上下文切换,当前进程进入睡眠,等待被唤醒。
    • 在调用 sched 函数期间,进程锁依然被持有,直到切换到调度器线程后才释放。
  5. 清理工作与重新获取锁
    • 当进程被唤醒后,sched 返回,线程继续执行。此时,清理 chan,并重新获取原来的条件锁 lk

2. UART 代码中 sleep 的应用

// transmit buf[].
void uartwrite(char buf[], int n)
{
  acquire(&uart_tx_lock);  // 获取条件锁

  int i = 0;
  while(i < n){
    while(tx_done == 0){  // 检查条件
      sleep(&tx_chan, &uart_tx_lock);  // 如果条件不满足,调用 sleep
    }
    WriteReg(THR, buf[i]);
    i += 1;
    tx_done = 0;
  }

  release(&uart_tx_lock);  // 释放条件锁
}

UART 代码分析

  • uartwrite 函数中,条件锁 uart_tx_lock 在一开始就被获取,并在循环的每一轮中保持,直到线程需要进入睡眠状态。
  • tx_done 为 0 时,意味着当前传输尚未完成,此时线程调用 sleep 进入睡眠,等待下一个传输中断信号。
  • sleep 函数在 uart_tx_lock 被释放之前获取进程锁 p->lock,确保不会在中途被错误地唤醒。
  • 通过上述操作,sleepwakeup 之间的时序问题得到解决,不会出现 lost wakeup 的情况。

3. 避免 Lost Wakeup 的机制总结

  • 持有锁的时机

    • sleep 函数的核心在于确保在释放条件锁 lk 和设置进程状态之间,持有进程锁 p->lock。这样,wakeup 函数必须等到进程锁被释放后,才能操作进程的状态,从而避免 lost wakeup
  • 规则与流程

    • 调用 sleep 时,必须持有条件锁 lk,以便在安全的环境下修改条件。

    • sleep 函数通过持有进程锁并原子地执行释放条件锁和设置进程状态这两步,避免了中断处理程序在不合适的时间唤醒线程。

    • wakeup 函数在检查和修改进程状态时,必须持有进程锁 p->lock,确保状态的改变不会发生竞态条件。

    • sleep 函数持有的进程锁,在进入调度器线程后被释放,之后wakeup 函数才能获取进程的锁,并进行后续操作。

      // scheduler()
      ...
        c->proc = 0;  // 清除当前CPU核的进程记录
        found = 1;  // 表示找到了一个可运行的进程
      }
      release(&p->lock);  // 释放进程锁(旧的,调度前的)
      ...
      

通过这些规则和流程,lost wakeup 问题被彻底解决,从而确保线程在并发环境下的正确协调和唤醒。

管道的读写同步:pipereadpipewrite

在前面的讨论中,我们通过分析UART驱动中的sleepwakeup机制,详细介绍了如何避免lost wakeup问题。我们看到,通过在调用sleep之前获取并保持条件锁,直到进程进入SLEEPING状态,能够有效防止lost wakeup的发生。

接下来,我们将探讨在其他场景下如何使用sleepwakeup机制,确保正确的线程同步。例如,在处理管道(pipe)的读写操作时,pipereadpipewrite函数之间也存在类似的同步问题。如果不加以防范,也可能出现lost wakeup的问题。

在XV6中,管道用于在两个进程之间进行数据通信。piperead函数用于从管道中读取数据,而pipewrite函数用于将数据写入管道。两个函数都依赖于sleepwakeup机制来处理线程间的同步,以确保在管道为空或已满的情况下正确地挂起和唤醒进程。

piperead 函数分析

piperead函数尝试从管道中读取数据。当管道为空时,即没有数据可读取,piperead函数会调用sleep进入睡眠,等待数据写入管道。

int piperead(struct pipe *pi, uint64 addr, int n)
{
  int i;
  struct proc *pr = myproc();
  char ch;

  acquire(&pi->lock);
  while (pi->nread == pi->nwrite && pi->writeopen) {  // 如果管道为空且仍然打开
    if (killed(pr)) {  // 检查进程是否已被杀死
      release(&pi->lock);
      return -1;
    }
    sleep(&pi->nread, &pi->lock);  // 进入睡眠,等待数据写入管道
  }

  // 读取数据
  for (i = 0; i < n; i++) {
    if (pi->nread == pi->nwrite)  // 管道中无更多数据
      break;
    ch = pi->data[pi->nread++ % PIPESIZE];
    if (copyout(pr->pagetable, addr + i, &ch, 1) == -1)
      break;
  }

  wakeup(&pi->nwrite);  // 唤醒可能在等待写入的进程
  release(&pi->lock);
  return i;
}
  • 条件检查与睡眠:在检查完管道状态后,如果发现pi->nread == pi->nwrite,即管道为空且管道仍然打开(pi->writeopen为真),piperead会调用sleep进入睡眠,并将pi->lock作为条件锁传递给sleep函数。这确保了在管道数据为空时,进程安全地进入睡眠,并避免了lost wakeup

  • 唤醒等待的写进程:读取完数据后,piperead会调用wakeup(&pi->nwrite),唤醒可能因管道满而进入睡眠的写进程。

pipewrite 函数分析

pipewrite函数将数据写入管道。如果管道已满,pipewrite函数将进入睡眠,等待管道中的数据被读取,以腾出空间来写入新数据。

int pipewrite(struct pipe *pi, uint64 addr, int n)
{
  int i = 0;
  struct proc *pr = myproc();

  acquire(&pi->lock);
  while (i < n) {
    if (pi->readopen == 0 || killed(pr)) {  // 检查管道是否仍然打开或进程是否被杀死
      release(&pi->lock);
      return -1;
    }

    if (pi->nwrite == pi->nread + PIPESIZE) {  // 管道已满
      wakeup(&pi->nread);  // 唤醒可能等待读取的进程
      sleep(&pi->nwrite, &pi->lock);  // 进入睡眠,等待空间腾出
    } else {
      char ch;
      if (copyin(pr->pagetable, &ch, addr + i, 1) == -1)
        break;
      pi->data[pi->nwrite++ % PIPESIZE] = ch;  // 写入数据
      i++;
    }
  }

  wakeup(&pi->nread);  // 唤醒可能等待数据的读取进程
  release(&pi->lock);

  return i;
}
  • 检查与唤醒pipewrite在检查到管道已满(pi->nwrite == pi->nread + PIPESIZE)的情况下,会首先调用wakeup(&pi->nread),唤醒等待读取数据的进程,然后调用sleep(&pi->nwrite, &pi->lock)进入睡眠,等待管道有空间可写。

  • 写入数据:如果管道有足够的空间,pipewrite会继续写入数据,并更新pi->nwrite

避免 lost wakeup 的机制

pipereadpipewrite 函数中,为了避免 lost wakeup,严格遵循以下机制:

  1. 条件锁的使用:在检查条件(如管道是否为空或已满)时,函数持有相应的条件锁(pi->lock)。这确保了在条件检查和进入睡眠之间没有窗口时间,使得另一个线程或中断处理程序不能在这段时间内引发wakeup,从而避免lost wakeup

  2. 调用wakeup:在调用sleep之前,pipewrite会先调用wakeup(&pi->nread),唤醒可能在等待数据读取的进程,确保读进程有机会运行并清理管道中的数据,进而为写进程腾出空间。

  3. 传递锁给sleep:在调用sleep时,函数会将条件锁(pi->lock)传递给sleep,确保sleep函数能够原子性地释放锁并设置进程为SLEEPING状态。这种设计确保了wakeup函数在操作系统的调度器中能够正确地唤醒相应的进程。

  4. sleep 包装在循环中。

为什么将 sleep 包装在循环中

在多进程环境中,多个进程可能同时等待从同一个管道中读取数据或写入数据。当一个进程向管道中写入数据并调用 wakeup 时,所有等待读取该管道的进程都会被唤醒。然而,由于管道中的数据可能非常有限,只有一个进程能首先获取数据,其余进程必须重新进入等待状态。

piperead 中的循环

piperead 函数中,等待条件是 pi->nread < pi->nwrite,即管道中有数据可供读取。如果条件不满足,进程将调用 sleep 进入睡眠状态。当另一个进程通过 pipewrite 向管道中写入数据并调用 wakeup 后,所有等待读取的进程都会被唤醒,但只有一个进程能够首先获取数据。

  while (pi->nread == pi->nwrite && pi->writeopen) {  // 如果管道为空,等待数据写入
    if (killed(pr)) {
      release(&pi->lock);
      return -1;
    }
    sleep(&pi->nread, &pi->lock);  // 进入睡眠,等待数据
  }
  • 循环中的 sleeppiperead 函数将 sleep 包装在一个 while 循环中,这是为了确保当 sleep 返回时,能够重新检查条件。如果管道中只有一个字节数据,最先被唤醒的进程将获取该数据,并将管道再次置为空状态。此时,其他被唤醒的进程在检查条件时会发现管道仍为空,因此它们会重新进入 sleep

  • 锁的获取与释放: sleep 函数在进入睡眠状态之前,会释放条件锁(pi->lock),以允许其他进程或中断处理程序获取锁。唤醒后,sleep` 函数会重新获取锁,从而保证在检查条件时,锁仍然被持有。

循环中的 sleep

几乎所有对 sleep 的调用都需要包装在循环中,因为 sleep 返回后,条件可能已经发生了变化,尤其是在多个进程竞争同一资源的情况下。将 sleep 包装在循环中确保了条件总是被重新检查,从而避免了错误假设条件仍然成立的情况。

如之前的uartwritesys_sleep

void uartwrite(char buf[], int n)
{
  acquire(&uart_tx_lock);  // 获取条件锁

  int i = 0;
  while(i < n){
    while(tx_done == 0){  // 检查条件
      sleep(&tx_chan, &uart_tx_lock);  // 如果条件不满足,调用 sleep
    }
    WriteReg(THR, buf[i]);
    i += 1;
    tx_done = 0;
  }

  release(&uart_tx_lock);  // 释放条件锁
}
uint64
sys_sleep(void)
{
  int n;
  uint ticks0;

  argint(0, &n);  // 从系统调用参数中获取休眠时间
  if(n < 0)
    n = 0;
  acquire(&tickslock);  // 获取系统时钟的锁
  ticks0 = ticks;       // 记录当前的tick数
  while(ticks - ticks0 < n){
    if(killed(myproc())){
      release(&tickslock);  // 如果进程被杀死,释放锁并返回-1
      return -1;
    }
    sleep(&ticks, &tickslock);  // 调用sleep函数进行休眠
  }
  release(&tickslock);  // 休眠结束后释放锁
  return 0;
}

sleepwakeup 机制的复杂性与灵活性

sleepwakeup 机制非常强大,但也相对复杂。调用者需要传入一个条件锁,并且在使用这些机制时必须遵循一系列严格的规则。具体来说:

  1. 条件锁的传递:调用 sleep 时,必须持有条件锁,并将其传递给 sleep 函数,以确保 sleep 函数能够原子性地释放锁并进入睡眠状态。

  2. 锁的重新获取sleep 函数在被唤醒后,会重新获取条件锁,从而确保其他被唤醒的进程无法在获取锁之前进行操作。

  3. 循环结构:将 sleep 包装在循环中,确保从 sleep 返回时总是能够重新检查条件,以判断是否需要再次进入睡眠状态。

这些规则虽然复杂,但赋予了 sleepwakeup 极大的灵活性。它们可以在任何需要等待某一条件发生的场景下使用,而无需对具体的条件或其保护机制有深入的了解。

信号量(Semaphore)作为另一种协调机制

除了 sleepwakeup 机制,还有一些更高级的同步原语,比如信号量(Semaphore)。与 sleepwakeup 不同,信号量的接口设计更为简单,不需要调用者传入条件锁,也无需担心 lost wakeup 问题,因为这些都在信号量的内部实现中得到了处理。

  • 信号量的优势:信号量通过内部维护的计数器来处理线程同步问题。调用者只需操作这个计数器,而不需要显式地传递锁或关心条件的变化。

  • 信号量的局限性:尽管信号量在处理类似计数器的同步问题时非常方便,但它并不适合所有场景。比如在复杂条件下(如等待多个条件同时满足)或者在不涉及计数器的场景中,sleepwakeup 机制可能更为合适。

XV6中的进程退出机制与相关挑战

在操作系统中,进程最终会面临退出的情况,无论是因为正常的程序终止(调用exit系统调用)还是因为异常情况(如被杀掉)。在XV6中,进程退出涉及多个步骤,包括释放资源、清理状态以及通知父进程等。为了正确处理这些步骤,同时避免潜在的并发问题,系统必须谨慎设计进程的退出机制。接下来,我们将深入探讨这一过程,并分析与之相关的挑战。

进程退出的两个主要挑战

  1. 避免单方面摧毁运行中的线程
    • 当另一个线程在内核中运行时,可能持有锁、正在执行关键的内核代码或在更新复杂的数据结构。如果我们在这种情况下强行终止该线程,可能会导致内核状态的不一致、死锁或其他严重问题。因此,不能直接摧毁正在运行的线程。
  2. 释放关键资源
    • 即使线程调用了exit,它仍然在执行最后的代码并持有某些资源(如栈、进程表中的条目)。因此,不能在线程执行exit的同时释放这些资源,必须找到一种机制,在保证线程彻底结束后,安全地释放这些资源。

XV6中与进程关闭相关的关键函数:exit

1. exit 函数

exit 函数是进程自发调用的,用于安全地终止当前进程并将其状态设置为ZOMBIEexit函数的设计应确保进程的资源得到妥善释放,并通知父进程该进程已退出。

// Exit the current process.  Does not return.
// An exited process remains in the zombie state
// until its parent calls wait().
void exit(int status)
{
  struct proc *p = myproc();  // 获取当前进程的指针

  if(p == initproc)
    panic("init exiting");  // 如果是init进程退出,触发panic,因为init进程不能退出

  // 关闭所有打开的文件
  for(int fd = 0; fd < NOFILE; fd++){
    if(p->ofile[fd]){
      struct file *f = p->ofile[fd];
      fileclose(f);  // 关闭文件,减少引用计数
      p->ofile[fd] = 0;
    }
  }

  // 释放当前工作目录
  begin_op();
  iput(p->cwd);  // 释放当前目录的i-node
  end_op();
  p->cwd = 0;

  acquire(&wait_lock);  // 获取wait锁,确保接下来的操作原子性

  // 将当前进程的所有子进程重新分配给init进程
  reparent(p);

  // 唤醒可能在wait()中等待的父进程
  wakeup(p->parent);

  acquire(&p->lock);  // 获取当前进程的锁,准备修改状态,由调度器释放

  p->xstate = status;  // 设置进程的退出状态
  p->state = ZOMBIE;  // 将进程状态设置为ZOMBIE

  release(&wait_lock);  // 释放wait锁

  // 调用调度器,不再返回,不必放在循环中
  sched();  
  panic("zombie exit");  // 如果执行到这里,说明出现了问题
}

2. exit 函数的工作流程

  1. 关闭所有打开的文件
    • 每个进程在退出时,需要关闭自己打开的所有文件。这不仅涉及简单的文件描述符清理,还涉及文件系统的引用计数管理。因此,在关闭文件时,可能触发复杂的资源释放过程。
  2. 释放当前工作目录
    • 进程在退出时,还需释放当前工作目录的引用。这通过操作文件系统的i-node完成,以确保文件系统中的资源被正确释放。
  3. 处理子进程
    • 如果进程退出时仍有子进程存在,这些子进程需要重新分配给init进程。这是因为wait系统调用依赖于父进程来处理子进程的清理工作。如果父进程已经退出,子进程将失去父进程,因此必须将它们重新分配给init,确保它们退出时仍有一个父进程可以调用wait来完成清理。
  4. 通知父进程
    • 退出进程必须通知其父进程自己已经退出。通常父进程可能在调用wait()时等待子进程的退出,因此需要唤醒父进程。
  5. 将进程状态设置为ZOMBIE
    • 退出进程在清理完资源后,其状态被设置为ZOMBIE。僵尸状态表示进程的所有资源已被释放,但其退出状态和进程表条目仍然保留,以便父进程调用wait()获取。
  6. 调度器的调用
    • 最后,exit函数调用sched()进入调度器,不再返回。此时,进程不会再执行,系统将调度其他可运行的进程。

3. 处理 ZOMBIE 状态

  • ZOMBIE 状态的进程不会继续执行代码,但它的进程表条目和退出状态仍然存在,直到父进程调用wait()。一旦父进程通过wait()获取了退出状态,进程的资源将彻底释放,进程表中的条目也会被标记为UNUSED,表示它可以被重新使用。

exit 函数的实现展示了操作系统在处理进程退出时的复杂性。通过正确的资源释放、父子进程关系处理以及同步机制,exit 函数确保进程能够安全退出,而不影响系统的稳定性。这个设计应对了进程退出的两个主要挑战,确保了在多核系统和并发环境下的安全性。

挑战1. 防止直接摧毁另一个线程

不能直接摧毁另一个线程,因为它可能正在运行、持有锁或更新数据结构。

应对方法

标记为僵尸状态

  • 通过将进程状态设置为 ZOMBIEexit 函数确保进程不再被调度运行,但其资源(如进程表项)在父进程调用 wait() 前保持保留。
  • 这意味着即使进程被标记为 ZOMBIE,其内核线程仍然存在,直到所有资源被安全释放。这避免了直接摧毁进程可能引发的竞争条件和资源不一致的问题。

使用锁机制

  • acquire(&wait_lock)acquire(&p->lock) 确保在修改进程状态和处理子进程时,其他进程无法干扰。这防止了在进程退出过程中,其他线程(可能在其他 CPU 核)对同一进程状态进行不安全的修改。

调度器控制

  • 通过调用 sched()exit 函数将进程从运行队列中移除,确保调度器不会再次调度这个进程。调度器负责选择下一个运行的进程,确保退出进程不会继续执行。

挑战2. 安全释放自身资源

进程在调用 exit 后,仍然持有运行所需的资源(如栈和进程表位置),需要一种方法让进程安全地释放这些资源。

应对方法

关闭文件描述符和释放工作目录

  • exit 函数中,所有打开的文件描述符和当前工作目录都被关闭和释放。这确保了进程退出时不会留下未释放的资源。

处理子进程

  • 通过 reparent(p) 函数,将子进程的父进程重新设置为 init,确保子进程在父进程退出后仍然有一个有效的父进程来处理其退出状态。

设置为僵尸状态

  • 将进程状态设置为 ZOMBIE,并保存退出状态 xstate,使得父进程可以通过 wait() 获取这些信息。这一步骤确保了进程的资源在父进程确认其退出状态前不会被完全释放,从而避免了在进程仍在运行时释放其资源的问题。

释放锁和调度

  • 释放 wait_lock 锁后,调用 sched() 让调度器切换到其他进程。此时,进程已经不再被调度运行,其剩余资源可以安全地由父进程通过 wait() 释放。

通过上述分析,可以看到 exit 函数在处理进程退出时,巧妙地使用了锁机制和状态标记,避免了直接摧毁进程可能带来的并发问题。同时,通过将进程状态设置为 ZOMBIE,确保了进程资源的安全释放,避免了在进程仍在运行时释放其关键资源。

wait 系统调用的实现及其与 exit 的关系

在 Unix 系统中,当一个进程调用 exit 系统调用退出时,它会进入僵尸状态(ZOMBIE),直到其父进程调用 wait 系统调用来获取该子进程的退出状态。wait 系统调用不仅用于通知父进程子进程的退出,还负责释放子进程的资源,使其在进程表中的位置可供新的进程使用。接下来,我们详细分析 wait 系统调用的实现,并探讨其与 exit 系统调用的关联。

wait 系统调用的实现

// Wait for a child process to exit and return its pid.
// Return -1 if this process has no children.
int wait(uint64 addr)
{
  struct proc *pp;  // 用于遍历进程表的指针
  int havekids, pid;  // havekids 标志表示当前进程是否有子进程
  struct proc *p = myproc();  // 获取当前进程的指针

  acquire(&wait_lock);  // 获取 wait 锁,以确保后续操作的原子性

  for(;;){  // 无限循环,直到找到一个退出的子进程
    // 遍历进程表,寻找当前进程的子进程
    havekids = 0;  // 初始化标志
    for(pp = proc; pp < &proc[NPROC]; pp++){  // 遍历进程表
      if(pp->parent == p){  // 如果找到当前进程的子进程
        acquire(&pp->lock);  // 获取子进程的锁,确保状态一致性

        havekids = 1;  // 当前进程确实有子进程
        if(pp->state == ZOMBIE){  // 如果子进程已经退出且处于 ZOMBIE 状态
          pid = pp->pid;  // 获取子进程的 PID
          if(addr != 0 && copyout(p->pagetable, addr, (char *)&pp->xstate, sizeof(pp->xstate)) < 0) {
            // 将子进程的退出状态复制到父进程的用户空间
            release(&pp->lock);  // 释放子进程的锁
            release(&wait_lock);  // 释放 wait 锁
            return -1;  // 如果复制失败,返回 -1
          }
          freeproc(pp);  // 释放子进程的资源
          release(&pp->lock);  // 释放子进程的锁
          release(&wait_lock);  // 释放 wait 锁
          return pid;  // 返回退出的子进程的 PID
        }
        release(&pp->lock);  // 如果子进程没有退出,释放其锁
      }
    }

    // 如果当前进程没有子进程,或者当前进程被杀死,直接返回 -1
    if(!havekids || killed(p)){
      release(&wait_lock);  // 释放 wait 锁
      return -1;  // 返回 -1,表示没有子进程或当前进程被杀死
    }
    
    // 如果当前进程有子进程但没有找到 ZOMBIE 状态的子进程,进入睡眠等待
    sleep(p, &wait_lock);  // 在 wait 锁下进入睡眠,等待子进程退出
  }
}

wait 系统调用的工作流程

  1. 获取 wait_lock
    • wait 函数开始时,首先获取 wait_lock 锁。这确保了在遍历进程表并检查子进程状态时,没有其他进程能同时修改这些状态。
  2. 扫描进程表
    • 进入一个无限循环,扫描进程表,寻找属于当前进程的子进程。
    • 如果找到一个子进程,则获取该子进程的锁,并检查其状态。
  3. 处理僵尸进程
    • 如果子进程的状态为 ZOMBIE,表示该子进程已经退出,但尚未释放所有资源。此时,wait 会获取子进程的 PID,并通过 copyout 将其退出状态复制到父进程的用户空间。
    • 然后调用 freeproc 函数释放子进程的资源,并将其状态设置为 UNUSED,以便在未来的 fork 调用中重用。
  4. 等待子进程退出
    • 如果在扫描进程表时没有找到 ZOMBIE 状态的子进程,且当前进程有子进程,wait 会调用 sleep 进入睡眠状态,等待子进程的退出。
    • 当某个子进程退出并调用 wakeup 函数时,wait 会被唤醒,再次扫描进程表。
  5. 处理无子进程的情况
    • 如果当前进程没有子进程,或在等待过程中被杀死,wait 函数会直接返回 -1,表示没有子进程可等待。
if(pp->state == ZOMBIE){  // 如果子进程已经退出且处于 ZOMBIE 状态
  pid = pp->pid;  // 获取子进程的 PID
  if(addr != 0 && copyout(p->pagetable, addr, (char *)&pp->xstate, sizeof(pp->xstate)) < 0) {
    // 将子进程的退出状态复制到父进程的用户空间
    release(&pp->lock);  // 释放子进程的锁
    release(&wait_lock);  // 释放 wait 锁
    return -1;  // 如果复制失败,返回 -1
  }
  freeproc(pp);  // 释放子进程的资源
  release(&pp->lock);  // 释放子进程的锁
  release(&wait_lock);  // 释放 wait 锁
  return pid;  // 返回退出的子进程的 PID
}

1. 检查子进程状态是否为 ZOMBIE

if(pp->state == ZOMBIE){
  • 背景:当一个进程调用 exit 退出时,它不会立即释放所有资源,而是将状态设置为 ZOMBIE,等待其父进程通过 wait 系统调用获取退出状态。
  • 作用:这行代码检查当前遍历到的子进程 pp 是否处于 ZOMBIE 状态。如果是,则说明该子进程已经退出,但其资源尚未完全释放,此时需要父进程处理。

2. 获取子进程的 PID

pid = pp->pid;
  • 作用:一旦确认子进程处于 ZOMBIE 状态,父进程会记录该子进程的 PID。这是因为 wait 系统调用的一个重要功能是返回退出的子进程的 PID,以便父进程能够识别是哪一个子进程已经退出。

3. 将子进程的退出状态复制到父进程的用户空间

if(addr != 0 && copyout(p->pagetable, addr, (char *)&pp->xstate, sizeof(pp->xstate)) < 0) {
  • 背景addr 是父进程提供的用户空间地址,通常用于存储子进程的退出状态。如果 addr 为非零,表示父进程希望将子进程的退出状态复制到这个地址。
  • copyout 函数copyout 是一个内核函数,用于将数据从内核空间复制到用户空间。这里它将 pp->xstate(子进程的退出状态)复制到父进程的用户空间地址 addr 处。
  • 错误处理:如果 copyout 返回值小于 0,表示复制失败,可能是因为父进程提供的地址无效。此时需要处理错误并返回 -1,表示 wait 系统调用失败。

4. 错误处理:释放锁并返回 -1

release(&pp->lock);  // 释放子进程的锁
release(&wait_lock);  // 释放 wait 锁
return -1;  // 如果复制失败,返回 -1
  • 锁的释放:在处理完 copyout 的失败情况后,必须确保释放之前获取的所有锁,防止死锁或资源被其他进程占用。
  • 返回 -1:如果发生了 copyout 失败的情况,wait 系统调用会返回 -1,通知父进程操作未成功。

5. 释放子进程的资源

freeproc(pp);  // 释放子进程的资源
  • 背景:在确认子进程已经处于 ZOMBIE 状态且成功处理了退出状态后,wait 函数会调用 freeproc 函数来释放子进程占用的资源。
  • 作用freeproc 函数会释放子进程的各种资源,包括用户空间内存、页表、trapframe 以及在进程表中的条目,将进程状态设置为 UNUSED,表示该进程表条目可以被复用。

6. 释放锁并返回子进程的 PID

release(&pp->lock);  // 释放子进程的锁
release(&wait_lock);  // 释放 wait 锁
return pid;  // 返回退出的子进程的 PID
  • 释放锁:在成功释放子进程的资源后,父进程会释放子进程的锁(pp->lock)和 wait_lock,以允许其他进程继续操作这些资源。
  • 返回 PID:最后,wait 函数返回子进程的 PID,表示哪个子进程已经退出。这使得父进程能够识别和处理多个子进程的退出情况。

freeproc 函数:释放进程资源

// 释放一个进程结构体及其相关资源,包括用户页面。
// 必须在持有 p->lock 的情况下调用。
static void freeproc(struct proc *p)
{
  if(p->trapframe)
    kfree((void*)p->trapframe);  // 释放 trapframe
  p->trapframe = 0;
  if(p->pagetable)
    proc_freepagetable(p->pagetable, p->sz);  // 释放页面表和用户空间内存
  p->pagetable = 0;
  p->sz = 0;
  p->pid = 0;  // 清空 PID
  p->parent = 0;  // 清空父进程指针
  p->name[0] = 0;  // 清空进程名称
  p->chan = 0;
  p->killed = 0;
  p->xstate = 0;
  p->state = UNUSED;  // 将进程状态设置为 UNUSED,表示可以重新分配
}

waitexit 的配合

  • exit 函数与 wait 的配合
    • 当一个进程调用 exit 时,它会进入 ZOMBIE 状态,释放大部分资源,但保留进程表条目以供父进程获取退出状态。
    • wait 函数则负责遍历进程表,找到这些处于 ZOMBIE 状态的子进程,获取其退出状态并最终调用 freeproc 释放剩余资源。
  • 为什么资源释放在 wait 中完成
    • exit 函数中,如果直接释放资源,进程可能在运行中释放其栈、页表等关键资源,这会导致严重错误。因此,exit 只是将进程置于 ZOMBIE 状态,等待父进程的 wait 调用来完成资源释放。
    • 这样设计的好处在于,确保了进程在运行中的任何时刻都不会错误地释放其仍在使用的资源。
  • 父进程和子进程的关系
    • 如果父进程退出,子进程会被重新分配给 init 进程。这是因为在 Unix 系统中,每个退出的进程都必须有一个父进程来调用 wait,否则这些进程会一直保持 ZOMBIE 状态,浪费系统资源。
    • init 进程作为系统中的第一个进程,其职责之一就是调用 wait 来清理所有无父进程的子进程。

处理父子进程同时退出的情况

在某些情况下,父进程和子进程可能会同时退出。这种情况下,子进程在退出时可能会尝试唤醒父进程,但父进程此时也在退出。为了处理这种情况,exitwait 函数的设计非常慎重,以确保不会产生竞态条件或未定义的行为。

当父进程和子进程几乎在同一时间调用 exit 时,以下几种情况可能会发生:

  1. 父进程在 wait 中等待子进程
    • 如果父进程正在等待子进程的退出,但子进程在父进程获取锁之前退出,可能会导致父进程无法正确处理子进程的退出。
  2. 父进程和子进程几乎同时退出
    • 子进程可能尝试唤醒已经退出的父进程,或者父进程在子进程状态未完全更新前退出,可能导致资源的重复释放或状态的错误更新。

如果没有锁处理父子进程同时退出的情况

如果在处理父子进程同时退出的情况下没有使用锁,会导致严重的问题:

  1. 竞态条件
    • 子进程在退出时,可能会尝试唤醒正在退出的父进程。如果没有锁保护,子进程可能会误唤醒一个即将被释放的进程,导致进程状态不一致或资源重复释放。
  2. 资源泄露或重复释放
    • 父子进程在没有锁保护的情况下同时退出,可能导致某些资源没有被正确释放(资源泄露),或者被多次释放(重复释放),这会导致内存泄漏、文件系统错误等问题。
  3. 进程状态不一致
    • 由于没有锁的保护,父进程可能在子进程状态尚未更新时退出,导致进程表中的状态不一致。这可能会导致父进程误认为子进程尚未退出,或者重复尝试处理已经处理过的子进程。
  4. 死锁
    • 如果没有正确的锁顺序和管理机制,父子进程在退出时可能会因为相互等待对方的状态更新而陷入死锁状态,导致整个系统无法继续工作。

旧版exit()

下面是视频中的旧版exit()参见Commit

// 退出当前进程。此函数不会返回。
// 已退出的进程将保持在僵尸状态(ZOMBIE),直到其父进程调用 wait()。
void exit(int status)
{
  struct proc *p = myproc();  // 获取当前正在执行的进程指针。

  if(p == initproc)  // 如果当前进程是 init 进程(PID 1),则触发 panic。
    panic("init exiting");  // init 进程不能退出,否则系统会失去最基本的管理进程。

  // 关闭当前进程打开的所有文件。
  for(int fd = 0; fd < NOFILE; fd++){
    if(p->ofile[fd]){  // 如果文件描述符存在,则关闭它。
      struct file *f = p->ofile[fd];
      fileclose(f);  // 关闭文件,减少文件系统的引用计数。
      p->ofile[fd] = 0;  // 将文件描述符置为 NULL。
    }
  }

  // 开始一个文件系统操作。
  begin_op();
  iput(p->cwd);  // 释放当前工作目录的 i-node 引用。
  end_op();  // 结束文件系统操作。
  p->cwd = 0;  // 将当前进程的工作目录指针置空。

  // 我们可能会将子进程重新分配给 init 进程。
  // 由于一旦获取了其他进程锁,就不能再精确获取 init 的锁,
  // 所以我们无论如何都唤醒 init 进程。即使 init 进程错过了这个唤醒操作,
  // 也不会有太大的问题。
  acquire(&initproc->lock);  // 获取 init 进程的锁。
  wakeup1(initproc);  // 唤醒 init 进程。
  release(&initproc->lock);  // 释放 init 进程的锁。

  // 获取 p->parent 的副本,以确保我们解锁的父进程与我们加锁的相同。
  // 这样做是为了防止在等待父进程锁时,父进程将我们重新分配给 init。
  // 这样可能会与正在退出的父进程竞争,但结果只是对死进程或错误进程的
  // 无害的伪唤醒;proc 结构体从不会重新分配为其他类型。
  acquire(&p->lock);  // 获取当前进程的锁。
  struct proc *original_parent = p->parent;  // 保存当前进程的父进程指针。
  release(&p->lock);  // 释放当前进程的锁。
  
  // 我们需要父进程的锁,以便从 wait() 中唤醒它。
  // 父进程-子进程规则要求我们必须首先锁定父进程。
  acquire(&original_parent->lock);  // 获取父进程的锁。

  acquire(&p->lock);  // 再次获取当前进程的锁。

  // 将当前进程的所有子进程重新分配给 init 进程。
  reparent(p);  // 执行子进程的重新分配操作。

  // 父进程可能正在 wait() 中睡眠,等待当前进程的退出状态。
  wakeup1(original_parent);  // 唤醒父进程。

  p->xstate = status;  // 设置当前进程的退出状态。
  p->state = ZOMBIE;  // 将当前进程状态设置为僵尸状态(ZOMBIE)。

  release(&original_parent->lock);  // 释放父进程的锁。

  // 跳转到调度器,不再返回。
  sched();  // 调用调度器,放弃当前进程的 CPU 时间。
  panic("zombie exit");  // 如果代码运行到这里,说明出错了,因为调度器应该不再返回。
}

主要区别和改动原因

  1. 锁的使用与管理改进
    • 旧版 exit 函数:使用了 initproc->lockoriginal_parent->lock,分别保护 init 进程和父进程之间的同步操作。这种设计在处理进程之间的依赖关系时较为复杂。
    • 新版 wait 函数:引入了一个新的全局锁 proc_tree_lock(后改名为wait_lock),用于保护整个进程树的结构,确保在遍历进程表和处理进程状态时不会出现竞态条件。这种设计简化了进程间同步的复杂性,并避免了旧版代码中可能存在的死锁问题。
  2. 改进的进程树同步机制
    • 旧版:进程树的同步依赖多个锁(父进程锁、initproc 锁),存在潜在的死锁风险,尤其是在多个进程同时操作父子进程关系时。
    • 新版:引入 proc_tree_lockwait_lock) 统一保护进程树的遍历和修改,减少了需要管理的锁的数量,提高了代码的可靠性和简洁性。
  3. 子进程的释放与等待
    • 旧版:依赖多个锁的嵌套和顺序来确保资源的释放和进程状态的同步。
    • 新版:通过全局锁 proc_tree_lock 以及在 wait 中对子进程状态的统一处理,使得子进程的释放更加简洁和安全。

新版代码如何处理父子进程同时退出

新版代码通过引入全局锁 proc_tree_lock,更简洁地处理了父子进程同时退出的情况。

  1. 全局锁 proc_tree_lock
    • 新版代码引入了 proc_tree_lock 作为保护进程树结构的全局锁。这意味着所有对进程树的修改和遍历都在获取此锁的情况下进行,从而避免了复杂的锁定顺序和竞态条件。
  2. 简化的处理逻辑
    • 因为使用了单一的全局锁,新版代码不再需要分别获取父进程和子进程的锁来保护状态。这简化了逻辑,减少了出错的可能性。
    • proc_tree_lock 确保在处理父子进程关系时,无论是哪个进程先退出,都不会导致不一致的状态或资源泄露。
  3. 对父子进程同时退出的有效处理
    • 由于 proc_tree_lock 的存在,即使父子进程同时退出,锁的全局保护机制会确保进程树的修改是原子的,避免了子进程误唤醒父进程或重复释放资源的问题。

XV6中与进程关闭相关的关键函数:kill

在操作系统中,kill 系统调用允许一个进程请求终止另一个进程。在 XV6 和其他 Unix 系统中,kill 系统调用并不会立即停止目标进程的运行,而是通过设置一个标志位,告知目标进程应在适当时机安全地退出。这种设计确保了系统的稳定性,避免了因为突然终止进程导致的资源泄露或系统不一致性问题。我们将逐步分析 kill 系统调用的代码,并解释其工作原理和设计决策。

kill 系统调用的代码

// Kill the process with the given pid.
// The victim won't exit until it tries to return
// to user space (see usertrap() in trap.c).
int
kill(int pid)
{
  struct proc *p;

  // 遍历进程表,寻找目标进程
  for(p = proc; p < &proc[NPROC]; p++){
    acquire(&p->lock);  // 获取进程的锁,以确保对进程状态的修改是安全的。
    if(p->pid == pid){  // 如果找到匹配的 PID
      p->killed = 1;  // 将目标进程的 `killed` 标志位设置为 1,表示该进程应被终止。
      if(p->state == SLEEPING){  // 如果目标进程当前处于睡眠状态
        p->state = RUNNABLE;  // 将其状态更改为 `RUNNABLE`,使其从 `sleep` 中唤醒。
      }
      release(&p->lock);  // 释放进程的锁。
      return 0;  // 成功标记目标进程为 `killed`,返回 0。
    }
    release(&p->lock);  // 如果未找到匹配的进程,释放锁并继续。
  }
  return -1;  // 如果遍历完所有进程都未找到匹配的 PID,返回 -1。
}

kill 系统调用的核心工作流程

  1. 遍历进程表
    • kill 函数首先遍历整个进程表,寻找与传入的 pid 匹配的目标进程。此操作是必要的,因为进程可以通过 pid 唯一标识。
  2. 设置 killed 标志
    • 一旦找到目标进程,kill 系统调用不会立即终止该进程,而是将其 killed 标志位设置为 1。这个标志位告诉目标进程,当它在安全位置检查到该标志时,应主动调用 exit 函数退出。这种设计允许目标进程在不持有任何锁且不处于关键操作中的时候安全地退出。
  3. 唤醒处于 SLEEPING 状态的进程
    • 如果目标进程当前处于 SLEEPING 状态,kill 系统调用将其状态更改为 RUNNABLE,从而唤醒该进程。这是为了确保被 kill 的进程能够尽快执行到检查 killed 标志的代码,并退出。
  4. 返回状态
    • 如果 kill 成功找到并标记了目标进程,它返回 0 表示成功。如果没有找到匹配的 pid,则返回 -1 表示失败。

kill 系统调用的延迟退出机制

kill 系统调用并不会立即强制停止目标进程,而是依赖目标进程在合适的时机(如系统调用或中断处理时)检查 killed 标志,并自行调用 exit 函数安全退出。这种延迟机制确保了以下几点:

  1. 系统稳定性
    • 通过允许进程自行退出,避免了在进程执行关键任务时突然中断导致的系统不一致性。例如,如果进程在更新文件系统或持有锁时被强制终止,可能会导致数据损坏或死锁。
  2. 安全性
    • 进程只有在不持有锁且不处于关键操作中时才会退出,这避免了资源泄露或部分操作未完成的情况。
  3. 用户态和内核态的协调
    • kill 标志位的检查通常发生在进程从内核态返回用户态的路径上,如在系统调用的入口和出口处。这种设计确保进程在适当的地方安全地退出。

usertrap 中的 killed 标志检查

void
usertrap(void)
{
  int which_dev = 0;

  if((r_sstatus() & SSTATUS_SPP) != 0)
    panic("usertrap: not from user mode");

  w_stvec((uint64)kernelvec);
  struct proc *p = myproc();
  p->trapframe->epc = r_sepc();
  
  if(r_scause() == 8){
    // system call
    if(killed(p))     //  如果已被 kill,则主动调用 exit 退出。
      exit(-1);

    p->trapframe->epc += 4;
    intr_on();
    syscall();
  } else if((which_dev = devintr()) != 0){
    // ok
  } else {
    printf("usertrap(): unexpected scause 0x%lx pid=%d\n", r_scause(), p->pid);
    printf("            sepc=0x%lx stval=0x%lx\n", r_sepc(), r_stval());
    setkilled(p);
  }

  if(killed(p))
    exit(-1);

  if(which_dev == 2)
    yield();

  usertrapret();
}
  1. 系统调用前的检查
    • 在处理系统调用之前,usertrap 首先检查 killed 标志。如果该标志被设置,进程会立即调用 exit 主动退出。这确保了即使进程正要进行系统调用,也不会在不安全的状态下继续执行。
  2. 系统调用后的检查
    • 系统调用完成后,usertrap 再次检查 killed 标志,以防在系统调用过程中或因中断引发的过程中,进程被标记为 killed
  3. 中断后的检查
    • 即使进程因中断(如定时器中断)而从用户态进入内核态,usertrap 也会检查 killed 标志。如果该标志被设置,进程会在处理完中断后安全退出。

总的来说,kill 系统调用并不直接强制终止进程,而是通过设置 killed 标志,让目标进程在合适的时机安全退出。这种延迟退出机制确保了系统的稳定性和安全性,避免了强制终止进程可能带来的数据不一致性、资源泄露和系统死锁问题。

  • kill 只标记进程为 killed,并在进程处于睡眠状态时唤醒它。
  • 目标进程在执行到安全的内核代码位置时,会检查 killed 标志,并根据情况主动调用 exit 退出。
  • usertrap 函数通过在系统调用和中断处理前后检查 killed 标志,确保进程能够在安全时机退出。

这使得 kill 系统调用不仅在设计上更加谨慎,还能够有效地协调进程的退出与系统的整体稳定性。

虽然上述机制已经足够处理大多数情况,但如果进程在执行系统调用时被 kill,而该系统调用涉及长时间的阻塞操作(如等待输入),进程可能会因为一直等待而无法立即检测到 killed 标志。

SLEEPING或特殊情况的kill

当一个进程被 kill 系统调用标记为要终止时,理想情况下,进程应该尽快安全地退出。然而,进程可能正在执行复杂的系统调用,例如等待磁盘 I/O 操作完成,或者在 pipe 读取操作中等待数据到来。为了应对这些情况,XV6 设计了一套机制,使进程在适当的时机退出。

kill 函数的作用

kill 函数被调用时,它会做两件关键的事情:

p->killed = 1;  // 将目标进程的 `killed` 标志位设置为 1
if(p->state == SLEEPING){  // 如果进程处于 SLEEPING 状态
  p->state = RUNNABLE;  // 将其唤醒,使其进入 RUNNABLE 状态
}
  1. 设置 killed 标志位:这告诉目标进程,它应该在适当的时机安全地退出。

  2. 唤醒处于 SLEEPING 状态的进程:如果目标进程正在 SLEEPING 状态,kill 函数会将其状态设置为 RUNNABLE,以使进程从 sleep 中返回,并继续执行。

piperead 函数中的处理机制

int piperead(struct pipe *pi, uint64 addr, int n)
{
  int i;
  struct proc *pr = myproc();
  char ch;

  acquire(&pi->lock);  // 获取管道的锁
  while(pi->nread == pi->nwrite && pi->writeopen){  // 如果管道为空,且写端仍然打开
    if(killed(pr)){  // 检查进程是否被标记为 killed
      release(&pi->lock);  // 释放管道锁
      return -1;  // 返回 -1 表示读取失败
    }
    sleep(&pi->nread, &pi->lock);  // 进入睡眠,等待数据到达
  }
  // 从管道中读取数据
  for(i = 0; i < n; i++){  
    if(pi->nread == pi->nwrite)
      break;
    ch = pi->data[pi->nread++ % PIPESIZE];
    if(copyout(pr->pagetable, addr + i, &ch, 1) == -1)
      break;
  }
  wakeup(&pi->nwrite);  // 唤醒可能在等待管道写入的进程
  release(&pi->lock);  // 释放管道锁
  return i;  // 返回实际读取的字节数
}
  1. 进入 sleep 状态:当 piperead 发现管道中没有数据时,进程会调用 sleep 进入睡眠状态,等待数据到达。

  2. kill 函数的作用:如果这个进程在睡眠过程中被 kill 函数标记为 killedkill 会将进程的状态从 SLEEPING 改为 RUNNABLE,使其从 sleep 中返回。

  3. 检查 killed 标志位:进程从 sleep 中返回后,会首先检查 killed 标志位。如果该标志位被设置,piperead 函数会立即返回 -1,表明操作被中断。

  4. 安全退出piperead 返回到用户态的系统调用处理器 usertrap,在 usertrap 中再次检查 killed 标志位,如果被标记为 killed,进程会调用 exit 函数安全退出。

非立即退出的特殊情况:virtio_disk.c

// 等待 virtio_disk_intr() 通知请求已经完成。
while(b->disk == 1) {
  sleep(b, &disk.vdisk_lock);
}

在某些情况下,进程即使被 kill 了,也不能立即退出。例如,在 virtio_disk.c 文件中,如果进程正在等待磁盘 I/O 操作完成,那么不检查 killed 标志位是有道理的。

为什么 virtio_disk.c 中不检查 killed 标志位?

  1. 保护文件系统的一致性:磁盘 I/O 操作通常是系统调用的一部分,这些操作可能涉及多个步骤,例如读取、写入数据块,更新元数据等。在这些操作未完成前,进程不能退出,否则会导致文件系统不一致。

  2. 确保操作的完整性:在这些 I/O 操作中,进程可能已经部分完成了一些重要的写入操作,强行中断会导致数据损坏。因此,XV6 允许这些操作完成,然后在更安全的时机检查 killed 标志位,并安全退出。

关于 kill 系统调用的讨论

1. 为什么允许一个进程 kill 另一个进程?这样不是能杀掉所有其他进程吗?

这是一个关于进程管理权限的重要问题。在一个操作系统中,允许一个进程终止另一个进程的操作是非常敏感的。

在 XV6 这样的教学操作系统中,设计相对简单,系统中没有实现与权限相关的机制。因此,任何进程都可以对其他进程执行 kill 操作,而不考虑权限问题。这种设计有助于教学目的,使学生能够理解操作系统基本功能的实现,而不需要处理复杂的安全机制。

然而,在生产环境中的操作系统(如 Linux),权限管理是至关重要的。每个进程都与一个用户 ID(UID)相关联,这个 UID 标识了执行该进程的用户。操作系统通过检查进程的 UID 来决定哪些操作是被允许的。例如:

  • 同一用户的进程:如果两个进程属于同一用户(即它们的 UID 相同),那么其中一个进程可以通过 kill 系统调用终止另一个进程。
  • 不同用户的进程:如果两个进程属于不同的用户,那么一个进程不能随意终止另一个进程。这样可以防止用户无意中或恶意地终止其他用户的进程,从而破坏系统的稳定性和安全性。

在多用户系统中,例如 MIT 的分时复用计算机 Athena 系统,进程的权限检查确保了一个用户不能干扰其他用户的操作,从而维护系统的整体安全性。

2. init 进程会退出吗?

在正常情况下,init 进程不应该退出。init 进程是操作系统中第一个启动的用户进程,它的主要任务是管理系统中的其他进程,尤其是孤儿进程。因此,init 进程的存在对系统的稳定性至关重要。

init 进程的设计

在 XV6 中,init 进程的代码(user/init.c)展示了它是如何在一个无限循环中不断地启动新的 shell 进程,并通过 wait 系统调用等待子进程退出。具体来说,init 进程的职责包括:

  • 启动 shell:每当 init 进程发现 shell 进程退出(无论是正常退出还是由于错误退出),它都会重新启动一个新的 shell,以确保系统始终处于可用状态。
  • 管理孤儿进程:当系统中某个进程的父进程退出时,这些进程会被重新分配给 init 进程。init 通过 wait 调用来处理这些孤儿进程的退出,并释放其占用的系统资源。

退出 init 进程的后果

如果 init 进程退出,会导致系统不可恢复的崩溃。原因如下:

  • 无法处理孤儿进程:如果没有 init 进程,系统中的孤儿进程将无法被正确管理,导致资源泄漏和进程表项耗尽。
  • 进程资源释放问题init 进程的 wait 调用负责释放已经退出的子进程的资源,如果 init 进程退出,这些资源将无法被释放,最终导致系统资源枯竭。
  • 系统崩溃:由于这些原因,init 进程的退出会触发一个不可恢复的系统错误,通常会导致系统崩溃。在 XV6 的 exit 函数中,有明确的检查防止 init 进程退出。
void exit(int status)
{
  struct proc *p = myproc();

  if(p == initproc)
    panic("init exiting");  // 如果是init进程退出,触发panic,因为init进程不能退出
  ...

3. 如果关闭一个操作系统会发生什么?

关闭操作系统是一个复杂的过程,取决于系统当前的状态和正在运行的进程。尤其是涉及到持久化数据的文件系统时,正确关闭系统至关重要。

文件系统的一致性

当操作系统在运行时,它可能正在对文件系统进行各种操作,如文件的读写、创建、删除等。这些操作通常需要多步完成,并且在不同的时刻更新磁盘上的数据结构。如果系统在操作过程中意外中断(例如断电或强制关机),可能会导致文件系统处于不一致状态。

为了防止这种情况发生,操作系统使用了多种机制来确保文件系统的一致性,即使在系统意外关闭时也能保证数据的完整性。这些机制包括:

  • 事务性文件系统:一些高级文件系统使用事务来保证文件操作的原子性。即,所有与文件操作相关的步骤要么全部成功完成,要么全部不做,从而避免中间状态的出现。
  • 日志:某些文件系统在实际更新磁盘数据之前,会先记录操作日志,以便在系统重新启动时可以检查和恢复未完成的操作。
  • 安全关闭流程:操作系统在关闭时,会确保所有文件操作完成,并将缓存中的数据同步到磁盘,避免数据丢失。

进程的重要性

如果系统正在运行一些关键服务(例如数据库服务器),在关机时需要特别小心,因为其他系统可能依赖这些服务的正常运行。因此,操作系统在关闭之前可能需要通知这些服务并等待它们安全停止。

正确的系统关闭步骤

  1. 确保文件系统处于一致状态:在关闭之前,操作系统会确保所有正在进行的文件系统操作完成,并将所有缓存数据写入磁盘。
  2. 停止所有运行中的进程:操作系统会通知所有正在运行的进程终止,并确保它们有机会正确释放资源。
  3. 停止指令执行:一旦所有进程都安全终止,文件系统的状态被确保为一致,操作系统会停止指令执行,并关闭计算机。

总之,系统关闭过程的核心是确保文件系统的一致性和重要服务的安全终止,以避免数据损坏和系统不可恢复的错误。