直接把进程和线程的概念铺开,肯定是很难理解的。不论学习计算机网络还是操作系统,都应该将各种概念看作一段历史的发展,明白这个概念为什么引入,没有引入之前计算机如何计算…
最开始,没有进程和线程的概念,一个程序就是一个任务,CPU按照顺序执行用户输入的任务。
出现的问题就是:程序是顺序执行的,一个任务正在执行的过程中,另一个任务必须等待。任务不总是全部提前准备好的,如果存在某些紧急任务需要执行,就必须等待前面的任务执行,而且前面的任务可能在某些环节,CPU并不参与计算(如等待IO设备就绪),但是CPU仍被该任务占用。
总之就是CPU利用率不高,形象点就是CPU总是在摸鱼,而为了剥削CPU的劳动力,后来就引入的进程。
引入了进程后,CPU就没有偷懒的理由了,虽然当前任务不需要它服务,但是它不能闲着,它必须去服务就绪的任务,这使得CPU利用率提高了。
最终的结果是CPU利用率提高了,那怎么提高的呢?
原来的程序是静态的,而且一次只能执行一个程序,而且不能被中断,也不能被抢占。一个任务只有两种状态:没执行,执行完了。
而现在引入进程的主要作用是什么——引入了一个新的状态“正在执行的程序”。有了这个状态,我们可以指明哪个程序在执行的过程中遇到了中断,哪些程序执行到一半CPU被抢占了。说白了,进入进程本质上是为了保存程序运行中途的上下文。
“上下文”是一个比较抽象的概念,它指的是一组软件或硬件的状态,比如当前某个寄存器保存的值、当前程序计数器指向了那个指令等,例如游戏打到一半存个档,当前的血槽、当前的金币数目都是上下文的一部分。上下文可以直接看作一组寄存器的状态和一组变量的值。(或者理解为硬件软件状态在某一刻的快照)
能够保存存档,我就能玩玩其他存档了,于是你可以“并发”玩多个存档——并发就是一段时间内同时执行了多个程序,并发的基础就是上下文能够被保存与恢复。
总结:进程是对执行中程序的抽象,引入进程是为了实现程序之间的并发执行 ,进而提高CPU的利用率。
进程、程序、应用进程、应用程序其实都是一个意思,指的都是正在执行的程序罢了。本质上执行的是程序的代码,而进程是一个载体,这里说的进程其实在具体的操作系统上通常是一个具体的数据结构或者结构体,例如linux上通过task_struck对应一个进程(或者说进程映像、PCB)。那么谁在执行呢?肯定不是进程自己执行,是CPU在执行,通常说的“进程占用CPU”其实指的是“当前CPU正在执行某个进程对应的代码”。(通常进程都有自己的内存地址空间,其中包含代码段和数据段包含代码与数据)。PCB(或者说进程映像的具体实现)你可以看作是一个map,通过pid可以找到这个进程的上下文,把进程的上下文读入CPU就是一次“恢复的过程”,PCB就像是“游戏的某个存档”,点击“继续游戏”就是恢复上下文,CPU就相当于游戏的引擎。嗯,就这么理解吧。
虽然通过引入进程,使得CPU摸鱼的机会减少了。但是进程的切换代价很大,我们暂且不谈进程切换对系统的压力,咱们就单纯看切换的耗时:首先进程上下文的保存和读取肯定是不可避免的,其次进程的地址空间是独立的,系统内核为每个进程都保存了一份页表,进程切换就意味着cpu必须要切换页表指针/地址值(访存,拿到新线程对应的页表基址)。
形象点:CPU最开始一次为一个用户办理业务(办完为止),引入进程后,CPU在前一个用户睡午觉(用不到CPU)的时候需要跑到另一家公司继续上班,但是CPU这小子路上扣手机啥也不干,压榨的还不够。引入线程后,CPU就坐在办公室哪里也不用去,开个会议软件轮流给每个用户办理业务。
说白了,CPU利用率、CPU利用率还是CPU利用率,引入线程就是为了进一步增加CPU利用率。
其实,引入线程之后,本质上是为了实现多线程。如果默认创建一个进程,那么它仅有一个执行流,我们可以称之为主线程。之前谈论的都可以看作单线程进程,进程之间的切换其实本质上是两个线程的跨进程切换。
如果一个程序内部想要实现并发,那么程序内部能够包含多个执行流,这个执行流就是线程。如果将多个单线程进程的程序改造成多线程单进程的程序,那么执行流之间的切换,时间成本将大大降低,CPU就可以更多的执行程序本身的代码,而不是执行流切换的内核代码。
总结:线程是对程序执行流的抽象,引入线程是为了实现程序内执行流之间的并发,进而减少执行流切换的代价,同时进一步提升CPU利用率
进程侧重程序之间的并发,而线程侧重于程序内部的并发
之前说过,不管进程切换还是线程切换,本质上切换的是任务执行流。进程至少包含一个执行流,同时它管理了各种资源如内存地址空间、文件描述符等程序所拥有的资源。这个执行流就是一个线程,而线程的实现主要分为系统线程和用户线程。
系统线程之间由操作系统管理(os能够“看见”它,因为os转为为它定义了特定的结构体)。而os“看不见”用户线程。
系统线程的切换、销毁、创建等全部由os负责,但是如果系统线程过多,对操作系统来说也是不小的压力,因为系统线程的保存占用系统内存,系统线程的调度也是需要经过“陷入内核”的 ,这个“陷入内核”将导致上下文切换,使得一部分时间不得不用来执行内核的代码。
协程的引用,其实就是通过线程的分时复用实现基于一对多或多对多的线程模型。
其中一个系统线程可以与多个用户线程产生映射,用户线程的切换在用户态进行,不需要上下文切换,CPU利用率进一步提高。
再次使用上面的例子,CPU使用的会议软件有多个对话框, 每个框是一个系统线程,CPU可以看见每一个框。引入协程,一个框不再是一个用户了,而是若干个用户,每个用户都是一个用户线程。(这下子,CPU切换框的几秒钟摸鱼时间都没了,CPU快被榨干了!)
这也存在一个问题,当其中一个用户线程(协程)发起系统调用或其他阻塞操作,系统线程对应的一组用户线程(协程)都将被阻塞。因此协程一般不进行阻塞调用,一般将协程和异步IO结合使用。(框里面一个用户办点事,然后CPU开始框中服务另一个用户,前一个用户办完事后主动通知CPU我办完了,咱们继续吧)
黑皮书里提了一个解决方案:如果某个协程进行阻塞调用,就拿到一个新的系统线程,然后将其他任务迁移过去,调用返回后读取结果
总之:常规的线程通常指的是操作系统可以直接控制的系统线程,而协程是一种用户线程,一个线程与多个协程可以通过分时复用进行映射,协程的引入通过减少线程切换的次数来进一步提升CPU利用率。
但是,协程毕竟是用户级层面的,而系统线程委托给操作系统管理,操作系统直接管理CPU硬件,它将把线程映射到多核CPU的每一个核心上,实现线程(执行流)的并行执行。而协程则无法利用这一点,因为操作系统只能看见与协程关联的系统进程而已
我们聊一聊进程与线程的区别,进程是对运行中程序的抽象,而线程是对程序执行流的抽象。进程的引入是为了保存运行到一半的程序的状态,转而可以执行别的程序,以此来实现程序之间的并行。而线程的引入同理,也是为了保存运行到一半的某条执行流,转而可以执行另外的一条执行流,以此来实现执行流之间的并行。
其实线程可以粗略地看作进程的一个子集,广义上的进程可以看作内存空间、CPU、文件描述符等资源的分配的基本单位。而狭义上的进程可以看作一个仅有一个主线程的工作单元,因此线程也称为轻量级线程。引入线程概念之前,进程即使资源分配的单位,又是程序调度和执行的基本单位。而引入线程后,“执行流”这个抽象的东西就被概念化为了“线程”,因此线程称为了描述任务执行的基本单位。
一个程序如果能够被执行下去,那么地址空间必不可少,程序中可能涉及打开文件、申请变量等操作,而且程序想要执行也必须占用CPU这个资源。如何理解资源分配的基本单位?内存条这个硬件被操作系统管理,操作系统将内存地址分配出去是以进程为单位分配的,但是一般分配的都是逻辑地址(虚拟地址),当对应空间需要被访问时才会映射为物理地址。而CPU也是一次分配给一个进程的,CPU的作用可以粗略理解为:一行行扫描代码,然后转换为CPU指令集中的汇编指令,产生结果…执行一个范围内的代码就是一个执行流,因此线程是程序执行的基本单位。
以上是从概念上的理解。总之,二者分别是对运行程序与程序执行流的抽象,都是动态的概念,而且核心就是提出一个可以保存上下文的结构,来实现并发的执行。
进程和线程在操作系统中都对应一个具体的结构去保存各自的上下文。
进程中至少有一个线程(执行流),如果忽略这个主线程,那么进程仍需要保存各种和资源有关的信息,如各种全局变量、申请到的文件描述符、静态变量、局部变量、内存地址空间(包括堆内存、栈内存、数据段、代码段和一些共享内存),而这些资源也都是被线程共享的。
而线程的上下文主要是和执行有关的。如:两个堆栈指针(stack point)、程序计数器(PC)、状态寄存字(PSW)、通用寄存器。也包含了一些状态信息如线程优先级、pid等,以上这些线程上下文都是线程私有的。
计算机操作系统运行着两类程序:用户程序与系统程序。这两个程序没什么区别,编译都是一对汇编语言,CPU去执行。但是为了防止应用程序破坏操作系统,两类程序会分别运行在不同的状态,而CPU通过程序状态字PSW来实现状态的切换。
现代操作系统将CPU指令集划分为特权指令和非特权指令,其中非特权指令只能完成有限的任务,如访问自己的内存、操作数入栈出栈、逻辑运算等。一旦需要使用到特权指令,如获打开一个文件、创建一个进程、访问内核空间等就必须采用系统调用,委托操作系统来完成。
一旦使用了系统调用,就会产生陷入内核,并将CPU上下文保存在内核栈,然后转而执行相应在指令(在当前用户的系统栈)。(不发生线程切换)。线程切换的过程中,发生陷入内核,其实就是使用了系统调用,委托操作系统执行线程调度程序(在被切换线程的内核栈执行操作系统内核提供的代码)
用户进程的代码保存在用户空间的代码段,而内核代码则保存在内核空间,每个进程/线程(这里指的都是CPU执行流)都拥有两个栈指针,内核栈和用户栈,在用户栈中执行用户程序的普通调用,而在内核栈执行的是系统调用。
如果用户想要创建一个进程,那么应该使用系统调用fork委托操作系统创建,操作系统创建完毕后返回用户一个子进程的标识符,操作系统创建这个子进程的过程,进程/线程是被“中断”的,当它恢复后便得到了这个标识符。因此系统调用导致陷入内核保存的上下文——即保存在内核栈的信息也可以称作中断上下文。
进程、线程、中断上下文本质上都属于CPU上下文,粒度依次降低
用户态与系统态进行切换,会保存CPU上下文,如果没有发生线程切换,那么这个上下文指的就是中断上下文。如果是进程内线程的切换那么这个上下文是线程上下文,如果是进程间的线程切换,那么这个上下文是进程上下文。粒度越大,上下文包含的内容越多,
操作系统为每个进程都分配了栈空间(划定一片空间,线程可以在其中进一步划分自己的领域),而每个线程都拥有独立的栈内存,而每当一个函数(方法)被调用,栈空间中就会创建一个栈帧,如果方法嵌套调用层数太多,可能会因为超过进程所分配的栈空间而导致“栈溢出错误”。
栈帧中保存了传递给函数的参数、返回值局部变量、程序计数器副本以及一些函数的元信息。
用户栈的作用前面提到了,即负责用户程序的调用执行。而系统栈除了负责系统调用的执行,也负责保存CPU上下文。
这里的CPU上下文主要就是几个之前提到过的寄存器的值:PC/PSW/通用寄存器/堆栈指针
用户态下的CPU、用户态下的进程/线程指的是:CPU正在执行的某一个进程的代码,同时CPU的PSW指向用户态。且用户态下的CPU在用户栈中执行指令
用户态转换为内核态的三种方式:系统调用、函数调用出现异常、外围设备的中断信号。其中只有系统调用是进程主动要求中断(说具体点,CPU执行到了涉及系统调用的代码)
内核栈不止一个,但是也并不是每个用户进程都各指向一个,具体个数与具体操作系统有关
因为线程共享进程的一切资源,最主要是内存地址空间。使得线程切换时不会发生页表的切换,仅仅需要保存与加载线程上下文即可。
CPU是执行代码用的,CPU在任意时刻在某一进程——这里的意思是:读取一个进程上下文的数据后,就开始在为该进程分配的内存空间中执行计算,如果需要创建对象,就在进程的堆内存中创建,如果需要执行方法就在进程的栈内存中创建栈帧,进程基本上只是为CPU提供了初始数据、代码、和存放中间数据的内存空间等资源。CPU执行某段代码所产生的上下文可以使用线程上下文来描述——因此进程、线程上下文本质上都是CPU上下文,或者说进程和线程这两个结构就是用于存储/描述不同粒度的CPU上下文的。
执行代码产生的临时数据都可以看作CPU上下文,而CPU执行中断后会把此时的上下文数据保存到进程的内核栈,当程序恢复后会从内核栈去读取线程上下文。因此当程序正在用户栈执行时,内核栈都是空的。而一个没有持有CPU的就绪进程/线程,它的上下文数据保存在内核栈。
CPU执行A线程中的代码时(其实就是执行一个个方法,用户态),如果A线程发生线程切换,CPU将陷入内核(CPU的程序状态字寄存器修改状态,进入内核态),然后CPU将堆栈指针从用户栈指向内核栈,并将线程上下文保存的内核栈后,开始在内核栈中执行线程切换的内核代码(主要是读取线程B的上下文,当线程切换相关的代码/方法全部执行完毕,相应的栈帧全部出栈,此时内核栈就剩下一个上下文数据了),切换程序执行文本后,此时CPU上下文已经被载入新的上下文数据(读取线程B上下文,栈帧出栈,然后堆栈指针指向线程B的用户栈,同时将PSW拨回用户态),因此CPU开始执行另一些代码了。
进程切换和线程切换都需要涉及一次CPU上下文(中断上下文)的切换,CPU的堆栈指针会指向内核栈,然后将包括程序计数器、通用寄存器、程序状态字等硬件上下文保存在内核栈中。
进程切换还需要额外做的就是切换虚拟地址空间,通过切换页表指针来实现,这个过程需要访问内存,重新读取页表的地址。
同时,页表切换意味着CPU的高速缓存要刷新,主要是页表条目的高速缓存TLB会被刷新(清空),CPU进行地址转换时必须通过访问内存。
CPU在一个时钟周期能够执行多条指令,而访问一次内存耗费的时间占用多个时钟周期,这使得CPU利用率下降。而现代内存地址映射一般采用多级页表,从内存中加载页表项的代价相对会更大。
进程通信和线程通信本质上都是程序之间的两个执行流的通信。
通信无处不在,通过cmd窗口执行命令、连接数据库、使用浏览器都是通信。
进程通信核心关注点,是将两个内存地址独立的进程可以互相发送消息。
大致的思路:使用一个中间的载体,一个进程放入一个进程取,或者进程能够通过端口+ip地址定位到另一个进程,约定某一种格式的信息,像写信一样交流、还有一种思路是内存映射。
由于同一进程下线程是共享内存空间的,也就是说“线程没有自己的内存空间”或者“线程可以访问进程的地址空间”,亦或者“线程可以轻易地访问对方的地址空间”。
因此线程通信非常简单,它们天生共享内存。因此线程通信的关注点在于如何“安全地”访问内存——线程安全问题。
进程如果想要达成线程的效果,需要使用共享内存映射或者共享内存文件映射。
因此,线程关注点在于“安全访问”,而进程关注点在于“信息传递”。
这里简单说一下通信方式。进程之间可以【1】采用管道通信,属于内核中的缓存(是否阻塞取决于具体实现和策略),但是通信效率低下(访问需要涉及系统调用,而且一般是阻塞调用),不适合频繁交换数据
,但是简单,一般执行简单的指令可以使用
【2】消息队列,进程以格式化的消息(类似报文)为单位,将数据封装到消息中,并且通过OS的原语进行消息传递。
通信不及时、而且消息体的大小有限制,通信的过程中需要把消息在内核缓存和用户缓存中进行数据拷贝。因此也不适合大数据传输以及频繁的通信
【3】内存映射
进程可以通过内存映射实现共享内存,达到和线程类似的效果(指内存),之后应用程序(CPU)可以像访问普通内存一样访问共享内存(不用切换PSW)
【4】线程和基于共享内存通信的进程都具有“访问安全问题”。因此需要使用锁或者信号量进行解决不安全问题。
操作系统层面可以使用P/V原语实现进程的同步或互斥。一些编程语言提供了锁和信号量的API可以直接使用。(这里同步与互斥的对象都是执行流,没必要区分太开)
【5】信号是一种异步的通信机制,如Ctrl+C和kill命令。
【6】同主机或跨主机进程还可以使用socket通信。
【7】锁。锁本质上是一个标志,是否已经上锁?owner是谁?锁也可以分为共享锁、互斥锁、自旋锁、无锁等。上锁(标志被置位)一般是基于CAS指令实现的(关中断、testAndSet也可以实现)。
有锁:资源访问前先CAS修改锁变量。
无锁:CAS直接修改资源,如果修改失败则认为竞争失败,如何重试取决具体逻辑
线程层面,锁一般是进程内存地址中的一个变量,而进程层面(跨进程线程),锁一般是两个进程共同指向的一个打开的文件
【8】文件映射。是共享内存的实现方式之一。先将磁盘物理块部分或者全部映射到内存物理块上,然后将这个内存物理块映射到若干个进程的内存地址空间(虚拟内存页)上。(内存映射后,内存物理块保存的二进制内存最终会保存到磁盘的物理块上)
【9】文件。如果说线程共享是进程内存地址下某个共享变量,那么进程就可以共享共同指向的某个文件。
多进程并发程序设计性能不一定差,但是通信和切换都需要借助系统调用,实现代价大,而且并发粒度有限。
其中,每个进程是相互独立的,每个进程都代表一个独立的程序,其中一个进程崩溃并不会影响主进程的正常运行,而且各进程可以分别运行在不同的主机上,实现分布式部署。线程(这里指用户级别的线程)的崩溃可能影响到整个程序(进程)的稳定性,如果发起系统调用,可能会导致当前进程陷入阻塞。
操作系统可以控制进程,那么一个进程中肯定存在一个系统级别的执行流(线程),受到影响的一定是用户可以看见,且“使用了用户线程API被阻塞”的系统线程,虽然是用户线程出问题,但是操作系统只看到了“调用被阻塞”的某个系统线程/执行流。
另一方面,同一时刻,一个CPU总是正在执行一个进程的代码。因此如果想要提升性能,那么可以通过扩充CPU的数量来直接提升性能。而线程(这里指系统级别的线程)的并行度可以通过增加CPU和核心数量来提升。
与多进程相比,线程能够提升的总性能总是收到当前进程的限制,因为它的内存地址空间总是不能高于进程申请的总地址空间,而且并行度总是受限于某个CPU的核心数量
总结:多线程程序天生共享内存,通信方便。创建、切换、销毁代价更小。但是性能提升有瓶颈。(是否稳定需要具体分析,和具体的线程模型、线程的实现等有关)。
多进程粒度大、代价大之外,但是性能扩展比较容易实现,而且具有稳定性(健壮性)
谈论进程和线程区别,思路:先引入,把你思考的逻辑展现出来。然后从概念、代价、通信、共享资源等方面展开。当然了,把本质用你自己的思考答出来:一个是对运行中的程序的抽象,另一个是对程序执行序列(流)的抽象。