操作系统-小林coding-进程管理
本系列笔记为作者在跟随小林coding学习的时候做的笔记。感谢小林大大。
进程、线程基础知识
进程
运行中的程序,就被称为「进程」(Process)
中断:阻塞进程释放CPU
多个程序、交替执行
并行:同一时刻多个进程同时执行 并发:同一时刻单个进程执行,但是一段时间内多个进程交替执行
进程的状态
描述进程没有占用实际的物理内存空间的情况,这个状态就是挂起状态
导致进程挂起的原因不只是因为进程所使用的内存空间不在物理内存,还包括如下情况:
- 通过 sleep 让进程间歇性挂起,其工作原理是设置一个定时器,到期后唤醒进程
- 用户希望挂起一个程序的执行,比如在 Linux 中用 Ctrl+Z 挂起进程
进程的控制结构
进程控制块(process control block,PCB):进程存在的唯一标识
信息:进程描述信息,进程控制和管理信息,资源分配清单,CPU 相关信息
PCB 如何组织的:链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列
进程的控制
创建、终止、阻塞、唤醒
进程的上下文切换
进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源
线程
为什么使用线程
- 进程间通信开销大
- 进程维护切换开销大
什么是线程
线程是进程当中的一条执行流程。
同一个进程内多个线程之间共享代码段、数据段、打开的文件等资源,但每个线程各自都有一套独立的寄存器和栈,确保线程的控制流是相对独立的
线程的优点:
- 一个进程中可以同时存在多个线程
- 各个线程之间可以并发执行
- 各个线程之间可以共享地址空间和文件等资源
线程的缺点:当进程中的一个线程崩溃时,会导致其所属进程的所有线程崩溃(这里是针对 C/C++ 语言和goruntine,Java语言中的线程奔溃不会造成进程崩溃)
举个例子,游戏的用户设计不应该使用多线程的方式,否则一个用户挂了,会影响其他同个进程的线程
线程与进程的比较
线程相比进程能减少开销
线程的上下文切换
线程是调度的基本单位,而进程则是资源拥有的基本单位
- 当两个线程不属于同一个进程,切换的过程就跟进程上下文切换一样
- 当两个线程是属于同一个进程,只需要切换线程的私有数据、寄存器等不共享的数据
线程的实现
主要有三种线程的实现方式:
- 用户线程(User Thread):在用户空间实现的线程,可扩展不支持线程技术的操作系统,切换块,但系统调用阻塞会影响同进程的线程,线程时间片分配不由操作系统控制无法打断,多线程进程和少线程进程分的时间片一样多
- 内核线程(Kernel Thread):在内核中实现的线程,由内核管理的线程,系统调用不会影响同进程线程,多线程进程获得更多时间片,但进程和线程上下文都由内核维护,线程的状态切换都需要系统调用开销大
- 轻量级进程(LightWeight Process):内核支持的用户线程,一个进程可有一个或多个 LWP,每个 LWP 是跟内核线程一对一映射的,也就是 LWP 都是由一个内核线程支持,而且 LWP 是由内核管理并像普通进程一样被调度。可以实现多对一,一对一,多对多用户线程和内核线程的对应关系
存储线程控制块(Thread Control Block, TCB)的进程表在用户空间就是用户线程实现,在内核空间就是内核线程实现
进程间有哪些通信方式
每个进程的用户地址空间都是独立的,一般而言是不能互相访问的,但内核空间是每个进程都共享的,所以进程之间要通信必须通过内核。
有六种进程间通信方式
管道
匿名管道
linux的|
运算符就使用了匿名管道
|
|
创建的子进程会复制父进程的文件描述符
匿名管道的通信范围是存在父子关系的进程
命名管道
命名管道可以在不相关的进程间也能相互通信
命名管道其实就是提前创建了一个类型为管道的设备文件,在进程里只要使用这个设备文件,就可以相互通信
消息队列
消息队列是保存在内核中的消息链表
限制:通信不及时,附件也有大小限制
消息队列不适合比较大数据的传输,存在用户态与内核态之间的数据拷贝开销
共享内存
多个进程拿出一块自己虚拟地址空间来映射到相同的物理内存中
信号量
信号量是一个整型的计数器,用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据
信号量表示资源的数量,控制信号量的方式有两种原子操作:
- P 操作:信号量减 1,如果信号量 < 0,表明资源已被占用,进程阻塞等待;如果信号量 >= 0,表明还有资源可使用,进程继续执行
- V 操作:信号量加上 1,如果信号量 <= 0,表明当前有阻塞中的进程,将进程唤醒运行;如果信号量 > 0,表明当前没有阻塞中的进程
信号
异常情况下的工作模式
Linux 操作系统中, 为了响应各种各样的事件,提供了几十种信号,分别代表不同的意义。可以通过 kill -l 命令查看所有信号
运行在 shell 终端的进程,可以通过键盘输入某些组合键的时候,给进程发送信号
- Ctrl+C 产生 SIGINT 信号,表示终止该进程
- Ctrl+Z 产生 SIGTSTP 信号,表示停止该进程,但还未结束
如果进程在后台运行,可以通过 kill 命令的方式给进程发送信号
- kill -9 1050 ,表示给 PID 为 1050 的进程发送 SIGKILL 信号,用来立即结束该进程
信号是进程间通信机制中唯一的异步通信机制
用户进程对信号的处理方式:
- 执行默认操作
- 捕捉信号:为信号定义一个信号处理函数,收到信号执行该函数
- 忽略信号:不希望处理某些信号时,忽略该信号,不做任何处理。SIGKILL 和 SEGSTOP无法捕捉和忽略,它们用于在任何时候中断或结束某一进程
各种语言通常可以重写信号处理函数,但是STOP和KILL信号无法重写
Socket
Socket跨网络与不同主机上的进程之间通信
根据创建 socket 类型的不同,通信的方式也就不同,主要有3类:TCP,UDP和本地
TCP
UDP
本地
在同一台主机上进程间通信
本地字节流 socket 和 本地数据报 socket 在 bind 的时候,不像 TCP 和 UDP 要绑定 IP 地址和端口,而是绑定一个本地文件,这也就是它们之间的最大区别
怎么避免死锁
死锁的概念
死锁:多个线程都在等待对方释放锁
条件:
- 互斥条件:多个线程不能同时使用同一个资源
- 持有并等待条件:线程 A 在等待资源 2 的同时并不会释放自己已经持有的资源 1
- 不可剥夺条件:在自己使用完之前不能被其他线程获取
- 环路等待条件:多个线程获取资源的顺序构成了环形链
什么是悲观锁、乐观锁
互斥锁与自旋锁
- 互斥锁加锁失败后,线程会释放 CPU ,给其他线程
- 自旋锁加锁失败后,线程会忙等待,直到它拿到锁
被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁,否则使用互斥锁
在单核 CPU 上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU
读写锁
读写锁适用于能明确区分读操作和写操作的场景
读写锁在读多写少的场景,能发挥出优势
乐观锁与悲观锁
前面提到的互斥锁、自旋锁、读写锁,都是属于悲观锁
- 悲观锁:访问共享资源前,先要上锁
- 乐观锁:结束操作时检查是否有其他线程修改资源
乐观锁全程并没有加锁,所以它也叫无锁编程
只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁