Hammy学习操作系统之进程

174 阅读14分钟

进程树

Linux中的所有进程都是从父进程fork过来的,那总归有一个祖宗进程,就是系统启动的init进程。

系统启动之后,init进程会启动很多的daemon进程,为系统运行提供服务,然后用户登录,登录后运行shell,用户启动的进程都是通过shell运行的,从而形成进程树。

通过ps -ef命令查看当前系统启动的进程,会发现三类进程。


[root@deployer ~]# ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0  2018 ?        00:00:29 /usr/lib/systemd/systemd --system --deserialize 21
root         2     0  0  2018 ?        00:00:00 [kthreadd]
root         3     2  0  2018 ?        00:00:00 [ksoftirqd/0]
root         5     2  0  2018 ?        00:00:00 [kworker/0:0H]
root         9     2  0  2018 ?        00:00:40 [rcu_sched]
......
root       337     2  0  2018 ?        00:00:01 [kworker/3:1H]
root       380     1  0  2018 ?        00:00:00 /usr/lib/systemd/systemd-udevd
root       415     1  0  2018 ?        00:00:01 /sbin/auditd
root       498     1  0  2018 ?        00:00:03 /usr/lib/systemd/systemd-logind
......
root       852     1  0  2018 ?        00:06:25 /usr/sbin/rsyslogd -n
root      2580     1  0  2018 ?        00:00:00 /usr/sbin/sshd -D
root     29058     2  0 Jan03 ?        00:00:01 [kworker/1:2]
root     29672     2  0 Jan04 ?        00:00:09 [kworker/2:1]
root     30467     1  0 Jan06 ?        00:00:00 /usr/sbin/crond -n
root     31574     2  0 Jan08 ?        00:00:01 [kworker/u128:2]
......
root     32792  2580  0 Jan10 ?        00:00:00 sshd: root@pts/0
root     32794 32792  0 Jan10 pts/0    00:00:00 -bash
root     32901 32794  0 00:01 pts/0    00:00:00 ps -ef

PID1的进程就是我们的init进程sysytemd,PID2的进程时内核进程kthread。接下来进程号依次增大,但是你会看所有带中括号的内核态的进程,祖先都是 2 号进程。而用户态的进程,祖先都是 1 号进程。

为什么要有线程

其实,对于任何一个进程来讲,即便我们没有主动去创建线程,进程也是默认有一个主线程的。线程是负责执行二进制指令的,进程要比线程管的宽多了,除了执行指令以外,内存、问价那系统等需要它来管。

所以,进程相当于一个项目,而线程就是为了完成项目需求,而建立的一个个开发任务。

为什么不使用进程实现并行开发?

进程实现并行执行的问题,第一,创建进程占用的资源太多;第二,进程之间的通信需要在不同的内存空间传来传去,不能共享。

线程的数据

线程可以将项目并行起来,加速速度,但是也带来了负面影响,过程并行起来,那数据呢?

我们可以把线程访问的数据分为三类 第一类是线程栈上的本地数据,比如函数执行过程中的局部变量。函数的调用会使用栈的模型,这在线程里面是一样的,只不过每个线程都有自己的栈空间。

栈的大小可以通过命令 ulimit -a 查看,默认情况下线程栈大小为 8192(8MB)。我们可以使用命令 ulimit -s 修改。

主线程在内存中有一个栈空间,其他线程栈也拥有独立的栈空间。为了避免线程之间的栈空间踩踏,线程栈之间还会有小块区域,用来隔离保护各自的栈空间。一旦另一个线程踏入到这个隔离区,就会引发段错误。

第二类数据就是在整个进程里共享的全局数据。例如,全局变量,虽然在不同进程中是隔离的,但是在一个进程中是共享的。两个线程一起修改,那肯定会有问题,有可能把数据改的面目全非。这就需要有一种机制来保护他们,比如你先用我再用。我们后续进行补充。

那线程能不能像进程一样,也有自己的私有数据呢?如果想声明一个线程级别,而非进程级别的全局变量,有没有什么办法呢?虽然咱们都是一个大组,分成小组,也应该有点隐私。

这就是第三类数据,线程私有数据,可以通过以下函数创建:

int pthread_key_create(pthread_key_t *key, void (*destructor)(void*))

key 一旦被创建,所有线程都可以访问它,但各线程可根据自己的需要往 key 中填入不同的值,这就相当于提供了一个同名而不同值的全局变量。

数据的保护

接下来,我们看共享数据的保护问题。

我们先来看一种方式,Mutex,全称 Mutual Exclusion,中文叫互斥。顾名思义,有你没我,有我没你。它的模式就是在共享数据访问的时候,去申请加把锁,谁先拿到锁,谁就拿到了访问权限,其他人就只好在门外等着,等这个人访问结束,把锁打开,其他人再去争夺,还是遵循谁先拿到谁访问。

进程的数据结构

有的进程只有一个线程,有的进程有多个线程,它们都需要由内核分配 CPU 来干活。可是 CPU 总共就这么几个,应该怎么管理,怎么调度呢?

同样在 Linux 里面,无论是进程,还是线程,到了内核里面,我们统一都叫任务(Task),由一个统一的结构 task_struct 进行管理。

首先,所有执行的项目应该有个项目列表吧,所以 Linux 内核也应该先弄一个链表,将所有的 task_struct 串起来。

任务ID

每一个任务都应该有一个 ID,作为这个任务的唯一标识。到时候排期啊、下发任务啊等等,都按 ID 来,就不会产生歧义。

信号处理

这里定义了哪些信号被阻塞暂不处理(blocked),哪些信号尚等待处理(pending),哪些信号正在通过信号处理函数进行处理(sighand)。处理的结果可以是忽略,可以是结束进程等等。

任务状态

TASK_RUNNING并不是说进程正在和运行,而是表示进程在时刻准备运行的状态。当处于这个状态的进程获得时间片的时候,就是在运行中;如果没有获得时间片,就说明其他进程抢占了,在等待再次分配时间片。

在运行中的进程,一旦要进行一些 I/O 操作,需要等待 I/O 完毕,这个时候会释放 CPU,进入睡眠状态。

在 Linux 中,有两种睡眠状态。

一种是TASK_INTERRUPTIBLE,可中断的睡眠状态。可中断的睡眠状态。这是一种浅睡眠的状态,也就是说,虽然在睡眠,等待 I/O 完成,但是这个时候一个信号来的时候,进程还是要被唤醒。只不过唤醒后,不是继续刚才的操作,而是进行信号处理。当然程序员可以根据自己的意愿,来写信号处理函数,例如收到某些信号,就放弃等待这个 I/O 操作完成,直接退出;或者收到某些信息,继续等待。

另一种睡眠是 TASK_UNINTERRUPTIBLE,不可中断的睡眠状态。这是一种深度睡眠状态,不可被信号唤醒,只能死等 I/O 操作完成。一旦 I/O 操作因为特殊原因不能完成,这个时候,谁也叫不醒这个进程了。你可能会说,我 kill 它呢?别忘了,kill 本身也是一个信号,既然这个状态不可被信号唤醒,kill 信号也被忽略了。除非重启电脑,没有其他办法。

因此,这其实是一个比较危险的事情,除非程序员极其有把握,不然还是不要设置成 TASK_UNINTERRUPTIBLE。

于是,我们就有了一种新的进程睡眠状态,TASK_KILLABLE,可以终止的新睡眠状态。进程处于这种状态中,它的运行原理类似 TASK_UNINTERRUPTIBLE,只不过可以响应致命信号。

TASK_STOPPED 是在进程接收到 SIGSTOP、SIGTTIN、SIGTSTP 或者 SIGTTOU 信号之后进入该状态。

TASK_TRACED 表示进程被 debugger 等进程监视,进程执行被调试程序所停止。当一个进程被另外的进程所监视,每一个信号都会让进程进入该状态。

一旦一个进程要结束,先进入的是 EXIT_ZOMBIE 状态,但是这个时候它的父进程还没有使用 wait() 等系统调用来获知它的终止信息,此时进程就成了僵尸进程。

EXIT_DEAD 是进程的最终状态。

运行的统计信息

在进程的运行过程中,会有一些统计量,具体你可以看下面的列表。这里面有进程在用户态和内核态消耗的时间、上下文切换的次数等等。

u64        utime;//用户态消耗的CPU时间
u64        stime;//内核态消耗的CPU时间
unsigned long      nvcsw;//自愿(voluntary)上下文切换计数
unsigned long      nivcsw;//非自愿(involuntary)上下文切换计数
u64        start_time;//进程启动时间,不包含睡眠时间
u64        real_start_time;//进程启动时间,包含睡眠时间

进程的亲缘关系

从我们之前讲的创建进程的过程,可以看出,任何一个进程都有父进程。所以,整个进程其实就是一棵进程树。而拥有同一父进程的所有进程都具有兄弟关系。

进程的权限

了解了运行统计信息,接下来,我们需要关注一下项目组权限的控制。什么是项目组权限控制呢?这么说吧,我这个项目组能否访问某个文件,能否访问其他的项目组,以及我这个项目组能否被其他项目组访问等等,这都是项目组权限的控制范畴。

除此之外,进程还有文件系统和内存管理。

调度

对于os来说,它面对的cpu数量是有限的,进程数目一般远远超过cpu的数目,因而需要进程有效的调度,有效地分配cpu的时间,保证进程的最快响应,保证进程之间的公平。

调度策略与调度类

在 Linux 里面,进程大概可以分成两种。

一种称为实施进程,也就是需要尽快执行返回结果的那种。

另一种是普通进程,大部分的进程其实都这种。也就是优先级没有那么高的进程。

显然,我们对于这两种进程,我们的调度策略也不同。

在 task_struct 中,有一个成员变量,我们叫调度策略。

unsigned int policy;
#define SCHED_NORMAL    0
#define SCHED_FIFO    1
#define SCHED_RR    2
#define SCHED_BATCH    3
#define SCHED_IDLE    5
#define SCHED_DEADLINE    6

配合调度策略的,还有我们刚才说的优先级,也在 task_struct 中。

int prio, static_prio, normal_prio;
unsigned int rt_priority;

优先级其实就是一个数值,对于实时进程,优先级的范围是 0~99;对于普通进程,优先级的范围是 100~139。数值越小,优先级越高。从这里可以看出,所有的实时进程都比普通进程优先级要高。

实时调度策略

对于调度策略,其中 SCHED_FIFO、SCHED_RR、SCHED_DEADLINE 是实时进程的调度策略.

例如,SCHED_FIFO 也就是说,高优先级的进程可以抢占低优先级的进程,而相同优先级的进程,我们遵循先来先得。

另外一种策略是, SCHED_RR 轮流调度算法,采用时间片,相同优先级的任务当用完时间片会被放到队列尾部,以保证公平性,而高优先级的任务也是可以抢占低优先级的任务

还有一种新的策略是 SCHED_DEADLINE,是按照任务的 deadline 进行调度的。当产生一个调度点的时候,DL 调度器总是选择其 deadline 距离当前时间点最近的那个任务,并调度它执行。

普通调度策略

对于普通进程的调度策略有,SCHED_NORMAL、SCHED_BATCH、SCHED_IDLE。

SCHED_NORMAL 是普通的进程

SCHED_BATCH 是后台进程,几乎不需要和前端进行交互。这类项目可以默默执行,不要影响需要交互的进程,可以降低它的优先级。

SCHED_IDLE 是特别空闲的时候才跑的进程,相当于咱们学习训练类的项目。

普通进程使用的调度策略是 fair_sched_class,顾名思义,对于普通进程来讲,公平是最重要的。

完全公平调度算法

在 Linux 里面,实现了一个基于 CFS 的调度算法。CFS 全称 Completely Fair Scheduling,叫完全公平调度。听起来很“公平”。那这个算法的原理是什么呢?我们来看看。

首先,你需要记录下进程的运行时间。CPU 会提供一个时钟,过一段时间就触发一个时钟中断。就像咱们的表滴答一下,这个我们叫 Tick。CFS 会为每一个进程安排一个虚拟运行时间 vruntime。如果一个进程在运行,随着时间的增长,也就是一个个 tick 的到来,进程的 vruntime 将不断增大。没有得到执行的进程 vruntime 不变。

显然,那些 vruntime 少的,原来受到了不公平的对待,需要给它补上,所以会优先运行这样的进程。这有点像让你把一筐球平均分到 N 个口袋里面,你看着哪个少,就多放一些;哪个多了,就先不放。这样经过多轮,虽然不能保证球完全一样多,但是也差不多公平。

你可能会说,不还有优先级呢?如何给优先级高的进程多分时间呢?这个简单,就相当于 N 个口袋,优先级高的袋子大,优先级低的袋子小。这样球就不能按照个数分配了,要按照比例来,大口袋的放了一半和小口袋放了一半,里面的球数目虽然差很多,也认为是公平的。

虚拟运行时间 vruntime += 实际运行时间 delta_exec * NICE_0_LOAD/ 权重

调度队列与调度实体

看来 CFS 需要一个数据结构来对 vruntime 进行排序,找出最小的那个。这个能够排序的数据结构不但需要查询的时候,能够快速找到最小的,更新的时候也需要能够快速地调整排序,要知道 vruntime 可是经常在变的,变了再插入这个数据结构,就需要重新排序。

能够平衡查询和更新速度的是树,在这里使用的是红黑树。

调度---主动调度是如何发生的

所谓进程调度,其实就是一个人在做A项目,在某个时刻,换成B项目去了。发生这种情况,主要有两种方式。

  • A项目做着做着,发生里面有一条指令sleep,或者在等待IO时间。
  • A 项目做着做着,旷日持久,实在受不了了。项目经理介入了,说这个项目 A 先停停,B 项目也要做一下,要不然 B 项目该投诉了。

主动调度

抢占式调度时如何发生的

最常见的现象就是一个进程执行时间太长了,是时候切换到另一个进程了。那怎么衡量一个进程的运行时间呢?在计算机里面有一个时钟,会过一段时间触发一次时钟中断,通知操作系统,时间又过去一个时钟周期,这是个很好的方式,可以查看是否是需要抢占的时间点。

另外一个可能抢占的场景是当一个进程被唤醒的时候。

默认实时调度策略是RR