1 、什么是进程
我们可以通俗地把进程看作是正在运行着的二进制程序,占用内存空间消耗系统资源,例如使用 vim 命令编辑文件内容就会生成一个进程。进程是 OS 资源分配的基本单位,每个进程在操作系统中都执行着特定的任务。如网络服务/etc/init.d /network 是管理各个以太网接口的守护进程。
进程启动后,系统会为它分配一个唯一的数值,用于标识该进程,这个数值就称为进程号 PID,每个进程都有自己的用户、工作目录、进程状态、父子关系、内存结构、启动命令、文件句柄等情况,都记录在/proc/PID/目录下,通常使用 ps 命令和 top 命令查看。
2、c 语言的 main()函数
C 程序总是从 main 函数开始执行,通常情况下,对一个大型的软件项目,有几个 main()
就有几个进程。main()函数的原型是:int main(int argc,char* argv[1],char *env[]); argc 是命令行参数的个数,程序文件路径自身是第一个参数。 argv 字符指针数组中保存了指向各个参数字符串的指针,argv[0]就指向程序文件。
env 字符指针数组中保存了指向当前系统中每个环境变量字符串的指针,也就是环境表的首地址,环境表以 null 结尾,和命令 env 的输出一致。
void main ( int argc , char *argv[] ,char *env[] ) { int i ;
fprintf ( stdout , ” 参数个数 argc = %d\n ” , argc ) ;
for ( i = 0 ; i < argc ; i++ ) printf ( “ 参数 argv[%d] = %s \n ” , i , argv[i] ); for ( i = 0 ; env[i] ; i++ ) puts ( env[i] ) ; //逐个打印环境变量
}
3、Linux 进程的内存管理机制
Linux 进程内存管理的对象都是虚拟内存,每个进程先天就有 0-4G 的各自互不干涉的虚拟内存空间,0—3G 是用户空间执行用户自己的代码, 高 1GB 的空间是内核空间执行 Linu x 系统调用,这里存放在整个内核的代码和所有的内核模块。用户所看到和接触的都是该虚拟地址,并不是实际的物理内存地址。虚拟内存不能直接存储数据,必须要先映射真实物理内存,C 语言中内存动态分配函数 malloc 严格来说不是分配内存,而是用这先天就存在虚拟内存映射物理内存的。
Linux 内存分配和回收以内存页为单位,一页是 16 的 3 次方共 4096 个字节。进程的内存结构都保存在/proc/$(pid)/maps 文件中,下图为 Linux 进程典型存储器安排:
(1)栈。栈内存由编译器在程序编译阶段完成,进程的栈空间位于进程用户空间的顶部并且是向下增长,每个函数的每次调用都会在栈空间中开辟自己的栈空间,函数参数、局部变量、函数返回地址等都会按照先入者为栈顶的顺序压入函数栈中,函数返回后该函数的栈空间消失,所以函数中返回局部变量的地址都是非法的。
(2)堆。堆内存是在程序执行过程中分配的,用于存放进程运行中被动态分配的的变量,大小并不固定,堆位于非初始化数据段和栈之间,并且使用过程中是向栈空间靠近的。当进程调用 malloc 等函数分配内存时,新分配的内存并不是该函数的栈帧中,而是被动态添加到堆上,此时堆就向高地址扩张;当利用 free 等函数释放内存时,被释放的内存从堆中被踢出,堆就会缩减。因为动态分配的内存并不在函数栈帧中,所以即使函数返回这段内存也是不会消失。
(3)非初始化数据段。通常将此段称为 bss 段,用来存放未初始化的全局变量和 static 静态变量。并且在程序开始执行之前,就是在 main()之前,内核会将此段中的数据初始化为 0 或空指针。
(4)初始化数据段。用来保已初始化的全局变量和 static 静态变量。
(5)代码段,这是可执行文件中由 CPU 执行的机器指令部分。正文段常常是只读的,以防止程序由于意外而修改其自身的执行。
Linux 内存管理的基本思想就是只有在真正访问一个地址的时候才建立这个地址的物理映射,Linux C/C++语言的分配方式共有 3 种方式。
(1)从静态存储区域分配。就是数据段的内存分配,这段内存在程序编译阶段就已经分配好,在程序的整个运行期间都存在,例如全局变量,static 变量。
(2)在栈上创建。在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是系统栈中分配的内存容量有限,比如大额数组就会把栈空间撑爆导致段错误。
(3)从堆上分配,亦称动态内存分配。程序在运行的时候用 malloc 或 new 申请任意多少的内存,程序员自己负责在何时用 free 或 delete 释放内存。此 区域内存分配称之为动态内存分配。动态内存的生存期由我们决定,使用非常灵活,但问题也最多,比如指向某个内存块的指针取值发生了变化又没有其他指针指向 这块内存,这块内存就无法访问,发生内存泄露。
4 、Linux 系统多进程编程
Linux 系统启动中加载 Linux 内核文件后会启动进程/sbin/init,该进程是系统运行的第一个进程,进程号为 1,称为 Linux 系统的初始化进程。除了这个进程之外,其他进程都是通过调用 fork()复制创建的。Linux 中维护着一个数据结构叫做 进程表,保存当前加载在内存中的所有进程的有关信息,其中包括进程的 PID、进程的状态、命令字符串等,操作系统通过进程的 PID 对它们进行管理,这些 PID 是进程表的索引。
4.1 fork()函数创建进程
在 Linux 系统中,创造新进程只有使用 fork()系统调用,这个函数是通过复制进程映像的方式创建新进程的。fork 函数创建子进程的过程为:在调用之前程序中只有一个进程,调用 fork 时,在 fork()函数内部,系统根据现有的进程的特性几乎是完全意义的复制出一个新进程,复制的内容包括原进程的程序代码、数据段、函数堆栈、文件描述符等,系统会为新进程新开辟一块内存空间,但是和原进程共享代码段。通过这种复制方 式创建出子进程后,原有进程和子进程都从函数 fork 返回,都执行 fork 函数的下一句代码,但是父子进程哪个先运行却是因 Linux 系统版本不同而不同的。
fork 调用的奇妙之处就在于该函数仅仅被调用一次,却能够返回两次,在父进程中 fork 返回新创建子进程的进程 ID;子进程中 fork 返回 0;如果出现错误,fork 返回一个负值;
#include
#include
#include
#include
int n = 0; //初始化全局变量在全局区,未初始化全局变量在 BSS 段 void main()
{
pid_t pid;
char *message; //栈区数据 int *pi = malloc(4); //堆区
printf("fork program starting\n"); pid = fork(); //以下代码父子进程都会执行,共享代码区 switch(pid)
{
case -1:
perror("fork failed"); exit(1);
case 0: //子进程的分支 message = "This is the child"; //子进程会复制父进程的堆区
n = 5; //子进程会复制父进程的数据区
printf(“n=%d,pi=%p\n”,n,pi);
break; default: //父进程的分支
message = "This is the parent"; n = 3; printf(“n=%d,pi=%p\n”,n,pi); break;
}
for ( ; n > 0 ;n-- ) { //父子进程都会执行 for 循环 puts(message);
sleep(1);
}
exit(0);
}
4.2、 vfork 与 exec 函数族
vfork 也是通过赋值进程映像的方式创建子进程,但是和 fork 有两点区别:1、vfork 创建子进程后会将父进程阻塞,保证子进程先运行,且必须要调用 exec 函数族或者 exit 系列函数终止本进程,父进程才有可能被调度执行;2、子进程是占据了父进程 的内存空间,与父进程共享数据段,vfork 的目的就是为了在子进程中调用 exec 启动新进程。
exec 函数族的作用是根据指定的文件名找到可执行文件,并且在调用进程内部执行这个可执行文件。Exec 系列函数并不是创建新进程,而是用另一个新程序替换了当前进程,调用进程被干掉,但前后的进程 ID 也不会改变。因为这种替换进程映像的特点,所以对 exec 函数常用的方式就是:先由 vfork 函数创建全新的子进程后,子进程内部再调用一种 exec 函数执行另一个可执行文件,此函数返回后子进程也就“死亡”了,父进程重新获得内存地址被调度执行。实际上在 Linux 中,exec 函数族中只有 execve 是真正意义上的系统调用,其它都是在此基础上经过封装的库函数,但是作用还是用法都非常相似。
/* 程序等同执行 ls -al /etc/passwd */ #include
#include void main()
{
printf(“父进程开始创建子进程了\n”); pid_t pid = vfork();
if (pid == 0) { //在子进程内部执行 exev,替换子进程的进程印象 char * argv[ ]={"ls","-al","/etc/passwd",NULL}; //新进程命令行参数 char * env[ ]={"PATH=/bin",NULL}; //环境变量
if ( execve("/bin/ls",argv,envp) == -1){ //函数返回 0 代表调用出错 perror("execve faild");
exit(1);
}
}
}
4.3、进程的退出
进程正常退出的常见方式通常有 3 钟:
(1)、进程的主函数脱离了作用域,没有调用 return 也没有调用 exit 系列函数。
(2)、在进程的 main 函数中执行了 return 语句,在调用函数里执行了返回语句只是结束了这个函数,在 main()函数里执行了返回语句就是结束了这个进程。
(3)、在进程的任何部分调用 exit 都会结束进程,此函数允许进程退出前调用被 atexit() 注册的回调函数做一些清理工作,然后再结束该进程,其实脱离作用域和 return 语句都会调用由 atexit()注册的回调函数。
一个进程在调用 exit 命令退出的时候,内核释放该进程拥有的资源, 但仍然会留下一个称为“僵尸进程(Zombie)”的数据结构,包括进程号、退出码、运行时间等,主要是为了把这些信息保存起来,以备父进程通过 wait()系统调用获取这些信息尤其是函数退出码,同时回收该僵尸子进程,此时这个子进程才彻底终止掉,从系统进程表中彻底消失。也就是若子进程先于父进程结束时肯定会先变成僵尸进程的,只是时间长短的问题,如果子进程刚结束父进程就给回收了,那么僵尸进程就瞬间消失。
4.4、等待子进程
在多进程程序中为了避免僵尸进程的干扰,常用的的手段是-----在父进程内部调用 wait 函数阻塞本进程的运行,直到子进程通过 return 语句或 exit 系统调用结束运行先变成僵尸子进程,并由 wait()函数的指针型参数获取子进程的返回码,同时回收该结束子进程的资源,否则这个僵尸进程子进程 会一直存在直到程序结束。这里一点优化之处,当一个进程正常或异常终止时,内核就向其父进程发送 SIGCHID 信号,因为子进程终止是个异步事件,所以这种信号也是内核向父进程发送的的异步通知,可以在父进程运行的任何时候发生。对于这种信号,程序员可以在父进程里提供一个对于 SIGCHID 信号的处理函数,在此处理函数里调用 wait()函数,实现子进程结束与父进程回收紧接着顺序进行,这样父进程就不会因为等待子进程阻塞自己了。
wait()系统用有一个指针型参数 status,父进程可以用来接收子进程的 main()函数里的 re turn 返回值或 exit()系列函数的退出码,用于判断子进程的结束状态,并根据不同的返回状态作不同的处理工作,Linux 中有一套预定义的的宏函数来来 处理子进程的返回值,最常用的两个是:1、WIFEXITED(status)这个宏用来指出子进程是否为正常退出的,如果是,它会返回一个非零值。
2、WEXITSTATUS(status)这个宏来提取子进程的返回值,但是会先把原始返回值和八进制数 0377 作位于&运算,所以上述返回值最好指定 0-255 之间的数字。
wait()如果调用成功,就会返回结束子进程的 PID,所以即使父进程有多个子进程,程序也总能确定是哪个子进程结束了。
#include
#include #include //定义了宏函数 #include
int status; pid_t pid , pid1 ; void handle(int signo){
printf("父进程%d 捕获了信号%d\n",getpid(),signo); pid1 = wait(&status); //仅有父进程执行等待语句printf(“等待结束,回收子进程\n”);
}
void main(){
pid_t pid = fork(); if(pid == 0){
printf(“子进程 d%开始运行\n”,getpid()); printf(“子进程运行结束|n”); exit(100); //子进程此时已经结束
}
signal(SIGCHID, handle);
sleep(5); //在子进程 exit 之后,父进程 wait 之间,子进程是 defunct 状态。printf(“父进程开始运行,\n”); if(WIFEXITED(status))
printf(“子进程%d 正常结束,退出码:%d\n”,pid1,WEXITSTATUS(status));
}