进程
对于操作系统来说,进程是一个很重要的抽象,进程的抽象是为了提高CPU的利用率,任何的抽象都需要一个物理基础,进程的物理基础便是程序。
程序和进程的区别:
- 进程是操作系统分配内核、CPU时间片等资源的基本单位。
- 程序是完成特定任务的指令集合,包含可运行的一堆CPU指令和响应的数据等信息;进程不仅包含代码段等信息,还有很多运行时需要的资源。
- 进程是一段执行中的程序,进程包含了可执行代码的代码段,存放变量、函数、返回值等信息的用户栈、存放进程相关数据的数据段,用于内核中进程切换的内核栈,以及动态分配内存的堆等。
- 进程是并发执行的一个实体,实现对CPU的虚拟化的核心技术是上下文切换以及进程调度。
进程分类:
根据运行模式分为:
- 核心态进程
- 用户态进程
用户态进程要执行一些核心态指令,会产生系统调用。
根据进程特点:
- 交互进程(
shell
) - 批处理进程
- 守护进程
根据进程状态:
- 守护进程(守护进程的父进程都是init进程)
- 孤儿进程(父进程退出,子进程被init收养)
- 僵尸进程(进程结束,但没有释放内存)
进程描述符
进程所拥有的资源抽象为进程控制块,即进程的资源及属性保存在进程控制块(PCB)中,linux中定义了task_struct
来保存进程属性。
PCB需要描述:
- 进程的运行状态:就绪、运行、阻塞、等待等等
- 程序计数器:记录当前进程运行到哪条指令
- CPU寄存器:保存当前运行的上下文,例如CPU寄存器信息,以便进程调度的数据恢复
- CPU调度信息:包括进程优先级、调度队列和调度等相关信息
- 内存管理信息:进程使用的内存信息,比如进程的页表等
- 统计信息:包含进程运行时间等相关的统计信息
- 文件相关信息:包括进程打开的文件等
linux内核利用链表task_list来存放所有进程描述符,task_struct数据结构定义在include/linux/sched.h文件中。
进程属性相关信息
- state:记录进程的状态,主要有TASK_RUNNING | TASK_INTERRUPTIBLE | TASK_UNINTERRUPTIBLE | EXIT_ZOMBIE | TASK_DEAD 等几个状态
- pid:进程标识符
- flag:进程属性标志位,标志位定义在include/linux/schude.h中,例如,程序退出时设置PF_EXITING;进程是一个workqueue类型的工作项成时会设置PF_WQ_WORKER;fork完成之后不执行exec命令时,会设置PF_FORKNOEXEC等。
- exit_code和exit_signal成员:用来存放进程退出值和终止信号,这样父进程可以知道子进程的退出原因
- pdeath_signal:父进程消亡时发出的信号
- comm:存放可执行程序的名称
- real_cred和cred:用来存放进程的一些认证信息,struct cred数据结构里包含了uid、gid等信息
调度相关信息
- prio:进程动态优先级,是调度类考虑的优先级
- static_prio:静态优先级,在进程启动时分配,内核不存储nice值,取而代之的是static_prio。
- normal_prio:基于static_prio和调度策略计算的优先级
- tr_priority:实时进程的优先级
- sched_class:调度类
- se:普通进程调度实体
- rt:实时进程调度实体
- dl:deadline进程调度实体
- policy:用来确定进程的类型,比如是普通类型还是实时进程
- cpus_allowed:进程可以在哪几个CPU上工作
进程间关系
系统中最初的第一个进程是idle进程(0号进程),此后每个进程都有一个父进程,也可以创建子进程,同时也可能有兄弟进程。
- real_parent:指向父进程的task_struct数据结构
- children:指向当前进程的子进程的链表
- sibling:指向当前进程的兄弟进程的链表
- group_leader:进程组的组长
内存管理和文件管理相关信息
进程在加载运行之前需要加载到内存中,因此PCB里必须有一个抽象描述内存相关的信息,有一个指向mm_struct数据结构的指针mm。此外,进程在生命周期内总是需要通过打开文件、读写文件等操作来完成一些任务,这就和文件系统密切相关了。
- mm:指向进程所管理的内存的一个总的抽象的数据结构mm_struct
- fs成员:保存一个指向文件系统信息的指针
- files成员:保存一个指向内存的文件描述符表的指针
进程的生命周期
进程标识
pid的分配
父进程为ppid,内核最大pid为32768,这是为了兼容旧版本unix系统,采用16为数据类型,可通过对/proc/sys/kernel/pid_max
修改,代价是降低兼容性。
内核会严格使用线性方程为进程分配pid,使用过的pid不再使用,除非超过最大值而循环使用较小的pid。
在内核中,pid被定义成pid_t
类型,通常是对int类型重新定义(typedef)。
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
printf("%d %d", getpid(), getppid());
}
进程的家族关系
linux内核在启动时会有一个init_task进程,即0号进程或idle进程,然后初始化完成后会创建一个init进程,即1号进程,它是所有进程的祖先。
空闲进程(idle process):pid=0,当没有进程可运行时,内核会运行此进程。
初始化进程(init process):pid=1,系统启动后内核执行的第一个进程。
init进程会按照以下四个顺序尝试执行:
/sbin/init
:首选和最有可能摆放init进程的位置/etc/init
:另一个可能摆放init进程的位置/bin/init
:一个可能摆放init进程的位置/bin/sh
:如果内核无法找到init进程,则会尝试运行它
如果都无法执行,则内核会发生panic而停止系统。
内核取得控制权后,由init进程负责处理启动过程的剩余步骤,包括初始化系统,启动服务以及执行一个登录程序。
init_task(0号进程)进程的task_struct数据结构通过INIT_TASK宏来赋值,定义在include/linux/init_task.h文件中。
除此之外,所有进程的task_struct数据结构都通过list_head类型的双向链表链在一起,因此每个task_struct数据结构都包含一个list_head类型的tasks成员。这个链表的头是init_task进程,即0号进程,init_task进程的tasks.prev字段指向链表中最后插入的进程task_struct数据结构的tasks成员。
linux提供了一个常用的宏for_each_process(p),来扫描系统中所有的进程,这个宏从init_task进程开始遍历,一直循环到init_task为止。
运行一个新进程
在unix中,一个进程的创建其实是包括两个步骤的:
- 创建(派生)一个新进程(fork)
- 执行一个新程序(将新程序加载到内存中执行)(exec)
在unix中,这两个步骤是分开的。
所以在创建子进程执行一个新程序通常会先fork()
,然后exec()
。
进程控制函数
fork()
函数
创建子进程,并运行与当前进程相同的映像。
#include <unistd.h>
pid_t fork(void);
unistd.h
封装了类UNIX系统下的很多固定名称的system_call系统调用。所以,这个函数是依赖于编译器,依赖于操作系统的。
父进程调用fork
函数创建子进程,会在父进程中返回子进程的进程id,在子进程中返回0。父进程无法通过函数获取子进程id,而子进程可以通过getppid()
获得父进程id。
通常fork
函数有两种用法:
- 父进程希望复制自己,使父子进程执行不同的代码。例如网络服务,父进程等待客户端请求,请求达到时,创建子进程执行此请求,父进程继续等待新请求。
- 一个进程要执行不同的程序。子进程从
fork
函数返回后,立即调用exec
函数(创建了一个全新进程),子进程在fork
函数和exec
函数之间可以更改自己的属性。例如shell
中,子进程创建后立即调用exec
函数。
一个进程调用了fork
函数后,系统先给新的进程分配资源,把原来的变量赋值到子进程中,只有少数变量域原来进程的变量不同,相当于克隆了自己。
#include <unistd.h>
#include <stdio.h>
int main()
{
pid_t fpid;
int count = 10;
fpid = fork();
printf("fpid = %d\n", fpid);
if(fpid < 0) printf("error\n"); // 如果出现错误,会返回一个负值
else if(fpid == 0) printf("i am child process, my pid = %d, count = %d\n", getpid(), ++count);
else printf("im child parent process, my pid = %d, count = %d\n", getpid(), ++count);
return 0;
/*
fpid = 3919
im child parent process, my pid = 3918, count = 11
fpid = 0
i am child process, my pid = 3919, count = 11
*/
}
vfork
函数
vfork
函数与fork
函数不同的地方是,vfork
函数会保证子进程先运行,在它调用exec
函数或者exit
函数之后父进程才可能被调度运行,子进程在调用exec
或exit
函数之前与父进程数据是共享的。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{
pid_t pid;
int count = 0;
pid = vfork();
if(pid < 0) printf("error.\n");
else if(pid == 0)
{
printf("child, count = %d\n", ++count);
exit(0);
}
else printf("father, count = %d\n", ++count);
return 0;
}
/*
child, count = 1
father, count = 2
*/
exec
函数
exec
不是单一的函数,而是一个系列。
execl(const char *path, const char *arg, ...);
此函数可以取代调用进程的内容,子进程可以调用execve
函数执行另一个程序。当调用execve
函数时,进程执行的程序完全被替换为新程序,但其进程ID并不变,只是用新程序替换了当前进程的正文、数据、堆和栈段。execve
函数族的工作过程与fork
函数完全不同,fork
函数是在复制一份原进程,而execve
函数是用第一个参数指定的程序覆盖现在有进程空间。
int execve(const char *filename, const char *argv[], const char *envp[])
第1个参数filename
是可执行程序文件名称,第2个参数argv
是需要传递给可执行程序的参数,第3个参数是环境变量数组,第2、3个参数都需要有空指针NULL为结束标识。如果调用成功则加载新的程序从启动代码开始执行,不再返回;如果调用出错则返回-1。
// mult.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char *argv[])
{
int a = atoi(argv[1]);
int b = atoi(argv[2]);
printf("%d * %d = %d\n", a, b, a*b);
return 0;
}
// main.c
#include <stdio.h>
#include <unistd.h>
int main()
{
char *argv1[] = {"mult", "2", "10", NULL};
char *envp[] = {"PATH=/root", NULL};
execve("./mult", argv1, envp);
return 0;
}
/*
2 * 10 = 20
*/
exit
函数
进程退出的方式有以下5种:
main
函数的自然返回- 调用
exit
函数 - 调用
_exit
函数 - 调用
abort
函数 - 接收到能导致进程终止的信号
Ctrl+C(SIGINT)
,Ctrl+\(SIGQUIT)
前三种是正常的终止,但无论哪种那种,进程终止时都将执行相同的关闭打开的文件操作,释放占用的内容等资源。
当程序执行到exit
和_exit
函数时,会无条件停下剩下的所有操作,清楚包括PCB在内的各种数据结构,并终止程序的运行。
#include <stdio.h>
#include <stdlib.h>
int main()
{
printf("EXIT");
exit(0);
}
#include <stdio.h>
#include <stdlib.h>
int main()
{
printf("EXIT");
_exit(0);
}
上面这两个例子,第一个会输出EXIT
,而第二个不会输出。这是因为printf
函数使用的时缓冲I/O方式,该函数在遇到\n
换行符时自动从缓冲区中将记录读出。exit(0)
会在终止进程前,将缓冲I/O内容清除掉,所以即使printf
函数里没有\n
换行符也会打印出。而_exit(0)
没有清楚缓冲区,因此不会打印。
守护进程
守护进程(daemon)是一个在后台运行的进程,不会连接到任何控制终端。它们常常在系统引导装入时启动,仅在系统关闭时才终止。UNIX系统有很多守护进程,守护进程程序的名称通常以字母“d”结尾:例如,syslogd 就是指管理系统日志的守护进程。通过ps进程查看器 ps -efj 的输出实例,内核守护进程的名字出现在方括号中,大致输出如下:
UID PID PPID PGID SID C STIME TTY TIME CMD
root 1 0 1 1 0 10:29 ? 00:00:02 /usr/lib/systemd/systemd --switched-root --system --deserialize 22
root 2 0 0 0 0 10:29 ? 00:00:00 [kthreadd]
root 4 2 0 0 0 10:29 ? 00:00:00 [kworker/0:0H]
root 6 2 0 0 0 10:29 ? 00:00:00 [ksoftirqd/0]
root 7 2 0 0 0 10:29 ? 00:00:00 [migration/0]
root 8 2 0 0 0 10:29 ? 00:00:00 [rcu_bh]
root 9 2 0 0 0 10:29 ? 00:00:01 [rcu_sched]
...
用户态的守护进程的父进程是init进程,内核态守护进程的父进程并非是init进程,
systemd:https://www.ruanyifeng.com/blog/2016/03/systemd-tutorial-commands.html
进程的调度
linux进程的切换时通过__schedule函数完成。
其实调度就是找一个已有的进程,然后进行上下文切换,并让它执行而已。