操作系统(四) -- 用户级线程与核心级线程(线程的切换)

为什么要说线程的切换

操作系统是多进程的,我们关注的应该是进程之间的切换,那为什么关注线程的切换呢?因为理解了线程的切换之后可以更好的理解进程的切换,换句话说线程的切换是进程切换的基础。

每一个进程都包含一个映射表,如果进程切换了,那么程序选择的映射表肯定也不一样;进程的切换其实是包含两个部分的,第一个指令的切换,第二个映射表的切换。指令的切换就是从这段程序跳到另外一段程序执行,映射表切换就是执行不同的进程,所选择的映射表不一样。线程的切换只有指令的切换,同处于一个进程里面,不存在映射表的切换。进程的切换就是在线程切换的基础上加上映射表的切换。

线程的引入

多个进程可以“同时”执行,其实也就是换着执行,那么在同一个进程里面,不同的代码段能不能换着执行呢?比如在进程A里面有代码段1、代码段2、代码段3;能不能先执行一下代码段1,然后执行一下代码段3,再执行一下代码段2呢?答案是可以的。进程的切换包括指令的切换和映射表的切换,那么同一个进程里面就没必要进行映射表的切换了,即只需要切换指令就可以了。上面所说的代码段其实就称为“线程”。

前面说了多线程只需要进行指令的切换就可以了;这样相对于进程来说,多线程保留了多进程的优点:并发。避免了进程切换的代价(切换映射表需要耗费比较多的时间)。如果能够将多线程的切换弄明白,那么多进程的切换其实也就直剩下了映射表的切换,这是典型的“分而治之”。

用户级线程

用户级线程作用举例

以前网速比较慢的时候,打开浏览器访问一个网页,首先弹出来的是网页的文字部分,然后是一些图片,最后才是一些小视频之类的。为什么呢?浏览器向服务器发起访问的程序是一个进程,它包含若干线程,比如:一个线程用来从服务器接收数据,一个线程用来显示文本,一个线程用来显示文本,一个线程用来显示图片等等。在网速比较慢的时候用来从服务器接收数据的线程要执行的时间比较长,因为一些图片和视频都比较大。如果要等这个线程运行完了之后再显示,那么电脑屏幕就会有一段时间什么东西都没有,这样用户体验就会比较差;一个比较合理的办法是:接受数据的线程接受完文本东西之后,就调用显示文本的线程将数据显示出来,然后再接受图片再显示,再接受视频再显示;这样至少可以保证电脑屏幕上始终有东西;相比前面的方法好很多,当然最根本的办法还是提高网速。

还有一个问题,为什么浏览器
向服务器请求数据的程序是一个进程,而不是多个?浏览器接受服务器的数据肯定都是存储在一个缓冲区里面的,并且这个缓冲区是共享的,如果是多个进程,那么肯定有多个映射表,也就是说如果程序里面存储数据的地址是连续的,经过不同的映射表之后,就会分布在内存的不同区域,这样肯定没有在一块地方好处理呀。

上面这个例子就牵涉到线程(用户级线程)的切换,也可以看出线程并不是一个无意义的概念,而是有实际作用的。
下面说一下线程之间到底是如何切换的,其实主要是切过去之后还要能够切回来。

两个线程与一个栈。。。

线程一

100:A()
{
	B();
	104:
}
200: B()
{
	Yield1();	// 切换线程
	204:
}

线程二

300:C()
{
	D();
	304:
}
400: D()
{
	Yield2();
	404:
}

按照这个执行一下,首先从线程一的A函数开始,调用B函数,将B函数的返回地址:104压栈,然后进入B函数;在B函数内部使用Yield1切换到线程二的C()函数里面去,同时将Yield1的返回地址压栈,此时栈中的数据如下:

104 204

Yield的伪代码应该是:

void Yield1()
{
	find 300;
	jmp 300;
}

现在执行到了线程二,计划是在D函数里面通过Yield2跳到线程一的204这个地址,完成线程的切换。调用c函数,同时将304这个地址压栈,跳到D函数里面执行,在D函数里面调用Yield2,同时将404压栈。Yield2的伪代码应该是:

void Yield2()
{
	find 204;
	jmp 204;
}

目前栈里面的数据应该是:

104 204 304 404

跳到204之后,接着执行B函数剩下的内容,执行完内容之后,执行函数B的"}"相当于ret,弹栈,此时栈顶的地址是404,B函数应该是返回到104处,而不是404处;这里就出现了问题。怎么处理?

从一个栈到两个栈。。。

处理方法是使用两个栈,在不同的线程里面使用不同的栈。在线程一中使用栈一,线程二中使用栈二。这是一个伟大的发明。

重新执行一下上面那个程序,从A函数开始执行,在B函数里面调用Yield1进入线程二的C函数之后,线程一对应的栈一中的内容应该是:

104  204

执行到D函数的Yield2之后,线程二对应的栈二的内容应该是:

304 404

在Yield2里面做的第一件事就应该是切换栈,如何切换?肯定需要一个数据结构将原来栈一的地址保存起来,这个数据结构就是TCB(Thread control block ) ;当前栈的栈顶地址是存放在cpu里面的esp寄存器里面的, 因此只需要改变esp的值就可以切换栈了。

void Yield2()
{
	TCB2.esp = esp;			// 保存当前栈顶地址
	esp = TCB1.esp;			// 切换栈
	jmp 204;				
}

jmp到204之后,执行完B函数剩下的代码之后执行B函数的"}",即弹栈,这时栈顶是204,也就是又跳到204去了,显然有问题,但是比前面已经好很多了,因为不会跳到另外一个线程里去。那现在为什么会这样呢?原因是Yield2()直接跳到204之后,而没有将栈中的204弹出去,如果Yield2跳到204这个位置,同时将栈中的204弹出去就好了。其实这个可以实现,修改
Yield2如下:

void Yield2()
{
	TCB2.esp = esp;			// 保存当前栈顶地址
	esp = TCB1.esp;			// 切换栈
}

没错,就是将jmp 204去掉就可以了,利用Yield2的"}“弹栈同时跳到204地址处, 执行完B函数之后, 通过B函数的”}"再次弹栈到104处,完美。

核心级线程

首先来看第一个问题:

多处理器和多核的区别:

在这里插入图片描述
多处理器每一个cpu都有一套自己的MMU。多核是所有的CPU共用一套MMU,也就是多个CPU的内存映射关系是一致的。

多核就有种单个进程的概念,在这个进程内部所有的线程都是共用一套MMU的。多处理器就有种多进程的概念,每个CPU的MMU都不一样。因此对于同一个进程来说,多核可以同时执行这个进程里面的线程,但是多处理器不行,只有多线程才能将多核利用起来,因为现在电脑都是多核的,所以这是多线程的一大用处。这里的线程指的是核心级线程。核心级线程可以将每一个线程对应到具体的cpu上。

核心级线程与用户级线程有什么区别呢?

核心级线程需要在用户态和核心态里面跑,在用户态里跑需要一个用户栈,在核心态里面跑需要一个核心栈。用户栈和核心栈合起来称为一套栈,这就是核心级线程与用户级线程一个很重要的区别,从一个栈变成了一套栈。用户级线程用TCB切换栈的时候是在一个栈与另外一个栈之间切换,核心级线程就是在一套栈与另外一套栈之间的切换(核心级线程切换),核心级线程的TCB应该是在内核态里面。

用户栈与内核栈之间的关联:

内核栈什么时候出现?当线程进入内核的时候就应该建立一个属于这个线程的内核栈,那么线程是如何进入系统内核的?通过INT中断。当线程下一次进入内核的时候,操作系统可以根据一些硬件寄存器来知道这个哪个线程,它对应的内核栈在哪里。同时会将用户态的栈的位置(SS、SP)和程序执行到哪个地方了(CS、IP)都压入内核栈。等线程在内核里面执行完(也就是IRET指令)之后就根据进入时存入的SS、SP的值找到用户态中对应栈的位置,根据存入的CS、IP的值找到程序执行到哪个地方。
在这里插入图片描述
看一个例子:

100:A()
{
	B();
	104:
}

200:B()
{
	read();
	204:
}

300:read()
{
	int 0x80;
	304:
}

---------------------------------

system_call:
	call sys_read;
1000:
2000:sys_read(){}

上面的“-----”表示用户态和核心态的分界;首先该线程调用B函数,将104压栈(用户栈),进入B函数之后调用read()这个系统调用,同时将204压栈(用户栈),进入read()系统调用通过int0x80这个中断号进入内核态,执行到

sys_read()
{	
	读磁盘;
	将自己变成阻塞状态;
	找到next(下一个执行的线程);
	调用switch_to(cur,next);
}

switch_to()方法就是切换线程,形参cur表示当前线程的TCB,next表示下一个执行线程的TCB。这个函数首先将目前esp寄存器的值存入cur.TCB.esp,将next.TCB.esp放入esp寄存器里面;其实就是从当前线程的内核栈切换到next线程的内核栈。这里要明白一件事,内核级线程自己的代码还是在用户态的,只是进入内核态完成系统调用,也就是逛一圈之后还是要回去执行的。因此切换到next线程就是要根据next线程的内核栈找到这个线程阻塞前执行到的位置,并接着执行。所以切换到next线程的内核栈之后应该通过一条包含IRET指令的语句进入到用户态执行这个线程的代码。这样就从cur线程切换到next线程。
在这里插入图片描述

核心级线程的实现实例

核心级线程的切换就是切换两套表,核心是内核栈的切换。

main()
{
	A();
	B();
}
A()
{
	fork();		// 系统调用
}

执行到A()调用的时候会将A函数的返回地址也就是B()函数的起始地址压入当前线程的用户栈中,然后转入fork()这个系统调用,在fork()中肯定会通过INT0x80这个中断号进入操作系统内核;进入内核的时候会将用户栈的位置以及当前程序的执行地址都压入到内核栈中,然后开始执行fork()。根据前面讲的系统调用知识可以知道,INT0x80的中断服务程序是_system_call这个宏定义,对于不同的系统调用_system_call执行的语句不同,对于fork()来说,如下:

_system_call:
	push %ds..%fs
	push1 %edx...
	call sys_fork
	push1 %eax

首先将一堆寄存器的值push到栈中,因为刚刚进入内核态的时候,这些寄存器的值都是用户态的内容,所以需要将这些值push到栈中为下一次这个线程的执行做准备。接着执行sys_fork,这个通过sys_call_table这张表找到的,这部分知识可以参考系统调用部分的东西。在sys_fork执行过程中可能需要切换到另外一个线程,它是如何切换的?其实也就是通过判断

movl _current;%eax
cmpl $0,state(%eax)
jne reschedule
cmp 1$0,counter(%eax)
je  reschedule
ret_from sys_call:

_current 指的是当前线程的TCB。

cmpl $0,state(%eax)
jne reschedule

判断当前线程的状态是不是0,如果不是就调度,即执行jne reschedule。

cmp1 $0,counter(%eax)
je  reschedule

判断当前线程的时间片是不是用完了,如果用完了也需要调度。

reschedule:
  pushl $ret_from_sys_call
  jmp schedule

调度程序首先将返回地址 ret_from_sys_call 压栈,接着执行调度函数 _schedule。具体的调度算法忽略,主要看
线程切换的过程。调度函数应该是下面这个样子,首先找到需要执行的下一个线程,然后转到该线程执行。

void schedule(void)
{
	next=i;				// 找到需要执行的下一个线程
	switch_ to(next);	// 转到该线程执行
}

执行完这个线程之后就会转到ret_from_sys_call执行

ret_from_sys_call:
	popl %eax...
	pop %fs
	iret

首先将进入内核时存储的寄存器的值弹栈,也就是将当前状态的寄存器值恢复到执行本线程的状态。接着利用iret转到用户态,接着执行。

到目前为止,线程的切换已经说完了,但是从线程一切换到线程二执行这部分没讲,也就是switch_to这个宏定义

#define  switch_ to(n)
  {struct{long a,b;}
  __asm__(
  movW %%dx,%1\n\t”
  ljmp %0\n\t"
  ::"m"(*&_tmp.a),
  "m(*&_tmp.b),
  "d"(_TSS(n))}

这是段内嵌汇编,是利用tss进行切换的,这种方式代码简单但是效率不高,因此window和稍高版本的linux都不是用的这种方法。这种方法主要就是通过

 ljmp %o\n\t

这条语句来实现的,这里牵涉到一个寄存器TR,保存的是gdt的选择子,也就是根据TR的值可以在gdt表中找到对应的tss描述符,tss描述符会指向一个段,这个段足够存储当前CPU的所有寄存器的值,当线程一执行到这里,会将cpu当前寄存器所有的值都存放在这个tss表中,然后

"d"(_TSS(n))

这条语句就是将线程二的tss段中的值赋给cpu的寄存器。其实就和看电视一样,如果从节目一切换到节目二,就要先将节目一切换前的一幕存在脑海里面,以便于下次再看这个节目的时候能“不间断”的看。

参考资料

哈工大李志军操作系统

  • 44
    点赞
  • 166
    收藏
    觉得还不错? 一键收藏
  • 13
    评论
评论 13
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值