《操作系统真象还原》chapter9 笔记与注意

First Post:

Last Update:


PART 1

  • 问:进程和线程的关系是什么?
  • 答:进程=线程+资源

    本书第一节用非常冗长的语言描述这个问题。但总而言之归纳几点就是:

  • 线程是操作系统调度的基本单位
  • 进程=线程+资源
  • 线程是一个 “执行流” 概念,不需要过多去解读这个词语。
  • 出于上面一点,能将所有程序分为 “单线程进程”“多线程进程”。即普通的未使用线程功能的程序也能算作 “单线程进程”
  • Linux系统下称进程为 “任务”(Task) ,但进程和线程是概念性的事物,而任务是实现上的结果,不需要过度去在意其称呼。
  • Linux下的线程实现来自于POSIX线程库,自Linux2.6以后,因为NPTL的成功,该方案支持的线程的内核级的。只有一些古老的版本会有用户级线程

本节也提到了所谓 “上下文” 的概念:程序代码执行所依赖的 寄存器映像内存资源。后者一般指的是堆和栈。


PART 2

    本章后半部分笔者曾在《深入Linux内核架构》中了解到些许,但对其实现十分费解,这次算是对实现也清楚一些了。但本章还留有一些问题,本书作者表示会在chapter 10解决它,但我目前还不清楚我遇到的问题是不是就是作者所说的,或许有一点偏差,但具体还要等笔者看完第十章再做评价。

    首先是Linux架构里所用的“任务”PCB:(注释就不删了,个人觉得还对本章掌握的有点模糊,留着以后有问题了再看)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct task_struct {
uint32_t* self_kstack; // 各内核线程都用自己的内核栈
enum task_status status;
char name[16];
uint8_t priority;
uint8_t ticks; // 每次在处理器上执行的时间嘀嗒数

/* 此任务自上cpu运行后至今占用了多少cpu嘀嗒数,
* 也就是此任务执行了多久*/
uint32_t elapsed_ticks;

/* general_tag的作用是用于线程在一般的队列中的结点 */
struct list_elem general_tag;

/* all_list_tag的作用是用于线程队列thread_all_list中的结点 */
struct list_elem all_list_tag;

uint32_t* pgdir; // 进程自己页表的虚拟地址
uint32_t stack_magic; // 边界标记,用于检测栈的溢出
};

    内核为支持多任务需要自己维护一张链表来让任务间能够切换。以PCB中的ticks代表时间片,每次时钟中断时将消减当前PCB中的时间片,在为0时进行一次调度(此前需要先关闭中断,以防止调度器被自己调度)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void schedule() {
ASSERT(intr_get_status() == INTR_OFF);
struct task_struct* cur = running_thread();
if (cur->status == TASK_RUNNING) {
ASSERT(!elem_find(&thread_ready_list, &cur->general_tag));
list_append(&thread_ready_list, &cur->general_tag);
cur->ticks = cur->priority;
cur->status = TASK_READY;
} else {
}

ASSERT(!list_empty(&thread_ready_list));
thread_tag = NULL; // thread_tag清空
/* 将thread_ready_list队列中的第一个就绪线程弹出,准备将其调度上cpu. */
thread_tag = list_pop(&thread_ready_list);
struct task_struct* next = elem2entry(struct task_struct, general_tag, thread_tag);
next->status = TASK_RUNNING;
switch_to(cur, next);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
switch_to:
;栈中此处是返回地址
push esi
push edi
push ebx
push ebp

mov eax, [esp + 20] ; 得到栈中的参数cur, cur = [esp+20]
mov [eax], esp ; 保存栈顶指针esp. task_struct的self_kstack字段,
; self_kstack在task_struct中的偏移为0,
; 所以直接往thread开头处存4字节便可。
;------------------ 以上是备份当前线程的环境,下面是恢复下一个线程的环境 ----------------
mov eax, [esp + 24] ; 得到栈中的参数next, next = [esp+24]
mov esp, [eax] ; pcb的第一个成员是self_kstack成员,用来记录0级栈顶指针,
; 用来上cpu时恢复0级栈,0级栈中保存了进程或线程所有信息,包括3级栈指针
pop ebp
pop ebx
pop edi
pop esi
ret ; 返回到上面switch_to下面的那句注释的返回地址,
; 未由中断进入,第一次执行时会返回到kernel_thread

    调度函数中用作切换的switch_to是由汇编语言编写的。其过程为:

保存现场 > 切换栈帧 > 恢复现场 > 返回线程

    之所以ret对应了返回地址,是因为上一个线程在被调度时调用了schedule将自身保存在自己的栈中,在切换回原本的栈帧以后便能够重新恢复。

注:kernel_thread中会先打开中断,然后跳转到对应线程。

    不过因为内核自己也是一个进程,所以在开始调度之前应该先为内核本身生成PCB:

1
2
3
4
5
6
static void make_main_thread(void) {
main_thread = running_thread();
init_thread(main_thread, "main", 31);
ASSERT(!elem_find(&thread_all_list, &main_thread->all_list_tag));
list_append(&thread_all_list, &main_thread->all_list_tag);
}

    另外再注册时钟中断函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
static void intr_timer_handler(void) {
struct task_struct* cur_thread = running_thread();
ASSERT(cur_thread->stack_magic == 0x19870916); // 检查栈是否溢出

cur_thread->elapsed_ticks++; // 记录此线程占用的cpu时间嘀
ticks++; //从内核第一次处理时间中断后开始至今的滴哒数,内核态和用户态总共的嘀哒数

if (cur_thread->ticks == 0) { // 若进程时间片用完就开始调度新的进程上cpu
schedule();
} else { // 将当前进程的时间片-1
cur_thread->ticks--;
}
}

    多嘴一句,中断默认情况下是关闭的。且每次进入中断以后,处理器会自动关中断,直到执行“iret”指令或者手动开启中断(本质上应该是恢复eflags寄存器)。

    所以schedule函数第一行能够成立:

1
ASSERT(intr_get_status() == INTR_OFF);

    一般的中断调用结束时会调用iret指令恢复eflags寄存器来重开中断。所以一个隐蔽的情况是:(其实调试一下应该就能明白)

调度程序的switch_to函数第一次调度时返回到kernel_thread,在该函数中开启中断;而在此后的调度中,会返回到jmp intr_exit指令出,在之后的iret指令下恢复eflags寄存器,从而开启中断。

插画ID:75919964