从01开始 从01开始
首页
  • 计算机科学导论
  • 数字电路
  • 计算机组成原理

    • 计算机组成原理-北大网课
  • 操作系统
  • Linux
  • Docker
  • 计算机网络
  • 计算机常识
  • Git
  • JavaSE
  • Java高级
  • JavaEE

    • Ant
    • Maven
    • Log4j
    • Junit
    • JDBC
    • XML-JSON
  • JavaWeb

    • 服务器软件
    • Servlet
  • Spring
  • 主流框架

    • Redis
    • Mybatis
    • Lucene
    • Elasticsearch
    • RabbitMQ
    • MyCat
    • Lombok
  • SpringMVC
  • SpringBoot
  • 学习网课的心得
  • 输入法
  • 节假日TodoList
  • 其他
  • 关于本站
  • 网站日记
  • 友人帐
  • 如何搭建一个博客
GitHub (opens new window)

peterjxl

人生如逆旅,我亦是行人
首页
  • 计算机科学导论
  • 数字电路
  • 计算机组成原理

    • 计算机组成原理-北大网课
  • 操作系统
  • Linux
  • Docker
  • 计算机网络
  • 计算机常识
  • Git
  • JavaSE
  • Java高级
  • JavaEE

    • Ant
    • Maven
    • Log4j
    • Junit
    • JDBC
    • XML-JSON
  • JavaWeb

    • 服务器软件
    • Servlet
  • Spring
  • 主流框架

    • Redis
    • Mybatis
    • Lucene
    • Elasticsearch
    • RabbitMQ
    • MyCat
    • Lombok
  • SpringMVC
  • SpringBoot
  • 学习网课的心得
  • 输入法
  • 节假日TodoList
  • 其他
  • 关于本站
  • 网站日记
  • 友人帐
  • 如何搭建一个博客
GitHub (opens new window)
  • 计算机历史

  • 数字电路

  • 计算机组成原理

  • 汇编语言

  • C语言

  • 数据结构

  • 操作系统

    • 我的操作系统学习笔记

      • 学习操作系统之前
      • 从操作系统启动开始
      • 操作系统接口
      • 操作系统历史与学习任务
      • 如何高效管理CPU:并发和进程
      • 如何支持多进程
      • 用户级线程
        • 引出线程的概念
        • 为什么有线程切换
        • 怎么实现多线程
        • Create和Yield函数要做什么,怎么做
        • 从一个栈到两个栈
        • Create函数应该做什么
        • 用户级线程调度小结
        • 引出核心级线程
    • 我的操作系统实验笔记

    • 操作系统网课-王道考研

    • 操作系统
  • Linux

  • 计算机网络

  • Git

  • 数据库

  • 计算机小知识

  • 编译原理

  • 名人堂

  • 计算机基础
  • 操作系统
  • 我的操作系统学习笔记
2022-11-09
目录

用户级线程

# 6. 用户级线程

上一讲我们讲了多进程图像,我们反复强调多进程图像是操作系统最核心的图像;我们还用Windows任务管理器来实际的看了一个操作系统里面的进程,操作系统将进程管理好了,也就管理好了CPU,也就带动着管理好了计算机。

我们还讲了操作系统如何支持多进程,并且合理有序的推进:

  1. 如何组织多进程:通过进程的状态+队列
  2. 如何切换多个进程,今天这门课会详细的讲
  3. 如何分离多个进程(避免进程之间互相影响)
  4. 多个进程如何合作

今天开始我们主要讲进程之间怎么切换。但为什么标题是线程呢?带着这个问题,我们开始今天的课程

‍

‍

# 引出线程的概念

我们再回顾一下多进程图像。

​​

进程PID1有一堆指令,mov和write,一个进程就是执行一堆指令的;

进程PID2也有一堆指令,执行的时候要访问的内存地址是通过映射表获得的。映射表对应的是内存,在程序执行需要的资源

在进程PID1执行的时候,可能遇到了磁盘读写,就得切换到进程PID2执行,也就是从一段指令执行序列,切换到另外一个指令序列;并且还要切换映射表。这样切换的代价是不小的

这里引出一个问题,我们能不能只切换执行序列,而不切换资源?这样肯定能让切换变的更快,因为只需要切换PC寄存器,而资源不用切换。这样既保留了并发的特点,又避免了进程切换的代价。

因此,我们得用到线程,在进程里可以启动多个指令序列,共用一套资源,指令序列看起来比较轻量级,我们叫它为线程,英文名thread。

也就是在一个资源下面启动了多个轻巧的指令序列,可以来回切换,并且切换的代价小;这就是本堂课重点讲解的内容,线程如何切换。

​​

现在我们可以回答课程一开始的问题:为什么讲进程切换,会扯到线程上?如果我们搞懂了线程的切换,后续再搞懂资源的切换,那么进程的切换也就搞懂了。这体现了分治的思想,我们先把问题拆成两个相对简单的问题,再组合起来。

‍

‍

‍

‍

# 为什么有线程切换

如果单纯为了分治,而线程没有任何实际作用的话,那么提出来也没有意思。但其实线程本身是很有价值的,非常实用,我们也很有必要讲解。

我们来举一个实际的例子,看看线程是否有用。我们以浏览器为例,例如我们访问某个网站,可以看到网站的内容是一部分一部分的加载出来的,可能是先看到文本,然后看到图片;而不是说打开网站后,页面是空白的,等了很久后页面突然全部加载好了(这样用户体验性很差)

这背后发生了什么事情?显然,首先得把数据从网站服务器上拉取下来,其中包括文本数据、图片数据和动画数据等。那么就得有一段任务去下载数据,还有一段任务去将下载好的数据显示到浏览器上。

如果这些任务都是一个程序做的话,那么就是先下载数据,等下载完后再显示,这样在一开始的时候屏幕上什么都没有,等下载完后,页面上才会突然显示全部数据;这对用户的交互性是很差的。通常下载这种网络传输是比较慢的,类似启动了IO

我们可以这样做:启动多个线程,一个用来下载文本,一个用来显示文本;一个用来下载图片,一个用来显示图片。那么我们就可以在下载图片和文本的时候,切换到显示文本和图片的线程去执行,这样就可以让网卡设备和CPU一起工作,提高利用率,并且用户的交互性也好了,这就是多道程序同时出发,交替执行;

同时,这样还有一个好处。我们从服务器上下载的数据,最终都是放到内存里的,而显示图片,显示文本这些程序,也是要从内存里去取;如果我们用进程的话,那么不同进程之间的数据还是隔离的,非常不方便;但如果这多个程序本身就是要合作的话,完全没必要内存隔离,可以在一套资源上做,共享资源。

​​

一个网页浏览器

  • 一个线程用来从服务器接收数据
  • 一个线程用来处理图片(如解压缩)
  • 一个线程用来显示文本
  • 一个线程用来显示图片

这些线程要共享资源吗?

  • 接收数据放在100处,显示时要读.........
  • 所有的文本、图片都显示在一个屏幕上

‍

通过这个例子,我们可以看到多线程是很有价值的,而且线程切换只是比进程切换少了一步(不涉及资源的切换),是进程切换的一个非常重要的部分,所以我们应该学习线程切换。

‍

‍

# 怎么实现多线程

我们要实现线程切换,首先得有多个线程。我们还是以浏览器为例,通过一个实际的例子,将线程切换的代码讲清除并写出来,那么操作系统的线程切换也就弄懂了

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){...};
1
2
3
4
5
6
7
8
9
10

我们讲讲这个代码。浏览器这个程序,首先申请了共享的缓冲区(第4行),然后创建了2个线程(第5,6行),每个线程执行一个函数,例如GetData就是下载数据到缓冲区的,Show就是展示缓冲区数据到浏览器上的。

‍

如果仅仅是创建了多个线程,但是不切换,也是没用的,这样仅仅是让多个指令序列同时出发,没有交替执行;怎么交替执行呢?在线程执行的过程中,我们得增加一些内容。

我们之前说过,如果遇到IO这种比较慢的指令,我们可以切换到其他程序让CPU执行,而不是让CPU空等IO(当然,磁盘IO我们目前讲不了,那个是内核级线程的内容,我们今天讲的是用户级线程),在这里我们如果要切换,我们得主动调用一个切换的函数。

也就是说,当GetData从网上下载了一段文本后,就调用一个函数(我们这里称之为Yield),然后就切换到显示文本的线程,然后浏览器就可以显示文本了。因此我们通过create函数产生多个执行序列,让Yield让线程交替执行,那么就可以做到并发了。

​​

‍

‍

# Create和Yield函数要做什么,怎么做

将Create和Yield讲清楚了,就可以创建多个线程并切换了,我们接下来就详细讲解这两个函数怎么写出来。其中核心是Yield函数

Yield的目的是完成切换,我们必须得先在脑海中明白,切换的时候应该做什么,我们才能写出代码来。

大家写操作系统这种复杂的代码时,可能无从下手,因为它确实很复杂。但是无论多复杂的程序都是一样的,在你的头脑中如果能形成这个样子,‍‍你剩下来的就用c语言把它表达出来,或者用汇编,用c语言或者用别的语言Python等等把它表达出来就可以了,‍‍写任何程序都是这样,写Yield,线程的切换也不例外

‍

而如果我们知道线程切换要怎么做,Create函数也就好写了。我们切换到一个线程刚开始被创建的地方,和切换到一个线程执行到中途的地方,其实没有太大的区别。我们在create的时候,只需要将线程弄成切换时应该有的样子,就清除了

‍

​​

‍

我们必须得以一个实际的例子来讲,大家不要凭空想,得实际的跑一跑,跑完就知道一个线程是怎么切换的了。接下来大家要集中注意力,因此两个执行序列的切换,在操作系统里面是最难的,最繁琐也是最不好理解的部分,而这个部分是操作系统真正的核心,是操作系统能运转起来的发送机。

‍

‍

‍

我们这里以两个线程切换为例,线程1和线程2. 线程1先执行A函数,然后调用B函数,B函数下面的104表示下一条指令的地址是104,后面的204和304,404同理。

‍

​​

我们开始讲解线程的切换了。线程1首先执行A函数,然后调用B函数,那么调用B函数会发生什么?我们学C和汇编时候讲过,调用完B函数后,将来得执行调用语句的下一行代码,这里是104处的代码,因此我们得将104压栈;然后就到了执行B函数了。这个是线程内部的函数调用,没什么可说的;

到了B函数,执行了Yield函数,这个Yield也是我们自己写的一个用户函数,那么调用Yield也是要压栈,因此把Yield的下一条指令的地址204压栈,然后调到线程2执行。此时栈的内容为104和204.

那Yield要做什么?Yield要做的是切换到下一个地方去执行,因此就是修改PC,例如这里就是找到要跳转到地方,然后jmp。

到了线程2,就是开始执行线程2的内容了。然后调用D函数,把D函数下一条指令的地址压栈,304;然后就是执行D函数,执行到Yield的时候,切换到线程1执行;那么就把Yield的下一条指令的地址压栈,404入栈。压栈后,就开始返回到204处执行,执行到目前,没什么问题

然后就往下执行的话,会遇到什么问题?等到B函数执行完后,学过汇编会知道,最后就会执行ret指令,因为我们是调用B函数,调用完后应该是回到调用后的下一条指令处去执行,也就是104;但目前,栈的内容是404,那么就会跳转到D函数去执行。这里就出错了。

本来ret是返回到104的,因为这是一个线程内部的函数调用,104和204是左边这个线程的内存地址,304和404是另一个线程的,函数的调用与返回应该是在一个线程里绕来绕去,怎么会跑到其他线程呢?

因为两个线程共用了一个栈,如果能把两个栈拆开来使用,那么函数调用和返回的时候,就是仅仅在自己的指令序列里跳转,不会跳转到其他线程。只有Yield才是会切换线程的。两个线程一个栈,会乱套。

‍

# 从一个栈到两个栈

一个函数调用是在一个指令序列内部发生的事情,每个指令序列里的函数调用,应该用自己的栈,那么这里就从一个栈变成了两个栈,这是一个非常伟大的过渡。

现在,我们来用两个栈的情况下,模拟线程的切换。

​​

‍

A函数往下执行,调用B函数的时候,将104压栈;在B函数里执行Yield,然后204压栈;

当执行线程2的指令序列的时候,用到了一个新的栈;调用D函数时,304压栈;然后在D函数里执行Yield的时候,404压栈,然后Yield就会切换到线程1里执行。切换的时候,首先应该做什么?应该把栈也切回去,因为切回去了,函数的调用和返回才是在线程1里跳转。那么栈的切换,就是把栈的指针切换回来;也就是说,在切换之前,就应该先把自己的栈的指针保存起来,这样切换回来的时候,才能找到;

那么保存在哪呢?这就因此了一个数据结构:TCB,thread control block。TCB是一个全局的数据结构,大家都能看到。

所以我们在线程1切换到线程2的时候,将线程1的栈的指针的值1000,存放到TCB1里;等线程2切换到线程1的时候,就从TCB1里找到这个栈的指针,然后将1000赋值给SP寄存器,这样就完成了栈的切换。

TCB2.esp = esp;
esp = TCB1.esp;
1
2

同理,线程2在切换的时候,也会将自己的栈的地址放到TCB2,因此TCB目前是和栈相互配合完成线程切换的。这也是切换的核心思想。

栈切换完后,就应该继续往下执行,就是切换PC,这里要跳回到204处去执行。但我们目前发现一个问题,如果就这样执行的话,有没什么问题?

当跳到204后,204的下一条指令就是B函数的结尾,然后就会执行ret指令;而ret指令后,由于栈里的内容还是204,因此又回到了B函数的204处内存地址。。。。

那么是在哪里出现了问题呢?那么大家就得想一想204是怎么来的。我们是执行Yield的时候压栈的,那么204应该什么时候弹栈呢?应该在Yield返回的时候。但是Yield返回的时候在哪里?在Yield函数里,但由于我们用了jmp指令,所以Yield是不会返回的;

void Yield(){
	TCB1.esp=esp;
	esp=TCB2.esp;
	jmp 204; 
}
1
2
3
4
5

那么,我们能不能把这个jmp 204去掉?是可以的,因为Yield执行完后就是ret指令,一弹出来,刚好是204;然后在204处,B函数执行完了,又会ret,就是104处,这样就正常了

Yield函数里只需要把栈切换,‍‍为什么不用PC跟着切换?因为 PC已经压在了栈里了,‍‍所以多么完美,Yield返回的这一个右括号就完成了PC的切换。

void Yield(){
	TCB1.esp=esp;
	esp=TCB2.esp;
}
1
2
3
4

这两页PPT,就完成了线程的切换,从一个栈到两个栈,配合上Yield;而Yield返回的时候PC跟着切换,非常简单,也非常漂亮

‍

‍

‍

‍

# Create函数应该做什么

两个线程切换,就是两个TCB,两个栈,这就是Yield要做的事情。那么我们可以讲Create了,Create就是走出要切换的样子。

大家可以看到,线程切换就是一个栈+一个TCB,TCB和栈关联,栈里放着返回的地址,那么create只要创建这3个东西就可以了:

​​

首先申请一段内存作为TCB,然后申请一段内存作为栈,假设这里申请了1000;然后往栈里存放内容。什么内容?就是程序的初始化地址,假设这里是100,放进栈里,然后将栈和TCB关联起来。

这样的话,切换到这个进程的时候,首先找到TCB,然后切换栈;然后Yield弹栈,就将程序的初始地址弹出并执行,因此这段程序就开始执行。

那么大家可以看到,Yield讲清楚了,那么Create也就清楚了,这两个都清楚后,线程不就完成调度了吗。

‍

‍

‍

# 用户级线程调度小结

​​

‍

我们将本节课讲的内容串起来。还是用浏览器的那个例子,首先浏览器程序用create创建了多个线程,每个线程都有用户栈,TCB,然后将这些程序的初始地址放到栈中,并关联栈和TCB。然后在切换的时候,通过Yield释放CPU,并且切换到其他线程去执行。这些程序编译出来,就是一个浏览器了。

本节课我们讲的是用户级线程,这种切换是应用程序自己主动做的,不用进内核,讲起来比较容易。后面我们会讲到内核级线程,其实用户级线程是内核级线程的一个子部分,我们先讲清楚用户级线程,有助于我们理解内核级线程。

实际上我们目前讲的和操作系统内部没有太大关系,不管操作系统内部支不支持多线程,我们都可以在应用程序方面这一层实现多线程,让浏览器使用起来更好。

‍

‍

‍

‍

‍

‍

# 引出核心级线程

我们现在引出内核级线程。用户级线程的Yield和Create都是用户自己写的函数,不用进入内核,完全是在用户态里切换;操作系统完全不知道有用户级线程的存在。但这种方式也有缺点,假设现在我们浏览器程序,执行了GetData函数,那么就是要使用网卡,是计算机硬件;这就得进入到内核;而一旦进入内核,由于是IO设备的操作,有可能会被操作系统阻塞,这时候内核就会切换到其他进程里去执行;内核并不知道应用程序还有其他线程,根本不会切换到其他线程去执行。

在早期,IE是单进程+多线程的,如果某一个线程卡了,那么整个IE都会卡死,其他标签页动都动不了。那么用户级即使启动了多个线程,并发性也没有得到发挥;即使目前CPU里没有其他程序在使用,操作系统也不会调度CPU执行其他线程。

不过目前很多浏览器都是多进程结构了,因此不再会出现这种情况

​​

‍

而核心级线程不一样,核心级线程的thread_create是一个系统调用,创建这个线程的时候就会进入到内核里,其TCB和栈也是在内核里;如果我们GetData和Show都创建的是核心级线程,那么如果某个线程阻塞了,就可以切换到另一个线程去执行,因此内核级线程的并发性会好一点。这就是用户级线程和内核级线程的特点。

‍

在内核里,线程的调度不叫Yield,而是叫schedule。因为内核级线程的调度是操作系统做的,而我们的Yield是应用程序做的,是有区别的,得区分开来,比用户级线程复杂;因此我们先学会用户级线程,打一个基础,有助于理解内核级线程

​​

在GitHub上编辑此页 (opens new window)
上次更新: 2023/4/23 09:32:45
如何支持多进程
搭建实验环境

← 如何支持多进程 搭建实验环境→

Theme by Vdoing | Copyright © 2022-2023 粤ICP备2022067627号-1 粤公网安备 44011302003646号
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式