语义:为调用进程创建一个一模一样的新进程
- 调用进程为父进程,新进程为子进程
- 接口简单,无需任何参数
- fork后的两个进程均为独立进程
- 拥有不同的进程id
- 可以并行执行,互不干扰(除非使用特定的接口)
- 父进程和子进程会共享部分数据结构(内存、文件等)
- fork的父子进程有相同的PCB
- PCB里存了fd
- 父子进程共用文件的偏移量
基本思路:只拷贝内存映射,不拷贝实际内存
- 性能较好:一条映射至少对应一个4K的页面
- 调用exec的情况里,减少了无用的拷贝(因为在调用fork之后立即调用exec会重置地址空间,之前内存拷贝毫无意义)
fork的优点
- 接口非常简洁
- 将进程“创建”和“执行”(exec)解耦,提高了灵活度
- 刻画了进程之间的内在关系(进程树、进程组)
vfork:类似于fork,但让父子进程共享同一地址空间。不会为子进程单独创建地址空间。因此父子进程中任一进程对内存的修改都会对另一进程产生影响。为了保证正确性,vfork
会在结束后阻塞父进程,直到子进程调用exec
或者退出为止。
优点:
- 连映射都不需要拷贝,性能更好
- "
vfork
+exec
"与"fork
+exec
"相比省去了一次地址空间拷贝
缺点:
- 只能用在”fork + exec”的场景中
- 共享地址空间存在安全问题
posix_spawn是POSIX提供的另一种创建进程的方式,最初是为不支持fork的机器设计的
相当于fork + exec
优点:
- 可扩展性、性能较好
- 执行时间与原进程的内存无关
缺点
- 定制参数表达能力有限,不如fork灵活
类似于fork的精密控制版,允许应用程序通过参数对创建过程进行更多控制。
exec
可以执行可执行文件
在fork
后调用exec
,在载入可执行文件之后会重置地址空间
exec
被调用时,操作系统:
- 根据pathname指明的路径,将可执行文件的数据段和代码段载入当前进程的地址空间
- 重新初始化堆栈(在这里,操作系统可以进行地址空间随机化ASLR,改变堆栈的起始地址)
- 将PC寄存器设置到可执行文件代码段定义的入口点,该入口点最终会调用main
▲ 线程是调度的基本单位
为什么需要线程
- 创建进程的开销较大
- 进程的隔离性过强(IPC)
- 进程内部无法支持并行
-
分离的内核栈和用户栈
- 当线程切换到内核中执行时,它的栈指针就会切换到对应的内核栈
- 一个线程栈对应一个内核栈
-
共享的其他区域
-
堆是共享的!(malloc)
根据线程是用户态应用还是由内核创建管理,可以分成两类:
- 内核态线程
- 由内核创建,线程相关信息存放在内核中
- 内核可见,受内核管理
- 用户态线程
- 在应用态创建,线程相关信息主要存放在应用数据中
- 内核不可见,不受内核直接管理
与内核线程相比,用户态线程更加轻量级,创建开销更小,但功能也较为受限,与内核态相关的操作需要内核态线程协助才能完成。
多对一模型
由于只有一个内核态线程,因此每次只有一个用户态线程可以进入内核,其他需要内核服务的用户态线程会被阻塞。
- 优点:内核管理简单
- 缺点:可扩展性差,无法适应多核机器的发展
一对一模型
- 优点:解决了多对一模型中的可扩展性问题
- 缺点:内核线程数量大,开销大
(主流操作系统都采用一对一模型)
多对多模型
- 优点:解决了可扩展性问题(多对一)和线程过多问题(一对一)
- 缺点:管理更为复杂
- 内核态:与PCB结构类似
- – Linux中进程与线程使用的是同一种数据结构(task_struct)
- – 上下文切换中会使用
- 应用态:可以由线程库定义
- Linux:pthread结构体
- Windows:TIB(Thread Information Block)
- 可以认为是内核TCB的扩展
不同线程可能会执行相同的代码(线程不具有独立的地址空间,多线程共享代码段),对于全局变量,不同线程可能需要不同的拷贝(用于标明系统调用错误的errno)。使用TLS可以很方便的实现线程内的全局变量。
-
线程库允许定义每个线程独有的数据
- __thread int id; 会为每个线程定义一个独有的id变
-
每个线程的TLS结构相似
- 可通过TCB索引
-
TLS寻址模式:基地址+偏移量
- X86: 段页式 (fs寄存器)
- AArch64: 特殊寄存器tpidr_el0
即重要寄存器信息
- 常规寄存器:x0-x30
- 程序计数器(PC): elr_el1
- 栈指针:sp_el0
- CPU状态(如条件码):spsr_el1
内核态TCB:
- 上半部分:线程的相关信息
- 下半部分:线程上下文
- TCB下面为线程的内核栈
- 刚进入内核时的线程内核栈为空
- sp_el1指向栈顶
应用线程可通过异常、中断或系统调用进入内核态
- 运行状态将切换到内核态(EL1)
- 开始使用sp_el1作为栈指针(用户栈切换到内核栈)
- 保存应用线程的PC(elr_el1)
- 保存应用线程的CPU状态(spsr_el1)
- 以上均由硬件自动完成
- 操作系统确定下一个被调度的线程(调度器决定)
- 切换页表
- 将页表相关寄存器的值置为目标线程的页表基地址
- 切换内核栈 – 找到目标内核栈的栈顶指针(目标线程的TCB)
- 修改sp_el1的值至目标内核栈
- 🔺可以认为是线程执行的分界点(切换之后变为目标线程执行)
- 上下文恢复:取出栈上的值并存回寄存器
- 返回用户态:调用eret,由硬件执行一系列操作
- 将elr_el1中的返回地址存回PC
- 改为使用sp_el0作为栈指针(内核栈切换到用户栈)
- 将CPU状态设为spsr_el1中的值
- 运行状态切换为用户态(EL0)
🔺 共涉及两次权限等级切换、三次栈切换
🔺 内核栈的切换是线程切换执行的“分界点
- 复杂应用:对调度存在更多需求
- 生产者消费者模型:生产者完成后,消费者最好马上被调度
- 内核调度器的信息不足,无法完成及时调度
- “短命”线程:执行时间亚毫秒级(如处理web请求)
- 内核线程初始化时间较长,造成执行开销
- 线程上下文切换频繁,开销较大
比线程更加轻量级的运行时抽象
- 不单独对应内核线程
- 一个内核线程可以对应多个纤程(多对一)
优点
- 不需要创建内核线程,开销小
- 上下文切换快(不需要进入内核)
- 允许用户态自主调度,有助于做出更优的调度决策
优势
- 纤程切换及时
- 当生产者完成任务后,可直接用户态切换到消费者
- 对该线程来说是最优调度(内核调度器和难做到)
- 高效上下文切换
- 切换不进入内核态,开销小
- 即时频繁切换也不会造成过大开销
在Linux中,若在一个多线程进程的某个线程中使用fork创建一个子进程,则在子进程中会存在几个线程?创建出的子进程可能会导致什么问题?Linux为何要如此设计Fork的语义
(1)只有调用fork的线程会存在于子进程中。
(2)于此同时,由于fork的调用会拷贝整个内存空间的内容(包括锁、条件变量等)。因此,若调用fork的线程希望获得另一个线程所持有的锁时,会触发死锁。
(3)(一种可能的解释):Linux如此设计fork是出于性能上的考虑。若希望在fork时对所有线程都进行复制,则需要确保所有线程被冻结在一个可被拷贝的状态,之后才能对所有线程进行状态复制。这一冻结、拷贝过程会消耗大量的时间。对fork的常见用法(首先使用fork创建新进程,之后使用exec执行这一进程)而言,对所有线程进行拷贝是昂贵且无用的,因此在当前的Linux设计中并没有如此实现。
对于分配大量内存的应用,为何及时采用了写时拷贝(Copy-on-write,COW)优化后,
fork
的性能仍然比vfork
要差?请尝试利用vfork
的思路对fork
进行优化,从而在不改变fork
语义的前提下,提升fork
的性能
原因:相较于使用了写时拷贝优化的fork而言,vfork更进一步,消除了对内存映射关系(页表)的拷贝,因此,对于消耗了大量内存的应用而说,fork对页表的拷贝仍然需要消耗一定时间,其性能相较于vfork更慢
可能优化方法:在fork时,可以选择仅拷贝初级页表,在后续第一次访问某一内存地址时,再对后续几级的页表进行拷贝。
在像
ChCore
一样的单进程多线程的操作系统中,操作系统通常以线程为粒度进行调度。在一些调度的实现中,在从属于同一个进程的两个不同线程之间进行上下文切换所消耗的时间比在从属于不同进程的两个不同线程之间进行切换耗时更低,试解释其原因
从属于同一个进程的线程共享同一个地址空间与内存映射,因此,在同一进程内不同线程间进行上下文切换时,无需进行页表切换、TLB刷新等操作,相较而言性能更快
对于像协程一样的用户态线程而言,由于所有的协程均从属于同一个内核态线程,因此同一时间仅能同时运行一个协程。在这一情况下,为何协程仍然能够提升应用性能?对于哪类应用,协程能够尽可能高的提升系统性能
(1)由于频繁的线程创建和线程上下文切换会消耗一些时间,因此,对于需要创建大量运行时间极短的线程的应用(如网络服务器等)而言,使用协程能够减少此类开销,提升性能。
(2)在应用程序内使用协程后,应用程序能够基于其逻辑进行更加合理的调度,在很多情况下更能够提升性能。如一个生产者、消费者的例子中,可以使用协程,确保消费者在生产者之后进行执行