进程和线程是操作系统的基本概念,许多人会有所了解,但不能较为清晰的分辨。
这里我们需要了解下面几个点:
进程是一个具有一定独立功能的程序在一个数据集上的一次动态执行的过程,是操作系统进行资源分配和调度的一个独立单位,是应用程序运行的载体。
具体来说:我们通常编写的代码只是一个存储在硬盘的静态文件,通过编译后就会生成二进制可执行文件,当我们运行这个可执行文件后,它会被装载到内存中,接着 CPU 会执行程序中的每一条指令,那么这个运行中的程序,就被称为进程(Process)。
现在我们考虑一个场景:有一个会读取硬盘文件数据的程序被执行了,那么当运行到读取文件的指令时,就会去从硬盘读取数据,但是硬盘的读写速度是非常慢的,那么在这个时候,如果 CPU 傻傻的等硬盘返回数据的话,那 CPU 的利用率是非常低的。
所以,当进程要从硬盘读取数据时,CPU 不需要阻塞等待数据的返回,而是去执行另外的进程。当硬盘数据返回时,CPU 会收到中断请求,于是 CPU 再继续运行这个进程。
中断是指计算机运行过程中,出现某些意外情况需主机干预时,机器能自动停止正在运行的程序并转入处理新情况的程序,处理完毕后又返回原被暂停的程序继续运行。
例如下面进程1与进程2中断过程:

这种多个程序、交替执行的思想,就有 CPU 管理多个进程的初步想法。
对于一个支持多进程的系统,CPU 会从一个进程快速切换至另一个进程,其间每个进程各运行几十或几百个毫秒。
虽然单核的 CPU 在某一个瞬间,只能运行一个进程。但在 1 秒钟期间,它可能会运行多个进程,这样就产生并行的错觉,实际上这是并发。
在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行。
这里划重点就是:同一个处理机上运行的程序,也就是在一个CPU上不断切换程序让他们看一了『同时运行』一样
并行(Parallel),当系统有一个以上CPU时,当一个CPU执行一个进程时,另一个CPU可以执行另一个进程,两个进程互不抢占CPU资源,可以同时进行,这种方式我们称之为并行(Parallel)。
这里面有一个很重要的点,那就是系统要有多个CPU才会出现并行。在有多个CPU的情况下,才会出现真正意义上的『同时运行』。
举例说明:
我们两个人在吃午饭。你在吃饭的整个过程中,吃了米饭、吃了蔬菜、吃了牛肉。吃米饭、吃蔬菜、吃牛肉这三件事其实就是并发执行的。
对于你来说,整个过程中看似是同时完成的的。但其实你是在吃不同的东西之间来回切换的。
还是我们两个人吃午饭。在吃饭过程中,你吃了米饭、蔬菜、牛肉。我也吃了米饭、蔬菜和牛肉。
我们两个人之间的吃饭就是并行的。两个人之间可以在同一时间点一起吃牛肉,或者一个吃牛肉,一个吃蔬菜。之间是互不影响的。
二者的区别图如下:

进程是能拥有资源和独立运行的最小单位,也是程序执行的最小单位。也可以具体的说:线程是进程当中的一条执行流程。
任务调度采用的是时间片轮转的抢占式调度方式,而进程是任务调度的最小单位,每个进程有各自独立的一块内存,使得各个进程之间内存地址相互隔离。
随着计算机的发展,对CPU的要求越来越高,进程之间的切换开销较大,已经无法满足越来越复杂的程序的要求了。于是就发明了线程,线程是程序执行中一个单一的顺序控制流程,是程序执行流的最小单元。
同一个进程内多个线程之间可以共享代码段、数据段、打开的文件等资源,但每个线程各自都有一套独立的寄存器和栈,这样可以确保线程的控制流是相对独立的。
例如下图的每一块都是一个进程,在每一个的进程内存中线程是其中一次的执行流程。

操作系统运行过程中主要有三种线程:
总的来说就是多个线程/进程在某一运行环境中执行
多进程:多进程指的是在同一个时间里,同一个计算机系统中如果允许两个或两个以上的进程处于运行状态。多进程带来的好处是明显的,比如你可以听歌的同时,打开编辑器敲代码,编辑器和听歌软件的进程之间丝毫不会相互干扰。
多线程是指程序中包含多个执行流,即在一个程序中可以同时运行多个不同的线程来执行不同的任务,也就是说允许单个程序创建多个并行执行的线程来完成各自的任务。
看完了进程线程的相关理解,下面我们再来看一个非常重要的概念:上下文
什么是上下文?
这个其实在之前Spring中也有理解,上下文可以简单理解为一个程序运行的环境,在这个环境里提供程序运行必要的依赖,比如说存储、计数等
什么是上下文切换
那么上下文切换就可以理解为是运行程序间环境的切换。
各个进程之间是共享 CPU 资源的,在不同的时候进程之间需要切换,让不同的进程可以在 CPU 执行,那么这个一个进程切换到另一个进程运行的过程,称为进程的上下文切换。
下面我们来看一下具体的进程上下文切换的内容:
进程的上下文切换到底是切换什么呢?
进程是由内核管理和调度的,所以进程的切换只能发生在内核态。
所以,进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。
通常,会把交换的信息保存在进程的 PCB,当要运行另外一个进程的时候,我们需要从这个进程的 PCB 取出上下文,然后恢复到 CPU 中,这使得这个进程可以继续执行,如下图所示:

大家需要注意,进程的上下文开销是很关键的,我们希望它的开销越小越好,这样可以使得进程可以把更多时间花费在执行程序上,而不是耗费在上下文切换。
以上,就是发生进程上下文切换的常见场景了。
在前面我们知道了,线程与进程最大的区别在于:线程是调度的基本单位,而进程则是资源拥有的基本单位。
所以,所谓操作系统的任务调度,实际上的调度对象是线程,而进程只是给线程提供了虚拟内存、全局变量等资源。
对于线程和进程上下文,我们可以这么理解:
另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。
那么线程上下文切换的是什么?
这还得看线程是不是属于同一个进程:
所以,线程的上下文切换相比进程,开销要小很多。
在学习过程当中还时常会碰见协程这个词,下面来简单看一下它的概念,只需要简单理解即可
协程:协程是微线程,在子程序内部执行,可在子程序内部中断,转而执行别的子程序,在适当的时候再返回来接着执行。
(1)协程执行效率极高。协程直接操作栈基本没有内核切换的开销,所以上下文的切换非常快,切换开销比线程更小。
(2)协程不需要多线程的锁机制,因为多个协程从属于一个线程,不存在同时写变量冲突,效率比线程高。
(3)一个线程可以有多个协程。
(1)协程调用跟切换比线程效率高:协程执行效率极高。协程不需要多线程的锁机制,可以不加锁的访问全局变量,所以上下文的切换非常快。
(2)协程占用内存少:执行协程只需要极少的栈内存(大概是4~5KB),而默认情况下,线程栈的大小为1MB。
(3)切换开销更少:协程直接操作栈基本没有内核切换的开销,所以切换开销比线程少。
快得多(刚才对上下文的探究)。进程都希望自己能够占用 CPU 进行工作,那么这涉及到前面说过的进程上下文切换。
一旦操作系统把进程切换到运行状态,也就意味着该进程占用着 CPU 在执行,但是当操作系统把进程切换到其他状态时,那就不能在 CPU 中执行了,于是操作系统会选择下一个要运行的进程。
选择一个进程运行这一功能是在操作系统中完成的,通常称为调度程序(scheduler)。
在进程的生命周期中,当进程从一个运行状态到另外一状态变化的时候,其实会触发一次调度。
比如,以下状态的变化都会触发操作系统的调度:
因为,这些状态变化的时候,操作系统需要考虑是否要让新的进程给 CPU 运行,或者是否让当前进程从 CPU 上退出来而换另一个进程运行。
另外,如果硬件时钟提供某个频率的周期性中断,那么可以根据如何处理时钟中断 ,把调度算法分为两类:
调度的时候要遵循五个原则:

针对上面的五种调度原则,总结成如下:
小总结:其实这么多调度原则,目的就是要使得进程切换的要“快”。
不同的调度算法适用的场景也是不同的。
下面,说说在单核 CPU 系统中常见的调度算法。
最简单的一个调度算法,就是非抢占式的先来先服务(First Come First Serve, FCFS)算法了。
顾名思义,先来后到,每次从就绪队列选择最先进入队列的进程,然后一直运行,直到进程退出或被阻塞,才会继续从队列中选择第一个进程接着运行。
例如下面的队列,就会按照:ABCD的顺序执行进程。

这似乎很公平,但是当一个长作业先运行了,那么后面的 ,不利于短作业,如下面例子:

FCFS 对长作业有利,适用于 CPU 繁忙型作业的系统,而不适用于 I/O 繁忙型作业的系统。
最短作业优先(Shortest Job First, SJF)调度算法同样也是顾名思义,它会优先选择运行时间最短的进程来运行,这有助于提高系统的吞吐量。
例如下面短作业会被排到前面先运行:

这显然对长作业不利,很容易造成一种极端现象。
比如,一个长作业在就绪队列等待运行,而这个就绪队列有非常多的短作业,那么就会使得长作业不断的往后推,周转时间变长,致使长作业长期不会被运行。
前面的「先来先服务调度算法」和「最短作业优先调度算法」都没有很好的权衡短作业和长作业。
那么,高响应比优先 (Highest Response Ratio Next, HRRN)调度算法主要是权衡了短作业和长作业。
每次进行进程调度时,先计算「响应比优先级」,然后把「响应比优先级」最高的进程投入运行,「响应比优先级」的计算公式:

从上面的公式,可以发现:
注意:因为进程要求服务的时间是不可预知的,所以,高响应比优先调度算法是「理想型」的调度算法,现实中是实现不了的。
最古老、最简单、最公平且使用最广的算法就是时间片轮转(Round Robin, RR)调度算法。

每个进程被分配一个时间段,称为时间片(Quantum),即允许该进程在该时间段中运行。
执行原理图如下所示:

另外,时间片的长度就是一个很关键的点:
一般来说,时间片设为 20ms~50ms 通常是一个比较合理的折中值。
前面的「时间片轮转算法」做了个假设,即让所有的进程同等重要,也不偏袒谁,大家的运行时间都一样。
但是,对于多用户计算机系统就有不同的看法了,它们希望调度是有优先级的,即希望调度程序能从就绪队列中选择最高优先级的进程进行运行,这称为最高优先级(*Highest Priority First,HPF*)调度算法。
进程的优先级可以分为,静态优先级和动态优先级:
该算法也有两种处理优先级高的方法,非抢占式和抢占式:
但是依然有缺点,可能会导致低优先级的进程永远不会运行。
多级反馈队列(Multilevel Feedback Queue)调度算法是「时间片轮转算法」和「最高优先级算法」的综合和发展。
顾名思义:

工作原理如下:
可以发现,对于短作业可能可以在第一级队列很快被处理完。对于长作业,如果在第一级队列处理不完,可以移入下次队列等待被执行,虽然等待的时间变长了,但是运行时间也变更长了,所以该算法很好的兼顾了长短作业,同时有较好的响应时间。
进程切换时需要刷新TLB并获取新的地址空间,然后切换硬件上下文和内核栈;线程切换时只需要切换硬件上下文和内核栈。
进程是程序的动态表现。 一个程序进行起来后,会使用很多资源,比如使用寄存器,内存,文件等。每当切换进程时,必须要考虑保存当前进程的状态。状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开的文件描述符的集合,这个状态叫做上下文(Context)。
可见,想要切换进程,保存的状态还不少。不仅如此,由于虚拟内存机制,进程切换时需要刷新TLB并获取新的地址空间。
线程存在于进程中,一个进程可以有一个或多个线程。线程是运行在进程上下文中的逻辑流,这个线程可以独立完成一项任务。同样线程有自己的上下文,包括唯一的整数线程ID, 栈、栈指针、程序计数器、通用目的寄存器和条件码。可以理解为线程上下文是进程上下文的子集。
由于保存线程的上下文明显比进程的上下文小,因此系统切换线程时,必然开销更小。
为了提高计算机系统的效率.增强计算机系统内各种硬件的并行操作能力.操作系统要求程序结构必须适应并发处理的需要.为此引入了进程的概念。
在进程并行时,需要考虑进程间的通信,进程间通信主要有以下几种方式:匿名管道、命名管道、信号、消息队列、共享内存、信号量、Socket。
管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。
匿名管道,由于没有名字,只能用于亲缘关系的进程间通信。为了克服这个缺点,提出了有名管道(FIFO)。
有名管道不同于匿名管道之处在于它提供了一个路径名与之关联,以有名管道的文件形式存在于文件系统中,这样,即使与有名管道的创建进程不存在亲缘关系的进程,只要可以访问该路径,就能够彼此通过有名管道相互通信,因此,通过有名管道不相关的进程也能交换数据。值的注意的是,有名管道严格遵循先进先出(first in first out) ,对匿名管道及有名管道的读总是从开始处返回数据,对它们的写则把数据添加到末尾。它们不支持诸如lseek()等文件定位操作。有名管道的名字存在于文件系统中,内容存放在内存中。
信号是Linux系统中用于进程间互相通信或者操作的一种机制,信号可以在任何时候发给某一进
程,而无需知道该进程的状态。
以下列出几个常用的信号:
| 信号 | 描述 |
|---|---|
SIGHUP | 当用户退出终端时,由该终端开启的所有进程都会接收到这个信号,默认动作为终止进程。 |
SIGINT | 程序终止(interrupt)信号, 在用户键入INTR字符(通常是 Ctrl+C )时发出,用于通知前台进程组终止进程。 |
SIGQUIT | 和 SIGINT 类似, 但由QUIT字符(通常是 Ctrl+\ )来控制. 进程在因收到 SIGQUIT 退出时会产生 core 文件, 在这个意义上类似于一个程序错误信号。 |
SIGKILL | 用来立即结束程序的运行. 本信号不能被阻塞、处理和忽略。 |
SIGTERM | 程序结束(terminate)信号, 与 SIGKILL 不同的是该信号可以被阻塞和处理。通常用来要求程序自己正常退出。 |
SIGSTOP | 停止(stopped)进程的执行. 注意它和terminate以及interrupt的区别:该进程还未结束, 只是暂停执行. 本信号不能被阻塞, 处理或忽略。 |
消息队列是存放在内核中的消息链表,每个消息队列由消息队列标识符表示。
与管道(无名管道:只存在于内存中的文件;命名管道:存在于实际的磁盘介质或者文件系统)不同的是消息队列存放在内核中,只有在内核重启(即,操作系统重启)或者显示地删除一个消息队列时,该消息队列才会被真正的删除。
另外与管道不同的是,消息队列在某个进程往一个队列写入消息之前,并不需要另外某个进程在该队列上等待消息的到达。
消息队列特点总结:
(1)消息队列是消息的链表,具有特定的格式,存放在内存中并由消息队列标识符标识.
(2)消息队列允许一个或多个进程向它写入与读取消息.
(3)管道和消息队列的通信数据都是先进先出的原则。
(4)消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取.比FIFO更有优势。
(5)消息队列克服了信号承载信息量少,管道只能承载无格式字 节流以及缓冲区大小受限等缺。
(6)目前主要有两种类型的消息队列:POSIX消息队列以及System V消息队列,系统V消息队列目前被大量使用。系统V消息队列是随内核持续的,只有在内核重起或者人工删除时,该消息队列才会被删除。
进程间本身的内存是相互隔离的,而共享内存机制相当于给两个进程开辟了一块二者均可访问的内存空间,这时,两个进程便可以共享一些数据了。但是,多进程同时占用资源会带来一些意料之外的情况,这时,我们往往会采用上述的信号量来控制多个进程对共享内存空间的访问。
信号量主要用来解决进程和线程间并发执行时的同步问题,进程同步是并发进程为了完成共同任务采用某个条件来协调他们的活动,这是进程之间发生的一种直接制约关系。
对信号量的操作分为P操作和V操作,P操作是将信号量的值减一,V操作是将信号量的值加一。当信号量的值小于等于0之后,再进行P操作时,当前进程或线程会被阻塞,直到另一个进程或线程执行了V操作将信号量的值增加到大于0之时。锁也是用的这种原理实现的。(p:pass减1)信号量我们需要定义信号量的数量,设定初始值,以及决定何时进行PV操作。
使用本地端口进行socket通信
锁机制:包括互斥锁、条件变量、读写锁互斥锁。锁提供了以排他方式防止数据结构被并发修改的方法。读写锁允许多个线程同时读共享数据,而对写操作是互斥的。条件变量可以以原子的方式阻塞进程,直到某个特定条件为真为止。对条件的测试是在互斥锁的保护下进行的。条件变量始终与互斥锁一起使用。
信号量机制(Semaphore):包括无名线程信号量和命名线程信号量
信号机制(Signal):类似进程间的信号处理线程间的通信目的主要是用于线程同步,所以线程没有像进程通信中的用于数据交换的通信机制。
所以,在一个进程的活动期间至少具备三种基本状态,即运行状态、就绪状态、阻塞状态。

上图中各个状态的意义:
当然,进程还有另外两个基本状态:
于是,一个完整的进程状态的变迁如下图:

再来详细说明一下进程的状态变迁:
如果有大量处于阻塞状态的进程,进程可能会占用着物理内存空间,显然不是我们所希望的,毕竟物理内存空间是有限的,被阻塞状态的进程占用着物理内存就一种浪费物理内存的行为。
所以,在虚拟内存管理的操作系统中,通常会把阻塞状态的进程的物理内存空间换出到硬盘,等需要再次运行的时候,再从硬盘换入到物理内存。

那么,就需要一个新的状态,来描述进程没有占用实际的物理内存空间的情况,这个状态就是挂起状态。这跟阻塞状态是不一样,阻塞状态是等待某个事件的返回。
另外,挂起状态可以分为两种:
这两种挂起状态加上前面的五种状态,就变成了七种状态变迁(留给我的颜色不多了),见如下图:

导致进程挂起的原因不只是因为进程所使用的内存空间不在物理内存,还包括如下情况:
Ctrl+Z 挂起进程;进程的创建、终止、阻塞、唤醒的过程,这些过程也就是进程的控制。
创建进程
操作系统允许一个进程创建另一个进程,而且允许子进程继承父进程所拥有的资源。
创建进程的过程如下:
终止进程
进程可以有 3 种终止方式:正常结束、异常结束以及外界干预(信号 kill 掉)。
当子进程被终止时,其在父进程处继承的资源应当还给父进程。而当父进程被终止时,该父进程的子进程就变为孤儿进程,会被 1 号进程收养,并由 1 号进程对它们完成状态收集工作。
终止进程的过程如下:
阻塞进程
当进程需要等待某一事件完成时,它可以调用阻塞语句把自己阻塞等待。而一旦被阻塞等待,它只能由另一个进程唤醒。
阻塞进程的过程如下:
唤醒进程
进程由「运行」转变为「阻塞」状态是由于进程必须等待某一事件的完成,所以处于阻塞状态的进程是绝对不可能叫醒自己的。
如果某进程正在等待 I/O 事件,需由别的进程发消息给它,则只有当该进程所期待的事件出现时,才由发现者进程用唤醒语句叫醒它。
唤醒进程的过程如下:
进程的阻塞和唤醒是一对功能相反的语句,如果某个进程调用了阻塞语句,则必有一个与之对应的唤醒语句。
在线程的生命周期中,它要经过新建(New)、就绪(Ready)、运行(Running)、阻塞(Blocked)和死亡(Dead)5种状态。尤其是当线程启动以后,它不可能一直“霸占”着CPU独自运行,所以CPU需要在多条线程之间切换,于是线程状态也会多次在运行、就绪之间切换。
线程5种状态的转换关系,如下图所示:

当程序使用new关键字创建了一个线程之后,该线程就处于新建状态,此时它和其他的Java对象一样,仅仅由Java虚拟机为其分配内存,并初始化其成员变量的值。此时的线程对象没有表现出任何线程的动态特征,程序也不会执行线程的线程执行体。
当线程对象调用了start()方法之后,该线程处于就绪状态,Java虚拟机会为其创建方法调用栈和程序计数器,处于这个状态中的线程并没有开始运行,只是表示该线程可以运行了。至于该线程何时开始运行,取决于JVM里线程调度器的调度。
如果处于就绪状态的线程获得了CPU,开始执行run()方法的线程执行体,则该线程处于运行状态,如果计算机只有一个CPU,那么在任何时刻只有一个线程处于运行状态。当然,在一个多处理器的机器上,将会有多个线程并行执行;当线程数大于处理器数时,依然会存在多个线程在同一个CPU上轮换的现象。
当一个线程开始运行后,它不可能一直处于运行状态,线程在运行过程中需要被中断,目的是使其他线程获得执行的机会,线程调度的细节取决于底层平台所采用的策略。对于采用抢占式策略的系统而言,系统会给每个可执行的线程一个小时间段来处理任务。当该时间段用完后,系统就会剥夺该线程所占用的资源,让其他线程获得执行的机会。当发生如下情况时,线程将会进入阻塞状态:
线程调用sleep()方法主动放弃所占用的处理器资源。
线程调用了一个阻塞式IO方法,在该方法返回之前,该线程被阻塞。
线程试图获得一个同步监视器,但该同步监视器正被其他线程所持有。
线程在等待某个通知(notify)。
程序调用了线程的suspend()方法将该线程挂起。但这个方法容易导致死锁,所以应该尽量避免使用该方法。
针对上面几种情况,当发生如下特定的情况时可以解除上面的阻塞,让该线程重新进入就绪状态:
线程会以如下三种方式结束,结束后就处于死亡状态:
run()或call()方法执行完成,线程正常结束。
线程抛出一个未捕获的Exception或Error。
直接调用该线程的stop()方法来结束该线程,该方法容易导致死锁,通常不推荐使用。
在操作系统中,是用进程控制块(process control block,PCB)数据结构来描述进程的。
PCB 是进程存在的唯一标识,这意味着一个进程的存在,必然会有一个 PCB,如果进程消失了,那么 PCB 也会随之消失。
不一 一列举了,PCB 结构包含信息还是比较多的:
进程描述信息:
进程控制和管理信息:
资源分配清单:
CPU 相关信息:
其他信息…
通常是通过链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列。比如:
那么,就绪队列和阻塞队列链表的组织形式如下图:

除了链接的组织方式,还有索引方式,它的工作原理:将同一状态的进程组织在一个索引表中,索引表项指向相应的 PCB,不同状态对应不同的索引表。
一般会选择链表,因为可能面临进程创建,销毁等调度导致进程状态发生变化,所以链表能够更加灵活的插入和删除。