在异步与资源调度-以浏览器事件循环为例我们以浏览器为例简单的介绍了一下资源的调度和事件循环,在那一期我们就留了一个坑。这一期我们就来填上,介绍一下什么是进程、线程、协程,以及他们的区别和联系。
硬件资源调度
今天我们讲的这些概念都来自于操作系统,是操作系统为了充分利用硬件资源的机制。
我们先来设想一个最简单的资源调度方案。这也是在IBM7094这样的上古机器里使用的资源调度方案。
在当时像IBM7094这样的计算机,造价在250万美元以上,非常昂贵因此我们希望充分利用计算机,尽可能地利用上计算机提供的算力资源。
这台计算机使用的就是批处理方法,计算机没有额外动作只会一个一个地执行任务。用户在磁带上编程,然后计算机不停的执行磁带上的代码,当完成了一个任务或者这个任务出现了异常,那么计算机就执行下一个任务。
这样的资源调度非常简单,但是后来人们还是发现了一些问题。
我们还是以现代计算机执行的任务为例,有的时候我可能会写这样的代码
|
|
这个代码的问题在于,当程序执行到scanf
的时候计算机会等待用户输入,CPU啊之类硬件的就停止工作了。这太浪费了啊!
这种任务被我们称为是IO密集型任务,当CPU在执行这样的任务的时候我们希望CPU能在IO阻塞的时候执行其他任务。
那怎么弄呢?
很简单其实,类似于计算机网络中使用的时分复用,我们可以让多道程序交替执行从而提高效率。
A程序先执行需要CPU的任务,接着进入需要磁盘IO的任务,CPU就切换到任务B的执行,同时
磁盘放在继续完成任务A。
这样的多道程序交替执行就是现代计算机资源调度的一个核心。
这种方式也被称为并发,并发本质上只有一个CPU在干活,但是他交替执行多个程序,这样不仅提高了CPU利用率而且在用户看来,他会感觉是多个任务同时执行。
但是我们怎么才能做到这样的并发呢?
熟悉计算机硬件的人可能会说,只需要修改PC寄存器就行了。(也就是修改CPU正在执行的指令)
但是
如果程序A是
|
|
也就是ax寄存器存1,bx寄存器存1,执行1+1
程序B是
|
|
ax寄存器存10,bx寄存器存10,执行10+10
我们这样生硬的切换PC就会出现问题了。
程序A可能已经把ax改成1了,接着你进入了程序B,把ax改成了10,接着B执行完了又回到A则ax还是10,程序A最后就会输出10+1=11了。
这就出了问题。
因而,我们需要一个数据结构来存储程序A执行到哪里了,执行的时候各种变量是多少。在操作系统中,这个数据结构被称为PCB。
但是这样会导致运行中的程序需要额外记录PCB,这就导致运行中的程序和其他程序不一样了。
所以我们又抽象了一个概念,叫做进程,它的意思就是进行中的程序。
进程
再次重复一遍,进程就是运行中的程序。现在我们对硬件资源的调度就是对进程的调度。
为了更好地管理进程,我们还会根据进程的状态对进程进行分类。
当我创建进程的时候我们说进程处于新建态,
接着进程会等待CPU的执行,这时是就绪态,通俗的说就是进程已经准备就绪了,随时可以被CPU执行。
执行中的进程进入了运行态,而执行完成的进程就进入了终止态。
前面我们还说了磁盘IO会阻塞进程,这时的经常处于阻塞态。
进程的调度策略,我们前面已经以浏览器为例介绍了一种方案了就不再详细说明了。
前面我们说我们对硬件资源的调度就是对进程的调度,多道程序交替执行就是现代计算机资源调度的一个核心,那么计算机是如何实现多个进程的交替执行呢?
这个我们暂时只讲一半。
因为进程的切换涉及到两块。
第一块我们之后再说。就是这样的:
操作系统为了防止进程之间相互影响,例如:
进程A设置内存地址为100的内存数据为1,进程B却来设置这块内存为2,这可能会导致进程A报错打架。所以操作系统引入了内存映射表,引入了虚拟内存的机制。
思路很简单,进程A看到的内存100,其实是在真实的内存里可能是1100,但是进程B看到的内存100在真实内存里是2100。每次看到一个进程访问内存100,我就先去内存映射表里看看这个内存地址100在真实的内存里到底是哪个地址然后再执行指令。(但是虚拟内存的存在导致了不同进程之间的资源是不共享的)
所以,进程之间的切换还要照顾到虚拟内存和内存映射表的切换,这一部分等我们以后有机会讲操作系统的内存调度的时候再说吧。
进程切换的另一块是不同进程之间指令序列的切换,我们来讲这个。
进程的指令切换
不过我更想做的是我们自己写个函数调用来实现一下进程间的指令切换。有没有办法呢?
可以的。
我们来试试看:
假如进程1是这样的代码
|
|
进程2是
|
|
我们先启动进程1,先进入地址100的A函数,然后A函数调用B函数,B函数执行yield函数。yield函数的意思是切换到另一个进程。
那么CPU会执行C函数,然后C函数调用了D函数,D函数再切换到进程1,接着函数就开始返回了。
但是我们来分析看看现在的函数调用栈。
A调用B,104入栈。
B调用yield,204入栈,
C调用D,304入栈,
D调用yield,404入栈。
接着开始返回了,此时我们在进程1,但是出栈的时候出的是进程2的404,这不就炸了。
你明明要运行进程1啊,怎么出了人家的东西。
这个问题只需要稍加修改就行了,我们给进程1和2分别维护一个栈。
这样进程1的栈只有104和204了,然后204出栈,104出栈。
但是现在入栈的过程又不太一样了。
还是
A调用B,104入栈。
B调用yield,204入栈
但是现在要切换到进程2去了,我们要换一个栈,因此需要一个指针来指向当前的栈,还需要给两个栈分别申请内存空间。
栈的信息存储在一个叫TCB的数据结构中,而esp是指向当前栈的指针变量。
那么yield函数是做了什么呢?
将当前栈的信息存起来,然后切换esp为下一个进程的栈。就这么两句简单的语句。
现在我们来看看新建线程的时候要做什么,就是要申请一个新的栈然后先把当前的第一个语句入栈再等待执行。(注意C语言基础,语句是在出函数调用栈的时候执行的)
出栈的时候你会发现我只说了线程1的出栈而没有说线程2的出栈。这是因为线程1已经结束了然而他没有切换到线程2。
但是这个无伤大雅,因为
线程2完成阻塞的任务之后会进入就绪态,当CPU空闲的时候操作系统会按一定规则唤醒一个就绪态的线程,这个就等以后有时间我们聊聊真实完整的操作系统的任务调度吧。
像我们这样实现的所谓的进程,只实现了指令序列的切换而资源没有切换,并且没有进入操作系统内核,一直在用户态进行操作,这种所谓的进程就是协程(也称为纤程或者用户级线程)。
这个协程呢,没有涉及到操作系统内核也就没有涉及到系统调用,甚至你自己可以在任何高级编程语言上实现。
现在Python、kotlin、Java等高级编程语言已经有一套官方的标准库来实现协程了。(啊哈哈,还在用java 8的可以试试参考上面的方法自己手动实现一个,俺们jdk 21先跑了)
用户级线程的问题
用户级线程始终没有进入操作系统内核但是有没有问题呢?
有的,因为这样的线程只在用户态并发,但是在内核态什么也没做,那么操作系统怎么可能会知道你创建了多个用户级线程呢?
试想这样的一个场景:
进程A开了用户级线程1 用户级线程2
用户级线程1进入了操作系统内核,也就是进入了内核态并发生了阻塞。
那么操作系统为了高效利用CPU会将CPU的使用权交给其他进程而不是用户级线程2(这是因为在操作系统眼里只有进程A一整个进程而没有两个用户级线程,所以他在调度的时候自然不会考虑这些)。从而你会发现,用户级线程2跟着用户级线程1阻塞了,此时用户就会感到卡顿了。
所以要想真正实现并发,必须要操作系统介入,实现内核级的线程。这就是真正的线程了。
线程之间仍然不切换虚拟内存的内存映射表,从而线程之间是共享资源的。
而要实现内核级线程,首先他在用户态和用户级线程没有区别需要维护两个栈。
那么在内核态呢?
你会发现,无非不还是栈啊什么的切换,无非还是一些函数调用,所以在内核态也还是要维护这样的两个栈。
因此每一个线程都要在用户态和内核态维护两套不同的栈。
当用户态通过系统调用调用了操作系统的接口说我要切换线程了,会发生什么呢?
在用户态,我们的线程应该做和用户态线程一样的操作,入栈接下来要运行的代码。
而在内核态,我们还是要把这个线程在内核态的信息入栈,接下来和前面类似,完成内核态的栈的切换(代码和前面其实是一样的),再从内核态离开进入另一个线程的用户态执行代码。
如果要说的高大上一点就是,线程1将从线程2返回后要执行的代码入用户态的栈,然后通过中断(对x86架构的计算机就是执行汇编命令int 0x80)进入内核态,内核态的栈再将切换线程的代码出栈执行线程切换的代码(schedule)将执行的线程切换为线程2,然后线程2在内核态的栈再出栈执行汇编命令iret回到用户态,接着用户态的栈出栈执行线程2的代码。
总结
总之,运行中的程序叫做进程,进程与进程之间资源是不共享的。
而我们只在用户态实现的资源共享的利用两个栈实现的用户级线程就是协程。
而操作系统利用两套栈实现的资源共享的并发结构就是线程。
他们的区别是进程资源不共享,切换的时候需要切换资源也要切换指令序列。而线程只切换指令序列。
但是相比于轻巧的协程,线程的切换开销要大,不过协程在执行需要进行系统调用的指令的时候还是会有阻塞的问题。
但是在实际编程的时候你可能又会发现,协程是用户级实现的,不受操作系统调度的影响,程序员可以随意把控当前运行的协程是哪一个自由度更高。(以后你会看到,操作系统在进行资源调度的时候会对线程进行时间分片,程序员无法直接把控什么时候切换切出线程,当前在执行哪个线程从而会带来一些并发问题)