进程、线程和协程详解

进程、线程和协程详解

  • 前言
  • 一、进程是什么?
    • 进程
    • 知识小点
  • 二、线程
    • 知识小点
  • 三、进程间的通信(IPC)
    • 临界区
    • 忙等互斥
      • 屏蔽中断
      • 锁变量
      • 严格轮询法
      • Peterson 解法
      • TSL 指令
      • 睡眠与唤醒
    • 管道
      • Linux管道的实现原理
    • 信号量
    • 信号
    • 共享内存
    • 消息队列
    • 套接字
    • 进程调度
  • 四、线程之间的通信
    • 锁机制
    • 信号量机制(Semaphore)
    • 屏障(barrier)
    • 小结
  • 五、协程
  • 总结


前言

本篇文章记录的是在学习操作系统的过程中的一些总结和笔记,本篇重点讲解进程、线程和协程的概念,以及三者之间的关系等等。


提示:以下是本篇文章正文内容,下面案例可供参考

一、进程是什么?

为了在讨论进程之前对其有一个大概的了解,首先不可避免的一个话题就是操作系统,我们都知道,现代的计算机软件都是基于操作系统之上来运行的,操作系统封装了计算机底层的硬件资源(如键盘、显示器、磁盘或主存等),当我们的应用程序想要访问和使用这些计算机资源的时候,唯一的方式就是依靠操作系统提供的服务(系统调用),进而在计算机硬件资源、操作系统以及应用程序之间有以下的一种关系

进程、线程和协程详解_第1张图片
操作系统通过几个基本的抽象概念(进程、虚拟内存和文件)来实现两个基本的功能:1)防止硬件被失控的应用程序滥用,2)向应用程序提供简单一致的机制来控制复杂而又大不相同的低级硬件设备。从宏观的角度来讲,文件是对IO设备的抽象,虚拟内存是对主存和磁盘IO设备的抽象,进程则是对处理器、主存和IO设备的抽象表示。下面我们重点讨论进程的概念,另外两个其他的概念以后再讨论。

进程

在程序运行的时候,操作系统提供了一种假象,使得程序认为自己是独占整个系统资源的(处理器、主存和IO设备),这种假象是通过进程来实现的。当然,进程的定义各说纷云,本质上来说,进程是操作系统对一个正在运行的程序的一种抽象。一个系统里面可以同时运行多个进程,而每一个进程又好像是在独占的使用硬件资源,这是通过处理器在进程之间不停的切换来实现的。操作系统实现这种交错运行的机制称为上下文切换。这里在另外的补充一点,进程之间的切换是有操作系统内核管理的,而内核就是操作系统的核心代码,常驻在主存部分。进程的抽象概念在代码实现中是使用进程控制块(PCB)来实现的,在内核代码中就是一个包含了进程运行时的各种数据的结构体(task_struct)。
这里在结合上面说到的系统调用(system call)来做一个整体的总结,当应用程序需要操作系统的某些操作的时候,他就会执行一条特殊的系统调用指令,将控制权传递给内核(有用户态转为内核态),然后内核执行被请求的操作并且返回给应用程序。

知识小点

Linux理论上可以创建多少个进程?
我们知道,每一个进程都有一个进程标识符(pid),而一般的Linux系统都是使用pid_t的类型来表示pid变量的,而pid_t类型一般都是int类型的,因此,最大的进程数量取决于pid_t变量的最大值。当然凡事都不会有绝对的,也可以通过系统配置文件来进行修改这个值的上限

二、线程

相比于进程来说,线程的概念就相对而言简单一些。一般来说,进程是系统资源分配和调度的基本单位,线程是处理器(CPU)资源分配和调度的基本单位。通常情况下,一个进程里面只有一个控制流,但是在现代的系统中,为了充分的利用系统资源,一个进程实际上是可以有多个称为线程的执行单元的,每一个线程都运行在进程的上下文(进程地址空间)中,一个进程中的线程除了自己独有的数据之外,都是共享同一个进程里面的数据的,故在多线程之间共享数据比在多进程之间共享数据容易很多。进程在实现上有分为内核级线程和用户级线程,以及内核级和用户级混合的线程。在Linux系统中,是没有真正意义上的线程的,它将线程的实现为一个轻量级的进程,其通过在进程之间共享数据的方式来实现线程的。相对于Linux系统的其他系统,比如windows系统就实现了区别于进程的线程。

知识小点

进程和线程的区别和联系
区别如下:

  • 进程是对运行时程序的封装,是系统进行资源分配和调度的基本单元,而线程是进程的子任务,是处理器(CPU)分配和调度的基本单元。
  • 一个进程可以有多个线程,但是一个线程只能属于一个进程。
  • 进程的创建需要系统分配内存和CPU,文件句柄等资源,销毁时也要进行相应的回收,所以进程的管理开销很大;但是线程的管理开销则很小。
  • 进程之间不会相互影响;而一个线程崩溃可能会导致进程崩溃,从而影响同个进程里面的其他线程。
  • 进程使用fork或者vfork来创建,而线程使用pthread_create来创建

联系如下:
进程与线程之间的关系:线程是存在进程的内部,一个进程中可以有多个线程,一个线程只能存在一个进程中。

进程之间私有和共享的资源:

  • 私有:地址空间、堆、全局变量、栈、寄存器
  • 共享:代码段,公共数据,进程目录,进程 ID

线程之间私有和共享的资源:

  • 私有:线程栈,寄存器,程序计数器
  • 共享:堆,地址空间,全局变量,全局静态变量

三、进程间的通信(IPC)

进程之间的通信方式主要有六种:管道、信号量、消息队列、信号、共享内存和套接字,下面从数据共享和状态同步开始来依次讲解各个通信方式。

临界区

资源的共享会造成数据的竞争问题,为了避免这种竞争问题的方式就是禁止多个进程在同一时刻对共享资源进行读写操作。也就是说,我们需要一种可以阻止多个进程同时去读写共享资源,最简单的实现方式就是使用互斥条件,此方式可以保证多个进程不会同时去读写共享资源。

为了讨论的方便,人为的把对共享内存进行访问的程序片段称作临界区域(critical region)或临界区(critical section)。只要保证使得不同的进程不能同时处于临界区,即可避免数据竞争问题。

尽管临界区的设计避免了竞争问题,但是不能确保并发线程同时访问共享数据的正确性和高效性,因此,进一步抽象得到一个好的解决方案应该包含以下必要条件:

  • 任何时候两个进程不能同时处于临界区
  • 不应对 CPU 的速度和数量做任何假设
  • 位于临界区外的进程不得阻塞其他进程
  • 不能使任何进程无限等待进入临界区

忙等互斥

在上一小节讨论了实现临界区的方案是使用互斥,本节讨论实现互斥的各种方案,在这些方案中,当一个进程正忙于更新其临界区的共享内存的时候,保证了没有其他进程会进入临界区。

屏蔽中断

最简单的实现方案是屏蔽中断,在进程进入到临界区的时候立刻屏蔽中断,在离开临界区的时候再重启。因为屏蔽中断后,系统的时钟中断也会被屏蔽掉,而CPU只会在发生时钟中断或其他系统中断的时候才会进行进程间的切换,这样就导致了CPU不会切换到其他的进程去执行,只会执行当前的进程。

上述方案存在的一个问题是,如果经过长时间的执行后,进程仍然没有离开临界区,那么系统中断就一直启动不了,如果此时是单处理器系统,那么整个系统就会终止服务,进而违反了在上一节提到的条件–不能使任何进程无限等待进入临界区。如果是多处理器的话,屏蔽中断仅仅对执行disable指令的CPU有效,其他的CPU仍然可以继续执行,并且可以访问共享的内存区域。

故一般的情况下,屏蔽中断只会用于内核执行期间,而对于用户进程或线程来说,屏蔽中断并不是一项通用的互斥解决方案。

锁变量

软件层面也有对应的解决方案,考虑如下的场景,使用一个共享的变量(锁)来记录进程进入临界区的状态,将锁变量初始化为0,当一个进程想要进入临界区的时候会执行以下的逻辑:

  • 进程会先查看此锁变量是否为0,如果为0,说明当前没有任何进程处于临界区,进而可以进入临界区,并且将该锁变量设置为1
  • 进程在进入临界区时检查该锁变量的值为1,意味着此时有其他的进程处于临界区,因此需要等待锁变量为0之后才能进入临界区

存在的问题:由于这个锁变量不是原子性的操作,因此对于锁变量的读写会存在竞争问题

严格轮询法

// 进程0的代码
while(TRUE){
  while(turn == 0){
    /* 进入关键区域 */
    critical_region();
    turn = 1;
    /* 离开关键区域 */
    noncritical_region();
  }
}

//进程1的代码
while(TRUE){
  while(turn == 1){
    critical_region();
    turn = 0;
    noncritical_region();
  }
}

在上面的代码中,使用turn来记录轮到哪个进程进入临界区,进程会在不停的检测turn的值,这种连续检查一个变量的值知道某个特殊的值出现的情况称为忙等待(busying waiting)。这种忙等待的方式是浪费CPU时间的,只有在有理由认为等待时间是非常短的情况下,才能够使用忙等待,用于忙等待的锁称为自旋锁(spinlock)。

存在的问题:对于turn变量的操作是不存在保护措施的,这就导致另一个数据竞争问题。

Peterson 解法

将锁变量与警告变量相结合,最早提出了一个不需要严格轮换的软件互斥算法,后来, G.L.Peterson 发现了一种简单很多的互斥算法,它的算法如下:

#define FALSE 0
#define TRUE  1
/* 进程数量 */
#define N     2													

/* 现在轮到谁 */
int turn;					

/* 所有值初始化为 0 (FALSE) */
int interested[N];											

/* 进程是 0 或 1 */
void enter_region(int process){					
  
  /* 另一个进程号 */
  int other;														
  
  /* 另一个进程 */
  other = 1 - process;				
  
  /* 表示愿意进入临界区 */
  interested[process] = TRUE;						
  turn = process;
  
  /* 空循环 */
  while(turn == process 
        && interested[other] == true){} 
}

void leave_region(int process){
  /* 表示离开临界区 */
  interested[process] == FALSE;				 
}

在使用共享变量时(即进入其临界区)之前,各个进程使用各自的进程号 0 或 1 作为参数来调用 enter_region,这个函数调用在需要时将使进程等待,直到能够安全的进入临界区。在完成对共享变量的操作之后,进程将调用 leave_region 表示操作完成,并且允许其他进程进入。

TSL 指令

一些计算机也提供了硬件层面的解决方案,如下所示:

TSL RX,LOCK	

上述指令称为测试并加锁(test and set lock),将一个内存字lock读到寄存器RX 中,然后在该内存地址上存储一个非零值。该指令保证了读和写是一体的不能分割,一同执行的。此指令是通过锁住内存总线的方式来禁止其他CPU在这指令结束之前访问内存。

与之前的屏蔽中断的方式不一样的是,禁用中断并不能保证一个处理器在读写操作之间另一个处理器对内存的读写。让其他处理器无法读写的最好的方式就是锁住内存总线。

为了使用 TSL 指令,要使用一个共享变量 lock 来协调对共享内存的访问。当 lock 为 0 时,任何进程都可以使用 TSL 指令将其设置为 1,并读写共享内存。当操作结束时,进程使用 move 指令将 lock 的值重新设置为 0 。

这条指令如何防止两个进程同时进入临界区呢?下面是解决方案

enter_region:
			| 复制锁到寄存器并将锁设为1
			TSL REGISTER,LOCK              
			| 锁是 0 吗?
  		CMP REGISTER,#0						 		
  		| 若不是零,说明锁已被设置,所以循环
  		JNE enter_region					 		
  		| 返回调用者,进入临界区
  		RET												 
       
leave_region:

			| 在锁中存入 0
			MOVE LOCK,#0			      
      | 返回调用者
  		RET	

进程在进入临界区之前会先调用 enter_region,判断是否进行循环,如果lock 的值是 1 ,进行无限循环,如果 lock 是 0,不进入循环并进入临界区。在进程从临界区返回时它调用 leave_region,这会把 lock 设置为 0 。

睡眠与唤醒

上面解法中的 Peterson和TSL解法都是正确的,但是它们都有忙等待的缺点。这些解法的本质上都是一样的,先检查是否能够进入临界区,若不允许,则该进程将原地等待,直到允许为止。

这种方式不但浪费了 CPU 时间,而且还可能引起意想不到的结果。考虑一台计算机上有两个进程,这两个进程具有不同的优先级,H 是属于优先级比较高的进程,L 是属于优先级比较低的进程。进程调度的规则是不论何时只要 H 进程处于就绪态 H 就开始运行。在某一时刻,L 处于临界区中,此时 H 变为就绪态,准备运行(例如,一条 I/O 操作结束)。现在 H 要开始忙等,但由于当 H 就绪时 L 就不会被调度,L 从来不会有机会离开关键区域,所以 H 会变成死循环,有时将这种情况称为优先级反转问题(priority inversion problem)。

现在让我们看一下进程间的通信原语,这些原语在不允许它们进入关键区域之前会阻塞而不是浪费 CPU 时间,最简单的是 sleep 和 wakeup。Sleep 是一个能够造成调用者阻塞的系统调用,也就是说,这个系统调用会暂停直到其他进程唤醒它。wakeup 调用有一个参数,即要唤醒的进程。还有一种方式是 wakeup 和 sleep 都有一个参数,即 sleep 和 wakeup 需要匹配的内存地址。

作为sleep 和 wakeup原语的应用,可以考虑生产者和消费者问题。

管道

管道是半双工的,双方需要通信的时候,需要建立两个管道。管道的实质是一个内核缓冲区,进程以先进先出的方式从缓冲区存取数据:管道一端的进程顺序地将进程数据写入缓冲区,另一端的进程则顺序地读取数据,该缓冲区可以看做一个循环队列,读和写的位置都是自动增加的,一个数据只能被读一次,读出以后在缓冲区都不复存在了。当缓冲区读空或者写满时,有一定的规则控制相应的读进程或写进程是否进入等待队列,当空的缓冲区有新数据写入或满的缓冲区有数据读出时,就唤醒等待队列中的进程继续读写。管道是最容易实现的。管道又分为有名管道(命名管道)和无名管道(匿名管道)。

  • 有名管道:一种半双工的通信方式,它允许无亲缘关系进程间的通信,有名管道是建立在实际的磁盘介质或文件系统(而不是只存在于内存中)上有自己名字的文件,任何进程可以在任何时间通过文件名或路径名与该文件建立联系。为了实现有名管道,引入了一种新的文件类型——FIFO文件(遵循先进先出的原则)。实现一个有名管道实际上就是实现一个FIFO文件。有名管道一旦建立,之后它的读、写以及关闭操作都与普通管道完全相同。虽然FIFO文件的inode节点在磁盘上,但是仅是一个节点而已,文件的数据还是存在于内存缓冲页面中,和普通管道相同。
    • 优点:可以实现任意关系的进程间的通信
    • 缺点:
      1. 长期存于系统中,使用不当容易出错
      2. 缓冲区有限
  • 无名管道:一种半双工的通信方式,只能在具有亲缘关系的进程间使用(父子进程),在linux系统中可以通过系统调用建立起一个单向的通信管道,且这种关系只能由父进程来建立
    • 优点:简单方便
    • 缺点:
      1. 局限于单向通信
      2. 只能创建在它的进程以及其有亲缘关系的进程之间
      3. 缓冲区有限

Linux管道的实现原理

一个管道实际上就是个只存在于内存中的文件,对这个文件的操作要通过两个已经打开文件进行,它们分别代表管道的两端。
管道是一种特殊的文件,它不属于某一种文件系统,而是一种独立的文件系统,有其自己的数据结构。

管道实现机制:

  • 管道是由内核管理的一个缓冲区,相当于我们放入内存中的一个纸条。
  • 管道的一端连接一个进程的输出。这个进程会向管道中放入信息。
  • 管道的另一端连接一个进程的输入,这个进程取出被放入管道的信息。
  • 一个缓冲区不需要很大一般为4K大小,它被设计成为环形的数据结构,以便管道可以被循环利用。
  • 当管道中没有信息的话,从管道中读取的进程会等待,直到另一端的进程放入信息。
  • 当管道被放满信息的时候,尝试放入信息的进程会等待,直到另一端的进程取出信息。
  • 当两个进程都终结的时候,管道也自动消失。

管道的创建过程如下:
进程、线程和协程详解_第2张图片

信号量

信号量是一个计数器,可以用来控制多个进程对共享资源的访问。信号量只有等待和发送两种操作。等待(P(sv))就是将其值减一或者挂起进程,发送(V(sv))就是将其值加一或者将进程恢复运行。

信号

信号是Linux系统中用于进程之间通信或操作的一种机制,信号可以在任何时候发送给某一进程,用来通知进程某个事件已经发生,而无须知道该进程的状态。如果该进程并未处于执行状态,则该信号就由内核保存起来,直到该进程恢复执行并传递给他为止。如果一个信号被进程设置为阻塞,则该信号的传递被延迟,直到其阻塞被取消时才被传递给进程。

一个发出而没有被接收到的信号叫做待处理信号,在任何时候,一种类型至多只会有一个待处理信号。如果一个进程有一个类型为k的待处理信号,那么任何接下来发送到这个进程的类型为k的信号都不会排队等待,而是被简单的丢弃。

共享内存

共享内存允许两个或多个进程共享一个给定的存储区,这一段存储区可以被两个或两个以上的进程映射至自身的地址空间中,就像由malloc()分配的内存一样使用。一个进程写入共享内存的信息,可以被其他使用这个共享内存的进程,通过一个简单的内存读取读出,从而实现了进程间的通信。共享内存的效率最高,缺点是没有提供同步机制,需要使用锁等其他机制进行同步。

消息队列

  • 消息队列就是一个消息的链表,是一系列保存在内核中消息的列表。用户进程可以向消息队列添加消息,也可以向消息队列读取消息。
  • 消息队列与管道通信相比,其优势是对每个消息指定特定的消息类型接收的时候不需要按照队列次序,而是可以根据自定义条件接收特定类型的消息。这里可以理解为每一种类型的消息都存在对应的一个队列,对特性消息类型感兴趣的进程可以直接从对应的消息队列中取出消息。
  • 可以把消息看做一个记录,具有特定的格式以及特定的优先级。对消息队列有写权限的进程可以向消息队列中按照一定的规则添加新消息,对消息队列有读权限的进程可以从消息队列中读取消息。
  • 通过系统调用函数来实现消息发送和接收之间的同步,无需考虑同步问题。
  • 信息的复制需要额外消耗 CPU 的时间,不适宜于信息量大或操作频繁的场合

套接字

套接口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同设备及其间的进程通信。借助于网络(TCP/IP)来进行进程之间的通信。

进程调度

下面大致的介绍进程的常见调度方法,由操作系统内核中的调度代码部分决定的。

  • 先来先服务 (FCFS first come first serve):按照作业到达任务队列的顺序调度,FCFS是非抢占式的,易于实现,效率不高,性能不好,有利于长作业(CPU繁忙性)而不利于短作业(I/O繁忙性)。
  • 短作业优先 (SHF short job first):每次从队列里选择预计时间最短的作业运行。SJF是非抢占式的,优先照顾短作业,具有很好的性能,降低平均等待时间,提高吞吐量。但是不利于长作业,长作业可能一直处于等待状态,出现饥饿现象;完全未考虑作业的优先紧迫程度,不能用于实时系统。
  • 最短剩余时间优先:该算法首先按照作业的服务时间挑选最短的作业运行,在该作业运行期间,一旦有新作业到达系统,并且该新作业的服务时间比当前运行作业的剩余服务时间短,则发生抢占;否则,当前作业继续运行。该算法确保一旦新的短作业或短进程进入系统,能够很快得到处理。
  • 高响应比优先调度算法(Highest Reponse Ratio First, HRRF)是非抢占式的,主要用于作业调度。基本思想:每次进行作业调度时,先计算后备作业队列中每个作业的响应比,挑选最高的作业投入系统运行。响应比 = (等待时间 + 服务时间) / 服务时间 = 等待时间 / 服务时间 + 1。因为每次都需要计算响应比,所以比较耗费系统资源。
  • 时间片轮转,用于分时系统的进程调度。基本思想:系统将CPU处理时间划分为若干个时间片(q),进程按照到达先后顺序排列。每次调度选择队首的进程,执行完1个时间片q后,计时器发出时钟中断请求,该进程移至队尾。以后每次调度都是如此。该算法能在给定的时间内响应所有用户的而请求,达到分时系统的目的。

四、线程之间的通信

锁机制

锁机制:包括互斥锁/量(mutex)、读写锁(reader-writer lock)、自旋锁(spin lock)、条件变量(condition)

  • 互斥锁/量(mutex):提供了以排他方式防止数据结构被并发修改的方法。
  • 读写锁(reader-writer lock):允许多个线程同时读共享数据,而对写操作是互斥的。(一般用于读多写少的场景)
  • 自旋锁(spin lock)与互斥锁类似,都是为了保护共享资源。互斥锁是当资源被占用,申请者进入睡眠状态;而自旋锁则循环检测保持者是否已经释放锁。
  • 条件变量(condition):可以以原子的方式阻塞进程,直到某个特定条件为真为止。对条件的测试是在互斥锁的保护下进行的。条件变量始终与互斥锁一起使用

信号量机制(Semaphore)

与进程之间通信使用的信号量一样,也是一个类似于计数器的机制,用来控制多个线程对共享资源的访问,根据信号量的大小,可以分为两种信号量:

  • 二值信号量:信号量的值只有0和1,若资源被锁住,信号量的值为0,若资源可用,则信号量的值为1,这种信号量与互斥量类似
  • 计数信号量:信号量的值在0到一个大于1的限制值之间,该计数表示可用的资源的个数。

信号量在创建时需要设置一个初始值,表示同时可以有几个任务可以访问该信号量保护的共享资源,初始值为1就变成互斥锁Mutex,即同时只能有一个任务可以访问信号量保护的共享资源

屏障(barrier)

屏障允许每个线程等待,直到所有的合作线程都达到某一点,然后从该点继续执行。

小结

线程间的通信目的主要是用于线程同步,所以线程没有像进程通信中的用于数据交换的通信机制

五、协程

近几年出现的一个在高性能、高并发场景下使用的概念:协程。一般称协程为轻量级线程。对于操作系统来说,内核只“认识”进程和线程,对于协程的存在,内核是不知道的,需要用户负责协程的创建、调度和销毁,因此,协程又被称为用户级线程。以下简要的介绍几点协程的性质:

  • 协程是一种用户态的轻量级线程,协程的调度完全由用户控制。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方(一般是自定义的数据结构中),在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。
  • 对操作系统而言,线程是最小的执行单元,进程是最小的资源管理单元。无论是进程还是线程,都是由操作系统所管理的。
  • 协程不是被操作系统内核所管理的,而是完全由程序所控制,也就是在用户态执行。这样带来的好处是性能大幅度的提升,因为不会像线程切换那样消耗资源。
  • 协程既不是进程也不是线程,协程仅仅是一个特殊的函数,协程它进程和进程不是一个维度的。
  • 一个进程可以包含多个线程,一个线程可以包含多个协程。
  • 一个线程内的多个协程虽然可以切换,但是多个协程是串行执行的,只能在一个线程内运行没法利用 CPU 多核能力
  • 协程与进程一样,切换是存在上下文切换问题的。

目前,协程的使用越来越广泛了,针对于高并发网络服务器的解决方案中就可以采用协程来实现。当然,手动实现一个高性能的协程还是有一定的难度的,因此,目前的很多编程语言都内置了协程的实现(例如:golang)。

总结

上述的内容从概念的角度简要的介绍了计算机中重要的几个概念:进程、线程和协程,以及其引申出来的一些常见的知识点。

你可能感兴趣的:(操作系统,操作系统)