进程、线程与协程
概述
说到协程,我们必须提到两个更远的东西。在操作系统级别,有进程(process)和线程(thread)两个实际的“东西”,之所以不说概念,因为这个家伙的确不仅仅是概念,而是实际存在的,由OS代码管理的资源。这两个东西都是用来模拟“并行”的,写操作系统的程序员通过一定的策略给不同的线程和进程分配CPU计算资源,来让用户“以为”几个事情是在“同时进行”。
而实际上在单核CPU上,当进行切换时,OS代码强制把一个进程或者线程挂起,换成另一个来进行计算。所以,CPU本身实际上是串行的,只是“概念上的并行”。实际上可以将进程看做对CPU的一次抽象,通过抽象在OS操作协同级别模拟出并行计算的效果。
当然现在的多核计算机已经实现了真正的并行运算。
进程
什么是进程
在电脑的应用程序被运行后,就相当于将应用程序装进容器里了,你可以往容器里加其他东西(如:应用程序在运行时所需的变量数据、需要引用的DLL文件)等),当应用程序被运行两次时,容器里的东西并不会被倒掉,系统会找一个新的进程容器来容纳它。为了深刻描述程序动态执行过程的性质,人们引入“进程(Process)”概念。
进程是一个具有独立功能的程序关于某个数据集合的一次运行活动。它可以申请和拥有系统资源,是一个动态的概念,是一个活动的实体。它不只是程序的代码,还包括当前的活动,通过程序计数器的值和处理寄存器的内容来表示。
程序计数器()是用于存放下一条指令所在单元的地址的地方。
为了保证程序(在操作系统中理解为进程)能够连续地执行下去,CPU必须具有某些手段来确定下一条指令的地址。而程序计数器正是起到这种作用,所以通常又称为指令计数器。
在程序开始执行前,必须将它的起始地址,即程序的一条指令所在的内存单元地址送入PC,因此程序计数器(PC)的内容即是从内存提取的第一条指令的地址。
当执行指令时,CPU将自动修改PC的内容,即每执行一条指令PC增加一个量,这个量等于指令所含的字节数,以便使其保持的总是将要执行的下一条指令的地址。由于大多数指令都是按顺序来执行的,所以修改的过程通常只是简单的对PC加1。
当程序转移时,转移指令执行的最终结果就是要改变PC的值,此PC值就是转去的地址,以此实现转移。有些机器中也称PC为指令指针IP(Instruction
Pointer)。
进程的概念主要有两点:
进程是一个实体。
每一个进程都有它自己的地址空间,一般情况下,包括文本区域(text region)、数据区域(data region)和堆栈(stack region)。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动过程调用的指令和本地变量。
进程是一个“执行中的程序”。
程序是一个没有生命的实体,只有处理器赋予程序生命时,它才能成为一个活动的实体,我们称其为进程。
地址空间(address space)表示任何一个计算机实体所占用的内存大小。在计算机中,每个设备以及进程都被分配了一个地址空间。处理器的地址空间由其地址总线以及寄存器决定。地址空间可以分为Flat——表示起始空间位置为0;或者Segmented——表示空间位置由偏移量决定。
把物理地址暴露给进程会带来下面几个严重问题。
第一,如果用户程序可以寻址内存的每个字节,它们就可以很容易地(故意地或偶然地)破坏操作系统,从而使系统慢慢地停止运行(除非有特殊的硬件进行保护,如IBM360的锁键模式)。即使在只有一个用户进程运行的情况下,这个问题也是存在的。
第二,使用这种模型,想要同时(如果只有一个CPU就轮流执行)运行多个程序是很困难的。在个人计算机上,同时打开几个程序是很常见的(一个文字处理器,一个邮件程序,一个网络浏览器,其中一个当前正在工作,其余的在按下鼠标的时候才会被激活)。在系统中没有对物理内存的抽象的情况下,很难做到上述情景。
要保证多个应用程序同时处于内存中并且不互相影响,则需要解决两个问题:保护和重定位。如何解决这个问题吶?一个更好的办法是创造一个新的内存抽象:地址空间。就像进程的概念创造了一类抽象的CPU以运行程序一样,地址空间为程序创造了一种抽象的内存。地址空间是一个进程可用于寻址内存的一套地址集合。每个进程都有一个自己的地址空间,并且这个地址空间独立于其他进程的地址空间(除了在一些特殊情况下进程需要共享它们的地址空间外)。
进程的运行状态
就绪状态(Ready)
进程已获得除处理器外的所需资源,等待分配处理器资源;只要分配了处理器进程就可执行。就绪进程可以按多个优先级来划分队列。
运行状态(Running)
进程占用处理器资源;处于此状态的进程的数目小于等于处理器的数目。在没有其他进程可以执行时(如所有进程都在阻塞状态),通常会自动执行系统的空闲进程。
阻塞状态(Blocked)
由于进程等待某种条件(如I/O操作或进程同步),在条件满足之前无法继续执行。该事件发生前即使把处理机分配给该进程,也无法运行。
线程的引入:
60年代,在OS中能拥有资源和独立运行的基本单位是进程,然而随着计算机技术的发展,进程出现了很多弊端,一是由于进程是资源拥有者,创建、撤消与切换存在较大的时空开销,因此需要引入轻型进程;二是由于对称多处理机(SMP)出现,可以满足多个运行单位,而多个进程并行开销过大。 因此在80年代,出现了能独立运行的基本单位——线程(Threads)。
线程
什么是线程
线程,有时被称为轻量级进程(Lightweight Process,LWP),是程序执行流的最小单元。一个标准的线程由线程ID、当前指令指针(PC)、寄存器集合和 堆栈组成。
线程是进程中的一个实体,是被系统独立调度和分派的基本单位,线程自己不拥有系统资源,只拥有一点儿在运行中必不可少的资源,但它可与同属一个进程的其它线程共享进程所拥有的全部资源。一个线程可以创建和撤消另一个线程,同一进程中的多个线程之间可以并发执行。由于线程之间的相互制约,致使线程在运行中呈现出间断性。
线程也有就绪、阻塞和运行三种基本状态。就绪状态是指线程具备运行的所有条件,逻辑上可以运行,在等待处理机;运行状态是指线程占有处理机正在运行;阻塞状态是指线程在等待一个事件(如某个信号量),逻辑上不可执行。每一个程序都至少有一个线程,若程序只有一个线程,那就是程序本身。
信号量(Semaphore),有时被称为信号灯,是在多线程环境下使用的一种设施,是可以用来保证两个或多个关键代码段不被并发调用。对信号量有4种操作(include):初始化initialize、等信号wait、发信号post、清理destroy;
在进入一个关键代码段之前,线程必须获取一个信号量;一旦该关键代码段完成了,那么该线程必须释放信号量。其它想进入该关键代码段的线程必须等待直到第一个线程释放信号量。为了完成这个过程,需要创建一个信号量VI,然后将Acquire Semaphore VI以及Release Semaphore VI分别放置在每个关键代码段的首末端,确认这些信号量VI引用的是初始创建的信号量。
以一个停车场的运作为例。简单起见,假设停车场只有三个车位,一开始三个车位都是空的。这时如果同时来了五辆车,看门人允许其中三辆直接进入,然后放下车拦,剩下的车则必须在入口等待,此后来的车也都不得不在入口处等待。这时,有一辆车离开停车场,看门人得知后,打开车拦,放入外面的一辆进去,如果又离开两辆,则又可以放入两辆,如此往复。在这个停车场系统中,车位是公共资源,每辆车好比一个线程,看门人起的就是信号量的作用。
抽象的来讲,信号量的特性如下:信号量是一个非负整数(车位数),所有通过它的线程/进程(车辆)都会将该整数减一(通过它当然是为了使用资源),当该整数值为零时,所有试图通过它的线程都将处于等待状态。在信号量上我们定义两种操作: Wait(等待) 和 Release(释放)。当一个线程调用Wait操作时,它要么得到资源然后将信号量减一,要么一直等下去(指放入阻塞队列),直到信号量大于等于一时。Release(释放)实际上是在信号量上执行加操作,对应于车辆离开停车场,该操作之所以叫做“释放”是因为释放了由信号量守护的资源。
与进程相比
进程是资源分配的基本单位。所有与该进程有关的资源,都被记录在 进程控制块PCB中。以表示该进程拥有这些资源或正在使用它们。另外,进程也是抢占处理机的调度单位,它拥有一个完整的虚拟地址空间。当进程发生调度时,不同的进程拥有不同的虚拟地址空间,而同一进程内的不同线程共享同一地址空间。
与进程相对应,线程与资源分配无关,它属于某一个进程,并与进程内的其他线程一起共享进程的资源。线程只由相关 堆栈( 系统栈或 用户栈) 寄存器和线程控制表TCB组成。 寄存器可被用来存储线程内的 局部变量,但不能存储其他线程的相关变量。
通常在一个进程中可以包含若干个线程,它们可以利用进程所拥有的资源。在引入线程的操作系统中,通常都是把进程作为分配资源的基本单位,而把线程作为独立运行和独立调度的基本单位。
由于线程比进程更小,基本上不拥有系统资源,故对它的调度所付出的开销就会小得多,能更高效的提高系统内多个程序间并发执行的程度,从而显著提高系统资源的利用率和吞吐量。因而近年来推出的 通用操作系统都引入了线程,以便进一步提高系统的 并发性,并把它视为现代操作系统的一个重要指标。
线程与进程的区别:
- 地址空间和其它资源(如打开文件):进程间相互独立,同一进程的各线程间共享。某进程内的线程在其它进程不可见。
- 通信: 进程间通信IPC,线程间可以直接读写进程数据段(如全局变量)来进行通信——需要进程同步和互斥手段的辅助,以保证数据的一致性。
- 调度和切换:线程上下文切换比进程上下文切换要快得多。
- 在多线程OS中,进程不是一个可执行的实体。
协程
什么是协程
子程序是协程的一个特例。
协程是一种程序组件,是由子例程(过程、函数、例程、方法、子程序)的概念泛化而来的,子例程只有一个入口点且只返回一次,而协程允许多个入口点,可以在指定位置挂起和恢复执行。
协程本质是一种控制抽象,它的价值在于可以简洁优雅地实现一些控制行为。在协程中,控制可以从当前执行上下文跳转到程序的其它位置,并且可以在之后的任意时刻恢复当前执行上下文,控制从跳出点处继续执行。这种行为与Continuation类似,但协程相比之下更容易理解,某些情况下还更加高效。
协程可以通过调用其他协程退出,这些新协程稍后可能会返回到原始协程中那个调用他们的点。当然,从原始协程的角度来看,他自身并没有退出但是调用了另一个协程。
因此,一个协程实例在调用之间保持状态和变量,一次可以有给定协程的多个实例。简单的调用一个另一个协程(当然,这个稍后也会返回调用点)和通过屈服给另一个协程的方式调用它之间的不同之处不是所谓的caller-callee,而是对等性(symmetric)。
协程是个单线程,他不能同时将多个CPU的多核心用上,协成需要和进程配合才能运行在多CPU上。所以,进行阻塞操作时,协程会阻塞掉整个程序,这一点和事件驱动一样,可以使用异步IO解决。
一个子程序可以认为是一个未调用yield的协程。
subroutines 翻译为子程序,或者成为函数,在所有语言都是层级调用,比如A调用B,B在执行过程中又调用了C,C执行完毕返回,B执行完毕返回,最后A执行完毕。所以子程序是通过
栈实现的,一个线程就是执行一个子程序。当子程序被调用时,开始执行,一旦一个子程序退出,执行就结束了。一个子程序的实例仅返回一次,并且在两次调用之间不保持状态。要实现带有子程序的编程语言,只需要一个堆栈,在程序执行的开始时就可以重新编译。相比之下,协程可以作为对等项调用其他协同程序,最好使用延续实现。延续可能需要分配额外的堆栈,因此通常在垃圾收集的高级别语言中实现。协程创建可以通过preallocating的低成本堆栈或者缓存以前分配的堆栈来进行。
Python协程实例
|
|
经过处理的执行顺序:
Producer调用c.next(),启动生成器Consumer开始运行,
生成器是这样一个函数,它记住上一次返回时在函数体中的位置。对生成器函数的第二次(或第 n 次)调用跳转至该函数中间,而上次调用的所有局部变量都保持不变。生成器不仅“记住”了它数据状态;生成器还“记住”了它在流控制构造(在命令式编程中,这种构造不只是数据值)中的位置。
在python中,当你定义一个函数,使用了yield关键字时,这个函数就是一个生成器,它的执行会和其他普通的函数有很多不同。首先,函数返回的是一个对象,而不是你平常所用return语句那样,能得到结果值。如果想取得值,那得调用next()函数。
其次,每当调用一次迭代器的next函数,生成器函数运行到yield之处,返回yield后面的值且在这个地方暂停,所有的状态都会被保持住,直到下次next函数被调用,或者碰到异常循环退出。
Consumer遇到yield关键字,屈服,Producer开始从c.next开始往下执行;
Producer遇到c.send()方法,返回yield点,Consumer开始运行,将r值赋值给n;
Consumer继续运行,循环;
Consumer遇到yield关键字,屈服,Producer开始从继续刚才打断的c.send处继续运行;
Producer判断循环条件,成立继续运行
Producer遇到c.send()方法,返回yield点,Consumer开始运行,将r值赋值给n;
Consumer继续运行,循环;
Consumer遇到yield关键字,屈服,Producer开始从继续刚才打断的c.send处继续运行;
Producer判断循环条件,成立继续运行
Producer遇到c.send()方法,返回yield点,Consumer开始运行,将r值赋值给n;
….
Producer判断循环条件,失败,跳出循环,关闭生成器,
协程是一种用户态的轻量级线程。
协程、线程与进程
处理级别
协程的处理是编译器级的,进程和线程是操作系统级的。协程的实现,通常是对某个语言做出相应的提议,通过后制定相应的编译器标准,然后编译器厂商来实现该机制。虽然进程和线程看起来也在语言层次,但是内在的原理却是把操作系统现有这个东西,然后通过API暴露给用户使用,两者在这里是不同的。
进程和线程是OS通过调度算法,保存当前的上下文,然后从上次暂停的地方再次开始计算,重新开始的地方不可预期,每次CPU计算的指令数量和代码跑过的CPU时间是相关的,跑到OS分配时间的CPU时间到达后,就会被OS强制挂起。
协程是一种用户级的轻量级线程。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切换回来时,回复之前保存的寄存器上下文和栈。因此:协程能保留上一次调用时的状态(即所有局部状态的一个特定组合),每次过程重入时,就相当于进入上一次调用的状态,换种说法:进入上一次离开时所处逻辑流的位置。
在并发编程中,协程与线程相似,每个协程表示一个执行单元,有自己的本地数据,与其他协程共享全局数据和其他资源。目前主流语言基本上都选择了多线程作为并发设施,与协程相关的概念是抢占式多任务(Preemptive multitasking),而与协程相关的是协作式任务。
不管线程和进程,每次阻塞、切换都需要陷入系统调用,先让CPU跑操作系统的调度程序,然后再由调度程序决定该跑哪一个进程(线程)。而且由于抢占式调度执行顺序无法确定的特点,使用线程时需要非常小心地处理同步问题,而协程完全不存在这个问题,事件驱动和异步程序也有相同的优点。
异步与同步
线程与进程使用的都是同步机制,当发生阻塞时,性能会大幅度降低,无法充分利用CPU潜力,浪费硬件投资,更重要的是造成软件模块的铁板化,紧耦合,无法分割,不利于日后扩展和变化。
不管是进程还是线程,每次阻塞、切换都需要陷入系统调用,先让CPU跑操作系统的调度程序,然后再由调度程序决定该跑那个进程(线程)。多个线程之间在一些访问互斥的代码时,还需要加上锁,这也是导致多线程变成困难的原因之一。
当下流行的异步Server都是基于事件驱动的,事件驱动简化了编程模型,很好的解决了多线程难于编程,难于调试的问题。异步事件驱动模型中,把会导致阻塞的操作转换为一个异步操作,主线程负责发起这个异步操作,并处理这个异步操作的结果。由于所有阻塞都转化为一步操作,理论上主线程的大部分时间都是在处理实际的计算任务,少了线程调度的时间,所以这种模型的性能通常比较好。
总的来说,当单核CPU性能提升,cpu不在成为性能瓶颈时,采用异步server能简化编程模型,也能提高IO密集型应用的性能。
事件驱动
以Nginx为代表的事件驱动的异步Server正在横扫天下,那么时间驱动模型会是Server端模型的重点吗?我们可以深入了解下,事件驱动编程的模型。
事件驱动编程的构架是预先设计一个事件循环,这个事件循环程序不断地检查目前要处理的信息,根据要处理的信息运行一个触发函数。其中这个外部信息可能来自一个文件夹中的文件,也可能来自键盘和鼠标动作,或者是一个时间事件。这个触发函数,可以使系统默认的,也可以是用户注册的回调函数。
事件驱动程序设计着重于弹性和异步化上面,许多GUI框架(如windows的MFC,Android的GUI框架),Zookeeper的Watcher等都是使用了事件驱动机制。未来还会有其他的基于事件驱动的作品出现。
基于事件驱动的编程是单线程思维,其特点是异步+回调。协程也是单线程,但是它能让原来要使用异步+回调方式写的非人类代码,可以用看似同步的方式写出来。它是实现推拉互动的所谓非抢占式协作的关键。
