前言
- 程序:编译后产生的,格式为ELF的,存储于硬盘的文件
- 进程:程序中的代码和数据,被加载到内存中运行的过程
.o文件是我们编译源代码产生的文件,ELF 格式为.o文件提供了结构化的存储方式。在linux中一般编译后的文件都是按ELF格式存储。程序是静态的,进程是程序被分配适当的计算机资后在计算机中动态的运行。
在之前的文章linux中的进程以及进程管理-CSDN博客,我们介绍了如何查看进程信息的命令,还介绍了进程中的信号。这里再次补充几点:
进程号PID
每个进程都有一个唯一的身份证号——PID,当我们用ps -ef列出当前的所有进程以及进程相关的信息时,我们发现进程号不是连续的,看上去就像是有些前面进程号还未使用,后面的一些进程号就被使用了。
原因是:操作系统为每个新创建的进程分配一个唯一的PID。PID通常是一个非负整数,范围从1到一个操作系统定义的最大值。当PID达到最大值后,再创建的新进程将从最小未使用的PID开始重新分配。
当一个进程终止时,其PID将被释放,进入可用PID池中。操作系统会在创建新进程时,从可用PID池中选择一个未被使用的PID进行分配。这意味着已经终止的进程的PID可能会被随后创建的进程再次使用。所以有些进程终止后,对应的进程号就被回收了,我们看到的所有进程对应进程号就是断断续续的了。
task_struct结构体
task_struct
是用于描述进程的核心数据结构。它包含了一个进程的所有重要信息,并且在进程的生命周期内保持更新。我们想要获取进程相关信息往往从这里得到。
进程状态
进程是动态的活动的实体,因此会有很多种运行状态:一会儿睡眠、一会儿暂停、一会儿又继续执行。下图给出Linux进程从被创建(生)到被回收(死)的全部状态,以及这些状态发生转换时的条件:
就绪态和执行态之间的相互转换
就绪态
进程处于就绪态时,表示它已经获得了所有运行所需的资源(如内存、I/O设备等),并且等待CPU的分配来执行它的指令。此时,进程已经通过创建或唤醒进入队列,但暂时没有被分配到CPU。进程在就绪队列中排队等待CPU资源分配。多个进程可以同时处于就绪态,等待CPU的调度。
就绪态——>执行态
在Linux中,进程从就绪态转换到执行态的过程由操作系统的调度器管理。调度器会根据调度算法(如CFS,完全公平调度器)选择一个处于就绪态的进程,并将其状态切换为执行态。调度器在选择下一个执行的进程时,会考虑进程的优先级、等待时间、进程类型(实时进程或普通进程)等因素。较高优先级或等待时间较长的进程更有可能被选中。每个被选中执行的进程通常会被分配一个时间片。
执行态
进程进入执行态时,表示它正在被CPU执行。此时,CPU正在运行该进程的代码,并且系统的资源被分配给该进程。单CPU系统中,系统最多只能有一个进程处于执行状态,进程一旦进入执行态,CPU将开始执行其指令,直到进程被抢占、主动让出CPU、进入等待状态或执行完成。
执行态——>就绪态
在Linux中,普通进程执行时,通常有一个固定的时间片,当进程的时间片用完后,调度器会强制中断该进程的执行,并将其状态切换回就绪态,然后选择另一个进程执行。被抢占的进程会回到就绪队列中等待下一个调度周期。当然还可能出现,更高优先级的进程需要被执行时,重点当前的进程,并将CPU资源分配给另一个进程。
睡眠状态
睡眠态(Sleep State)指的是进程或线程在等待某些事件或资源时的状态。在这个状态下,进程不会占用CPU资源,而是被操作系统挂起,直到它所等待的事件发生或条件满足时才会被唤醒。
浅睡眠(可中断睡眠)
- 定义:当进程进入可中断睡眠态时,它会等待某些事件的发生,比如I/O操作的完成。这个状态是可中断的,即进程可以被某些信号唤醒。
- 典型场景:例如,进程在执行
read()
系统调用等待数据时,会进入可中断睡眠态。如果在等待期间收到信号(如SIGINT
),进程可以被唤醒并处理这个信号。
深睡眠(不可中断睡眠)
- 定义:当进程进入不可中断睡眠态时,它正在等待某些关键事件或资源的获取,这种睡眠态不能被信号打断。进程在此状态下不会响应信号,直到它所等待的条件满足或资源可用。
- 典型场景:例如,进程等待某些硬件操作完成时可能会进入不可中断睡眠态。如果内核中发生了某些需要长时间等待的操作(如磁盘I/O),进程可能会在此状态下。
从睡眠态到其他状态的转换
-
可中断睡眠到就绪态:当进程在可中断睡眠态下等待的事件发生(例如I/O操作完成),它会被唤醒,并将其状态从睡眠态切换到就绪态。调度器随后会选择这个进程来执行。
-
不可中断睡眠到就绪态:进程在不可中断睡眠态下等待的事件(如硬件操作完成)发生时,进程会被唤醒并切换到就绪态,等待调度器分配CPU时间。
停止状态
进程的停止状态(Stopped State)是指进程被操作系统暂停执行,无法继续运行,直到它被显式地恢复。
停止状态可以很方便地让我们对于进程进行调试,以此来查看进程的相关信息。一方面我们可以通过给进程发送信号来控制进程的停止和恢复。另一方面某些调试工具在调试的时候可以暂停进程的执行,如 gdb可以暂停进程的执行,进入停止状态,以便进行调试和检查。
僵尸态和死亡态
- 当进程退出时(不管是主动退出还是被动退出),都会进入僵尸态(EXIT_ZOMBIE),僵尸态下的进程无法运行,也无法被调度,但其所占据的系统资源未被释放。僵尸态是进程的必经状态,编程过程中不能避免僵尸态,但要避免进程长时间处于僵尸态。
- 僵尸态进程要等待其父进程调用
wait()
或waitpid()
来获取子进程的退出状态信息后,才能变成死亡态(EXIT_DEAD)。如果父进程没有及时处理僵尸子进程,子进程会保持在僵尸态。
长时间处于僵尸态的危害:僵尸进程虽然不占用CPU和内存,但会占用进程表项。如果系统中存在大量僵尸进程,可能会导致进程表项耗尽,从而无法创建新进程。
僵尸态存在的意义:当一个进程终止时,操作系统会保留其退出状态信息(即退出码和信号信息)。这个信息对于父进程来说是重要的,因为它可以用来判断子进程是否成功完成其任务,或者是否遇到了错误。
进程创建(fork)
创建进程及如何区分进程
调用 fork()
之后,新创建的子进程会从调用 fork()
的那一行代码开始执行。这意味着在 fork()
调用之后,父进程和子进程都将继续执行相同的代码,唯一的区别在于 fork()
的返回值不同。
fork()
调用后,父进程和子进程是并发执行的,它们的执行顺序不确定。这意味着在父进程和子进程之间,谁先执行完毕、谁先执行下一行代码,完全取决于调度器的决定。那现在我们两个进程用的都是这一段代码,我们如何来写fork之后的代码从而来分别对这两个进程发号施令呢?答案就是根据我们fork返回值的不同,来为两个进程安排不同的执行逻辑。
由于这里是并发执行,所以下面代码例子,是先输出child process这一行,还是先输出parent process这行,我们是不得而知的。
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main() {
pid_t pid = fork();
if (pid < 0) {
// fork失败
perror("fork failed");
return 1;
} else if (pid == 0) {
// 子进程执行的代码
printf("This is the child process. PID: %d\n", getpid());
} else {
// 父进程执行的代码
printf("This is the parent process. PID: %d\n", getpid());
}
return 0;
}
进程创建的详细内容(空间复制)
当一个进程在调用 fork()
之前,声明并赋值了很多变量时,fork()
生成的新进程(子进程)将会继承这些变量及其值。
fork()
调用后,子进程是父进程的一个几乎完全的副本,包括:
- 所有的全局变量和局部变量:在
fork()
调用之前父进程中声明和赋值的变量,子进程都会完整继承。 - 变量的值:子进程中的变量值在
fork()
后会与父进程中的变量值完全相同,因为子进程在fork()
时复制了父进程的整个地址空间。
复制了整个地址空间意味着,将原进程中栈空间,堆空间,代码段等都复制了过来,复制之后,代码是从fork调用之后开始执行。
之后两个进程的地址空间是完全独立的,对其中一个存储内容进行更改不会影响到另一个进程对应的地址空间存放内容。我们可以通过下面这个例子来验证。
展示fork创建的进程内存独立例子
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main() {
int x = 10; // 在fork之前声明并赋值的变量
pid_t pid = fork();
if (pid < 0) {
// fork失败
perror("fork failed");
return 1;
} else if (pid == 0) {
// 子进程:修改x的值
x = 20;
printf("Child process: x = %d\n", x);
} else {
sleep(1);
// 父进程:x的值不受子进程的影响
printf("Parent process: x = %d\n", x);
}
return 0;
}
上面我们使用fork创建了新进程之后,我们通过fork的返回值区分了两个进程,并且为两个进程安排了不同的执行逻辑。在新进程中对变量x进行赋值操作,在旧进程中我们输出变量x,发现新进程中赋值操作对另一个进程地址空间没有影响。(这里由于两个进程并发执行,为了确保父进程的输出语句在子进程赋值之后执行,所以加上了sleep(1),从而让我们实验更加严谨。)
多说一句,实际编程中,fork和exec族函数几乎绑定使用,这就使得我们可以在区分父子进程之后,使用exec单独让子进程执行新的程序,不会像这里展示的将父子进程的代码逻辑写在同一个文件中,下一篇文章会介绍exec族函数使用。
进程退出(exit,_exit,main 函数的return 0)
exit 和 return 0的区别
exit( int status )是一个标准库函数,用于终止当前进程并返回一个状态码给操作系统。status
参数可以用来指示程序的终止状态,一般约定为 0
表示正常退出,非零值表示异常退出。
适用于在程序的任意位置终止程序,特别是在 main()
函数之外,更加灵活。常用于需要明确控制退出代码的场景。main ()
return 0 在main () 函数中执行时,可以认为等价于exit(0),并且仅仅在main函数中才具有退出程序的作用。
exit 和 _exit 的区别
退出处理函数
C 语言中,退出处理函数通常是通过 atexit()
函数来设置的。atexit()
用于注册在程序正常退出时自动调用的函数。这些函数通常用于执行清理操作,如释放资源、关闭文件或执行其他必要的终结操作。简单来讲,就是你想要在程序退出的时候,执行一些函数,你就可以将这个函数传递给atexit () 函数。这样就能在即将退出程序的时候,程序自动帮你执行这些函数。
下面的代码中,我们想要在退出程序的时候自动执行cleanup1和cleanup2函数,就可以利用atexit函数来将这两个函数都设置为退出处理函数。
调用顺序: 如果注册了多个退出处理函数,它们会以逆序调用,即最后注册的函数会首先被调用。这是因为它们被组织在一个栈中。
#include <stdio.h>
#include <stdlib.h>
// 定义退出处理函数
void cleanup1(void) {
printf("Cleanup function 1 called.\n");
}
void cleanup2(void) {
printf("Cleanup function 2 called.\n");
}
int main() {
// 注册退出处理函数
atexit(cleanup1);
atexit(cleanup2);
printf("Program is running...\n");
// 正常退出程序
exit(0);
}
写为exit(0) ;
写为_exit(0) ;
从这个运行结果我们可以看出来,_exit()它与 exit() 不同,_exit() 直接终止程序而不调用任何已注册的退出处理函数
清理I/O缓冲
I/O缓冲区我们在之前讲到文件I/O哪里有详细谈过,具体来说可以参考文章:读写二进制文件的fread和fwrite函数,文件位置相关函数(fseek,ftell),格式化读写文件函数,标准I/O缓冲区的类型及相关函数(fflash,setvbuf)_ftell 4096-CSDN博客
下面我们使用一个例子来展示一下exit 和 _exit 对于退出程序是否清理I/O缓冲的区别。
写为exit(0) ;
#include <stdio.h> #include <stdlib.h> int main() { printf("huan chong xinxi"); exit(0); }
写为_exit(0) ;
#include <stdio.h> #include <stdlib.h> #include <unistd.h> int main() { printf("huan chong xinxi"); _exit(0); }
代码解释:printf函数是向标准输出流(stdout
)中写入数据,标准输出流对应的缓冲区类型是行缓冲区。我们知道行缓冲区的特点是读取到换行符会立即将数据从缓冲区写入到标准输出流中,这里我们想要输出的数据中没有包含换行符,所以这个数据会暂时留在缓冲区中,不会立即输出。
当然还有通过exit/main函数中的return 0结束程序的时候,会将缓冲区中残留的数据冲刷,清理I/O缓冲。写为exit(0)的时候可以发现最后输出了I/O缓冲区中的数据,写为_exit(0)的时候,我们发现I/O缓冲区中的数据并没有被冲刷出来。
通过这个例子我们就理解了清理I/O缓冲方面,exit和_exit函数的区别。