(推荐)进程的详细讲解(一)

进程详细讲解

1.谁调用了mian函数?

应用程序在运行 main()函数之前需要先执行一段引导代码,最终由这段引导代码去调用应用程序的 main()函数。在编译链接时,由链接器将引导代码链接到我们的应用程序当中,一起构成最终的可执行文件。程序运行需要通过操作系统的加载器来实现,加载器是操作系统中的程序,当执行程序时,加载器负责将此应用程序加载内存中去执行。

2.程序如何结束?

大体上分为正常终止和异常终止,正常终止包括:调用return 语句,_exit()或_Exit(),exit()来终止。异常终止包括:调用 abort()函数或者进程接收到一个信号,譬如 SIGKILL 信号

3.何为进程?

用一句话来说即是一个可执行文件被运行。文件是一个静态的概念,存放磁盘中,如果可执行文件没有被运行,那它将不会产生什么作用。进程是一个动态过程,而非静态文件,它是程序的一次运行过程,当应用程序被加载到内存中运行之后它就称为了一个进程,当程序运行结束后也就意味着进程终止,这就是进程的一个生命周期。

4.进程号

Linux 系统下的每一个进程都有一个进程号(processID,简称 PID),进程号是一个正数,用于唯一标识系统中的某一个进程。在 Ubuntu 系统下执行 ps 命令可以查到系统中进程相关的一些信息。

5.获取进程号

使用 getpid()函数获取进程的进程号;使用getppid()系统调用获取父进程的进程号

6.环境变量

环境变量是在操作系统中一个具有特定名字的对象,它包含了一个或者多个应用程序所将使用到的信息。例如window下的path环境变量,当要求系统运行一个程序而没有告诉它程序所在的完整路径时,系统除了在当前目录下面寻找此程序外,还应到path中指定的路径去找。用户通过设置环境变量,来更好的运行进程。

7.进程的环境变量

每一个进程都有一组与其相关的环境变量,这些环境变量以字符串形式存储在一个字符串数组列表中,把这个数组称为环境列表。 env 命令查看到 shell 进程的所有环境变量;使用 export 命令还可以添加一个新的环境变量或删除一个环境变量;环境变量存放在一个字符串数组中,在应用程序中,通过 environ 变量指向它,environ 是一个全局变量,在我们的应用程序中只需申明它即可使用;获取指定环境变量用 getenv();
C 语言函数库中提供了用于修改、添加、删除环境变量的函数,譬如 putenv()、setenv()、unsetenv()、clearenv()函数等。setenv()& putenv()添加一个新的环境变量;unsetenv()函数可以从环境变量表中移除参数 name 标识的环境变量;可以通过将全局变量 environ 赋值为 NULL来清空所有变量也可通过 clearenv()函数来操作。

8.进程的内置布局:

正文段。也可称为代码段,这是 CPU 执行的机器语言指令部分,文本段具有只读属性,以防止程序由于意外而修改其指令;正文段是可以共享的,即使在多个进程间也可同时运行同一段程序。
初始化的数据段。通常称为数据段,是程序的虚拟地址空间的一部分,它包含有程序员初始化的全局变量和静态变量,可以进一步划分为只读区域和读写区域。
未初始化数据段。包含了未进行显式初始化的全局变量和静态变量,通常将此段称为 bss 段,系统会将本段内所有内存初始化为 0,可执行文件并没有为 bss 段变量分配存储空间,在可执行文件中只需记录 bss 段的位置及其所需大小,直到程序运行时,由加载器来分配这一段内存空间。
。函数内的局部变量以及每次函数调用时所需保存的信息都放在此段中,每次调用函数时,函数传递的实参以及函数返回值等也都存放在栈中。栈是一个动态增长和收缩的段,由栈帧组成,系统会为每个当前调用的函数分配一个栈帧,栈帧中存储了函数的局部变量(所谓自动变量)、实参和返回值。栈是一个后进先出的压入弹出式数据结构。在程序执行时,每次向栈中压入一个对象后,栈指针向下移动一个位置。当系统从栈中弹出一个对象时,最后进栈的对象将会被弹出,然后栈指针向上移动一个位置。如果栈指针位于栈顶,说明栈是空的,如果栈在栈底,说明栈式满的。
。是动态内存分配通常发生的部分,由程序员申请分配和释放,内存分配由低到高,采用链式存储结构。

9.进程的虚拟地址空间

Linux 系统下,应用程序运行在一个虚拟地址空间中,所以程序中读写的内存地址对应也是虚拟地址,并譬如应用程序中读写 0x80800000 这个地址,实际上并不对应于硬件的 0x80800000这个物理地址,还需要MMU翻译。也就是虚拟地址通过硬件MMU映射到实际的物理地址空间中。使用虚拟地址空间主要优点是提高内存使用效率,隔离进程地址空间,防止恶意篡改。可以让不同进程的虚拟地址空间映射到相同的物理地址空间中让两个或者更多进程能够共享内存,共享内存可用于实现进程间通信。

10.fork()创建子进程

一个现有的进程A可以调用 fork()函数创建一个新的进程B,调用 fork()函数的进程A称为父进程,由 fork()函数创建出来的进程被称为子进程B。创建多个进程是任务分解时行之有效的方法,通常会简化应用程序的设计,同时提高了系统的并发性。
调用fork()函数以后,就有了父和子两个进程,每个进程都会从 fork()函数的返回处继续执行,会导致调用 fork()返回两次值,子进程返回一个值、父进程返回一个值。在程序代码中,可通过返回值来区分是子进程还是父进程。fork()调用成功后,将会在父进程中返回子进程的 PID,而在子进程中返回值是 0;如果调用失败,父进程返回值-1,不创建子进程,并设置 errno。事实上,子进程是父进程的一个副本,譬如子进程拷贝了父进程的数据段、堆、栈以及继承了父进程打开的文件描述符,每个进程可以修改自己的栈和堆数据且不相互影响,但是,两个进程执行相同的代码段,因为代码段是只读的,也就是说父子进程共享代码段,在内存中只存在一份代码段数据。
在这里插入图片描述

由此可知,子进程拷贝了父进程的文件描述符表,使得父、子进程中对应的文件描述符指向了相同的文件表,也意味着父、子进程中对应的文件描述符指向了磁盘中相同的文件,因而这些文件在父、子进程间实现了共享,譬如,如果子进程更新了文件偏移量,那么这个改变也会影响到父进程中相应文件描述符的位置偏移量。
总结:如果在调用fork()函数建立子进程之前,如果已经打开了一些文件A,那么建立子进程之后,父子进程间实现了共享,父、子进程中对应的文件A描述符指向了相同的文件表,文件偏移量等是相互影响的。父、子进程分别对同一个文件进行写入操作,结果是接续写。
但是,如果是在建立fork()函数后,父子进程再打开同一个文件B,那此时父、子进程的这两个文件描述符分别指向的是不同的文件表,意味着它们有各自的文件偏移量,一个进程修改了文件偏移量并不会影响另一个进程的文件偏移量,所以写入的数据会出现覆盖的情况。

11.fork()函数使用场景

1.父进程希望子进程复制自己,使父进程和子进程同时执行不同的代码段。这在网络服务进程中是常见的,父进程等待客户端的服务请求,当接收到客户端发送的请求事件后,调用 fork()创建一个子进程,使子进程去处理此请求、而父进程可以继续等待下一个服务请求。
2.一个进程要执行不同的程序。譬如在程序 app1 中调用 fork()函数创建了子进程,此时子进程是要去执行另一个程序 app2,也就是子进程需要执行的代码是 app2 程序对应的代码,子进程将从 app2程序的 main 函数开始运行。这种情况,通常在子进程从 fork()函数返回之后立即调用 exec 族函数来实现。

12. 关于vfork()函数

vfork()函数类似于fork,优点是效率高,在一些细微处有区别。vfork()函数并不会将父进程的地址空间完全复制到子进程中,但是 vfork()可能会导致一些难以察觉的程序 bug,所以尽量避免使用 vfork()来创建子进程,除非速度绝对重要的场合。

13.父进程和子进程的竞争冒险

调用 fork 之后,无法确定父、子两个进程谁将率先访问 CPU,也就是说无法确认谁先被系统调用运行,虽然绝大部分情况下,父进程会先于子进程被执行,但是并不排除子进程先于父进程被执行的可能性。如果我们要子进程先于父进程执行,那应该怎么办呢?我们可以调用 sigsuspend()使父进程进入挂起状态,由子进程通过 kill 命令发送信号唤醒,也就是使父进程被阻塞,等到子进程来唤醒它。(详情看330页)

14.进程的终止

一般推荐的是子进程使用_exit()退出、而父进程则使用 exit()退出。其原因就在于调用 exit()函数终止进程时会刷新进程的 stdio 缓冲区。我们来看看_exit()和exit()的区别吧!
exit()函数会执行的动作如下:
⚫ 如果程序中注册了进程终止处理函数,那么会调用终止处理函数。
⚫ 刷新 stdio 流缓冲区。
⚫ 执行_exit()系统调用。
也就是exit()多执行了一些步骤。stdio 流缓冲区是干嘛的呢?如果我们需要printf一个字符串,比如printf(“nice/n”),标准输出设备默认使用的是行缓冲,当检测到换行符\n 时会立即显示函数 printf()输出的字符串,也就是会立即读走缓冲区中的数据并显示,读走之后此时缓冲区就空了。但是如果使用printf(“nice”)时,没有\n,就不会被立即读走,等子进程建立以后,用exit()会让子进程刷新stdio缓冲区,那父子进程就会各printf一次,也就是一共print2次。

15.wait()函数

有时设计需要监视子进程的终止时间以及终止时的一些状态信息,在某些设计需求下这是很有必要的。系统调用 wait()可以等待进程的任一子进程终止,同时获取子进程的终止状态信息。

16.waitpid()函数

waitpid()函数可以识别特定的进程或进程组的所有子进程,当然也是识别任意进程。
使用 wait()系统调用存在着一些限制,这些限制包括如下:
⚫ 如果父进程创建了多个子进程,使用 wait()将无法等待某个特定的子进程的完成,只能按照顺序等待下一个子进程的终止,一个一个来、谁先终止就先处理谁;
⚫ 如果子进程没有终止,正在运行,那么 wait()总是保持阻塞,有时我们希望执行非阻塞等待,是否有子进程终止,通过判断即可得知;
⚫ 使用 wait()只能发现那些被终止的子进程,对于子进程因某个信号(譬如 SIGSTOP 信号)而停止(注意,这里停止指的暂停运行),或是已停止的子进程收到 SIGCONT 信号后恢复执行的情况就无能为力了。

17.waitid()函数

waitid() 也和wait(),waitpid()类似,但提供了更多的扩展功能,这里就不展开了。

18.孤儿进程

孤儿进程就是父进程先于子进程结束了,这时候子进程就变成孤儿进程了,此时我们调用 getppid()寻找父进程的pid,将返回 1,也就是int进程变成了孤儿进程的“养父”。这是判定某一子进程的“生父”是否还“在世”的方法之一。

19.僵尸进程

进程结束之后,通常需要其父进程为其“收尸”,回收子进程占用的一些内存资源,父进程通过调用wait()(或其变体 waitpid()、waitid()等)函数回收子进程资源,归还给系统,子进程就会被内核彻底删除。但是如果父进程还没来得及收尸,那子进程就会变成僵尸进程。或者父进程并没有调用 wait()函数然后就退出了,那么此时 init 进程将会接管它的子进程并自动调用 wait(),故而从系统中移除僵尸进程。理论上来说,僵尸进程是程序设计有问题的,如果存在很多僵尸进程,将会导致填满内核进程表,从而阻碍新进程的创建。需要注意的是,僵尸进程是无法通过信号将其杀死的,即使是“一击必杀”信号 SIGKILL 也无法将其杀死,那么这种情况下,只能杀死僵尸进程的父进程。所以!子进程结束,记得用wait函数回收!!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值