xv6 中的进程切换:MIT6.s081/6.828 lectrue11:Scheduling 以及 Lab6 Thread 心得
絮絮叨
这两节主要介绍 xv6 中的线程切换,首先预警说明,这节课程的容量和第 5/6 节:进程的用户态到内核态的切换一样,细节多到爆炸,连我自己复习时都有点懵,看来以后不能偷懒了,学完课程之后要马上写博客总结。但是不要怕,这节的内容真的特别有趣!
同时,再次强调这个系列的博客(其实包括我所有的博客)都不是课程的中文翻译或者简单摘抄,是我学完课程之后的思考和总结,是综合了视频、xv6 book、xv6 源码以及大量资料后的思考和总结的成果,写博客真的好累啊,点收藏的小伙伴如果觉得俺写的还行顺便点个赞呗,这个点赞-收藏比如此悬殊让我有点绷不住 233333,刚从知乎上学了个金句:“反正收藏了你也不看,点赞意思下得了。。。”~如果想要看课程中文翻译的童鞋点[这里](11.1 线程(Thread)概述 - MIT6.S081 (gitbook.io))
ps:xv6 中一个进程只包含一个线程,所以老师在讲课时并没有特意区分进程和线程(值得吐槽),本文中也是混用的,,但是要记住本节讲的是进程的切换,如果出现线程字样,也请理解为进程。
引言
说到进程切换,我相信即使是计算机的初学者,都能说上一句:
“ 就是上下文切换嘛!保存 A 线程的各种寄存器信息等,然后恢复 B 进程的相应信息,这样就由 A 进程切换到了 B 进程。”
这样的回答没有任何问题,上下文切换确实是线程切换最核心的思想,但是线程切换的一个重要特点是:思想很简单,工程实现十分晦涩,连 xv6 book 都承认这部分代码是整个 xv6 中 最晦涩难懂的一部分代码:
the implementation is some of the most opaque code in xv6.
但还是那句话,魔鬼隐藏在细节中,之所以实现如此晦涩,是因为线程切换面临以下几个难点:
- 切换线程时,保存线程的哪些信息?在哪保存它们?
- 什么时候切换线程?是当前线程自愿让出 cpu 还是 cpu 强制其让出?如何自愿?如何强制?
- 线程切换对于用户进程如何实现透明?比如你有一个单核 cpu,需要运行 2 个进程,那么这 2 个进程一定是都认为自己独占 cpu(就像认为自己独占内存一样),如何做到这一点?
- 和上一个情况相反,如果有多个 cpu 核,但是只有一个待调度的进程,如何加锁来防止这个进程在多个 cpu 上运行?
以上几点如果要在工程代码中全部解决,确实需要花一番心思的,下面来看细节。
进程切换的细节
以两个计算密集型进程为例,我们讨论在不主动 yield(出让)cpu 的情况下,进程是如何切换的。
当一个进程运行的时间足够久,以至于硬件产生周期性的定时器中断,该中断信号传入内核,程序运行的控制权从用户空间代码切换到内核中的中断处理程序(注,因为中断处理程序优先级更高)
usertrap() 函数在第5/6节已经讲过,这是 xv6 内核空间中的一个函数,如果有中断、异常、系统调用发生,就会跳转到这里,在这里进行进一步判断并且运行相应的处理程序
RISC-V 中规定了如果是定时器中断 which_dev
的值会被置为 2,所以如果是定时器中断,就会运行函数yield()
,yield()
的作用是该内核线程自愿地将 cpu 出让(yield)给线程调度器,并告诉线程调度器:你可以让一些其他的线程运行了,下面是yield()
的实现,可以看到核心函数是 sched()
,并且将旧线程的状态由"RUNNING"
改为"RUNABLE"
,将一个正在运行的线程转换成了一个当前不在运行但随时可以再运行的线程。:
来看核心函数是 sched()
,其中核心函数是swtch()
,注意这里不是拼写错误,因为 switch 是 C 语言的关键字,不能作为函数名,所以采用 swtch 作为函数名:
swtch函数会保存用户进程P1对应内核线程的寄存器至P1的 context 对象。然后将 cpu 的 context 对象恢复到相应的寄存器中,因为需要直接和寄存器打交道,所以 swtch 函数的代码是用汇编写的:
可以看到所谓的内核线程寄存器就是指 ra、sp、s0~s11 这 14 个寄存器。a0 寄存器对应着 swtch 函数的第一个参数,也就是当前线程的 context 对象地址;a1 寄存器对应着 swtch 函数的第二个参数,也就是即将要切换到的调度器线程的 context 对象地址
为什么RISC-V中有32个寄存器,但是swtch函数中只保存并恢复了14个寄存器?
因为swtch函数是从C代码调用的,所以我们知道Caller Saved Register会被C编译器保存在当前的栈上。Caller Saved Register大概有15-18个,而我们在swtch函数中只需要处理C编译器不会保存,但是对于swtch函数又有用的一些寄存器。所以在切换线程的时候,我们只需要保存Callee Saved Register。
这里要特别注意 ra 和 sp 寄存器的值,这也是理解整个线程切换的关键的关键!!!
打印出保存之前的 ra 的值:可以发现是 sched 函数中的地址,这也符合逻辑,因为我们在 sched 函数中调用了 swtch 函数,在 swtch 函数中做的第一件事就是保存 ra 寄存器,这时 ra 寄存器的值是当前进程中 swtch 函数执行完毕后的地址,也就是 sched 函数中 swtch 函数的下一行的地址
再打印出恢复之后 ra 的值,现在 ra 寄存器的值是 0x80001f2e
,
打印出这个地址的指令,发现这个地址在 scheduler 函数中,意味着在 swtch 函数的后半部分:切换到调度器线程执行完毕后,函数会返回到 scheduler 函数中
完成 swtch 函数的后半部分:恢复从 cpu 中 14 个寄存器的值后,虽然依旧在 swtch 函数中,但已经不是 usertrap() -> yield() -> sched() -> swtch 这个链条上的 swtch 函数了,而是 scheduler() -> swtch 这个链条上的 swtch 函数;
至于 scheduler 函数什么时候运行并且调用了 swtch 函数,这里先大概说一下,下面会详解:scheduler 函数属于调度器线程,而调度器线程 是 xv6 系统启动的最后一环,所以调度器线程早就随着 xv6 的启动而启动了。
现在捋一下这个过程:
有两个进程 P1 和 P2, 1 个 CPU core, 先运行 P2,然后运行 P1,然后再运行 P2,那么 P1-> P2 是怎么切换的?
答:xv6 运行 P1 一段时间后,定时器中断被周期性触发,进程 P1 陷入内核态,在内核态中,保存公用寄存器(14 个)的状态到 P1->context 结构体中,然后恢复 cpu->context 结构体的数据到公用寄存器中,这样就把切换到了调度器线程,调度器线程会寻找一个进程状态为 RUNABLE 的进程(即 P2),将其状态修改为 RUNNING,然后调用 swtch 切换公用寄存器的状态为 P2->context,此时由调度器线程切换到 P2 的内核进程,接着返回到用户态,便完成了 P1->P2 的切换,如下图演示的这样:
这个过程中最妙的地方在于对于 ra 寄存器的巧妙使用,使 swtch 函数巧妙地返回到了调度器进程中,然后又巧妙地返回到另一个用户进程中,多到爆炸的细节见下图:尤其注意图中橙色的箭头就是 swtch 函数返回的路径。即 P1 的 shced 函数的 swtch 函数执行完毕后,就跳转到 scheduler 函数的 c->proc = 0
这一行开始执行。当 shceduler() 函数的 swtch 执行完毕后,就跳转到 P2 的 sched 函数的 swtch 函数的下一行开始执行。
具体细节见下图:
这里最妙的地方在于调度器进程是怎么保持连续性的,如下面的代码所示,scheduler 函数最核心的部分就是调用 swtch 函数,当进程 P1 由于终端切换到调度器进程时,会从地点 1 开始执行(因为 ra 指向地点 1),经过循环后到达地点 2,然后执行 swtch 后,离开 scheduler 函数,返回到 P2 的内核进程(因为 ra 指向该地址),下次又有中断需要切换进程时,又会从地点 1 开始执行,所以 scheduler 函数就是连贯的,遍历时 p 的值一直保存在调度器进程的 stack 中,并不会丢失。
线程除了寄存器以外的还有很多其他状态,它有变量,堆中的数据等等,但是所有的这些数据都在内存中,并且会保持不变。我们没有改变线程的任何栈或者堆数据。所以线程切换的过程中,cpu 中的寄存器是唯一的不稳定状态,且需要保存并恢复。而所有其他在内存中的数据会保存在内存中不被改变,所以不用特意保存并恢复。我们只是保存并恢复了cpu 中的寄存器,因为我们想在新的线程中也使用这组寄存器。
第一次调用 swtch 的特例
刚刚的过程我们已经看到了,当调用swtch函数的时候,实际上是从 P1 对于 swtch 的调用切换到了 P2 对于 switch 的调用(实际上是从 P1 对于 swtch 的调用切换到 调度器进程对于 swtch 的调用;从 调度器进程对于 swtch 的调用切换到 P2 对于 swtch 的调用,这里这么说只是为了宏观上的理解),为什么能从 cpu 的调度器线程切换到 P2 的内核进程呢?关键就是P2 的 context -> ra 的值是 P2 的 sched 函数的 swtch 函数的下一行,当这个地址被从 context 中恢复到 ra 寄存器中后,就会根据该地址返回到 P2 进程的 swtch 函数的下一行。
这里有一个关键问题就是如果 P2 进程是第一次被调度,那么 context->ra 的值就不会是 P2 的 sched 函数的 swtch 函数的下一行,原因也很简单啊,因为之前 P2 一定是在运行,然后主动或者被动调用了 yield() 函数,出让了 cpu,所以 ra 的值就保存了出让的那一刻的地址,也就是 P2 的 sched 函数的 swtch 函数的下一行,但是如果之前 P2 没有运行,而是第一次被调度,就需要我们手动设置 P2->context->ra 的值了,在 xv6 中,这个值在 allocproc() 函数中被设置为 p->context.ra = (uint64)forkret;
这个函数如下:
这个函数其实做的工作很简单,当调用 fork 函数分配的子进程准备好后,会先在池子中等待 scheduler() 函数调度,当呗调度后,就会返回到 forkret 函数中,在这个函数中返回到用户空间,这里其实也解释了为什么 fork() 函数可以一次调用,两次返回。
fork() 函数的实现
在第一节中我们就了解到,fork()函数是一次调用两次返回,在父进程中返回子进程 pid,在子进程中返回 0,所以fork 的典型用法就是:
这好像和我们 c 语言的是相反的,怎么可能一个函数调用一次,有两个返回值呢???
别急,学完前面的知识,我们就能理解这件事了,来看 fork 函数的实现:
刚刚说过,在 allocproc() 函数设置为 p->context.ra = (uint64)forkret;
根据 ra 的值,所以子进程将来被调度后,会返回到 forkret 函数中,进而返回到用户空间,并且子进程保存返回值的 trapframe->a0 被设置为 0,而父进程的 trapframe->a0 被设置为子进程的 pid
父进程遵循 c 语言的直觉,调用了 fork 函数,然后把自己的内存复制给子进程,并且返回了子进程的 pid,而子进程没有立即返回,而是等待 scheduler 调度,调度后返回到 forkret 函数进而返回到用户空间,并且由于父子进程的 trapframe page 是一样的,下一行的代码地址是由 trapframe page 的 epc 变量保存的,所以 pid_t pid = fork();
这行代码在父进程中被执行后,子进程也会执行这行代码,给 pid 重新赋值为 0。
所以总结一下就是两个要点:
- 由于复制了trapframe page ,所以
pid_t pid = fork();
会被父子进程都执行 - fork() 是系统调用,进入内核态后父进程会新建一个子进程,父子进程会分别从内核态返回到用户空间,父进程是系统调用的正常返回到用户空间,子进程由于生在内核空间,是由 scheduler 调用返回到 forkret 函数然后返回到用户空间
lab6 Thread 心得
这一节的三个小 lab 都是 morerate 级别的,思路和实现都比较简单
Uthread: switching between threads
设计并实现一个用户级别的线程切换机制,我理解就是为 xv6 实现多线程机制,其实相当于在用户态重新实现一遍 xv6 中的 scheduler() 和 swtch() 的功能,所以大多数代码都是可以借鉴的。
而且由于是“用户级”的线程,所以无需 trap 到内核态,只需要在进程中设置 n 个 thread 结构体,每个结构体都有空间保存自己的 context 即可。
也无需使用时钟中断来强制执行调度,由线程主动调用 yield() 来出让 cpu 、重新调度,这里的代码比较简单,就直接都贴出来了
Using threads
这个可以说是整个课程中最简单的 lab 了,要做的就是两点:
- 为 hashtable 加大表保证多线程操作的正确性;
- 降低锁的粒度,为每个 hashtable 的 bucket 加锁以提高并发性。
Barrier
这个 lab 但是挺有趣的,可以了解到了计算机中同步屏障机制是如何实现的。
简单来说,一段代码被多个线程执行,如何保证多个线程都到了其中某一点之后,才能继续往下执行?但是由于这个 lab 涉及到 lost wake-up 问题,所以我打算放到下一节一起复习~
ok,本节就到这里,本门课程最难的一节就复习完啦,按照 lab 的线索,接下来再写 3 篇,这个系列就收工~
对了我目前在寻找工作机会,本人计算机基础比较扎实,独立完成了 CSAPP(计算机组成)、MIT6.s081(操作系统)、MIT6.824(分布式)、Stanford CS144 NetWorking(计算机网络)、CMU15-445(数据库基础,leaderboard可查,唉,现在风气太浮躁了) 等硬核课程的所有 lab,如果有内推名额的大佬可私信我,我来发简历。
获得更好的阅读体验,这里是我的博客,欢迎访问:byFMH - 博客园
所有代码见:我的GitHub实现(记得切换到相应分支)
__EOF__

本文链接:https://www.cnblogs.com/looking-for-zihuatanejo/p/17682582.html
关于博主:评论和私信会在第一时间回复。或者直接私信我。
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角【推荐】一下。您的鼓励是博主的最大动力!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· DeepSeek 开源周回顾「GitHub 热点速览」