1. 进程创建
1.1 进程创建的方式与过程
在Linux操作系统中,我们使用C语言库函数fork
来创建进程,fork函数在父进程中会返回创建的子进程pid,在子进程中返回0。
在前面的学习中,我们已经对创建出的子进程有了一定的了解,我们知道父子进程是共享代码与数据。只有在我们对数据进行更改时,操作系统才会对指定数据部分进行写实拷贝。
进程由内核数据结构 + 代码 + 数据
组成,其中内核数据结构包括,PCB(进程控制块),进程地址空间,页表。在创建进程时,子进程会创建单独的内核数据结构,并将父进程的内核数据结构中的信息拷贝一份。每个进程都拥有自己独立的内核数据结构,进行的独立性由此显示 。
1.2 写时拷贝
既然对数据无论立即拷贝或是写时拷贝的总时间消耗都是相同的,那为什么要进行步骤繁琐的多次写时拷贝呢? <1> 如果在创建子进程时将父进程的所有数据进行一次性的拷贝,这样会使得子进程的创建效率变低。 <2> 操作系统要为计算机的运行效率负责,进程在运行时,大概率不会对所有数据都进行修改。那么,在拷贝方式消耗相同的情况下,对大部分只读类型数据不进行拷贝,而只对指定数据进行写时拷贝的方式会大大提高效率。
数据拷贝的过程一共分为两步,申请空间与拷贝数据,那么,我们已经要对指定数据进行修改,为什么还要将父进程的原数据拷贝一份而不是只开辟需要的内存空间呢? <1> 写时拷贝中写的操作,不进行是指对数据的修改,而是增,删,改等一系列操作。也就是说,我们需要在原数据的基础上做做一些调整修改,而不是覆盖式的写入,所以拷贝的步骤是必须的。
写时拷贝的底层技术支持: <1> 我们知道在未触发写时拷贝之前,父子进程是共用数据,也就是说页表中的所建立的映射关系是相同的。 <2> 而只有在需要对数据进行修改时,才会对指定需要修改的数据进行拷贝,在底层上就表现为在物理内存中开辟一块新的内存空间,将数据拷贝一份,然后再于页表中建立全新的映射关系。 <3> 可是,操作系统是如何辨别何时应该进行如上操作,又是如何找到需要进行写时拷贝的指定数据呢? <4> 接下来,让我们对页表的结构进行进一步的了解: <5> 页表中,不仅仅存在虚拟地址物理地址的映射关系,还有许多其他的选项,其中有一个选项就是标明我们对相应地址空间内数据的访问权限,而这也正是操作系统控制写时拷贝发生的手段。
写时拷贝的底层实现原理: <1> 在进行子进程创建时,操作系统会将父子进程中所有数据的访问权限都设置为只读,而在此之后,当我们尝试对任意父进程或是子进程中的数据进行修改时,都会因为权限不足而触发问题。 <2> 在操作系统发现问题后,会对问题(缺页中断)进行种类判断,而后进行相应的处理解决,写时拷贝的实现方式正是通过这种问题触发的方式来引起操作系统的注意,从而让操作系统进行写时拷贝与提权。
1.3 补充知识
在C语言的学习中,我们知道字符串常量是不能被修改的,给出的解释为字符串存储在常量区,常量的数据不能被修改,若强行修改则会报错。
如上给出的解释,只是基于编程语言层面上的概念,而每一个语言上的概念在底层上都有着技术上的支持。
C语言中内存空间的概念只是操作系统中的进程地址空间,而不是真正的物理内存空间,我们之所以不能进行对字符串常量进行访问与修改,是因为底层上对应的地址空间的访问权限为只读权限。
我们知道,C语言中被const
关键字修饰的变量具有常性,是不能修改的,当我们对被修饰变量进行修改时,会发生报错。 <1> 这类报错是语法上的报错,在程序的编译阶段就会被编译器检测出来,属于语法报错,会对出错的位置进行提示。 <2> 对没有访问权限的数据进行修改时,编译阶段不会进行报错,而是会在我们执行对应生成的可执行程序时,进行报错,属于运行报错。 <3> const
修饰我们不想更改变量的方式,被称之为防御性编程 ,将错误报警在运行之前,大大优化了调试与纠错的效率。
char * str = "hello world!" ;
* str = 'x' ;
const char * str = "hello world!" ;
* str = 'x'
进程创建失败的原因: <1> 操作系统内当前的进程太多 <2> 创建进程的数量超过上限(每个用户能够创建的进程数量有限)
创建子进程的常规用法: <1> 与父进程执行不同的代码段 <2> 使用进程替换的方式,执行全新的代码
2. 进程终止
2.1 main函数返回值与进程退出码
在编写C/C++程序时,我们首先都要写一个main函数,而main函数的返回值我们都统一为0。我们为什么要去定义一个main函数,并且将其的返回值设置为0呢?
操作系统在每个进程被执行完成之后,需要对执行完的进程进行回收,这一过程中并不是直接将程序进行销毁的。操作系统需要从回收执行完的进程中获取其的相关执行信息,然后,通过其的执行情况来判断决定后续如何处理。
进程使用退出码 的方式用来告知操作系统自己的执行情况,而在语言层面上就为main函数的返回值,进程使用不同的退出码用来标识自己不同的执行情况。
指令echo $?
,显示最近一次执行进程的退出码,我们让main函数返回不同的返回值,就会得到不同的退出码。
int main ( )
{
return 3 ;
}
2.2 进程退出码的意义
不同的进程退出码代表着程序不同执行情况,Linux中, <1> 使用0表示进程执行成功无异常 <2> 用非0表示进程内执行失败,非0数字有多个,可以用来表示不同的失败原因
C语言string,h
头文件中的库函数strerror
,可以将错误码转化为我们可以理解所对应的错误描述。
# include <stdio.h>
# include <string.h>
int main ( )
{
int i = 0 ;
for ( i = 1 ; i < 201 ; i++ )
{
printf ( "%s\n" , strerror ( i) ) ;
}
return 0 ;
}
Linux操作系统中,错误码有133个,错误码我们既可以使用系统自带的方法,也可以自己定义。
# include <stdio.h>
enum exit_code
{
success = 0 ,
open_err,
malloc_err,
}
const char * get_exit_code ( int code)
{
switch ( code)
{
case success:
return "success" ;
case open_err:
return "open_err" ;
case malloc_err:
return "malloc_err" ;
default :
return "unknown_err" ;
}
}
int main ( )
{
int i = 0 ;
for ( i = 0 ; i < 5 ; i++ )
{
printf ( "%s\n" , get_exit_code ( i) ) ;
}
return 0 ;
}
程序中,只有main函数的返回值为进程退出码,其他函数的退出,仅仅代表此函数调用完毕。
操作系统通过获知进程的执行情况来调整之后的行为动作,而进程内部,也需要通过获知各个函数的执行情况来进行后续的动作。
C语言的库函数,在实现上会将返回结果与退出码压缩,通过返回值的方式表示,函数的返回值只能简单表明函数执行失败而退出,具体退出原因我们无法通过返回值得知。
C语言头文件errno.h
中,包含有一个全局变量errno
,这一变量中会记录我们调用库函数执行失败时的退出码。
# include <stdio.h>
# include <errno.h>
int main ( )
{
FILE* fp =