学习完操作系统的基本原理和调度的相关知识后,开始学习进程的上下文切换。 本章主要需要理解的内容:
用户级线程上下文切换
在上面线程的基本概念中,我们讨论了什么是多线程,线程又分为用户级线程和内核级线程。 本节我们首先讨论什么是用户级线程以及用户级线程的底层原理。 用户级线程的切换是由我们用户主动控制的。 现在我们假设有两个线程,线程1和线程2(图中红色数字是内存的地址)
线程1中有两个函数A()和B(),执行过程是A()函数调用B()函数。 B()函数执行完毕后,返回到地址为104的语句处继续执行。
线程2中有两个函数C()和D(),执行过程是C()函数调用D()函数。 D()函数执行完后,返回到地址为304的语句处继续执行。
那么图中的Yield()函数是什么呢? 简单来说就是我们用户主动控制线程切换的功能。 如果在线程1中调用Yield()函数,此时就会切换到线程2。 Yield()函数在线程2中被调用,此时会返回线程1继续执行。 因此,执行流程如下图所示。
现在让我们更深入地分析一下整个切换过程中发生了哪些有趣的事情,用我们传统的方式:
此时根据调用关系,从D退出时应该从404返回,而不是返回104执行。 错误原因是两个线程共享一个栈,导致线程间切换和内部操作。 问题,所以这个问题可以采用两种解决方案,即为每个线程分配一个独立的堆栈。 还是上面的例子,线程1和线程2都有自己特有的堆栈,各种堆栈地址都放在各自线程的TCB中,过程如下:
更多Linux内核视频教程和文档免费接收后台私信【内核】自行获取。
当线程2执行Yield函数时,全局堆栈指示变量会执行线程1的堆栈,此时进行出栈操作。 当线程1的栈退出时,弹出204,转而去204执行,然后调用B后,继续执行线程1的栈,此时就出现了a带来的混乱问题堆栈完美解决。 可见,用户级线程是基于在用户态创建一组维护的用户栈来实现进程之间的切换的。 其特点如下:
但其缺点也很明显。 对于多核CPU来说,同一个进程中的多个线程无法做到真正的并发和并行。 如果进程中的一个线程进入核心并阻塞,则进程中的其他线程将不会被执行。 例如,如果内核进程需要等待网卡的IO,这需要很长时间并导致进程阻塞,那么用户态下的多线程将不起作用。
内核级线程上下文切换
现在操作系统是多核的,为了充分发挥操作系统的并行能力,所以采用一个用户线程来映射到一个内核级线程,也就是说一个用户线程必须创建一个内核级线程,由此产生的时间 级线程的开销增加,因此系统中线程的数量会受到限制,目前的操作系统如 /linux 均采用这种模型。
我们还是用实际例子来说明操作系统是如何完成内核级线程上下文切换的原理的。 每个用户级线程需要一个栈,而内核级线程则需要一组栈,即两个栈:用户栈+内核栈,还是之前的A、B、C的例子
线程1,函数A调用B。首先,此时应该将104的地址压入线程1的堆栈中。 此时104进入用户栈。 对于B函数,会调用read接口。 这时204就会被压入栈中。 因为 read 接口是一个系统调用。 这时,用户空间必须立即进入内核态。
当进入内核态时,需要保存此时用户空间的状态,以便返回用户空间时可以使用。 首先将栈段寄存器SS(存储段地址、基地址)和栈指针SP(寄存器存储偏移地址),同时将标志寄存器()放入内核的栈中,然后运行用户态用户那里,包括此时运行的PC和下一条指令,页面为PC(304)、CS、IP
然后执行并进入内核态,开始读盘,将自身转为阻塞状态,然后将进程切换到另一个进程运行,并使用()进行内核线程调度,并切换堆栈
整个流程可以如下图所示理解:
因此,对于内核级线程来说,分为用户态和内核态。 例如1、用户进程中有线程A和线程B。 它们共享进程的内存空间,并有自己的用户栈来存储自己的调用进程。 同时在内核空间中,有自己的PCB,但是每个进程都有一个内核堆栈
进程切换时机
一个流程由哪些部分组成? 主要包括用户空间和内核空间,如下图所示:
这在linux进程管理章节中已经详细介绍过。 OS为了切换进程,首先要获得控制权,主要有以下几种情况获得
进程切换
基于内核栈的进程切换的基本思想:
1.当进程从用户态进入内核态时,主要是通过系统调用或者中断,会引起堆栈切换,用户信息会被压入内核堆栈,包括用户的堆栈指针、PC和程序状态此时。 在内核堆栈中
2、进入内核后,由于某些原因,因为进程需要读取磁盘或者网络信息,导致阻塞,或者时间片用完。 这时候就需要让出CPU,重新开始调度。 操作系统会找到新的工艺PCB并完成新工艺PCB的切换
3、当新进程的切换完成后,内核也完成了内核堆栈的切换,那么当中断返回时,执行IRET,弹出新进程的EIP,从而跳转到用户指令执行的新流程。
这次切换的核心是构建内核堆栈的外观,将堆栈压入适当的地方,返回适当的地方的地址,并根据内核堆栈的外观编写相应的汇编代码,完成压入和退出内核堆栈的操作,从而保证进程切换的顺利完成。
4.1 中断入口
操作系统负责进程的调度和切换,因此进程的切换必须发生在内核中,而用户程序运行在内核态,因此需要使用系统调用进入内核态。 主要伪代码如下:
push ds;
mov ds, 内核段号
system_call
4.2 中断处理
当用户态进入内核态时,需要发生堆栈切换。 该系统调用的核心指令是X86的int 0x80指令,该系统调用被中断。 当执行int 0x80语句,用户态进入内核态时,CPU会自动将这些寄存器的值按***SS、ESP、CS、EIP***的顺序压入内核堆栈,由于int 0x80的执行还没有进入内核,所以压入内核堆栈的这5个寄存器的值就是用户态下的值,其中***EIP*就是下一条语句“=a”( __res) of int 0x80 ,该语句的含义是将 eax 表示的寄存器的值放入 _res 变量中。 所以当应用程序返回内核时,语句“=a”(__res)会继续执行。 **这个过程完成了进程切换的第一步。 用户栈和内核栈之间的连接是通过将用户栈的ss和esp压入内核栈来建立的。 形象地说,就是在用户栈和内核栈之间拉一条线,形成一组栈。
中执行相应的系统调用后,将函数的返回值eax压入堆栈。 如果引发调度,则跳转执行。 否则执行。
执行前会被压栈,因为是c函数,所以c函数末尾的}相当于ret指令,会弹出作为返回地址,跳转执行。总之,之后系统调用结束,中断返回前,内核栈主要是SS:SP指向用户栈,标志寄存器,返回地址EIP,还有一些其他的:EAX,EBX等,如下图
4.3 查找当前进程的PCB和新进程的PCB
当前进程的PCB 当前进程的PCB是由一个全局变量*(在sched.c中定义)*指向的,所以它指向当前进程的PCB,pnext指向下一个进程的PCB 。 ()*函数中,调用函数*(pent,_LDT(next))*时,返回地址**}***,参数2***_LDT(next)***,参数1*pnext **入栈。当执行**的返回指令ret时,返回pop()函数的},执行*()*函数的返回指令
4.4
对于中,就是取出代表下一道工序的PCB参数,与当前的进行比较。 如果是当前的,则不执行任何操作; 如果不等于当前,则启动进程切换,完成PCB切换、内核堆栈切换等。
在.c中定义一个全局变量如*tss=&(.task.tss),即进程0的tss,所有进程共享这个tss,任务切换时不会改变。 虽然所有进程共享一个tss,但是不同进程的内核栈是不同的,所以每次进程切换时,tss中esp0的值都需要更新为指向新进程的内核栈,并指向新进程的内核栈。 new进程的内核栈底,即保证此时的内核栈为空栈,并且帧指针和栈指针都指向内核栈底。
4.5 中断退出
PC切换 对于切换出的进程,当再次调度时,根据切换出进程的内核堆栈的出现情况,最后一条指令ret会弹出下面的指令,作为返回地址继续执行,弹出作为返回地址,继续在 中进行一些处理,最后执行 iret 命令返回终端,弹出原来用户进程被中断的地方作为返回地址,在中断的地方继续执行。
5. 总结
进程切换和我们熟悉的“模式切换”不同。 模式切换时,CPU仍然运行在同一个进程中或者中断上下文; 而进程切换是指CPU转向另一个进程执行。 进程切换改变当前进程空间,其主要工作如下:
保留当前进程的硬件上下文(PC/SP和通用寄存器等)。 对于Linux系统来说,其大部分硬件上下文都存储在 中,但通用寄存器等存储在内核堆栈中
修改当前进程的PCB,例如将其状态从运行更改为就绪或等待,并将进程PCB添加到相关队列中
调度另一个进程,修改被调度进程的PCB,将其状态改为运行中
将“当前进程”的管理数据更改为调度进程的存储数据,如页表、TLB,并恢复新进程的硬件上下文,以便PC可以执行新进程的代码