Linux系统调用--进程管理(1)
本文介绍了Linux下的进程概念,并着重讲解了与Linux进程管理相关的4个重要系统调用getpid,fork,exit和_exit,辅助一些例程说明了它们的特点和使用方法。
关于进程的一些必要知识
先看一下进程在大学课本里的标准定义:“进程是可并发执行的程序在一个数据集合上的运行过程。”这个定义非常严谨,而且难懂,如果你没有一下子理解这句话,就不妨看看笔者自己的并不严谨的解释。我们大家都知道,硬盘上的一个可执行文件经常被称作程序,在Linux系统中,当一个程序开始执行后,在开始执行到执行完毕退出这段时间里,它在内存中的部分就被称作一个进程。
当然,这个解释并不完善,但好处是容易理解,在以下的文章中,我们将会对进程作一些更全面的认识。
Linux进程简介
Linux是一个多任务的操作系统,也就是说,在同一个时间内,可以有多个进程同时执行。如果读者对计算机硬件体系有一定了解的话,会知道我们大家常用的单CPU计算机实际上在一个时间片断内只能执行一条指令,那么Linux是如何实现多进程同时执行的呢?原来Linux使用了一种称为“进程调度(process scheduling)”的手段,首先,为每个进程指派一定的运行时间,这个时间通常很短,短到以毫秒为单位,然后依照某种规则,从众多进程中挑选一个投入运行,其他的进程暂时等待,当正在运行的那个进程时间耗尽,或执行完毕退出,或因某种原因暂停,Linux就会重新进行调度,挑选下一个进程投入运行。因为每个进程占用的时间片都很短,在我们使用者的角度来看,就好像多个进程同时运行一样了。
在Linux中,每个进程在创建时都会被分配一个数据结构,称为进程控制块(Process Control Block,简称PCB)。PCB中包含了很多重要的信息,供系统调度和进程本身执行使用,其中最重要的莫过于进程ID(process ID)了,进程ID也被称作进程标识符,是一个非负的整数,在Linux操作系统中唯一地标志一个进程,在我们最常使用的I386架构(即PC使用的架构)上,一个非负的整数的变化范围是0-32767,这也是我们所有可能取到的进程ID。其实从进程ID的名字就可以看出,它就是进程的身份证号码,每个人的身份证号码都不会相同,每个进程的进程ID也不会相同。
一个或多个进程可以合起来构成一个进程组(process group),一个或多个进程组可以合起来构成一个会话(session)。这样我们就有了对进程进行批量操作的能力,比如通过向某个进程组发送信号来实现向该组中的每个进程发送信号。
最后,让我们通过ps命令亲眼看一看自己的系统中目前有多少进程在运行:
$ps -aux(以下是在我的计算机上的运行结果,你的结果很可能与这不同。)
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.1 0.4 1412 520 ? S May15 0:04 init [3]
root 2 0.0 0.0 0 0 ? SW May15 0:00 [keventd]
root 3 0.0 0.0 0 0 ? SW May15 0:00 [kapm-idled]
root 4 0.0 0.0 0 0 ? SWN May15 0:00 [ksoftirqd_CPU0]
root 5 0.0 0.0 0 0 ? SW May15 0:00 [kswapd]
root 6 0.0 0.0 0 0 ? SW May15 0:00 [kreclaimd]
root 7 0.0 0.0 0 0 ? SW May15 0:00 [bdflush]
root 8 0.0 0.0 0 0 ? SW May15 0:00 [kupdated]
root 9 0.0 0.0 0 0 ? SW< May15 0:00 [mdrecoveryd]
root 13 0.0 0.0 0 0 ? SW May15 0:00 [kjournald]
root 132 0.0 0.0 0 0 ? SW May15 0:00 [kjournald]
root 673 0.0 0.4 1472 592 ? S May15 0:00 syslogd -m 0
root 678 0.0 0.8 2084 1116 ? S May15 0:00 klogd -2
rpc 698 0.0 0.4 1552 588 ? S May15 0:00 portmap
rpcuser 726 0.0 0.6 1596 764 ? S May15 0:00 rpc.statd
root 839 0.0 0.4 1396 524 ? S May15 0:00 /usr/sbin/apmd -p
root 908 0.0 0.7 2264 1000 ? S May15 0:00 xinetd -stayalive
root 948 0.0 1.5 5296 1984 ? S May15 0:00 sendmail: accepti
root 967 0.0 0.3 1440 484 ? S May15 0:00 gpm -t ps/2 -m /d
wnn 987 0.0 2.7 4732 3440 ? S May15 0:00 /usr/bin/cserver
root 1005 0.0 0.5 1584 660 ? S May15 0:00 crond
wnn 1025 0.0 1.9 3720 2488 ? S May15 0:00 /usr/bin/tserver
xfs 1079 0.0 2.5 4592 3216 ? S May15 0:00 xfs -droppriv -da
daemon 1115 0.0 0.4 1444 568 ? S May15 0:00 /usr/sbin/atd
root 1130 0.0 0.3 1384 448 tty1 S May15 0:00 /sbin/mingetty tt
root 1131 0.0 0.3 1384 448 tty2 S May15 0:00 /sbin/mingetty tt
root 1132 0.0 0.3 1384 448 tty3 S May15 0:00 /sbin/mingetty tt
root 1133 0.0 0.3 1384 448 tty4 S May15 0:00 /sbin/mingetty tt
root 1134 0.0 0.3 1384 448 tty5 S May15 0:00 /sbin/mingetty tt
root 1135 0.0 0.3 1384 448 tty6 S May15 0:00 /sbin/mingetty tt
root 8769 0.0 0.6 1744 812 ? S 00:08 0:00 in.telnetd: 192.1
root 8770 0.0 0.9 2336 1184 pts/0 S 00:08 0:00 login -- lei
lei 8771 0.1 0.9 2432 1264 pts/0 S 00:08 0:00 -bash
lei 8809 0.0 0.6 2764 808 pts/0 R 00:09 0:00 ps -aux
以上除标题外,每一行都代表一个进程。在各列中,PID一列代表了各进程的进程ID,COMMAND一列代表了进程的名称或在Shell中调用的命令行,对其他列的具体含义,我就不再作解释,有兴趣的读者可以去参考相关书籍。
getpid
在2.4.4版内核中,getpid是第20号系统调用,其在Linux函数库中的原型是:
#include /* 提供类型pid_t的定义 */
#include /* 提供函数的定义 */
pid_t getpid(void);
getpid的作用很简单,就是返回当前进程的进程ID,请大家看以下的例子:
/* getpid_test.c */
#include
main()
{
printf("The current process ID is %d
",getpid());
}
细心的读者可能注意到了,这个程序的定义里并没有包含头文件sys/types.h,这是因为我们在程序中没有用到pid_t类型,pid_t类型即为进程ID的类型。事实上,在i386架构上(就是我们一般PC计算机的架构),pid_t类型是和int类型完全兼容的,我们可以用处理整形数的方法去处理pid_t类型的数据,比如,用"%d"把它打印出来。
编译并运行程序getpid_test.c:
$gcc getpid_test.c -o getpid_test
$./getpid_test
The current process ID is 1980
(你自己的运行结果很可能与这个数字不一样,这是很正常的。)
再运行一遍:
$./getpid_test
The current process ID is 1981
正如我们所见,尽管是同一个应用程序,每一次运行的时候,所分配的进程标识符都不相同。
fork
在2.4.4版内核中,fork是第2号系统调用,其在Linux函数库中的原型是:
#include /* 提供类型pid_t的定义 */
#include /* 提供函数的定义 */
pid_t fork(void);
只看fork的名字,可能难得有几个人可以猜到它是做什么用的。fork系统调用的作用是复制一个进程。当一个进程调用它,完成后就出现两个几乎一模一样的进程,我们也由此得到了一个新进程。据说fork的名字就是来源于这个与叉子的形状颇有几分相似的工作流程。
在Linux中,创造新进程的方法只有一个,就是我们正在介绍的fork。其他一些库函数,如system(),看起来似乎它们也能创建新的进程,如果能看一下它们的源码就会明白,它们实际上也在内部调用了fork。包括我们在命令行下运行应用程序,新的进程也是由shell调用fork制造出来的。 fork有一些很有意思的特征,下面就让我们通过一个小程序来对它有更多的了解。
/* fork_test.c */
#include
#inlcude
main()
{
pid_t pid;
/*此时仅有一个进程*/
pid=fork();
/*此时已经有两个进程在同时运行*/
if(pid<0)
printf("error in fork!");
else if(pid==0)
printf("I am the child process, my process ID is %d
",getpid());
else
printf("I am the parent process, my process ID is %d
",getpid());
}
编译并运行:
$gcc fork_test.c -o fork_test
$./fork_test
I am the parent process, my process ID is 1991
I am the child process, my process ID is 1992
看这个程序的时候,头脑中必须首先了解一个概念:在语句pid=fork()之前,只有一个进程在执行这段代码,但在这条语句之后,就变成两个进程在执行了,这两个进程的代码部分完全相同,将要执行的下一条语句都是if(pid==0)……。
两个进程中,原先就存在的那个被称作“父进程”,新出现的那个被称作“子进程”。父子进程的区别除了进程标志符(process ID)不同外,变量pid的值也不相同,pid存放的是fork的返回值。fork调用的一个奇妙之处就是它仅仅被调用一次,却能够返回两次,它可能有三种不同的返回值:
在父进程中,fork返回新创建子进程的进程ID;
在子进程中,fork返回0;
如果出现错误,fork返回一个负值;
fork出错可能有两种原因:
(1)当前的进程数已经达到了系统规定的上限,这时errno的值被设置为EAGAIN。(2)系统内存不足,这时errno的值被设置为ENOMEM。(关于errno的意义,请参考本系列的第一篇文章。)
fork系统调用出错的可能性很小,而且如果出错,一般都为第一种错误。如果出现第二种错误,说明系统已经没有可分配的内存,正处于崩溃的边缘,这种情况对Linux来说是很罕见的。
说到这里,聪明的读者可能已经完全看懂剩下的代码了,如果pid小于0,说明出现了错误;pid==0,就说明fork返回了0,也就说明当前进程是子进程,就去执行printf("I am the child!"),否则(else),当前进程就是父进程,执行printf("I am the parent!")。完美主义者会觉得这很冗余,因为两个进程里都各有一条它们永远执行不到的语句。不必过于为此耿耿于怀,毕竟很多年以前,UNIX的鼻祖们在当时内存小得无法想象的计算机上就是这样写程序的,以我们如今的“海量”内存,完全可以把这几个字节的顾虑抛到九霄云外。
说到这里,可能有些读者还有疑问:如果fork后子进程和父进程几乎完全一样,而系统中产生新进程唯一的方法就是fork,那岂不是系统中所有的进程都要一模一样吗?那我们要执行新的应用程序时候怎么办呢?从对Linux系统的经验中,我们知道这种问题并不存在。至于采用了什么方法,我们把这个问题留到后面具体讨论。
exit
在2.4.4版内核中,exit是第1号调用,其在Linux函数库中的原型是:
#include
void exit(int status);
不像fork那么难理解,从exit的名字就能看出,这个系统调用是用来终止一个进程的。无论在程序中的什么位置,只要执行到exit系统调用,进程就会停止剩下的所有操作,清除包括PCB在内的各种数据结构,并终止本进程的运行。请看下面的程序:
/* exit_test1.c */
#include
main()
{
printf("this process will exit!
");
exit(0);
printf("never be displayed!
");
}
编译后运行:
$gcc exit_test1.c -o exit_test1
$./exit_test1
this process will exit!
我们可以看到,程序并没有打印后面的“never be displayed! ”,因为在此之前,在执行到exit(0)时,进程就已经终止了。
exit系统调用带有一个整数类型的参数status,我们可以利用这个参数传递进程结束时的状态,比如说,该进程是正常结束的,还是出现某种意外而结束的,一般来说,0表示没有意外的正常结束;其他的数值表示出现了错误,进程非正常结束。我们在实际编程时,可以用wait系统调用接收子进程的返回值,从而针对不同的情况进行不同的处理。关于wait的详细情况,我们将在以后的篇幅中进行介绍。
exit和_exit
作为系统调用而言,_exit和exit是一对孪生兄弟,它们究竟相似到什么程度,我们可以从Linux的源码中找到答案:
#define __NR__exit __NR_exit /* 摘自文件include/asm-i386/unistd.h第334行 */
“__NR_”是在Linux的源码中为每个系统调用加上的前缀,请注意第一个exit前有2条下划线,第二个exit前只有1条下划线。
这时随便一个懂得C语言并且头脑清醒的人都会说,_exit和exit没有任何区别,但我们还要讲一下这两者之间的区别,这种区别主要体现在它们在函数库中的定义。_exit在Linux函数库中的原型是:
#include
void _exit(int status);
和exit比较一下,exit()函数定义在stdlib.h中,而_exit()定义在unistd.h中,从名字上看,stdlib.h似乎比 unistd.h高级一点,那么,它们之间到底有什么区别呢?让我们先来看流程图,通过下图,我们会对这两个系统调用的执行过程产生一个较为直观的认识。
从图中可以看出,_exit()函数的作用最为简单:直接使进程停止运行,清除其使用的内存空间,并销毁其在内核中的各种数据结构;exit()函数则在这些基础上作了一些包装,在执行退出之前加了若干道工序,也是因为这个原因,有些人认为exit已经不能算是纯粹的系统调用。
exit()函数与_exit()函数最大的区别就在于exit()函数在调用exit系统调用之前要检查文件的打开情况,把文件缓冲区中的内容写回文件,就是图中的“清理I/O缓冲”一项。
在Linux的标准函数库中,有一套称作“高级I/O”的函数,我们熟知的printf()、fopen()、fread()、fwrite()都在此列,它们也被称作“缓冲I/O(buffered I/O)”,其特征是对应每一个打开的文件,在内存中都有一片缓冲区,每次读文件时,会多读出若干条记录,这样下次读文件时就可以直接从内存的缓冲区中读取,每次写文件的时候,也仅仅是写入内存中的缓冲区,等满足了一定的条件(达到一定数量,或遇到特定字符,如换行符和文件结束符EOF),再将缓冲区中的内容一次性写入文件,这样就大大增加了文件读写的速度,但也为我们编程带来了一点点麻烦。如果有一些数据,我们认为已经写入了文件,实际上因为没有满足特定的条件,它们还只是保存在缓冲区内,这时我们用_exit()函数直接将进程关闭,缓冲区中的数据就会丢失,反之,如果想保证数据的完整性,就一定要使用exit()函数。
请看以下例程:
/* exit2.c */
#include
main()
{
printf("output begin
");
printf("content in buffer");
exit(0);
}
编译并运行:
$gcc exit2.c -o exit2
$./exit2
output begin
content in buffer
/* _exit1.c */
#include
main()
{
printf("output begin
");
printf("content in buffer");
_exit(0);
}
编译并运行:
$gcc _exit1.c -o _exit1
$./_exit1
output begin
在Linux中,标准输入和标准输出都是作为文件处理的,虽然是一类特殊的文件,但从程序员的角度来看,它们和硬盘上存储数据的普通文件并没有任何区别。与所有其他文件一样,它们在打开后也有自己的缓冲区。
请读者结合前面的叙述,思考一下为什么这两个程序会得出不同的结果。相信如果您理解了我前面所讲的内容,会很容易的得出结论。
在这篇文章中,我们对Linux的进程管理作了初步的了解,并在此基础上学习了getpid、fork、exit和_exit四个系统调用。在下一篇文章中,我们将学习与Linux进程管理相关的其他系统调用,并将作一些更深入的探讨。
(一) 理解Linux下进程的结构
Linux下一个进程在内存里有三部份的数据,就是“数据段”,“堆栈段”和“代码段”,其实学过汇编语言的人一定知道,一般的CPU象I386,都有上述三种段寄存器,以方便操作系统的运行。“代码段”,顾名思义,就是存放了程序代码的数据,假如机器中有数个进程运行相同的一个程序,那么它们就可以使用同一个代码段。
堆栈段存放的就是子程序的返回地址、子程序的参数以及程序的局部变量。而数据段则存放程序的全局变量,常数以及动态数据分配的数据空间(比如用malloc之类的函数取得的空间)。这其中有许多细节问题,这里限于篇幅就不多介绍了。系统如果同时运行数个相同的程序,它们之间就不能使用同一个堆栈段和数据段。
(二) 如何使用fork
在Linux下产生新的进程的系统调用就是fork函数,这个函数名是英文中“分叉”的意思。为什么取这个名字呢?因为一个进程在运行中,如果使用了fork,就产生了另一个进程,于是进程就“分叉”了,所以这个名字取得很形象。下面就看看如何具体使用fork,这段程序演示了使用fork的基本框架:
void main(){
int i;
if ( fork() == 0 ) {
/* 子进程程序 */
for ( i = 1; i <1000; i ++ )
printf("This is child process\n");
}
else {
/* 父进程程序*/
for ( i = 1; i <1000; i ++ )
printf("This is process process\n");
}
}
程序运行后,你就能看到屏幕上交替出现子进程与父进程各打印出的一千条信息了。如果程序还在运行中,你用ps命令就能看到系统中有两个它在运行了。
那么调用这个fork函数时发生了什么呢?一个程序一调用fork函数,系统就为一个新的进程准备了前述三个段,首先,系统让新的进程与旧的进程使用同一个代码段,因为它们的程序还是相同的,对于数据段和堆栈段,系统则复制一份给新的进程,这样,父进程的所有数据都可以留给子进程,但是,子进程一旦开始运行,虽然它继承了父进程的一切数据,但实际上数据却已经分开,相互之间不再有影响了,也就是说,它们之间不再共享任何数据了。而如果两个进程要共享什么数据的话,就要使用另一套函数(shmget,shmat,shmdt等)来操作。现在,已经是两个进程了,对于父进程,fork函数返回了子程序的进程号,而对于子程序,fork函数则返回零,这样,对于程序,只要判断fork函数的返回值,就知道自己是处于父进程还是子进程中。
读者也许会问,如果一个大程序在运行中,它的数据段和堆栈都很大,一次fork就要复制一次,那么fork的系统开销不是很大吗?其实UNIX自有其解决的办法,大家知道,一般CPU都是以“页”为单位分配空间的,象INTEL的CPU,其一页在通常情况下是4K字节大小,而无论是数据段还是堆栈段都是由许多“页” 构成的,fork函数复制这两个段,只是“逻辑”上的,并非“物理”上的,也就是说,实际执行fork时,物理空间上两个进程的数据段和堆栈段都还是共享着的,当有一个进程写了某个数据时,这时两个进程之间的数据才有了区别,系统就将有区别的“页”从物理上也分开。系统在空间上的开销就可以达到最小。
一个小幽默:下面演示一个足以"搞死"Linux的小程序,其源代码非常简单:
void main()
{
for(;;) fork();
}
这个程序什么也不做,就是死循环地fork,其结果是程序不断产生进程,而这些进程又不断产生新的进程,很快,系统的进程就满了,系统就被这么多不断产生的进程"撑死了"。用不着是root,任何人运行上述程序都足以让系统死掉。哈哈,但这不是Linux不安全的理由,因为只要系统管理员足够聪明,他(或她)就可以预先给每个用户设置可运行的最大进程数,这样,只要不是root,任何能运行的进程数也许不足系统总的能运行和进程数的十分之一,这样,系统管理员就能对付上述恶意的程序了。
(三) 如何启动另一程序的执行
下面我们来看看一个进程如何来启动另一个程序的执行。在 Linux中要使用exec类的函数,exec类的函数不止一个,但大致相同,在Linux中,它们分别是:execl,execlp,execle,execv,execve和execvp,下面我只以execlp为例,其它函数究竟与execlp有何区别,请通过manexec命令来了解它们的具体情况。
一个进程一旦调用exec类函数,它本身就“死亡”了,系统把代码段替换成新的程序的代码,废弃原有的数据段和堆栈段,并为新程序分配新的数据段与堆栈段,唯一留下的,就是进程号,也就是说,对系统而言,还是同一个进程,不过已经是另一个程序了。(不过exec类函数中有的还允许继承环境变量之类的信息。)
那么如果我的程序想启动另一程序的执行但自己仍想继续运行的话,怎么办呢?那就是结合fork与exec的使用。下面一段代码显示如何启动运行其它程序:
char command[256];
void main()
{
int rtn; /*子进程的返回数值*/
while(1) {
/* 从终端读取要执行的命令 */
printf( ">" );
fgets( command, 256, stdin );
command[strlen(command)-1] = 0;
if ( fork() == 0 ) {
/* 子进程执行此命令 */
execlp( command, command );
/* 如果exec函数返回,表明没有正常执行命令,打印错误信息*/
perror( command );
exit( errorno );
}
else {
/* 父进程, 等待子进程结束,并打印子进程的返回值 */
wait ( &rtn );
printf( " child process return %d\n",. rtn );
}
}
}
此程序从终端读入命令并执行之,执行完成后,父进程继续等待从终端读入命令。熟悉DOS和WINDOWS系统调用的朋友一定知道DOS/WINDOWS 也有exec类函数,其使用方法是类似的,但DOS/WINDOWS还有spawn类函数,因为DOS是单任务的系统,它只能将“父进程”驻留在机器内再执行“子进程”,这就是spawn类的函数。WIN32已经是多任务的系统了,但还保留了spawn类函数,WIN32中实现spawn函数的方法同前述 UNIX中的方法差不多,开设子进程后父进程等待子进程结束后才继续运行。UNIX在其一开始就是多任务的系统,所以从核心角度上讲不需要spawn类函数。
另外,有一个更简单的执行其它程序的函数system,它是一个较高层的函数,实际上相当于在SHELL环境下执行一条命令,而exec类函数则是低层的系统调用。
(四) Linux的进程与Win32的进程/线程有何区别
熟悉WIN32编程的人一定知道,WIN32的进程管理方式与UNIX上有着很大区别,在UNIX里,只有进程的概念,但在WIN32里却还有一个“线程”的概念,那么UNIX和WIN32在这里究竟有着什么区别呢?
UNIX里的fork是七十年代UNIX早期的开发者经过长期在理论和实践上的艰苦探索后取得的成果,一方面,它使操作系统在进程管理上付出了最小的代价,另一方面,又为程序员提供了一个简洁明了的多进程方法。
WIN32里的进程/线程是继承自OS/2的。在WIN32里,“进程”是指一个程序,而“线程”是一个“进程”里的一个执行“线索”。从核心上讲,WIN32的多进程与UNIX并无多大的区别,在WIN32里的线程才相当于UNIX的进程,是一个实际正在执行的代码。但是,WIN32里同一个进程里各个线程之间是共享数据段的。这才是与UNIX的进程最大的不同。
下面这段程序显示了WIN32下一个进程如何启动一个线程:(请注意,这是个终端方式程序,没有图形界面)
int g;
DWORD WINAPI ChildProcess( LPVOID lpParameter ){
int i;
for ( i = 1; i <1000; i ++) {
g ++;
printf( "This is Child Thread: %d\n", g );
}
ExitThread( 0 );
};
void main()
{
int threadID;
int i;
g = 0;
CreateThread( NULL, 0, ChildProcess, NULL, 0, &threadID );
for ( i = 1; i <1000; i ++) {
g ++;
printf( "This is Parent Thread: %d\n", g );
}
}
在WIN32下,使用CreateThread函数创建线程,与UNIX不同,线程不是从创建处开始运行的,而是由CreateThread指定一个函数,线程就从那个函数处开始运行。此程序同前面的UNIX程序一样,由两个线程各打印1000条信息。threadID是子线程的线程号,另外,全局变量 g是子线程与父线程共享的,这就是与UNIX最大的不同之处。大家可以看出,WIN32的进程/线程要比UNIX复杂,在UNIX里要实现类似WIN32 的线程并不难,只要fork以后,让子进程调用ThreadProc函数,并且为全局变量开设共享数据区就行了,但在WIN32下就无法实现类似fork 的功能了。所以现在WIN32下的C语言编译器所提供的库函数虽然已经能兼容大多数UNIX的库函数,但却仍无法实现fork。
对于多任务系统,共享数据区是必要的,但也是一个容易引起混乱的问题,在WIN32下,一个程序员很容易忘记线程之间的数据是共享的这一情况,一个线程修改过一个变量后,另一个线程却又修改了它,结果引起程序出问题。但在UNIX下,由于变量本来并不共享,而由程序员来显式地指定要共享的数据,使程序变得更清晰与安全。
Linux还有自己的一个函数叫clone,这个函数是其它UNIX所没有的,而且通常的Linux也并不提供此函数(要使用此函数需自己重新编译内核,并设置CLONE_ACTUALLY_WORKS_OK选项),clone函数提供了更多的创建新进程的功能,包括象完全共享数据段这样的功能。
至于WIN32的“进程”概念,其含义则是“应用程序”,也就是相当于UNIX下的exec了。
v
多进程编程
写在前面的话
本文主要根据本人在UNIX系统上的编程实践经验总结而成, 既做为自己在
一个时期内编程实践的部分总结, 又可成为文章发表. 对UNIX程序员初学者来
说是一个小小的经验, 仅供参考; 对UNIX老手来说则不值一哂, 请各位多多指
教.
一.多进程程序的特点
由于UNIX系统是分时多用户系统, CPU按时间片分配给各个用户使用, 而在
实质上应该说CPU按时间片分配给各个进程使用, 每个进程都有自己的运行环境
以使得在CPU做进程切换时不会"忘记"该进程已计算了一半的"半成品". 以DOS
的概念来说, 进程的切换都是一次"DOS中断"处理过程, 包括三个层次:
(1)用户数据的保存: 包括正文段(TEXT), 数据段(DATA,BSS), 栈段
(STACK), 共享内存段(SHARED MEMORY)的保存.
(2)寄存器数据的保存: 包括PC(program counter,指向下一条要执行的指
令的地址), PSW(processor status word,处理机状态字), SP(stack
pointer,栈指针), PCBP(pointer of process control block,进程控
制块指针), FP(frame pointer,指向栈中一个函数的local变量的首地
址), AP(augument pointer,指向栈中函数调用的实参位置), ISP(
interrupt stack pointer,中断栈指针), 以及其他的通用寄存器等.
(3)系统层次的保存: 包括proc,u,虚拟存储空间管理表格,中断处理栈.
以便于该进程再一次得到CPU时间片时能正常运行下去.
既然系统已经处理好所有这些中断处理的过程, 我们做程序还有什么要担
心的呢? 我们尽可以使用系统提供的多进程的特点, 让几个程序精诚合作, 简
单而又高效地把结果给它搞出来.
另外,UNIX系统本身也是用C语言写的多进程程序,多进程编程是UNIX的特
点,当我们熟悉了多进程编程后,将会对UNIX系统机制有一个较深的认识.
首先我介绍一下多进程程序的一些突出的特点:
1.并行化
一件复杂的事件是可以分解成若干个简单事件来解决的, 这在程序员
的大脑中早就形成了这种概念, 首先将问题分解成一个个小问题, 将小问
题再细分, 最后在一个合适的规模上做成一个函数. 在软件工程中也是这
么说的. 如果我们以图的方式来思考, 一些小问题的计算是可以互不干扰
的, 可以同时处理, 而在关键点则需要统一在一个地方来处理, 这样程序
的运行就是并行的, 至少从人的时间观念上来说是这样的. 而每个小问题
的计算又是较简单的.
2.简单有序
这样的程序对程序员来说不亚于管理一班人, 程序员为每个进程设计
好相应的功能, 并通过一定的通讯机制将它们有机地结合在一起, 对每个
进程的设计是简单的, 只在总控部分小心应付(其实也是蛮简单的), 就可
完成整个程序的施工.
3.互不干扰
这个特点是操作系统的特点, 各个进程是独立的, 不会串位.
4.事务化
比如在一个数据电话查询系统中, 将程序设计成一个进程只处理一次
查询即可, 即完成一个事务. 当电话查询开始时, 产生这样一个进程对付
这次查询; 另一个电话进来时, 主控程序又产生一个这样的进程对付, 每
个进程完成查询任务后消失. 这样的编程多简单, 只要做一次查询的程序
就可以了.
二.常用的多进程编程的系统调用
1.fork()
功能:创建一个新的进程.
语法:#include <unistd.h>
#include <sys/types.h>
pid_t fork();
说明:本系统调用产生一个新的进程, 叫子进程, 是调用进程的一个复
制品. 调用进程叫父进程, 子进程继承了父进程的几乎所有的属
性:
. 实际UID,GID和有效UID,GID.
. 环境变量.
. 附加GID.
. 调用exec()时的关闭标志.
. UID设置模式比特位.
. GID设置模式比特位.
. 进程组号.
. 会话ID.
. 控制终端.
. 当前工作目录.
. 根目录.
. 文件创建掩码UMASK.
. 文件长度限制ULIMIT.
. 预定值, 如优先级和任何其他的进程预定参数, 根据种类不同
决定是否可以继承.
. 还有一些其它属性.
但子进程也有与父进程不同的属性:
. 进程号, 子进程号不同与任何一个活动的进程组号.
. 父进程号.
. 子进程继承父进程的文件描述符或流时, 具有自己的一个拷贝
并且与父进程和其它子进程共享该资源.
. 子进程的用户时间和系统时间被初始化为0.
. 子进程的超时时钟设置为0.
. 子进程的信号处理函数指针组置为空.
. 子进程不继承父进程的记录锁.
返回值: 调用成功则对子进程返回0, 对父进程返回子进程号, 这也是
最方便的区分父子进程的方法. 若调用失败则返回-1给父进程,
子进程不生成.
例子:pid_t pid;
if ((pid=fork())>0) {
/*父进程处理过程*/
}
else if (pid==0) {
/*子进程处理过程*/
exit(0); /*注意子进程必须用exit()退出运行*/
}
else {
printf("fork error\n");
exit(0);
}
2.system()
功能:产生一个新的进程, 子进程执行指定的命令.
语法:#include <stdio.h>
#include <stdlib.h>
int system(string)
char *string;
说明:本调用将参数string传递给一个命令解释器(一般为sh)执行, 即
string被解释为一条命令, 由sh执行该命令.若参数string为一
个空指针则为检查命令解释器是否存在.
该命令可以同命令行命令相同形式, 但由于命令做为一个参数放
在系统调用中, 应注意编译时对特殊意义字符的处理. 命令的查
找是按PATH环境变量的定义的. 命令所生成的后果一般不会对父
进程造成影响.
返回值:当参数为空指针时, 只有当命令解释器有效时返回值为非零.
若参数不为空指针, 返回值为该命令的返回状态(同waitpid())
的返回值. 命令无效或语法错误则返回非零值,所执行的命令被
终止. 其他情况则返回-1.
例子:char command[81];
int i;
for (i=1;i<8;i++) {
sprintf(command,"ps -t tty%02i",i);
system(command);
}
3.exec()
功能:执行一个文件
语法:#include <unistd.h>
int execl(path,arg0,...,argn,(char*)0)
char *path,*arg0,...,*argn;
int execv(path,argv)
char *path,*argv[];
int execle(path,arg0,...,argn,(char*)0,envp)
char *path,*arg0,...,*argn,*envp[];
int execve(path,argv,envp)
char *path,*argv[],*envp[];
int execvp(file,argv)
char *file,*argv[];
说明:这是一个系统调用族, 用于将一个新的程序调入本进程所占的内
存, 并覆盖之, 产生新的内存进程映象. 新的程序可以是可执行
文件或SHELL批命令.
当C程序被执行时,是如下调用的:
main(int argc,char *argv[],char *envp[]);
argc是参数个数,是各个参数字符串指针数组,envp是新进程的环
境变量字符串的指针数组.argc至少为1,argv[0]为程序文件名,
所以,在上面的exec系统调用族中,path为新进程文件的路径名,
file为新进程文件名,若file不是全路径名,系统调用会按PATH环
境变量自动找对应的可执行文件运行.若新进程文件不是一个可
执行的目标文件(如批处理文件),则execlp()和execvp()会将该
文件内容作为一个命令解释器的标准输入形成system().
arg0,...等指针指向'\0'结束的字符串,组成新进程的有效参数,
且该参数列表以一个空指针结束.反过来,arg0至少必须存在并指
向新进程文件名或路径名.
同样,argv是字符串指针数组,argv[0]指向新进程文件名或路径
名,并以一空指针结束.
envp是一个字符串指针数组,以空指针结束,这些字符串组成新进
程的环境.
在调用这些系统调用前打开的文件指针对新进程来说也是打开的,
除非它已定义了close-on-exec标志.打开的文件指针在新进程中
保持不变,所有相关的文件锁也被保留.
调用进程设置并正被捕俘的信号在新进程中被恢复为缺省设置,
其它的则保持不变.
新进程启动时按文件的SUID和SGID设置定义文件的UID和GID为有
效UID和GID.
新进程还继承了如下属性:
. 附加GID.
. 进程号.
. 父进程号.
. 进程组号.
. 会话号.
. 控制终端.
. alarm时钟信号剩下的时间.
. 当前工作目录.
. 根目录.
. 文件创建掩码.
. 资源限制.
. 用户时间,系统时间,子进程用户时间,子进程系统时间.
. 记录锁.
. 进程信号掩码.
. 信号屏蔽.
. 优先级.
. 预定值.
调用成功后,系统调用修改新进程文件的最新访问时间.
返回值:该系统调用一般不会有成功返回值, 因为原来的进程已荡然无
存.
例子:printf("now this process will be ps command\n");
execl("/bin/ps","ps","-ef",NULL);
4.popen()
功能:初始化从/到一个进程的管道.
语法:#include <stdio.h>
FILE *popen(command,type)
char *command,type;
说明:本系统调用在调用进程和被执行命令间创建一个管道.
参数command做为被执行的命令行.type做为I/O模式,"r"为从被
执行命令读,"w"为向被执行命令写.返回一个标准流指针,做为管
道描述符,向被执行命令读或写数据(做为被执行命令的STDIN或
STDOUT)该系统调用可以用来在程序中调用系统命令,并取得命令
的输出信息或者向命令输入信息.
返回值:不成功则返回NULL,成功则返回管道的文件指针.
5.pclose()
功能:关闭到一个进程的管道.
语法:#include <stdio.h>
int pclose(strm)
FILE *strm;
说明:本系统调用用于关闭由popen()打开的管道,并会等待由popen()
激活的命令执行结束后,关闭管道后读取命令返回码.
返回值:若关闭的文件描述符不是由popen()打开的,则返回-1.
例子:printf("now this process will call popen system call\n");
FILE * fd;
if ((fd=popen("ps -ef","r"))==NULL) {
printf("call popen failed\n");
return;
}
else {
char str[80];
while (fgets(str,80,fd)!=NULL)
printf("%s\n",str);
}
pclose(fd);
6.wait()
功能:等待一个子进程返回并修改状态
语法:#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(stat_loc)
int *stat_loc;
说明:允许调用进程取得子进程的状态信息.调用进程将会挂起直到其
一个子进程终止.
返回值:等待到一个子进程返回时,返回值为该子进程号,否则返回值为
-1.同时stat_loc返回子进程的返回值.
例子:/*父进程*/
if (fork()>0) {
wait((int *)0);
/*父进程等待子进程的返回*/
}
else {
/*子进程处理过程*/
exit(0);
}
7.waitpid()
功能:等待指定进程号的子进程的返回并修改状态
语法:#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid,stat_loc,options)
pid_t pid;
int *stat_loc,options;
说明:当pid等于-1,options等于0时,该系统调用等同于wait().否则该
系统调用的行为由参数pid和options决定.
pid指定了一组父进程要求知道其状态的子进程:
-1:要求知道任何一个子进程的返回状态.
>0:要求知道进程号为pid值的子进程的状态.
<-1:要求知道进程组号为pid的绝对值的子进程的状态.
options参数为以比特方式表示的标志以或运算组成的位图,每个
标志以字节中某个比特置1表示:
WUNTRACED:报告任何未知而又已停止运行的指定进程号的子进
程的状态.该子进程的状态自停止运行时起就没有被报告
过.
WCONTINUED:报告任何继续运行的指定进程号的子进程的状态,
该子进程的状态自继续运行起就没有被报告过.
WHOHANG:若调用本系统调用时,指定进程号的子进程的状态目
前并不是立即有效的(即可被立即读取的),调用进程并被
暂停执行.
WNOWAIT:保持将其状态设置在stat_loc的进程在可等待状态.
该进程将等待直到下次被要求其返回状态值.
返回值:等待到一个子进程返回时,返回值为该子进程号,否则返回值为
-1.同时stat_loc返回子进程的返回值.
例子:pid_t pid;
int stat_loc;
/*父进程*/
if ((pid=fork())>0) {
waitpid(pid,&stat_loc,0);
/*父进程等待进程号为pid的子进程的返回*/
}
else {
/*子进程的处理过程*/
exit(1);
}
/*父进程*/
printf("stat_loc is [%d]\n",stat_loc);
/*字符串"stat_loc is [1]"将被打印出来*/
8.setpgrp()
功能:设置进程组号和会话号.
语法:#include <sys/types.h>
pid_t setpgrp()
说明:若调用进程不是会话首进程.将进程组号和会话号都设置为与它
的进程号相等.并释放调用进程的控制终端.
返回值:调用成功后,返回新的进程组号.
例子:/*父进程处理*/
if (fork()>0) {
/*父进程处理*/
}
else {
setpgrp();
/*子进程的进程组号已修改成与它的进程号相同*/
exit(0);
}
9.exit()
功能:终止进程.
语法:#include <stdlib.h>
void exit(status)
int status;
说明:调用进程被该系统调用终止.引起附加的处理在进程被终止前全
部结束.
返回值:无
10.signal()
功能:信号管理功能
语法:#include <signal.h>
void (*signal(sig,disp))(int)
int sig;
void (*disp)(int);
void (*sigset(sig,disp))(int)
int sig;
void (*disp)(int);
int sighold(sig)
int sig;
int sigrelse(sig)
int sig;
int sigignore(sig)
int sig;
int sigpause(sig)
int sig;
说明:这些系统调用提供了应用程序对指定信号的简单的信号处理.
signal()和sigset()用于修改信号定位.参数sig指定信号(除了
SIGKILL和SIGSTOP,这两种信号由系统处理,用户程序不能捕捉到).
disp指定新的信号定位,即新的信号处理函数指针.可以为
SIG_IGN,SIG_DFL或信号句柄地址.
若使用signal(),disp是信号句柄地址,sig不能为SIGILL,SIGTRAP
或SIGPWR,收到该信号时,系统首先将重置sig的信号句柄为SIG_DFL,
然后执行信号句柄.
若使用sigset(),disp是信号句柄地址,该信号时,系统首先将该
信号加入调用进程的信号掩码中,然后执行信号句柄.当信号句柄
运行结束
后,系统将恢复调用进程的信号掩码为信号收到前的状态.另外,
使用sigset()时,disp为SIG_HOLD,则该信号将会加入调用进程的
信号掩码中而信号的定位不变.
sighold()将信号加入调用进程的信号掩码中.
sigrelse()将信号从调用进程的信号掩码中删除.
sigignore()将信号的定位设置为SIG_IGN.
sigpause()将信号从调用进程的信号掩码中删除,同时挂起调用
进程直到收到信号.
若信号SIGCHLD的信号定位为SIG_IGN,则调用进程的子进程在终
止时不会变成僵死进程.调用进程也不用等待子进程返回并做相
应处理.
返回值:调用成功则signal()返回最近调用signal()设置的disp的值.
否则返回SIG_ERR.
例子一:设置用户自己的信号中断处理函数,以SIGINT信号为例:
int flag=0;
void myself()
{
flag=1;
printf("get signal SIGINT\n");
/*若要重新设置SIGINT信号中断处理函数为本函数则执行以
*下步骤*/
void (*a)();
a=myself;
signal(SIGINT,a);
flag=2;
}
main()
{
while (1) {
sleep(2000); /*等待中断信号*/
if (flag==1) {
printf("skip system call sleep\n");
exit(0);
}
if (flag==2) {
printf("skip system call sleep\n");
printf("waiting for next signal\n");
}
}
}