进程创建
1.fork函数初识
在Linux上一篇文章进程概念详解我们提到了在linux中fork函数是非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
返回值
自进程中返回0,父进程返回子进程id,出错返回-1
1.1那么fork创建子进程时,操作系统都做了什么呢?
当在操作系统中调用 fork 函数创建子进程时,操作系统会执行以下一系列步骤:
复制父进程: 操作系统会创建一个新的子进程,该子进程是父进程的一个副本。子进程将会继承父进程的代码、数据、堆栈、文件描述符等信息。
分配进程ID(PID): 操作系统会为新的子进程分配一个唯一的进程ID(PID)。父进程和子进程都有不同的PID。
复制文件描述符表: 子进程会复制父进程的文件描述符表。这意味着子进程可以访问与父进程相同的打开文件、网络连接等资源。
复制内存映像: 子进程会复制父进程的内存映像,包括代码段、数据段和堆栈。这样,子进程和父进程可以开始在不同的内存空间中执行。
创建唯一的资源: 操作系统会为子进程创建一些唯一的资源,如计时器、信号处理等。
设置返回值: 在父进程和子进程中,fork 函数会返回不同的值。在父进程中,它返回子进程的PID。在子进程中,它返回0,表示这是子进程。
开始执行子进程: 子进程从 fork 函数调用的位置开始执行。这意味着子进程会执行与父进程相同的代码。
总之,fork 函数通过创建一个几乎与父进程相同的子进程,允许父进程和子进程在独立的环境中运行。这是实现多任务和多进程编程的重要机制之一。
当一个进程调用fork之后,就有两个二进制代码相同的进程。而且它们都运行到相同的地方。但每个进程都将可以开始它们自己的旅程,看如下程序:
1 #include<stdio.h> 2 #include<unistd.h> 3 #include<stdlib.h> 4 int main() 5 { 6 pid_t pid; 7 printf("Before: pid is %d\n", getpid()); 8 9 if ( (pid=fork()) == -1 ) 10 { 11 perror("fork()"); 12 exit(1); 13 } 14 printf("After:pid is %d, fork return %d\n", getpid(), pid); 15 sleep(1); 16 return 0; 17 }
运行结果
[kingxzq@localhost Documents]$ ./test Before: pid is 7052 After:pid is 7052, fork return 7053 After:pid is 7053, fork return 0
这里看到了三行输出,一行before,两行after
第7行:父进程打印了"Before: pid is 7052",表示它的进程ID(PID)是7052。
第14行:父进程打印了"After:pid is 7052, fork return 7053"。这意味着fork()调用成功,父进程接收到了子进程的PID,即7053。
第14行:子进程打印了"After:pid is 7053, fork return 0"。在子进程中,fork()调用返回0,表示它是子进程。
因此,该程序使用fork()创建了一个子进程,父进程和子进程都从fork()调用的位置继续执行。父进程接收到子进程的PID作为fork()的返回值,而子进程接收到0作为返回值。
那么为什么进程7053没有打印before呢?
进程7053没有打印"Before: pid is 7052"是因为在调用fork()之后,父进程和子进程是并发执行的。在父进程执行到打印"Before: pid is 7052"之后,它创建了一个子进程。子进程继承了父进程的代码和数据,包括printf语句,但是子进程的输出缓冲区是独立的。
因此,当父进程执行完printf语句后,它的输出被刷新到终端,而子进程的输出缓冲区中仍然存在。当子进程执行到打印"After:pid is 7053, fork return 0"时,它的输出也被刷新到终端。
这就是为什么父进程和子进程的输出顺序可能会交错的原因。在这种情况下,父进程的输出先于子进程的输出,因此你看到的输出是"Before: pid is 7052"在"After:pid is 7053, fork return 0"之前打印的。注意,fork之后,谁先执行完全由调度器决定。
1.2 父子进程和CPU中的EIP(指令指针)之间存在一定的关系
当一个程序(进程)在执行时,CPU会通过EIP来跟踪下一条要执行的指令的内存地址。当遇到函数调用、分支语句或系统调用等情况时,CPU会根据程序的逻辑跳转到相应的地址执行。
在创建子进程时,通过fork()系统调用,操作系统会复制父进程的代码段、数据段和堆栈等信息给子进程。这意味着子进程会拥有与父进程相同的代码和数据。
在fork()调用之后,父进程和子进程会在不同的内存空间中独立执行。它们各自拥有自己的EIP,用于跟踪各自的执行状态。父进程和子进程的EIP会根据各自的代码逻辑独立地进行跳转和执行。
因此,父进程和子进程的EIP是相互独立的,它们在执行过程中不会相互影响。每个进程都有自己的EIP,用于指示下一条要执行的指令的地址。所以这也就是为什么子进程只会执行fork函数之后位置的代码。
1.3 fork的常规用法有哪些?
创建子进程:最常见的用法是使用fork()创建一个子进程。父进程调用fork()后,会创建一个与父进程几乎完全相同的子进程。子进程从fork()调用的位置开始执行,而父进程继续执行后续的代码。
并行处理:通过fork()可以实现并行处理任务。父进程可以将任务分配给多个子进程,每个子进程独立执行任务,从而实现并行处理,提高程序的执行效率。
进程间通信:通过fork()创建的子进程可以用于进程间通信。父进程和子进程可以通过管道、共享内存、消息队列等机制进行通信,实现数据的交换和共享。
守护进程:守护进程是在后台运行的长期运行的进程,通常通过fork()创建。父进程可以通过fork()创建一个子进程,并在子进程中执行守护进程的任务,而父进程则可以继续执行其他任务或退出。
多进程编程:fork()可以用于多进程编程,例如使用多个子进程同时处理不同的任务,或者使用子进程执行特定的功能,从而实现更复杂的程序逻辑。
这些是fork()的一些常规用法,但并不限于此。fork()是进程创建和管理的基础,可以根据具体的需求和场景进行灵活的应用。
1.4 fork调用失败的原因有哪些?
fork()调用可能会失败,导致返回-1。以下是一些可能导致fork()调用失败的原因:
系统资源不足:当系统中的进程数量已经达到了操作系统的限制时,fork()调用可能会失败。这可能是由于系统内存不足、进程数量达到上限或者其他资源限制导致的。
进程数量限制:操作系统可能对每个用户或每个进程组设置了最大进程数量的限制。当达到这个限制时,fork()调用可能会失败。
虚拟内存不足:当系统的虚拟内存空间不足以容纳新的进程时,fork()调用可能会失败。
权限不足:如果当前进程没有足够的权限来创建新的进程,例如缺少适当的权限或者超过了进程数量限制,fork()调用也会失败。
系统错误:其他系统级错误,如内核错误或其他底层问题,也可能导致fork()调用失败。
在fork()调用失败时,通常会使用perror()函数打印错误信息,并根据具体的错误原因采取适当的处理措施。
2.写时拷贝
2.1 什么是写实拷贝?
写时拷贝(Copy-on-Write,COW)是一种内存管理技术,用于在创建子进程时延迟复制父进程的内存内容。在写时拷贝中,当父进程创建子进程时,子进程会与父进程共享相同的物理内存页。
在写时拷贝的情况下,当父进程或子进程尝试修改共享的内存页时,操作系统会执行实际的内存复制操作。这样,父进程和子进程就会拥有各自的独立内存副本,而不会相互干扰。
写时拷贝的主要优势在于节省内存和提高性能。在创建子进程时,不需要立即复制整个父进程的内存空间,而是共享相同的物理内存页。这样可以减少内存的使用量,并且在父进程和子进程之间切换时,不需要进行大量的内存复制操作,提高了性能。
总结来说,写时拷贝是一种延迟复制的技术,用于在创建子进程时共享父进程的内存,只有在需要修改共享内存时才进行实际的复制操作,以提高内存利用率和性能。
举个简单的例子:
在C语言中,常量字符串是指在代码中直接使用的字符串字面量,例如:“Hello, World!”。常量字符串在编译时就会被存储在程序的只读数据段(常量区)中,而不是在堆栈或堆中。
类似于写时拷贝,在进程调度中,当创建一个新的进程时,操作系统通常会延迟复制父进程的内存内容。这意味着父进程和子进程会共享相同的物理内存页,包括常量字符串所在的只读数据段。
这种共享常量字符串的方式类似于写时拷贝的思想。当父进程或子进程尝试修改共享的常量字符串时,操作系统会执行实际的复制操作,将被修改的字符串复制到新的内存页中,以确保父进程和子进程拥有各自的独立副本。
这种共享常量字符串的方式可以节省内存空间,因为不需要为每个进程复制相同的字符串副本。只有在需要修改字符串时,才会进行实际的复制操作,以确保进程间的独立性。
因此,类似于写时拷贝,进程调度中的共享常量字符串的方式延迟了复制操作,提高了内存利用率,并在需要修改时才进行实际的复制,以确保进程间的独立性。
通常,父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本。具体见下图:
进程终止
1.进程退出场景有哪些?
进程可以在多种场景下退出。以下是一些常见的进程退出场景:
正常退出:进程完成了它的任务,并通过调用exit()系统调用或从main()函数中返回来正常退出。在退出之前,进程可以释放资源、保存状态或执行其他必要的清理操作。
异常退出:进程在执行过程中遇到了错误或异常情况,无法继续执行下去。这可能是由于内存访问错误、除零错误、无效指令、段错误等导致的。在这种情况下,操作系统会终止进程并生成相应的错误报告。
信号终止:进程可以通过接收到特定的信号而终止。例如,当进程接收到
SIGTERM
信号时,它可以选择优雅地终止并执行清理操作。另外,一些信号如SIGKILL
和SIGSTOP
是无法被捕获或忽略的,它们会立即终止进程。父进程终止:当一个进程的父进程终止时,操作系统会将该进程的父进程设置为init进程(通常是进程ID为1的进程)。如果该进程没有被其他进程接管,它可能会成为孤儿进程,并由操作系统接管并终止。
资源耗尽:进程可能因为系统资源的耗尽而被迫终止。例如,当进程请求的内存超过系统可用内存时,操作系统可能会终止该进程以保护系统的稳定性。
被其他进程终止:其他进程可以通过发送特定的信号(如SIGKILL)来终止目标进程。这通常是由于需要强制终止进程或出于系统管理的目的。
这些是一些常见的进程退出场景,但并不限于此。进程退出的原因可以是多样的,具体取决于进程的任务、运行环境和外部因素。
2.常见查看进程退出方法
2.1 正常终止
可以通过 echo $?
查看进程退出码
echo $?
命令用于显示上一个执行的命令的退出状态码(或称为返回值)。在Unix/Linux
系统中,每个命令在执行完毕后都会返回一个退出状态码,用于表示命令执行的结果。
$?
是一个特殊的变量,用于存储上一个命令的退出状态码。通过在命令行中执行echo $?
,可以打印出上一个命令的退出状态码。
退出状态码通常是一个整数值,其中0表示命令成功执行,而非零值表示命令执行失败或出现错误。具体的退出状态码的含义可以根据不同的命令而有所不同,通常会在命令的文档或手册中进行说明。
echo $?
命令对于调试和脚本编写非常有用,可以根据上一个命令的退出状态码来进行条件判断或错误处理。
2.2 异常退出
Ctrl+C
,信号终止
你在终端中按下Ctrl+C
组合键时,会发送一个SIGINT
信号给当前正在运行的进程。这个信号通常用于请求进程终止。
当进程接收到SIGINT
信号时,默认的行为是终止进程并进行清理操作。这被称为信号终止。进程可以选择捕获和处理SIGINT信号,例如执行一些清理操作后再终止。
在终端中按下Ctrl+C
时,操作系统会将SIGINT
信号发送给前台运行的进程组中的所有进程。通常情况下,这会导致当前正在运行的进程终止。
需要注意的是,有些进程可能会忽略SIGINT
信号或者通过编写信号处理程序来自定义处理方式。但是,大多数情况下,按下Ctrl+C
会导致进程异常退出。
2.3 _exit函数和exit函数退出
_exit()
函数和exit()
函数都用于终止进程,但它们之间有一些区别。
_exit()
函数:
_exit()
函数是一个系统调用,用于立即终止进程的执行。- 它不会执行任何清理操作,包括不会刷新缓冲区、关闭文件描述符等。
_exit()
函数的原型为void _exit(int status)
,其中status
参数是进程的退出状态码。- 进程的退出状态码可以通过父进程的
wait()
或waitpid()
系统调用来获取。
说明:虽然status是int,但是仅有低8位可以被父进程所用。所以_exit(-1)时,在终端执行$?发现返回值是255
exit()
函数:
exit()
函数是一个库函数,用于正常终止进程的执行。- 在调用
exit()
函数之前,会执行一些清理操作,例如刷新缓冲区、关闭文件描述符等。exit()
函数的原型为void exit(int status)
,其中status参数是进程的退出状态码。- 进程的退出状态码可以通过父进程的
wait()
或waitpid()
系统调用来获取。
exit
最后也会调用_exit
, 但在调用exit之前,还做了其他工作:
- 执行用户通过
atexit
或on_exit
定义的清理函数。- 关闭所有打开的流,所有的缓存数据均被写入
- 调用
_exit
总结:
_exit()
函数是一个系统调用,立即终止进程的执行,不执行清理操作。exit()
函数是一个库函数,正常终止进程的执行,执行清理操作后退出。- 两者都接受一个退出状态码作为参数,用于表示进程的退出状态。
- 进程的退出状态码可以通过父进程的
wait()
或waitpid()
系统调用来获取。 exit
也是通过调用_exit
来实现的
2.4 return退出
return是一种更常见的退出进程方法。执行return n等同于执行exit(n),因为调用main的运行时函数会将main的返回值当做 exit的参数。
在C语言中,main函数的返回值类型通常是int类型。根据C语言标准,main函数的返回值可以是0或者非零的整数。返回0表示程序成功地执行完毕,而非零的返回值通常用于表示程序执行过程中的错误或异常情况。
非零的返回值可以用于向调用程序或操作系统报告错误信息或状态。例如,当程序需要在执行过程中发生错误时,可以返回一个非零值来指示错误的类型或代码。这样,调用程序或操作系统可以根据返回值来采取相应的措施,比如输出错误信息、终止程序或进行其他处理。
在实际应用中,非零的返回值可以根据具体需求进行定义和使用。不同的程序可能会定义不同的非零返回值来表示不同的错误或状态。一般来说,返回值的具体含义和用途是由程序员根据程序的逻辑和需求来决定的。
需要注意的是,main函数的返回值只能是整数类型,不能返回其他类型的值。如果需要返回其他类型的值,可以通过全局变量、指针参数或其他方式来实现。
进程等待
1.什么是进程等待?
进程等待是指一个进程在执行过程中暂停自己的执行,等待某个特定的条件满足后再继续执行。进程等待的必要性主要体现在以下几个方面:
同步操作:在多进程或多线程的环境中,进程之间可能需要进行协调和同步。例如,一个进程可能需要等待其他进程完成某个任务后才能继续执行,或者需要等待某个共享资源的释放。进程等待可以确保进程之间的操作按照正确的顺序进行,避免数据竞争和不一致的结果。
资源管理:进程等待还可以用于管理系统资源的分配和释放。当一个进程需要使用某个资源时,如果该资源已经被其他进程占用,那么该进程可以选择等待资源的释放,而不是一直占用CPU资源进行忙等待。这样可以提高系统的资源利用率和效率。
阻塞操作:有些操作需要等待一段时间才能完成,例如网络通信、文件读写等。在这种情况下,进程可以选择等待操作完成后再继续执行,而不是一直占用CPU资源进行忙等待。这样可以避免资源的浪费,提高系统的响应速度。
总之,进程等待是一种有效的管理和调度进程的机制,可以确保进程之间的协调和同步,提高系统的资源利用率和效率,以及提供更好的用户体验。
2.进程等待必要性
之前博客写过,子进程退出,父进程如果不管不顾,就可能造成僵尸进程的问题,进而造成内存泄漏。另外,进程一旦变成僵尸状态,那就刀枪不入,kill -9
也无能为力,因为谁也没有办法杀死一个已经死去的进程。最后,父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对,或者是否正常退出。父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息。
3.进程等待的方法
3.1 wait()和waitpid()
wait()
pid_t wait(int*status);
返回值:
成功返回被等待进程pid,失败返回-1。
参数:
输出型参数,获取子进程退出状态,不关心则可以设置成为NULL
waitpid()
pid_ t waitpid(pid_t pid, int *status, int options);
返回值:
当正常返回的时候waitpid返回收集到的子进程的进程ID;
如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0;
如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;
参数:
pid:
如果pid等于(pid_t)-1,则请求任何子进程的状态。在这方面,waitpid()等同于wait()。
如果pid大于0,则指定要请求状态的单个子进程的进程ID。
如果pid为0,则请求任何进程组ID与调用进程相同的子进程的状态。
如果pid小于(pid_t)-1,则请求任何进程组ID等于pid的绝对值的子进程的状态。
status:
WIFEXITED(stat_val)
:如果状态是由正常终止的子进程返回的,则评估为非零值。
WEXITSTATUS(stat_val)
:如果WIFEXITED(stat_val)的值非零,则该宏评估为子进程传递给_exit()或exit()的状态参数的低8位,或者子进程从main()返回的值。
WIFSIGNALED(stat_val)
:如果状态是由未被捕获的信号终止的子进程返回的,则评估为非零值(参见<signal.h>)。
WTERMSIG(stat_val)
:如果WIFSIGNALED(stat_val)的值非零,则该宏评估为导致子进程终止的信号编号。
WIFSTOPPED(stat_val)
:如果状态是由当前停止的子进程返回的,则评估为非零值。
WSTOPSIG(stat_val)
:如果WIFSTOPPED(stat_val)的值非零,则该宏评估为导致子进程停止的信号编号。
WIFCONTINUED(stat_val)
:如果状态是由从作业控制停止中继续的子进程返回的,则评估为非零值。
options:
WCONTINUED
:waitpid()函数将报告由pid指定的任何继续运行的子进程的状态,只要该子进程自从作业控制停止后其状态尚未被报告。
WNOHANG
:如果status对于由pid指定的任何子进程不立即可用,waitpid()函数将不会挂起调用线程的执行(父进程非阻塞等待)。
WUNTRACED
:任何由pid指定的已停止的子进程的状态,且自从它们停止后其状态尚未被报告,也将被报告给请求进程。
如果子进程已经退出,调用wait/waitpid时,wait/waitpid会立即返回,并且释放资源,获得子进程退出信息。
如果在任意时刻调用wait/waitpid,子进程存在且正常运行,则进程可能阻塞。
如果不存在该子进程,则立即出错返回。
3.2 获取子进程status
wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。
如果传递NULL,表示不关心子进程的退出状态信息。
否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
status不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究status低16bit)
结合这张图片我们可以知晓可以用于从状态字中提取信号编号和退出码。
status & 0x7F
:这个表达式使用了位运算与操作符(&)和一个掩码(0x7F)。掩码0x7F的二进制表示为01111111,它的作用是将状态字中的高位清零,只保留最低的7位。这样做的目的是提取信号编号,因为信号编号通常存储在状态字的最低位。
(status >> 8) & 0xFF
:这个表达式使用了右移操作符(>>)和位运算与操作符(&),以及一个掩码(0xFF)。首先,status >> 8将状态字向右移动8位,将退出码移动到最低位。然后,位运算与操作符&与掩码0xFF进行与操作,将高位清零,只保留最低的8位。这样做的目的是提取退出码,因为退出码通常存储在状态字的高8位。
综上所述,这种方式通过使用位运算和掩码,从状态字中提取信号编号和退出码。