8.1 引言
无
8.2 进程标志符
1) 当一个进程终止时,其进程ID可以重复使用;unix采用延迟重用算法, 赋予新建进程的pid不会是最近终止进程的pid;
2) pid = 0 的进程是调度进程,该进程是内核的一部分,但是不执行磁盘上的任何程序,被称为系统进程;
3) pid = 1 的进程是init进程, 自举过程中由内核调用(/etc/init; sbin/init). init进程负责在自举内核后启动一个UNIX系统,然后将系统引导到一个多用户状态; init绝对不会终止, 是一个普通的用户进程,不是系统进程, 但是以超级用户权限运行,
4) pid = 2 的进程是守护进程pagedaemon, 此进程负责支持虚拟存储系统的分页操作;
8.3 fork函数
1) 为什么父进程使用0表示子进程?
子进程只有一个父亲,在子进程中可以调用getpid()得到父进程的ID;
父进程有很多的子进程,没有函数让父进程调用得到子进程;所有使用创建子进程时候的返回值,返回值==0表示这是一个子进程(真实的PID = 0的进程是系统内核中的调度进程,绝不会出现在用户空间, 所以使用0);
2) 子进程获得父进程的数据空间(初始化、未初始化), 堆、栈;父子进程并不共享存储空间,但是共享程序正文段;
3) 目前子进程并不完全复制,而是执行写时复制技术,存储空间由父子进程共享,内核将存储区域的权限更改为只读。父子进程中任何一个想要修改这些区域,内核只为修改区域的那一部分内存制作一个副本(通常是一页);
4) linux提供了一种新进程创建函数——clone系统调用, 这是一种fork泛型, 允许调用者控制哪些部分由父子进程共享;
5) 父子进程,哪一个进程先执行,是不确定的;因此父子进程之间存在同步问题;
6) strlen和sizeof的差别:
- strlen计算不包括null字节; sizeof计算包括null字节;
- strlen是一次函数调用,执行期; sizeof在编译时候计算缓冲区长度,编译器确定;
7) read, write等系统调用是不带缓冲的; 标准IO库函数是带缓冲的;
子进程复制父进程的数据空间,也会将父进程的缓冲区复制;
标准输出连接到终端设备,是行缓冲的;否则就是全缓冲的;(例如标准输出到"hello world\n",到终端,换行符将刷新缓冲区;标准输出到文件时候, \n并不刷新缓冲区;)
8) 重定向父进程的标准输入、输出,子进程的标准输入、输出也会被重定向;因为父子进程共享文件;
9) 父子进程的每一个相同的打开描述符都共享一个文件表项;父子进程对同一文件使用一个文件偏移量;父子进程访问同一个文件描述符时候,需要某种形式的同步机制;
10) 父子进程一定是要共享文件的偏移量的;父子进程是共享同一文件的,如果父子进程不共享文件偏移量,子进程写了文件,其文件偏移量更新了,此时父进程还是要老的文件偏移量,父进程写的内容将会覆盖掉子进程写的内容;
11) 如果父子进程写到同一文件描述符,但是没有任何形式的同步,那么父子进程的输出会相互混合,应当避免这种情形;
12) fork之后对文件描述符的两种常见操作模式:
- 父进程等待子进程结束, 此时父进程无需对文件做任何处理,子进程终止后,其文件偏移量也被更新了,父子进程不会混合输出;
- 父子进程各自执行不同的程序段(常见于网络服务进程), 此时, fork之后, 父子进程各自关闭不需要使用的文件描述符。这样就不会干扰对方使用文件,不会造成混合输出;但是对于父子进程共享的文件,依旧存在混合输出的可能;
13) 父子进程的区别:
- 父进程设置的文件锁不会被继承;
- 父进程的未处理闹钟被清除;
- 子进程的未处理信号集设置为空;
14) fork失败的主要原因,系统中有了太多的进程;或是实际用户ID的进程数超过了系统的限制(CHILD_MAX );
15) fork的两种用法:
- 一个父进程复制自己,父子进程使用不同的代码段(不调用exec);这是在网络服务中比较常见的——父进程继续等待客户端其他网络请求到来,子进程处理网络请求;
- 一个进程要去执行不同的程序。 这是shell中常见的操作,如shell启动过程中, init fork出子进程,先后exec调用getty, login程序;
16) 使用两个函数fork, exec创建一个进程的原因:很多场合需要单独的fork函数功能; 分开两个函数,在fork之后,可以更改子进程的属性(IO重定向, 用户ID, 信号安排);再有,将一个复杂的功能分为独立的两个, 比较不容易出错,即使出错了,出错分析也比较简单;
8.4 vfork函数
1) vfork用于创建一个新进程,该新进程的目的就只有一个, 就是exec一个新程序;
2) vfork并不会复制父进程的地址空间,这是因为vfork的进程注定只会执行exec,不需要复制父进程的空间;
3) vfork在调用exec之间,是运行在父进程的地址空间中的;
4) vfork保证子进程先运行,在调用exec或exit之后,父进程才会被调度运行;所以vfork出来的父子进程,本身具有一定的同步机制;
5) 注意: 调用exec之前, 由于子进程运行的时候,父进程停止,此时如果子进程等待父进程完成某些动作,就会导致死锁;
vfork 使用要小心避免死锁的产生;
8.5 exit 函数
1) 不管进程如何终止,都会执行内核中的一段代码,这段代码关闭进程打开的所有文件描述符, 释放进程的存储空间;
2) 我们希望将进程的退出状态传递给父进程,告知父进程子进程是如何死去的(爸爸,给我报仇);
实现:
正常终止的时候,子进程将其退出状态作为一个参数传递给函数,让父进程知道;
异常终止的时候,内核产生一个指示其异常终止原因的终止状态;
3) 区分退出状态和终止状态:
退出状态: 是exit, return 的参数,是main的返回值, ;
终止状态:是wait, waitpid的参数, 在最后调用_exit的时候, 内核将退出状态转换为终止状态;
4) init进程领养孤儿进程的过程:
在一个进程终止的时候, 内核逐个检查进程组中的所有活动进程, 判断是否是正要终止的进程的子进程, 如果是,就将该进程的PPID改为1;
5) 子进程返回到父进程的终止状态,包含的信息有:
1) 进程的ID;
2) 进程的终止状态;
3) 该进程使用的CPU时间总量;
6) 僵尸进程:
一个已经终止的进程,但是其父进程并没有进行善后处理(父进程没有wait), 这就是一个僵尸进程, 资源没有回收;
使用ps命令打印僵尸进程,显示的是Z;
7) 由init进程领养的进程不会变为一个僵尸进程, 对于每一个init子进程,init进程会有一个wait函数取得子进程的状态, 对子进程进行善后处理;
8.6 wait 和 waitpid函数
1) 当一个进程终止的时候, 内核会向其父进程发送一个SIGCHLD信号(异步通知);SIGCHLD信号的系统默认动作是忽略;
2) 如果在信号处理程序中, 使得父进程在接收到SIGCHLD信号而调用wait函数, 可以期望wait函数会立即返回; 但是如果在任意时候调用wait函数, 进程肯呢个会阻塞;
pid_t wait(int *status); //一定会阻塞
pid_t waitpid(pid_t pid, int *status, int options); //选用参数,可设定不阻塞WNOHANG
3) 如果子进程是一个僵尸进程,读进程调用wait会立即返回子进程的状态;
4) 有四个互斥宏可以用来查看子进程返回的终止状态:
- WIFEXITED(status), 如果真, 子进程就是正常终止的; WEXITSTATUS(status)
- WIFSIGNALED(status), 子进程是由于异常终止的, 使用WTERMSIG(status)可得到使得子进程异常终止的信号编号;
- WIFSTOPPED(status), 为真,当前子进程是暂停的, 使用WSTOPSIG(status), 得到使得子进程暂停的信号编号;
- WIFCONTINUED(status)为真, 如果在作业控制暂停后, 已经继续的子进程返回了状态,就是真;
没有一种可移植的办法将信号编号转化为信号编号说明(有办法,但是这种办法不可移植), 我们必须查看signal.h 头文件获得信号编号的意义;
waitpid函数的第一个参数:
pid == -1; 等待任一子进程;
pid > 0; 等待子进程pid;
pid = 0, 等待同一进程组中的任一子进程;
pid < 0, 等待进程组pid中的任一子进程;
waitpid函数的第三个参数:
1)WCONTINUED, 作业控制 ;
2)WNOHANG,父进程不阻塞
3) WUNTRACED, 作业控制;
8.7 waitid函数
linux不支持waitid函数;
int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);
第一个参数:idtype
1) P_PID 等待一个特定的子进程;
2) P_PGID 等待一个特定进程组中的任一子进程;
3) P_ALL 等待任一子进程,忽略第二个参数id;
第三个参数:infop, 包含了有关引起子进程状态改变的生成信号的详细信息(信号的信息)
第四个参数: options
WCONTINUED: 等待一个子进程,此后又继续,但是状态未报告;
WEXITED: 等待已退出的进程;
WNOHANG:不阻塞
WNOWAIT:不破坏子进程的退出状态;
WSTOPPED:等待一个子进程,子进程已经停止,但是状态未报告;
8.8 wait3 和 wait4 函数
1) 这两个函数的参数中有一个参数, struct rusage , 该参数要求内核返回由终止进程及其所有子进程使用的资源汇总;
这些资源包括:
CPU时间总量,页面出错次数,接收到信号的次数。
8.9 竞争条件
1) 竞争条件:当多个进程都企图对共享数据进行了某种处理,但是最后的结果又取决于进程运行的顺序,就发生了竞争条件;
2) 子进程等待父进程终止:
- 使用轮询;
while(getppid() != 1)
sleep(1);
-
使用信号:
-
使用IPC进程间通信;
8.10 exec函数
1) 当子进程使用exec函数的时候, 新程序从main函数开始执行;
2) exec使用一个全新的程序代替了父进程的正文,数据,堆,栈;
3) 基本的进程控制原语:fork, exec, exit, wait, waitpid;
popen, system这些函数是使用进程控制原语构造的;
4) 如果execlp或是execvp使用路径前缀中的一个找到了一个可执行文件,但是该文件不是机器可执行文件, 就认为这是一个shell脚本,于是尝试调用/bin/sh, 并且以filename作为shell的输入(这时候运行的是脚本);
5) 使用execle和execve的,可以传递一个指向环境字符串指针数组的指针。其他四个函数则使用调用进程中的environ变量为新进程赋值现有的环境变量(使用setenv, getenv函数修改子进程的环境变量);
6) 一个进程允许将其环境传递给子进程。但是父进程也可以为子进程指定某一确定的环境;
7) 子进程对打开文件的处理与每个描述符的执行时关闭位标志值有关系(close-on-exec); FD_CLOEXEC, 如果此标志位设置, 子进程执行exec的时候就关闭该描述符;除非特地用fcntl设置了该标志, 否则系统默认操作时执行exec后依旧保持这种描述符打开;
8) POSIX 明确要求在执行exec时关闭打开的目录流,这是由opendir函数实现的,该函数调用fcntl函数为对应于目录流的描述符设置执行时关闭标志, 所以子进程在exec的时候关闭相应目录流的文件描述符;
9) 在执行exec前后,进程的实际用户ID和实际组ID保持不变,而有效ID是否改变取决于所执行文件的设置用户ID位和设置组ID位是否设置。如果新程序的设置用户ID位已经设置了,则子进程的有效用户ID变为程序文件所有者的ID;
10)execve函数是系统调用, 其他5个函数是库函数;
8.11 更改用户ID和组ID
1) 实际用户ID和实际组ID是针对文件的, 有效用户ID和有效组ID是针对进程的(权限);
2) 使用setuid函数设置实际用户和有效用户的ID; 使用setgid函数设置实际组ID和有效组ID;
//这两个函数可以更改实际用户ID和有效用户ID
int setuid(uid_t uid);
int setgid(gid_t gid);
int setuid(uid_t uid);
int setgid(gid_t gid);
3) 更改用户ID的若干规则:
- 若进程具有超级用户特权, 则setuid函数将实际用户ID, 有效用户ID, 以及保存的设置用户ID设置为uid;
- 如果进程没有超级用户特权, 但是uid等于实际用户ID或是保存的设置用户ID,则setuid只是将有效用户ID设置为uid。不改变实际用户ID和保存的设置用户ID;
- 如果以上不满足, 则setuid返回-1, 设置errno为EPERM;
4) 关于内核维护的三个用户ID, 注意几点:
- 实际用户ID在用户登录的时候, 由login程序设置, 并且永远不会改变;只有超级用户权限才可以更改实际用户ID;login调用setuid会设置三个用户ID;
- 只有程序文件设置了 设置用户ID位的时候,exec函数才会设置有效用户ID;任何时候, 可以调用setuid,将有效用户ID设置为实际用户ID或保存的设置用户ID;
- 保存的设置用户ID是经过exec复制得到的。如果设置了文件的设置用户ID位,则在exec根据文件的用户ID设置了进程的有效用户ID之后, 会将进程原先的有效用户ID保存起来;
5) seteuid, setegid函数//只更改有效用户ID和有效组ID
- setreuid 函数和setregid函数, 交换实际用户ID和有效用户ID;
一个非特权用户总能交换实际用户ID和有效用户ID, 使用该函数, 可以将一个设置用户ID程序转换成只具有用户的普通权限, 并且还可以再次转换回设置用户以得到额外权限; - setuid(uid), setegid(gid); 设置有效用户ID和有效组ID;
8.12 解释器文件
1) 解释器文件是文本文件, 起始形式是:
#! pathname [optional-argument]
例如解释器文件
#! /bin/sh
2) 对解释器文件的识别是由内核作为exec系统调用处理的一部分完成的。exec函数执行的pathname如果不是一个可执行文件, exec会将其当作一个解释器;内核调用exec函数的进程实际执行的并不是该解释器文件,而是解释器文件第一行中的pathname所制定的文件。
3) 要区分解释器文件和解释器,解释器文件的第一行的pathname就是解释器;
4) 很多系统对解释器文件的第一行有长度限制;
5) 内核是如何处理exec函数的参数及该解释器文件的第一行可选参数???
一个解释器文件执行的大概过程:
当在shell中执行一个可执行文件时,系统会另起一个子进程, 在子系统中内核首先将文件当作二进制文件来执行,但是内核发现该文件不是机器文件后会返回一个错误码;收到错误信息后, 进程会将该文件当作一个解释器文件,之后扫描解释器文件的第一行,获取解释器程序的名字(解释器文件第一行#!后面就是解释器),然后执行这个解释器,并且将解释器(pathname)当作解释器的第一个参数;然后由解释器程序扫描整个解释器文件,执行每一条语句(当然会跳过第一条语句);
内核中的exec调用用于识别解释器,并且path必须是一个绝对路径名;当exec识别到path是一个解释器的时候,该进程执行那个的程序就是path指定的解释器;然后使用该解释器执行整个解释器文件;
6) 在一个shell上,执行一个shell脚本的不同方式:假设脚本是test.sh, 脚本位于当前目1. 输入命令行: sh test.sh. 这里实在shell上执行程序/bin/sh, 脚本test.sh作为一个参数执行, /bin/sh 程序会在当前目录下找到文件test.sh;(sh程序会忽略掉脚本文件中以#开头的行,包括第一行)(此时不要求test.sh 具有可执行权限)
2. ./test.sh是执行一个脚本文件,子进程先将其当作二进制可执行文件,在收到内核发出的错误之后, 子进程将test.sh当作一个脚本文件, 在第一行找到解释器,然后使用解释器执行整个test.sh;(此时要求test.sh 具有可执行权限)
3. 输入命令:test.sh, 类似第2)。 其区别是这种情况下, test.sh的路径必须在系统的环境变量中(PATH 变量), 否则系统会找不到当前目录;
7) 区别一下解释器文件和解释器:
- test.sh 就是一个解释器文件(俗称脚本);
- test.sh 文件中的第一行, #! pathname, 其中pathname就是解释器;
8) 关于awk脚本的用法:
awk -f hello.awk hello.txt //hello.awk 中是awk脚本,里面是关于awk语法的一些指令; hello.txt 是内容,对于其中的每一行内容,都执行hello.awk 中的指令; 选项-f表示将脚本文件hello.awk 传递给脚本awk; (注意区分脚本和脚本文件);
书中的awk -f myfile 的意思就是,从myfile中读awk程序;
8.13 system函数
1) int system(const char *command) //system() 函数调用/bin/sh 来执行参数指定的命令,/bin/sh 一般是一个软连接,指向某个具体的shell。在command执行那个期间, SIGCHLD是被阻塞的,此时内核不会发生HCILD信号;同时,SIGINT和SIGQUIT是被忽略的,进程接收到这两个信息没有任何动作;
2) 如果你想要在一个程序文件(进程)中使用shell完成某些功能,可以使用system;例如在一个进程中, 想要将当前时间保存下来,可以让shell执行date>file, 语句system(“date > file”);
3) system中调用了fork, exec, waitpid函数,有三种返回值:
- 如果fork失败,返回-1;
- exec失败,返回127;
- 如果waitpid失败,错误是EINTR,不返回-1; 错误不是EINTR, 返回-1;
- system(); 返回1, 表示这个系统支持system函数;
4) 使用system而不直接使用fork, exec的优点是:system进行了所需的各种出错处理, 以及各种信号处理;
5) 如果一个进程正在以特殊的权限(例如超级权限)运行,这个进程又想生成另一个进程执行另一个程序,则应该直接使用fork和exec,而且在fork之后,exec之前要改回到到普通权限;直接调用system函数, 进程的特殊权限会传递到system中的子进程;特别的,设置用户ID或设置组ID的程序绝对不能调用system函数;(这里体会到了分开使用fork() 函数和exec() 函数的好处了);
8.14 进程会计
启用进程会计选项之后(你可以选择关闭进程会计), 每当进程结束后,内核都会写一个会计记录。
典型的会计记录都会包含总量较小的二进制数据:命令名, CPU时间总量, 用户ID或组ID,启动时间的等;