操作系统核心知识点整理--进程篇
- 什么是系统调用
- 进程篇
- 什么是进程
- 什么是线程
- 从一次fork调用看linux进程和线程的本质区别
- 小结
- 用户级线程和内核级线程的区别
- 进程的状态
- 进程的切换
- 进程调度
- 并发问题
- 死锁
- 参考
本文主要面向应用层软件开发人员整理一篇必须了解的操作系统核心知识图谱,每小节参考文章链接都已经在小节末尾给出,如果大家有疑问,可以评论区留言,或者直接去阅读原文。
什么是系统调用
操作系统对内存的使用是按段的,例如: 我们编写的一个程序被操作系统加载到内存是按照数据段,代码段等形式分段载入。而操作系统自身的代码也是按段载入的,为了确保安全性,我们用户编写的程序是不能直接访问操作系统的相关段的,因此需要给不同段赋予不同的特权级。
特权级高段可以访问特权级低的段,反之则不能。因此,操作系统的相关段具有更高的特权级,用户程序的相关段具有更低的优先级,操作系统相关段也被称为内核态,用户程序相关段被称为用户态。用户态无法直接访问内核态,内核态可以访问用户态。
linux 0.11中每个进程都关联一个LDT表,该表中记录了当前进程执行的程序对应的各个段信息,如: 段的起始地址,段限长,段的一个特权级等。
linux 0.11中当前访问者的特权级由CS寄存器低两位表示,cs寄存器保存当前执行进程代码段基址,而ip保存当前执行指令在代码段中的偏移地址
用户态无法直接访问内核态,但又需要去操作设备,进行文件管理等需要和硬件打交道的活。因此,操作系统必须开放出来一批调用接口,让用户程序可以调用接口,完成对底层硬件资源的使用,这些接口被称为系统调用。
系统调用通过中断实现,会提升当前访问者的特权级,在中断返回时,再将特权级恢复。
操作系统接口和调用–02
进程篇
什么是进程
操作系统为正在运行的程序提供的抽象,就是所谓的进程。当然,进程本身还记录了当前程序运行的一些状态,如: 进程可以访问的内存地址空间范围, 使用到的相关寄存器,如: 程序计数器,栈指针和帧指针等。
一个CPU核同时可以执行一个进程,一个CPU核通常
搭配一套寄存器使用,也就是说当发生进程切换时,需要将相关寄存器状态保存到要切换进程的PCB中,然后再将新进程的PCB记录的寄存器状态拍到寄存器上,从而完成进程的切换。
Linux 中使用task_struct 结构体作为PCB的实现:
Linux中所有进程都是通过一颗进程树来管理的,操作系统启动时会创建一个init进程,接下来所有进程都由该进程之间或者间接创建:
Linux中通过一个mm_struct结构体记录当前进程虚拟地址空间的分配和使用情况,包括程序各种分段信息:
在内核内存区域,可以通过直接计算得出物理内存地址,并不需要复杂的页表计算。而且最重要的是所有内核进程、以及用户进程的内核态,这部分内存都是共享的。
files_struct 结构体记录当前进程打开的文件有哪些:
namespaces结构体是用来隔离内核资源的方式,通过namespaces可以让一些进程只能看到与自己相关的一部分资源:
docker底层容器间资源隔离的核心实现思路就是使用namespace完成多个进程间对内核资源的隔离,在创建进程或线程的时候,可以让内核帮我们创建独立的命名空间。在默认情况下,创建进程没有指定命名空间相关的标记,因此也不会创建。新旧进程仍然复用同一套命名空间对象。
Linux中使用fork创建进程的时候,地址空间mm_struct,打开文件列表files_struct都是需要独立拥有的,这样才能完成进程间的资源隔离,但是对于命名空间而言,如果不特殊指定,子进程会复用父进程的命名空间:
Linux进程是如何创建出来的?
CPU篇—理清“核“概念-01
什么是线程
进程记录了当前程序的运行状态,并管理着当前程序运行所需要的各种资源,如果频繁对进程进行切换,显然代价还是比较大的。
其实我们可以将进程看做是资源+指令序列,如果我们把资源和指令序列分开的话,我们可以让一个进程内存在多套指令序列,但是资源还是只有一份,相当于多个指令序列执行过程中共享当前进程的内存资源。
那么这些正在运行的指令序列,其实就是我们说的线程,一个进程内可以存在多个线程,多个线程在执行过程中不断切换执行,并且切换只需要保存和PC相关寄存器状态,不需要切换页表等重量级资源,因此效率更高。
线程本质是指令之间的切换,一个进程中有代码片段,而多个指令序列会存在在这个代码片段中,每个指令序列一旦运行起来了,就是一个线程,当存在多个线程时,对于线程的切换,也只需要切换指令序列即可,不需要涉及到映射表和内存段的改变。
在Linux中,线程的表示依然使用task_struct表示:
无论是进程还是线程,都需要有一个唯一标识符号,这个符号就是pid,也就是我们常说的进程ID,线程ID。
如果一个进程下创建了多个线程,那么每个线程的pid都是不同的,但是我们一般又需要记录线程属于哪个进程,这时候,tgid就派上用场了,通过tgid字段来表示自己所归属的进程ID。
从一次fork调用看linux进程和线程的本质区别
Nginx服务采用多进程方式进行工作,它启动的时候会创建若干个worker进程,来响应和处理用户请求。
Redis 6.0以上版本,也开始支持使用多线程来提供核心服务,redis服务启动后,会调用initThreadIo来创建多个IO线程。
左边是nginx创建进程的核心调用链,右边是redis通过glibc函数库提供的pthread_create函数创建线程的核心调用过程。
选择创建进程还是线程,核心在于do_fork函数,我们来看看do_fork函数具体干了啥:
//file:kernel/fork.c
long do_fork(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr)
{
//复制一个 task_struct 出来 ————> 复制父进程的task_struct,具体复制哪些部分,由clone_flags决定
struct task_struct *p;
p = copy_process(clone_flags, stack_start, stack_size,
child_tidptr, NULL, trace);
//子任务加入到就绪队列中去,等待调度器调度
wake_up_new_task(p);
...
}
do_fork做的事情: copy一份父进程的task_struct结构体数据给子类,具体copy过程由clone_flags决定
。 子进程task_struct结构体准备好了以后,将子任务放入就绪队列,等待被调度。
//file:kernel/fork.c
static struct task_struct *copy_process(...)
{
//3.1 复制进程 task_struct 结构体
struct task_struct *p;
p = dup_task_struct(current);
...
//3.2 拷贝 files_struct
retval = copy_files(clone_flags, p);
//3.3 拷贝 fs_struct
retval = copy_fs(clone_flags, p);
//3.4 拷贝 mm_struct
retval = copy_mm(clone_flags, p);
//3.5 拷贝进程的命名空间 nsproxy
retval = copy_namespaces(clone_flags, p);
//3.6 申请 pid && 设置进程号
pid = alloc_pid(p->nsproxy->pid_ns);
p->pid = pid_nr(pid);
p->tgid = p->pid;
if (clone_flags & CLONE_THREAD)
p->tgid = current->tgid;
......
}
copy_process先是完全复制了一份父进程的task_struct(浅拷贝
),复制完进行子进程基本信息覆盖后,父子进程状态如下:
下面开始通过clone_flags标志判断哪一部分子进程需要和父进程共享,即子进程无需对父进程指定资源进行深拷贝,这边我简单列举copy_xxx函数过程中的几个例子:
- copy_files: 子进程是否需要对父进程的打开文件列表进行深拷贝
static int copy_files(unsigned long clone_flags, struct task_struct *tsk)
{
struct files_struct *oldf, *newf;
oldf = current->files;
//如果是clone_flag标记中CLONE_FILES位被设置为了true,那么引用计数加一,然后返回--子进程共享父进程文件打开列表
if (clone_flags & CLONE_FILES) {
atomic_inc(&oldf->count);
goto out;
}
//否则子进程单独申请一块内存,用于files_struct对象存储---对父进程的file_strcut进行深拷贝
newf = dup_fd(oldf, &error);
tsk->files = newf;
...
}
如果此时创建的是进程,例如: nginx,那么do_fork函数中传入的clone_flags标志位的CLONE_FILES就为0,即子进程对父进程的打开文件列表采用的是深拷贝方式:
对于redis来说创建的线程来说,会将clone_flags中的CLONE_FILES标记位设置为1,即子进程共享父进程的打开文件列表资源:
- copy_mm: 子进程是否需要对父进程的地址空间进行深拷贝
static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
struct mm_struct *mm, *oldmm;
oldmm = current->mm;
//判断clone_flags中的CLONE_VM标记位是否被设置为1,如果为1,则子进程共享父进程地址空间
if (clone_flags & CLONE_VM) {
atomic_inc(&oldmm->mm_users);
mm = oldmm;
goto good_mm;
}
//否则子进程对父进程地址空间进行深拷贝
mm = dup_mm(tsk);
good_mm:
return 0;
}
nginx中创建进程时,不会将CLONE_VM标记设置为1,因此进行的是深拷贝:
地址空间是进程线程最核心的东西,每个进程都有独立的地址空间
redis中创建线程时,会将CLONE_VM标记设置为1,因此子进程共享父进程的地址空间:
在创建进程或线程的时候,还可以让内核帮我们创建独立的命名空间。在默认情况下,创建进程没有指定命名空间相关的标记,因此也不会创建。新旧进程仍然复用同一套命名空间对象。
小结
在Linux中,进程和线程都是用task_struct来表示的,只不过线程和进程的区别在于: 是否和创建它的父进程共享打开文件列表,目录信息,虚拟地址空间等数据结构。
在上述共享信息中。内存虚拟地址空间是最重要的,因此,区分一个任务是叫线程还是进程,一般习惯上就看它是否有独立的地址空间,如果有,就叫做进程,没有,就叫做线程。
对于内核任务来说,无论有多少个任务,其使用的地址空间都是同一个,所以一般叫做内核线程,而不是内核进程。
对于内核线程来讲,不需要虚拟地址空间,所以 mm 成员的值为 null。
- 对于Linux中的线程而言,其地址空间mm_struct,目录信息fs_struct,打开文件列表files_struct都是和创建它的任务共享的:
- 对于进程而言,地址空间mm_struct,挂载点fs_struct,打开文件列表files_struct都是要独立拥有的,都需要申请内存并初始化它们:
总之,在Linux内核中并没有对线程做特殊处理,还是由task_struct进行管理,从内核角度看,用户天的线程本质还是一个进程,只不过和普通进程比,稍微轻量了那么一点。
Linux进程是如何创建出来的?
聊聊Linux中线程和进程的联系与区别!
用户级线程和内核级线程的区别
操作系统内存整体可以划分为用户区和内核区两部分,如果用户区的函数需要调用内核区相关函数,需要通过系统调用切换到内核区执行。
由于用户区和内核区是分开的,因此对应的函数栈也是不同的,因此如果我们需要从用户态切换到内核态执行,需要准备两套栈,一套用户栈,一套内核栈:
用户态切换到内核态执行的过程大致如下:
- 当用户态程序进行系统调用时,发生中断,此时需要定位到当前用户栈关联的内核栈地址,然后将用户栈执行状态,压入内核栈保存。
- 当内核函数执行完毕后,中断返回时,弹出内核栈保存的用户栈状态,恢复用户态先前执行状态。
关键点: 操作系统只能看见内核栈,我们可以将一套内核栈看做是一个内核线程
,而一套用户栈,可以看做是一个用户线程
。
用户线程
和内核线程
的关系可以是1:1或者n:1或者n:n。
- 如果是n:1的情况,我们称此时为用户级线程实现
- 如果是1:1的情况,我们称此时为核心级线程实现
- 如果是n:n的情况,我们称此时为混合实现
用户级线程:
- 用户级线程的切换只在用户态完成,并且线程调度算法由用户自己编写,因此即使一个进程中存在多个用户级线程,其实也可以看做是只存在一个核心级线程。
- 可以在不支持线程的操作系统中实现。
- 当用户级线程发生IO或页面故障引起的阻塞时,由于操作系统无法感知用户级线程存在,所以会直接进行进程切换,而不是切换到进程中另一个线程继续执行。
- 一个单独的进程内部没有时钟中断,所以无法采用轮转调度的方式来调度用户级线程,所以对于用户级线程的调度只能采用分时复用机制。
核心级线程:
- 核心级线程的切换在内核态完成,并且线程调度算法由操作系统完成,因此一个进程如果存在多个内核级线程,那么每个内核级线程都可以利用一个CPU,这样就可以完成多线程执行。但是由于内核级线程线程切换需要进入内核态完成,因此切换代价大。
- 进程中某一线程的阻塞不会影响当前进程中其他线程的调度执行。
用户级线程和内核级线程—04
linux:线程的3种实现方式(内核级,用户级和混合型)
进程的状态
- 创建状态(new) :进程正在被创建,尚未到就绪状态。
- 就绪状态(ready) :进程已处于准备运行状态,即进程获得了除了处理器之外的一切所需资源,一旦得到处理器资源(处理器分配的时间片)即可运行。
- 运行状态(running) :进程正在处理器上上运行(单核 CPU 下任意时刻只有一个进程处于运行状态)。
- 阻塞状态(waiting) :又称为等待状态,进程正在等待某一事件而暂停运行如等待某资源为可用或等待 IO 操作完成。即使处理器空闲,该进程也不能运行。
- 结束状态(terminated) :进程正在从系统中消失。可能是进程正常结束或其他原因中断退出运行。
进程的切换
我们知道进程是对运行程序的抽象表示,主要负责记录当前程序运行状态,管理当前程序运行需要的资源,进程在Linux中使用PCB表示,线程在Linux中使用TCB表示。
那我们考虑一下进程的切换需要做哪些事情:
- 切换PCB
- 保存当前CPU各种寄存器的状态,寄存器状态就当前当前程序运行的状态,核心的寄存器有: 程序计数器,栈顶指针等
Linux 0.11是只支持单核,进程实现的操作系统,因此Linux 0.11中的进程切换采用的是TSS方式完成的:
Linux 0.11只支持进程实现,所以Linux 0.11将进程直接叫做线程也是可以的。
使用TSS完成内核线程的切换的过程大致为: 通过一条长跳转指令,将当前CPU状态,拍到老进程的TSS上,而将新进程TSS拍到当前CPU上,TSS中保存了当前CPU各种寄存器状态值,这个切换过程代价还是比较大的。
Linux 0.11不支持内核栈的实现内核线程切换的方式,所以采用的是TSS,如果采用内核栈方式实现内核线程的切换,那么只需要切换TCB,因为在进入中断的时候,已经将各种寄存器状态压入内核栈保存了,相当于与内核栈保存CPU当前状态,从而替换了TSS。
esp栈顶指针很关键,由于一个CPU中只存在一个esp栈顶寄存器,那么内核线程切换的本质,其实是将esp指向另一个内核栈栈顶罢了,中断返回时,弹出先前内核栈中压入的用户栈状态,就可以恢复之前程序运行的样子,完美!
内核级线程源码实现—05
进程调度
进程调度需要关注的点:
- 响应时间和吞吐量
- 前后台任务异同:前台任务希望更短的响应时间,后台任务希望更短的周转时间
- IO约束型任务和CPU约束型任务: 我们应该让IO约束型任务优先执行,充分利用IO约束型任务IO阻塞时间,让CPU约束型任务继续执行。
常见的进程调度算法:
- 先到先服务(FCFS): 不考虑短作业的感受,平均周转时间长
- 短作业优先(SJF): 平均周转时间短,但是不考虑长作业感受,容易产生饥饿
- 最短完成时间优先(STCF): 在SJF基础上,增加了抢占,当新任务进行了系统,会查看新任务剩余工作时间是否为最短的,如果是就抢占CPU让自己先执行。缺点还是容易产生饥饿,不考虑长作业感受。
- 时间片轮转调度(RR):交替运行所有工作,从而优化响应时间。
时间片过小,会导致频繁的上下文切换,上下文切换的成本不仅包括相关寄存器状态的保存和恢复,还包括程序运行时,他们在CPU高速缓存,TLB,分支预测期和其他硬件上建立的大量状态,一旦进行了线程切换,这些状态都会被刷新,这会导致显著的性能成本。
上面列举的进程调度算法并没有考虑到IO,并且由于作业的执行时长通常是无法确定的,所以类似于SJF这种算法就难以实现,现代操作通常既需要考虑响应时间,如: 前台交互式任务。又需要考虑周转时间,如: 后台任务。 如何设计一种调度算法能够同时兼顾这两者,是一个技术活!
- 多级反馈队列(MLQF): 设置多个独立的队列,每个队列有不同的优先级,一个工作只能存在于一个队列中。调度程序总是会选择执行较高优先级队列中的任务。对于同一个队列中的多个任务,调度程序采取轮转调度。
MLQF主要由如下几个规则构成:
- 如果任务A的优先级大于任务B,运行A
- 如果A的优先级等于任务B,轮转运行A和B
- 任务进入系统时,放在最高优先级队列中
- 任务用完整个时间片后,降低其优先级,移动到下一层优先级更低的队列
- 任务在时间片内主动放弃CPU,则保持优先级不变 — > 照顾了交互性任务的响应时间
- 经过一段时间S,就将系统中所有工作重新加入最高优先级队列 —> 防止饥饿
- 为了防止恶意任务总是在时间片以内释放CPU,从而一直持有CPU,我们又提出了一个规则: 一旦工作用完了其在某一层的时间配额,无论中间主动放弃了多少次CPU,还是会降低其优先级(移动到低一级队列)
注意: 高优先级队列通常分配较短的时间片,因此这一层交互工作可以更快地切换,相反,低优先级队列中更多的是CPU密集型工作,配置更长的时间片会取得更好的效果。
这里额外再多说一点关于多处理器调度的注意点:
多处理器调度和单处理器调度区别的核心在于硬件缓存的使用,以及多处理器之间共享数据的方式。
在单cpu系统中,存在多级硬件缓存,缓存是很小但很快的存储设备,通常拥有内存中最热的数据的备份,相比之下,内存很大且拥有所有的数据,但访问速度较慢,通过将频繁访问的数据放在缓存中,系统似乎拥有又大又快的内存。
缓存的高效是基于程序运行的时间局部性和空间局部性。时间局部性: 一个数据被访问后,它很可能在不久又被访问到。空间局部性: 当程序访问地址为X的数时,很可能会紧接着访问x周围的数据。
对于多处理器而言,通常会存在缓存一致性问题,即各个CPU修改了高速缓存数据后,不直接同步回主存,各个CPU查询数据时,只查询自己的高速缓存,而不检查数据是否过期。
解决缓存一致性通常采用总线嗅探技术,每个缓存都通过监听所有缓存和内存的总线,来发现内存的访问,如果CPU发现对它放在缓存中的数据的更新,会作废本地副本,从主存同步最新结果。
设计多处理器调度时,我们需要考虑: 缓存亲和度。因为一个进程在某个CPU上运行时,会在该CPU的缓存中维护许多状态,下次该进程在相同的CPU上运行时,由于缓存中的数据而执行的很快。相反,在不同的CPU上执行,会由于重新加载数据到缓存而变慢。因此多处理器调度需要考虑缓存亲和性,尽可能让进程保持在同一个CPU上运行。
并发问题
对临界区资源进行保护通常有几种解决方法:
- 信号量: 可以简单理解为剩余资源数量,>0时表示还剩多少资源,=0时表示无剩余资源,<0时表示有几个线程正在等待当前资源
- 自旋锁: 当一个线程无法获取临界区资源时,不是挂起等待,而是保持运行,反复轮询资源是否空闲,以此减少挂起等待导致的线程上下文开销。
- 关中断(适合单CPU场景)
这块介绍的比较简单
常见的并发问题通常分为两类: 非死锁问题,死锁问题。
非死锁问题通常由两种原因导致:
- 违反原子性缺陷: 代码本意是原子的,但是执行过程中并没有强制实现原子性
- 违反顺序缺陷: 内存的访问顺序与预期不符
死锁
死锁产生的四个条件:
- 互斥: 资源必须处于非共享模式
- 持有并等待: 线程持有了某个资源,同时又在等待其他资源
- 非抢占: 线程获得了资源,不能被抢占,例如: 锁
- 循环等待: 线程之间的资源请求存在环路
死锁的解决办法:
- 预防: 可以考虑让进程在执行前就必须获取到它所需要的所有资源,否则就不能执行;
- 避免: 采用银行家算法,进程申请资源时,通过银行家算法先试探分配给进程资源,然后通过安全性算法判断分配后系统是否处于安全状态,如果不安全则试探分配作废,让进程继续等待,如果能够进入安全状态,则分配资源给进程使用。
- 检测: 通过进程资源分配图,检测是否产生了死锁
- 解除: 当检测到死锁后,可以采用重启大法,或者回滚死锁涉及到的进程,或者逐个回滚,直到死锁解除,或者从涉及死锁的一个或几个进程中抢占资源,把夺得的资源再分配给涉及死锁的进程直到死锁解除。
参考
操作系统导论
哈工大李老师操作系统课程
张彦飞大佬公众号