线程和进程
简述一下之间的区别与联系 (假设使用时间片轮转的抢占式调度方式,其实还有优先级调度、多级反馈队列(MLFQ)、完全公平调度(CFS)等调度方式
进程
首先,我们写的代码存储在硬盘的静态文件中,接着被编译成二进制的可执行文件。在运行该可执行文件的时候,它被丢到内存里,然后 CPU 按顺序执行丢到内存中的指令。
那个被丢到内存中一整个被CPU执行的东西,被称为进程。比如我们同时打开了浏览器,笔记软件和声破天,就相当于现在计算机在运行三个进程(仅指前台)
单核一次只能执行一个进程,那么,如果一直等待一个进程结束之后再执行另一个进程,就会变得很不妙(想象一下,如果计算机只能等待每一个进程执行结束之后再执行下一个 —— 打字的时候就无法使用其他软件了,因为处理打字信息也是一个进程,我们需要等待打字全部结束之后才可以访问其他进程,电脑废了
所以就需要引入进程的控制
并发
简单来说,就是单核、在多个程序间交替执行。比如我们需要把两辆车搬回家,一种方式是先开 A 车到家,再开 B 车到家。另一种并发的方式就是先把 A 车开 10m,再把 B 车开 10m,这样交替执行。假设我们把 A 和 B 开的距离无线缩短并且忽略切换车辆的时间,那么宏观上就好像 A と B 是同时前进了。
并行
还是 A 和 B 车,但是现在富有的我请代驾帮忙把 B 车开回家,同时我也把 A 车开回家。这个就是并行 —— 多核执行多进程(真正的把多个进程同时运行
关于并发和并行的内容可能后面会再开一个 doc 详细说明,在此处先简略一下。
具体执行
既然需要在不同的进程中不断跳转(分时控制) 就需要考虑两件事情 ——
- 如何切换进程 —— 上下文切换机制
- 如何选择要切换的进程 / 何时切换进程 —— 调度策略
操作系统通过调度策略决定切换时机,通过上下文切换机制完成切换进程的操作
上下文切换机制
要切换进程,肯定不是简单的直接丢掉不做,而要考虑两件事情——
- 保存当前的进程状态
- 恢复待执行的进程状态
所以一个进程一般有以下状态 ——
- 就绪状态:标记可以运行,但是还没运行(没有被分配时间片,在等待被调度)
- 运行状态:被赋予了时间片,开始执行
- 阻塞状态:为了等待某事件发生而暂停运行(有点像挂起?),比如触发了同步的IO流操作等,为了等待IO操作的完成,于是会被标记为阻塞状态 现代操作系统中可能会有更复杂的状态,但是上面三个的互相转化是基础
控制结构
引入了一个数据结构 PCB(Process Control Block)来描述进程的运行,其中包含很多信息,如
- 进程描述信息
- 进程控制和管理信息
- 资源分配信息
- CPU 相关信息
最后通过链表或者索引表的形式来组成各种状态队列来依次执行
线程
比进程更小的基本运行单位,可以把进程中每一个小的执行流看成一个线程(thread)。
同进程内的多个线程之间共享一定的资源(全局变量、文件等),但是每一个线程都有自己独立的寄存器和栈,以保证线程之间的相对独立。
线程同样也可以并发,同时使用了 TCB (Thead Control Block)来描述一个线程的运行。
举点例子
- 现在有两个核心和一个支持多线程的任务。假设我们不采用多线程的开发方式,只是让一个单核同步运行,那么比较慢;但是如果这个多线程之间不冲突,那么理论上可以用两个核心同时执行程序,使得该进程的执行速度减半。
- 现在有若干核心和一个单线程任务,那么,此时执行该任务所花费的时间就与核心数量无关,因为不论有多少核心,只能分配一个来给单线程任务执行。
可能还有其他例子,但是综上所述,可以发现,线程其实是 CPU 调度的最小单位,而进程可以看作分配资源的最小单位。
发现问题
线程之间必然存在一定的关系,假设线程之间有 竞争 / 依赖 关系怎么办? 生活化的例子 ——
- 竞争:有三个人去一个咖啡机前喝咖啡,此时三个人就是竞争的关系,而咖啡机就可以看作单核,三个人分别对应三个线程(多个线程对部分相同资源的调用往往就是竞争
为了规避竞争条件,引入互斥的概念 —— 在集合中,互斥是没有交集。此处也有点类似,互斥即保证在对同一个资源的调用上,保证同一时间只能有一个线程(这个概念进程也同样适用)。
比如 线程A 和 线程B 都在修改 全局变量G,那么互斥就保证在 线程A 执行的时候访问 G,并且阻塞 线程B 的执行;在 线程A 执行结束之后再让 线程B 执行。
- 依赖:在做饭这个进程中(
每日四菜一汤),为了炒菜(线程 A),必须先切菜(线程 B),此时线程 A 就依赖线程 B(比如我们在操作本地数据时,需要先读取数据之后才可以开始修改,这也是一种依赖关系
为了描述依赖条件,引入了同步的概念 —— 安排线程执行的先后顺序
为了实现互斥和同步,可以引入两种常见的解决方式——
- 锁
- 信息量
锁
想象一下住在高塔里的公主,每个骑士想要看到她都需要爬上高塔,一面之后再离开,下一位骑士再爬上塔与公主见面(好奇怪的公主)。
这个“塔”就有点像是一个“锁”。
每个进入临界区线程,都必须先上锁,接着在访问临界资源之后解锁。
小心死锁
信号量
通常信息量是用于表示资源的数量。有两种原子操作的方法来控制信息量
- P 操作 —— 将信号量 -1,如果信号量小于零,则不再允许进程 / 线程进行这段资源的访问,进入阻塞状态;
- V 操作 —— 将信号量 +1,如果此时信号量小等于零,则会唤醒一个正在等待的进程 / 线程 其中 P 是在进入临界区之前调用;V 是在离开临界区时调用,必须成对出现
灵活的移动不同线程 P、V 函数之间的位置,就可以对应实现同步或是互斥
- 同步,把共享资源的信号量设为1,假设此时有两个需要进行互斥操作的线程A和B
sem = 1
P_A(sem)
P_B(sem)
... # A 的执行, 因为此时 sem = 0,所以 B 无法进入临界区
V_A(sem)
... # B 的执行, 因为 V_A 会调起正在等待的 B
V_B(sem)
- 互斥,把共享资源的信号量设为0,这样 进程A 在最开始访问的时候无法访问,被阻塞;但是如果让 进程B 执行一次针对此信号量的V操作,就会使 A 开始执行,然后 A 执行后再类似的唤醒 B 进程;就可以看作是 B 需要在 A 之后执行了
semA = 0
semB = 0
def A():
P(semA) # 想要访问贡献资源A, 但是因为此时 semA < 0 了,所以被阻塞,无法执行
V(semB) # 在 A 执行结束之后,唤醒 B 执行
def B():
V(semA) # 先使得 A 可以执行
P(semB) # 想要访问共享资源 B,但是此时 semB < 0 了,所以被阻塞,无法执行
区别与联系
区别
进程 | 线程 | |
---|---|---|
资源拥有 | 独立拥有地址空间、文件描述符、信号处理等 | 共享进程的地址空间、文件描述符等 |
上下文切换开销 | 较大:切换时需保存/恢复整个 PCB,切换地址空间 | 较小:只需保存/恢复线程的寄存器和栈指针 |
调度粒度 | 操作系统调度单位 | 操作系统最小调度单位 |
系统调用 | 切换或创建进程需进入内核,开销高 | 创建/切换线程也需内核介入(除用户级线程外),但整体开销较低 |
通信方式 | 需进程间通信(IPC):管道、消息队列、共享内存、信号等 | 同进程内直接通过共享内存或同步原语(锁、条件变量等)即可 |
稳定性与安全 | 一个进程崩溃不会直接影响其他进程 | 同进程内一个线程崩溃可能导致整个进程(包括其他线程)被终结 |
联系
- 隶属关系:线程必须依附于某个进程,所有线程共享其所属进程的资源池。
- 并发与并行:
- 单核 CPU 上,多个进程(或线程)通过时间片抢占式调度实现并发;
- 多核 CPU 上,不同的进程和线程可以真正并行执行。
- 上下文切换流程相似:无论进程还是线程,切换都要保存当前状态并恢复目标控制块(PCB/TCB),只是切换粒度与成本不同。
- 同步与互斥:同进程内线程间共享资源更方便,却也更易产生竞态条件,进程间竞争通常更为隔离,但通信成本更高。
写在最后
但是我们在生产生活实际当中,还要考虑到调度算法的复杂度,以及轮换所消耗的时间,所以其实多线程不一定会比单线程跑的快