进程树
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