小林图解系统进程与线程
四、进程与线程
1. 进程线程基础知识
进程:
我们编写的代码只是一个存储在硬盘的静态文件,通过编译后就会生成⼆进制可执行文件,当我们运行这个可执行文件后,它会被装载到内存中,接着 CPU 会执行程序中的每一条指令,那么这个运行中的程序,就被称为「进程」(Process) 。
进程的状态:
一个进程的活动期间至少具备三种基本状态,即运行状态、就绪状态、阻塞状态
此外,进程还有另外两个基本状态:创建状态和结束状态
因为阻塞状态的进程占用了物理内存,因此,在虚拟内存管理的操作系统中,通常会把阻塞状态的进程的物理内存空间换出到硬盘,等到再次运行的时候,再从硬盘换入到物理内存。这种行为也叫做挂起状态,挂起状态又分为阻塞挂起状态和就绪挂起状态两种。
进程的控制结构:操作系统中使用进程控制块(PCB)来描述进程,每个PCB通常是通过链表的方式进行组织。
PCB包含的信息:
- 进程描述信息:
- 进程标识符:标识各个进程,每个进程都有一个并且唯一的标识符;
- 用户标识符:进程归属的用户,用户标识符主要为共享和保护服务;
- 进程控制和管理信息:
- 进程当前状态,如 new、 ready、 running、 waiting 或 blocked 等;
- 进程优先级:进程抢占 CPU 时的优先级;
- 资源分配清单:
- 有关内存地址空间或虚拟地址空间的信息,所打开文件的列表和所使用的 I/O 设备信息。
- CPU 相关信息:
- CPU 中各个寄存器的值,当进程被切换时, CPU 的状态信息都会被保存在相应的 PCB
中,以便进程重新执行时,能从断点处继续执行。
- CPU 中各个寄存器的值,当进程被切换时, CPU 的状态信息都会被保存在相应的 PCB
进程的控制
1.创建进程
- 为新进程分配一个唯一的进程标识号,并申请一个空白的 PCB, PCB 是有限的,若申请失败则创建失败;
- 为进程分配资源,此处如果资源不足,进程就会进入等待状态,以等待资源;
- 初始化 PCB;
- 如果进程的调度队列能够接纳新进程,那就将进程插入到就绪队列,等待被调度运行;
2.终止进程:正常结束、异常结束和外界干预(信号kill掉)
- 查找需要终止的进程的 PCB;
- 如果处于执行状态,则立即终止该进程的执行,然后将 CPU 资源分配给其他进程;
- 如果其还有子进程,则应将其所有子进程终止;
- 将该进程所拥有的全部资源都归还给父进程或操作系统;
- 将其从 PCB 所在队列中删除;
3.阻塞进程
- 找到将要被阻塞进程标识号对应的 PCB;
- 如果该进程为运行状态,则保护其现场,将其状态转为阻塞状态,停止运行;
- 将该 PCB 插入到阻塞队列中去;
4.唤醒线程
- 在该事件的阻塞队列中找到相应进程的 PCB;
- 将其从阻塞队列中移出,并置其状态为就绪状态;
- 把该 PCB 插入到就绪队列中,等待调度程序调度;
进程的上下文切换
进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。 通常,会把交换的信息保存在进程的 PCB,当要运行另外一个进程的时候,我们需要从这个进程的 PCB 取出上下文,然后恢复到 CPU 中,这使得这个进程可以继续执行。
线程:
线程之间可以并发运行且共享相同的地址空间。同一个进程内多个线程之间可以共享代码段、数据段、打开的文件等资源,但每个线程各自都有一套独立的寄存器和栈,这样可以确保线程的控制流是相对独立的。
线程与进程的最大区别为:线程是调度的基本单位,进程是资源拥有的基本单位
线程的优点:
- 一个进程中可以有多个线程
- 各个线程之间可以并发执行
- 各个线程之间可以共享地址空间和文件等资源
线程的缺点:
- 当进程中的一个线程崩溃时,会导致所属进程的所有线程崩溃
线程在创建,终止,线程切换(同一进程内的不同线程),共享内存和文件资源等方面都比进程快
线程的实现:
- 用户线程(User Thread) :在用户空间实现的线程,不是由内核管理的线程,是由用户态的线程库来完成线程的管理;对于操作系统不能看见TCB,只能看得见整个进程的TCB,上下文切换发生在用户空间,不用通过内核,切换速度较快。
- 内核线程(Kernel Thread) :在内核中实现的线程,是由内核管理的线程,用户线程和内核线程是1对1的;缺点是线程的开销比较大
- 轻量级进程(LightWeight Process):在内核中来支持用户线程;
调度:
实际上就是选择一个进程执行
- 非抢占式调度算法就是挑选进程运行直到被阻塞,才会调用另外一个进程;
- 抢占式调度算法就是挑选一个进程只允许一段时间,到时如果没执行完,就会将其挂起。也就是时间片机制
针对上面的五种调度原则,总结成如下:
- CPU 利用率:调度程序应确保 CPU 是始终匆忙的状态,这可提高CPU 的利用率;
- 系统吞吐量:吞吐量表示的是单位时间内 CPU 完成进程的数量,长作业的进程会占用较长的 CPU 资源,因此会降低吞吐量,相反,短作业的进程会提升系统吞吐量;
- 周转时间:周转时间是进程运行和阻塞时间总和,一个进程的周转时间越小越好;
- 等待时间:这个等待时间不是阻塞状态的时间,而是进程处于就绪队列的时间,等待的时间越长,用户越不满意;
- 响应时间:用户提交请求到系统第一次产生响应所花费的时间,在交互式系统中,响应时间是衡量调度算法好坏的主要标准。
调度算法
- 先来先服务,FCFS,可能有一个长作业,后面短作业都得等着
- 短作业优先(SJF):也就是运行时间越短(占CPU的时间)的任务先完成,长作业可能一直等待
- 时间片轮转(RR):每个线程分配一定时间,没执行完也会切换下一个,时间片过长,退回到FCFS
- 高优先级(HPF):业务按照优先级处理,运行时间越长,降低其优先级。等待时间越长,提高其优先级
- 多级反馈队列(MFQ):优先级越高时间片越短,如果长作业到了高优先级没有轮到,会将其放到此优先级的队列末尾,这样虽然等待时间增长,但是轮到自己可以运行的时间也变长了,算是一种平衡。
4.2 进程间通信
进程间通信的方式:管道,消息队列,共享内存,信号量,信号,Socket;进程间通信是需要内核作为支持的
管道:$ ps auxf | grep mysql
;|
就是一个管道,他的作用是将前一个命令的输出,作为后一个命令的输入。管道传输数据是单向的。如果想双向通信,就需要创建两个管道;这里的管道是匿名管道。(实际上是创建了两个子进程,子进程都复制了父进程的文件描述符来实现通信),其生命周期随着进程创建而建立,随着进程终止而消失。
也可以创建命名管道,也被叫做FIFO,因为数据是先进先出的传输方式。在使用命名管道前,先需要使用mkfifo
来创建,并指定管道名字:$ mkfifo myPipe
,然后可以向管道中写入数据$ echo "hello" > myPipe
,并执行另外一个命令来读取数据$ cat < myPipe
管道的缺点是通信效率低,不适合频繁的进程间交换,优点就是简单。匿名管道是特殊的文件,只存在内存中,不存在文件系统中。管道实际就是内核中的一串缓存。从管道的一段写入的数据缓存在内核中,另一端再读取。
- 对于匿名管道,它的通信范围是存在父子关系的进程。因为管道没有实体,也就是没有管道文件,只能通过 fork 来复制父进程 fd文件描述符,来达到通信的目的。
- 对于命名管道,它可以在不相关的进程间也能相互通信。因为命令管道,提前创建了一个类型为管道的设备文件,在进程里只要使用这个设备文件,就可以相互通信。
消息队列:消息队列是保存在内核中的消息链表,发送数据时,会分成一个个独立的数据单元,也就是消息体。发送时只需要把数据放到对应的消息队列就可以了,不需要等待对端读取。(相对比管道的改进),此外,还克服了管道通信的数据是无格式的字节流的问题。
存在的问题:通信不及时(存在内核态和用户态之间的数据拷贝开销),而且附件大小有限制(Linux中定义了一条消息的最大长度和一个队列的最大长度)。
共享内存:相较于消息队列不存在用户态和内核态的消息拷贝过程。拿出一块虚拟地址空间来,映射到相同的物理内存中去。
信号量:共享内存存在的问题,如果多个进程同时修改一个共享内存,很有可能会出现冲突。为了防止多进程竞争共享资源造成数据错乱,需要保护机制,也就是信号量,其主要用于实现进程间的互斥和同步,使得共享的资源在任意时刻只能被一个进程访问。
- P操作:进入共享内存之前,把信号量减1,如果信号量 < 0,表明资源已被占用,进程需阻塞等待。如果信号量 >=0,表面还有资源可用,进程可以正常执行。
- V操作:离开共享内存之后,把信号量加1,如果信号量 < 0,表明资源已被占用,进程需阻塞等待。如果信号量 >=0,表面还有资源可用,进程可以正常执行。
当信号量初始化为1时,就代表互斥信号量,共享内存任意时刻只有一个进程在访问。
当信号量初始化为0时,就代表同步信号量,保证进程A应在进程B之前执行。
信号:对于异常的工作情况,就需要用信号的方式(异步通信机制)来通知进程。例如Ctrl + C
代表SIGINT
信号,表示终止进程;Ctrl + Z
代表SIGTSTP
信号,表示停止进程,但还没结束。
Socket:前面的管道,消息队列,共享内存,信号,信号量都是同一主机间的进程间通信,如果想跨网络与不同主机上的进程之间通信,就需要Socket。
4.3 多线程同步
出现的背景:对于共享资源,如果没有上锁,在多线程的环境中,就有可能出现问题。线程是调度的基本单位,进程是资源分配的基本单位。
多线程在相互竞争共享变量时,如果运气不好,在执行过程中就可能出现上下文切换,输出的结果存在不确定性。
为了实现进程/线程间正确的协作,主要的方法有两种:
- 锁:加锁、解锁操作
- 信号量:P、V操作
4.4 死锁
当互斥锁使用不当的时候,可能造成两个线程都在等待对方释放锁,没有外力作用的情况下,这些线程会一直相互等待对方释放锁,这时就发生了死锁。
死锁必须同时满足下面的条件才会发生:
- 互斥条件:多个线程不能同时使用同一个资源
- 持有并等待条件:线程A持有资源1,还需要资源2,但是资源2被占用了,等待的过程中不能释放资源1
- 不可剥夺条件:若线程持有资源,自己使用完之前不能被其他资源获取
- 环路等待条件:死锁发生时,两个线程获取资源的顺序构成了环形链
避免死锁问题,就要破坏其中的条件,最常用的就是资源有序分配法来破坏环路等待条件。(也就是不同线程获取资源的顺序相同)
4.5 悲观锁和乐观锁
当加锁失败时,互斥锁用线程切换来应对,自旋锁用忙等待来应对。
- 互斥锁加锁失败后,线程会释放CPU给其他进程,线程加锁的代码会被阻塞,需要两次上下文切换的成本;(用户态和内核态之间)
- 自旋锁加锁失败后,线程会忙等待,直到他拿到锁。在锁住的代码执行时间很短时应该用自旋锁,其在用户态完成加锁和解锁操作,在等待锁时使用CPU提供的PAUSE,相当于while循环。
读写锁:如果只读取共享资源加读锁,如果需要修改共享资源加写锁;
互斥锁、自旋锁、读写锁都属于悲观锁,每次在访问共享资源时,都需要上锁。
相反的,如果多线程同时修改共享资源的概率比较低,就可以采用乐观锁。比如在线文档
加锁需要注意的点时加锁的粒度应该尽可能的小,这样执行速度会比较快;