Linux 进程学习笔记
1、什么是程序?什么是进程?它们有什么区别?
定义:
程序:程序(Program)是一个静态的命令集合,程序一般放在磁盘中,然后通过用户的执行来触发。触发后程序会加载到内存中成为一个个体,就是进程。
进程:进程(Process)的一个比较正式的定义是〔在自身的虚拟地址空间运行的一个单独的程序。〕
〔个人理解:进程的本质是一个被加载到内存中并获得系统资源使之运行的程序实例〕
区别:
程序通常是一个二进制文件,不占用系统的运行资源。
进程是一个随时都可能发生变化的、动态的、使用系统运行资源的程序。而且一个程序可以启动多个进程。
2、进程组和作业
定义:
作业(Job):完成一个特定任务的一组进程称为作业(主要体现在管道和重定向的使用上,这里不讨论)。
进程组:一个或多个进程的集合称为进程组
〔这个定义个人觉得太笼统,因此我把它归结为:一组由领导进程(通常为一个父进程)构建的进程称为进程组)〕
详解:
事实上,Shell分前、后台来控制的不是进程(Process)而是作业(Job)或者进程组(Process Group)。
一个前台作业可以由多个进程组成,一个后台作业也可以由多个进程组成,Shell可以同时运行一个前台作业和任意多个后台作业,这称为作业控制(JobControl)。例如用以下命令启动5个进程(这个例子出自APUE):
$ proc1 | proc2 &
$ proc3 | proc4 | proc5
其中proc1和proc2属于同一个后台进程组,proc3、proc4、proc5属于同一个前台进程组,Shell进程本身属于一个单独的进程组。这些进程组的控制终端相同,它们属于同一个Session,一个Session与一个控制终端相关。当用户在控制终端输入特殊的控制键(例如Ctrl-C)时,内核会发送相应的信号(例如SIGINT)给前台进程组的所有进程。各进程、进程组、Session的关系如下图所示。
在上面的例子中,proc3、proc4、proc5被Shell放到同一个前台进程组,其中有一个进程是该进程组的Leader,Shell调用wait等待它们运行结束。一旦它们全部运行结束,Shell就调用tcsetpgrp函数将自己提到前台继续接受命令。但是注意,如果proc3、proc4、proc5中的某个进程又fork出子进程,子进程也属于同一进程组,但是Shell并不知道子进程的存在,也不会调用wait等待它结束。换句话说,proc3 | proc4 | proc5是Shell的作业,而这个子进程不是,这是作业和进程组在概念上的区别。一旦作业运行结束,Shell就把自己提到前台,如果原来的前台进程组还存在(如果这个子进程还没终止),则它自动变成后台进程,被init进程接管。
3、进程的几个特点
每个进程都有一个进程号PID,用于系统识别和调度。
PID是唯一的,但在释放之后,可以延时重用,延时是为了避免将新进程误认为是使用同一PID的某个已经终止的旧进程。
PID为0的进程通常是调度进程,常被称为交换进程(swapper),该进程是内核的一部分,不执行任何用户程序,因此也称为系统进程。
PID为1的进程是init进程,该进程负责在自举内核之后启动系统;init读取与系统有关的初始化文件(/etc/rc* 或 /etc/inittab,以及 /etc/init.d 文件),并将系统引导到一个状态之后,成为所有用户进程的始祖。
PID为2的进程成为页守护进程(pagedaemon),该进程负责支持虚拟存储系统的分页操作。
除了init(初始化进程),所有的进程都有一个父进程。
getpid执行成功可以获得当前进程的pid,类型为pid_t(即int类型),若失败则返回-1,错误原因储存在errno中。该函数在/usr/include/unistd.h文件中声明。
每个进程都有三组人的权限(所有者,用户主,其他人),所以不同用户身份执行这个程序时,系统给予的权限也不同。
进程不是被创建的,而是从其直系父进程复制过来的。
4、启动一个进程有两种途径——手工启动和进程调度
手工启动:由用户输入命令直接启动一个进程。
——前台启动:fg
——后台启动:bg
调度启动:指定时间或场合对任务进行调度安排
——at命令:在指定时刻执行一次指定命令序列
——cron命令:在指定时刻重复执行指定命令序列
——crontab命令:用于安装、删除或者列出用于驱动cron后台进程的表格,也就是说,用户把需要执行的命令序列放到crontab文件中执行
5、进程的挂起与恢复
作业控制允许将进程挂起并可以在需要时恢复进程的运行。
Ctrl+Z 将当前运行的前台进程挂起(Ctrl+C 中止)
此外,fg将挂起的作业放回到前台执行,bg将前台作业放到后台执行,&也可以实现后台运行。
6、进程管理
(1)who
查看活动用户
(2)w
查看活动用户及其正在进行的工作〔相当于who的增强版〕
(3)ps
查看当前运行的进程〔前台和后台〕[参见Linux/Linux命令/ps]
(4)top
动态显示当前进程状态[参见Linux/Linux命令/top]
(5)kill
中断进程[参见Linux/Linux命令/kill]
(6)nohup
退出系统后仍然执行
7、fork与子进程的创建
由fork创建的新进程称为子进程。
fork有返回值,fork每被调用一次,可以返回两次:
对于父进程,返回子进程的PID。这是因为一个进程的子进程可以有多个,并且没有一个函数使一个进程可以获得其所有子进程的PID;
对于子进程,则返回0。这是因为一个子进程只有一个父进程,所以子进程总是可以通过调用getppid函数来获得其父进程的PID,而且PID为0的进程是swapper(交换进程),是不允许使用的。
子进程是父进程的副本,子进程获得父进程数据空间、堆、栈的副本,但父子进程并不共享这些存储空间(共享正文段);现代技术实现了写时复制,也就是说,子进程不需要完全复制父进程的数据段、堆和栈,这些区域由父子进程共享,而且内核将它们的访问权限置为只读;如果父子进程中的任一个试图修改,则内核只为修改区域的那块那部分制作一个副本。
fork函数示例:
1 #include "apue.h" 2 3 int glob = 6; 4 char buf[] = "a write to stdout\n"; 5 6 int main(void) 7 { 8 int var; 9 pid_t pid; 10 11 var = 88; 12 if(write(STDOUT_FILENO, buf, sizeof(buf) - 1) != sizeof(buf) - 1) 13 err_sys("write error"); 14 printf("before fork\n"); 15 16 if((pid = fork()) < 0) 17 { 18 err_sys("fork error"); 19 } 20 else if 21 { 22 glob++; 23 var++; 24 } 25 else 26 { 27 sleep(2); 28 } 29 30 printf("pid = %d, glob = %d, var = %d\n", getpid(), glob, var); 31 exit(0); 32 }
执行:
$./a.out a write to stdout before fork pid = 430, glob = 7, var = 89 子进程的变量值改变了 pid = 429, glob = 6, var = 88 父进程的变量值没有改变
重定向输出到文件时before fork输出两遍:
$ ./a.out > test.out $ cat test.out a write to stdout before fork 子进程输出 pid = 430, glob = 7, var = 89 before fork 父进程输出 pid = 429, glob = 6, var = 88
一般说来,在fork之后是父进程先执行还是子进程先执行是不确定的,这取决于内核所用的调度算法。在上述例子中,父进程使自己先休眠2秒钟,以使子进程先执行,但并不保证2秒钟足够(如不足够,则需要阻塞等操作,这里不说)。
直接运行程序时标准输出是行缓冲的,很快被新的一行冲掉。而重定向后,标准输出是全缓冲的。当调用fork时before fork这行仍保存在缓冲中,并随着数据段复制到子进程缓冲中。这样,这一行就分别进入父子进程的输出缓冲中,余下的输出就接在了这一行的后面。
fork失败的原因:
(1)系统中已经有了太多了进程;
(2)实际用户ID的进程总数超过了系统限制;
8、进程中止
进程终止有8种方式,其中又分为正常终止和异常终止:
正常终止:
1)从main返回
2)调用exit
3)调用_exit或_Exit
4)最后一个线程从其启动例程返回
5)最后一个线程调用pthread_exit
异常终止:
6)调用abort
7)接到一个信号并终止
8)最后一个线程对取消请求作出响应
内核使程序执行的唯一方法是调用一个exec函数;
进程自愿终止的唯一方法是显式或隐式地(通过调用exit)调用_exit或_Exit;进程非自愿终止的方法是通过一个信号使其终止。
9、vfork函数
vfork函数的调用序列与返回值和fork函数相同,但两者意义不同。
vfork也可以创建子进程,但它并不将父进程的地址空间完全复制到子进程中,而是在子进程调用exec或exit之前,在父进程空间中运行;而且,vfork保证子进程先执行,在子进程执行时将父进程置为休眠状态,在它调用exec或exit之后父进程才可能被调度执行。(如果在调用之前,子进程的执行依赖于父进程的进一步动作,则会导致死锁)
10、父子进程的终止关系
(1)父进程在子进程终止前终止:那么子进程的父进程都改变为init进程。
其操作过程大致如下:在一个进程终止时,内核逐个检查所有活动进程,以判断它是否是正要终止进程的子进程,如果是,则将该进程的父进程ID更改为1,这种方法保证了每个进程都有一个父进程。
(2)子进程在父进程终止前终止:那么父进程如何能在作相应检查时获得子进程的终止状态呢?
对此问题的答案是:内核为每个终止子进程保存了一定量的信息,所以当终止进程的父进程调用wait或waitpid函数时,可以得到这些信息。
这些信息至少包含进程ID,该进程的终止状态,以及该进程使用的CPU时间总量。
内核可以释放终止进程的所使用的所有存储区,关闭其所有打开文件。
如果子进程已经终止,但是其父进程未对其进行善后处理,那么就可能产生僵死进程〔见11点——僵死进程〕。
11、僵死进程
定义:在UNIX术语中,一个已经终止、但是其父进程尚未对其进程善后处理的进程被称为僵死进程(zombie)。
我们说init的子进程时,可能是init直接产生的子进程,也可能是其父进程已终止、过继给init的子进程。那么init的子进程终止时,会不会成为僵死进程呢?
答案是否定的。在UNIX中,init进程被编写为无论何时,只要有一个子进程终止,init就会调用一个wait函数取得其终止状态,因此,当一个由init进程领养的子进程在终止时,并不会成为僵死进程。
12、wait 和 waitpid
当一个进程终止时,内核就向其父进程发送SIGCHLD信号。由于子进程终止是异步发生的,因此SIGCHLD信号也是内核向父进程发的异步通知。父进程默认忽略该信号,也可以提供一个该信号发生时即被调用执行的函数,这就是wait 和 waitpid 函数。
wait 和 waitpid 被调用时可能发生的情况:
· 如果其所有子进程都还在运行,则阻塞;
· 如果一个子进程已终止,正等待父进程获取其终止状态,则取得该进程的终止状态,并立即返回;
· 如果其没有任何子进程,则立即出错返回。
#include <sys/wait.h> pid_t wait(int *statloc); pid_t waitpid(pid_t pid, int *statloc, int options); 两个函数返回值:若成功则返回pid,若失败则返回-1
两个函数的区别:
· 在一个子进程终止前,wait 使其调用者阻塞,而waitpid有一个选项,可使调用者不阻塞。
· waitpid 并不等待在其调用之后的第一个终止子进程,它有若干个选项,可以控制它所等待的进程。
· waitpid 提供 wait 没有的三个功能:
(1)waitpid 可等待一个特定的进程,而wait则返回任一终止子进程的状态;
(2)waitpid提供了一个wait的非阻塞版本;
(3)waitpid支持作业控制。
参数statloc是一个整型指针,用以防止终止进程的终止状态;若不关心终止状态,则将statloc置为NULL。
如果一个子进程已经终止,并且是一个僵死进程,则wait立即返回并取得该子进程的状态,否则wait使其调用者阻塞直到一个子进程终止(当一个进程终止时,内核发送SIGCHLD信号给父进程,父进程可以忽略该信号或者调用wait / waitpid 函数来响应这个信号,这里我们假设父进程调用wait去获得该子进程的PID和终止状态等信息。那么这句话说直到一个子进程终止,意思是所有子进程都在执行,目前还没有一个子进程终止,这不是和内核检测到的有进程终止矛盾吗?难道是内核检测失误?)。
如果调用者阻塞且它有多个子进程,则在其一个子进程终止时,wait就立即返回。
如果希望等待一个指定的进程终止,那么就要使用waitpid了(早期的UNIX版本中,可以使用wait,然后将其返回的PID与所期望的PID进行对比,如果不是所期望的,则将该进程的PID和终止状态保存起来,然后继续调用wait,直到遇到期望的PID;下次又想等待一个特定的进程时,则查询已保存的有关信息,否则继续调用wait)。
对于waitpid函数中pid参数的作用解释如下:
pid == -1 等待任一子进程,与wait等效
pid > 0 等待其进程ID与pid相等的子进程
pid == 0 等待其组ID等于调用进程组ID的任一子进程
pid < -1 等待其组ID等于pid绝对值的任一子进程
wait 出错:调用进程没有子进程(函数调用被一个信号中断时,也可能返回另一种错误)。
waitpid出错:指定的进程或进程组不存在,或者参数pid指定的进程不是调用进程的子进程。
13、exec 函数族
用fork创建子进程后,往往要调用一种exec函数以执行另一个程序,当进程调用exec函数时,该进程执行的程序完全替换为新程序,而新程序则从其main函数开始执行。
因为调用exec并不创建新的进程,所以前后进程的PID并未改变,可以说,exec只是用一个全新的程序替换了当前进程的正文段、数据段、堆和栈段。
exec函数族包括6种exec函数:
#include <unistd.h> int execl(const char *pathname, const char *arg0, ... /* (char *) 0 */); int execv(const char *pathname, char *const argv[]); int execle(const char *pathname, const char *arg0, ... /* (char *) 0, char *const envp[] */); int execve(const char *pathname, char *const argv[], char *const envp[]); int execlp(const char *filename, char *const argv[], ... /* (char *) 0 */); int execvp(const char *filename, char *const argv[]); 6个函数的返回值:若成功则不返回值,若出错则返回-1
这些函数的第一个区别是前4个取路径名作为参数,后2个去文件名作为参数。
当指定filename作为参数时:
· 如果filename中包含/,则将其视为路径名;
· 否则就按照PATH环境变量,在它所制定的各目录中搜寻可执行文件。
第二个区别与参数表的传递有关。函数execl、execlp、execle要求将新程序的每个命令行参数都说明为一个单独的参数,这种参数表以空指针结尾。对于另外三个函数(execv、execvp、execve),则应先构造一个指向各参数的指针数组,然后将该数组地址作为这三个函数的参数。
最后一个区别与向新程序传递环境表相关。以e结尾的两个函数可以传递一个指向环境字符串指针数组的指针。其他四个函数则使用调用进程中的environ变量为新程序复制现有的环境。