在进程的创建上, Unix采取了一种有趣和少见的处理方法:它将进程的创建和加载一个新二进制镜像分离。Unix提供了两个系统调用fork和exec。
创建进程:
缺省情况下,内核将进程ID的最大值限制为32768,2^15。系统管理员可以设置/proc/sys/kernel/pid_max的值来突破这个缺省的限制,但会牺牲一些兼容性。
创建新进程的那个进程称为父进程,而新进程被称为子进程。每个进程都是由其他进程创建的(除了init进程),因此每个子进程都有一个父进程。这种关系保存在每个进程的父进程ID号(ppid)中。每个进程都被一个用户和组所拥有。这种从属关系是用来实现访问控制的。每个进程都是某个进程组的一部分,它简单的表明了自己和其他进程之间的关系,但是不要和上面的用户、组的概念混淆了。子进程通常属于其父进程所在的那个进程组。进程组使得与管道相关的进程间发送和获取信息变得很容易,这同样也适用于管道中的子进程。从用户的角度来看,进程组更像是一个任务。
在Unix中,载入内存并执行程序映像的操作与创建一个新进程的操作是分离的。Unix有一个系统调用(实际上是一系列系统调用之一)是可以将二进制文件的程序映像载入内存,替换原先进程的地址空间,并开始运行它。这个过程称为运行一个新的程序,而相应的系统调用称为exec系统调用。同时,另一个不同的系统调用是创建一个新的进程,它基本上就是复制父进程。通常情况下新的进程会立刻执行一个新的程序。完成创建新进程的这种行为叫做派生(fork),完成这个功能的系统调用就是fork()。
int execl (const char path, const char arg, ...);
execl()成功的调用不仅仅改变了地址空间和进程的映像,还改变了进程的一些属性:
1,任何挂起的信号都会丢失。
2,捕捉的任何信号会还原为缺省的处理方式,因为信号处理函数已经不存在于地址空间中了。
3,任何内存的锁定(参看第八章)会丢失。
4,多数线程的属性会还原到缺省值。
5,多数关于进程的统计信息会复位。
6,与进程内存相关的任何数据都会丢失,包括映射的文件。
7,包括C语言库的一些特性(例如atexit())等独立存在于用户空间的数据都会丢失。
然而也有很多进程的属性没有改变,例如pid、父进程的pid、优先级、所属的用户和组。
其他五个系统调用:execlp,execle,execv,execvp,execve。字 母 l 和 v 分别表示参数是以列表方式或者数组 (向量)方式提供的。字 母p意味着在用户的PATH环境变量中寻找可执行文件。只要出现在用户的路径中,带p的exec函数可以简单的只提供文件名。最后e表示会提供给新进程以新的环境变量。
pid_t fork (void);
成功的fork()调用会返回0。在父进程中fork()返回子进程的pid。除了必要的一些方面,父进程和子进程之间在每个方面都非常相近:
1,显然子进程的pid是新分配的,它是与父进程不同的。
2,子进程的ppid会设置为父进程的pid。
3,子进程中的资源统计信息(Resource statistics)会清零。
4,任何挂起的信号会清除,也不会被子进程继承(参看第九章)。
5,任何文件锁都不会被子进程所继承。
写时复制是一种采取了惰性优化方式来避免复制时的系统开销。它的前提很简单:如果有多个进程要读取它们自己的那部分资源的副本,那么复制是不必要的。每个进程只要保存一个指向这个资源的指针就可以了。只要没有进程要去修改自己的”副本”,就存在着这样的幻觉:每个进程好像独占那个资源。从而就避免了复制带来的负担。如果一个进程要修改自己的那份资源“副本”,那么就会复制那份资源,并把复制的那份提供给进程。不过其中的复制对进程来说是透明的。这个进程就可以修改复制后的资源了,同时其他的进程仍然共享那份没有修改过的资源。
写时复制在内核中的实现非常简单。与内核页相关的数据结构可以被标记为只读和写时复制。如果有进程试图修改一个页,就会产生一个缺页中断。内核处理缺页中断处理的方式就是对该页进行一次透明复制。这时会清除页面的COW 属性,表示着它不再被共享。
在实现写时复制之前, Unix 的设计者们就一直很关注在fork 后立刻执行exec所造成的地址空间的浪费。vfork()会挂起父进程直到子进程终止或者运行了一个新的可执行文件的映像。通过这种方式,vfork()避免了地址空间的按页复制。实际上vfork()只完成了一件事:复制内部的内核数据结构。因此,子进程也就不能修改地址空间中的任何内存。
终止进程
进程成功的退出时,只需要简单的写上:exit(EXIT_SUCCESS);
在终止进程之前,C语言函数执行以下关闭进程的工作:
1,以在系统中注册的逆序来调用由atexit()或on_exit()注册的函数。
2,空所有已打开的标准I/O流。
3,删除由tmpfile()创建的所有临时文件。
这些步骤完成了在用户空间中所需要做的事情,这样exit()就可以调用_exit()来让内核来处理终止进程的剩余工作了。
内核会清理进程所创建的、不再用到的任何资源。这包括:申请的内存、打开的文件和SystemV的信号量。清理完成后,内核摧毁进程,并告知父进程其子进程的终止。
C 语言中在main()函数返回时,编译器会简单的在最后的代码中插入一个_exit()。 shell会根据这个返回值来判断命令是否成功的执行,在 main()函数返回时明确给出一个状态值 , 或者调用exit() , 这是一个良好的编程习惯。
atexit()的成功调用会把指定的函数注册(无参数的,无返回值)到进程正常结束(例如一个进程以调用exit()或者从main()中返回的方式终止自己)时调用的函数中。如果进程调用了exec , 所注册的函数列表会被清除(因为这些函数不存在于新进程的地址空间中)。如果进程是通过信号而结束的,这些注册的函数也不会被调用。
函数调用的顺序是和注册的顺序相反的。也就是这些函数存储在栈中,以后进先出的方式调用(LIFO) 。注册的函数不能调用exit() , 否则会引起无限的递归调用。
SunOS 4定义自己的一个和atexit()等价的函数on_exit(),这个函数的工作方式和atexit()—样,只是注册的函数原型不同。
当一个进程子进程终止时,内核会向其父进程发送SIGCHILD信号。缺省情况下会忽略此信号量,父进程也不会有任何的动作。进程也可通过signal()或sigaction()系统调用来有选择的处理这个信号。通常情况下,父进程都希望能更多的了解到子进程的终止,或者显式的等待子进程的终止。
用户和组
用户和组:Linux中通过用户和组进行认证,每个用户和唯一的正整数关联,称为用户ID(uid)。每一个进程与一系列用户ID关联:
真实uid(real uid):每一个进程与一个用户ID关联,用来识别运行这个进程的用户。用于辨识进程的真正所有者,且会影响到进程发送信号的权限。没有超级用户权限的进程仅在其RUID与目标进程的RUID相匹配时才能向目标进程发送信号,例如在父子进程间,子进程从父进程处继承了认证信息,使得父子进程间可以互相发送信号。
有效UID(effective uid):在创建与访问文件的时候发挥作用。具体来说,创建文件时,系统内核将根据创建文件的进程的EUID与EGID设定文件的所有者/组属性,而在访问文件时,内核亦根据访问进程的EUID与EGID决定其能否访问文件。
保留uid(saved uid):于以提升权限运行的进程暂时需要做一些不需特权的操作时使用,这种情况下进程会暂时将自己的有效用户ID从特权用户(常为root) 对应的UID变为某个非特权用户对应的UID,而后将原有的特权用户UID复制为SUID暂存;之后当进程完成不需特权的操作后,进程使用SUID的值重 置EUID以重新获得特权。在这里需要说明的是,无特权进程的EUID值只能设为与RUID、SUID与EUID(也即不改变)之一相同的值。
文件系统uid(filesystem uid):在Linux中使用,且只用于对文件系统的访问权限控制,在没有明确设定的情况下与EUID相同(若FSUID为root的UID,则SUID、RUID与EUID必至少有一亦为root的UID),且EUID改变也会影响到FSUID。设立FSUID是为了允许程序(如NFS服务器)在不需获取向给定UID账户发送信号的情况下以给定UID的权限来限定自己的文件系统权限。
会话和进程组
每个进程都属于某个进程组。进程组是由一个或多个相互间有关联的进程组成的,它的目的是为了进行作业控制。进程组的主要特征就是信号可以发送给进程组中的所有进程:这个信号可以使同一个进程组中的所有进程终止、停止或者继续运行。每个进程组都由进程组ID(pgid)唯一的标识,并且有一个组长进程。进程组ID就是组长进程的pid。只要在某个进程组中还有一个进程存在,则该进程组就存在。即使组长进程终止了,该进程组依然存在。
当有新的用户登陆计算机,登陆进程就会为这个用户创建一个新的会话。这个会话中只有用户的登陆shell—个进程。登陆shell做为会话首进程(session leader)。会话首进程的pid就被作为会话的ID。一个会话就是一个或多个进程组的集合。会话囊括了登陆用户的所有活动,并且分配给用户一个控制终端(controling terminal)。控制终端是一个用于处理用户I/O的tty设备。因此,会话的功能和shell差不多。没有谁刻意去区分它们。
会话中的进程组分为一个前台进程组和零个或多个后台进程组。当用户退出终端时,向前台进程组中的所有进程发送SIGQUIT信号。当出现网络中断的情况时,向前台进程组中的所有进程发送SIGHUP信号。当 用户敲入了终止键(一般是Ctrl+C) ,向前台进程组中的所有进程发送SIGINT信号。
相关系统调用:setsid,getsid,setpgid,getpgid。
特殊进程:
Init 进程
Idle进程
空闲进程,当没有其他进程在运行时,内核所运行的进程—它的pid是0。
init进程,在启动后,内核运行的第一个进程称为init进程,它的pid是1。除非用户显式地指定内核所要运行的程序(通过内核启动的init参数),否则内核依次寻找一个init程序,第一被发现的就会当做init运行。如果所有的都失败了,内核就会发出panic,挂起系统。在内核交出控制后,init会接着完成后续的启动过程。典型的情况是init会初始化系统,启动各种服务和启动登陆进程。
Orphan Process孤儿进程
Zombie Process僵尸进程
等待终止的子进程
用信号通知父进程是可以的,但是很多的父进程想知道关于子进程终止的更多信息——例如子进程的返回值。如果在终止过程中,子进程完全消失了,就没有给父进程留下任何可以来了解子进程的东西。Unix的设计者们做出了这样的决定:如果一个子进程在父进程之前结束,内核应该把子进程设置为一个特殊的状态。处于这种状态的进程叫做僵死(zombie)进程。进程只保留最小的概要信息一一些保存着有用信息的内核数据结构(进程号,退出状态,运行时间等)。僵死的进程等待这父进程来查询自己的信息(这叫做在僵死进程上等待)。只要父进程获取了子进程的信息,子进程就会消失,否则一直保持僵死状态。
为避免僵死进程,如进程可以显示等待子进程结束、处理或者忽略SIGCHLD信号。
pid_t wait (int status);
pid_t waitpid (pid_t pid, int status, int options);
int waitid (idtype_t idtype, id_t id, siginfo_t *infop, int options);
wait()返回已终止子进程的pid , 或者返回-1表示出错。如果没有子进程终止,调用者会被阻塞,直到一个子进程终止。
int system (const char *command);
ANSI和 POSIX 都定义了一个用于创建新进程并等待它结束的函数—— 可以把它想象成是同步的创建进程。
只要有进程结束了,内核就会遍历它的所有子进程,并且把它们的父进程重新设为init进程(pid为1的那个进程)。这保证了系统中不存在没有父进程的进程。init进程会周期性的等待所有子进程,确保不会有长时间存在的僵死进程。
Daemon Process守护进程
守护进程运行在后台,不与任何控制终端相关联。守护进程通常在系统启动时就运行,它们以root用户运行或者其他特殊的用户(例如apache和postfix),并处理一些系统级的任务。习惯上守护进程的名字通常以d结尾(就像crond和sshd),但这不是必须的,甚至不是通用的。对于守护进程有两个基本要求:它必须是init进程的子进程,并且不与任何控制终端相关联。
一般来讲,进程可以通过以下步骤成为守护进程:
1,调用fork(),创建新的进程,它会是将来的守护进程。
2,在守护进程的父进程中调用exit()。这保证了守护进程的祖父进程确认父进程已经结束。还保证了父进程不再继续运行,守护进程不是组长进程。最后一点是顺利完成以下步骤的前提。
3,调用setsid(),使得守护进程有一个新的进程组和新的会话,两者都把它作为首进程。这也保证它不会与控制终端相关联(因为进程刚刚创建了新的会话,同时也就不会为其关联一个控制终端)。
4,用chdir()将当前工作目录改为根目录。因为前面调用fork()创建了新进程,它所继承来的当前工作目录可能在文件系统中任何地方。而守护进程通常在系统启动时运行,同时不希望一些随机目录保持打开状态,也就阻止了管理员卸载守护进程工作目录所在的那个文件系统。
5,关闭所有的文件描述符。不需要继承任何打开的文件描述符,对于无法确认的文件描述符,让它们继续处于打开状态。
6,打开0、1和2号文件描述符(标准输入、标准输出和标准错误),把它们重定向到/dev/null。