进程、线程、协程
- 进程、线程、协程
- 进程
- 概念
- 生命周期
- 进程的五状态模型
- 进程同步机制
- 进程通信机制
- 死锁
- 进程调度算法
- 线程
- 概念
- 生命周期
- 线程同步机制
- 互斥锁
- 信号量
- 条件变量
- 读写锁
- 线程通信机制
- 线程死锁
- 协程
- 进程、线程、协程对比
- 进程与线程比较
- 协程与线程比较
- 如何选择进程、线程、协程?
进程、线程、协程
进程
概念
进程是资源分配的最小单位,是最小的资源管理单元。
直白地讲,进程就是应用程序的启动实例。比如我们运行一个游戏,打开一个软件,就是开启了一个进程。
每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。由于进程占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。
生命周期
当程序需要运行时,操作系统将代码和所有静态数据记载到内存和进程的地址空间(每个进程都拥有唯一的地址空间,见下图所示)中,通过创建和初始化栈(局部变量,函数参数和返回地址)、分配堆内存以及与IO相关的任务,当前期准备工作完成,启动程序,OS将CPU的控制权转移到新创建的进程,进程开始运行。
PCB(Processing Control Block):操作系统对进程的控制和管理通过,PCB通常是系统内存占用区中的一个连续存区,它存放着操作系统用于描述进程情况及控制进程运行所需的全部信息(进程标识号、进程状态、进程优先级、文件系统指针以及各个寄存器的内容等),进程的PCB是系统感知进程的唯一实体。
进程的五状态模型
一个进程至少具有5种基本状态:创建态、执行状态、等待(阻塞)状态、就绪状态、终止状态。
- 创建状态:进程刚被创建需要申请一个空白PCB,向其中填写控制和管理进程的信息,完成资源分配。此时进程刚被创建,由于其他进程正占有CPU所以得不到执行,只能处于初始状态。
- 就绪状态:进程已经准备好,且已分配到所需资源,只要分配到cpu就能立即运行。
- 执行状态:进程处于就绪状态的经过调度才能到执行状态。任意时刻处于执行状态的进程只能有一个。
- 阻塞状态:正在执行的进程由于某些事件(IO请求,申请缓存区失败)而暂时无法运行,进程受到阻塞,在满足请求时进入就绪状态等待系统调用。
- 终止状态:进程结束或出现错误或系统终止,进入终止状态,无法再执行。
进程同步机制
进程同步机制的主要任务是对多个相关的进程在执行次序上进行协调,使并发执行的诸多进程之间能够按照一定的规则共享系统资源,并能很好的相互合作,从而使程序之间的执行具有可再现性。
进程间的两种制约关系:
-
间接相互制约(互斥):因为进程在并发执行的时候共享临界资源而形成的相互制约的关系,需要对临界资源互斥地访问。
-
直接制约关系(同步):多个进程之间为完成同一任务而相互合作而形成的制约关系。
临界资源:同一时刻只允许一个进程可以访问的资源。各个进程之间采取互斥方式,实现对临界资源的共享。
临界区:进程中访问临界资源的那段代码。
同步机制应遵循的规则:
-
空闲让进:当临界区的“大门”敞开时,应当允许一个请求的进入临界区的进程立即进入临界区。
-
忙则等待:当临界区的“大门”关闭时,因而其他试图进入临界区的进程必须等待,以保证对临界资源的互斥访问。
-
有限等待:对要求进入临界区的进程,应保证有限的时间能进入自已的临界区,以免陷入“死等” 状态。
-
让权等待:当进程不能进入自已的临界区时,应立即释放处理机,以免进程陷入“忙等”状态。
“忙等 ”和 “死等” 都是没能进入临界区,它们的区别如下:
- 死等: 对行死等的进程来说,这个进程可能是处于阻塞状态,等着别的进程将其唤醒(signal 原语),但是如果唤醒原语一直无法执行,对于阻塞的进程来说,就是一直处于死等的状态,是无法获得处理机的。
- 忙等:忙等状态比较容易理解,处于忙等状态的进程是一直占有处理机去不断的判断临界区是否可以进入,在此期间,进程一直在运行,这就是忙等状态。有一点需要注意的是,忙等是非常可怕的,因为处于忙等的进程会一直霸占处理机的,相当于陷入死循环了。 忙等的状态在单CPU 系统中是无法被打破的,只能系统重启解决。
进程通信机制
详见于:《进程间的通信方式》
死锁
详见于:《操作系统:死锁》
进程调度算法
详见于:《进程调度算法》
线程
概念
线程是CPU调度的最小单位,是最小的执行单元。 线程又叫做轻量级进程,线程从属于进程,是程序的实际执行者。
- 一个进程至少包含一个主线程,也可以有更多的子线程。
- 多个线程共享所属进程的资源,同时线程也拥有自己的专属资源、拥有自己的栈空间。
- 线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。
一个进程中的各个线程都有共享的资源而且是完全开放的,那么在进程运行中会出现多个线程访问同一个公共资源的问题。这种现象我们称之为线程之间产生了资源竞争,这种竞争会导致程序异常甚至崩溃。
生命周期
线程的生命周期包含5个阶段,包括:新建、就绪、运行、阻塞、死亡。当线程进入运行状态后,一般的操作系统是采用抢占式的方式来让线程获得CPU。所以CPU需要在多条线程之间切换,于是线程状态也会多次在运行、阻塞、就绪之间切换。
- 新建:使用new方法,new出来线程,此时仅仅由JAVA虚拟机为其分配内存,并初始化成员变量的值。此时仅仅是个对象。
- 就绪:就是调用的线程的start()方法后,这时候线程处于等待CPU分配资源阶段,谁先抢的CPU资源,谁开始执行;该线程进入就绪状态,JAVA虚拟机会为其创建方法调用栈和程序计数器。线程的执行是由底层平台控制, 具有一定的随机性。
- 运行:当就绪的线程被调度并获得CPU资源时,便进入运行状态,run方法定义了线程的操作和功能;(当处于就绪状态的线程获得CPU,它就会执行run()方法)。对于一个单核cpu(或者是一个内核)来说,只能同时执行一条指令,而JVM通过快速切换线程执行指令来达到多线程的,真正处理器就能同时处理一条指令,只是这种切换速度很快,我们根本不会感知到。为了线程切换后能恢复到正确的执行位置,每条线程都有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储。当一个线程开始运行后,它不可能一直持有CPU(除非该线程执行体非常短,瞬间就执行结束了)。所以,线程在执行过程中需要被中断,目的是让其它线程获得执行的CPU的机会。线程的调度细节取决于底层平台所采用的策略。
- 阻塞:在运行状态的时候,可能因为某些原因导致运行状态的线程变成了阻塞状态。原因如下:
- 等待I/O流的输入输出
- 等待网络资源,即网速问题
- 调用sleep()方法,需要等sleep时间结束
- 调用wait()方法,需要调用notify()唤醒线程
- 其他线程执行join()方法,当前线程则会阻塞,需要等其他线程执行完。
- 死亡:
- run()/call()方法执行完成,线程正常结束;
- 线程抛出一个未捕获的Exception或Error;
- 直接调用线程的stop()方法结束该线程——该方法容易导致死锁,通常不建议使用。
线程同步机制
线程同步四种方法:互斥锁、信号量、条件变量、读写锁。
互斥锁
互斥锁又称互斥体或互斥量,是最简单的一种线程同步机制。
顾名思义,当一个线程访问的时候,就会把资源“锁”上,直到访问结束才会解锁,给其他线程访问。互斥锁本身就是一个全局变量:unlock 和 lock。unlock表示当前资源可以访问,第一个访问资源的线程将互斥锁修改为lock,访问完以后再修改为unlock;lock表示线程正在访问资源,其他线程需要等待互斥锁的值为unlock才能继续访问。
该线程负责的加锁,解锁也需要该线程。
信号量
信号量控制同时访问公共资源的线程数量。当线程数量小于等于1时,这种信号量可以叫二元信号量,同理多的时候,叫多元信号量,是指同一时刻最后只有这么多个线程可以访问该资源。
信号量的取值范围必须>=0;值得一提的是信号量可以执行加一和减一的操作,而且这种操作还是原子操作来实现的。
原子操作,你可以理解为多个线程修改信号量,但是在修改值的过程中互不干扰。
具体操作流程:
- 线程访问资源时,信号量减一,访问完成加一。
- 信号量为0时候,其他访问线程需要等待,直到大于0。
信号量分类:
- 二元信号量:初始值为1,信号量的值只有0和1,一定程度上替代互斥锁进行线程同步。
- 计数信号量:初始值大于1,可以允许多个线程同一时间访问同一资源,起到限制访问个数的作用。
条件变量
功能类似现实中的门,只有打开和关闭两种状态,对应条件中的成立与不成立两种判定,一旦关闭,所有线程都不得访问该资源,一旦打开,那就恢复执行。通常,条件变量和互斥锁是搭配使用的。
条件变量的本质也是全局变量,它的功能是阻塞线程,直到收到条件成立的信号,被阻塞的程序才能继续执行。
具体流程:
- 阻塞线程,直到收到信号
- 向等待队列中一个或者所有线程发送条件成立的信号,解除被阻塞的状态。
为了避免多线程抢资源的情况发生,条件变量必须和互斥锁搭配使用。
读写锁
如果很多线程只是进行读取操作,只有少部分是写操作(修改),可以使用读写锁。
读写锁的核心思想是将线程访问共同数据发出的请求分类:
- 读请求:只读,不修改共享数据
- 写请求: 存在修改共享数据的操作
当有多个读线程的时候,他们可以同时访问,但是写线程就必须要等他们读完才能访问,反过来也是,只不过写线程必须要一个一个来访问。读线程访问的时候,读写锁称之为读锁,写线程访问的时候,读写锁称之为写锁。
线程通信机制
线程通信就是当多个线程共同操作共享的资源时,互相告知自己的状态以避免资源争夺。
线程通信主要可以分为三种方式,分别为共享内存、消息传递和管道。每种方式有不同的方法来实现:
- 共享内存:多个线程共享同一块内存区域,通过读写共享内存来实现信息交流和数据共享。需要考虑线程安全问题,可以使用互斥锁、信号量等机制来保证数据的一致性。
- 消息传递:线程之间没有公共的状态,线程之间必须通过明确的发送信息来显示的进行通信。wait/notify等待通知方式、join方式。
- 管道:通过管道来实现线程之间的通信。一个线程可以将数据写入管道,另一个线程可以从管道中读取数据。需要注意管道的大小限制和线程安全问题。
线程死锁
线程死锁指的是线程一直被阻塞的情况。比如:给线程加上互斥锁但是忘了解锁,那就会出现一直阻塞的情况。
避免死锁的建议:
- 使用互斥锁,信号量,条件变量,读写锁的时候
- 占用互斥锁的进程要及时解锁
- 通过sem_wait()函数占用信号量资源的线程,及时调用sem_post()函数进行释放
- 当线程phtread_cond_wait()函数被阻塞时,一定要保证有其他线程唤醒此线程
- 无论线程占用的是读锁还是写锁,都要及时解锁
- POSIX标准中,很多阻塞线程的函数都提供两个版本:tryxxx()、timexxx()。其中try是不会阻塞线程,time不会一直阻塞线程,多使用这两种可以大大降低死锁的概率。
- 多线程程序中,多个线程申请资源的顺序最好一致,比如线程1先申请matex1在申请matex2,而线程2先申请matex2,再申请matex1,就会发生顺序不一致导致的死锁。
协程
在多核场景下,如果是I/O密集型场景,就算开多个线程来处理,也未必能提升CPU的利用率,反而会增加线程切换的开销。另外,多线程之间假如存在临界区或者共享数据,那么同步的开销也是不可忽视的。
协程,正好可以解决上面的相关问题。
协程是一种用户态的轻量级线程。在一个用户线程上可以跑多个协程,这样就提高了单核的利用率。协程不像进程或者线程,可以让系统负责相关的调度工作,协程是处于一个线程中,系统是无感知的,所以想要在该线程中阻塞某个协程的话,就需要手工进行调度。
- 协程是一种用户态的轻量级线程,协程的调度完全由用户控制。
- 一个线程可以拥有多个协程,协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行)。
- 与其让操作系统调度,不如我自己来,这就是协程。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。
协程与线程主要区别是它将不再被内核调度,而是交给了程序自己,而线程是将自己交给内核调度,所以也不难理解golang中调度器的存在。
进程、线程、协程对比
进程与线程比较
关系:
- 一个进程内有一个或多个线程,某进程内的线程在其它进程不可见。
- 二者均可并发执行。
区别
3. 地址空间:进程有自己独立的地址空间,进程之间相互独立。线程是进程内的一个执行单元,一个进程内的所有线程共享地址空间。
4. 资源拥有:进程之间相互独立,但同一进程下的各个线程之间共享程序的内存空间(包括代码段、数据集、堆等)及一些进程级的资源(如打开文件和信号),某进程内的线程在其它进程不可见。
5. 进程是资源分配的基本单位,线程是处理器调度的基本单位。
6. 调度和切换:线程上下文切换比进程上下文切换要快得多。
协程与线程比较
- 一个线程可以多个协程,一个进程也可以单独拥有多个协程。
- 线程进程都是同步机制,而协程则是异步。
- 程能保留上一次调用时的状态,每次过程重入时,就相当于进入上一次调用的状态。
如何选择进程、线程、协程?
- IO密集型一般使用多线程或者多进程。
- CPU密集型一般使用多进程。
- 强调非阻塞异步并发的一般都是使用协程,当然有时候也是需要多进程线程池结合的,或者是其他组合方式。