04-Linux系统编程之进程

一、进程的概述

1.什么是进程

进程:即进行中的程序,可执行文件从开始运行到结束运行这段过程就叫进程。

2.程序和进程的区别

  • 程序:存储在磁盘上、占磁盘空间、静态的。如:我们编写的C语言代码就是程序,存储在我们电脑磁盘上;

  • 进程:运行在系统上、占内存空间,动态的,包括进程的创建、调度、消亡。如:我们的代码经过编译生成了可执行文件,然后将可执行文件运行,这个运行中的程序就是进程。

3.并发和并行的区别

  • 并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行,并行是真正做到了同时进行;
  • 并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,利用了人眼的暂留现象(余晖效应),因为切换太快,人眼感觉不出来,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行(分时复用)。

4.PCB 进程控制块

4.1 PCB 的概述

PCB:进程控制块,程序运行时,内核为每个进程分配一个 PCB(进程控制块),维护进程相关的信息。Linux 内核的进程控制块是 task_struct 结构体,这个结构体里面存放着运行维护进程需要的所有资源。

  • task_struct 结构体:这个结构体里面的内容很多,但很多是涉及到系统内核的一些操作,我们不需要全部了解,后续学习可能用到和需要掌握的内容主要如下:
    1. 进程 id:系统会为每个进程分配唯一的 id,在 C 语言中用 pid_t 类型表示,其本质是一个非负整数, 进程有就绪、运行、挂起、停止等状态;
    2. 进程切换时需要保存和恢复的一些 CPU 寄存器,因为我们上面讲到了分时复用,进程间快速切换,当前这个进程暂停了以后,下次要接着当前运行,就得保存当前的工作状态;
    3. 描述虚拟地址空间的信息,描述控制终端的信息,当前工作目录(CurrentWorking Directory),umask 掩码;
    4. 文件描述符表,包含很多指向 file 结构体的指针;
    5. 和信号相关的信息,用户 id 和组 id,会话(Session)和进程组,进程可以使用的资源上限(Resource Limit)等。

4.2进程的状态

上面提到了进程包括就绪、运行、挂起、停止等状态,这里就详细介绍一下。

4.2.1进程状态三层模型
  • 三层模型包括:

    1. 等待态:进程还不具备被 CPU 调度的条件,进程正在等待 CPU 能调用的条件成立;
    2. 就绪态:进程被调度的条件成立,等待 CPU 调度;
    3. 执行态:进程的正在被 CPU 执行。
  • 三层模型示意图

这里还有一个就绪态和执行态之间的一个循环切换,这里就是我们前面提到的分时复用,不同进程来回切换,每个进程只允许执行很短的事件,一个时间片到以后就把 CPU 让出来给其它进程用,该进程就变为就绪态等待被再次调用,如此循环。

4.2.2进程状态五层模型

相比于三层模型,多了僵尸态和停止态,等待态也分为了两种情况,其示意图如下:

  • 五层模型介绍:
    1. 可中断等待态(TASK_INTERRUPTIBLE) :进程被 CPU 调度的条件还不成立,但不一定要条件成立才能被唤醒,也会因为接收到信号而提前被唤醒;
    2. 不可中断等待态(TASK_UNINTERRUPTIBLE):和可中断相比,这个必须等到条件满足才能被唤醒,不能通过信号提前唤醒;
    3. 就绪态(TASK_RUNNABLE): 表示己经准备就绪,正等待被调度;
    4. 执行态(TASK_RUNNING) : 进程正在被 CPU 执行 ;
    5. 僵尸态(TASK_ZOMBIE):表示该进程已经结束了,但是其父进程还没有调用 wait 或 waitpid 来释放该进程资源(PCB资源);
    6. 停止态(TASK_STOPPED):进程停止执行,当进程接收到 SIGSTOP,SIGTSTP,SIGTTIN,SIGTTOU 等信号的时候会进入停止态。此外,在调试期间接收到任何信号,都会使进程进入这种状态。当接收到 SIGCONT 信号,会重新回到执行态。
4.2.3查看进程状态

通过 ps -aux命令查看进程状态。

  • 命令演示
shell 复制代码
edu@edu:~$ ps -aux
USER        PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root          1  0.1  0.1 119968  6004 ?        Ss   15:43   0:01 /sbin/init splash
root          2  0.0  0.0      0     0 ?        S    15:43   0:00 [kthreadd]
root          3  0.0  0.0      0     0 ?        S    15:43   0:00 [ksoftirqd/0]
root          5  0.0  0.0      0     0 ?        S<   15:43   0:00 [kworker/0:0H]
root          7  0.0  0.0      0     0 ?        S    15:43   0:00 [rcu_sched]
root          8  0.0  0.0      0     0 ?        S    15:43   0:00 [rcu_bh]
root          9  0.0  0.0      0     0 ?        S    15:43   0:00 [migration/0]
root         10  0.0  0.0      0     0 ?        S    15:43   0:00 [watchdog/0]
root         11  0.0  0.0      0     0 ?        S    15:43   0:00 [watchdog/1]
root         12  0.0  0.0      0     0 ?        S    15:43   0:00 [migration/1]
......
  • 上面的 STAT 就是其状态信息列,参数意义如下:

    D 不可中断 Uninterruptible(usually IO)
    R 正在运行,或在队列中的进程
    S 处于休眠状态(大写S)
    T 停止或被追踪
    Z 僵尸进程
    W 进入内存交换(从内核 2.6 开始无效)
    X 死掉的进程
    < 高优先级
    N 低优先级
    s 包含子进程(小写s)

    • 复制代码
      位于前台的进程组(即与终端设备有交互的进程)
  • ps 命令常用于查看进程相关的信息,其选项如下:

    -a 显示终端上的所有进程,包括其他用户的进程
    -u 显示进程的详细状态
    -x 显示没有控制终端的进程
    -w 显示加宽,以便显示更多的信息
    -r 只显示正在运行的进程
    pstree 树状显示进程关系
    啥也不加,显示的是当前进程

二、进程号

1.进程号概述

每个进程都由一个唯一的进程号来标识,其类型为 pid_t。进程号总是唯一的,但进程号可以重用,即当一个进程终止后,其进程号就可以再次被其它使用。

  • 常用进程号分为:
  1. PID:当前进程号;
  2. PPID:当前进程的父进程号;
  3. PGID:进程组 ID。

2.获取进程号

2.1获取当前进程号

  • 函数介绍

    #include <sys/types.h> // 包含的头文件
    #include <unistd.h>
    pid_t getpid(void);
    功能:获取本进程号(PID)
    参数:

    返回值:
    本进程的进程号(PID)

2.2获取当前进程父进程号

  • 函数介绍

    #include <sys/types.h>
    #include <unistd.h>
    pid_t getppid(void);
    功能:获取调用此函数的进程的父进程号(PPID)
    参数:

    返回值:
    调用此函数的进程的父进程号(PPID)

2.3获取进程组号

  • 函数介绍

    #include <sys/types.h>
    #include <unistd.h>
    pid_t getpgid(pid_t pid);
    功能:获取进程组号(PGID)
    参数:
    pid:0或指定进程号
    返回值:
    参数为 0 时返回当前进程组号,否则返回参数指定的进程的进程组号

  • 代码演示

c 复制代码
void test01()
{
    printf("当前进程号:%d\n", getpid());
    printf("当前进程父进程号:%d\n", getppid());
    printf("当前进程组号:%d\n", getpgid(0));

    // 用于阻塞,防止进程退出
    getchar();
}
  • 运行结果

    当前进程号:4618
    当前进程父进程号:2791
    当前进程组号:4618

  • 通过 ps 命令查看当前进程相关进程号

shell 复制代码
edu@edu:~$ ps -ajx | grep a.out
 PPID    PID   PGID    SID TTY       TPGID STAT   UID   TIME COMMAND
  2791   4618   4618   2791 pts/21     4618 S+    1000   0:00 ./a.out

可以看到,几个进程号是对应的。

查看父进程号对应的哪个进程:

sell 复制代码
edu@edu:~$ ps -A | grep 2791
  2791 pts/21   00:00:00 bash

可以看到,父进程号对应的进程是 bash 解析器,因此,每个进程都不能独立启动,都必须通过一个父进程间接启动,父进程还有父进程,就这样一层层有秩序地管理进程(创建进程和回收进程资源),一直到最顶层的 1 号进程。

  • 可以通过 pstree 命令查看进程间的创建关系
shell 复制代码
systemd─┬─ManagementAgent───6*[{ManagementAgent}]
        ├─ModemManager─┬─{gdbus}
        │              └─{gmain}
        ├─NetworkManager─┬─dhclient
        │                ├─dnsmasq
        │                ├─{gdbus}
        │                └─{gmain}
        ├─VGAuthService
        ├─accounts-daemon─┬─{gdbus}
        │                 └─{gmain}
... ...

三、创建子进程

1.子进程引入

  • 为什么我们要创建子进程,看下面的例子:
c 复制代码
void test02()
{
    while (1)
    {
        printf("---------------------------1\n");
        sleep(1);
    }
    while (1)
    {
        printf("---------------------------2\n");
        sleep(1);
    }
}
  • 运行结果
shell 复制代码
---------------------------1
---------------------------1
---------------------------1
---------------------------1
---------------------------1
---------------------------1
... ...
  • 说明:可以看到,当程序执行时,永远都只能执行第一个循环,第二个循环被第一个阻塞掉了,就无法执行到,而如果我们想要同时执行两个循环,就需要用到子进程。

2.fork 创建进程

2.1fork 语法

进程是系统进行资源分配的基本单位。

子进程:系统允许一个进程创建新进程,这个新进程即为子进程,子进程还可以创建新的子进程,形成进程树结构模型。

  • fork 函数介绍

    #include <sys/types.h>
    #include <unistd.h>
    pid_t fork(void);
    功能:用于从一个已存在的进程中创建一个新进程,新进程称为子进程,原进程称为父进程
    参数:

    返回值:
    成功:在子进程中返回 0,父进程中返回子进程 ID。pid_t,为整型。
    失败:返回-1。
    失败的两个主要原因是:
    1)当前的进程数已经达到了系统规定的上限,这时 errno 的值被设置为 EAGAIN。
    2)系统内存不足,这时 errno 的值被设置为 ENOMEM。

  • 注意:父进程和子进程都会在 fork 之后运行,创建子进程后,会另外开辟一个空间,将父进程的资源拷贝一份给子进程。

2.2创建一个子进程

  • 代码演示
c 复制代码
void test03()
{
    pid_t pid = fork();
    if (pid > 0) // 父进程执行的代码
    {
        printf("父进程ID:%d\n", getpid());
        getchar(); // 阻塞,防止父进程退出
    }
    else if (pid == 0) // 子进程执行的代码
    {
        printf("子进程ID:%d\n", getpid());
        getchar(); // 阻塞,防止子进程退出
    }
}
  • 运行结果

    父进程ID:6826
    子进程ID:6827

  • 命令查看进程号

shell 复制代码
edu@edu:~$ ps -ajx | grep a.out
  2791   6826   6826   2791 pts/21     6826 S+    1000   0:00 ./a.out
  6826   6827   6826   2791 pts/21     6826 S+    1000   0:00 ./a.out
  • 当我们输入字符解堵塞

    // 明明是一个父进程一个子进程,但是只输入了一个字符就退出了
    // 难道父子进程都退出了马,通过命令查看进程状态
    edu@edu:~$ ps -ajx | grep a.out
    1 6827 6826 2791 pts/21 2791 S 1000 0:00 ./a.out

  • 说明:

    1. 可以看到还有一个 a.out 进程在运行,对应进程号,可以看到是之前的子进程,说明刚刚只是父进程退出了,然后子进程没了父进程,就没有父进程为其回收资源了,为了防止无法回收子进程资源,系统会通过1号进程来接手,这样的进程叫做孤儿进程;
    2. 我们上面创建父子进程,然后分别执行了各自的代码,这里有一个误区,就是会误以为,if (pid > 0) 条件成立里面的部分是父进程,else if (pid == 0)条件成立里面的是子进程。其实创建子进程的时候,会将父进程的整个资源,包括这里的所有代码都拷贝一份到子进程,所有这里所有的代码在父子进程中都存在,只是从逻辑角度将其划分为父进程执行的代码和子进程执行的代码。
  • 上面同时执行两个 while 循环的代码的实现

c 复制代码
void test04()
{
    pid_t pid = fork();
    if (pid > 0)
    {
        while (1)
        {
            printf("---------------------------1\n");
            sleep(1);
        }
    }
    else if (pid == 0)
    {
        while (1)
        {
            printf("---------------------------2\n");
            sleep(1);
        }
    }
}
  • 运行结果

    edu@edu:~/study/my_code$ ./a.out
    ---------------------------1
    ---------------------------2
    ---------------------------2
    ---------------------------1
    ---------------------------1
    ---------------------------2
    ---------------------------1
    ---------------------------2
    ... ...

3.父进程和子进程的关系

3.1父子进程的关系

使用 fork 函数创建的子进程是父进程的一个复制品,父进程的空间地址的内容拷贝了一份给子进程空间。

地址空间中包括:进程上下文、进程堆栈、打开的文件描述符、信号控制设定、进程优先级、进程组号等。

子进程所独有的只有它的进程号,计时器等。因此,使用 fork函数的代价是很大的。

但是为了尽可能减少空间的消耗,并不是将父进程的资源完完全全拷贝给子进程,对于一些数据在写时是独立的,读时是共享。

3.2 写时独立读时共享

  • 代码演示:读时共享
c 复制代码
void test05()
{
    int num = 10;
    pid_t pid = fork();
    if (pid > 0)
    {
        while (1)
        {
            printf("父进程:num = %d,num_id = %p\n", num, &num);
            sleep(1);
        }
    }
    else if (pid == 0)
    {
        while (1)
        {
            printf("子进程:num = %d,num_id = %p\n", num, &num);
            sleep(1);
        }
    }
}
  • 运行结果

    父进程:num = 10,num_id = 0x7ffcfcff2ee0
    子进程:num = 10,num_id = 0x7ffcfcff2ee0
    父进程:num = 10,num_id = 0x7ffcfcff2ee0
    子进程:num = 10,num_id = 0x7ffcfcff2ee0
    父进程:num = 10,num_id = 0x7ffcfcff2ee0
    子进程:num = 10,num_id = 0x7ffcfcff2ee0
    父进程:num = 10,num_id = 0x7ffcfcff2ee0
    子进程:num = 10,num_id = 0x7ffcfcff2ee0
    ... ...

  • 说明:上面是通过父子进程分别读取 num 变量的数据,同时打印 num 变量数据的地址,发现打印的数据和地址都一样,验证了上面所说的读时共享。也就是创建子进程的时候,只是将变量名拷贝了过去,但是通过变量名访问数据的时候还是访问的同一个内存地址。

  • 代码演示:写时独立

c 复制代码
void test06()
{
    int num = 10;
    pid_t pid = fork();
    if (pid > 0)
    {
        while (1)
        {
            printf("父进程:num = %d,num_id = %p\n", num, &num);
            sleep(1);
        }
    }
    else if (pid == 0)
    {
        while (1)
        {
            num++;
            printf("子进程:num = %d,num_id = %p\n", num, &num);
            sleep(1);
        }
    }
}
  • 运行结果

    父进程:num = 10,num_id = 0x7ffc89a37c30
    子进程:num = 11,num_id = 0x7ffc89a37c30
    父进程:num = 10,num_id = 0x7ffc89a37c30
    子进程:num = 12,num_id = 0x7ffc89a37c30
    父进程:num = 10,num_id = 0x7ffc89a37c30
    子进程:num = 13,num_id = 0x7ffc89a37c30
    父进程:num = 10,num_id = 0x7ffc89a37c30
    ... ...

  • 说明:可以看到,父子进程打印的两个 num 的值不一样了,说明子进程修改 num 变量了以后,num 在父子进程中已经是独立的两份了。但是这里看到的 num 的地址还是一样的,那是因为进程里面的内存地址是虚拟地址,并不是实际的物理地址,虽然父进程与子进程中变量虚拟地址是一样的,但是映射到不同的物理地址就得到了不同的数值。

3.3 printf 换行与不换行

  • 代码演示
c 复制代码
void test07()
{
    printf("hello world\n"); // 加换行
    printf("hello friend");  // 不加换行
    pid_t pid = fork();
    if (pid > 0)
    {
    }
    else if (pid == 0)
    {
    }
}
  • 运行结果

    hello world
    hello friendhello friendedu@edu:~/study/my_code$

  • 说明

    1. 现象:可以看到,加了换行符的字符串打印了一遍,但没加换行符的字符串打印了两遍;
    2. 加换行符打印一遍,是因为我们知道 printf 函数是一个库函数,库函数有缓冲区,要将数据显示在终端设备上,需要将输出到缓冲区的数据刷新到终端,换行就是其中的刷新方式之一。在创建子进程之前,字符串就已经刷新到终端了,又因为父子进程是从 fork 之后执行的,因此对有换行的这个打印不会有任何影响,直接打印一次就完事了;
    3. 但是不加换行符,没有行刷新、满刷新和强制刷新,就只剩下进程结束刷新了,又因为在进程结束前先创建了子进程,子进程会拷贝父进程资源,连同缓冲区一起拷贝了,因此父子进程结束,会分别将字符串刷新到终端设备,就出现了两个 hello friend;
    4. 如果在其下面添加一个 fflush(stdout) 强制刷新,就只会打印一次。

3.4库函数 write 输出

  • 代码演示
c 复制代码
void test08()
{
    write(1, "hello world", 11); // 加换行
    printf("hello friend");      // 不加换行
    pid_t pid = fork();
    if (pid > 0)
    {
    }
    else if (pid == 0)
    {
    }
}
  • 运行结果

    hello worldhello friendhello friendedu@edu:~/study/my_code$

  • 说明:可以看到,如果使用库函数输出,即使不加换行也只会输出一次,因为库函数是直接操作内核资源,可以直接将数据输出到终端设备,根本不需要什么缓冲区,因此也就不存在库函数的缓冲区拷贝和结束刷新。

3.5 exit 和 _exit

  • 代码演示1
c 复制代码
void test09()
{
    printf("hello friend");
    pid_t pid = fork();
    if (pid > 0)
    {
        exit(-1);
    }
    else if (pid == 0)
    {
        _exit(-1);
    }
}
  • 运行结果

    hello friendedu@edu:~/study/my_code$

  • 代码演示2

c 复制代码
void test09()
{
    printf("hello friend");
    pid_t pid = fork();
    if (pid > 0)
    {
        _exit(-1);
    }
    else if (pid == 0)
    {
        _exit(-1);
    }
}
  • 运行结果

    edu@edu:~/study/my_code$ // 啥也没有

  • 代码演示3

c 复制代码
void test09()
{
    printf("hello friend");
    pid_t pid = fork();
    if (pid > 0)
    {
        exit(-1);
    }
    else if (pid == 0)
    {
        exit(-1);
    }
}
  • 运行结果

    hello friendhello friendedu@edu:~/study/my_code$

  • 说明:

    1. 上面演示的三种情况,可以发现,通过 _exit(-1) 退出进程的时候,不打印,通过exit(-1)会打印;
    2. 因为_exit(-1)是系统调用,作用是退出进程,不会刷新缓冲区;
    3. exit(-1)是库函数,作用是退出进程,会刷新缓冲区。

4.父子进程运行顺序

  • 代码演示
c 复制代码
void test10()
{
    pid_t pid = fork();
    if (pid > 0)
    {
        printf("父进程运行了\n");
    }
    else if (pid == 0)
    {
        printf("子进程运行了\n");
    }
}
  • 运行结果

    edu@edu:~/study/my_code ./a.out 父进程运行了 子进程运行了 edu@edu:~/study/my_code ./a.out
    父进程运行了
    子进程运行了

  • 说明:

    1. 上面运行的结果可以看出,我们多次调用,都是父进程先执行,子进程后执行,那是因为这里只有父进程先执行了才能调用 fork 创建子进程,因此这里演示肯定是父进程先执行,不然哪来的子进程;
    2. 但是我们站在原理的角度出发,父子进程是分别独立的进程,它们之间谁先运行要看谁先抢占到 CPU 资源,因此谁先运行是不确定的。
相关推荐
HLC++11 小时前
Linux的进程间通信
android·linux·服务器
华清远见IT开放实验室13 小时前
实验室建设案例 | 石家庄科技信息职业学院嵌入式实验室——从底层硬件到系统应用,一所应用型高校的嵌入式人才培养这样落地
linux·arm开发·stm32·嵌入式硬件·高校·实验室建设
groundhappy15 小时前
idalib安装和codex ida-mcp配置
linux·开发语言·python
通信小小昕16 小时前
Ubuntu 26.04 中文输入法安装
linux·运维·ubuntu
张小姐的猫17 小时前
【Linux】网络编程 —— HTTP协议(上)
linux·运维·服务器·网络·http·单例模式·策略模式
栩栩云生17 小时前
AI 写代码犯的错,早被写进了错题集
linux·安全·ai编程
imc.1119 小时前
linux基础IO
linux·运维·服务器
BelongPanda20 小时前
Linux Nginx 纯手动 Let‘s Encrypt 泛域名证书配置教程
linux·nginx
酷可达拉斯21 小时前
Linux操作系统-shell编程(0)
linux·运维·服务器·python·云计算
2301_7779983421 小时前
Linux中断机制:操作系统如何高效运行
linux·运维·服务器