用户级线程
# 6. 用户级线程
上一讲我们讲了多进程图像,我们反复强调多进程图像是操作系统最核心的图像;我们还用 Windows 任务管理器来实际的看了一个操作系统里面的进程,操作系统将进程管理好了,也就管理好了 CPU,也就带动着管理好了计算机。
我们还讲了操作系统如何支持多进程,并且合理有序的推进:
- 如何组织多进程:通过进程的状态 + 队列
- 如何切换多个进程,今天这门课会详细的讲
- 如何分离多个进程(避免进程之间互相影响)
- 多个进程如何合作
今天开始我们主要讲进程之间怎么切换。但为什么标题是线程呢?带着这个问题,我们开始今天的课程
# 引出线程的概念
我们再回顾一下多进程图像。
进程 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){...};
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;
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;
}
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;
}
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 是应用程序做的,是有区别的,得区分开来,比用户级线程复杂;因此我们先学会用户级线程,打一个基础,有助于理解内核级线程