fork 函数
fork函数用于创建子进程,先看其声明方式:
pid_t fork(void);
该函数如果执行成功,则会返回两次,对于父进程,返回其子进程的ID,对于子进程,返回0。
进程创建成功后,子进程会拷贝父进程的地址空间,包括数据空间,堆和栈。但这在许多情况下会不必要的耗费很多资源,所以现在的实现一般都采用了一种叫做“copy-on-write (COW)”的技术。在这种技术下,父进程和子进程会共享这片空间,内核会保护这片空间为只读模式,只有当有进程要修改时,才进行部分拷贝工作。
对于Posix标准的线程,fork函数创建的进程只包含当前的调用线程。在前面的学习笔记中,我们知道,每个进程都会有自己的进程表,在fork以后,进程表会被复制,从而父子进程共享打开的文件。
除了打开的文件,以下进程属性也将被子进程继承:
Real user ID, real groupID, effective user ID, effective group ID
Supplementary group IDs
Process group ID
Session ID
Controlling terminal
The set-user-ID andset-group-ID flags
Current workingdirectory
Root directory
File mode creation mask
Signal mask anddispositions
The close-on-exec flagfor any open file descriptors
Environment
Attached shared memorysegments
Memory mappings
Resource limits
exec系列函数
在前面的学习笔记中,我们已经讨论过exec系列函数了,今天,我们将看到更多关于它的细节问题。我们知道,exec系列函数将使用一个新的进程镜像替换掉原先的进程镜像,而这部分包含代码段,数据段,堆以及栈。为了更好的说明exec函数之间的关系,我们还是回顾一下这六个声明:
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, const char *arg0,... /* (char *)0 */ );
int execvp(const char *filename, char *const argv []);
实际上,这六个函数只有一个execve属于系统调用,其它都属于库函数的定义,它们之间的关系可见下图:
对于这六个函数的命名方式,其规律其实很容易看出来,exec是共有部分,而l带表可变参,与之相对的则是v,代表数组。如果有p,则说明使用了PATH环境变量,其参数则是filename,最后的e则代表替换的进程(实际上并没有创建进程)将使用envp所指定的环境表。
需要着重解释的是关于filename与pathname的区别,如果是pathname,则它所代表的是可执行文件的路径,而filename则分两种情况,如果包含'/'前缀,则其意义与pathname相同,否则,则只代表文件名,寻找该可执行文件时需要使用PATH环境变量中的值作为查找前缀。
每一个打开的文件描述符都有一个close-on-exec标识,它默认是没有被设置的,如果该位被设置,则在使用exec系列函数后,该文件描述符将被关闭。
许多情况下,我们创建进程的目的只是为了调用exec系列的函数去加载新的程序,而在这种情况下使用fork就太浪费了,因为我们并不需要使用到父进程的地址空间及相关资源,替代的,我们可以使用vfork函数。该函数和fork的功能基本相同,区别在于,子进程并不会复制父进程的地址空间,在子进程调用_exit系列函数或是exec系列函数之前,子进程将在父进程的地址空间中运行,而在此期间父进程将被阻塞(请注意,一旦子进程调用了_exit系列函数或是exec系列函数之后,父进程将可以继续执行)。
关于vfork函数,还有一点需要特别注意的是,永远不要再子进程里进行main函数的返回或是调用exit函数,因为子进程和父进程共用地址空间,而exit函数会导致流的关闭以及调用“出口函数”,这将影响到父进程的状态。至于main函数的返回,其效果和调用exit函数是一样的。
尽管vfork函数并不复制地址空间,但进程表之类的东西它仍然是会复制的,也就是说,虽然调用_exit函数会关闭文件描述符,但这并不影响到父进程。如果父进程先于子进程结束,那么子进程将被init进程收养。
进程中的User ID 和 Group ID
在之前的学习笔记中,我已经介绍过关于进程ID的部分知识已经文件权限中set user id 位和set group id 位的作用。今天将更深入的介绍里面的一些知识,先来看两个函数声明:
int setuid(uid_t uid);
int setgid(gid_t gid);
这两个函数是用来设置进程ID以及组ID的,我们着重介绍setuid函数,后者与其类似,它的执行规则如下:
1. 如果进程拥有超级用户的权限,则setuid函数将real user ID, effective user ID, 以及 saved set-user-ID都设置为指定ID。
2. 如果进程没有超级用户的权限,则除非指定ID与real user ID 或则是 saved set-user-ID相同,否则执行不会成功,当执行成功时, effective user ID被设置为指定ID的值。
当我们执行exec系列函数后,如果set user id位已被设置,则进程的 effective user ID将被设置为执行文件的属主ID,而无论该位是否被设置,saved set-user-ID都将被设置为effective user ID的值(这一点非常重要)。
我们来看一下saved set-user-ID的作用,拿man命令来举例,因为它将使用到许多普通用户无法使用的文件,所以它的set user id 是被设置了的,也就是说在执行它的时候,effective user ID将被设置为man用户。然后,由于,我们可以在man运行的过程中通过命令执行其他进程(例如shell),这样是非常危险的,因为它很可能把man的权限传递给危险的进程。为了防止这种情况的发生,man在执行完它的初始化后,在等待用户命令之前,它将调用setuid函数将effective user ID设置回real id(saved set-user-ID不变,依然是man),而在需要再次访问man所需要的文件时,我们再通过setuid提高权限,将effective user ID设置回man(因为saved set-user-ID是man,所以操作合法),所以,我们可以看到man在这里就起着一个记录保存的作用,可以方便安全的进行权限的提高和降低。