【linux】进程管理

进程

对于操作系统来说,进程是一个很重要的抽象,进程的抽象是为了提高CPU的利用率,任何的抽象都需要一个物理基础,进程的物理基础便是程序。

程序和进程的区别:

  1. 进程是操作系统分配内核、CPU时间片等资源的基本单位。
  2. 程序是完成特定任务的指令集合,包含可运行的一堆CPU指令和响应的数据等信息;进程不仅包含代码段等信息,还有很多运行时需要的资源。
  3. 进程是一段执行中的程序,进程包含了可执行代码的代码段,存放变量、函数、返回值等信息的用户栈、存放进程相关数据的数据段,用于内核中进程切换的内核栈,以及动态分配内存的堆等。
  4. 进程是并发执行的一个实体,实现对CPU的虚拟化的核心技术是上下文切换以及进程调度

进程分类:

根据运行模式分为:

  1. 核心态进程
  2. 用户态进程

用户态进程要执行一些核心态指令,会产生系统调用。

根据进程特点:

  1. 交互进程(shell)
  2. 批处理进程
  3. 守护进程

根据进程状态:

  1. 守护进程(守护进程的父进程都是init进程)
  2. 孤儿进程(父进程退出,子进程被init收养)
  3. 僵尸进程(进程结束,但没有释放内存)

进程描述符

进程所拥有的资源抽象为进程控制块,即进程的资源及属性保存在进程控制块(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中,一个进程的创建其实是包括两个步骤的:

  1. 创建(派生)一个新进程(fork)
  2. 执行一个新程序(将新程序加载到内存中执行)(exec)

在unix中,这两个步骤是分开的。

所以在创建子进程执行一个新程序通常会先fork(),然后exec()

进程控制函数

  1. 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
    */
}
  1. vfork函数

vfork函数与fork函数不同的地方是,vfork函数会保证子进程先运行,在它调用exec函数或者exit函数之后父进程才可能被调度运行,子进程在调用execexit函数之前与父进程数据是共享的。

#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
*/
  1. 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
*/
  1. 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函数完成。

其实调度就是找一个已有的进程,然后进行上下文切换,并让它执行而已。

线程