实际上一个进程,包括了代码、数据和分配给进程的资源,这是一个动态资源。
这里简单介绍与进程相关的东西,例如进程创建、优先级、进程之间的关系、进程组和会话、进程状态等。
进程
一个进程,包括了代码、数据和分配给进程的资源。
初始进程
在 Linux 中,进程基本都是通过复制其它进程的结构来实现的,利用 slabs 来动态分配,系统没有提供用于创建进程的接口。
唯一的一个例外是第一个 task_struct
,这是由一个静态或者说是固化的结构表示的 (init_task
),该进程的 PID=0
,可以参看 arch/x86/kernel/init_task.c
,也可以称之为空闲进程。
当内核执行到 sched_init()
时,task_struct
的核心 TR
、LDT
就被手工设置好了,这时,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() 的一个奇妙之处就是它仅仅被调用一次,却能够返回两次,它可能有三种不同的返回值:
- 在父进程中,fork() 返回新创建子进程的进程 ID;
- 在子进程中,fork() 返回 0;
- 如果出现错误,fork() 返回一个负值;
创建新进程成功后,系统中出现两个基本完全相同的进程,这两个进程执行没有固定的先后顺序,哪个进程先执行要看系统的进程调度策略。
执行流程
- 进程可以看做程序的一次执行过程。在 linux 下,每个进程有唯一的 PID 标识进程。 PID 是一个从 1 到 32768 的正整数,其中 1 一般是特殊进程 init ,其它进程从 2 开始依次编号。当用完 32768 后,从 2 重新开始。
- Linux 中有一个叫进程表的结构用来存储当前正在运行的进程。可以使用
ps aux
命令查看所有正在运行的进程。 - 进程在 linux 中呈树状结构, init 为根节点,其它进程均有父进程,某进程的父进程就是启动这个进程的进程,这个进程叫做父进程的子进程。
- fork 的作用是复制一个与当前进程一样的进程。新进程的所有数据(变量、环境变量、程序计数器等)数值都和原进程一致,但是是一个全新的进程,并作为原进程的子进程。
上图表示一个含有 fork 的程序,而 fork 语句可以看成将程序切为 A、B 两个部分。然后整个程序会如下运行:
- 设由 shell 直接执行程序,生成了进程 M 。 M 执行完 Part.A 的所有代码。
- 当执行到 pid = fork(); 时, M 启动一个进程 S ,S 是 M 的子进程,和 M 是同一个程序的进程。S 继承 M 的所有变量、环境变量、程序计数器的当前值。
- 在 M 进程中,fork() 将 S 的 PID 返回给变量 pid ,并继续执行 Part.B 的代码。
- 在进程 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
实际的执行过程如下所示:
最后的实际执行过程为:
- 从 shell 中执行此程序,启动了一个进程,我们设这个进程为 P0 ,设其 PID 为 XXX (解题过程不需知道其 PID)。
- 当执行到 pid1 = fork(); 时, P0 启动一个子进程 P1 ,由题目知 P1 的 PID 为 1001 。我们暂且不管 P1 。
- P0 中的 fork 返回 1001 给 pid1 ,继续执行到 pid2 = fork(); ,此时启动另一个新进程,设为 P2 ,由题目知 P2 的 PID 为 1002 。同样暂且不管 P2 。
- P0 中的第二个 fork 返回 1002 给 pid2 ,继续执行完后续程序,结束。所以, P0 的结果为 “pid1:1001, pid2:1002” 。
- 再看 P2 , P2 生成时, P0 中 pid1=1001 ,所以 P2 中 pid1 继承 P0 的 1001 ,而作为子进程 pid2=0 。 P2 从第二个 fork 后开始执行,结束后输出 “pid1:1001, pid2:0” 。
- 接着看 P1 , P1 中第一条 fork 返回 0 给 pid1 ,然后接着执行后面的语句。而后面接着的语句是 pid2 = fork(); 执行到这里, P1 又产生了一个新进程,设为 P3 。先不管 P3 。
- P1 中第二条 fork 将 P3 的 PID 返回给 pid2 ,由预备知识知 P3 的 PID 为 1003 ,所以 P1 的 pid2=1003 。 P1 继续执行后续程序,结束,输出 “pid1:0, pid2:1003” 。
- P3 作为 P1 的子进程,继承 P1 中 pid1=0 ,并且第二条 fork 将 0 返回给 pid2 ,所以 P3 最后输出 “pid1:0, pid2:0” 。
- 至此,整个执行过程完毕。
进程创建
进程和线程相关的函数在内核中,最终都会调用 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/vfork
为 SIGCHLD
,clone
可以指定;剩余的三个字节则是各种 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()
返回的线程号只能保证在进程中是唯一的。
下面是内核提供的系统调用,实现有点复杂,可以通过注释查看返回的 task_strcut
中的值。
Linux 中的进程组是为了方便对进程进行管理,假设要完成一个任务,需要同时并发 100 个进程,当用户处于某种原因要终止这个任务时,可以将这些进程设置备为同一个进程组,然后向进程组发送信号。
进程必定属于一个进程组,也只能属于一个进程组;一个进程组中可以包含多个进程,进程组的生命周期从被创建开始,到其内所有进程终止或离开该组。
由于 Linux 是多用户多任务的分时系统,所以必须要支持多个用户同时使用一个操作系统,当一个用户登录一次系统就形成一次会话。
一个或多个进程组可以组成会话,由其中一个进程建立,该进程叫做会话的领导进程 (session leader),会话领导进程的 PID 成为识别会话的 SID(session ID);一个会话可包含多个进程组,但只能有一个前台进程组。
会话中的每个进程组称为一个作业 (job),bash(Bourne-Again shell) 支持作业控制,而 sh(Bourne shell) 并不支持。
会话可以有一个进程组成为会话的前台工作 (foreground),而其他的进程组是后台工作 (background)。每个会话可以连接一个控制终端 (control terminal),当控制终端有输入输出时,都传递给该会话的前台进程组,由终端产生的信号,比如 CTRL+Z
、CTRL+\
会传递到前台进程组。
会话的意义在于将多个工作囊括在一个终端,并取其中的一个工作作为前台,来直接接收该终端的输入输出以及终端信号,其他工作在后台运行。一般开始于用户登录,终止于用户退出,此期间所有进程都属于这个会话期。
一个工作可以通过 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
参考
- 神奇的vfork (local),一个针对错误使用 vfork() 时的场景分新。
- 进程描述和进程创建,用于描述进程,其中包括了进程创建以及退出,一篇不错的文章。