Linux:进程控制

这篇博客是我看《linux环境编程+从应用到内核》这本书中的知识点总结,若读者有看不懂的地方,可以参考书籍中的具体内容。

1,进程ID的分配
每个进程都会有自己的父进程,父进程又会有自己的父进程,最终都会追溯到init进程(pid=1)。关于init进程请参考链接init进程的前世今生

进程ID是唯一的,内核分配进程ID用延迟重用算法:
1>位图记录进程ID的使用情况(0为可用,1为已被占用)
2>将上次分配的ID记录到last_pid中,从last_pid+1开始找起,从位图中寻找可用的ID
3>如果找到位图集合的最后一位仍不可用,则回滚到位图集合的起始位置,从头开始找。
对于单线程的进程,调用getpid函数,其返回值就是当前进程的ID。对于多线程的进程,每一个线程调用getpid,其返回值都是进程ID

2,进程的层次
进程组是一组相关进程的集合,会话是一组相关进程组的集合。类似于在一个公司中,进程就是员工,会话相当于这个公司,而公司中的部门就是进程组。
PID:进程唯一标识符
PGID:进程组ID,默认情况下新创建的进程会继承父进程的进程组ID
SID:会话ID,默认情况下,新创建的进程会继承父进程的会话ID

获取进程组ID

pid_t getgrp(void);

获取会话ID

pid_t getsid(pid_t pid)

3,进程组

int setpgid(pid_t pid,pid_t pgid);

这个函数用来新建进程组或修改进程组id
pid指定的子进程,不能是会话首进程。子进程如果执行了exec函数,则不能修改子进程的进程组ID。
进程组和进程的关系:
这里写图片描述
带☆的表示进程组组长。
在命令结尾加“&”表示将命令放入后台执行,在任意时刻,可能同时存在多个后台进程,但只能存在一个前台进程。
当用户在终端输入(Crtl+C、Ctrl+Z等),对应的信号只会发送给前台进程组。

4,会话
这里写图片描述
会话的意义在于将很多的工作囊括在一个终端,选取其中一个作为前台来直接接收终端的输入及信号,其他工作则可以放在后台进行。

pid_t setsid(void);//创建会话

如果调用这个函数的进程不是进程组组长,那么会发生以下情景:
1)创建一个新会话,会话ID等于进程ID,调用进程成为会话的首进程
2)创建一个进程组,进程组ID等于进程ID,调用进程成为会话的进程组组长
3)该进程没有控制,如果调用setsid前,该进程有控制终端,这种联系就会断掉
调用setsid的进程不能是进程组的组长,否则会调用失败,返回-1

5,进程的创建之fork()

pid_t for(void);

fork成功向子进程返回0,失败返回-1。
注意:绝不能对父子进程执行顺序做出任何假设,如果确实需要某一特定的执行顺序,那么需要进程间通信的手段。

fork时子进程获得父进程数据空间、堆和栈的复制,所以变量的地址(当然是虚拟地址)也是一样的。

每个进程都有自己的虚拟地址空间,不同进程的相同的虚拟地址显然可以对应不同的物理地址。因此地址相同(虚拟地址)而值不同没什么奇怪。
具体过程是这样的:
fork子进程完全复制父进程的栈空间,也复制了页表,但没有复制物理页面,所以这时虚拟地址相同,物理地址也相同,但是会把父子共享的页面标记为“只读”(类似mmap的private的方式),如果父子进程一直对这个页面是同一个页面,知道其中任何一个进程要对共享的页面“写操作”,这时内核会复制一个物理页面给这个进程使用,同时修改页表。而把原来的只读页面标记为“可写”,留给另外一个进程使用。
这里写图片描述

这就是所谓的“写时复制”。正因为fork采用了这种写时复制的机制,所以fork出来子进程之后,父子进程哪个先调度呢?内核一般会先调度子进程,因为很多情况下子进程是要马上执行exec,会清空栈、堆。。这些和父进程共享的空间,加载新的代码段。。。,这就避免了“写时复制”拷贝共享页面的机会。如果父进程先调度很可能写共享页面,会产生“写时复制”的无用功。所以,一般是子进程先调度。

执行fork,内核会复制父进程所有的文件描述符。
创建线程和执行vfork,都不用复制父进程文件描述符每增加引用计数即可,对于fork而言,需要复制父进程的文件描述符(CLOSE_FILES),文件描述符的拷贝是通过内核的dup_fd函数来完成的。dup_struct会首先给子进程分配一个file_struct结构体,然后做一些赋值操作,这个结构体是进程描述符中与打开文件夹相关的数据结构,每一个打开的文件都会记录在该结构体中。
父子进程之间拷贝的是struct file的指针,而不是struct file实例,父子类型的struct file 类型指针,都指向struct file实例。

6,进程的创建之vfork
vfork会创建一个子进程,该子进程会共享父进程的内存数据,而且系统将保证子进程先于父进程获得调度,子进程也会共享父进程的地址空间,而父进程将被一直挂起,直到子进程退出或执行exec
注意:vfork后,子进程如果返回,则不要调用return,而应该使用_exit函数,如果使用return,就会出错。
一般来说,vfork创建的子进程会执行exec,执行完exec后,应该调用_exit返回,不是exit,因为exit会导致父进程stdio缓冲区的冲刷和关闭。

7,daemon进程的创建(守护进程)
特点:1)生命周期很长,一旦启动,正常情况下不会终止
2)在后台执行,并且不与任何控制终端相关联
创建一个daemon进程的步骤:
(1)执行一个fork函数,父进程退出,子进程继续。
原因:1>父进程有可能是进程组的组长(在命令行启动的情况下),从而不能够执行后面要执行的setsid函数,子进程继承了父进程的进程组ID,并且拥有自己的进程ID,一定不会是进程组的组长,所以子进程一定可以执行后面的setsid函数。
2>如果daemon是从终端命令行启动的,那么父进程退出会被shell检测到,shell会显示shell提示符,让子进程在后台执行
(2)子进程执行如下三个步骤,以摆脱与环境的关系
1>修改进程的当前目录为根目录 chdir(“/”);
2>调用setsid函数,这个函数的目的是切断与控制终端的所有关系,并且创建一个新的会话
3>设置文件格式创建掩码 umask(0)
(3)再次执行fork,父进程退出,子进程继续
确保daemon进程不是会话的首进程,才能保证打开的终端设备不会自动成为控制终端
(4)关闭标准输入(stdin),标准输出(stdout),标准错误(strerr)
一般关闭后,会打开/dev/null,并执行dup2函数,将0,1,2重定向到/dev/null,防止后面程序在文件描述符0,1和2上执行I/O库函数而导致报错。

8,进程的终止
正常退出:
(1)从main函数调用return
(2)调用exit
(3)调用_exit
异常退出:
(1)调用abort
(2)接收到信号,由信号终止

9,_exit函数

void _exit(int status)

status函数定义了进程的终止状态,父进程可以通过wait()来获取该状态值,status仅有低8位可以被父进程所用,所以写exit(-1)结束进程时,在终端执行“$?”会返回255
用户调用_exit(),本质是调用exit_group系统调用。

10,exit函数

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

exit()最后也会调用_exit,但在调用之前还做了:
(1)执行用户通过调用atexit函数或on_exit定义的清理函数
(2)关闭所有打开的流(stream),所有缓冲的数据均被写入flush,通过tmpfile创建的临时文件都会被删除
(3)调用_exit
exit和_exit的区别如图所示
这里写图片描述

11,僵尸进程
父进程调用fork创建子进程,子进程退出后,父进程如果不调用wait或waitpid来获取子进程的退出信息,子进程就沦为僵尸进程
清除僵尸进程的方法:
(1)父进程调用wait,为子进程“收尸”
(2)父进程退出,init进程会为子进程“收尸”
如何预防僵尸进程的产生呢?
(1)若是不关心子进程的退出状态,就将父进程对SIGCHID的处理方式设置为SIG_IGN,或在调用sigaction函数时设置SA_NOCLDWAIT标志位,子进程就不会陷入僵尸状态,而是调用release_task函数“自行了断”。
(2)若是关心子进程的退出状态,则应及时调用wait

12,等待子进程之wait()

#include<sys/wait.h>
pid_t wait(int* status);

成功时,返回已退出子进程的ID,失败返回-1。
调用wait的两种情况:
(1)子进程先退出,父进程后调用wait函数,父进程获取到子进程的状态信息,wait函数立刻返回
(2)父进程先调用wait,子进程后退出。
调用时并无子进程退出,该函数就会陷入阻塞状态,直到某个子进程退出。
注意:wait()函数等待时,任何一个子进程退出,都可以让其返回。
wait()的返回有三种可能性:
(1)等到了子进程退出,获取其退出信息,返回子进程的进程ID。
(2)等待过程中,收到了信号,信号打断了系统调用,并且注册信号处理函数时并没有设置SA_RESTART标志位,系统调用不会被重启,wait()函数返回-1,并设置errno为EINTR
(3)已经成功地等到了所有子进程,没有子进程的退出信息需要接收,在这种情况下,wait()返回-1,并设置errno为ECHID
wait()的局限性:
(1)不能等待特定的子进程,如果进程存在多个子进程,而它只想获取某个子进程的退出状态,并不关心其他子进程的退出状态,此时wait()只能一一等待,通过查看返回值来判断是否为关心的子进程。
(2)如果不存在子进程退出,wait()只能阻塞。有些时候,仅仅是想尝试获取退出子进程的退出状态,如果不存在子进程退出就立刻返回,不需要阻塞等待,类似于trywait的概念,wait()韩阿叔设有提供trywait的接口。
(3)wait能够探知子进程的死亡而不能探知暂停,也无法探知子进程恢复执行。

13,等待子进程之waitpid()

#include<sys/wait.h>
pid_t waitpid(pid_t pid,int *status,int options)

与wait函数相同的地方:
(1)返回值得含义相同,都是终止子进程或因信号停止,或因信号恢复而执行的子进程的进程ID
(2)status含义相同,都是用来记录子进程的相关事件
waitpid函数的第三个参数options是一位掩码(bit mask),可以同时存在多个标志,当options没有设置任何标志时,其行为与wait类似,即阻塞等待与pid匹配的子进程退出。
options的标志位可以是以下标志位的组合:
(1)WVNTRACE:除了关心终止子进程的信息,也关心那些因信号而停止的子进程的信息。
(2)WCONTINVED:除了关心子进程的信息,也关心那些因收到信号而恢复执行的子进程的状态信息。
(3)WNOHANG:指定的子进程并未发生状态变化,立刻返回,不会阻塞。这种情况下返回0。如果调用进程并没有与pid匹配的子进程,则返回-1。并设置errno为ECHILD,根据返回值和errno可以区分这两种情况。

kill -19  //暂停进程
kill -18  //恢复进程

waitpid的缺陷:无论用户是否关心相关子进程的终止事件,终止事件都可能会返回给用户,当waitpid返回时,可能是因为进程终止,也可能是因为子进程停止。

13,等待子进程之waitid()

#include<sys/wait.h>
int waitid(idtype_t idtype,id_t id,siginfo_t* infop,int option)

第一个入参idtype和第二个入参id用于选择用户关心的子进程
(1)idtype == P_PID:精确打击,等待进程ID等于id的子进程
(2)idtype == P_PGID:在所有的子进程中等待进程组ID等于id的进程
(3)idtype == P_ALL:等待任意子进程,第二个参数id被忽略
第四个参数options:
WEXITED:等待子进程的终止事件
WSTOPPED:等待被信号暂停的子进程事件
WCONTIUED:等待先前被暂停,但被SIGCONT信号恢复执行的子进程
这里写图片描述
WNOHANG:与id匹配的子进程若并无状态信息需要返回,则不阻塞,立刻返回,返回值是0。如果调用进程并无进程与id匹配,则返回-1,并设errno为ECHILD
WNOWAIT:只负责获取信息,不改变子进程的状态,带有WNOWAIT标志位调用waitid函数,稍后还可以调用wait或waitpid或waitid再次获得同样的信息。
第三个参数infop本质是个返回值,系统调用负责将子进程的相关信息填充到infop指向的结构体中。
对于返回值,在两种情况下会返回0:
(1)成功等到子进程的变化,并取回相应的信息
(2)设置了WNOHANG标志位,并且子进程状态无变化
为了区分这两种情况,内核是通过先将siginfo_t结构体清零,返回后,通过判断si_pid是否为0来分辨这两种情况。

14,进程退出和等待的内核实现
这里写图片描述
在do_exit中,还有两件事情需要exit_notify完成
(1)forget_aniginal_parent:负责给退出的子进程寻找新的父进程
1>为子进程寻找新的父进程,通过find_new_raper()函数完成,如果退出的进程是多线程进程,则可以将子进程他托福给自己的兄弟进程,没有没有,就“托孤”给init进程。
2>将子进程的父进程设置为第一步中找到的新的父亲。
(2)do_notify_parent:负责通知退出进程的父进程
在用户层面,可以调用pthread_exit让主线程先“死”,但在内核态中,主线程的task_struct一定要挺住,哪怕编程僵尸,也不能释放资源。
注意:只有线程组的主线程才有资格通知父进程,线程组的其他线程终止的时候。不需要通知父进程
当线程组最后一个线程退出时,如果发现:
(1)该线程不是线程组的主线程
(2)线程组的主线程已经退出,且处于僵尸状态
(3)自己是最后一个线程
同时满足这三个条件时,该子进程就需要冒充线程组的组长,即以子进程的主线程的身份来通知父进程。

15,父子进程的互动
父子进程的活动有两种方式:
(1)子进程向父进程发送SIGCHLD信号
子进程退出时,异步通知父进程,发送SIGCHLD信号。父进程收到该信号,默认行为是置之不理,此时,子进程会陷入僵尸状态。但这又会浪费系统资源,该状态会维持到父进程退出,子进程被init进程接管,init进程会等待僵尸进程,使僵尸进程释放资源。
如果父进程不太关心子进程的退出事件,可采用以下方法:
1>父进程调用signal函数或sigaction函数,将SIGCHLD信号的处理函数设置为SIG_IGN
2>父进程调用sigaction函数,设置标志位时置上SA_NOCLDNAIT位(如果不关心子进程的暂停和恢复执行,则置上SA_NOCLDSTOP位)
(2)子进程唤醒父进程(等待队列)
父进程调用wait主动等待,如果父进程调用wait陷入阻塞,那么子进程退出时,又该如何及时唤醒父进程呢?子进程会调用_wake_up_parent来唤醒父进程

16,exec家族
先来了解一下execve函数:

#include<unistd.h>
int execve(const char* filename,char* const argv[],char* const envp[]);

第一个参数是执行新程序的路径名,第二个参数argv[0]一般对应可执行文件的文件名。第三个参数与C语言的main函数中的第三个参数envp一样,也是字符串指针数组,以NULL结束,指针指向字符串的格式为name=value.一般来说,execve函数总是紧随fork之后,父进程调用fork后,子进程执行execve函数,抛弃父进程的程序段,和父进程分道扬镳。execve()成功不返回,失败返回-1。
再来看exec家族:

(1)int execl(const char *path, const char *arg, ......);

(2)int execle(const char *path, const char *arg, ...... , char * const envp[]);

(3)int execv(const char *path, char *const argv[]);

(4)int execve(const char *filename, char *const argv[], char *const envp[]);

(5)int execvp(const char *file, char * const argv[]);

(6)int execlp(const char *file, const char *arg, ......);

这些函数其实本质上都是调用execve系统调用,只是使用方法略有不同。
exec的作用:一个进程需要执行另一个程序,该进程首先调用fork创建一个副本,然后其中一个副本(通常是子进程)调用exec把自身替换成新的程序。
这里写图片描述

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值