写点什么

Linux 进程知识干货|收藏

用户头像
赖猫
关注
发布于: 2021 年 01 月 05 日

Linux 进程知识干货|收藏


1、进程的创建:

一般使用 fork、vfork 或者 clone 创建进程。

进程号 PID: 标志进程的一个非负整型数。getpid()

父进程号 PPID: 任何进程(除了 init 进程,进程号为 1,进程号为 0 的是调度进程)都是由另一个进程创建,该进程称为被创建进程的父进程,其对应的进程号就是父进程号。getppid()

进程组号 PGID: 进程组是一个或多个进程的集合。他们之间相互关联,进程组可以接收同一终端的各种信号,关联的进程有一个进程组号 PGID。getpgid()


fork 函数

	定义:pid_t fork(void);	返回值:若成功调用一次则返回两个值,子进程返回0,父进程返回子进程ID;否则,出错返回-1。
复制代码
  • 使用 fork 函数得到的子进程是父进程的一个复制品,它从父进程处继承了整个进程的地址空间,包括进程上下文、进程堆栈、打开的文件描述符、信号控制设定、 进程优先级、进程组号等。例如,要是父进程打开了五个文件,那么子进程也有五个打开的文件,而且这些文件的当前读写指针也停在相同的地方。所以,这一步所做的是复制。这样得到的子进程独立于父进程,具有良好的并发性,但是二者之间的通讯需要通过专门的通讯机制。子进程所独有的只有它的进程号、计时器等。使用 fork 函数的代价是很大的,这些开销并不是所有的情况下都是必须的。比如某进程 fork 出一个子进程后,其子进程仅仅是为了调用 exec 执行另一个可执行文件,那么在 fork 过程中对于虚存空间的复制将是一个多余的过程。但由于现在 Linux 中是采取了 copy-on-write(COW 写时复制)技术,为了降低开销,fork 最初并不会真的产生两个不同的拷贝,因为在那个时候,大量的数据其实完全是一样的。写时复制是在推迟真正的数据拷贝。若后来确实发生了写入,那意味着 parent 和 child 的数据不一致了,于是产生复制动作,每个进程拿到属于自己的那一份,这样就可以降低系统调用的开销。所以有了写时复制后,vfork 其实现意义就不大了。

  • 在 fork 之后,子进程和父进程都会继续执行 fork 调用之后的指令。子进程是父进程的副本。它将获得父进程的数据空间,堆和栈的副本,这些都是副本,父子进程并不共享这部分的内存。也就是说,子进程对父进程中的同名变量进行修改并不会影响其在父进程中的值。但是父子进程又共享一些东西,简单说来就是程序的正文段。正文段存放着由 cpu 执行的机器指令,通常是 read-only 的。

  • 值得注意的是,子进程也是继承父进程的缓冲区的(全缓冲,在填满标准 I/O 缓冲区后,才进行实际的 I/O 操作;行缓冲,在遇到换行符时,标准 I/O 库进行实际 I/O 操作。),所以打印输出时在不进行特殊处理的情况下会出现交叉打印。

  • 有些打印函数,如 printf,在输出到屏幕上时是行缓冲,但是重定向输出到文件中却变成了全缓冲。比如程序 a.out 中使用了 printf 进行输出,如果 a.out > log,则 printf 是按照全缓冲处理的。


vfork 函数

 定义:pid_t vfork(void); 
复制代码

vfork 和 fork 的区别:

  • vfork 保证子进程先运行,父进程会被阻塞直到子进程调用 exec 或 exit 之后,父进程才可能被调度运行。

  • vfork 和 fork 一样都创建一个子进程,但它并不将父进程的地址空间完全复制到子进程中,因为子进程会立即调用 exec(或者 exit),于是也就不访问该地址空间。 相反,在子进程调用 exec 和 exit 之前,它在父进程的地址空间中运行,在 exec 之后子进程会有自己的进程地址空间。


clone 函数

定义:int clone(int (*fn)(void *), void *child_stack, int flags, void *arg);
复制代码

系统调用 fork()和 vfork()是无参数的,而 clone()则带有参数。fork()是全部复制,vfork()是共享内存,而 clone() 是则可以将父进程资源有选择地复制给子进程,而没有复制的数据结构则通过指针的复制让子进程共享,具体要复制哪些资源给子进程,由参数列表中的 clone_flags 来决定。另外,clone()返回的是子进程的 pid。

	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线程标准,子进程与父进程共享相同的线程群
复制代码

例子:

  #include <stdio.h>    #include <stdlib.h>    #include <sched.h>    #include <unistd.h>    #include <fcntl.h>    #include <sys/types.h>    #include <sys/stat.h>    int variable,fd;    int do_something(){ 	  variable = 42; 	 	  printf("in child process\n"); 	 	  close(fd); 	 	  return 0;    }    int main(int argc, char *argv[]){ 	  void *child_stack;  	  char tempch; 	 	  variable = 9; 	 	  fd = open("/test.txt",O_RDONLY);  	  child_stack = (void *)malloc(16384);		  printf("The variable was %d\n",variable); 	 	  clone(do_something, child_stack+10000, CLONE_VM |CLONE_FILES,NULL); 	 	  sleep(3); /* 延时以便子进程完成关闭文件操作、修改变量 */ 	 	  printf("The variable is now %d\n",variable); 	 	  if(read(fd,&tempch,1) < 1){ 		  perror("File Read Error");  		  exit(1);    	}  	  printf("We could read from the file\n");  	  return 0;    } 
  运行结果:   the value was 9   in child process   The variable is now 42   File Read Error
复制代码

2、sleep 函数

进程挂起指定的秒数,直到指定的时间用完或者收到信号才解除挂起。注意:进程挂起指定的秒数后程序并不会立即执行,系统只是将此进程切换到了就绪态。

3、wait 函数

工作原理:

父进程调用wait函数后阻塞,子进程结束时,系统向其父进程发送SIGCHILD信号。父进程被SIGCHILD信号唤醒然后去回收僵尸子进程,父子进程之间是异步的,SIGCHILD信号机制就是为了解决父子进程之间的异步通信问题,让父进程可以及时的去回收僵尸子进程。若父进程没有任何子进程则wait返回错误。当前进程有可能有多个子进程,wait函数阻塞直到其中一个子进程结束wait就会返回,wait的返回值就可以用来判断到底是哪一个子进程本次被回收了。
pid_t wait(int *status); 功能:等待子进程终止,如果终止了,此函数会回收子进程的资源。调用wait函数的进程会挂起,直到它的一个子进程退出或收到一个不能被忽视的信号时才被唤醒。 若调用进程没有子进程或它的子进程已经结束,该函数立即返回。 参数:函数返回时,参数status中包含子进程退出时的状态信息。子进程的退出信息在一个int中包含了多个字段,用宏定义可以取出其中的每个字段。 返回值:如果执行成功则返回子进程的进程号,出错返回-1,失败原因存于errno中。 1、WIFEXITED(status) 这个宏用来指出子进程是否为正常退出的,如果是,它会返回一个非零值。 2、WEXITSTATUS(status) 当WIFEXITED返回非零值时,我们可以用这个宏来提取子进程的返回值,如果子进程调用exit(5)退出,WEXITSTATUS(status) 就会返回5;如果子进程调用exit(7),WEXITSTATUS(status)就会返回7。请注意,如果进程不是正常退出的,也就是说, WIFEXITED返回0,这个值就毫无意义。
复制代码

waitpid 函数

	pid_t waitpid(pid_t pid, int *status,int options)		- pid>0时,只等待进程ID等于pid的子进程,不管其它已经有多少子进程运行结束退出了,只要指定的子进程还没有结束,waitpid就会一直等下去。		- pid=-1时,等待任何一个子进程退出,没有任何限制,此时waitpid和wait的作用一模一样。		- pid=0时,等待同一个进程组中的任何子进程,如果子进程已经加入了别的进程组,waitpid不会对它做任何理睬。		- pid<-1时,等待一个指定进程组中的任何子进程,这个进程组的ID等于pid的绝对值。			options提供了一些额外的选项来控制waitpid,参数option可以为0或可以用"|"运算符把它们连接起来使用,比如:ret=waitpid(-1,NULL,WNOHANG | WUNTRACED);	如果我们不想使用它们,也可以把options设为0,如:ret=waitpid(-1,NULL,0);		- WNOHANG 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若结束,则返回该子进程的ID。		- WUNTRACED 若子进程进入暂停状态,则马上返回,但子进程的结束状态不予以理会。WIFSTOPPED(status)宏确定返回值是否对应于一个暂停子进程。
复制代码

注: wait 就是经过包装的 waitpid。

	static inline pid_t wait(int *wait_stat)	{		return waitpid(-1,wait_stat,0);	}
复制代码

如果父进程属于守护进程一类,开启 TCP 套接字等待链接,每当有请求到来,便 fork 一个子进程传输信息并自由退出。父进程并不关注子进程的退出状态,是否正常都不影响今后的服务,但子进程变成僵尸进程便麻烦了,随着时间的进行,僵尸进程一大堆,虽然占用资源不多,且终究是个隐患。此时可以利用信号处理方式进行非阻塞回收僵死子进程资源。

例子(子进程状态改变会发送SIGCHLD信号给父进程,父进程创建并回收多个子进程):
#include <stdlib.h> #include <stdio.h> #include <unistd.h> #include <sys/wait.h> #include <sys/stat.h> #include <sys/mman.h> #include <fcntl.h> #include <signal.h>
#define MY_PROCESS_COUNT 10
void child_catch(int signalNumber) {   //子进程状态发生改变时,内核对信号作处理的回调函数   int w_status;   pid_t w_pid;
  while ((w_pid = waitpid(-1, &w_status, WNOHANG)) != -1 && w_pid != 0) {     if (WIFEXITED(w_status)) //判断子进程是否正常退出       printf("---catch pid %d,return value %d\n", w_pid, WEXITSTATUS(w_status)); //打印子进程PID和子进程返回值   } }
int main(int argc, char **argv) {   pid_t pid;   int i;
  //在此处阻塞SIGCHLD信号,防止信号处理函数尚未注册成功就有子进程结束   sigset_t child_sigset;   sigemptyset(&child_sigset); //将child_sigset每一位都设置为0   sigaddset(&child_sigset, SIGCHLD); //添加SIGCHLD位   sigprocmask(SIG_BLOCK, &child_sigset, NULL); //完成父进程阻塞SIGCHLD的设置
  for (i = 0; i < MY_PROCESS_COUNT; i++) {     //创建子进程,创建成功就跳出循环     if ((pid = fork()) == 0)       break;   }
  if (MY_PROCESS_COUNT == i) { //括号内为父进程代码     struct sigaction act; //信号回调函数使用的结构体
    act.sa_handler = child_catch;     sigemptyset(&(act.sa_mask)); //设置执行信号回调函数时父进程的的信号屏蔽字     act.sa_flags = 0;     sigaction(SIGCHLD, &act, NULL); //给SIGCHLD注册信号处理函数
    //解除SIGCHLD信号的阻塞     sigprocmask(SIG_UNBLOCK, &child_sigset, NULL);     printf("im PARENT ,my pid is %d\n", getpid());
    while (1){ //父进程在这里处理自己的相关事情,同时回收僵死子进程资源 //父进程运行相关代码 }   } else {     //子进程执行代码     printf("im CHILD ,my pid is %d\n", getpid());     return i;   } }
复制代码

4、特殊进程

  • 僵尸进程:进程已运行结束,但进程占用的资源未被回收,这样的进程称为僵尸进程。子进程已运行结束,父进程未调用 wait 或者 waitpid 函数回收子进程的资源是 子进程变为僵尸进程的原因。

  • 孤儿进程:父进程运行结束,但子进程未运行结束的子进程。(在 bash 终端上,父进程结束后即会释放终端,子进程其实是在后台运行的。)

  • 守护进程:守护进程是个特殊的孤儿进程,这种进程脱离终端,在后台运行。

5、exit 函数

	#include <stdlib.h>	void exit(int status)	参数:status是返回给父进程的参数(低8位有效)。
复制代码

注:exit 用于在程序运行的过程中随时结束程序,exit 的参数是返回给 OS 的。main 函数结束时也会隐式地调用 exit 函数。exit 函数运行时首先会执行由 atexit()函数登记的函数,然后会做一些自身的清理工作,同时刷新所有输出流、关闭所有打开的流并且关闭通过标准 I/O 函数 tmpfile()创建的临时文件。exit 是结束一个进程,它将删除进程使用的内存空间,同时把错误信息返回父进程;而 return 是返回函数值并退出函数。通常情况:exit(0)表示程序正常, exit(1)和 exit(-1)表示程序异常退出,exit(2)表示表示系统找不到指定的文件。在整个程序中,只要调用 exit 就结束(当前进程或者在 main 时候为整个程序)。


_exit 函数

	#include <unistd.h>	void _exit(int status)	参数:status是返回给父进程的参数(低8位有效)。
复制代码

注:相比于 exit 函数,_exit 函数是系统调用,而 exit 函数是库函数。

6、exec 函数族

	int execl(const char *path, const char *arg, ...);	int execlp(const char *file, const char *arg, ...);	int execle(const char *path, const char *arg, ..., char *const envp[]);	int execv(const char *path, char *const argv[]);	int execvp(const char *file, char *const argv[]);	int execve(const char *path, char *const argv[], char *const envp[]);
复制代码

exec 函数族的作用是根据指定的文件名找到可执行文件,并用它来取代调用进程的内容,换句话说,就是在调用进程内部执行一个可执行文件。这里的可执行文件既可以是二进制文件,也可以是任何 Linux 下可执行的脚本文件。

与一般情况不同,exec 函数族的函数执行成功后不会返回,因为调用进程的实体,包括代码段,数据段和堆栈等都已经被新的内容取代,只留下进程 ID 等一些表面上的信息仍保持原样。只有调用失败了,它们才会返回一个-1,从原程序的调用点接着往下执行。所以通常我们直接在 exec 函数调用后直接调用 perror()和 exit(),无需 if 判断。

每当有进程认为自己不能为系统和用户做出任何贡献了,他就可以调用任何一个 exec,让自己以新的面貌重生;或者,更普遍的情况是,如果一个进程想执行另一个程序,它就可以 fork 出一个新进程,然后调用任何一个 exec,这样看起来就好像通过执行应用程序而产生了一个新进程一样。

事实上第二种情况应用得很普遍,以至于 Linux 专门为其作了优化,我们知道,fork 会将调用进程的所有内容原封不动的拷贝到新产生的子进程中去,这些拷贝的动作很消耗时间,而如果 fork 完之后我们马上就调用 exec,这些辛辛苦苦拷贝来的东西又会被立刻抹掉,这看起来非常不划算,于是人们设计了一种"写时拷贝(copy-on-write)"技术,使得 fork 结束后并不立刻复制父进程的内容,而是到了真正实用的时候才复制,这样如果下一条语句是 exec,它就不会白白作无用功了,也就提高了效率。

	带l的exec函数		这类函数有:execl,execlp,execle		具体说明:表示后边的参数以可变参数的形式给出且都以一个空指针结束。这里特别要说明的是,程序名也是参数,所以第一个参数就是程序名。
带p的exec函数 这类函数有:execlp,execvp 具体说明:表示第一个参数无需给出具体的路径,只需给出函数名即可,系统会在PATH环境变量中寻找所对应的程序,如果没找到的话返回-1。
带v的exec函数 这类函数有:execv,execvp 具体说明:表示命令所需的参数以char *arg[]形式给出且arg最后一个元素必须是NULL
带e的exec函数 这类函数有:execle 具体说明:将环境变量传递给需要替换的进程,原来的环境变量不再起作用。
复制代码


事实上,只有 execve 是真正的系统调用,其它五个函数最终都调用 execve,都是库函数。一个进程调用 exec 后,除了进程 ID,进程还保留了下列特征不变:父进程号、进程组号、控制终端、根目录、当前工作目录、进程信号屏蔽集、未处理信号,...

附:

在 Linux 中进程和线程实际上都是用一个结构体 task_struct 来表示一个执行任务的实体。进程创建调用 fork 系统调用,而线程创建则是 pthread_create 方法,但是这两个方法最终都会调用到 do_fork 来做具体的创建操作 ,区别就在于传入的参数不同。Linux 实现线程的方式很巧妙,实际上根本没有线程,它创建的就是进程,只不过通过参数指定多个进程之间共享某些资源(如虚拟内存、页表、文件描述符等),函数调用栈、寄存器等线程私有数据则独立


小编推荐自己的Linux、C/C++技术交流群整理了一些个人觉得比较好的学习书籍、大厂面试题、有趣的项目和热门技术教学视频资料共享在里面(包括 C/C++,Linux,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK 等等.),有需要的可以自行添加哦!~



以上有不足的地方欢迎指出讨论,觉得不错的朋友希望能得到您的转发支持,同时可以持续关注我,每天分享 Linux C/C++后台开发干货内容!


用户头像

赖猫

关注

C/C++Linux服务器开发学习群960994558 2020.11.28 加入

纸上得来终觉浅,绝知此事要躬行

评论

发布
暂无评论
Linux进程知识干货|收藏