Kernel 进程相关

2015-06-02 kernel

实际上一个进程,包括了代码、数据和分配给进程的资源,这是一个动态资源。

这里简单介绍与进程相关的东西,例如进程创建、优先级、进程之间的关系、进程组和会话、进程状态等。

进程

一个进程,包括了代码、数据和分配给进程的资源。

初始进程

在 Linux 中,进程基本都是通过复制其它进程的结构来实现的,利用 slabs 来动态分配,系统没有提供用于创建进程的接口。

唯一的一个例外是第一个 task_struct,这是由一个静态或者说是固化的结构表示的 (init_task),该进程的 PID=0,可以参看 arch/x86/kernel/init_task.c,也可以称之为空闲进程。

当内核执行到 sched_init() 时,task_struct 的核心 TRLDT 就被手工设置好了,这时,0 号进程就有了,而在 sched_init() 之前,是没有 “进程” 这个概念的,而 init(PID=1) 进程是 0 号进程 fork() 出来的。

struct task_struct init_task = INIT_TASK(init_task);

查看 INIT_TASK() 宏定义时,会发现很多有意思的东西,如 init_task 的栈通过 .stack = &init_thread_info 指定,而该栈实际时通过如下的方式静态分配。

union thread_union init_thread_union __init_task_data = { INIT_THREAD_INFO(init_task) };

定义中的 __init_task_data 表明该内核栈所在的区域位于内核映像的 init data 区,可以通过编译完内核后所产生的 System.map 来看到该变量及其对应的逻辑地址。

$ cat System.map-`uname -r` | grep init_thread_union
ffffffff818fc000 D init_thread_union

而内核在无进程的情况下,将一直从初始化部分的代码执行到 start_kernel(),然后再到其最后一个函数调用 rest_init(),在该函数中将产生第一个进程 PID=1

start_kernel()
 |-rest_init()
   |-kernel_thread()        实际上调用kernel_init()创建第一个进程PID=1

kernel_init()
 |-run_init_process()
   |-do_execve()

最后 init_task 任务会变成 idle 进程。

在 Linux 系统中,可创建进程的最大值是由 max_threads@kernel/fork.c 变量确定的,也可以通过 /proc/sys/kernel/threads-max 查看/更改此值。

fork

通过 fork() 将创建一个与原来进程几乎完全相同的进程,系统给新的进程分配资源,例如存储数据和代码的空间,然后把原来的进程的所有值都复制到新的新进程中,只有少数值与原来的进程的值不同,相当于克隆了一个自己。

#include <stdio.h>
#include <unistd.h>

int main()
{
    pid_t pid;
    int count = 0;

    pid = fork();            // vfork(); error
    if (pid < 0) {
        printf("error in fork!");
    } else if (pid == 0) {   // child process
        count++;
        printf(" child: PID is %d, count is %d\n", getpid(), count);
    } else {                 // parent process
        count++;
        printf("parent: PID is %d, count is %d\n", getpid(), count);
    }

    return 0;
}

在语句 fork() 之前,只有一个进程在执行这段代码,但在这条语句之后,就变成两个进程在执行了,这两个进程的几乎完全相同。其中 fork() 的一个奇妙之处就是它仅仅被调用一次,却能够返回两次,它可能有三种不同的返回值:

  1. 在父进程中,fork() 返回新创建子进程的进程 ID;
  2. 在子进程中,fork() 返回 0;
  3. 如果出现错误,fork() 返回一个负值;

创建新进程成功后,系统中出现两个基本完全相同的进程,这两个进程执行没有固定的先后顺序,哪个进程先执行要看系统的进程调度策略。

执行流程

  1. 进程可以看做程序的一次执行过程。在 linux 下,每个进程有唯一的 PID 标识进程。 PID 是一个从 1 到 32768 的正整数,其中 1 一般是特殊进程 init ,其它进程从 2 开始依次编号。当用完 32768 后,从 2 重新开始。
  2. Linux 中有一个叫进程表的结构用来存储当前正在运行的进程。可以使用 ps aux 命令查看所有正在运行的进程。
  3. 进程在 linux 中呈树状结构, init 为根节点,其它进程均有父进程,某进程的父进程就是启动这个进程的进程,这个进程叫做父进程的子进程。
  4. fork 的作用是复制一个与当前进程一样的进程。新进程的所有数据(变量、环境变量、程序计数器等)数值都和原进程一致,但是是一个全新的进程,并作为原进程的子进程。

process fork

上图表示一个含有 fork 的程序,而 fork 语句可以看成将程序切为 A、B 两个部分。然后整个程序会如下运行:

  1. 设由 shell 直接执行程序,生成了进程 M 。 M 执行完 Part.A 的所有代码。
  2. 当执行到 pid = fork(); 时, M 启动一个进程 S ,S 是 M 的子进程,和 M 是同一个程序的进程。S 继承 M 的所有变量、环境变量、程序计数器的当前值。
  3. 在 M 进程中,fork() 将 S 的 PID 返回给变量 pid ,并继续执行 Part.B 的代码。
  4. 在进程 S 中,将 0 赋给 pid ,并继续执行 Part.B 的代码。

这里有三个点非常关键:

  • M 执行了所有程序,而 S 只执行了 Part.B ,即 fork() 后面的程序,(这是因为 S 继承了 M 的 PC-程序计数器)。
  • S 继承了 fork() 语句执行时当前的环境,而不是程序的初始环境。
  • M 中 fork() 语句启动子进程 S ,并将 S 的 PID 返回,而 S 中的 fork() 语句不启动新进程,仅将 0 返回。

举例

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main()
{
    pid_t pid1;
    pid_t pid2;

    pid1 = fork();
    pid2 = fork();

    printf("pid1:%d, pid2:%d\n", pid1, pid2);
}

对于如上的程序,在执行之后将会生成 4 个进程,如果其中一个进程的输出结果是 pid1:1001, pid2:1002 ,那么其他的分别为。

pid1:1001, pid2:0
pid1:0, pid2:1003
pid1:0, pid2:0

实际的执行过程如下所示:

process fork

最后的实际执行过程为:

  1. 从 shell 中执行此程序,启动了一个进程,我们设这个进程为 P0 ,设其 PID 为 XXX (解题过程不需知道其 PID)。
  2. 当执行到 pid1 = fork(); 时, P0 启动一个子进程 P1 ,由题目知 P1 的 PID 为 1001 。我们暂且不管 P1 。
  3. P0 中的 fork 返回 1001 给 pid1 ,继续执行到 pid2 = fork(); ,此时启动另一个新进程,设为 P2 ,由题目知 P2 的 PID 为 1002 。同样暂且不管 P2 。
  4. P0 中的第二个 fork 返回 1002 给 pid2 ,继续执行完后续程序,结束。所以, P0 的结果为 “pid1:1001, pid2:1002” 。
  5. 再看 P2 , P2 生成时, P0 中 pid1=1001 ,所以 P2 中 pid1 继承 P0 的 1001 ,而作为子进程 pid2=0 。 P2 从第二个 fork 后开始执行,结束后输出 “pid1:1001, pid2:0” 。
  6. 接着看 P1 , P1 中第一条 fork 返回 0 给 pid1 ,然后接着执行后面的语句。而后面接着的语句是 pid2 = fork(); 执行到这里, P1 又产生了一个新进程,设为 P3 。先不管 P3 。
  7. P1 中第二条 fork 将 P3 的 PID 返回给 pid2 ,由预备知识知 P3 的 PID 为 1003 ,所以 P1 的 pid2=1003 。 P1 继续执行后续程序,结束,输出 “pid1:0, pid2:1003” 。
  8. P3 作为 P1 的子进程,继承 P1 中 pid1=0 ,并且第二条 fork 将 0 返回给 pid2 ,所以 P3 最后输出 “pid1:0, pid2:0” 。
  9. 至此,整个执行过程完毕。

进程创建

进程和线程相关的函数在内核中,最终都会调用 do_fork(),只是最终传入的参数不同。

其中在用户空间中与进程相关的接口有 fork()vfork()clone()exec();线程相关的有 pthread_create (glibc) 和 kernel_thread (Kernel内部函数),pthread_create() 是对 clone() 的封装,kernel_thread() 用于创建内核线程,两者最终同样会调用 do_fork()

区别

fork() 会创建新的进程;exec() 的原进程将会被新的进程替换;而 vfork() 其实就是 fork() 的部分过程,通过简化来提高效率。

fork() 是进程资源的完全复制,包括进程的 PCB(task_struct)、线程的系统堆栈、进程的用户空间、进程打开的设备等,而在 clone() 中其实只有前两项是被复制了的,后两项都与父进程共享,对共享数据的保护必须有上层应用来保证。

vfork()fork() 主要区别:

  • fork() 子进程拷贝父进程的数据段,堆栈段;vfork() 子进程与父进程共享数据段。
  • fork() 父子进程的执行次序不确定;vfork() 保证子进程先运行,在调用 exec()exit() 之前与父进程数据是共享的,在它调用 exec()exit() 之后父进程才可能被调度运行,否则会发生错误。
  • 如果在此之前子进程有依赖于父进程的进一步动作,如调用函数,则会导致错误。

fork() 在执行复制时,采用 “写时复制”,开始的时候内存并没有被复制,而是共享的,直到有一个进程去写某块内存时,它才被复制。实际操作时,内核先将这些内存设为只读,当它们被写时,CPU 出现访存异常,内核捕捉异常,复制空间,并改属性为可写。

但是,“写时复制” 其实还是有复制,进程的 mm 结构、页表都还是被复制了,而 vfork() 会忽略所有关于内存的东西,父子进程的内存是完全共享的。

不过此时,父子进程共用着栈,如果两个进程并行执行,那么可能会导致调用栈出错。所以,vfork() 有个限制,当子进程生成后,父进程在 vfork() 中被内核挂起,直到子进程有了自己的内存空间 (exec**) 或退出 (_exit)

并且,在此之前,子进程不能从调用 vfork() 的函数中返回,同时,不能修改栈上变量、不能继续调用除 _exit()exec() 系列之外的函数,否则父进程的数据可能被改写。

虽然 vfork() 的限制很多,但是对于 shell 来说却非常适合。

源码解析

如下是内核中的接口,do_fork() 返回的是子进程的 PID。

// kernel/fork.c
do_fork(SIGCHLD, 0, 0, NULL, NULL);                           // sys_fork
do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, 0, 0, NULL, NULL);  // sys_vfork
do_fork(clone_flags, newsp, 0, parent_tidptr, child_tidptr);  // sys_clone


do_fork() @ kernel/fork.c
  |-... ...                            // 做一些参数的检查工作,主要是针对flag
  |-copy_process()                     // 复制一个进程描述符,PID不同
  |  |-security_task_create()          // 安全模块的回调函数
  |  |-dup_task_struct()               // 新建task_struct和thread_info,并将当前进程相应的结构完全复制过去
  |  |-atomic_read()                   // 判断是否超过了进程数
  |  |-copy_creds()                    // 应该是安全相关的设置
  |  |-... ...                         // 判断创建的进程是否超了进程的总量等
  |  |-sched_fork()                    // 调度相关初始化
  |  |-copy_xxx()                      // 根据传入的flag复制相应的数据结构
  |  |-alloc_pid()                     // 为新进程获取一个有效的PID
  |  |-sched_fork()                    // 父子进程平分共享的时间片
  |
  |-wake_up_new_task()                 // 如果创建成功则执行
  |-wait_for_vfork_done()              // 如果是vfork()等待直到子进程exit()或者exec()

clone_flags 由 4 个字节组成,最低的一个字节为子进程结束时发送给父进程的信号代码,fork/vforkSIGCHLDclone 可以指定;剩余的三个字节则是各种 clone() 标志的组合,用于选择复制父进程那些资源。

内核有意选择子进程首先执行。因为一般子进程都会马上调用 exec 函数,这样可以避免写时拷贝的额外开销,如果父进程首先执行的话,有可能开始向地址空间写入。

PID分配

pid 分配的范围是 0~32767,struct pidmap 用来标示 pid 是不是已经分配出去了,采用位图的方式,每个字节表示 8 个 PID,为了表示 32768 个进程号要用 32768/8=4096 个字节,也即一个 page,结构体中的 nr_free 表示剩余 PID 的数量。

PID 相关的一些操作实际上就是 bitmap 的操作,常见的如下。

  • int test_and_set_bit(int offset, void *addr);
    将 offset 在 pidmap 变量当中相应的位置为 1,并返回旧值;也就是申请到一个 pid 号之后,修改位标志,其中 addr 是 pidmap.page 变量的地址。
  • void clear_bit(int offset, void *addr);
    将 offset 在 pidmap 变量当中相应的位置为 0,也就是释放一个 pid 后,修改位标志,其中 addr 是 pidmap.page 变量的地址。
  • int find_next_zero_bit(void *addr, int size, int offset);
    从 offset 开始,找下一个是 0 (也就是可以分配) 的 pid 号,其中 addr 是 pidmap.page 变量的地址,size 是一个页的大小。
  • int alloc_pidmap();
    分配一个 pid 号。
  • void free_pidmap(int pid);
    回收一个 pid 号。

目前 Linux 通过 PID 命名空间进行隔离,其中有一个变量 last_pid 用于标示上一次分配出去的 pid 编号。

在内核中通过移位操作来实现根据 PID 查找地址,可以想象抽象出一张表,这个表有 32 列,1024行,这个刚好是一个页的大小,可以参考相应程序的示例。

系统进程遍历

在内核中,会通过双向链表保存任务列表,可以将 init_task 作为链表的开头,然后进行迭代。

可以通过内核模块 procsview 进行查看,通过 printk 将所有的进程打印出来,结果可以通过 tail -f /var/log/messages 查看。

可以通过宏定义 next_task()@include/linux/sched.h 简化任务列表的迭代,该宏会返回下一个任务的 task_struct 引用;current 标识当前正在运行的进程,这实际是一个宏,在 arch/x86/include/asm/current.h 中定义。

通过 Make 进行编译,用 insmod procsview.ko 插入模块对象,用 rmmod procsview 删除它。插入后,/var/log/messages 可显示输出,其中有一个空闲任务 (称为 swapper) 和 init 任务 (pid 1)。

进程关系

task_struct 结构中,保存了一些字段,用来维护各个进程之间的关系。

在 Linux 中,线程是通过轻量级进程 (LWP) 实现,会为每个进程和线程分配一个 PID,同时我们希望由一个进程产生的轻量级进程具有相同的 PID,这样当我们向进程发送信号时,此信号可以影响进程及进程产生的轻量级进程。

为此,采用线程组 (可以理解为轻量级进程组) 的概念,在线程组内,每个线程都使用此线程组内第一个线程 (thread group leader) 的 pid,并将此值存入tgid,当我们使用 getpid() 函数得到进程 ID 时,其实操作系统返回的是 task_struct 的 tgid 字段,而非 pid 字段。

struct task_struct {
    pid_t pid; pid_t tgid;            // 用于标示线程和线程组ID
    struct task_struct *real_parent;  // 实际父进程real parent process
    struct task_struct *parent;       // SIGCHLD的接受者,由wait4()报告
    struct list_head children;        // 子进程列表
    struct list_head sibling;         // 兄弟进程列表
    struct task_struct *group_leader; // 线程组的leader
};
$ ps -eo  uid,pid,ppid,pgid,sid,pidns,tty,comm

getpid()[tgid]、gettid()[pid]、getppid()[real_parent]、getsid()

getuid()、getgid()、geteuid()、getegid()、getgroups()、getresuid()、getresgid()、getpgid()、<br><br>

getpgrp()/getpgid()/setpgid()        // 获取或者设置进程组

gettid() 返回线程号,如果是单线程与 getpid() 相同,该值在整个 Linux 系统内是唯一的;pthread_self() 返回的线程号只能保证在进程中是唯一的。

process relations

下面是内核提供的系统调用,实现有点复杂,可以通过注释查看返回的 task_strcut 中的值。

Linux 中的进程组是为了方便对进程进行管理,假设要完成一个任务,需要同时并发 100 个进程,当用户处于某种原因要终止这个任务时,可以将这些进程设置备为同一个进程组,然后向进程组发送信号。

进程必定属于一个进程组,也只能属于一个进程组;一个进程组中可以包含多个进程,进程组的生命周期从被创建开始,到其内所有进程终止或离开该组。

由于 Linux 是多用户多任务的分时系统,所以必须要支持多个用户同时使用一个操作系统,当一个用户登录一次系统就形成一次会话。

一个或多个进程组可以组成会话,由其中一个进程建立,该进程叫做会话的领导进程 (session leader),会话领导进程的 PID 成为识别会话的 SID(session ID);一个会话可包含多个进程组,但只能有一个前台进程组。

会话中的每个进程组称为一个作业 (job),bash(Bourne-Again shell) 支持作业控制,而 sh(Bourne shell) 并不支持。

会话可以有一个进程组成为会话的前台工作 (foreground),而其他的进程组是后台工作 (background)。每个会话可以连接一个控制终端 (control terminal),当控制终端有输入输出时,都传递给该会话的前台进程组,由终端产生的信号,比如 CTRL+ZCTRL+\ 会传递到前台进程组。

会话的意义在于将多个工作囊括在一个终端,并取其中的一个工作作为前台,来直接接收该终端的输入输出以及终端信号,其他工作在后台运行。一般开始于用户登录,终止于用户退出,此期间所有进程都属于这个会话期。

一个工作可以通过 fg 从后台工作变为前台工作,如 fg %1

进程组和会话期

当通过 SSH 或者 telent 远程登录到 Linux 服务器时,如果执行一个长时间运行的任务,如果关掉窗口或者断开连接,这个任务就会被杀掉,一切都半途而废了,这主要是因为 SIGHUP 信号。

在 Linux/Unix 中,有这样几个概念:

  • 进程组 (process group): 一个或多个进程的集合,每一个进程组有唯一一个进程组 ID ,即进程组长进程的 ID 。
  • 会话期 (session): 一个或多个进程组的集合,有唯一一个会话期首进程 (session leader),会话期 ID 为首进程的 ID 。
  • 会话期可以有一个单独的控制终端(controlling terminal)。与控制终端连接的会话期首进程叫做控制进程(controlling process)。当前与终端交互的进程称为前台进程组,其余进程组称为后台进程组。

根据 POSIX.1 的定义,挂断信号 (SIGHUP) 默认的动作是终止程序;当终端接口检测到网络连接断开,将挂断信号发送给控制进程 (会话期首进程);如果会话期首进程终止,则该信号发送到该会话期前台进程组;一个进程退出导致一个孤儿进程组中产生时,如果任意一个孤儿进程组进程处于 STOP 状态,发送 SIGHUP 和 SIGCONT 信号到该进程组中所有进程。

因此当网络断开或终端窗口关闭后,控制进程收到 SIGHUP 信号退出,会导致该会话期内其他进程退出。

打开两个 SSH 终端窗口,或者两个 gnome-terminal,在其中一个运行 top 命令。在另一个终端窗口,找到 top 的进程 ID 为 24317 ,其父进程 ID 为 24230 ,即登录 shell 。使用 pstree 命令可以更清楚地看到这个关系。

# top

# ps -ef | grep top
UID        PID  PPID  C STIME    TTY       TIME CMD
andy     24317 24230  2 13:45 pts/16   00:00:05 top
andy     24526 24419  0 13:48 pts/17   00:00:00 grep --color=auto top

# pstree -H 24317 | grep top
|-sshd-+-sshd-+-sshd---bash---top

使用 ps -xj 命令可以看到,登录 shell (PID 24230) 和 top 在同一个会话期, shell 为会话期首进程,所在进程组 PGID 为 24230, top 所在进程组 PGID 为 24317 ,为前台进程组。

~$ ps -xj | grep 24230
 PPID   PID  PGID   SID    TTY   TPGID STAT   UID   TIME COMMAND
24229 24230 24230 24230 pts/16   24317 Ss    1000   0:00 -bash
24230 24317 24317 24230 pts/16   24317 S+    1000   0:18 top
24419 24543 24542 24419 pts/17   24542 S+    1000   0:00 grep --color=auto 2423

关闭第一个 SSH 窗口,在另一个窗口中可以看到 top 也被杀掉了,如果我们可以忽略 SIGHUP 信号,关掉窗口应该就不会影响程序的运行了。

nohup 命令可以达到这个目的,如果程序的标准输出/标准错误是终端, nohup 默认将其重定向到 nohup.out 文件。值得注意的是 nohup 命令只是使得程序忽略 SIGHUP 信号,还需要使用标记 & 把它放在后台运行。

其它

指定CPU

Affinity 是进程的一个属性,这个属性指明了进程调度器能够把这个进程调度到哪些 CPU 上,可以利用 CPU affinity 把一个或多个进程绑定到一个或多个 CPU 上。

CPU Affinity 分为 soft affinity 和 hard affinity; soft affinity 仅是一个建议,如果不可避免,调度器还是会把进程调度到其它的 CPU 上;hard affinity 是调度器必须遵守的规则。

增加 CPU 缓存的命中率。CPU 之间是不共享缓存的,如果进程频繁的在各个 CPU 间进行切换,需要不断的使旧 CPU 的 cache 失效。如果进程只在某个 CPU 上执行,则不会出现失效的情况。

另外,在多个线程操作的是相同的数据的情况下,如果把这些线程调度到一个处理器上,大大的增加了 CPU 缓存的命中率。但是可能会导致并发性能的降低。如果这些线程是串行的,则没有这个影响。

适合 time-sensitive 应用,在 real-time 或 time-sensitive 应用中,我们可以把系统进程绑定到某些 CPU 上,把应用进程绑定到剩余的 CPU 上。

CPU 集可以认为是一个掩码,每个设置的位都对应一个可以合法调度的 CPU,而未设置的位则对应一个不可调度的 CPU。换而言之,线程都被绑定了,只能在那些对应位被设置了的处理器上运行。通常,掩码中的所有位都被置位了,也就是可以在所有的 CPU 中调度。

另外可以通过 schedutils 或者 python-schedutils 进行设置,后者现在更加常见。

杀死进程的N中方法

查看进程通常可以通过如下命令

$ ps -ef
$ ps -aux
......
smx    1827     1   4 11:38 ?        00:27:33 /usr/lib/firefox-3.6.18/firefox-bin
......

此时如果我想杀了火狐的进程就在终端输入:

$ kill -s 9 1827

其中 -s 9 制定了传递给进程的信号是 9,即强制、尽快终止进程。

无论是 ps -ef 还是 ps -aux,每次都要在一大串进程信息里面查找到要杀的进程,看的眼都花了。因此通过如下的方法进行改进。

使用grep

把 ps 的查询结果通过管道给 grep 查找包含特定字符串的进程。管道符 | 用来隔开两个命令,管道符左边命令的输出会作为管道符右边命令的输入。

$ ps -ef | grep firefox
smx    1827     1   4 11:38 ?        00:27:33 /usr/lib/firefox-3.6.18/firefox-bin
smx    12029  1824  0 21:54 pts/0    00:00:00 grep --color=auto firefox
$ kill -s 9 1827

使用pgrep

pgrep 的 p 表明了这个命令是专门用于进程查询的 grep 。

$ pgrep firefox
1827
$ kill -s 9 1827

使用pidof

实际就是 pid of xx,字面翻译过来就是 xx 的 PID ,和 pgrep 相比稍显不足的是,pidof 必须给出进程的全名。

$ pidof firefox-bin
1827
$kill -s 9 1827

无论是使用 ps 然后慢慢查找进程 PID 还是用 grep 查找包含相应字符串的进程,亦或者用 pgrep 直接查找包含相应字符串的进程 PID ,然后手动输入给 Kill 杀掉,都稍显麻烦。

一步完成

$ps -ef | grep firefox | grep -v grep | cut -c 9-15 | xargs kill -s 9

使用 pgrep/pidof

$ pgrep firefox | xargs kill -s 9

使用awk

$ ps -ef | grep firefox | awk '{print $2}' | xargs kill -9
kill: No such process

替换 xargs

$ kill -s 9 `ps -aux | grep firefox | awk '{print $2}'`

换成 pgrep

$ kill -s 9 `pgrep firefox`

使用 pkill

pkill=pgrep+kill。

$ pkill -9 firefox

说明:"-9" 即发送的信号是9,pkill与kill在这点的差别是:pkill无须 “s”,终止信号等级直接跟在 “-“ 后面。

使用 killall

killall和pkill是相似的,不过如果给出的进程名不完整,killall会报错。pkill或者pgrep只要给出进程名的一部分就可以终止进程。

$ killall -9 firefox

参考