摘要:本节将介绍进程的定义。进程作为构成系统的基本细胞,不仅是系统内部独立运行的实体,而且是独立竞争资源的基本实体。了解进程的本质,对于理解、描述和设计操作系统有着极为重要的意义。了解进程的活动、状态,也有利于编制复杂程序。
1.进程的基本概念
首先我们先看看进程的定义,进程是一个具有独立功能的程序关于某个数据集合的一次可以并发 执行的运行活动,是处于活动状态的计算机程序。进程作为构成系统的基本细胞,不仅是系统内部独立运行的实体,而且是独立竞争资源的基本实体。了解进程的本 质,对于理解、描述和设计操作系统有着极为重要的意义。了解进程的活动、状态,也有利于编制复杂程序。
1.1 进程状态和状态转换
现在我们来看看,进程在生存周期中的各种状态及状态的转换。下面是LINUX系统的进程状态模型的各种状态:
用户状态:进程在用户状态下运行的状态。
内核状态:进程在内核状态下运行的状态。
内存中就绪:进程没有执行,但处于就绪状态,只要内核调度它,就可以执行。
内存中睡眠:进程正在睡眠并且进程存储在内存中,没有被交换到SWAP设备。
就绪且换出:进程处于就绪状态,但是必须把它换入内存,内核才能再次调度它进行运行。
睡眠且换出:进程正在睡眠,且被换出内存。
被抢先:进程从内核状态返回用户状态时,内核抢先于它,做了上下文切换,调度了另一个进程。原先这个进程就处于被抢先状态。
创建状态:进程刚被创建。该进程存在,但既不是就绪状态,也不是睡眠状态。这个状态是除了进程0以外的所有进程的最初状态。
僵死状态(zombie):进程调用exit结束,进程不再存在,但在进程表项中仍有纪录,该纪录可由父进程收集。
现在我们从进程的创建到退出来看看进程的状态转化。需要说明的是,进程在它的生命周期里并不一定要经历所有的状态。
首先父进程通过系统调用fork来创建子进程,调用fork时,子进程首先处于创建态,fork调用为子进程配置好内核数据结构和子进程私有数据结构后,子进程就要进入就绪态3或5,即在内存中就绪,或者因为内存不够,而导致在SWAP设备中就绪。
假设进程在内存中就绪,这时子进程就可以被内核调度程序调度上CPU运行。内核调度该进程进入内核状态,再由内核状态返回用户状态执行。该进程在用户状 态运行一定时间后,又会被调度程序所调度而进入内核状态,由此转入就绪态。有时进程在用户状态运行时,也会因为需要内核服务,使用系统调用而进入内核状 态,服务完毕,会由内核状态转回用户状态。要注意的是,进程在从内核状态向用户状态返回时可能被抢占,进入状态7,这是由于有优先级更高的进程急需使用 CPU,不能等到下一次调度时机,从而造成抢占。
进程还会因为请求的资源不能得到满足,进入睡眠状态,直到它请求的资源被释放, 才会被内核唤醒而进入就绪态。如果进程在内存中睡眠时,内存不足,当进程睡眠时间达到一个阀值,进程会被SWAP出内存,使得进程在SWAP设备上睡眠。 这种状况同样可能发生在就绪的进程上。
进程调用exit系统调用,将使得进程进入内核状态,执行exit调用,进入僵死状态而结束。以上就是进程状态转换的简单描述。
进程的上下文是由用户级上下文、寄存器上下文以及系统级上下文组成。主要内容是该进程用户空间内容、寄存器内容以及与该进程有关的内核数据结构。当系统 收到一个中断、执行系统调用或内核做上下文切换时,就会保存进程的上下文。一个进程是它的上下文中运行的,若要调度进程,就要进行上下文切换。内核在四种 情况下允许发生上下文切换:
当进程自己进入睡眠时;
当进程执行完系统调用要返回用户状态,但发现该进程不是最有资格运行的进程时;
当内核完成中断处理后要返回用户状态,但发现该进程不是最有资格运行的进程时;
当进程退出(执行系统调用exit后)时。
有时内核要求必须终止当前的执行,立即从先前保存的上下文处执行。这可由setjmp和longjmp实现,setjmp将保存的上下文存入进程自身的 数据空间(u区)中,并继续在当前的上下文中执行,一旦碰到了longjmp,内核就从该进程的u区,取出先前保存的上下文,并恢复该进程的上下文为原先 保存的。这时内核将使得进程从setjmp处执行,并给setjmp返回1。
进程因等待资源或其他原因,进入睡眠态是通过内核的 sleep算法。该算法与本章后面要讲到的sleep函数是两个概念。算法sleep记录进程原先的处理机优先级,置进程为睡眠态,将进程放入睡眠队列, 记录睡眠的原因,给该进程进行上下文切换。内核通过算法wakeup来唤醒进程,如某资源被释放,则唤醒所有因等待该资源而进入睡眠的进程。如果进程睡眠 在一个可以接收软中断信号(signal)的级别上,则进程的睡眠可由软中断信号的到来而被唤醒。
1.2 进程控制
现在我们开始讲述一下进程的控制,主要介绍内核对fork、exec、wait、exit的处理过程,为下一节学习这些调用打下概念上的基础,并介绍系统启动(boot)的过程以及进程init的作用。
在Linux系统中,用户创建一个进程的唯一方法就是使用系统调用fork。内核为完成系统调用fork要进行几步操作第一步,为新进程在进程表中分配 一个表项。系统对一个用户可以同时运行的进程数是有限制的,对超级用户没有该限制,但也不能超过进程表的最大表项的数目。第二步,给子进程一个唯一的进程 标识号(PID)。该进程标识号其实就是该表项在进程表中的索引号。第三步,复制一个父进程的进程表项的副本给子进程。内核初始化子进程的进程表项时,是 从父进程处拷贝的。所以子进程拥有与父进程一样的uid、euid、gid、用于计算优先权的nice值、当前目录、当前根、用户文件描述符表等。第四 步,把与父进程相连的文件表和索引节点表的引用数加1。这些文件自动地与该子进程相连。第五步,内核为子进程创建用户级上下文。内核为子进程的u区及辅助 页表分配内存,并复制父进程的区内容。这样生成的是进程的静态部分。第六步,生成进程的动态部分,内核复制父进程的上下文的第一层,即寄存器上下文和内核 栈,内核再为子进程虚设一个上下文层,这是为了子进程能“恢复”它的上下文。这时,该调用会对父进程返回子进程的pid,对子进程返回0。
Linux系统的系统调用exit,是进程用来终止执行时调用的。进程发出该调用,内核就会释放该进程所占的资源,释放进程上下文所占的内存空间,保留 进程表项,将进程表项中纪录进程状态的字段设为僵死状态。内核在进程收到不可捕捉的信号时,会从内核内部调用exit,使得进程退出。父进程通过 wait得到其子进程的进程表项中纪录的计时数据,并释放进程表项。最后,内核使得进程1(init进程)接收终止执行的进程的所有子进程。如果有子进程 僵死,就向init进程发出一个SIGCHLD的软中断信号.
一个进程通过调用wait来与它的子进程同步,如果发出调用的进程 没有子进程则返回一个错误,如果找到一个僵死的子进程就取子进程的PID及退出时提供给父进程的参数。如果有子进程,但没有僵死的子进程,发出调用的进程 就睡眠在一个可中断的级别上,直到收到一个子进程僵死(SIGCLD)的信号或其他信号。
进程控制的另一个主要内容就是对其他程序引 用。该功能是通过系统调用exec来实现的,该调用将一个可执行的程序文件读入,代替发出调用的进程执行。内核读入程序文件的正文,清除原先进程的数据 区,清除原先用户软中断信号处理函数的地址,当exec调用返回时,进程执行新的正文。
一个系统启动的过程,也称作是自举的过 程。该过程因机器的不同而有所差异。但该过程的目的对所有机器都相同:将操作系统装入内存并开始执行。计算机先由硬件将引导块的内容读到内存并执行,自举 块的程序将内核从文件系统中装入内存,并将控制转入内核的入口,内核开始运行。内核首先初始化它的数据结构,并将根文件系统安装到根“/”,为进程0形成 执行环境。设置好进程0的环境后,内核便作为进程0开始执行,并调用系统调用fork。因为这时进程0运行在内核状态,所以新的进程也运行在内核状态。新 的进程(进程1)创建自己的用户级上下文,设置并保存好用户寄存器上下文。这时,进程1就从内核状态返回用户状态执行从内核拷贝的代码(exec),并调 用exec执行/sbin/init程序。进程1通常称为初始化进程,它负责初始化新的进程。
进程init除了产生新的进程外, 还负责一些使用户在系统上注册的进程。例如,进程init一般要产生一些getty的子进程来监视终端。如果一个终端被打开,getty子进程就要求在这 个终端上执行一个注册的过程,当成功注册后,执行一个shell程序,来使得用户与系统交互。同时,进程init 执行系统调用wait来监视子进程的死亡,以及由于父进程的退出而产生的孤儿进程的移交。以上是系统启动和进程init的一个粗略的模型。
1.3 进程调度的概念
Linux系统是一个分时系统,内核给每个进程分一个时间片,该进程的时间片用完就会 调度另一个进程执行。LINUX系统上的调度程序属于多级反馈循环调度。该调度方法是,给一个进程分一个时间片,抢先一个运行超过时间片的进程,并把进程 反馈到若干优先级队列中的一个队列。进程在执行完之前,要经过这样多次反馈循环。
进程调度分成两个部分,一个是调度的时机,即什 么时候调度;一个是调度的算法,即如何调度和调度哪个进程。我们先来看看调度的算法,假设目前内核要求进行调度,调度程序从“在内存中就绪”和“被抢先” 状态的进程中选择一个优先权最高的进程,如果有若干优先权一样高的进程,则在其中选择等待时间最长的进程。切换进程上下文,继续执行该进程。如果没有选择 到进程,则不做操作,等待下一次调度时机的到来。
每一个进程都有一个用于调度的优先权域。进程的优先权由低到高粗略地分为用户优先权和内核优先权。每种优先权有若干优先权值(优先数)与其对 应。每个优先权都有一个逻辑上与其相连的进程队列。进程从内核状态返回用户状态时被抢先,从而得到用户优先权。进程在内核算法sleep中得到内核优先 权。内核优先权高于用户优先权,即内核优先权和用户优先权之间存在一个阀值,所有用户优先权低于该阀值,而内核优先权高于该阀值。内核优先权中又划分为可 中断和不可中断,即进程在收到一个软中断信号时,低内核优先权的进程可被唤醒,而有高内核优先权的进程继续睡眠。
计算一个进程优 先权的时机是:内核将一个优先权值赋给一个将进入睡眠的进程,这个优先权值是固定的,且与睡眠原因相联系;另一个时机是,时钟处理程序每隔一定时间(如每 隔1秒)调整用户状态下的所有进程的优先权,并使内核运行调度算法。时钟处理程序还根据一个衰减函数,每秒一次的调整每个进程的最近CPU使用时间。例如 可按如下公式调整:
decay(CPU) = CPU/2;
再根据公式重新计算在“就绪”和“被抢先”状态下的每个进程的优先权。
Priority = (“recent CPU usage”/constant) + (base priority) + (nice value);
其中constant是个系统常量(一般取值为“2”)。base priority值也是系统的一个常量,一般base priority取值为60。最后,nice的值是由进程发出nice调用时给出的值,这样就可以使得用户通过降低优先权而让出一些执行时间。只有超级用 户才能指定提高优先权的nice值。
摘要:本节先介绍一些关于进程的基本操作,通过本节,我们将了解如何产生子进程,进程如何改变它的执行映像,父子进程的同步等操作。由此也了解到一些并行程序的基本概念与如何编制简单的并行程序。
2. 进程的一般操作
上一节介绍了一些有关进程的基本概念,从这一节开始要结合一些例子来阐述一些有关进程的系统调用。本节先介绍一些关于进程的基本操作,通过本节,我们将了 解如何产生子进程,进程如何改变它的执行映像,父子进程的同步等操作。由此也了解到一些并行程序的基本概念与如何编制简单的并行程序。
2.1 fork 系统调用
系统调用fork是用来创建一个子进程。创建的过程前面一节已经介绍过。现在,再介绍一个系统调用vfork,这个调用的产生是因为认识到创建子进程时对 父进程的所有页不进行拷贝能带来性能上的改善。该调用假定进行vfork调用后,将立即调用exec,这样就不需要拷贝父进程的所有页表。因为它不拷贝页 表,所以比fork调用快。有些系统的fork也采用了其他方法来提高性能,比较典型的一种是增加“写时拷贝”。这种fork调用,产生子进程时,并不拷 贝父进程的所有页面,而是置父进程所有页面的写时拷贝位,子进程共享父进程的所有页面。直到父进程或子进程写某个页面时,就会发生一个保护性错误,并拷贝 该页面。这样不仅提高了内核的性能,而且改善了内存的利用。
系统调用fork和vfork的声明格式如下:
pid_t fork(void);
pid_t vfork(void);
在使用该系统调用的程序中要加入以下头文件:
#include
当调用执行成功时,该调用对父进程返回子进程的PID,对子进程返回0。调用失败时,给父进程返回-1,没有子进程创建。
下面是发生错误时,可能设置的错误代码errno:
EAGAIN:系统调用fork不能得到足够的内存来拷贝父进程页表。或用户是超级用户但进程表满,或者用户不是超级用户但达到单个用户能执行的最大进程数。
ENOMEM:对创建新进程来说没有足够的空间,该错误是指没有足够的空间分配给必要的内核结构。
下面我们看一个fork调用的简单的例子。该例子产生一个子进程,父进程打印出自己和子进程的PID,子进程打印出自己的PID和父进程的PID。
注意:父进程打开了一个文件。父子进程都可以对该文件操作,该程序父子进程都向文件中写入了一行。
#include
#include
#include
#include
#include
#include
#include
#include
extern int errno;
int main()
{
char buf[100];
pid_t cld_pid;
int fd;
int status;
if ((fd=open("temp",O_CREAT|O_TRUNC|O_RDWR,S_IRWXU)) == -1)
{
printf("open error %d",errno);
exit(1);
}
strcpy(buf,"This is parent process write");
if ((cld_pid=fork()) == 0)
{ /* 这里是子进程执行的代码 */
strcpy(buf,"This is child process write");
printf("This is child process");
printf("My PID(child) is %d",getpid()); /*打印出本进程的ID*/
printf("My parent PID is %d",getppid()); /*打印出父进程的ID*/
write(fd,buf,strlen(buf));
close(fd);
exit(0);
}
else
{ /* 这里是父进程执行的代码 */
printf("This is parent process");
printf("My PID(parent) is %d",getpid()); /*打印出本进程的ID */
printf("My child PID is %d",cld_pid); /*打印出子进程的ID*/
write(fd,buf,strlen(buf));
close(fd);
}
wait(&status); /* 如果此处没有这一句会如何?*/
return 0;
}
下面我们看一下,程序运行的结果,假设源文件命名为fork.c:
[root@wapgw /root]# gcc -o fork fork.c
[root@wapgw /root]# ./fork
This is parent process
This is child process
My PID(child) is 5258
My parent PID is 5257
My PID(parent) is 5257
My child PID is 5258
[root@wapgw /root]#
从上面的运行结果可以看出进程的调度,父进程打印出第一行后,CPU调度子进程,打印出后续的三行,子进程结束,调度父进程执行(其中可能还有其他的进程被调度),父进程执行完,将控制返还给shell程序,最后一行是shell程序输出的提示符。
看看temp文件里有什么内容
[root@wapgw /root]# more temp
This is child process write
This is parent process write
[root@wapgw /root]#
现在我们将程序稍作修改。将wait调用注释掉,我们看看会有什么样的结果。因为调度的原因,多执行几次,你会看到如下的结果:
[root@wapgw /root]#vi fork.c //将wait调用注释掉
[root@wapgw /root]# gcc -o fork fork.c
[root@wapgw /root]# ./fork
This is parent process
This is child process
My PID(parent) is 5282
My child PID is 5283
[root@wapgw /root]# My PID(child) is 5283
My parent PID is 1
[root@wapgw /root]#
第一行是父进程的输出,第二行是子进程的输出,第三、四行是父进程的输出,这时父进程由于没有wait调用,不等待子进程而结束。下面一行中的 “[root@wapgw /root]#”是父进程结束,将控制返回给shell时,shell输出的提示符。然后CPU调用子进程,输出子进程的 PID是5283。注意,下面子进程输出其父进程的ID是1,因为它的父进程结束了,内核将它交给了进程1(进程init)来管理,这个过程见前面一节。 这里要提一下的是,输出结果的顺序和进程调度的顺序有关,自己试验的结果与例子中的顺序很可能不同,请自行分析。(从我的系统给出的结果来看,加不加 wait 都一样,都先执行完子进程,后执后父进程,不过用管道从父进程向子进程传消息,子进程也可以正常收到,看来现在内核调度比较智能,具体调度顺序 有待于进一步研究)
2.2 exec 系统调用
系统调用exec是用来执行一个可执行文件来代替当前进程的执行映像。需要注意的是,该调用并没有生成新的进程,而是在原有进程的基础上,替换原有进程的 正文,调用前后是同一个进程,进程号PID不变。但执行的程序变了(执行的指令序列改变了)。它有六种调用的形式,随着系统的不同并不完全与以下介绍的相 同。它们的声明格式如下:
int execl( const char *path, const char *arg, ...);
int execlp( const char *file, const char *arg, ...);
int execle( const char *path, const char *arg , ..., char* const envp[]);
int execv( const char *path, char *const argv[]);
int execve( const char *filename, char *const argv [], char *const envp[]);
int execvp( const char *file, char *const argv[]);
在使用这些系统调用的程序中要加入以下头文件和外部变量:
#include
extern char **environ;
下面我们先详细讲述其中的一个,然后再给出它们之间的区别。在系统调用execve中,参数path是将要执行的文件,参数argv是要传递给文件的参 数,参数envp是要传递给文件的环境变量。当参数path所指的文件替换原进程的执行映像后,文件path开始执行,参数argv和envp便传递给进 程。下面我们举一个简单的例子。
在讲述系统调用execve的例子之前,我们先来看看环境变量。为了使用户方便和灵活地使用Shell,LINUX引入了环境的概念。环境是一些数据,用 户可以改变这些数据,增加新的数据或删除一些数据。这些数据称为环境变量。因为它们定义了用户的工作环境,同时又可以被修改。每个用户都可以有自己不同的 环境变量,用户可以用env命令(不带参数)浏览环境变量,输出的格式和变量名随着 Shell的不同和系统配置的不同而不同。下面这个例子打印出传递给 该进程的所有参数和环境变量:
#include
#include
extern char **environ;
int main(int argc,char* argv[])
{
int i;
printf("Argument:\n");
for (i=0;i<=argc;i++) printf("Arg%d is: %s\n",i,argv[i]);
printf("Environment:\n");
for (i=0;environ[i]!=NULL;i++) printf("%s\ ",environ[i]);
}
下面是执行时的屏幕拷贝:
[root@wapgw /root]# gcc -o example example.c
[root@wapgw /root]# ./example test
Argument:
Arg0 is ./example
Arg1 is test
Environment:
PWD=/root
REMOTEHOST=cjm
HOSTNAME=wapgw
HOME=/root
。。。。。。。。。。。。。。。。。。。。。。。
SSH_ASKPASS=/usr/libexec/ssh/gnome-ssh-askpass
PATH=/usr/local/sbin:/usr/sbin:/sbin:/usr/local/sbin:/usr/local/bin:
/sbin:/bin:/usr/sbin:/usr/bin:/usr/X11R6/bin:/root/bin
[root@wapgw /root]#
其中Environment后的都是环境变量及其取值。下面我们来看看execve的一个简单的例子:
#include
#include
extern char **environ;
int main(int argc,char* argv[])
{
printf("Will replace by another image");
execve("example",argv,environ); /* 用上面的例子example替换进程执行映像 */
printf("process never go to here"); /* 进程永远不会执行到这里 */
}
该程序用自己的参数argv和环境变量传递给新的执行映像。执行结果的屏幕拷贝如下:
[root@wapgw /root]# gcc -o execve execve.c
[root@wapgw /root]# ./execve these args will dend to example
Will replace by another image
Argument:
Arg0 is ./execve
Arg1 is these
Arg2 is args
Arg3 is will
Arg4 is dend
Arg5 is to
Arg6 is example
Environment:
PWD=/root
REMOTEHOST=cjm
HOSTNAME=wapgw
HOME=/root
。。。。。。。。。。。。。。。。。。。。。。。
SSH_ASKPASS=/usr/libexec/ssh/gnome-ssh-askpass
PATH=/usr/local/sbin:/usr/sbin:/sbin:/usr/local/sbin:/usr/local/bin:
/sbin:/bin:/usr/sbin:/usr/bin:/usr/X11R6/bin:/root/bin
[root@wapgw /root]#
这里要注意的是,如果你用execve some args > screen时,会发现输出重定向到一个文件后,丢失了数据(即少了输出的第一行 Will replace …)。这是因为将输出重定向到一个文件后,进程的第一行是输出到文件,所以被缓冲还没有真正写入文件,进程的第二行替换进程的 执行映像,也覆盖了文件的缓冲。这个问题可以通过fflush(stdout)刷新stdout的缓冲区或者用setbuf(stdout,NULL)设 置stdout的缓冲为空来解决。
如果对某个文件描述符fd设置了close-on-exec标志,那么在exec调用后,该文件描述符被关闭。下面我们看一个简单的例子:
这里有一个程序pp.c:
#include
int main()
{
printf("test");
}
它是用来替换进程执行图像的。再来看看下面的程序:
#include
#include
#include
extern char **environ;
int main(int argc,char* argv[])
{
printf("close-on-exec is %d",fcntl(1,F_GETFD));
fcntl(1,F_SETFD,16);
printf("close-on-exec is %d",fcntl(1,F_GETFD));
execve("pp",argv,environ);
printf("AH!!!!!");
}
该程序的执行结果为:
[root@wapgw /root]# ./fcntl
close-on-exec is 0
close-on-exec is 0
test
[root@wapgw /root]#
这是没有设置close-on-exec标志的结果,将fcntl语句改为
fcntl(1,F_SETFD,25);
对于最后一个参数,只要保证该参数的最低位(二进制)是1就可以。这时的执行结果为:
[root@wapgw /root]# ./fcntl
close-on-exec is 0
close-on-exec is 1
[root@wapgw /root]#
这时,系统调用execve用pp替换原进程的执行图像,但由于文件描述符1(stdout)被关闭,所以执行完execve调用后无输出。
系统调用execve可以执行二进制的可执行文件(如a.out)。也可以执行shell程序,该shell程序必须以下面所示的格式开头,即第一行为: #! interpreter [arg]。其中interpreter可以是shell或其他解释器,例如:#!/bin/bsh或#! /usr/bin/perl。其中的arg是传递给解释器的参数。
该系统调用成功时,不会返回任何值(因为进程的执行映像已经被替换,没有接收返回值的地方了)。如果有任何返回值(一般是-1),就代表有错误发生,内核将设置相应的错误代码errno,下面是一些可能设置的错误代码:
EACCES:指向的文件或脚本文件不是普通文件;指向的文件或脚本文件没有设置可执行位;文件系统被安装成noexec;指向的文件或脚本文件所处的路径中有目录不能搜索(没有execute权)。
E2BIG:传递的参数清单太大。
ENOEXEC:指定的文件确实有执行权限位,但是为即不可识别的执行文件格式。
ETXTBUSY:指定文件被一个或多个进程以可写入的方式打开。
EIO:从文件系统读入时发生I/O错误。
现在我们来看看这一族系统调用。在系统调用execl、execlp、execle中,参数是以arg0、arg1、arg2、…的方式传递的。按照惯 例,arg0应该是要执行的程序名。在调用execl、execlp中环境变量的值是自动传递的,即不用象调用execve、execle那样在调用中指 定参数envp。在调用execve、execv、execvp中参数是以数组的方式传递的。另一个区别是,调用execlp、execvp可以在环境变 量PATH定义的路径中查找执行程序。例如,PATH定义为“/bin:/usr/bin:/usr/sbin”,如果调用指定执行文件名为test,那 么这两个调用会在PATH定义的三个目录中查找名为test的可执行文件。
系统调用exec和fork经常结合使用,父进程fork一个子进程,在子进程中调用exec来替换子进程的执行映像,并发的执行一些操作。
2.3 exit 系统调用
系统调用exit的功能是终止发出调用的进程。它的声明格式如下:
void _exit(int status);
在使用这个系统调用的程序中要加入以下头文件:
#include
系统调用_exit立即终止发出调用的进程。所有属于该进程的文件描述符都关闭。该进程的所有子进程由进程1(进程init)接收,并对该进程的父进程发 出一个SIGCHLD(子进程僵死)的信号。参数status作为退出的状态值返回父进程,该值可以通过系统调用wait来收集。返回状态码status 只有最低一个字节有效。如果进程是一个控制终端进程,则SIGHUP信号将被送往该控制终端的前台进程。系统调用_exit从不返回任何值给发出调用的进 程;也不刷新I/O缓冲,如果要自动完成刷新,可以用函数调用exit。
2.4 wait 系统调用
系统调用wait的功能是发出调用的进程只要有子进程,就睡眠直到它们中的一个终止为止。该调用声明的格式如下:
pid_t wait(int *status)
pid_t waitpid(pid_t pid, int *status, int options);
在使用这些系统调用的程序中要加入以下头文件
#include
#include
发出wait调用的进程进入睡眠直到它的一个子进程退出时或收到一个不能被忽略的信号时被唤醒。如果调用发出时,已经有退出的子进程(这时子进程的状态是僵死状态),该调用立即返回。其中调用返回时参数status中包含子进程退出时的状态信息。
调用waitpid与调用wait的区别是waitpid等待由参数pid指定的子进程退出。其中参数pid的含义与取值方法如下:
参数pid < -1时,当退出的子进程满足下面条件时结束等待:该子进程的进程组ID(process group)等于绝对值的pid这个条件。
参数pid = 0时,等待任何满足下面条件的子进程退出:该子进程的进程组ID等于发出调用进程的进程组ID。
参数pid > 0时,等待进程ID等于参数pid的子进程退出。
参数pid = -1时,等待任何子进程退出,相当于调用wait。
对于调用waitpid中的参数options的取值及其含义如下:
WNOHANG:该选项要求如果没有子进程退出就立即返回。
WUNTRACED:对已经停止但未报告状态的子进程,该调用也从等待中返回和报告状态。如果status不是空,调用将使status指向该信息。下面的宏可以用来检查子进程的返回状态。前面三个用来判断退出的原因,后面三个是对应不同的原因返回状态值:
WIFEXITED(status):如果进程通过系统调用_exit或函数调用exit正常退出,该宏的值为真。
WIFSIGNALED(status):如果子进程由于得到的信号(signal)没有被捕捉而导致退出时,该宏的值为真。
WIFSTOPPED(status):如果子进程没有终止,但停止了并可以重新执行时,该宏返回真。这种情况仅出现在waitpid调用中使用了WUNTRACED选项。
WEXITSTATUS(status):如果WIFEXITED(status)返回真,该宏返回由子进程调用_exit(status)或exit(status)时设置的调用参数status值。
WTERMSIG(status):如果WIFSIGNALED(status)返回为真,该宏返回导致子进程退出的信号(signal)的值。
WSTOPSIG(status):如果WIFSTOPPED(status)返回真,该宏返回导致子进程停止的信号(signal)值。
该调用返回退出的子进程的PID;或者发生错误时返回-1;或者设置了WNOHANG选项没有子进程退出就返回0;发生错误时,可能设置的错误代码如下:
ECHILD:该调用指定的子进程pid不存在,或者不是发出调用进程的子进程。
EINVAL:参数options无效。
ERESTARTSYS:WNOHANG没有设置并且捕获到SIGCHLD或其它未屏蔽信号。
关于wait调用的例子,前面在介绍fork调用时,就有了简单的应用。此处不再举例。
注意:子进程退出(SIGCHLD)信号设置不同的处理方法,会导致该调用不同的行为,详细情况见Linux信号处理机制。
2.5 sleep 函数调用
函数调用sleep可以用来使进程挂起指定的秒数。该函数调用的声明格式如下:
unsigned int sleep(unsigned int seconds)
在使用这个函数调用的程序中加上以下的头文件:
#include
该函数调用使得进程挂起一个指定的时间,直到指定时间用完或者收到信号。系统的活动对指定的时间有一定的影响。Linux系统是用SIGALRM实现的,在Linux系统里,sleep函数不能和alarm()调用混用。
如果指定挂起的时间到了,该调用返回0;如果该函数调用被信号所打断,则返回剩余挂起的时间数(指定的时间减去已经挂起的时间)。
摘要:本节要介绍一些有关进程的特殊操作。有了这些操作,就使得进程的编程更加完善,能编制更为实用的程序。主要的内容有得到关于进程的各种ID、对进程的设置用户ID、改变进程的工作目录、改变进程的根、改变进程的优先权值等操作。
3.进程的特殊操作
上一节介绍了有关进程的一些基本操作,如进程的产生、进程的终止、进程执行映像的改变、等待子进程终止等。本节要介绍一些有关进程的特殊操作。有了这些操作,就使得进程的编程更加完善,能编制更为实用的程序。
主要的内容有得到关于进程的各种ID、对进程的设置用户ID、改变进程的工作目录、改变进程的根、改变进程的优先权值等操作。
3.1 获得进程相关的ID
与进程相关的ID有:
真正用户标识号(UID):该标识号负责标识运行进程的用户。
有效用户标识号(EUID):该标识号负责标识以什么用户身份来给新创建的进程赋所有权、检查文件的存取权限和检查通过系统调用kill向进程发送软中断信号的许可权限。
真正用户组标识号(GID):负责标识运行进程的用户所属的组ID。
有效用户组标识号(EGID):用来标识目前进程所属的用户组。可能因为执行文件设置set-gid位而与gid不同。
进程标识号(PID):用来标识进程。
进程组标识号(process group ID):一个进程可以属于某个进程组。可以发送信号给一组进程。注意,它不同与gid。前面的系统调用wait中指定参数pid时,就用到了进程组的概念。
如果要获得进程的用户标识号,用getuid调用。调用geteuid是用来获得进程的有效用户标识号。有效用户ID与真正用户ID的不同是由于执行文件设置set-uid位引起的。这两个调用的格式如下:
uid_t getuid(void);
uid_t geteuid(void);
在使用这两个调用的程序中加入下列头文件:
#include
#include
如果要获得运行进程的用户组ID,使用getgid调用来获得真正的用户组ID,用getegid获得有效的用户组ID。标识gid与egid的不同是由于执行文件设置set-gid位引起的。这两个调用的格式如下:
gid_t getgid(void);
gid_t getegid(void);
在使用这两个调用的程序中加入下列头文件:
#include
#include
如果要获得进程的ID,使用getpid调用;要获得进程的父进程的ID,使用getppid调用。这两个调用的格式如下:
pid_t getpid(void);
pid_t getppid(void);
在使用这两个调用的程序中加入下列头文件:
#include
如果要获得进程所属组的ID,使用getpgrp调用;若要获得指定PID进程所属组的ID用getpgid调用。这两个调用的格式如下:
pid_t getpgrp(void);
pid_t getpgid(pid_t pid);
在使用这两个调用的程序中加入下列头文件:
#include
注意一下gid和pgrp的区别,一般执行该进程的用户的组ID就是该进程的gid,如果该执行文件设置了set_gid位,则文件所属的组ID就是该 进程的gid。对于进程组ID,一般来说,一个进程在shell下执行,shell程序就将该进程的PID赋给该进程的进程组ID,从该进程派生的子进程 都拥有父进程所属的进程组ID,除非父进程将子进程的所属组ID设置成与该子进程的PID一样。由于这几个调用使用很简单,这里就不再举例。
3.2 setuid 和 setgid 系统调用
前面讲述了如何得到uid和gid,现在来看看如何设置它们。在讲述这两个调用以前我们先来看看对文件设置set_uid位会有什么作用。我们先编了一 个小程序来做试验。这个程序的作用是,打印出进程的uid和euid,然后打开一个名为tty.c的文件。如果打不开,就显示错误代码;如果打开了,就显 示打开成功。假设该程序名叫uid_test.c:
/* uid_test.c */
#include
#include
#include
#include
#include
#include
extern int errno;
int main()
{
int fd;
printf("This process's uid = %d, euid = %d ",getuid(),geteuid());
if ((fd = open("tty.c",O_RDONLY))==-1)
{
printf("Open error, errno is %d ",errno);
exit(1);
}
else
{
printf("Open success ");
}
}
下面列出这几个文件的目录,可以看到文件tty.c的存取许可权仅为属主root可读写。
[wap@wapgw /tmp]$ ls -l
total 3
-rw------- 1 root root 0 May 31 16:15 tty.c
-rwxr-xr-x 1 root root 14121 May 31 16:15 uid_test
-rw-r--r-- 1 root root 390 May 31 16:15 uid_test.c
[wap@wapgw /tmp]$
在该系统中的用户中个用户wap(500),以root用户身份执行程序:
[root@wapgw /tmp]# ./uid_test
This process's uid = 0, euid = 0
Open success
[root@wapgw /tmp]#
下面使用su命令,转到用户wap下,执行程序
[root@wapgw /tmp]#su wap
[wap@wapgw /tmp]$ ./uid_test
This process's uid = 500, euid = 500
Open error, errno is 13
[wap@wapgw /tmp]$
这是由于进程的uid是500(wap),对文件tty.c没有存取权,所以出错。
给程序文件设置set-uid位
[root@wapgw /tmp]# chmod 4755 uid_test
再转到用户wap下,执行程序uid_test。
[wap@wapgw /tmp]$ ./uid_test
This process's uid = 500, euid = 0
Open success
[wap@wapgw /tmp]$
从上面我们看到,进程打印出的euid是0(root),而运行该进程的用户是500(wap)。由于进程的euid是root,所以成功打开了文件tty.c。
上面的例子说明了两个事实:第一,内核对进程存取文件的许可权的检查,是通过检查进程的有效用户ID来实现的;第二,执行一个设置set_uid位的程 序时,内核将进程表项中和u区中的有效用户ID设置为文件属主的ID。为了区别进程表项中的euid和u区中的euid,我们将进程表项中的euid 域称为保存用户标识号(saved user ID)。
下面我们来看看这两个调用。调用的声明格式如下:
int setuid(uid_t uid);
int setgid(gid_t gid);
在使用这两个调用的程序中加入下面的头文件:
#include
调用setuid为当前发出调用的进程设置真正和有效用户ID。参数uid是新的用户标识号(该标识号应该在/etc/passwd文件中存在)。如果 发出调用的进程的有效用户ID是超级用户,内核将进程表中和u区中的真正用户标识号和有效用户标识号置为参数uid。如果发出调用的进程的有效用户 ID而不是超级用户,那么内核将根据指定的参数uid来执行,如果这时指定的参数uid的值是真正用户标识号或者是保存用户标识号(saved user ID),则内核将u区中的有效用户标识号改为参数uid,否则,该调用返回错误。该调用成功时,返回值为0;发生错误时,返回-1,并设置相应的错误代码 errno,下面是经常可能发生的错误代码:
EPERM:用户不是超级用户,并且指定的参数uid与发出调用的进程的真正用户ID或保存用户ID不匹配。
调用setgid设置当前发出调用的进程的真正、有效用户组ID。该调用允许进程指定进程的用户组ID为参数gid,如果进程的有效用户ID不是超级用 户,该参数gid必须等于真正用户组ID、有效用户组ID中的一个。如果进程的有效用户ID是超级用户,可以指定任何存在的用户组ID(在 /etc/group文件中存在)。
注意: 对于setuid程序尤其要小心,当进程的euid是超级用户时,如果将进程setuid到其他用户,就无法再得到超级用户的权力。我们可能这样用这个调 用,某个程序,开始需要root权力才能完成开始的工作,但后续的工作不需要root的权力,所以,我们将程序的执行文件设置set_uid位,并使得执 行文件的属主是root,这样进程开始时,就具有了root的权限,在不再需要root权限的地方,用setuid(getuid)恢复进程的uid、 euid。对于可执行文件设置set_uid位,一定要注意,尤其是对那些属主是root的更要注意。因为LINUX系统中root拥有任何权力。使用不 当,会对系统安全有极大的损害。
3.3 setpgrp和setpgid 系统调用
这两个调用是用来设置进程组ID的,其声明格式如下:
int setpgrp(void);
int setpgid(pid_t pid, pid_t pgid);
在使用这两个调用的程序中加入下面的头文件:
#include
调用setpgrp用来将发出调用的进程的进程组ID设置成与该进程的PID相等。注意,以后由这个进程派生的子进程都拥有该进程组ID(除非修改子进程的进程组ID)。
调用setpgid用来将进程号为参数pid的进程的进程组ID设定为参数pgid。如果参数pid为0,则修改发出调用进程的进程组ID。如果参数 pgid为0,将进程号为pid的进程改为与发出调用的进程同组。如果不是超级用户发出的调用,那么被指定的进程必须与发出调用的进程有相同的 EUID,或者被指定的进程是发出调用进程的子进程。
进程组可用于信号的发送,或者终端输入的仲裁(与终端控制进程有相同的进程组ID且在前台可以读取终端,其他进程在企图读的时候被阻塞并发送信号给该进程)。
该调用成功时,返回值为0;如果请求失败,返回-1,并设置全局变量errno为对应的值。下面是可能遇到的错误代码:
ESRCH:参数pid指定的进程不存在。
EINVAL:参数pgid小于0。
EPERM:指定进程的EUID与发出调用进程的euid不同,且指定进程不是发出调用进程的子进程。
3.4 chdir 系统调用系统调用
chdir是用来将进程的当前工作目录改为由参数指定的目录。该调用的声明格式如下:
int chdir(const char *path);
在使用该调用的程序中加入下面的头文件:
#include
使用该调用时要注意,发出该调用的进程必须对参数path指定的目录有搜索(execute)的权力。调用成功时,返回值为0;错误时,返回-1,并设置相应的错误代码。
3.5 chroot 系统调用
系统调用chroot用来改变发出调用进程的根(“/”)目录。该调用声明的格式如下:
int chroot(const char *path);
在使用该调用的程序中加入下面的头文件:
#include
调用chroot将进程的根目录改到由参数path所指定的地方。以后该进程中以“/”(根)开始的路径,都从指定目录处开始查找。发出调用进程的子进 程都继承这个根目录的位置。该调用只能由超级用户(root)发出。注意,该调用并不改变当前工作目录,所以有可能当前工作目录“.”在根目录“/” 之外。调用成功时,返回值为0;错误时,返回-1,并设置相应的错误代码。
注意: 如果用chroot调用改变根后,不能由调用chroot(“/”)来返回真正的根,因为调用中的参数“/”会被理解成新设置的根。该调用一般可以用在 login程序中,或者现在国内常见的BBS系统等应用程序中,用户登录后执行系统的一个程序,该程序将根改变成用户登录的目录(例如 /home/bbs)。这样使用的好处是,利于调试和安装;也利于安全。
摘要:这一节,我们来看一种比较简单的数据传送的方法,即通过管道传送数据
4.进程间使用管道通信
前几节中我们讲述了有关进程的操作,我们已经学会产生一个新的进程,改变进程的执行图像等操作。然而,子进程与父进程,子进程与子进程之间,还缺少数据交换的方法。这一节,我们就来看一种比较简单的数据传送的方法,即通过管道传送数据。
管道允许在进程之间按先进先出的方式传送数据,管道也能使进程同步执行。管道传统的实现方法是通过文件系统作为存储数据的地方。有两种类型的管道:一种 是无名管道,简称为管道;另一种是有名管道,也称为FIFO。进程使用系统调用open来打开有名管道,使用系统调用pipe来建立无名管道。使用无名管 道通讯的进程,必须是发出pipe调用的进程及其子进程。使用有名管道通讯的进程没有上述限制。在以后的叙述中,所有无名管道都简称为管道。下面来看一下 系统调用pipe。
4.1 pipe 系统调用
系统调用pipe是用来建立管道的。该调用的声明格式如下:
int pipe(int filedes[2]);
在使用该调用的程序中加入下面的头文件:
#include
一个管道拥有两个文件描述符用来通信,它们指向一个管道的索引节点,该调用将这两文件描述符放在参数filedes中返回。现在的许多系统中管道允许数 据双向流动,但一般习惯上,文件描述符filedes[0]用来读数据,filedes[1]用来写数据。如果要求程序的可移植性好,就按照习惯的用法来 编程。调用成功时,返回值为0;错误时,返回-1,并设置错误代码errno:
EMFILE:进程使用了过多的文件描述符。
ENFILE:系统文件表满。
EFAULT:参数filedes无效。
下面介绍管道的操作的情况:
对于写管道:
写入管道的数据按到达次序排列。如果管道满,则对管道的写被阻塞,直到管道的数据被读操作读取。对于写操作,如果一次write调用写的数据量小于管道 容量,则写必须一次完成,即如果管道所剩余的容量不够,write被阻塞直到管道的剩余容量可以一次写完为止。如果write调用写的数据量大于管道容 量,则写操作分多次完成。如果用fcntl设置管道写端口为非阻塞方式,则管道满不会阻塞写,而只是对写返回0。
对于读管道:
读操作按数据到达的顺序读取数据。已经被读取的数据在管道内不再存在,这意味着数据在管道中不能重复利用。如果管道为空,且管道的写端口是打开状态,则 读操作被阻塞直到有数据写入为止。一次read调用,如果管道中的数据量不够read指定的数量,则按实际的数量读取,并对read返回实际数量值。如果 读端口使用fcntl设置了非阻塞方式,则当管道为空时,read调用返回0。
对于管道的关闭:
如果管道的读端口 关闭,那么在该管道上的发出写操作调用的进程将接收到一个SIGPIPE信号。关闭写端口是给读端口一个文件结束符的唯一方法。对于写端口关闭后,在该管 道上的read调用将返回0。下面再来看看,系统调用pipe的例子。在下面的例子中,父进程通过管道向子进程发送了一个字符串。子进程将它显示出来:
#include
#include
#include
#include
#include
int main()
{
int fd[2],cld_pid,status;
char buf[200], len;
if (pipe(fd) == -1)
{
printf("creat pipe error ");
exit(1);
}
if ((cld_pid=fork()) == 0)
{
close(fd[1]);
len = read(fd[0],buf,sizeof(buf));
buf[len]=0;
printf("Child read pipe is -- %s ",buf);
exit(0);
}
else
{
close(fd[0]);
sprintf(buf,"Parent creat this buff for cld %d",cld_pid);
write(fd[1],buf, strlen(buf));
exit(0);
}
}
该程序执行过程的屏幕拷贝:
[root@wapgw /tmp]# ./pipe
[root@wapgw /tmp]# Child read pipe is -- Parent creat this buff for cld 5954
[root@wapgw /tmp]#
4.2 dup 系统调用
系统调用dup是用来复制一个文件描述符,也就是将进程u区的文件描述符表中的一项复制一份,使得这两项同时指向系统文件表的同一表项。该调用的声明格式如下:
int dup(int oldfd);
int dup2(int oldfd, int newfd);
在使用该调用的程序中加入下面的头文件:
#include
系统调用dup复制由参数oldfd指定的文件描述到进程文件描述符表的第一个空表项处。而系统调用dup2复制由参数oldfd指定的文件描述到参数 newfd指定的文件描述符表项处。老的文件描述符和新复制的文件描述符可以互换使用。它们共享锁、文件指针和文件状态。例如,对其中一个文件描述符使用 系统调用lseek修改文件指针的位置,对另一文件描述符来说文件指针也改变了,其实我们了解了内核的工作原理,这一点很容易理解。因为我们知道,文件指 针是放在系统文件表中的。但这两个文件描述符具有不同的close-on-exec标志,因为该标志是存放在文件描述符表中的。
该调用成功时,返回值为新的描述符;错误时,返回-1,并设置相应的错误代码errno:
EBADF:参数oldfd不是一个已经打开的文件描述符;或者参数newfd超出允许的文件描述符的取值范围。
EMFILE:进程打开的文件描述符数量已经到达最大值,但仍然企图打开新的文件描述符。
下面我们来看一个简单的例子。在这个例子中,我们将标准输出(文件描述符为1)关闭,并将一个打开了普通文件“output”的文件描述符复制到标准输 出上,因为刚关闭了文件描述符1,所以,文件描述符表的第一个空表项是1。所以,程序以后的printf等向标准输出写的内容都写到了文件 output中。
#include
#include
#include
#include
#include
int main()
{
int fd;
if ((fd=open("output",O_CREAT|O_RDWR,0644))==-1)
{
printf("cannot open output file ");
exit(1);
}
close(1); /* 关闭标准输出 */
dup(fd); /* 复制fd到文件描述符1上 */
close(fd); /* 即时关闭不用的文件描述符是一个好习惯 */
printf("This line will write to file ");
exit(0);
}
该程序执行过程的屏幕拷贝:
[wap@wapgw /tmp]$ gcc -o dup_test dup_test.c
[wap@wapgw /tmp]$ ./dup_test
[wap@wapgw /tmp]$ more output
This line will write to file
[wap@wapgw /tmp]$