本文主要面向应用层软件开发人员整理一篇必须了解的操作系统核心知识图谱,每小节参考文章链接都已经在小节末尾给出,如果大家有疑问,可以评论区留言,或者直接去阅读原文。
操作系统对内存的使用是按段的,例如: 我们编写的一个程序被操作系统加载到内存是按照数据段,代码段等形式分段载入。而操作系统自身的代码也是按段载入的,为了确保安全性,我们用户编写的程序是不能直接访问操作系统的相关段的,因此需要给不同段赋予不同的特权级。
特权级高段可以访问特权级低的段,反之则不能。因此,操作系统的相关段具有更高的特权级,用户程序的相关段具有更低的优先级,操作系统相关段也被称为内核态,用户程序相关段被称为用户态。用户态无法直接访问内核态,内核态可以访问用户态。
linux 0.11中每个进程都关联一个LDT表,该表中记录了当前进程执行的程序对应的各个段信息,如: 段的起始地址,段限长,段的一个特权级等。 linux 0.11中当前访问者的特权级由CS寄存器低两位表示,cs寄存器保存当前执行进程代码段基址,而ip保存当前执行指令在代码段中的偏移地址
用户态无法直接访问内核态,但又需要去操作设备,进行文件管理等需要和硬件打交道的活。因此,操作系统必须开放出来一批调用接口,让用户程序可以调用接口,完成对底层硬件资源的使用,这些接口被称为系统调用。
系统调用通过中断实现,会提升当前访问者的特权级,在中断返回时,再将特权级恢复。
操作系统为正在运行的程序提供的抽象,就是所谓的进程。当然,进程本身还记录了当前程序运行的一些状态,如: 进程可以访问的内存地址空间范围, 使用到的相关寄存器,如: 程序计数器,栈指针和帧指针等。
一个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都是需要独立拥有的,这样才能完成进程间的资源隔离,但是对于命名空间而言,如果不特殊指定,子进程会复用父进程的命名空间:
进程记录了当前程序的运行状态,并管理着当前程序运行所需要的各种资源,如果频繁对进程进行切换,显然代价还是比较大的。
其实我们可以将进程看做是资源+指令序列,如果我们把资源和指令序列分开的话,我们可以让一个进程内存在多套指令序列,但是资源还是只有一份,相当于多个指令序列执行过程中共享当前进程的内存资源。
那么这些正在运行的指令序列,其实就是我们说的线程,一个进程内可以存在多个线程,多个线程在执行过程中不断切换执行,并且切换只需要保存和PC相关寄存器状态,不需要切换页表等重量级资源,因此效率更高。
线程本质是指令之间的切换,一个进程中有代码片段,而多个指令序列会存在在这个代码片段中,每个指令序列一旦运行起来了,就是一个线程,当存在多个线程时,对于线程的切换,也只需要切换指令序列即可,不需要涉及到映射表和内存段的改变。
在Linux中,线程的表示依然使用task_struct表示:
无论是进程还是线程,都需要有一个唯一标识符号,这个符号就是pid,也就是我们常说的进程ID,线程ID。
如果一个进程下创建了多个线程,那么每个线程的pid都是不同的,但是我们一般又需要记录线程属于哪个进程,这时候,tgid就派上用场了,通过tgid字段来表示自己所归属的进程ID。
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函数过程中的几个例子:
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,即子进程共享父进程的打开文件列表资源:
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内核中并没有对线程做特殊处理,还是由task_struct进行管理,从内核角度看,用户天的线程本质还是一个进程,只不过和普通进程比,稍微轻量了那么一点。
操作系统内存整体可以划分为用户区和内核区两部分,如果用户区的函数需要调用内核区相关函数,需要通过系统调用切换到内核区执行。
由于用户区和内核区是分开的,因此对应的函数栈也是不同的,因此如果我们需要从用户态切换到内核态执行,需要准备两套栈,一套用户栈,一套内核栈:
用户态切换到内核态执行的过程大致如下:
关键点: 操作系统只能看见内核栈,我们可以将一套内核栈看做是一个**内核线程
**,而一套用户栈,可以看做是一个**用户线程
**。
用户线程
**和**内核线程
**的关系可以是1:1或者n:1或者n:n。**
用户级线程:
核心级线程:
我们知道进程是对运行程序的抽象表示,主要负责记录当前程序运行状态,管理当前程序运行需要的资源,进程在Linux中使用PCB表示,线程在Linux中使用TCB表示。
那我们考虑一下进程的切换需要做哪些事情:
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指向另一个内核栈栈顶罢了,中断返回时,弹出先前内核栈中压入的用户栈状态,就可以恢复之前程序运行的样子,完美!
进程调度需要关注的点:
常见的进程调度算法:
时间片过小,会导致频繁的上下文切换,上下文切换的成本不仅包括相关寄存器状态的保存和恢复,还包括程序运行时,他们在CPU高速缓存,TLB,分支预测期和其他硬件上建立的大量状态,一旦进行了线程切换,这些状态都会被刷新,这会导致显著的性能成本。
上面列举的进程调度算法并没有考虑到IO,并且由于作业的执行时长通常是无法确定的,所以类似于SJF这种算法就难以实现,现代操作通常既需要考虑响应时间,如: 前台交互式任务。又需要考虑周转时间,如: 后台任务。 如何设计一种调度算法能够同时兼顾这两者,是一个技术活!
MLQF主要由如下几个规则构成:
注意: 高优先级队列通常分配较短的时间片,因此这一层交互工作可以更快地切换,相反,低优先级队列中更多的是CPU密集型工作,配置更长的时间片会取得更好的效果。
这里额外再多说一点关于多处理器调度的注意点:
多处理器调度和单处理器调度区别的核心在于硬件缓存的使用,以及多处理器之间共享数据的方式。
在单cpu系统中,存在多级硬件缓存,缓存是很小但很快的存储设备,通常拥有内存中最热的数据的备份,相比之下,内存很大且拥有所有的数据,但访问速度较慢,通过将频繁访问的数据放在缓存中,系统似乎拥有又大又快的内存。
缓存的高效是基于程序运行的时间局部性和空间局部性。时间局部性: 一个数据被访问后,它很可能在不久又被访问到。空间局部性: 当程序访问地址为X的数时,很可能会紧接着访问x周围的数据。
对于多处理器而言,通常会存在缓存一致性问题,即各个CPU修改了高速缓存数据后,不直接同步回主存,各个CPU查询数据时,只查询自己的高速缓存,而不检查数据是否过期。
解决缓存一致性通常采用总线嗅探技术,每个缓存都通过监听所有缓存和内存的总线,来发现内存的访问,如果CPU发现对它放在缓存中的数据的更新,会作废本地副本,从主存同步最新结果。
设计多处理器调度时,我们需要考虑: 缓存亲和度。因为一个进程在某个CPU上运行时,会在该CPU的缓存中维护许多状态,下次该进程在相同的CPU上运行时,由于缓存中的数据而执行的很快。相反,在不同的CPU上执行,会由于重新加载数据到缓存而变慢。因此多处理器调度需要考虑缓存亲和性,尽可能让进程保持在同一个CPU上运行。
对临界区资源进行保护通常有几种解决方法:
这块介绍的比较简单
常见的并发问题通常分为两类: 非死锁问题,死锁问题。
非死锁问题通常由两种原因导致:
死锁产生的四个条件:
死锁的解决办法: