深入理解进程、线程、协程的原理及应用,助你开发更加高效

tech2023-02-09  89

1. 进程

进程是系统进行资源分配和调度的一个独立单位,程序段、数据段、PCB三部分组成了进程实体(进程映像),PCB是进程存在的唯一标准

1.1 进程的组织方式:

链接方式 按照进程状态将PCB分为多个队列,就绪队列,阻塞队列等操作系统持有指向各个队列的指针索引方式 根据进程状态的不同,建立几张索引表操作系统持有指向各个索引表的指针

1.2 进程的状态

创建态: 操作系统为进程分配资源,初始化PCB

就绪态:运行资源等条件都满足,存储在就绪队列中,等待CPU调度

运行态:CPU正在执行进程

阻塞态:等待某些条件满足,等待消息回复,等待同步锁,sleep等,阻塞队列

终止态 :回收进程拥有的资源,撤销PCB

1.3 进程的切换和调度

进程在操作系统内核程序临界区中不能进行调度与切换

临界资源:一个时间段内只允许一个进程使用资源,各进程需要互斥地访问临界资源

临界区:访问临界资源的代码

内核程序临界区:访问某种内核数据结构,如进程的就绪队列(存储各进程的PCB)

进程调度的方式:

非剥夺调度方式(非抢占方式),只允许进程主动放弃处理机,在运行过程中即便有更紧迫的任务到达,当前进程依然会继续使用处理机,直到该进程终止或者主动要求进入阻塞态剥夺调度方式(又称抢占方式)当一个进程正在处理机上执行时,如果有一个优先级更高的进程需要处理机,则立即开中断暂停正在执行的进程,将处理机饭呢陪给优先级高的那个进程

进程的切换与过程:进程的调度、切换是有代价的

对原来运行进程各种数据的保存对新的进程各种数据恢复(程序计数器,程序状态字,各种数据寄存器等处理机的现场)

进程调度算法的相关参数:

CPU利用率:CPU忙碌时间/作业完成的总时间系统吞吐量:单位时间内完成作业的数量周转时间:从作业被提交给系统开始,到作业完成为止的时间间隔 = 作业完成时间-作业提交时间带权周转时间:(由于周转时间相同的情况下,可能实际作业的运行时间不一样,这样就会给用户带来不一样的感觉) 作业周转时间/作业实际运行时间, 带权周转时间>=1, 越小越好平均带权周转时间:各作业带权周转时间之和/作业数等待时间响应时间

调度算法:

算法思想,用于解决什么问题?

算法规则,用于作业(PCB作业)调度还是进程调度?

抢占式还是非抢占式的?

优缺点?是否会导致饥饿?

以下调度算法是适用于当前交互式操作系统

时间片轮转(Round-Robin) 算法思想:公平地、轮流地为各个进程服务,让每个进程在一定时间间隔内可以得到相应算法规则:按照各进程到达就绪队列的顺序,轮流让各个进程执行一个时间片(如100ms)。若进程未在一个时间片内执行完,则剥夺处理机,将进程重新放到就绪队列队尾重新排队。用于作业/进程调度:用于进程的调度(只有作业放入内存建立相应的进程后,才会被分配处理机时间片)是否可抢占?若进程未能在规定时间片内完成,将被强行剥夺处理机使用权,由时钟装置发出时钟中断信号来通知CPU时间片到达优缺点:适用于分时操作系统,由于高频率的进程切换,因此有一定开销;不区分任务的紧急程度是否会导致饥饿? 不会优先级调度算法 算法思想:随着计算机的发展,特别是实时操作系统的出现,越来越多的应用场景需要根据任务的进程成都决定处理顺序算法规则:每个作业/进程有各自的优先级,调度时选择优先级最高的作业/进程用于作业/进程调度:即可用于作业调度(处于外存后备队列中的作业调度进内存),也可用于进程调度(选择就绪队列中的进程,为其分配处理机),甚至I/O调度是否可抢占? 具有可抢占版本,也有非抢占式的优缺点:适用于实时操作系统,用优先级区分紧急程度,可灵活地调整对各种作业/及进程的偏好程度。缺点:若源源不断地提供高优先级进程,则可能导致饥饿是否会导致饥饿: 会多级反馈队列调度算法

算法思想:综合FCFS、SJF(SPF)、时间片轮转、优先级调度

算法规则:

1.设置多级就绪队列,各级别队列优先级从高到底,时间片从小到大2.新进程到达时先进入第1级队列,按照FCFS原则排队等待被分配时间片,若用完时间片进程还未结束,则进程进入下一级队列队尾3.只有第k级别队列为空时,才会为k+1级对头的进程分配时间片

用于作业/进程调度:用于进程调度

是否可抢占? 抢占式算法。在k级队列的进程运行过程中,若更上级别的队列(1-k-1级)中进入一个新进程,则由于新进程处于优先级高的队列中,因此新进程会抢占处理机,原理运行的进程放回k级队列队尾。

优缺点:对各类型进程相对公平(FCFS的有点);每个新到达的进程都可以很快就得到相应(RR优点);短进程只用较少的时间就可完成(SPF)的有点;不必实现估计进程的运行时间;可灵活地调整对各类进程的偏好程度,比如CPU密集型进程、I/O密集型进程(拓展:可以将因I/O而阻塞的进程重新放回原队列,这样I/O型进程就可以保持较高优先级)

是否会导致饥饿: 会

 

1.4 进程间通信方式

管道FIFO有名管道消息队列信号量共享内存套接字 Socket

1.5 僵尸进程/孤儿进程/守护进程

孤儿进程:如果父进程先退出,子进程还没退出,那么子进程将被托孤给 init进程,这时子进程的父进程就是init进程(1号进程) 孤儿进程无危害僵尸进程:如果我们了解过linux进程状态及转换关系,我们应该知道进程这么多状态中有一种状态是僵尸状态,就是进程终止后进入僵尸状态(zombie),等待告知父进程自己终止,后才能完全消失.但是如果一个进程已经终止了,但是其父进程还没有获取其状态,那么这个进程就称之为僵尸进程.僵尸进程还会消耗一定的系统资源,并且还保留一些概要信息供父进程查询子进程的状态可以提供父进程想要的信息.一旦父进程得到想要的信息,僵尸进程就会结束. 一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait和waitpi获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵尸进程。守护进程: 守护进程就是在后台运行,不与任何终端关联的进程,通常情况下守护进程在系统启动时就在运行,它们以root用户或者其他特殊用户(apache和postfix)运行,并能处理一些系统级的任务.习惯上守护进程的名字通常以d结尾(sshd),但这些不是必须的

危害: 孤儿进程结束后会被 init 进程善后,并没有危害,而僵尸进程则会一直占着进程号,操作系统的进程数量有限则会受影响。

解决僵尸进程: 一般僵尸进程的产生都是因为父进程的原因,则可以通过 kill 父进程解决,这时候僵尸进程就变成了孤儿进程,被 init 进程接收

0 号进程负责进程间的切换,解决CPU占用,1号进程,直接或者间接的创建进程,负责进程的创建和孤儿进程的善后

2. 线程

引入线程之后,进程只作为除CPU之外的系统资源的分配单元(如:打印机,内存地址空间等都是分配给进程的)

 

 

线程的是实现方式:

用户级线程(User-Level Thread),用户级线程由应用程序通过线程库是实现如python (import thread), 线程的管理工作由应用程序负责。内核级线程(kernel-Level Thread),内核级线程的管理工作由操作系统内核完成,线程调度,切换等工作都由内核负责,因此内核级线程的切换必然需要在核心态下才能完成 进程和线程的关系:一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。CPU的最小调度单元是线程,所以单进程多线程是可以利用多核CPU的。

线程的基本状态:派生,阻塞,就绪,运行,结束。

新建(New):线程在进程内派生出来,它即可由进程派生,也可由线程派生。阻塞(blocked):线程运行过程中,可能由于各种原因进入阻塞状态 通过sleep方法进入睡眠状态线程调用一个在I/O上被阻塞的操作,即该操作在输入输出操作完成之前不会返到它的调用者试图得到一个锁,而该锁被其他线程持有等待某个出发条件就绪(ready):一个新创建的线程并不自动开始运行,要执行线程,必须调用线程的start()方法。当线程对象调用start()方法即启动了线程,start()方法创建线程运行的系统资源,并调度线程运行run()方法。当start()方法返回后,线程就处于就绪状态。处于就绪状态的线程并不一定立即运行run()方法,线程还必须同其他线程竞争CPU时间,只有获得CPU时间才可以运行线程运行(running):线程后的CPU时间后,进入运行状态,真正开始执行run()方法死亡(dead): 征程退出自然死亡,或者异常终止导致线程猝死

2.1 线程模型:

用户级线程模型(一对多模型)

 

多个用户态的线程对应着一个内核线程,程序线程的创建、终止、切换或者同步等线程工作必须自身来完成。python就是这种。虽然可以实现异步,但是不能有效利用多核(GIL)

内核级线程模型 (一对一)

这种模型直接调用操作系统的内核线程,所有线程的创建、终止、切换、同步等操作,都由内核来完成。C++就是这种

两级线程模型(M:N)

这种线程模型会先创建多个内核级线程,然后用自身的用户级线程去对应创建的多个内核级线程,自身的用户级线程需要本身程序去调度,内核级的线程交给操作系统内核去调度。GO语言就是这种。

python中的多线程因为GIL的存在,并不能利用多核CPU优势,但是在阻塞的系统调用中,如sock.connect(), sock.recv()等耗时的I/O操作,当前的线程会释放GIL,让出处理器。但是单个线程内,阻塞调用上还是阻塞的。除了GIL之外,所有的多线程还有通病,他们都是被OS调用的,调度策略是抢占式的,以保证同等有限级的线程都有机执行,带来的问题就是:并不知道下一刻执行那个线程,也不知道正在执行什么代码,会存在竞态条件

3. 协程

协程通过在线程中实现调度,避免了陷入内核级别的上下文切换造成的性能损失,进而突破了线程在IO上的性能瓶颈。

python的协程源于yield指令

yield item 用于产出一个值,反馈给next()的调用方法让出处理机,暂停执行生成器,让调用方继续工作,直到需要使用另一个值时再调用next()

协程式对线程的调度,yield类似惰性求职方式可以视为一种流程控制工具,实现协作式多任务,python3.5引入了async/await表达式,使得协程证实在语言层面得到支持和优化,大大简化之前的yield写法。线程正式在语言层面得到支持和优化。线程是内核进行抢占式调度的,这样就确保每个线程都有执行的机会。而coroutine运行在同一个线程中,有语言层面运行时中的EventLoop(事件循环)来进行调度。在python中协程的调度是非抢占式的,也就是说一个协程必须主动让出执行机会,其他协程才有机会运行。让出执行的关键字 await, 如果一个协程阻塞了,持续不让出CPU处理机,那么整个线程就卡住了,没有任何并发。

PS: 作为服务端,event loop最核心的就是I/O多路复用技术,所有来自客户端的请求都由I/O多路复用函数来处理;作为客户端,event loop的核心在于Future对象延迟执行,并使用send函数激发协程,挂起,等待服务端处理完成返回后再调用Callback函数继续执行。

3.1 Golang 协程

Go 天生在语言层面支持,和python类似都是用关键字,而GO语言使用了go关键字,go协程之间的通信,采用了channel关键字。

go实现了两种并发形式:

多线程共享内存:如Java 或者C++在多线程中共享数据的时候,通过锁来访问Go语言特有的,也是Go语言推荐的 CSP(communicating sequential processes)并发模型。 package main import ("fmt") func main() { jobs := make(chan int) done := make(chan bool) // end flag go func() { for { j, ok := <- jobs fmt.Println("---->:", j, ok) if ok { fmt.Println("received job") } else { fmt.Println("end received jobs") done <- true return } } }() go func() { for j:= 1; j <= 3; j++ { jobs <-j fmt.Println("sent job", j) } close(jobs) fmt.Println("close(jobs)") }() fmt.Println("sent all jobs") <-done // 阻塞 让main等待协程完成 }

Go的CSP并发模型是通过goroutine 和 channel来实现的。

goroutine是go语言中并发的执行单位。channel是Go语言中各个并发结构体之间的通信机制。 channel -< data 写数据<- channel 读数据

协程本质上来说是一种用户态的线程,不需要系统来执行抢占式调度,而是在语言测个面实现线程的调度。

4. 并发

并发:Do not communicate by sharing memory; instead, share memory by communicate.

4.1 Actor模型

Actor模型和CSP模型的区别:

CSP并不Focus发送消息的实体/Task, 而是关注发送消息时消息所使用的载体,即channel。在Actor的设计中,Actor与信箱是耦合的,而在CSP中channel是作为first-class独立存在的Actor中有明确的send/receive关系,而channel中并不区分这样的关系,执行快可以任意选择发送或者取消息

4.4 Go 协程调度器 GPM

G 指的是Goroutine,其本质上也是一种轻量级的线程P proessor, 代表M所需要的上下文环境,也是处理用户级代码逻辑处理器。同一时间只有一个线程(M)可以拥有P, P中的数据都是锁自由(lock free)的, 读写这些数据的效率会非常的高M Machine,一个M直接关联一个内核线程,可以运行go代码 即goroutine, M运行go代码需要一个P, 另外就是运行原生代码,如 syscall。运行原生代码不需要P。

一个M会对应一个内核线程,一个M也会连接一个上下文P,一个上下文P相当于一个“处理器”,一个上下文连接一个或者多个Goroutine。P(Processor)的数量是在启动时被设置为环境变量GOMAXPROCS的值,或者通过运行时调用函数runtime.GOMAXPROCS()进行设置

 

erlang和golang都是采用CSP模型,python中协程是eventloop模型。但是erlang是基于进程的消息通信,go是基于goroutine和channel通信。

python和golang都引入了消息调度系统模型,来避免锁的影响和进程线程的开销问题。

计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决 -- G-P-M模型正是此理论践行者,此理论也用到了python的asyncio对地狱回调的处理上(使用Task+Future避免回调嵌套),是不是巧合? 其实异步≈可中断的函数+事件循环+回调,go和python都把嵌套结构转换成列表结构有点像算法中的递归转迭代.

调度器在计算机中是分配工作时所需要的资源,Linux的调度是CPU找到可运行的线程,Go的调度是为M线程找到P(内存,执行票据)和可运行的G(协程)

Go协程是轻量级的,栈初始2KB(OS操作系统的线程一般都是固有的栈内存2M), 调度不涉及系统调用,用户函数调用前会检查栈空间是否足够,不够的话,会进行站扩容,栈大小限制可以达到1GB。

Go的网络操作是封装了epoll, 为NonBlocking模式,切换协程不阻塞线程。

Go语言相比起其他语言的优势在于OS线程是由OS内核来调度的,goroutine则是由Go运行时(runtime)自己的调度器调度的,这个调度器使用一个称为m:n调度的技术(复用/调度m个goroutine到n个OS线程)。 其一大特点是goroutine的调度是在用户态下完成的, 不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池, 不直接调用系统的malloc函数(除非内存池需要改变),成本比调度OS线程低很多。 另一方面充分利用了多核的硬件资源,近似的把若干goroutine均分在物理线程上, 再加上本身goroutine的超轻量,以上种种保证了go调度方面的性能。

喜欢请多多点赞评论转发,你们的支持就是小编不断创作更新的动力!!!

最新回复(0)