《Linux应用进程控制(二) — 进程的三种创建方式fork、vfork、clone及退出exit》

1.进程的创建

1.1 fork

#include <unistd.h>

pid_t fork(void);
返回值:
    父进程返回的是新建的子进程的进程ID,子进程返回的是0

  在fork的返回值中,最好是判断3个值,一个等于0、大于0以及小于0(fork失败的场景)。

  当调用完fork函数后,子进程获得父进程的数据空间、堆和栈,但是这是子进程单独拥有的,并不和父进程共享,因此修改子进程的变量不会影响父进程的变量。父进程和子进程共享正文段。

  由于在fork之后经常跟随着exec,所以现在的很多实现并不执行一个父进程数据段、栈和堆得完全副本,作为代替,使用了写时复制(copy-on-write,COW)技术。

  写时复制:写时拷贝是一种可以推迟甚至避免拷贝数据的技术。内核此时并不复制整个进程的地址空间,而是让父子进程共享同一个地址空间。只用在需要写入的时候才会复制地址空间,从而使各个进行拥有各自的地址空间。也就是说,资源的复制是在需要写入的时候才会进行,在此之前,只有以只读方式共享。这种技术使地址空间上的页的拷贝被推迟到实际发生写入的时候。在页根本不会被写入的情况下---例如,fork()后立即执行exec(),地址空间就无需被复制了。

  不使用写时复制的缺点:传统的fork()系统调用直接把所有的资源复制给新创建的进程。这种实现过于简单并且效率低下,因为它拷贝的数据或许可以共享。但是如果新进程立即执行了exec()函数,那么之前的拷贝就全都浪费了。

#include "apue.h"

int globvar = 6;
char buf[] = "a write to stdout \n";

int main(void)
{
  int var;
  pid_t pid;

  var = 88;
  if(write(STDOUT_FILENO, buf, sizeof(buf) - 1) != sizeof(buf) -1)
  {
     err_sys("write_error");
  }    
  printf("before fork\n");

  if((pid = fork()) < 0)
  {
     err_sys("fork error");
  }  
  else if(pid == 0)
  {
    globvar++;
    var++;
  }
  else
  {
    sleep(2);
  }

  printf("pid=%ld, glob=%d, var=%d\n",(long)getpid(), globvar, var);

  exit(0);
}

  $ ./a.out

a write to stdout
before fork
pid=430,glob=7,var=69     //子进程的变量值改变了
pid=430,glob=6,var=68    //父进程的变量值没有改变
由此可以证明:父子进程不同享数据。

  $ ./a.out > temp.out

  $ cat temp.out

a write to stdout
before fork
pid=430,glob=7,var=69     //子进程的变量值改变了
before fork
pid=430,glob=6,var=68    //父进程的变量值没有改变

  问题:这边为什么会输出两个before fork?

   因为将标准输出重定向到一个文件时,这个时候标准I/O就变成了一个全缓冲区(当缓冲区或者程序结束才会将缓冲区的数据输出),所以在第一次printf的时候,因为缓冲区没有满所以不会将数据打印出来,而是存在缓冲区中。fork函数将父进程复制完后,会将这个缓冲区也复制下来,所以在子程序的第二个printf中,会在已有的缓冲区数据后面再增加数据,最后在子程序的第二个printf中就会将两个数据一起输出出来。

注意:sizeof(buf)计算出buf的长度,write的时候长度要减去1。原因是sizeof计算包括终止null字节的缓冲区长度。

#include <unistd.h>

int main(int argc, char *argv[])
{
	char buf[] = "a write to stdout";
	int ret, len;

	printf("buf len is %d\n", sizeof(buf));

	ret = write(STDOUT_FILENO, buf ,sizeof(buf)-1);
	printf("\n");

	return 0;
}

  结果:

  

  如果把buf[ ]改成buf[17]的话,sizeof(buf)就不能减去1。

  拓展知识:字符串数组的初始化

fork父子进程后文件的关系?

  执行 fork 函数,内核会复制父进程所有的文件描述符。对于父进程打开的所有文件,子进程也是可以操作的。

  以读写文件为例,父子进程同时读写文件,会导致文件偏移量改变,但是父子进程共用文件偏移量,就会出现文件读写位置错误。
  所以在fork之后执行exec后,子进程应该关闭复制的文件描述符:

  Linux 引入了 close on exec 机制。设置了 FD_CLOSEXEC 标志位的文件,在子进程调用 exec 家族函数时会将相应的文件关闭。而设置该标志位的方法有两种:

  • open 时,带上 O_CLOSEXEC 标志位。
  • open 时如果未设置,那就在后面调用 fcntl 函数的 F_SETFD 操作来设置

  建议使用第一种方法。原因是第二种方法在某些时序条件下并不那么绝对的安全。

 

1.2 vfork 

#include <sys/types.h>
#include <unistd.h>

pid_t vfork(void);

  vfork的使用和fork是一样的。

vfork和fork的区别:

执行次序:

  vfork是先调用子进程,等子进程的_exit(exit是不正确的)或exec被调用后,再调用父进程(在此之前,父进程一直被挂起)。

  fork对父子进程的调度室由调度器决定的。

数据段的影响:

  fork采用的是写时复制技术。

  vfork的父子进程是共享数据的,所以在子程序中修改变量,父进程的变量也会被修改。(在fork中不会这样)

  总结:vfork创建的子进程调用exec前,与父进程是共享一个地址空间的(根本不存在复制的这个步骤,因此直接执行exec效率方面比fork快)。但是需要注意的是:如果子进程修改数据、进行函数调用或者没有调用exec或exit就直接return(return后,会释放局部变量并弹栈。但是因为vfork是共享父进程的地址空间,那么换句话说也就是return掉了main函数。return后还会调用类似exit这样的退出函数。那么就又回到父进程的vfokr往下运行,但是由于之前子进程return掉了main函数的栈,所以会出现段错误。这就类似C++中return会调用局部对象的析构函数,exit不会,是直接退出)会总成未知的后果。

 

1.3 clone函数

#include <sched.h>

int clone(int (*fn)(void*), void *child_stack, int flags, void *arg); 

返回值:成功返回子线程的ID号,失败返回-1

参数:
  fn:是函数指针,我们知道进程的4要素,这个就是指向程序的指针,就是所谓的“剧本”
  child_stack:是为子进程分配系统堆栈空间(在linux下系统堆栈空间是2页面,就是8K的内存,其中在这块内存中,低地址上放入了值,这个值就是进程控制块task_struct的值)
  flags:标志用来描述你需要从父进程继承那些资源
    CLONE_PARENT:创建的子进程的父进程是调用者的父进程,新进程与创建它的进程成了“兄弟”而不是“父子”
    CLONE_FS:子进程与父进程共享相同的文件系统,包括root、当前目录、umask
    CLONE_FILES:子进程与父进程共享相同的文件描述符(file descriptor)表
    CLONE_NEWNS:在新的namespace启动子进程,namespace描述了进程的文件hierarchy
    CLONE_SIGHAND:子进程与父进程共享相同的信号处理(signal handler)表
    CLONE_PTRACE:若父进程被trace,子进程也被trace
    CLONE_VFORK:父进程被挂起,直至子进程释放虚拟内存资源
    CLONE_VM:子进程与父进程运行于相同的内存空间
    CLONE_PID:子进程在创建时PID与父进程一致
    CLONE_THREAD:Linux 2.4中增加以支持POSIX线程标准,子进程与父进程共享相同的线程群
  arg:传给子进程的参数

  clone可以让你有选择性的继承父进程的资源,你可以选择想vfork一样和父进程共享一个虚存空间,从而使创造的是线程,你也可以不和父进程共享,你甚至可以选择创造出来的进程和父进程不再是父子关系,而是兄弟关系。

  下面的例子是创建一个线程(子进程共享了父进程虚存空间,没有自己独立的虚存空间不能称其为进程)。父进程被挂起当子线程释放虚存资源后再继续执行。

#include <stdio.h>
#include <malloc.h>

#include <sched.h>
#include <signal.h>

#include <sys/types.h>
#include <unistd.h>


#define FIBER_STACK 8192
int a;
void * stack;

int do_something()
{
    printf("This is son, the pid is:%d, the a is: %d\n", getpid(), ++a);
    free(stack); //这里我也不清楚,如果这里不释放,不知道子线程死亡后,该内存是否会释放,知情者可以告诉下,谢谢
    exit(1);
}

int main()
{
    void * stack;
    a = 1;
    stack = malloc(FIBER_STACK);//为子进程申请系统堆栈

    if(!stack)
    {
        printf("The stack failed\n");
        exit(0);
    }
    printf("creating son thread!!!\n");

    clone(&do_something, (char *)stack + FIBER_STACK, CLONE_VM|CLONE_VFORK, 0);//创建子线程

    printf("This is father, my pid is: %d, the a is: %d\n", getpid(), a);
    exit(1);
}

  

2.进程的终止

  在不考虑线程的情况下,进程的退出有以下 5 种方式:
正常退出有 3 种:
  · 从 main 函数 return 返回
  · 调用 exit
  · 调用 _exit
异常退出有两种:
  · 调用 abort
  · 接收到信号,由信号终止

2.1 _exit()函数

#include <unistd.h>
void _exit(int status);

  _exit 函数中 status 参数定义了进程的终止状态,父进程可以通过 wait ()来获取该状态值。

  需要注意的是返回值,虽然 status 是 int 型,但是仅有低 8 位可以被父进程所用。所以写 exit ( -1 )结束进程时,在终端执行 “$ ? ” 会发现返回值是 255。

 

2.2 exit()函数

#include <stdlib.h>
void exit(int status);

exit ()函数的最后也会调用 _exit ()函数,但是 exit 在调用 _exit 之前,还做了其他工作:
  1 )执行用户通过调用 atexit 函数或 on_exit 定义的清理函数。
  2 )关闭所有打开的流( stream ),所有缓冲的数据均被写入( flush ),通过 tmpfile 创建的临时文件都会被删除。
  3 )调用 _exit


2.3 return()函数

  执行 return ( n )等同于执行 exit ( n )。

 

 

 

 

 

 

 

 

 

 

 

posted @ 2020-06-12 15:48  一个不知道干嘛的小萌新  阅读(829)  评论(0编辑  收藏  举报