【Linux】学习记录_8_进程

8 进程

Linux中是通过检查表记录与进程相关的信息的,进程表就是一个数据结构, 它把当前加载在内存中的所有进程的有关信息保存在一个表中。包括进程的PID、进程的状态、命令字符串和其他一些ps命令输出的各类信息。

8.1 进程ID

inux系统中的每个进程都都会被分配一个唯一的数字编号,称为进程ID(ProcessID,通常也被简称为PID)。PID是16位的正整数, 默认取值范围是从2到32768(可以修改),由Linux在启动新进程的时候自动依次分配,当进程被启动时, 系统将按顺序选择下一个未被使用的数字作为它的PID,当PID的数值达到最大时, 系统将重新选择下一个未使用的数值,新的PID重新从2开始,这是因为PID数字为1的值一般是为特殊进程init保留, 即系统在运行时就存在的第一个进程,init进程负责管理其他进程。

8.1.2 父进程ID

任何进程(除init进程)都是由另一个进程启动,该进程称为被启动进程的父进程, 被启动的进程称为子进程,父进程号无法在用户层修改。父进程的进程号(PID)即为子进程的父进程号(PPID)。 用户可以通过调用getppid()函数来获得当前进程的父进程号。

8.3 程序与进程

  • 程序:程序(program)是一个普通文件,是为了完成特定任务而准备好的指令序列与数据的集合

  • 进程:程序执行的具体实例,比如一个可执行文件,在执行的时候, 它就是一个进程,直到该程序执行完毕

程序并不能单独执行,只有将程序加载到内存中,系统为他分配资源后才能够执行, 这种执行的程序称之为进程,也就是说进程是系统进行资源分配和调度的一个独立单位, 每个进程都有自己单独的地址空间。程序变进程的步骤:

  1. 查找命令对应程序文件的位置。

  2. 使用 fork()函数为启动一个新进程。

  3. 在新进程中调用 exec族函数装载程序文件,并执行程序文件中的main()函数。

8.4 进程状态
进程状态 说明
R 运行状态。严格来说,应该是“可运行状态”,即表示进程在运行队列中,处于正在执行或即将运行状态,只有在该状态的进程才可能在 CPU 上运行,而同一时刻可能有多个进程处于可运行状态。
S 可中断的睡眠状态。处于这个状态的进程因为等待某种事件的发生而被挂起,比如进程在等待信号
D 不可中断的睡眠状态。通常是在等待输入或输出(I/O)完成,处于这种状态的进程不能响应异步信号
T 停止状态。通常是被shell的工作信号控制,或因为它被追踪,进程正处于调试器的控制之下
Z 退出状态。进程成为僵尸进程
X 退出状态。进程即将被回收
s 进程是会话其首进程
l 进程是多线程的
+ 进程属于前台进程组
< 高优先级任务
8.5 进程状态转换

进程是动态的活动的实例,这其实指的是进程会有很多种运行状态, 一会儿睡眠、一会儿暂停、一会儿又继续执行。虽然Linux操作系统是一个多用户多任务的操作系统, 但对于单核的CPU系统来说,在某一时刻,只能有一个进程处于运行状态(此处的运行状态指的是占用CPU), 其他进程都处于其他状态,等待系统资源,各任务根据调度算法在这些状态之间不停地切换。 但由于CPU处理速率较快,使用户感觉每个进程都是同时运行。

下图展示了Linux进程从被启动到退出的全部状态,以及这些状态发生转换时的条件。

proces007

8.6 启动新进程

Linux中启动一个进程有多种方法

  • system()函数:相对简单,但效率低下,而且具有不容忽视的安全风险

  • fork()函数:复杂了很多,但是提供了更好的弹性、效率和安全性。

8.6.1 fork()进程实验

init进程可以启动一个子进程, 它通过fork()函数从原程序中创建一个完全分离的子进程, 这只是init进程启动子进程的第一步,fork()函数的基础功能就是启动一个子进程 。

在父进程中的fork()调用后返回的是新的子进程的PID。 新进程将继续执行,就像原进程一样,不同之处在于,子进程中的fork()函数调用后返回的是0, 父子进程可以通过返回的值来判断究竟谁是父进程,谁是子进程。

proces010

fork()函数用于从一个已存在的进程中启动一个新进程, 新进程为子进程,原进程为父进程。 使用fork()函数的本质是将父进程的内容复制一份,正如细胞分裂一样, 得到的是几乎两个完全一样的细胞,因此这个启动的子进程基本上是父进程的一个复制品, 但子进程与父进程有不一样的地方,它们的联系与区别简单列举如下:

子进程与父进程一致的内容:

  • 进程的地址空间。

  • 进程上下文、代码段。

  • 进程堆空间、栈空间,内存信息。

  • 进程的环境变量。

  • 标准 IO 的缓冲区。

  • 打开的文件描述符。

  • 信号响应函数。

  • 当前工作路径。

子进程独有的内容:

  • 进程号 PID。 PID 是身份证号码,是进程的唯一标识符。

  • 记录锁。父进程对某文件加了把锁,子进程不会继承这把锁。

  • 挂起的信号。这些信号是已经响应但尚未处理的信号,也就是”悬挂”的信号, 子进程也不会继承这些信号。

因为子进程几乎是父进程的完全复制,所以父子两个进程会运行同一个程序, 但是这种复制有一个很大的问题,那就是资源与时间都会消耗很大, 当发出fork()系统调用时,内核原样复制父进程的整个地址空间并把复制的那一份分配给子进程。 这种行为是非常耗时的,因为它需要做一些事情:

  • 为子进程的页表分配页面。

  • 为子进程的页分配页面。

  • 初始化子进程的页表。

  • 把父进程的页复制到子进程相应的页中

在fork()启动新的进程后,子进程与父进程开始并发执行,谁先执行由内核调度算法来决定。 fork()函数如果成功启动了进程,会对父子进程各返回一次,其中对父进程返回子进程的PID,对子进程返回0; 如果fork()函数启动子进程失败,它将返回-1。 失败通常是因为父进程所拥有的子进程数目超过了规定的限制(CHILD_MAX), 此时errno将被设为EAGAIN。如果是因为进程表里没有足够的空间用于创建新的表单或虚拟内存不足, errno变量将被设为ENOMEM。

#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
    pid_t result;
    printf("This is a fork demo!\n\n");

    result = fork();
    if(result == -1)
    {
        printf("Fork error\n");
    }
    else if (result == 0)    /*返回值为 0 代表子进程*/
    {
        printf("The returned value is %d, In child process!! My PID is %d\n\n", result, getpid());

    }
    else    /*返回值大于 0 代表父进程*/
    {
        printf("The returned value is %d, In father process!! My PID is %d\n\n", result, getpid());
    }
     return result;
 }
  • 调用fork函数后系统就会启动一个子进程,并且子进程与父进程执行的内容是一样的(代码段), 可以通过返回值result判断fork()函数的执行结果。

  • 如果result的值为-1,代表fork()函数执行出错。

  • 如果返回值为0,则表示此时执行的代码是子进程,那么就打印返回的结果、“In child process!!”与子进程的PID, 进程的PID通过getpid()函数获取得到。

  • 如果返回值大于0,则表示此时执行的代码是父进程,同样也打印出返回的结果、”In father process!!”与父进程的PID。

8.6.2 exec系列函数进程实验

事实上,使用fork()函数启动一个子进程是并没有太大作用的,因为子进程跟父进程都是一样的, 子进程能干的活父进程也一样能干。于是诞生了exec系列函数,这个系列函数主要是用于替换进程的执行程序, 它可以根据指定的文件名或目录名找到可执行文件,并用它来取代原调用进程的数据段、代码段和堆栈段, 在执行完之后,原调用进程的内容除了进程号外,其他全部被新程序的内容替换。 另外,这里的可执行文件既可以是二进制文件,也可以是Linux下任何可执行脚本文件。 简单来说就是覆盖进程,举个例子,A进程通过exec系列函数启动一个进程B,此时进程B会替换进程A, 进程A的内存空间、数据段、代码段等内容都将被进程B占用,然后进程A将不复存在。

8.6.2.1 实验分析
int execl(const char *path, const char *arg, ...)

execl()函数用于执行参数path字符串所代表的文件路径(必须指定路径), 接下来是一系列可变参数,它们代表执行该文件时传递过去的 argv[0]、argv[1]… argv[n] , 最后一个参数必须用空指针NULL作为结束的标志。

int main(void)
{
    int err;
    printf("this is a execl function test demo!\n\n");
    err = execl("/bin/ls", "ls", "-la", NULL);
    if (err < 0)
        printf("execl fail!\n\n");

    printf("Done!\n\n");
}

exec族实际包含有 6 个不同的 exec 函数,它们功能一样,主要是传参的形式不同, 函数原型分别如下:

int execl(const char *path, const char *arg, ...)
int execlp(const char *file, const char *arg, ...)
int execle(const char *path, const char *arg, ..., char *const envp[])
int execv(const char *path, char *const argv[])
int execvp(const char *file, char *const argv[])
int execve(const char *path, char *const argv[], char *const envp[])

这些函数可以分为两大类, execl、execlp和execle传递给子程序的参数个数是可变的, 如“ls -la”示例中,“-la”为子程序“ls”的参数。 execv、execvp和execve通过数组去装载子程序的参数,无论那种形式,参数都以一个空指针NULL结束,

总结来说,可以通过它们的后缀来区分他们的作用:

  • 名称包含l字母的函数(execl、execlp和execle)接收参数列表“list”作为调用程序的参数。

  • 名称包含p字母的函数(execvp和execlp)可接受一个程序名作为参数,它可使用相对路径; 名字不包含p字母要求绝对路径。

  • 名称包含v字母的函数(execv、execvp 和 execve)的子程序参数通过数组“vector”装载。

  • 名称包含e字母的函数(execve 和 execle)比其它函数多接收一个指明环境变量列表的参数, 并且可以通过参数envp传递字符串数组作为新程序的环境变量, 这个envp参数的格式应为一个以 NULL 指针作为结束标记的字符串数组, 每个字符串应该表示为“environment = virables”的形式。

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

/* 选择一个要测试的函数示例,把对应的宏为1即可 */
#define     EXECL   1
#define     EXECLP  0
#define     EXECLE  0
#define     EXECV   0
#define     EXECVP  0
#define     EXECVE  0

#if ((EXECL | EXECLP | EXECLE | EXECV | EXECVP | EXECVE) == 0)
#error "must choose a function to compile!"
#endif

#if ((EXECL & EXECLP) || (EXECL & EXECLE) || (EXECL & EXECV) || (EXECL & EXECVP) || (EXECL & EXECVE) || \
    (EXECLP & EXECLE) || (EXECLP & EXECV) || (EXECLP & EXECVP) || (EXECLP & EXECVE) || \
    (EXECLE & EXECV) || (EXECLE & EXECVP) || (EXECLE & EXECVE) || \
    (EXECV & EXECVP) || (EXECV & EXECVE) || \
    (EXECVP & EXECVE))
#error "have and can only choose one of the functions to compile"
#endif

#if EXECL
int main(void)
{
    int err;
    printf("this is a execl function test demo!\n\n");
    /*
        execl()函数用于执行参数path字符串所代表的文件路径(必须指定路径),
        接下来是一系列可变参数,它们代表执行该文件时传递过去的 ``argv[0]、argv[1]… argv[n]`` ,
        最后一个参数必须用空指针NULL作为结束的标志。
    */
    err = execl("/bin/ls", "ls", "-la", NULL);
    if (err < 0)
        printf("execl fail!\n\n");
    printf("Done!\n\n");
}
#endif

#if EXECLP
int main(void)
{
    int err;
    printf("this is a execlp function test demo!\n\n");
    /*
        与execl的差异是,execlp()函数会从PATH环境变量所指的目录中
        查找符合参数file的文件名(不需要指定完整路径)。
    */
    err = execlp("ls", "ls", "-la", NULL);
    if (err < 0)
        printf("execlp fail!\n\n");
    printf("Done!\n\n");
}
#endif

#if EXECLE
int main(void)
{
    int err;
    char *envp[] = {"/bin", NULL};
    printf("this is a execle function test demo!\n\n");
    /*
        与execl的差异是,execle()函数会通过最后一个参数(envp)指定新程序使用的环境变量。
    */
    err = execle("/bin/ls", "ls", "-la", NULL, envp);
    if (err < 0)
        printf("execle fail!\n\n");
    printf("Done!\n\n");
}
#endif

#if EXECV
int main(void)
{
    int err;
    char *argv[] = {"ls", "-la", NULL};

    printf("this is a execv function test demo!\n\n");
    /*
       与execl的差异是,直接使用数组来装载要传递给子程序的参数/   
    */
    err = execv("/bin/ls", argv);
    if (err < 0)
        printf("execv fail!\n\n");
    printf("Done!\n\n");
}
#endif

#if EXECVP
int main(void)
{
    int err;
    char *argv[] = {"ls", "-la", NULL};
    printf("this is a execvp function test demo!\n\n");
    /*
       是execlp,execv函数的结合体
    */
    err = execvp("ls", argv);
    if (err < 0)
        printf("execvp fail!\n\n");
    printf("Done!\n\n");
}
#endif

#if EXECVE
int main(void)
{
    int err;
    char *argv[] = {"ls", "-la", NULL};
    char *envp[] = {"/bin", NULL};
    printf("this is a execve function test demo!\n\n");
    /*
       是execle,execv函数的结合体
    */
    err = execve("/bin/ls", argv, envp);
    if (err < 0)
        printf("execve fail!\n\n");
    printf("Done!\n\n");
}
#endif

8.7 终止进程

在Linux系统中,进程终止的常见方式有5种, 可以分为正常终止与异常终止:

正常终止

  • 从main函数返回。

  • 调用exit()函数终止。

  • 调用_exit()函数终止。

异常终止

  • 调用abort()函数异常终止。

  • 由系统信号终止。

在Linux系统中,exit()函数定义在stdlib.h中,而_exit()定义在unistd.h中, exit()和_exit()函数都是用来终止进程的,当程序执行到exit()或_exit()函数时, 进程会无条件地停止剩下的所有操作,清除包括PCB在内的各种数据结构,并终止当前进程的运行。 不过这两个函数还是有区别的,具体下图所示。

proces014

_exit()函数的作用最为简单:直接通过系统调用使进程终止运行, 当然,在终止进程的时候会清除这个进程使用的内存空间,并销毁它在内核中的各种数据结构;

exit()函数则在这些基础上做了一些包装,在执行退出之前加了若干道工序: 比如exit()函数在调用exit系统调用之前要检查文件的打开情况, 把文件缓冲区中的内容写回文件,这就是“清除I/O缓冲”。

在 Linux 的标准函数库中,有一种被称作“缓冲 I/O(buffered I/O)”操作, 其特征就是对应每一个打开的文件,在内存中都有一片缓冲区。每次读文件时, 会连续读出若干条记录,这样在下次读文件时就可以直接从内存的缓冲区中读取; 同样,每次写文件的时候,也仅仅是写入内存中的缓冲区,等满足了一定的条件(如达到一定数量或遇到特定字符等), 再将缓冲区中的内容一次性写入文件。

这种技术大大增加了文件读写的速度,但也为编程带来了一些麻烦。 比如有些数据,程序认为已经被写入文件中,实际上因为没有满足特定的条件,它们还只是被保存在缓冲区内, 这时用_exit()函数直接将进程关闭,缓冲区中的数据就会丢失。 因此,若想保证数据的完整性,就一定要使用exit()函数。

不管是那种退出方式,系统最终都会执行内核中的同一代码,这段代码用来关闭进程所用已打开的文件描述符, 释放它所占用的内存和其他资源。

8.8 等待进程

在Linux中,当我们使用fork()函数启动一个子进程时,子进程就有了它自己的生命周期并将独立运行, 在某些时候,可能父进程希望知道一个子进程何时结束,或者想要知道子进程结束的状态, 甚至是等待着子进程结束,那么我们可以通过在父进程中调用wait()或者waitpid()函数让父进程等待子进程的结束。

当一个进程调用了exit()之后,该进程并不会立刻完全消失, 而是变成了一个僵尸进程。僵尸进程是一种非常特殊的进程,它已经放弃了几乎所有的内存空间, 没有任何可执行代码,也不能被调度,仅仅在进程列表中保留一个位置, 记载该进程的退出状态等信息供其他进程收集,除此之外,僵尸进程不再占有任何内存空间。 那么无论如何,父进程都要回收这个僵尸进程,因此调用wait()或者waitpid()函数其实就是将这些僵尸进程回收, 释放僵尸进程占有的内存空间,并且了解一下进程终止的状态信息。

相关推荐

  1. 嵌入式学习记录——进程

    2024-04-21 21:30:01       40 阅读
  2. Linux深入学习 - 进程

    2024-04-21 21:30:01       35 阅读

最近更新

  1. docker php8.1+nginx base 镜像 dockerfile 配置

    2024-04-21 21:30:01       98 阅读
  2. Could not load dynamic library ‘cudart64_100.dll‘

    2024-04-21 21:30:01       106 阅读
  3. 在Django里面运行非项目文件

    2024-04-21 21:30:01       87 阅读
  4. Python语言-面向对象

    2024-04-21 21:30:01       96 阅读

热门阅读

  1. Canvas图形编辑器-数据结构与History(undo/redo)

    2024-04-21 21:30:01       37 阅读
  2. STL基础(四)非类型模板参数

    2024-04-21 21:30:01       44 阅读
  3. 【创建git仓库并关联github账户】

    2024-04-21 21:30:01       41 阅读
  4. Python模块之logging

    2024-04-21 21:30:01       97 阅读
  5. React中 useReducer的使用

    2024-04-21 21:30:01       38 阅读
  6. ubuntu在docker容器中安装strongswan

    2024-04-21 21:30:01       35 阅读
  7. 一些linux命令

    2024-04-21 21:30:01       33 阅读