这个部分主要面试问的比较多,提前补一补进程和线程的知识,之前的操作系统实战会继续更,踏实完成内存管理之后再考虑进程和线程的问题。
内容主要来自学习小林coding继图解网络之后出的图解系统的一些笔记,写的比较通俗易懂,向大家推荐。
进程
代码通过预编译编译汇编连接到可执行文件,运行中的程序被称为进程。
多个程序,通过CPU中断,交替执行即可实现并发。
进程与程序关系
拿做菜举例:
人——CPU
菜谱——程序
食材——数据
做菜这个动作——进程
做到一半突然想喝可乐,于是,人把做菜的事情停一下,记录菜谱到哪一个步骤,状态信息记录下来,然后去做喝可乐这个进程。喝完回来继续做菜。
对于做菜这个进程来说,它有 运行——暂停——运行 这样的规律。
进程的状态
⼀个进程的活动期间⾄少具备三种基本状态,即运⾏状态、就绪状态、阻塞状态;
运行状态: 该时刻进程占用CPU
就绪状态: 该进程可以运行,处于就绪状态时因为其他的进程在运行,自己只好暂停运行
阻塞状态: 该进程正在等某个事情(如等待I/O)发生而暂停运行,这个时候即使给他cpu的控制权他也没法运行。
另外,进程还有创建和结束 两个基本状态。
在虚拟内存管理的操作系统中,通常会把阻塞状态的进程的物理内存空间换出到硬盘,等需要再次运⾏的时候,再从硬盘换⼊到物理内存。
需要⼀个新的状态,来描述进程没有占⽤实际的物理内存空间的情况,这个状态就是挂起状态。
这跟阻塞状态是不⼀样,阻塞状态是等待某个事件的返回。
进程挂起的原因:
1.sleep让进程间歇式挂起;定时器唤醒。
2.用户强行挂起程序的执行,如Linux的ctrl+z
挂起分为两种:
阻塞挂起: 进程在硬盘等待某个事件出现。
就绪挂起: 进程在硬盘,但是只要进入内存就能立刻运行。
进程控制模块PCB
PCB 是进程存在的唯⼀标识,这意味着⼀个进程的存在,必然会有⼀个 PCB,如果进程消失了,那么
PCB 也会随之消失。
PCB包含信息:
- 进程描述信息:1.进程标识符,标识各个进程,每个进程都有⼀个并且唯⼀的标识符 2.用户标识符,进程归属的⽤户,⽤户标识符主要为共享和保护服务;
- 进程控制和管理信息:1.进程当前状态,如 new、ready、running、waiting 或 blocked 等;2.进程抢占 CPU 时的优先级;
- 资源分配清单:有关内存地址空间或虚拟地址空间的信息,所打开⽂件的列表和所使⽤的 I/O 设备信息
- CPU相关信息:CPU 中各个寄存器的值,当进程被切换时,CPU 的状态信息都会被保存在相应的 PCB 中,以便进程重新执⾏时,能从断点处继续执⾏。
PCB组织方式
通过链表把相同状态的进程连在一起,组成队列 。
进程的控制(进程状态的具体过程)
1.进程的创建
操作系统允许⼀个进程创建另⼀个进程,⽽且允许⼦进程继承⽗进程所拥有的资源,当⼦进程被终⽌时,其在⽗进程处继承的资源应当还给⽗进程。同时,终⽌⽗进程时同时也会终⽌其所有的⼦进程。
注意:Linux 操作系统对于终⽌有⼦进程的⽗进程,会把⼦进程交给 1 号进程接管。本⽂所指出的进程终⽌概念是宏观操作系统的⼀种观点,最后怎么实现当然是看具体的操作系统
进程创建的过程如下:
- 为新进程分配唯一进程标识号,申请一个空白的PCB。PCB 是有限的,若申请失败则创建失败;
- 为进程分配资源,如果资源不足,进程进入阻塞状态,等待资源。
- 初始化PCB
- 如果进程的调度队列可以接纳新结成,就将该进程插入到就绪队列,等待被调度。
2.进程的终止
有三种方式终止:
正常终止
异常结束
外界干预(用信号kill杀死进程)
具体过程如下:
- 查找需要终止进程的PCB
- 如果处于执行,就立即终止其执行,将CPU给其他进程。
- 如果有子进程,应将所有子进程终止。
- 将进程拥有的所有资源还给父进程或操作系统。
- 将其从PCB队列中删除。
3.进程的阻塞
当进程需要等待某⼀事件完成时,它可以调⽤阻塞语句把⾃⼰阻塞等待。⽽⼀旦被阻塞等待,它只能由另⼀个进程唤醒。
具体过程如下:
- 找到要被阻塞的PCB的标识号。
- 如果其在运行,就保护上下文,将其状态转为阻塞状态,停止运行。
- 将该PCB插入阻塞队列。
4.进程的唤醒
进程的阻塞和唤醒是⼀对功能相反的语句,如果某个进程调⽤了阻塞语句,则必有⼀个与之对应的唤醒语句
进程由「运⾏」转变为「阻塞」状态是由于进程必须等待某⼀事件的完成,所以处于阻塞状态的进程是绝对不可能叫醒⾃⼰的。
如果某进程正在等待 I/O 事件,需由别的进程发消息给它,则只有当该进程所期待的事件出现时,才由发现者进程⽤唤醒语句叫醒它。
具体步骤:
- 把该事件从阻塞队列的标识号找到对应的PCB。
- 将其从阻塞队列中移出,状态改为就绪。
- 把PCB插入到就绪队列中,等待调度程序调度。
进程上下文切换
各个进程之间是共享 CPU 资源的,在不同的时候进程之间需要切换,让不同的进程可以在 CPU 执⾏,那么这个⼀个进程切换到另⼀个进程运⾏,称为进程的上下⽂切换。
上下文:
CPU寄存器和程序计数器
CPU寄存器是小的高速缓存。
程序计数器用来存储CPU正在执行的指令位置,或者即将执行的下一条指令位置
所以说,CPU 寄存器和程序计数是 CPU 在运⾏任何任务前,所必须依赖的环境,这些环境就叫做 CPU
上下⽂。
进程上下文切换,要保存的信息保存在PCB,不仅包括虚拟内存,栈,全局变量等用户空间资源,还要包括内核堆栈,寄存器等内核资源。
CPU 上下⽂切换就是先把前⼀个任务的 CPU 上下⽂(CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下⽂到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运⾏新任务。系统内核会存储保持下来的上下⽂信息,当此任务再次被分配给 CPU 运⾏时,CPU 会重新加载这些上下⽂,这样就能保证任务原来的状态不受影响,让任务看起来还是连续运⾏。
上面的任务可以是进程,也可以是线程,还可以是中断,三者称之为CPU上下文切换
进程上下文切换的场景:
- 时间片耗尽,进程由运行到就绪状态,系统从就绪队列选择另外一个进程运行。
- 系统资源不足(如内存不足):进程被挂起,并由系统调度其他进程运行。
- sleep函数:将自己主动挂起
- 优先级更高的进程出现,当前进程也被挂起
- 硬件中断,进程被挂起转而执行中断服务程序。
线程
因为进程之间不好共享数据,通信也需要解决,维护进程的系统开销大。
所以出现了一种新的实体:
- 线程之间并发运行
- 线程之间共享相同的地址空间。
线程( Thread ),线程之间可以并发运⾏且共享相同的地址空间。
线程是进程中的一条执行流程
同一进程内的多个线程,可以共享代码段和数据段,文件资源;但每个线程由自己独立的寄存器和运行栈 这样可以保障线程的控制流是相对独立的。
进程与线程的区别
进程是资源分配的基本单位,而线程是CPU调度的基本单位。
1地址空间:线程共享一个进程的地址空间,而进程之间是独立的;
2资源:线程共享进程的资源(代码段、数据段,CPU 内存 IO)但有独立的寄存器、计数器和运行栈,进程之间的资源是独立的,进程有独立的代码和数据空间;
3健壮性:多进程比多线程健壮,进程崩溃不会影响其他进程,而一个线程崩溃整个进程都GG;所以比如我们设计一个游戏,用户肯定不能用多线程,否则一个用户挂了会影响到其他用户。
4执行过程:进程有独程序运行的入口,顺序执行序列,开销大。而线程不能独立执行,必需依存在应用程序之中。应用程序提供线程的执行控制,开销小。
5切换:进程切换时消耗资源大。频繁切换用多线程好。如果要求同时进行且共享某些变量的并发操作只能用多线程。
相同点:都有就绪,阻塞,执行三种基本状态。都能并发;
线程开销小的原因:
1.线程创建时间快,进程创建需要资源管理信息,比如内存文件,而线程不会涉及这些资源管理,直接共享。
2.切换快:线程有相同的地址空间,意味着同一个进程中的线程有同一个页表,切换不需要切换页表。而进程切换需要换页表。
3.资源传递:线程之间数据传递不需要经过内核,因为他们共享它。
线程的上下文切换
1.当两个线程不是属于同⼀个进程,则切换的过程就跟进程上下⽂切换⼀样;
2.当两个线程是属于同⼀个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器,运行栈等不共享的数据
线程的实现方式
- 用户线程:在用户态实现的线程,
- 内核线程:在内核态管理的线程,
- 轻量级进程:在内核中支持用户线程;
注意对应关系:
- 多对一:多个用户线程对应同一个内核线程
- 一对一:一个用户线程对应一个内核线程
- 多对多:多个用户线程对应多个内核线程
用户线程如何理解,优缺点
⽤户线程是基于⽤户态的线程管理库来实现的,那么线程控制块(Thread Control Block, TCB) 也是在库⾥⾯来实现的,对于操作系统⽽⾔是看不到这个 TCB 的,它只能看到整个进程的 PCB。
所以,⽤户线程的整个线程管理和调度,操作系统是不直接参与的,⽽是由⽤户级线程库函数来完成线程的管理,包括线程的创建、终⽌、同步和调度等。
⽤户级线程的模型,也就类似前⾯提到的多对⼀的关系,即多个⽤户线程对应同⼀个内核线程,如下图所示:
用户线程的优点:
- 每个进程都需要有它私有的线程控制块(TCB)列表,⽤来跟踪记录它各个线程状态信息(PC、栈指针、寄存器),TCB 由⽤户级线程库函数来维护,可⽤于不⽀持线程技术的操作系统;
- ⽤户线程的切换也是由线程库函数来完成的,⽆需⽤户态与内核态的切换,所以速度特别快;
缺点:
- 由于操作系统不参与线程的调度,如果⼀个线程发起了系统调⽤⽽阻塞,那进程所包含的⽤户线程都不能执⾏了。
- 当⼀个线程开始运⾏后,除⾮它主动地交出 CPU 的使⽤权,否则它所在的进程当中的其他线程⽆法运⾏,因为⽤户态的线程没法打断当前运⾏中的线程,它没有这个特权,只有操作系统才有,但是⽤户线程不是由操作系统管理的
- 由于时间⽚分配给进程,故与其他进程⽐,在多线程执⾏时,每个线程得到的时间⽚较少,执⾏会⽐较慢;
内核线程如何理解,优缺点
内核线程是由操作系统管理的,线程对应的 TCB ⾃然是放在操作系统⾥的,这样线程的创建、终⽌和管理都是由操作系统负责。
内核线程的模型,也就类似前⾯提到的⼀对⼀的关系,即⼀个⽤户线程对应⼀个内核线程
优点:
- 在⼀个进程当中,如果某个内核线程发起系统调⽤⽽被阻塞,并不会影响其他内核线程的运⾏;
- 分配给线程,多线程的进程获得更多的 CPU 运⾏时间;
缺点:
- 在⽀持内核线程的操作系统中,由内核来维护进程和线程的上下⽂信息,如 PCB 和 TCB;
- 线程的创建、终⽌和切换都是通过系统调⽤的⽅式来进⾏,因此对于系统来说,系统开销⽐较⼤;
轻量级进程如何理解
轻量级进程(Light-weight process,LWP)是内核⽀持的⽤户线程,⼀个进程可有⼀个或多个 LWP,每个 LWP 是跟内核线程⼀对⼀映射的,也就是 LWP 都是由⼀个内核线程⽀持。另外,LWP 只能由内核管理并像普通进程⼀样被调度,Linux 内核是⽀持 LWP 的典型例⼦。
LWP与普通进程的区别也在于它只有⼀个最⼩的执⾏上下⽂和调度程序所需的统计信息。⼀般来说,⼀个进程代表程序的⼀个实例,⽽ LWP 代表程序的执⾏线程,因为⼀个执⾏线程不像进程那样需要那么多状态信息,所以 LWP 也不带有这样的信息。
在 LWP 之上也是可以使⽤⽤户线程的,那么 LWP 与⽤户线程的对应关系就有三种:
- 1 : 1 ,⼀个用户线程对应到⼀个 LWP 再对应到⼀个内核线程,优点:实现并⾏,当⼀个 LWP 阻塞,不会影响其他 LWP;缺点:每⼀个⽤户线程,就产⽣⼀个内核线程,创建线程的开销较⼤。
- N : 1 :多个⽤户线程对应⼀个 LWP 再对应⼀个内核线程,线程管理是在⽤户空间完成的,此模式中⽤户的线程对操作系统不可⻅;优点:⽤户线程要开⼏个都没问题,且上下⽂切换发⽣⽤户空间,切换的效率较⾼;缺点:⼀个⽤户线程如果阻塞了,则整个进程都将会阻塞,另外在多核 CPU 中,是没办法充分利⽤CPU 的;
- M : N:该模型提供了两级控制,⾸先多个⽤户线程对应到多个 LWP,LWP 再⼀⼀对应到内核线程;优点:综合了前两种优点,⼤部分的线程上下⽂发⽣在⽤户空间,且多个线程⼜可以充分利⽤多核CPU 的资源
- 组合模式:此进程结合 1:1 模型和 M:N 模型。开发⼈员可以针对不同的应⽤特点调节内核线程的数⽬来达到物理并⾏性和逻辑并⾏性的最佳⽅案。
进程调度
选择⼀个进程运⾏这⼀功能是在操作系统中完成的,通常称为调度程序(scheduler)。当进程从⼀个运⾏状态到另外⼀状态变化的时候,其实会触发⼀次调度。
以下状态的变化都会触发操作系统的调度:
- 从就绪态 -> 运⾏态:当进程被创建时,会进⼊到就绪队列,操作系统会从就绪队列选择⼀个进程运⾏;
- 从运⾏态 -> 阻塞态:当进程发⽣ I/O 事件⽽阻塞时,操作系统必须另外⼀个进程运⾏;
- 从运⾏态 -> 结束态:当进程退出结束后,操作系统得从就绪队列选择另外⼀个进程运⾏
调度算法
如果硬件时钟提供某个频率的周期性中断,那么可以根据如何处理时钟中断,把调度算法分为两类:
- 非抢占式调度算法挑选⼀个进程,然后让该进程运⾏直到被阻塞,或者直到该进程退出,才会调⽤另外⼀个进程,也就是说不会理时钟中断这个事情。
- 抢占式调度算法挑选⼀个进程,然后让该进程只运⾏某段时间,如果在该时段结束时,该进程仍然在运⾏时,则会把它挂起,接着调度程序从就绪队列挑选另外⼀个进程。这种抢占式调度处理,需要在时间间隔的末端发⽣时钟中断,以便把 CPU 控制返回给调度程序进⾏调度,也就是常说的时间⽚机制。
调度原则
- CPU利用率:调度程序应确保CPU始终忙,不应空闲。
- 系统吞吐量:表示单位时间完成进程的数量,所以短时间的作业能提升系统的吞吐量。
- 周转时间:指进程运行和阻塞时间的总和。越小越好
- 等待时间:指进程处于就绪队列的时间,等待时间越越短越好。
- 响应时间:指用户提交请求到第一次产生响应的时间,越短越好。
下面正式介绍单核CPU常见的调度算法:
1.先来先服务FCFS
非抢占式,先来后到,每次从就绪队列选择最先进队的进程,然后一直运行,直到进程退出或者阻塞,才会继续从就绪队列中选择第一个进程运行。
这对于长作业有利,当⼀个⻓作业先运⾏了,那么后⾯的短作业等待的时间就会很⻓,不利于短作业。
适用于CPU繁忙型作业的系统,而不是IO繁忙型作业的系统。
2.最短作业优先
每次优先选择运行时间最短的进程来运行,有助于提高系统的吞吐量。
这对长作业不利;
3.高响应比优先
每次调度先计算响应比优先级,选择优先级最高的运行
响应比优先级=(处于就绪队列的时间+需求服务时间)/需求服务时间
由此:
- 如果两个进程等待时间相同,则需要服务的时间越短,响应比越高 这样短作业进程容易被选中。
- 如果两个进程需要服务时间相同,那么等待时间越长,响应比越高 这样兼顾到了先进入就绪队列的进程,这样长作业时间的进程也会有机会。
4.时间片轮转
每个进程运行同样的时间片,时间片结束的时候还未完成的进程从队头回到队尾。如果时间片结束之前进程就阻塞或者结束,则CPU立即切换。
所以时间片的长度很关键:
太长,短作业的响应时间变长。
太短,产生过多进程的上下文切换,降低CPU的效率。
一般来说20-50ms比较合适。
5.最高优先级优先
从就绪队列中选择最高优先级的进程运行。
进程的优先级分为:
- 静态优先级,创建的时候确定优先级。整个运行时间都不变。
- 动态优先级:根据进程的动态变化调整优先级,比如随着进程的运行时间增加,降低优先级,如果处于就绪队列的等待时间增加就提高优先级;
该调度算法也有两种处理方法:
- 非抢占式:运行完一个运行下一个优先级最高。
- 抢占式:就绪队列中只要出现比当前优先级高的,当前进程就被挂起,调度优先运行优先级高的进程。
缺点:可能导致优先级低的进程永远都不运行。
6.多级反馈队列
是时间片+最高优先级的综合发展。
- 多级:表示有多个队列,每个队列优先级从高到低排,优先级越高的时间片越短。
- 反馈:如果有新的优先级更高的进程加入队列时,立即停止当前进程,转而去运行优先级别高的队列。
- 有多个队列,每个队列的优先级从高到低,优先级越高时间片越短。
- 新的进程会被放到第一级队列的末尾,按FIFS原则排队调度,如果其在第一级队列的时间片未完成,就被转入到第二级队列的末尾。以此类推。
- 当优先级高的队列为空时,调度低优先级的队列的进程,如果某个低优先级进程运行时,有新的进程进入了高优先级的队列,则停止当前运行的进程,并将其移动到当前队列的末尾,让高优先级的进程先运行。
由此,短作业在第一队列很快就被处理完,但是长作业可能第一队列处理不完,就转到下一级队列,该算法很好的兼容了长短作业,同时有较好的响应时间。
进程间的通信
每个进程的⽤户地址空间都是独⽴的,⼀般⽽⾔是不能互相访问的,但内核空间是每个进程都共享的,所以进程之间要通信必须通过内核
管道
linux命令中“|”竖线就是一个匿名管道,用完了就销毁,把前一个命令的输出作为后一个命令的输入,通信范围是存在父子关系的进程,用fork赋值父进程的文件描述符。管道的数据传输是单向的,所以要想相互通信,必需创建两个管道。
还有一种命名管道,因采取先进先出的传输方式,叫做FIFO,可以在不相关的进程间相互通信,通过创建类型为管道的设备文件通信,在使⽤命名管道前,先需要通过 mkfifo 命令来创建,并且指定管道名字:
$ mkfifo myPipe
可以用echo将数据写入管道:
$ echo "hello" > myPipe /
然后用cat读取管道中的数据:
$ cat < myPipe
原理:
所谓管道,就是内核的一段缓存,管道写入的数据缓存在内核中,从另一端读取即从内核中读取。
匿名管道的创建,需要pipe系统调用:
int pipe(int fd[2])
这⾥表示创建⼀个匿名管道,并返回了两个描述符,⼀个是管道的读取端描述符 fd[0] ,另⼀个是管道的
写⼊端描述符 fd[1] 。注意,这个匿名管道是特殊的⽂件,只存在于内存,不存于⽂件系统中。
可以用fork()创建子进程复制⽗进程的⽂件描述符,这样两个进程就都有了f0和f1,可以从同一管道读写;
但同时读写会造成混乱,所以我们通常只让父进程写,只让子进程读:
所以这样的话双向通信需要两个管道
但实际shell中的管道不是这样,在 shell ⾥⾯执⾏ A | B 命令的时候,A 进程和 B 进程都是 shell 创建出来的⼦进程,A 和 B 之间不存在⽗⼦关系,它俩的⽗进程都是 shell。
所以shell指令通过|将多个命令连载一起,实际上创建了多个子进程:
管道效率低,不适合进程间频繁交互数据。
消息队列
所以出现了消息队列,AB进程要通信,A把数据放在消息队列就返回,B需要数据的时候去消息队列读就行。
消息队列是保存在内核中的消息链表 ,发送数据时,会分成一个个独立的消息体,可被用户自定义类型。如果进程从消息队列中读取了某个消息体,就将其删除。
与管道的区别:
每个消息体都是固定大小的存储块,不像管道是无格式的字节流
消息队列的声明周期随内核,没释放就一直在,而管道随进程,进程结束就销毁
消息这种模型就像邮件,来回发邮件以实现频繁沟通,
但缺点在于
通信不及时(联想邮件可知)
消息大小有限制,不适合大数据传输,内核限制每个消息的最大长度和队列的最大长度。
消息通信过程中,存在用户态和内核态之间的数据拷贝开销,因为进程写入数据到内核的消息队列需要发送从用户态拷贝数据到用户态,而另一进程读取内核的消息队列时,也会发生内核态到用户态的拷贝数据。
共享内存
因为消息队列读取和写入都会发生用户态和内核态的消息拷贝。共享内存解决了这个问题。
我们说操作系统虚拟地址到物理地址的转换通过MMU,且不同进程映射到的物理地址是独立的。
共享内存主要使两个进程的虚拟内存空间映射到相同的物理内存中
这样一个进程写入到内存的东西另一个进程马上就看见了。不需要拷贝来拷贝去。
信号量
但是共享内存显然会出现读写冲突。
为了放置多进程竞争共享资源,信号量实现了共享资源在任意时刻都只能被一个进程访问。
我们在操作系统6中已经写过,这里简单讲下:
添加链接描述
实现信号量的数据结构主要的内容在于一个整型的计数器,初始化为1(初始化为1代表互斥信号量),主要用于实现进程间的互斥与同步。申请处理之后如果>=0,就可以操作占用资源,反之就需要阻塞等待,挂载到等待链表。
主要解决三个问题:
- 等待:如果这个资源被占用,申请的进程需要被阻塞等待;
- 互斥:同一时刻,只能有一个进程占用这个特定资源。
- 唤醒:达到某种条件我们要激活阻塞等待的进程,然后让其竞争我
#define SEM_FLG_MUTEX 0
#define SEM_FLG_MULTI 1
#define SEM_MUTEX_ONE_LOCK 1
#define SEM_MULTI_LOCK 0
//等待链数据结构,用于挂载等待代码执行流(线程)的结构,里面有用于挂载代码执行流的链表和计数器变量,这里我们先不深入研究这个数据结构。
typedef struct s_KWLST
{
spinlock_t wl_lock;
uint_t wl_tdnr;
list_h_t wl_list;
}kwlst_t;
//信号量数据结构
typedef struct s_SEM
{
spinlock_t sem_lock;//维护sem_t自身数据的自旋锁
uint_t sem_flg;//信号量相关的标志
sint_t sem_count;//信号量计数值
kwlst_t sem_waitlst;//用于挂载等待代码执行流(线程)结构
}sem_t;
该数据结构解释一下:
需要一个维护信号量自身数据的一个自旋锁
信号量需要一个标志位和一个数值位
需要挂载等待链
控制信号量有两种原子操作:
P操作:减一,如果p之后>=0,就可以用资源,进程正常执行。反之表明已被占用,进程需要阻塞等待。
V操作:加一,如果V之后>0表明当前没有阻塞中的进程,反之代表当前有阻塞的进程,需要将其唤醒。
这是比较简单的说法,我们在之前详细说过具体步骤,复习一下:
1.获取信号量
对用于保护信号量自身的自旋锁 sem_lock 进行加锁;
对信号值“减一”。检测现在信号值sem_count是否大于等于0;
如果大于等于0: 表示获取信号量成功;
如果小于0,就不能执行代码流,需要让进程进入等待链等待,等信号量被放出之后再与其他进程抢占这个信号量。
最后释放自旋锁sem_lock解锁。
2.抢到信号量的进程代码执行流开始执行相关操作,比如从缓冲区读取键盘输入的数据,比如控制打印机硬件进行打印操作。
3.释放信号量
释放和获取一样,首先一定要对用于保护信号量自身的自旋锁sem_lock 进行加锁;
对信号值加一,然后检测器是否大于0;
不管检测是否大于0,都会标记信号量释放成功。但是如果大于0(通常会大于0),我们要唤醒其他等待链中的进程。如果小于1则出现错误,需要挂起系统。
最后释放自旋锁sem_lock。
我们来看具体代码:
//获取信号量
void krlsem_down(sem_t* sem)
{
cpuflg_t cpufg;
start_step:
krlspinlock_cli(&sem->sem_lock,&cpufg);
if(sem->sem_count<1)
{//如果信号量值小于1,则让代码执行流(线程)睡眠
krlwlst_wait(&sem->sem_waitlst);
krlspinunlock_sti(&sem->sem_lock,&cpufg);
krlschedul();//切换代码执行流,下次恢复执行时依然从下一行开始执行,所以要goto开始处重新获取信号量
goto start_step;
}
sem->sem_count--;//信号量值减1,表示成功获取信号量
krlspinunlock_sti(&sem->sem_lock,&cpufg);
return;
}
//释放信号量
void krlsem_up(sem_t* sem)
{
cpuflg_t cpufg;
krlspinlock_cli(&sem->sem_lock,&cpufg);
sem->sem_count++;//释放信号量
if(sem->sem_count<1)
{//如果小于1,则说数据结构出错了,挂起系统
krlspinunlock_sti(&sem->sem_lock,&cpufg);
hal_sysdie("sem up err");
}
//唤醒该信号量上所有等待的代码执行流(线程)
krlwlst_allup(&sem->sem_waitlst);
krlspinunlock_sti(&sem->sem_lock,&cpufg);
krlsched_set_schedflgs();
return;
}
如果把信号量初始化为0,就可实现同步信号量,从而实现多进程同步。
如果我们希望多个进程能密切合作,实现共同的任务,例如,进程 A 是负责⽣产数据,⽽进程 B 是负责读取数据,这两个进程是相互合作、相互依赖的,进程 A必须先⽣产了数据,进程 B 才能读取到数据,所以执⾏是有前后顺序的。
即A只有V操作,B只有P操作:
那么即使B先P-1,这个时候信号量的值也是0-1=-1;被阻塞;这样B就不可能在A前面执行。
这个时候A,v+1之后信号量变为0,这个时候A会唤醒B;
又因为B一旦被唤醒意味着A已经产生了数据,B可以正常读取数据了。
从而保证了A一定在B前面执行
信号
上面的管道,消息队列,共享内存,信号量都是正常工作的模式;
异常情况下需要使用信号,信号是进程间通信中唯一的异步通信机制,因为可以在任何时候发送信号给某一进程
一旦收到信号,进程有三种对信号的处理方式:
- 执行默认操作,系统事先规定好收到某种信号做特定操作
- 捕捉信号:我们可以定义一个信号处理函数,信号发生时执行对应的信号处理函数(在C++系列中也讲过C++怎么处理的信号添加链接描述)
- 忽略信号:不希望处理某种信号就可忽略,不作处理,有两个信号是系统进程无法捕捉和忽略的,即 SIGKILL 和 SEGSTOP ,它们⽤于在任何时候中断或结束某⼀进程
Socket
前面都是在同一台主机上通信,如果想跨网络与不同主机的进程通信,就需要用到Socket;(实际上,Socket 通信不仅可以跨⽹络与不同主机的进程间通信,也可以在同主机上进程间通信。)
创建 socket 的系统调⽤:
int socket(int domain, int type, int protocal)
有三个参数:
- domain:用来指定协议族;⽐如 AF_INET ⽤于 IPV4、AF_INET6 ⽤于 IPV6、AF_LOCAL/AF_UNIX ⽤于本机
- type:用来指定通信特性,⽐如 SOCK_STREAM 表示的是字节流,对应 TCP、SOCK_DGRAM
表示的是数据报,对应 UDP、SOCK_RAW 表示的是原始套接字;
后面一个参数被废弃,写0;
根据创建 socket 类型的不同,通信的⽅式也就不同:
- 实现 TCP 字节流通信: socket 类型是 AF_INET 和 SOCK_STREAM;
- 实现 UDP 数据报通信:socket 类型是 AF_INET 和 SOCK_DGRAM;
- 实现本地进程间通信: 「本地字节流 socket 」类型是 AF_LOCAL 和 SOCK_STREAM,「本地数据报 socket 」类型是 AF_LOCAL 和 SOCK_DGRAM。另外,AF_UNIX 和 AF_LOCAL 是等价的,所以AF_UNIX 也属于本地 socket
针对 TCP协议通信的 socket 编程模型:
流程:
示意图:
注意:
- 服务端调⽤ accept 时,连接成功了会返回⼀个已完成连接的 socket,后续⽤来传输数据。监听的socket 和真正⽤来传送数据的 socket,是「两个」 socket,⼀个叫作监听 socket,⼀个叫作已完成连接 socket。
- 成功连接建⽴之后,双⽅开始通过 read 和 write 函数来读写数据,就像往⼀个⽂件流⾥⾯写东⻄⼀样。
针对 UDP 协议通信的 socket 编程模型
注意:
- UDP 是没有连接的,所以不需要三次握⼿,也就不需要像 TCP 调⽤ listen 和 connect,但是 UDP 的交互仍然需要 IP 地址和端⼝号,因此也需要 bind。对于 UDP 来说,不需要要维护连接,那么也没有所谓的发送⽅和接收⽅,甚⾄都不存在客户端和服务端的概念,只要有⼀个 socket 多台机器就可以任意通信,因此每⼀个 UDP 的 socket 都需要 bind。
- 每次通信时,调⽤ sendto 和 recvfrom,都要传⼊⽬标主机的 IP 地址和端⼝(即套接字)。
本地通信
本地字节流 socket 和 本地数据报 socket 在 bind 的时候,不像 TCP 和 UDP 要绑定 IP 地址和端⼝,⽽是绑定⼀个本地⽂件,这也就是它们之间的最⼤区别。
多线程同步
互斥
多线程执行操作共享变量的代码导致竞争状态,将此代码称之为临界区。临界区内代码访问共享资源,一定不能给多线程同时执行。
我们希望临界区的代码是互斥的,保证只有一个线程在临界区执行,其他线程被阻止进入临界区。
同步
有时候我们⼜希望多个线程能密切合作,以实现⼀个共同的任务。
例⼦,线程 1 是负责读⼊数据的,⽽线程 2 是负责处理数据的,这两个线程是相互合作、相互依赖的。线程 2 在没有收到线程 1 的唤醒通知时,就会⼀直阻塞等待,当线程 1 读完数据需要把数据传给线程 2 时,线程 1 会唤醒线程 2,并把数据交给线程 2 处理。
所谓同步,就是并发进程/线程在⼀些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通信息称为进程/线程同步。
总之,同步意味着A应该在B之前执行,而互斥意味着A和B不能同时执行
然后后面谈到互斥的操作,主要是锁,我们之前说过了,就不赘叙了
说到忙等待锁,就是我们说过的自旋锁。
然后就是信号量,我们也说过
添加链接描述
生产者—消费者问题
主要问题:
- 生产者产生数据放在缓冲区中
- 消费者从缓冲区读取数据处理
- 任何时刻只能有一个生产者或者消费者访问缓冲区 ,说明操作缓冲区的代码时临界区,需要互斥
- 缓冲区为空,消费者必需等待生产者生成数据,缓冲区满时生产者必需等待消费者读取数据 ,说明需要同步
所以需要三个信号量:
互斥信号量:mutex ,用于互斥访问缓冲区,初始化为1;
资源信号量: fullbuffers,用于消费者询问缓冲区内是否有数据,初始化为0(表明一开始为空,代表消费者只关系里面有多少资源,满槽);
资源信号量:emptybuffers,用于生产者询问缓冲区是否有空位,有则生成数据,初始化为n(n为缓冲区大小,代表生产者只关心缓冲区空多少,空槽);
#define n 100
int mutex=1;
int dbuffers=0;(满槽)
int xbuffers=n;(空槽)
void producer()
{
while(true)
{
p(xbuffers);//生产者先看是不是能生产 xbuffer-1 ;空槽-1
p(mutex);//进入临界区
write;
v(mutex);//离开临界区
v(dbuffers);//生产了一个,消费者可读的资源+1 ,满槽+1
}
}
void consumer()
{
while(true)
{
p(dbuffers);//消费者先看缓冲区里有没有资源可以消费 -1 ;满槽-1
p(mutex);
read;
v(muetx);
v(xbuffers);//消费者消费了一个,+1, 空槽+1;
}
}
消费者如果一开始读满槽P(fullBuffers),发现fullBuffers为0,就不能读,变成-1;只能等待;
然后生产者读空槽P(emptyBuffers) ,正常生产,减去一个空槽,如果没有其他生产者竞争临界区,就生产一个,然后V(fullBuffers),给满槽+1,此时信号量fullBuffers从 -1 变成 0;一开始为-1代表有进程阻塞了,唤醒消费者线程。
哲学家就餐问题
主要问题:
- 5个人5个叉子
- 每个人必需有两个叉子才愿意吃饭
- 吃完饭放回叉子
方案1:
即让偶数编号的哲学家「先拿左边的叉⼦后拿右边的叉⼦」,奇数编号的哲学家「先拿右边的叉⼦后拿左边的叉⼦」。不会出现死锁;
方案2:
⽤⼀个数组 state 来记录每⼀位哲学家在进餐、思考还是饥饿状态(正在试图拿叉⼦)⼀个哲学家只有在两个邻居都没有进餐时,才可以进⼊进餐状态。
顺时针编号:第 i 个哲学家的左邻右舍,则由宏 LEFT 和 RIGHT 定义:
LEFT : ( i + n- 1 ) % n
RIGHT : ( i + 1 ) % n
读者-写者问题
数据库的读写问题
读读不冲突
读写冲突
写写更冲突
主要不能使读优先或者写优先,这样都会造成饥饿问题。
公平策略:
读写优先级相同增加flag
同一时间只有一个写者能访问临界区
同一时间可以多个读者访问临界区。
int rcountmutex=1;//读者个数互斥修改
int wdatamutex=1;//写者操作的互斥信号量
int flag=1;//用于实现公平竞争
int rcount=0;//代表正在读的读者个数
void writer()
{
while(true)
{
p(flag);
p(wdatamutex);//写者进入临界区
write();
v(wdatamutex);
v(flag);
}
}
void reader()
{
while(true)
{
p(flag);
p(rcountmutex);//进入读者个数临界区
if(rcount==0)
{
//没有人在读,我就可以读,并且我读的时候要阻塞写者写
p(wdatamutex);
}
rcount++;//读者数量+1
v(rconutmutex);
v(flag);
read();
//读完了要修改读者数量,且要唤醒阻塞中的写操作
p(rcountmutex);
rcount--;
if(count==0)
{
v(wdatamutex);
}
v(rcountmutex);
}
}
加了⼀个信号量 flag ,就实现了公平竞争
flag 的作⽤就是阻⽌读者的这种特殊权限(特殊权限是只要读者到达,就可以进⼊读者队列)
开始来了⼀些读者读数据,它们全部进⼊读者队列,此时来了⼀个写者,执⾏ P(falg) 操作,使得
后续到来的读者都阻塞在 flag 上,不能进⼊读者队列,这会使得读者队列逐渐为空,即 rCount 减为
0。
这个写者也不能⽴⻢开始写(因为此时读者队列不为空),会阻塞在信号量 wDataMutex 上,读者队列
中的读者全部读取结束后,最后⼀个读者进程执⾏ V(wDataMutex) ,唤醒刚才的写者,写者则继续开始
进⾏写操作
死锁
成两个线程都在等待对⽅释放锁,在没有外⼒的作⽤下,这些线程会⼀直相互等待,就没办法继续运⾏,这种情况就是发⽣了死锁
产生条件:
1.互斥:指多个线程不能同时使用一个资源
2.持有并等待:线程在等待别的资源2的时候不会释放自己已经有的资源1
3.不可剥夺:自己线程持有资源后,使用完之前不能被其他线程获取
4.环路等待:两个线程获取资源的顺序构成了环形链。
解决方法
破坏上面其中一点即可,最常见的可行办法使用资源有序分配法,来破坏环路等待
即假设有资源ABCD,所有线程都必需按顺序从A到D申请自己需要的资源,这样就不会产生死锁。
乐观锁与悲观锁
最基本的锁互斥,自旋锁。其余锁选择一个:
1.互斥锁:强调独占,加锁失败释放CPU,然后阻塞。睡眠。等到合适时机唤醒。具体的说:加锁失败从用户态陷入内核态,让内核切换线程,开销存在两次线程上下文切换,一.加锁失败线程从运行到睡眠,把CPU切换给其他线程; 二.锁释放的时候,睡眠状态变为就绪,内核找一个合适的时间把CPU切换给线程运行。 线程运行:当两个线程是属于同⼀个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据。
2.自旋锁:加锁失败线程进入忙等待(用CPU的PAUSE指令实现),直到拿到锁。锁住时间短用自旋锁,时间长用互斥锁
3.读写锁:分为S读锁和X写锁,同数据库。读写会有冲突。公平读写锁:用队列先进先出。读写锁可以选择互斥或者自选锁的某一种实现。
4.悲观锁:互斥,自旋锁,读写锁都是悲观锁。
5.乐观锁:多线程同时修改共享资源概率低时用乐观锁:即不加锁;先修改完共享资源,再验证这段时间内有没有发⽣冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。