Linux系统笔记(三)进程管理(1)

877 阅读16分钟

一、进程

在 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 中,有时候我们希望将前台的任务和后台的任务分开。因为有些任务是需要马上返回结果的,例如你输入了一个字符,不可能五分钟再显示出来;而有些任务是可以默默执行的,例如将本机的数据同步到服务器上去,这个就没刚才那么着急。因此这样两个任务就应该在不同的线程处理,以保证互不耽误。

线程的数据

我们把线程访问的数据细分成三类。

  1. 线程栈上的本地数据,比如函数执行过程中的局部变量。前面我们说过,函数的调用会使用栈的模型,这在线程里面是一样的。只不过每个线程都有自己的栈空间。栈的大小可以通过命令 ulimit -a 查看,默认情况下线程栈大小为 8192(8MB)。我们可以使用命令 ulimit -s 修改。
    主线程在内存中有一个栈空间,其他线程栈也拥有独立的栈空间。为了避免线程之间的栈空间踩踏,线程栈之间还会有小块区域,用来隔离保护各自的栈空间。一旦另一个线程踏入到这个隔离区,就会引发段错误。
  2. 在整个进程里共享的全局数据。例如全局变量,虽然在不同进程中是隔离的,但是在一个进程中是共享的。
  3. 线程私有数据(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,有一个就足以做唯一标识了,这个怎么看起来这么麻烦?这是因为,上面的进程和线程到了内核这里,统一变成了任务,这就带来两个问题。

  1. 任务展示。
  2. 给任务下发指令。

所以在内核中,它们虽然都是任务,但是应该加以区分。其中,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 中,有两种睡眠状态。

  1. TASK_INTERRUPTIBLE,可中断的睡眠状态。这是一种浅睡眠的状态,也就是说,虽然在睡眠,等待 I/O 完成,但是这个时候一个信号来的时候,进程还是要被唤醒。只不过唤醒后,不是继续刚才的操作,而是进行信号处理。当然程序员可以根据自己的意愿,来写信号处理函数,例如收到某些信号,就放弃等待这个 I/O 操作完成,直接退出,也可也收到某些信息,继续等待。
  2. TASK_UNINTERRUPTIBLE,不可中断的睡眠状态。这是一种深度睡眠状态,不可被信号唤醒,只能死等 I/O 操作完成。一旦 I/O 操作因为特殊原因不能完成,这个时候,谁也叫不醒这个进程了。你可能会说,我 kill 它呢?别忘了,kill 本身也是一个信号,既然这个状态不可被信号唤醒,kill 信号也被忽略了。除非重启电脑,没有其他办法。因此,这其实是一个比较危险的事情,除非程序员极其有把握,不然还是不要设置成TASK_UNINTERRUPTIBLE。
  3. TASK_KILLABLE,可以终止的新睡眠状态。进程处于这种状态中,它的运行原理类似 TASK_UNINTERRUPTIBLE,只不过可以响应致命信号。
  4. TASK_STOPPED 是在进程接收到 SIGSTOP、SIGTTIN、SIGTSTP 或者 SIGTTOU 信号之后进入该状态。
  5. TASK_TRACED 表示进程被 debugger 等进程监视,进程执行被调试程序所停止。当一个进程被另外的进程所监视,每一个信号都会让进程进入该状态。
  6. EXIT_ZOMBIE, 一旦一个进程要结束,先进入的是 EXIT_ZOMBIE 状态,但是这个时候它的父进程还没有使用 wait() 等系统调用来获知它的终止信息,此时进程就成了僵尸进程。
  7. EXIT_DEAD 是进程的最终状态。
  8. 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;

从这里的定义可以看出,大部分是关于用户和用户所属的用户组信息。

  1. uid 和 gid,注释是 real user/group id。一般情况下,谁启动的进程,就是谁的ID。但是权限审核的时候,往往不比较这两个,也就是说不大起作用。
  2. euid 和 egid,注释是 effective user/group id。一看这个名字,就知道这个是起“作用”的。当这个进程要操作消息队列、共享内存、信号量等对象的时候,其实就是在比较这个用户和组是否有权限。
  3. 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 的的结构图如下: