其他分享
首页 > 其他分享> > 操作系统学习笔记5 | 用户级线程 && 内核级线程

操作系统学习笔记5 | 用户级线程 && 内核级线程

作者:互联网

在上一部分中,我们了解到操作系统实现多进程图像需要组织、切换、考虑进程之间的影响,组织就是用PCB的队列实现,用到了一些简单的数据结构知识。而本部分重点就是进程之间的切换。


参考资料:


1. 用户级线程

1.1 线程

我们聊的是进程切换,为什么引入了线程

本部分专注于线程的切换,而不关注资源的切换,而大的进程切换,需要结合内存管理来理解。

线程切换也是进程切换的一部分,学习线程的切换也就是在学习进程切换的一个部分。

可以理解为:进程切换=线程切换 + 内存映射表切换。

image.png

image.png

1.2 线程设计的实用性

上面提出了一种轻量化进程的想法,下面分析这种想法是否实用。

1.3 浏览器多线程的设计理解

尝试简单实现这个浏览器多线程模型,借此理解操作系统的实现方式.

//下面代码,启动了多个线程,同时出发
void WebExplorer(){
    char URL[] = "http://cms.hit.edu.cn"
    char buffer[1000];
    pthread_create(...,GetData, URL, buffer);
    pthread_create(...,Show, buffer);
    
}
// 从网站上下载数据包,如文本、图片
void GetData(char *URL, char *p){...};
// 到显示器上显示内容
void Show(char* p){...};

上面代码只讲了多个线程同时出发,下面还要实现线程之间的交替执行,才叫多线程,才能提高CPU利用率:

image.png

1.4 Create && Yield 函数理解

1.4.1 Yield && 线程切换机制

举个实际程序例子:

image.png

问题出在哪呢?

image.png

这个跟进程不能共享内存需要地址映射的原因相似,因而处理时采取的操作也类似:

1.4.2 Create

前面已经提到过了,Create 即做出切换所需的样子:

三样:栈 + TCB + 存放在栈中的返回地址

执行过程:

具体见下面代码:

image.png

1.5 梳理整合

上面Create 和 Yield 都明白后,多线程的情况也就基本实现了。可以把上面的浏览器代码和多线程组装在一起:

image.png

1.6 Yield 是用户程序

为什么这一部分是用户级线程呢?

而核心级线程 ThreadCreate 是系统调用,创建线程时,会进入内核;TCB也在内核中;此时各个线程对于操作系统是可见的,当 GetData 停滞,操作系统就会切换到其他线程(如Show)

内核级线程的并发性更好。

当然,对于内核级线程而言,此时 原先的 Yield 成为内核级的程序 Schedule,对于用户就不可访问了。

2. 内核级线程

Kernel Threads.

回到我们提出线程的初心,是想分而治之地实现进程切换,线程切换即进程切换中的指令流切换,而在用户级的线程无法实现进程切换的全部特征(因为进程是在内核中的,用户级线程无法深入内核)。

我们可以通过用户级线程理解线程的相关特点,而前文所说的:进程切换 = 线程切换 + 资源切换(内存管理)中的线程,实际上应当是 内核级线程。

而实际上,用户级线程也是内核级线程的一部分。

这部分就主要来看如何切换内核级线程

2.1 为什么会有内核级线程

核心级线程的优点和必要性原因有很多,我们只挑其中一个讲解。

下图展示的就是,用户程序级别的函数只有通过内核,才能分配到底层的核中合理使用。

image.png

2.2 内核级线程实现原理

内核级线程和用户级线程的区别:

一套栈是指每个内核级线程有一个用户栈和一个内核栈;进行两个内核级线程的切换就需要切换的两个线程的栈,即两套栈。

关于用户栈和内核栈:

  • 我们平时使用的是在用户级别的程序,所以还是需要用户栈;
  • 而进行内核级线程切换需要进入内核,并且内核中相应也有函数调用,所以需要内核栈。

什么时候会出现内核栈呢?

收到中断,内核态启用后需要压栈,需要在内核栈中先依次压入源SS、源SP、EFLAGS、源PC、源CS等内容

image.png

源SS和源SP是指向用户栈的指针,也就是说内核栈中存放了指向用户栈的指针;源PC和源CS是用户栈中的返回地址;(这点非常重要!

SS:存放栈顶段地址,SP:存放栈顶偏移地址

EFLAGS 是标志寄存器,用于存放一些标志位,保存当前运行状态;比如进位,溢出,奇偶等等,详见:X86标志寄存器EFLAGS详解

从内核态返回用户态的时候(IRET中断返回指令),会从内核栈弹出这些信息,根据这些信息就可以恢复到用户栈;

2.3 内核级线程原理举例

通过一个例子来看看通过中断进入内核级线程,过程是什么样的。

image.png

那么如何实现内核级线程切换呢?

如何实现 线程T 栈的切换(即拿到内核栈向用户态切换)?

image.png

2.4 内核线程 switch_to 的五段论

这里对 2.3 中的内容再做一次梳理,归纳整理为五个部分。

  1. 中断入口:由 线程A 的 用户态代码进入内核,才能完成内核栈与用户栈的联系

  2. 中断处理:引发线程切换,找到下一个线程的TCB

    这里引发线程切换的原因有很多,比如操作系统的分时系统,当时钟中断时,也会引出 schedule。

  3. 根据 找到的TCB ,调用 switch_to;

  4. 内核栈切换

  5. 中断出口:使用 iret 内核栈返回到线程 B 的用户栈和用户态代码

    下部分会再详细讲解这部分的代码。

image.png

2.5 ThreadCreate 的实现原理

跟用户级线程一样,讲完线程切换的机制,就要说一说线程的初始化与创建机制(1.4.2的 Create)。

回忆上文 内核栈线程切换 所需的结构:

将它们做成可以如上文切换的样子就可以;

image.png

2.6 简单总结

用户级线程、核心级线程的对比,用户级线程和核心级线程搭配的效果最好;

image.png

3. 内核级线程实现

这部分来讲内核级线程的具体代码实现。

linux0.11 不支持内核级线程,但是进程和内核级线程非常像,只是没有资源切换。大实验就是在0.11内核上实现内核级线程;

内核级线程的切换过程对用户程序来说是透明的,用户可以看见的只是用户栈、用户级线程的切换。

image.png

3.1 中断入口:进入内核

3.1.1 int 0x80

这一部分应当与上文2.3结合着看。

前面提到过,进入内核靠的是中断,引起中断的原因有很多:系统调用、时钟中断、键盘中断等;我们比较熟悉的是系统调用引起的中断,比如 fork() 这个系统调用。

fork,创建系统线程调用;从这里进去内核就可以看到创建线程、切换线程两个过程。

代码:

main(){
    A();
    B();
}
A(){
    fork();
}

image.png

3.1.2 _system_call

前面几讲讲过,int0x80是由 system_call 函数来处理的。

这里需要复习第二讲,操作系统接口。当时以 whoami为例讲解了上层用户程序 到 sys_write 级别函数的工作机制。

下面来看看 system_call 做了什么:

到这里就是五段论中的第一段:从用户栈到内核栈的关联建立;接下来需要在内核中进行中间三段。

3.2 中间三段:进行切换

这部分就是五段论中的中间三段

泛化一点讲,进行 sys_fork 级别的功能代码时,可能就会因为需要等待磁盘、键盘等设备响应而引起系统的阻塞,或者遇到系统的时钟阻塞,进而需要进行线程切换

上文的例子中,sys_fork 的功能代码本身并不会引起阻塞(要想阻塞只会是类似于系统的时钟阻塞),所以在代码 call sys_fork 后会继续向下执行,会有如下判断程序:

mov1 _current,%eax
cmpl $0,state(%eax)
jne reschedule
cmpl $0,counter(%eax)
je reschedule
ret_from_sys_call:

注意这里是AT&T汇编,和 intel 汇编(王爽汇编)刚好反着的。

state 此前存放在PCB中:

current是当前的PCB结构体的地址。state是该结构体的一个成员,如果当前 state≠0,表示当前进程阻塞,就进行 系统重新调度(schedule),实现线程切换:reschedule;至于下面又出现了 cmpl $0,counter(%eax),并跟着一句 je;意思是判断操作系统的时间片是否用光,用光了也需要切换。

切换完后,就是五段论的第五段中断出口 iret :ret_from_sys_call。见 3.3 中断返回。

reschedule 的汇编代码:

reschedule:
	push1 $ret_from_sys_call
	jmp _schedule

可见放入地址后,调用 _schedule 这个C函数;当C函数返回,就会从栈中弹出,执行 上文的 ret_from_sys_call. 返回到用户层。

image.png

image.png

那么 schedule 引发了什么:

  1. 调度 next,依据一定的规则找到下一个线程/进程以及PCB,这个规则后面专门用一篇笔记来学习。

  2. switch_to,linux0.11目前不是用内核栈切换的,而是通过TSS,tasks struct segment进行切换的,实验四是要将其变成基于 内核栈Kernel Thread 的切换。

    前者实现原理简单,但效率较低;后者是现代操作系统普遍使用的方法。

3.2.1 TSS 切换

intel CPU架构已经实现了这种方式,代码只需要 ljmp一句即可;下面看看这种跳转方式:

image.png

参考资料:《Linux内核完全解析》第五章

具体代码如下,是一段内嵌汇编,具体语义不细说了,就是上面的过程。

image.png image.png

分析:

3.2.2 内核栈切换

这部分在实验四中,具体实现在实验中体会。

3.3 中断返回:返回用户态

前面3.2中3.2.1 之前的部分讲解到,ret_from_sys_call会实现返回用户层。

ret_from_sys_call:
	pop1 %eax
	pop %fs... ###一堆pop弹栈,弹出用户的东西
	iret ###最核心代码,返回到用户层

3.4 ThreadCreate | fork

3.4.1 fork 过程 && copy_process参数表

前文3.1.2中讲到了 sys_fork 的功能代码,下面继续接着这里向更深处讲解:

fork实际上是 linux 中创建子进程的一种方式,创建子进程,所以可以通过 fork 这个系统调用看看 ThreadCreate 的过程。

fork实际上要做的是,把原来的进程一分为二,一条为调用fork的父进程,另一条是父进程调用fork函数copy出来的子进程。

形状上像一个叉子。

sys_fork的功能代码如下图:

image.png

3.4.2 copy_proces 的细节

回忆一下前文2.5中的创建进程需要做的事情:

  • 申请内存空间
  • 创建TCB
  • 创建内核栈和用户栈
  • 填写两个栈
  • 关联栈和TCB

copy_process 要做的事情是:

image.png

接下来初始化TSS,即使用copy_process传入的参数来初始化子进程的TSS。

看下面我对于整个过程的梳理

image.png

nr不是地址,而是task数组中空闲task_struct的标号!

下面梳理一下整个父子进程的过程:

  1. 在A函数中运行,遇到了fork,也就是父进程执行如下汇编代码(3.1.1~3.2的例子,以及其对应的汇编)

    # 首先将__NR_fork(即系统调用 fork 的编号)放入 eax 寄存器
    mov %eax,__NR_fork
    # 然后就是int 0x80中断指令,开始做进入内核态的准备工作
    INT 0X80
    mov res,%eax
    
  2. fork 执行完毕,新建了一个子进程,此时还没有执行子进程。则int指令结束,返回,接下来会执行 3.1.2 中的 _system_call

  3. 虽说fork的功能代码不会引发阻塞,但是我们假设其引发了阻塞(比如系统的时间片用完了,需要切换),于是system_call检测到阻塞,的调度下一个进程后,进行 switch_to

  4. switch_to 切换到子进程,子进程进行3.2.1 中的TSS切换,由于前文提过的父子进程的eip相同,则子进程开始就工作上面汇编代码的最后一句 mov res,%eax,这里子进程的eax已经被赋值为0

操作系统学习笔记4中的4.1 中提到过if(!fork()){...}的经典语句。

子进程调用fork()的返回值是0,父进程调用fork()的返回值是1,所以通常会用fork()的返回值区分是父进程还是子进程来编写代码。

达成的效果就是:

  • 子进程执行 if 语句段内的语句;父进程跳过不执行;
  • 形成了一个叉子(fork)状的分叉

3.5 如何执行我们的代码\实际举例

上述过程已经回扣了例子,可能还是抽象,用操作系统学习笔记4中 终端shell 的例子再说明一下。

下图可见,子进程通过 if语句结构,在父进程的壳子里(shell终端),执行自己的功能,比如 ls (子进程)在终端(父进程)里,但是执行自己显示目录的功能。

简单看看 exec 的系统调用讲解(此时就是子进程了):

sys_execve(最后这里没太懂原理,涉及了编译)

image.png

image.png

4. 总结

标签:fork,用户,线程,内核,&&,进程,切换
来源: https://www.cnblogs.com/Roboduster/p/16622413.html