管程是什么?有什么核心功能?

管程(Monitor)是一种用于进程同步的工具,类似一个管道,其中存在共享资源,有选择地控制进入的进程,避免并发问题如数据竞争和死锁。管程通过将共享资源的访问封装在一个对象中,并提供互斥和同步功能来实现这一点。

管程的核心功能

管程通常由以下几个核心功能组成:

  1. 互斥锁(Mutual Exclusion):
    • 管程确保同一时刻只有一个线程可以访问共享资源或执行管程中的临界区代码。这通过一个隐式的锁(通常称为管程锁)实现。
    • 当一个线程进入管程时,它会获取锁,其他试图进入的线程会被阻塞,直到锁被释放。
  2. 条件变量(Condition Variables):
    • 条件变量用于线程间的同步,允许线程在特定条件不满足时等待,或者在条件满足时被唤醒。
    • 主要操作包括:
      • wait():线程释放锁并进入等待状态,直到被通知。
      • signal():唤醒一个等待在条件变量上的线程。
      • broadcast():唤醒所有等待在条件变量上的线程。
  3. 共享资源(Shared Data):
    • 管程封装了需要线程安全访问的数据或资源。这些资源只能通过管程定义的方法(通常是同步方法)访问。
  4. 同步方法(Synchronized Methods/Procedures):
    • 管程提供一组方法,用于操作共享资源。这些方法是线程安全的,通常由管程的锁保护,确保每次只有一个线程可以执行这些方法。

管程的工作原理

  • 线程进入管程时,获取管程的锁。
  • 如果需要等待某个条件,线程调用 wait(),释放锁并进入等待队列。
  • 当其他线程通过 signal() 或 broadcast() 通知时,等待的线程被唤醒,重新竞争锁。
  • 线程完成操作后,释放锁,允许其他线程进入。

由于其设计目标是简单和清晰,Unix v0.11 并没有直接实现完整的“管程”(monitor)机制,如现代操作系统或编程语言(如 Java)中常见的那种高级同步原语。不过,它通过信号量(semaphore)和基本的同步机制间接实现了类似管程的功能,用于进程同步和互斥。

(1) 信号量(Semaphore)

  • Unix 0.11原版代码中没有对信号量的实现,后续扩展中加入了该操作
  • 信号量主要通过 P(等待)和 V(释放)操作来控制进程的同步和互斥。
  • 在 Unix 0.11 的内核代码中,信号量通常用于管理共享资源(如文件系统缓冲区或设备访问)。
  • 实现细节:
    • 信号量通常由一个整数值和一个等待队列组成。
    • 当进程执行 P 操作时,如果信号量值小于 0,进程会被挂起并加入等待队列。
    • 当其他进程执行 V 操作时,会唤醒等待队列中的一个进程。
    • 这些操作通常在内核的 kernel/sem.c 或类似文件中实现。

(2) 进程调度与睡眠/唤醒机制

sched.c

  • Unix v0.11 使用 sleep 和 wakeup 机制来实现进程的等待和唤醒,类似于管程中的条件变量。
  • 例如,当一个进程需要等待某个资源(如等待 I/O 完成),它会调用 sleep 函数进入睡眠状态,并将控制权交给其他进程。
  • 当资源可用时,另一个进程或中断处理程序会调用 wakeup,唤醒等待的进程。
  • 这种机制在 kernel/sched.c 中实现,广泛用于设备驱动和文件系统操作。
  • image-20250504163450596

schedule() 是调度器的核心,运行逻辑如下:

1.检查信号和定时器

#检查每一个进程
for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
	if (*p) {
		if ((*p)->alarm && (*p)->alarm < jiffies) {
            #如果设置了定时器,而且定时器时间到了,就给它发送signal信号
			(*p)->signal |= (1<<(SIGALRM-1));
			(*p)->alarm = 0;
		}
        #如果有时钟信号,且状态是可中断睡眠状态,就改为可运行状态
		if (((*p)->signal & ~(_BLOCKABLE & (*p)->blocked)) &&
		(*p)->state==TASK_INTERRUPTIBLE)
			(*p)->state=TASK_RUNNING;
	}

  • 遍历任务列表(task[]),检查每个任务的 alarm(定时器)是否到期。
  • 如果定时器到期,设置 SIGALRM 信号。
  • 如果进程处于 TASK_INTERRUPTIBLE 状态且收到未被屏蔽的信号(signal & ~(_BLOCKABLE & blocked)),将其状态改为 TASK_RUNNING,使其可被调度。

2.选择下一个任务

while (1) {
	c = -1;#假设没有进程可以运行
	next = 0;
	i = NR_TASKS;
	p = &task[NR_TASKS];
	while (--i) {
		if (!*--p)
			continue;
		if ((*p)->state == TASK_RUNNING && (*p)->counter > c)#找到可运行且时间片最大的任务
			c = (*p)->counter, next = i;
	}
	if (c) break;#如果找到了,就跳出循环
	for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)#如果时间片都用完了,就重新分配时间片(counter//2+priority)
		if (*p)
			(*p)->counter = ((*p)->counter >> 1) +
					(*p)->priority;
}

  • 调度器遍历所有任务(task[0] 到 task[NR_TASKS-1]),寻找状态为 TASK_RUNNING 且 counter(时间片计数器)最大的任务。

  • counter 表示进程剩余的时间片,初始值基于进程的优先级(priority)。

  • 如果找到 counter > 0 的任务,记录其索引(next)并跳出循环。

  • 如果没有任务有剩余时间片(c == -1)则重新计算所有任务的 counter:

    (*p)->counter = ((*p)->counter >> 1) + (*p)->priority;

    这将时间片折半并加上优先级,相当于动态调整时间片。

3.切换到新任务

  • 调用 switch_to(next),执行上下文切换,将 CPU 控制权交给选中的任务。
  • switch_to 涉及保存当前任务的寄存器状态并加载新任务的上下文(包括 TSS 和 LDT)。

4.相关调度函数

int sys_pause(void)
{
	current->state = TASK_INTERRUPTIBLE;
	schedule();
	return 0;
}
#调用这个函数,就会把进程设置为可中断睡眠状态(收到某个信号才会再次唤醒)
void sleep_on(struct task_struct **p)
{
	struct task_struct *tmp;

	if (!p)
		return;
	if (current == &(init_task.task))
		panic("task[0] trying to sleep");
	tmp = *p;
	*p = current;
	current->state = TASK_UNINTERRUPTIBLE;
	schedule();
	if (tmp)
		tmp->state=0;
}
#将进程设置为不可打断睡眠状态,直到使用wake_up函数唤醒,被唤醒后,如果之前有其他进程在 *p 上睡觉,就唤醒它
void interruptible_sleep_on(struct task_struct **p)
{
	struct task_struct *tmp;

	if (!p)
		return;
	if (current == &(init_task.task))
		panic("task[0] trying to sleep");
	tmp=*p;
	*p=current;
repeat:	current->state = TASK_INTERRUPTIBLE;
	schedule();
	if (*p && *p != current) {
		(**p).state=0;
		goto repeat;
	}
	*p=NULL;
	if (tmp)
		tmp->state=0;
}
让当前进程进入 可中断睡眠状态,并加入某个等待队列。被唤醒后会检查是否真正轮到自己运行,如果不是,则再次进入睡眠。

工作流程:

  1. sleep_on() 类似,把当前进程加入 *p 等待队列;
  2. 设置为 TASK_INTERRUPTIBLE(可以被信号打断);
  3. 调度出去;
  4. 唤醒时,如果 *p 上还有别人,而且不是自己,那就唤醒别人并再次进入睡眠(防止“被假叫醒”);
  5. 最终清空等待队列指针。

(3) 中断屏蔽与互斥

  • Unix v0.11 通过屏蔽中断(cli 和 sti 指令)来实现内核中的互斥访问,类似于管程的互斥锁。
  • 例如,在访问共享数据结构(如进程表或缓冲区)时,内核会临时屏蔽中断,以防止其他进程或中断处理程序干扰。
  • 这种方式虽然简单,但效率较低,仅适用于短时间的互斥操作。

image-20250504173107968

image-20250509085854820

int ticks_to_floppy_on(unsigned int nr)
{
	extern unsigned char selected;
	unsigned char mask = 0x10 << nr;

	if (nr>3)
		panic("floppy_on: nr>3");
	moff_timer[nr]=10000;		/* 100 s = very big :-) */
	cli();				/*中断对操作硬件的保护*/
	mask |= current_DOR;
	if (!selected) {
		mask &= 0xFC;
		mask |= nr;
	}
	if (mask != current_DOR) {
		outb(mask,FD_DOR);
		if ((mask ^ current_DOR) & 0xf0)
			mon_timer[nr] = HZ/2;
		else if (mon_timer[nr] < 2)
			mon_timer[nr] = 2;
		current_DOR = mask;
	}
	sti();
	return mon_timer[nr];
}

void floppy_on(unsigned int nr)
{
	cli();   //中断保护循环顺利执行
	while (ticks_to_floppy_on(nr))
		sleep_on(nr+wait_motor);
	sti();
}

为什么需要阻止中断

本质是防止中断处理程序干扰内核正在执行的临界区代码

保护共享资源(避免竞争条件)

  • 内核中的共享资源(如进程表

    task[]、定时器链表、硬件寄存器)可能被多个执行流访问,包括:

    • 当前运行的进程。
    • 中断处理程序(由硬件中断触发)。
  • 如果中断处理程序在临界区执行期间访问同一资源,可能导致数据不一致。

  • 示例: 在 kernel/sched.c的 add_timer 函数中:

    cli(); // 操作定时器链表 sti();

    假设不屏蔽中断,时钟中断可能触发并调用 do_timer,修改同一定时器链表,导致链表结构损坏。

确保临界区的原子性

  • 内核中的临界区是指需要连续执行且不能被打断的代码段(如更新进程状态、操作硬件寄存器)。

  • 中断可能在任意指令间发生,打断临界区执行,导致操作未完成的状态被其他代码看到。

  • 屏蔽中断(cli)确保临界区代码作为一个整体执行,完成后用 sti 恢复中断。

  • 示例: 在 kernel/blk_drv/floppy.c的 ticks_to_floppy_on 函数中:

    cli(); mask |= current_DOR; // 设置软盘寄存器 sti();

    如果中断在设置 current_DOR期间发生,另一个中断处理程序可能覆盖寄存器值,导致软盘控制器状态错误。

防止进程切换

  • 在 Linux 0.11 中,进程切换通常由时钟中断触发(调用 schedule())。
  • 如果在修改进程控制块(如 task_struct)时发生时钟中断,可能切换到另一个进程,导致数据不一致。
  • 屏蔽中断防止调度器运行,确保当前进程完成关键操作。
  • 示例: 在 schedule() 函数中,修改 task[] 或切换进程上下文时,屏蔽中断以避免嵌套调度。

单 CPU 环境的同步需求

  • Linux 0.11 设计为单 CPU 系统(不支持 SMP)。在这种环境中,竞争主要来自:
    • 进程执行与中断处理程序的并发。
    • 不同进程通过时钟中断切换。
  • 屏蔽中断可以完全阻止这些竞争,确保临界区的独占访问。
  • 在多核系统中,屏蔽中断只影响当前 CPU,需结合其他锁机制(如自旋锁)。

硬件操作的原子性

  • 访问硬件设备(如 I/O 端口、控制寄存器)通常需要多个指令,且必须按顺序执行。
  • 中断可能打断这些操作,导致硬件进入不一致状态。
  • 屏蔽中断确保硬件操作的完整性。
  • 示例: 在软盘驱动中,设置 FD_DOR 寄存器需要连续操作,cli 防止中断干扰。

image-20250504173456706

总结

image-20250509085515441

心中无难事,只要肯放弃