一、进程
在 Windows 上写的程序,都会被保存成.h 或者.c 文件,容易让人感觉这是某种有特殊格式的文件,但其实这些文件只是普普通通的文本文件。
进程树
既然所有的进程都是从父进程 fork 过来的,那总归有一个祖宗进程,这就是咱们系统启动的 init 进程。
在解析 Linux 的启动过程的时候,1 号进程是 /sbin/init。如果在 centOS 7 里面,我们 ls一下,可以看到,这个进程是被软链接到 systemd 的。
sbin/init -> ../lib/systemd/systemd
系统启动之后,init 进程会启动很多的 daemon 进程,为系统运行提供服务,然后就是启动 getty,让用户登录,登录后运行 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
你会发现,PID 1 的进程就是我们的 init 进程 systemd,PID 2 的进程是内核线程kthreadd,这两个我们在内核启动的时候都见过。其中用户态的不带中括号,内核态的带中括号。
接下来进程号依次增大,但是你会看所有带中括号的内核态的进程,祖先都是 2 号进程。而用户态的进程,祖先都是 1 号进程。tty 那一列,是问号的,说明不是前台启动的,一般都是后台的服务。
pts 的父进程是 sshd,bash 的父进程是 pts,ps -ef 这个命令的父进程是 bash。这样整个链条都比较清晰了。
二、线程
为什么要有线程?
其实,对于任何一个进程来讲,即便我们没有主动去创建线程,进程也是默认有一个主线程的。线程是负责执行二进制指令的。
使用进程实现并行执行的问题也有两个。第一,创建进程占用资源太多;第二,进程之间的通信需要数据在不同的内存空间传来传去,无法共享。
在 Linux 中,有时候我们希望将前台的任务和后台的任务分开。因为有些任务是需要马上返回结果的,例如你输入了一个字符,不可能五分钟再显示出来;而有些任务是可以默默执行的,例如将本机的数据同步到服务器上去,这个就没刚才那么着急。因此这样两个任务就应该在不同的线程处理,以保证互不耽误。
线程的数据
我们把线程访问的数据细分成三类。
- 线程栈上的本地数据,比如函数执行过程中的局部变量。前面我们说过,函数的调用会使用栈的模型,这在线程里面是一样的。只不过每个线程都有自己的栈空间。栈的大小可以通过命令 ulimit -a 查看,默认情况下线程栈大小为 8192(8MB)。我们可以使用命令 ulimit -s 修改。
主线程在内存中有一个栈空间,其他线程栈也拥有独立的栈空间。为了避免线程之间的栈空间踩踏,线程栈之间还会有小块区域,用来隔离保护各自的栈空间。一旦另一个线程踏入到这个隔离区,就会引发段错误。 - 在整个进程里共享的全局数据。例如全局变量,虽然在不同进程中是隔离的,但是在一个进程中是共享的。
- 线程私有数据(Thread Specific Data)
三、进程数据结构
在 Linux 里面,无论是进程,还是线程,到了内核里面,我们统一都叫任务(Task),由一个统一的结构task_struct进行管理。
首先,所有执行的项目应该有个项目列表吧,所以 Linux 内核也应该先弄一个链表,将所有的 task_struct 串起来。接下来,我们来看每一个任务都应该包含哪些字段。
任务 ID
每一个任务都应该有一个 ID,作为这个任务的唯一标识。task_struct 里面涉及任务 ID 的,有下面几个:
pid_t pid;
pid_t tgid;
struct task_struct *group_leader;
你可能觉得奇怪,既然是 ID,有一个就足以做唯一标识了,这个怎么看起来这么麻烦?这是因为,上面的进程和线程到了内核这里,统一变成了任务,这就带来两个问题。
- 任务展示。
- 给任务下发指令。
所以在内核中,它们虽然都是任务,但是应该加以区分。其中,pid 是 process id,tgid是 thread group ID。
任何一个进程,如果只有主线程,那 pid 是自己,tgid 是自己,group_leader 指向的还是自己。但是,如果一个进程创建了其他线程,那就会有所变化了。线程有自己的 pid,tgid 就是进程的主线程的 pid,group_leader 指向的就是进程的主线程。
有了 tgid,我们就知道 tast_struct 代表的是一个进程还是代表一个线程了。
信号处理
task_struct 里面关于信号处理的字段如下:
/* Signal handlers: */
struct signal_struct *signal;
struct sighand_struct *sighand;
sigset_t blocked;
sigset_t real_blocked;
sigset_t saved_sigmask;
struct sigpending pending;
unsigned long sas_ss_sp;
size_t sas_ss_size;
unsigned int sas_ss_flags;
这里定义了哪些信号被阻塞暂不处理(blocked),哪些信号尚等待处理(pending),哪些信号正在通过信号处理函数进行处理(sighand)。处理的结果可以是忽略,可以是结束进程等等。
下发信号的时候,需要区分进程和线程。
任务状态
在 task_struct 里面,涉及任务状态的是下面这几个变量:
volatile long state; /* -1 unrunnable, 0 runnable, >0 stopped */
int exit_state;
unsigned int flags;
state(状态)可以取的值定义在 include/linux/sched.h 头文件中。
/* Used in tsk->state: */
#define TASK_RUNNING 0
#define TASK_INTERRUPTIBLE 1
#define TASK_UNINTERRUPTIBLE 2
#define __TASK_STOPPED 4
#define __TASK_TRACED 8
/* Used in tsk->exit_state: */
#define EXIT_DEAD 16
#define EXIT_ZOMBIE 32
#define EXIT_TRACE (EXIT_ZOMBIE | EXIT_DEAD)
/* Used in tsk->state again: */
#define TASK_DEAD 64
#define TASK_WAKEKILL 128
#define TASK_WAKING 256
#define TASK_PARKED 512
#define TASK_NOLOAD 1024
#define TASK_NEW 2048
#define TASK_STATE_MAX 4096
在 task_struct 里面,涉及任务状态的是下面这几个变量:state(状态)可以取的值定义在 include/linux/sched.h 头文件中。从定义的数值很容易看出来,flags 是通过 bitset 的方式设置的,也就是说,当前是什么状态,哪一位就置一。
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, 一旦一个进程要结束,先进入的是 EXIT_ZOMBIE 状态,但是这个时候它的父进程还没有使用 wait() 等系统调用来获知它的终止信息,此时进程就成了僵尸进程。
- EXIT_DEAD 是进程的最终状态。
- EXIT_ZOMBIE 和 EXIT_DEAD 也可以用于 exit_state。
进程调度
进程的状态切换往往涉及调度,下面这些字段都是用于调度的。
运行统计信息
u64 utime; // 用户态消耗的 CPU 时间
u64 stime; // 内核态消耗的 CPU 时间
unsigned long nvcsw; // 自愿 (voluntary) 上下文切换计数
unsigned long nivcsw; // 非自愿 (involuntary) 上下文切换计数
u64 start_time; // 进程启动时间,不包含睡眠时间
u64 real_start_time; // 进程启动时间,包含睡眠时间
进程亲缘关系
从我们之前讲的创建进程的过程,可以看出,任何一个进程都有父进程。所以,整个进程其实就是一棵进程树。而拥有同一父进程的所有进程都具有兄弟关系。
struct task_struct __rcu *real_parent; /* real parent process */
struct task_struct __rcu *parent; /* recipient of SIGCHLD, wait4() reports */
struct list_head children; /* list of my children */
struct list_head sibling; /* linkage in my parent's children list */
parent 指向其父进程。当它终止时,必须向它的父进程发送信号。
children 表示链表的头部。链表中的所有元素都是它的子进程。
sibling 用于把当前进程插入到兄弟链表中。
通常情况下,real_parent 和 parent 是一样的,但是也会有另外的情况存在。例如,bash创建一个进程,那进程的 parent 和 real_parent 就都是 bash。如果在 bash 上使用 GDB来 debug 一个进程,这个时候 GDB 是 real_parent,bash 是这个进程的 parent。
进程权限
在 Linux 里面,对于进程权限的定义如下:
/* Objective and real subjective task credentials (COW): */
const struct cred __rcu *real_cred;
/* Effective (overridable) subjective task credentials (COW): */
const struct cred __rcu *cred;
在 Linux 里面,对于进程权限的定义如下:这个结构的注释里,有两个名词比较拗口,Objective 和 Subjective。事实上,所谓的权限,就是我能操纵谁,谁能操纵我。“谁能操作我”,很显然,这个时候我就是被操作的对象,就是 Objective,那个想操作我的就是 Subjective。“我能操作谁”,这个时候我就是 Subjective,那个要被我操作的就是 Objectvie。
“操作”,就是一个对象对另一个对象进行某些动作。当动作要实施的时候,就要审核权限,当两边的权限匹配上了,就可以实施操作。其中,real_cred 就是说明谁能操作我这个进程,而 cred 就是说明我这个进程能够操作谁。
这里 cred 的定义如下:
struct cred {
......
kuid_t uid; /* real UID of the task */
kgid_t gid; /* real GID of the task */
kuid_t suid; /* saved UID of the task */
kgid_t sgid; /* saved GID of the task */
kuid_t euid; /* effective UID of the task */
kgid_t egid; /* effective GID of the task */
kuid_t fsuid; /* UID for VFS ops */
kgid_t fsgid; /* GID for VFS ops */
......
kernel_cap_t cap_inheritable; /* caps our children can inherit */
kernel_cap_t cap_permitted; /* caps we're permitted */
kernel_cap_t cap_effective; /* caps we can actually use */
kernel_cap_t cap_bset; /* capability bounding set */
kernel_cap_t cap_ambient; /* Ambient capability set */
......
} __randomize_layout;
从这里的定义可以看出,大部分是关于用户和用户所属的用户组信息。
- uid 和 gid,注释是 real user/group id。一般情况下,谁启动的进程,就是谁的ID。但是权限审核的时候,往往不比较这两个,也就是说不大起作用。
- euid 和 egid,注释是 effective user/group id。一看这个名字,就知道这个是起“作用”的。当这个进程要操作消息队列、共享内存、信号量等对象的时候,其实就是在比较这个用户和组是否有权限。
- fsuid 和 fsgid,也就是 filesystem user/group id。这个是对文件操作会审核的权限。
一般说来,fsuid、euid,和 uid 是一样的,fsgid、egid,和 gid 也是一样的。因为谁启动的进程,就应该审核启动的用户到底有没有这个权限。
但是也有特殊的情况。
例如,用户 A 想玩一个游戏,这个游戏的程序是用户 B 安装的。游戏这个程序文件的权限为 rwxr–r--。A 是没有权限运行这个程序的,因而用户 B 要给用户 A 权限才行。用户 B 说没问题,都是朋友嘛,于是用户 B 就给这个程序设定了所有的用户都能执行的权限 rwxr-xr-x,说兄弟你玩吧。于是,用户 A 就获得了运行这个游戏的权限。当游戏运行起来之后,游戏进程的 uid、euid、fsuid 都是用户 A。看起来没有问题,玩的很开心。
用户 A 好不容易通过一关,想保存通关数据的时候,发现坏了,这个游戏的玩家数据是保存在另一个文件里面的。这个文件权限 rw-------,只给用户 B 开了写入权限,而游戏进程的 euid 和 fsuid 都是用户 A,当然写不进去了。完了,这一局白玩儿了。
那怎么解决这个问题呢?我们可以通过 chmod u+s program 命令,给这个游戏程序设置set-user-ID 的标识位,把游戏的权限变成 rwsr-xr-x。这个时候,用户 A 再启动这个游戏的时候,创建的进程 uid 当然还是用户 A,但是 euid 和 fsuid 就不是用户 A 了,因为看到了 set-user-id 标识,就改为文件的所有者的 ID,也就是说,euid 和 fsuid 都改成用户B 了,这样就能够将通关结果保存下来。
在 Linux 里面,一个进程可以随时通过 setuid 设置用户 ID,所以,游戏程序的用户 B 的ID 还会保存在一个地方,这就是 suid 和 sgid,也就是 saved uid 和 save gid。这样就可以很方便地使用 setuid,通过设置 uid 或者 suid 来改变权限。
除了以用户和用户组控制权限,Linux 还有另一个机制就是capabilities。
原来控制进程的权限,要么是高权限的 root 用户,要么是一般权限的普通用户,这时候的问题是,root 用户权限太大,而普通用户权限太小。有时候一个普通用户想做一点高权限的事情,必须给他整个 root 的权限。这个太不安全了。于是,我们引入新的机制 capabilities,用位图表示权限,在 capability.h 可以找到定义的权限。我这里列举几个。
#define CAP_CHOWN 0
#define CAP_KILL 5
#define CAP_NET_BIND_SERVICE 10
#define CAP_NET_RAW 13
#define CAP_SYS_MODULE 16
#define CAP_SYS_RAWIO 17
#define CAP_SYS_BOOT 22
#define CAP_SYS_TIME 25
#define CAP_AUDIT_READ 37
#define CAP_LAST_CAP CAP_AUDIT_READ
对于普通用户运行的进程,当有这个权限的时候,就能做这些操作;没有的时候,就不能做,这样粒度要小很多。
cap_permitted 表示进程能够使用的权限。但是真正起作用的是 cap_effective。cap_permitted 中可以包含 cap_effective 中没有的权限。一个进程可以在必要的时候,放弃自己的某些权限,这样更加安全。假设自己因为代码漏洞被攻破了,但是如果啥也干不了,就没办法进一步突破。cap_inheritable 表示当可执行文件的扩展属性设置了 inheritable 位时,调用 exec 执行该程序会继承调用者的 inheritable 集合,并将其加入到 permitted 集合。但在非 root 用户下执行 exec 时,通常不会保留 inheritable 集合,但是往往又是非 root 用户,才想保留权限,所以非常鸡肋。
cap_bset,也就是 capability bounding set,是系统中所有进程允许保留的权限。如果这个集合中不存在某个权限,那么系统中的所有进程都没有这个权限。即使以超级用户权限执行的进程,也是一样的。这样有很多好处。例如,系统启动以后,将加载内核模块的权限去掉,那所有进程都不能加载内核模块。这样,即便这台机器被攻破,也做不了太多有害的事情。cap_ambient 是比较新加入内核的,就是为了解决 cap_inheritable 鸡肋的状况,也就是,非 root 用户进程使用 exec 执行一个程序的时候,如何保留权限的问题。当执行 exec的时候,cap_ambient 会被添加到 cap_permitted 中,同时设置到 cap_effective 中。
内存管理
每个进程都有自己独立的虚拟内存空间,这需要有一个数据结构来表示,就是mm_struct。
struct mm_struct *mm;
struct mm_struct *active_mm;
文件与文件系统
每个进程有一个文件系统的数据结构,还有一个打开文件的数据结构。
/* Filesystem information: */
struct fs_struct *fs;
/* Open file information: */
struct files_struct *files;
进程管理 task_struct 的的结构图如下: