1.1 Linux中的进程 --fork、孤儿进程、僵尸进程、文件共享分析

时间:2023-12-30 11:49:44

  操作系统经典的三态如下:

1、就绪态

2、等待(阻塞)

3、运行态

  其转换状态如下图所示:

1.1   Linux中的进程 --fork、孤儿进程、僵尸进程、文件共享分析

  操作系统内核中会维护多个队列,将不同状态的进程加入到不同的队列中,其中撤销是进程运行结束后,由内核收回。

  以上的三态是操作系统原理中给出的,但是各个操作系统的平台实现这些状态的时候是有差异的,例如linux操作系统中进程的状态有以下几种:

1、运行状态(TASK_RUNNING)

2、可中断睡眠状态(TASK_INTERRUPTIBLE)

3、不可中断睡眠状态(TASK_UNINTERRUPTIBLE)

4、暂停状态(TASK_STOPPED)

5、僵死状态(TASK_ZOMBIE)

这些状态之间的转换如下所示:

1.1   Linux中的进程 --fork、孤儿进程、僵尸进程、文件共享分析

  linux中的进程状态是包含经典三态的,只不过分类更加细化,可中断睡眠和不可中断睡眠对应于阻塞态,就绪态也被认为是运行态的一种,也用TASK_RUNNING标识,而运行态又分为用户空间运行态和内核空间运行态,此外还多出了暂停状态和僵死状态。

小知识:

  linux内核加载完成后会自己创建一个0号进程(空闲进程),创建方式不同于普通进程,然后再创建一个1号进程,其中一号进程就是/sbin/init。linux中进程的最大数量是有限的,可以通过命令  cat  /proc/sys/kernel/pid_max查看,一般默认值是32768。

  进程是操作系统对资源的一种抽象,一个进程包括代码段、数据段、堆栈段和进程控制块,进程控制块是操作系统管理进程的一个重要的数据结构。一个进程只能对应一个程序(这里的一个程序指的是一个可加载可执行程序。如果一个程序是一个文件,那么一个进程可以对应多个程序(文件),例如:可加载可执行程序和多个动态库程序(文件)可运行在一个进程中),一个程序可以对应多个进程。

  linux中创建进程的系统调用时fork,fork函数一次调用,两次返回,子进程和父进程在各自的用户空间返回。fork一个新进程时,具体有哪些东西被生成或者拷贝了呢?在暂时不考虑COW(写时拷贝)的情况下,fork一个进程时,内核会创建一个新的PCB(进程控制块),此外还会拷贝父进程的数据段、堆栈段等。新创建的进程进入内核中的就绪队列。

  查看fork的具体使用方法可以使用man命令查看。linux中的man命令共有以下几个章节:

1.1   Linux中的进程 --fork、孤儿进程、僵尸进程、文件共享分析

  fork失败时,只返回一次,返回值是-1,并设置全局的errno。fork成功返回时,父进程返回的是子进程的pid,这样可以让父进程知道子进程的pid,方便对子进程进行控制,父进程可能会fork很多子进程,将子进程id返回给父进程可以使父进程将这些pid保存组织起来,能轻松的对子进程进行控制,如果不是这样的话,那父进程要想找到一个子进程可能需要遍历很多复杂的数据结构,增加了复杂性。子进程从fork返回时返回的是0。父进程和子进程是一对多的关系,子进程获取父进程的pid是很方便的。

  看一个fork示例:

 #include <sys/types.h>
#include <unistd.h> #include <stdlib.h>
#include <stdio.h>
#include <string.h> #include <signal.h>
#include <errno.h> int main(void)
{
pid_t pid;
signal(SIGCHLD, SIG_IGN);
printf("before fork pid : %d \n", getpid()); int abc = ;
pid = fork(); if(- == pid)
{
perror("pid < 0 err");
return -;
}
if(pid > )
{
abc++;
printf("parent : pid : %d \n", getpid());
printf("abc : %d \n", abc);
sleep();
} if( == pid)
{
abc++;
printf("child : %d, parent : %d\n", getpid(), getppid());
printf("abc : %d \n", abc);
}
return ;
}

  运行结果如下:

1.1   Linux中的进程 --fork、孤儿进程、僵尸进程、文件共享分析

可以看到成功创建了子进程,而且父进程和子进程各有一份abc变量的副本。

  再看下一个fork小程序:

 #include <unistd.h>
#include <stdio.h> int main()
{
fork();
fork();
fork(); printf("Hello fork ...\n");
return ;
}

  

这个程序中fork“分裂”的结果如下所示:

1.1   Linux中的进程 --fork、孤儿进程、僵尸进程、文件共享分析

因此,最后会打印出8条语句,如下所示:

1.1   Linux中的进程 --fork、孤儿进程、僵尸进程、文件共享分析

如果想让一个父进程按如下图所示的方式产生多个子进程该怎么办呢?

1.1   Linux中的进程 --fork、孤儿进程、僵尸进程、文件共享分析

直接给出如下的程序:

 #include <sys/types.h>
#include <unistd.h> #include <stdlib.h>
#include <stdio.h>
#include <string.h> #include <signal.h>
#include <errno.h> void TestFunc(int num)
{
printf("TestFunc : %d\n", num);
} int main(void)
{
int i = ;
int j = ; int ProcNum = ;
int LoopNum = ; printf("please enter the ProcNum : \n");
scanf("%d", &ProcNum); printf("please enter the LoopNum : \n");
scanf("%d", &LoopNum); pid_t pid; for(i = ; i < ProcNum; i++)
{
pid = fork(); if( == pid)
{
for(j = ; j < LoopNum; j++)
{
TestFunc(j);
} exit();
}
} printf("parent process exit\n");
return ;
}

运行时,我们输入产生的进程数和每个进程循环的次数,执行结果如下:

1.1   Linux中的进程 --fork、孤儿进程、僵尸进程、文件共享分析

写时拷贝:

  父进程fork出子进程后,这时两个进程有各自的虚拟地址空间,但是它们相同的虚拟地址空间映射到了同一片物理内存中的代码段、数据段、堆栈段,也就是说它们是 共享物理内存中的数据的,只有当子进程或者父进程试图去修改这些物理内存中的数据时,才会触发缺页异常,完成真正的物理内存的拷贝,并修改进程相应的页表,而这个真正的拷贝也不是拷贝所有物理内存,而是拷贝进程真正修改的位置所在的页框,等到下次再访问到其他页框是再去拷贝其他页框。

孤儿进程和僵尸进程:

  1.  如果父进程先退出,子进程还没有退出,这时子进程就成了孤儿进程,子进程的父进程会被内核设置成1号进程即init进程(注:任何一个进程都必须有父进程)。
  2. 如果子进程先退出,父进程还没有退出,那么子进程必须等到父进程捕获到了子进程的退出状态才真正结束,否则这个时候子进程就成为僵尸进程。

孤儿进程实验,程序如下:

 #include <sys/types.h>
#include <unistd.h> #include <stdlib.h>
#include <stdio.h>
#include <string.h> #include <signal.h>
#include <errno.h> int main(void)
{
pid_t pid;
printf("before fork pid : %d \n", getpid()); pid = fork(); if(- == pid)
{
perror("pid < 0 err");
return -;
}
if(pid > )
{
printf("parent : pid : %d \n", getpid());
} if( == pid)
{
printf("child : %d, parent : %d\n", getpid(), getppid());
sleep();
}
return ;
}

上面程序中让父进程先死,子进程睡眠100秒后死,执行结果如下:

1.1   Linux中的进程 --fork、孤儿进程、僵尸进程、文件共享分析

可见,a.out进程的父进程变成了1号进程。

下面演示僵尸进程,程序如下:

 #include <sys/types.h>
#include <unistd.h> #include <stdlib.h>
#include <stdio.h>
#include <string.h> #include <signal.h>
#include <errno.h> int main(void)
{
pid_t pid;
printf("before fork pid : %d \n", getpid()); pid = fork(); if(- == pid)
{
perror("pid < 0 err");
return -;
}
if(pid > )
{
printf("parent : pid : %d \n", getpid());
sleep();
} if( == pid)
{
printf("child : %d, parent : %d\n", getpid(), getppid());
}
return ;
}

让子进程先死,父进程睡眠20秒,先不收尸,执行结果如下:

1.1   Linux中的进程 --fork、孤儿进程、僵尸进程、文件共享分析

图中显示子进程4003处于defunct状态,也即僵尸状态。

  在程序中我们应该怎么避免僵尸进程呢?那就是在创建子进程的时候,我们要告诉内核,子进程结束时,我们不准备收尸,这样的话内核就会来完成这件事。告诉内核不收尸这件事是通过signal函数来完成的,在调用fork之前,我们使用signal(SIGCHLD, SIG_IGN)告诉内核不收尸,子进程死了之后,内核会向其父进程发送SIGCHID信号,而这个调用的含义就是告诉内核:父进程忽略SIGCHLD这个信号,SIG_IGN是忽略的意思,这句话同时也是告诉内核让内核来收尸。

  更改程序如下:

 #include <sys/types.h>
#include <unistd.h> #include <stdlib.h>
#include <stdio.h>
#include <string.h> #include <signal.h>
#include <errno.h> int main(void)
{
pid_t pid;
printf("before fork pid : %d \n", getpid()); signal(SIGCHLD, SIG_IGN);
pid = fork(); if(- == pid)
{
perror("pid < 0 err");
return -;
}
if(pid > )
{
printf("parent : pid : %d \n", getpid());
sleep();
} if( == pid)
{
printf("child : %d, parent : %d\n", getpid(), getppid());
}
return ;
}

  以上程序只是添加了第16行,运行结果如下:

1.1   Linux中的进程 --fork、孤儿进程、僵尸进程、文件共享分析

如果想知道系统支持哪些信号可以使用kill -l命令,如下:

1.1   Linux中的进程 --fork、孤儿进程、僵尸进程、文件共享分析

进程间的文件共享:

  父进程打开一个文件,然后调用了fork,文件描述符会复制给子进程吗?两个进程同时读写文件会互相影响吗?下面我们一一进行分析,首先给出打开文件的一个测试程序,如下:

 #include <sys/types.h>
#include <unistd.h> #include <stdlib.h>
#include <stdio.h>
#include <string.h> #include <signal.h>
#include <errno.h> #include <sys/stat.h>
#include <fcntl.h> int main(void)
{
pid_t pid;
int fd = ; printf("before fork pid : %d \n", getpid());
signal(SIGCHLD, SIG_IGN); fd = open("./1.txt", O_RDWR);
if(fd == -)
{
perror("open");
return -;
} pid = fork(); if(- == pid)
{
perror("pid < 0 err");
return -;
}
if(pid > )
{
printf("parent : pid : %d \n", getpid());
sleep();
} if( == pid)
{
printf("child : %d, parent : %d\n", getpid(), getppid());
}
return ;
}

第23行,我们打开当前目录下的1.txt,当这个文件不存在时,open返回-1,我们使用perror打印返回-1时的错误信息,执行程序,输出如下:

1.1   Linux中的进程 --fork、孤儿进程、僵尸进程、文件共享分析

执行上述程序时,1.txt不存在,open返回了-1,出错信息的那一行中open字符串使,是我们传给perror函数的,其余的信息是perror根据error出错号打印的信息。我们在调用perror时,只需要给它传入一个“标题“即可,在本例中我们传入的标题即是“open”。我们在当前目录创建1.txt后再次执行程序就不会出错了。

  我们在父进程和子进程中同时向文件中写数据,程序如下:

 #include <sys/types.h>
#include <unistd.h> #include <stdlib.h>
#include <stdio.h>
#include <string.h> #include <signal.h>
#include <errno.h> #include <sys/stat.h>
#include <fcntl.h> int main(void)
{
pid_t pid;
int fd = ; printf("before fork pid : %d \n", getpid());
signal(SIGCHLD, SIG_IGN); fd = open("./1.txt", O_RDWR);
if(fd == -)
{
perror("open");
return -;
} pid = fork(); if(- == pid)
{
perror("pid < 0 err");
return -;
}
if(pid > )
{
printf("parent : pid : %d \n", getpid());
write(fd, "parent", );
} if( == pid)
{
printf("child : %d, parent : %d\n", getpid(), getppid());
write(fd, "child", );
} return ;
}

  运行结果如下:

1.1   Linux中的进程 --fork、孤儿进程、僵尸进程、文件共享分析

从上图中可以看到多次运行的结果都不一样,可见父进程和子进程写数据的顺序是不一定的,也有可能是交叉写入的,会相互影响。父进程在fork时将文件描述符复制给了子进程。要想关闭一个这个文件,必须在父进程和子进程中都执行一次close操作才会真正将文件关闭。

  父子进程共享文件的示意图如下:

1.1   Linux中的进程 --fork、孤儿进程、僵尸进程、文件共享分析

每一个进程中会有一个 文件描述符表,表中的每一项都是一个指向文件表的指针,文件描述符fd只是文件描述符表的下标。文件表是真正描述一个文件状态的数据结构,从上图可以看出,虽然父进程和子进程各有一份文件描述符表,但是它们是共享同一个文件表的,而且文件表中有一项是引用计数,表示有几个进程正在引用它,这些共享文件表的进程当然也是共享当前文件偏移量的,所以它们在写入数据时,会发生顺序错乱,但是不会覆盖。  

  多个进程打开一个文件会对应多个不同的文件表,这时候文件表不再是共享的,它们维护各自的偏移量,但是多个文件表最终还是指向相同的inode,因为是同一个物理文件。同一个进程打开一个文件多次,也不会共享同一个文件表,而是每个文件描述符各自对应一个文件表。使用fork产生的父子进程之间会共享同一个文件表。