Linux进程控制

1 进程的建立与运行

1.1 进程的概念

        Linux中进程是正在执行的程序,每个进程包括程序代码和数据,其中数据包含程序变量数据、外部数据和程序堆栈等。因为一个进程对应于一个程序的执行,所以不要把进程和程序混淆。进程是动态的概念,而程序是静态的概念。实际上,多个进程可以并发执行同一个程序,例如,几个用户可以同时运行一个数据管理程序,每个用户对此程序的执行均为一个单独的进程。在Linux中,一个进程还可以启动另一个进程,进程环境就相当于提供了一个类似文件系统目录般的层次结构。进程树的顶端是一个控制进程,它是一个名为init的程序的执行,该进程是所有用户进程的祖先。

可以在shell中打印当前进程:

$ ps x

        Linux提供的进程控制方面的系统调用,最重要的有如下几个:

a) fork(),通过复制调用进程来创建新的进程,它是最基本的进程建立操作。

b) exec系列函数,其中每个系统调用均可完成相同的功能,即通过用一个新的程序覆盖原内存空间来实现进程的转变。它们的区别仅在于参数构造不同。

c) wait(),提供了初级的进程同步措施,能使一个进程等待直到另一个进程结束为止。

d) exit(),终止一个进程的运行。

1.2 进程的建立--复制进程映像

        调用fork()函数可以建立进程,它是把Linux变换为多任务系统的基础。声明如下:

#include <sys/types.h>

#include <unistd.h>

pid_t fork(void);

        注:有时候调用fork函数,在编译的时候不需要头文件sys/types.h也能编译成功无任何警报信息,但是推荐使用这个头文件,因为pid_t这个类型一般都是在sys/types.h中声明的,添加此头文件有助于标准化代码。

        在父进程中调用fork返回的是新的子进程的PID。新进程将继续执行,就像原进程一样,运行的与其创建者一样的程序,其中的变量具有与创建进程中的变量相同的值,不同之处在于,子进程中的fork调用返回的是0。父子进程可以通过返回值来判断父进程和子进程。如果fork调用失败,则返回-1。失败通常是因为父进程所拥有的子进程数目超过了规定的限制(CHILD_MAX),此时errno将被设置为EAGAIN。如果是因为进程表里没有足够的空间用于创建新的表单或虚拟内存不足,errno变量将被设为ENOMEM。

#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main()
{
	pid_t pid;
	char *msg;
	int n;
	
	printf("Just one process\n");
	sleep(2);
	printf("Calling fork...\n");
	pid = fork();
	switch (pid)
	{
		case -1:
			perror("fork failed");
			exit(EXIT_FAILURE);
		case 0:
			msg = "This is the child";
			n = 5;
			break;
		default:
			msg = "This is the parent";
			n = 3;
			break;
	}

	for (; n > 0; n--)
	{
		puts(msg);
		sleep(1);
	}

	exit(EXIT_SUCCESS);
}

        程序在调用fork时分为两个独立的进程,程序通过fork调用返回的非零值确定父进程,并根据该值来设置消息的输出次数,两次消息的输出之间间隔1秒。

        编译

$ gcc -Wall -o fork_test fork_test.c

        运行

$ ./fork_testJust one process
Calling fork...
This is the parent
This is the child
This is the parent
This is the child
This is the parent
This is the child
This is the child
$ This is the child

由运行结果可以看出,子进程被创建并且输出消息5次,父进程(原进程)只输出消息3次,并且父进程在子进程打印完全部消息之前就结束了(最后一行内容混杂着一个shell提示符)。

如果把fork调用的过程隔离起来单独看的话,毫无意义。但是,当它与其它的Linux功能结合起来时就显现出其价值。例如,利用进程间通信机构(如信号和管道等),使父进程与子进程协作完成彼此有关的不同任务。

        在程序的内部启动另一个程序,也可以达到创建新进程的目的。可以调用库函数system来实现。

#include <stdlib.h>

int system(const char *string);

        system函数的作用是,运行以字符串参数的形式传递给它的命令并等待该命令的完成。命令的执行情况就如同在shell中执行如下的命令:

$ sh -c string

如果无法启动shell来运行这个命令,system函数将返回错误代码127;如果是其它错误则返回-1。否则system函数将返回该命令的退出码。例如,查看当前的进程,我们可以调用函数system("ps ax");来完成这个命令。

        一般来说,使用system函数并不是启动其它进程的理想手段,因为它必须用一个shell来启动需要的程序。由于在启动程序之前需要先启动一个shell,而且对shell的安装情况及使用的环境的依赖很大,所以使用system函数的效率不高。我们在程序中应该优先使用exec系列函数来实现程序的调用。

1.3 进程的运行--替换进程的映像

1.3.1 系统调用exec系列

        fork建立进程会使Linux的性能受到很大影响。因为fork智能创建相同程序的副本。而exec系列的函数可以用于新程序的运行。exec系列中的系统调用都完成相同的功能,它们把一个新程序装入调用进程的内存空间来改变调用进程的执行代码,从而形成新进程。如果exec调用成功,调用进程将被覆盖,然后从新程序的入口开始执行。这样就产生了一个新的进程,但是它的进程标识符与调用进程相同。也就是说,exec没有建立一个与调用进程并发的新进程,而是新进程直接取代了原来的进程。所以,exec调用成功后,一般是没有任何数据返回的,除非发生了错误,出现错误时,exec函数将返回-1,并且会设置错误变量errno。

#include <unistd.h>

char **environ;  // 全局变量,可用来把一个值传递到新的程序环境中

int execl(const char *path, const char *arg0, ..., (char *)0);

int execlp(const char *file, const char *arg0, ..., (char *)0);

int execle(const char *path, const char * arg0, ..., (char *)0, char *const envp[]);

int execv(const char *path, char *const argv[]);

int execvp(const char *file, char *const argv[]);

int execve(const char *path, char *const argv[], char *const envp[]);

        这里以execl为例,所有调用的参数均为字符型指针,第一个参数path给出了被执行的程序所在的文件名,它必须是一个有效的路径名,文件本身也必须含有一个真正的可执行程序。但是不能用execl来运行一个shell命令组成的文件,系统只要检查文件的开头两个字节就可以知道该文件是否为程序文件(程序文件的开头两个字节是系统规定的专用值)。第二个以及省略号表示的其它参数一起组成了该程序执行时的参数表。按照Linux的惯例,参数表的第一项是不带路径的程序文件名。被调用的程序可以访问这个参数表,它们相当于shell下的命令行参数。实际上,shell本身对命令的调用也是用exec调用来实现的。由于参数的个数是任意的,所以必须用一个null指针来标记参数表的结尾。
        这些函数可以分为两大类。execl、execlp和execle的参数个数是可变的,参数以一个空指针结束。execv和execvp的第二个参数是一个字符串数组。不管是哪种情况,新程序在启动时会把在argv数组中给定的参数传递给main函数。以字母p结尾的函数通过搜索PATH环境变量来查找新程序的可执行文件的路径。如果可执行文件不在PATH定义的路径中,我们就需要把包括目录在内的使用绝对路径的文件名作为参数传递给函数。函数execle和execve可以通过参数envp传递字符串数组作为新程序的环境变量。

#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>

int main()
{
	char *const ps_argv[] = {"ps", "ax", 0};

	char *const ps_envp[] = {"PATH=/bin:/usr/bin", "TERM=console", 0};

	printf("Running ps with exec..\n");
#if(EXECL)
	{
		execl("/bin/ps", "ps", "ax", 0);
	}
#elif(EXECLP)
	{
		execlp("ps", "ps", "ax", 0);
	}
#elif(EXECLE)
	{
		execle("/bin/ps", "ps", "ax", 0, ps_envp);
	}
#elif(EXECV)
	{
		execv("/bin/ps", ps_argv);
	}
#elif(EXECVP)
	{
		execvp("ps", ps_argv);
	}
#elif(EXECVE)
	{
		execve("/bin/ps", ps_argv, ps_envp);
	}
#endif
	printf("Done.\n");
	exit(0);
}
编译

$ gcc -Wall -DEXECL exec_test.c -o exec_test

运行的时候,执行的是#if(EXECL)后的语句。同理,编译的时候将-D后面的宏变量换成程序中if条件中的宏变量可以执行分别对应的语句(注,可能有警告提示,这里可以忽略)。运行结果基本相同。值得注意的是,不管编译的时候加入哪个宏变量,最后的printf("Done.\n");及之后的语句都没有执行,这是因为如果exec调用成功,那么肯定会用新的程序代替调用程序,相当于调用程序被抹杀了,就不用说继续执行后续代码的事了。

1.3.2 exec和fork的联用

        先用fork函数建立子进程,然后在子进程中使用exec,这样就实现了父进程运行一个与其不同的子进程,并且父进程不会被覆盖。

        我们接下来在父进程中创建一个子进程,子进程调用一个新的程序,将所有的参数打印出来,为此,我们先写一个打印参数的小程序。

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char **argv)
{
	while (--argc > 0)
	{
		printf("%s ", *(++argv));
	}
	printf("\n");
	exit(EXIT_SUCCESS);
}
然后,我们将在exec_fork.c中调用这个程序(姑且命名为printargs)。

#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>

int main()
{
	pid_t pid;
	char *argvs[] = {"printargs", "Nice", "to", "meet", "you", NULL};

	switch (pid = fork())
	{
		case -1:
			perror("fork failed");
			exit(EXIT_FAILURE);
		case 0:
			execv("printargs", argvs);
			perror("execv failed");
			exit(EXIT_FAILURE);
		default:
			wait(NULL);
			printf("print args completed\n");
			exit(EXIT_SUCCESS);
	}
}
编译两个源文件,并执行exec_fork。

$ ./exec_fork
Nice to meet you
print args completed
$

        由执行结果可以看到,不仅子进程调用printargs程序打印出了所有的参数,而且父进程也在子进程执行完成后继续执行了后续代码。这里的wait函数使父进程在子进程结束之前一直处于睡眠状态。

        通过上述内容,我们大致了解了命令解释程序shell的工作概况。当shell从命令行接受到以正常方式(即前台运行)执行一个命令或程序的要求时,shell就按一定的顺序调用fork函数,exec函数集合、wait函数等,以实现命令或程序的执行。当要求在后台执行一个命令或程序时,shell就省略对wait函数的调用,使得shell和命令进程并发运行。

1.4 数据和文件描述符的继承

1.4.1 fork函数、文件、数据

        用系统fork()建立的子进程几乎与其父进程完全一样。子进程中的所有变量均保持它们在父进程中的值(fork()的返回值除外)。因为子进程可用的数据是父进程可用数据的拷贝,并且其占用不同的内存地址空间,所以必须要确保以后一个进程中变量数据的变化,不能影响到其它进程中的变量。另外,在父进程中已打开的文件,在子进程中也被打开,子进程支持这些文件的文件描述符。但是,通过fork()调用后,被打开的文件与父进程和子进程存在着密切的关系,这是因为子进程与父进程公用这些文件的文件指针,那么就可能发生以下情况:由于文件指针由系统保存,所以程序中没有保存它的值,从而当子进程移动文件指针时,也等于移动了父进程的文件指针,这可能导致意想不到的结果。例如,在子进程中读取父进程中打开的文件的内容,然后查看当前文件指针的位置。

#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdlib.h>

void printpos(char *string, int fildes);

int main()
{
	pid_t pid;
	int fd;
	char buf[10];

	if ((fd = open("exec_file.c", O_RDONLY)) < 0)
	{
		perror("open failed");
		exit(EXIT_FAILURE);
	}
	read(fd, buf, 10);
	printpos("Before fork", fd);
	if ((pid = fork()) < 0)
	{
		perror("fork failed");
		exit(EXIT_FAILURE);
	}
	else if (!pid)
	{
		printpos("Child before read", fd);
		read(fd, buf, 10);
		printpos("Child after read", fd);
	}
	else
	{
		wait(NULL);
		printpos("Parent after wait", fd);
	}

	exit(EXIT_SUCCESS);
}

void printpos(char *string, int fildes)
{
	long pos;
	if ((pos = lseek(fildes, 0L, 1)) < 0L)
	{
		perror("lseek failed");
		exit(EXIT_FAILURE);
	}
	printf("%s: %ld \n", string, pos);
}
编译并运行,结果如下:

$ ./exec_fileBefore fork: 10
Child before read: 10
Child after read: 20
Parent after wait: 20
$

        结果显示,在子进程的文件指针位置发生偏移后,子进程执行完毕,父进程中此文件指针的位置依然是偏移的,且偏移量跟子进程操作后的偏移量相同。

1.4.2 exec()和打开文件

        当一个程序调用exec执行新程序时,在程序中已被打开的文件,其在新程序中仍保持打开,也就是说已打开文件描述符能通过exec传送给新程序,并且这些文件的指针爷不会被exec调用改变。这里,介绍一个与文件有关的执行关闭位(close-on-exec),该位被设置的话,则调用exec时会关闭相应的文件。该位的默认值为非设置。fcntl能用于对这一标志位的操作。比如:

#include <fcntl.h>

        ...

        ...

        ...

int fd;

fd = open("file", O_RDONLY);

        ...

        ...

fcntl(fd, F_SETFD, 1);

如果已经设置了执行关闭位可以用下面的语句来撤销“执行关闭位”的设置,并取得其返回值:

res = fcntl(fd, F_SETFD, 0);

如果文件描述符所对应的“执行关闭位”已经被设置,则res为1,否则res为0。


2 进程的控制操作

2.1 进程的终止

        系统调用exit()实现进程的终止。声明如下:

#include <stdlib.h>

void exit(int status);

       exit()只有一个参数status,称为进程的退出状态,父进程可以使用它的低8位,一般来说,0表示没有意外的正常结束。exit()调用成功返回0,否则返回非0值。

       exit()会停止剩下的所有操作,并且清除包括PCB在内的各种数据结构,并终止本进程的运行。如果父进程因执行了wait()调用而处于睡眠状态,那么子进程执行exit()会重新启动父进程运行。

       同exit函数一样,_exit函数也是用于正常终止一个程序,原型如下:

#include <unistd.h>

void _exit(int status);

       exit与_exit几乎相同,还是存在一定的区别,这种区别主要体现在它们在函数库中的定义。另外,_exit立即进入内核,exit则先执行一些清除工作(包括调用执行各终止处理程序,关闭所有标准I/O流等),然后进入内核。使用不同头文件的原因是,exit是由ANSI C说明的,而_exit则是由POSIX.1说明的。

2.2 进程的同步

        一个进程在调用exit之后,该进程并非马上消失,而是留下一个称为僵尸进程(Zombie)或死进程(defunct)的数据结构。这时候的处理方法之一就是使用进程等待的系统调用wait和waitpid。

        注:在Linux进程的5种状态中,僵尸进程是非常特殊的一种,它已经放弃了几乎所有内存空间,没有任何可执行代码,也不能被调度,仅仅在进程列表中保留一个位置,记载该进程的退出状态等信息供其他进程收集,除此之外,僵尸进程不再占有任何内存空间。

        wait和waitpid的函数原型是:

#include <sys/types.h>

#include <sys/wait.h>

pid_t wait(int *status);

pid_t waitpid(pid_t pid, int *status, int options);

        两个函数的返回:若成功则返回进程ID,若失败则返回-1。

        进程一旦调用了wait,就立即阻塞自己,由wait自动分析是否当前进程的某个子进程已经退出,如果让它找到了这样一个已经变成僵尸的子进程,wait就会收集这个子进程的信息,并把它彻底销毁后返回;如果没有找到这样一个子进程,wait就会一直阻塞在这里,直到有一个出现为止。参数status用来保存被收集进程退出时的一些状态,它是一个指向int类型的指针。但如果程序对这个子进程是如何死掉的毫不在意,而只是想消灭这个僵尸进程,这时候就可以设定status为NULL。

        我们可以用sys/wait.h文件中定义的宏来解释状态信息,如表2-2-1。

表2-2-1
说明
WIFEXITED(status)如果子进程正常结束,它就取一个非零值
WEXITSTATUS(status)如果WIFEXITED非零,它返回子进程的退出码
WIFSIGNALED(status)如果子进程因为一个未捕获的信号而终止,它就取一个非零值
WTERMSIG(status)如果WIFSIGNALED非零,它返回一个信号代码
WIFSTOPPED(status)如果子进程意外终止,它就取一个非零值
WSTOPSIG(status)如果WIFSTOPPED非零,它返回一个信号代码

        我们稍微修改一下fork_test.c的源代码,让父进程等待并检查子进程的退出状态。

#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main()
{
	pid_t pid;
	char *msg;
	int n;
	int exit_code;

	printf("stating...\n");
	switch (pid = fork())
	{
		case -1:
			perror("fork failed");
			exit(EXIT_FAILURE);
		case 0:
			msg = "This is the child";
			n = 5;
			exit_code = 37;
			break;
		default:
			msg = "This is the parent";
			n = 3;
			exit_code = 0;
			break;
	}

	for (; n > 0; n--)
	{
		puts(msg);
		sleep(1);
	}

	if (pid != 0)
	{
		int status;
		pid_t child_pid;

		child_pid = wait(&status);

		printf("Child has finished: PID = %d\n", child_pid);
		if(WIFEXITED(status))
			printf("Child exited with code %d\n", WEXITSTATUS(status));
		else
			printf("Child terminated abnormally\n");
	}
	exit(exit_code);
}
        父进程用wait系统调用将自己的执行挂起,直到子进程的状态信息出现为止。这将发生在子进程调用exit的时侯。我们将子进程的退出码设置为37,父进程然后继续执行,通过测试wait调用的返回值来判断子进程是否正常终止。如果是,就从状态信息中提取出子进程的退出码。编译并执行,结果如下:

$ ./wait_test
stating...
This is the parent
This is the child
This is the parent
This is the child
This is the parent
This is the child
This is the child
This is the child
Child has finished: PID = 3974
Child exited with code 37
$

        从本质上讲,waitpid和wait的作用是完全相同的,但waitpid多出了两个可由用户控制的参数pid和options,从而为编程提供了一种更为灵活的方式。waitpid可以用来等待指定的进程,可以使进程不挂起而立刻返回。参数pid用于指定所等待的进程,其取值及相应的含义如表2-2-2。

表2-2-2 参数pid取值及含义
pid取值含义
pid > 0只等待进程ID为pid的子进程,不管其他已经有多少子进程运行结束退出了,只要指定的子进程还没有结束,waitpid就一直等待下去
pid = -1等待任何一个子进程退出,没有任何限制,此时waitpid等价于wait
pid = 0等待同一个进程组中的任何子进程,如果某一子进程已经加入了别的进程组,waitpid则不会对它做任何理睬
pid < -1等待一个指定进程组中的任何子进程,这个进程组的ID等于pid的绝对值

        参数options提供了一些额外的选项来控制waitpid,目前在Linux中只支持WNOHANG和WUNTRACED两个选项,这是两个常数,可以用“|”运算符把它们连接起来使用,比如:

ret = waitpid(-1, NULL, WNOHANG | WUNTRACED);

        如果不想使用它们,也可以把options设置为0,比如:

ret = waitpid(-1, NULL, 0);

        如果使用了WNOHANG参数调用waitpid,即使没有子进程退出,它也会立即返回,不会像wait那样永远等下去。而WUNTRACED参数,由于涉及一些跟踪调试方面的知识,并且很少使用,这里不做介绍,有兴趣可自行查阅相关资料。

        其实wait就是经过包装的waitpid,内核源代码如下:

static inline pid_t wait(int * wait_stat)
{
    return waitpid(-1, wait_stat, 0);
}

        另外,waitpid的返回值也要逼wait稍微复杂一点,一共三种情况:

a) 当正常返回的时侯,waitpid返回收集到的子进程的进程ID

b) 如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0

c) 如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在

        当pid所指示的子进程不存在,或此进程存在,但不是调用进程的子进程,waitpid就会出错返回,这时errno被设置为ECHILD。关于waitpid的实例,献上一枚:

#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main()
{
	pid_t pc, pr;

	if ((pc = fork()) == -1)
	{
		perror("fork failed");
		exit(EXIT_FAILURE);
	}
	else if (pc == 0)
	{
		sleep(10);
		exit(0);
	}

	do
	{
		pr = waitpid(pc, NULL, WNOHANG);
		if (pr == 0)
		{
			printf("No child exited\n");
			sleep(1);
		}
	} while (pr == 0);

	if (pr == pc)
	{
		printf("Successfully get child %d\n", pr);
	}
	else
	{
		printf("Some error occured\n");
	}

	exit(0);
}
编译并运行:

$ ./waitpid_test
No child exited
No child exited
No child exited
No child exited
No child exited
No child exited
No child exited
No child exited
No child exited
No child exited
Successfully get child 4111

        由打印结果可以看到,因为使用了WNOHANG参数,所以父进程在调用waitpid后并没有在调用处等待,而是继续执行后续的语句。父进程经过10次失败的尝试后,终于收集到了退出的子进程。

        此外,Linux系统还提供了另外两个用于进程等待的调用——wait3和wait4系统调用。wait3和wait4函数分别相当于wait和waitpid函数,但与wait和waitpid相比,wait3和wait4多了一个结构指针参数rusage。它们的函数原型如下:

#include <sys/types.h>

#include <sys/time.h>
#include <sys/resource.h>

#include <sys/wait.h>

pid_t wait3(int *status, int options, struct rusage *rusage);

pid_t wait4(pid_t pid, int *status, int options, struct rusage *rusage);

        两个函数若成功则返回进程ID,若失败则返回-1。

        wait3和wait4在以前的waitpid等函数的基础上增加了可以获取进程及其子进程所占用的resources的情况的功能。参数rusage是一个结构zhizh,调用这两个函数时,如果rusage不为NULL,则关于子进程执行时的相关信息将被写入该指针指向的缓冲区内。至于功能和前面的wait和waitpid的功能非常相似,不再赘述。

2.3 进程终止的特殊情况

        前面讨论了wait()和exit()联用来等待子进程终止的情况。但是还有两种进程终止情况值得讨论:

  • 子进程终止时,父进程并不正在执行wait()调用。
  • 当子进程尚未终止时,父进程却终止了。

        在第一种情况中,要终止的进程就处于一种过度状态(即僵尸进程),处于这种状态的进程不使用任何内核资源,但是要占用内核中的进程处理表的一项。当其父进程执行wait()等待子进程时,它会进入睡眠状态,然后把这种处于过渡状态的进程聪系统内删除,父进程仍能得到该子进程的结束状态。

        第二种情况中,一般允许父进程结束,并把它的子进程(包括处于过渡状态的进程)交归系统的初始化进程所属。


3 进程的属性

3.1 进程标识符

        系统给每个进程定义了一个标识该进程的非负正数,称作进程标识符。当某一进程终止后,其标识符可以重新用作另一进程的标识符。不过,在任何时刻,一个标识符所代表的进程是唯一的。系统把标识符0和1保留给系统的两个重要进程。进程0是调度进程,它按一定的原则把处理机分配给进程使用。进程1是初始化进程,它是程序/sbin/init的执行。进程1是其它进程的祖先,并且是进程结构的最终控制者。

        利用系统调用getpid可以得到程序本身的进程标识符,返回pid_t类型。利用系统调用getppid可以得到调用进程的父进程的标识符,返回pid_t类型。

3.2 进程的组标识符

        Linux把进程分属一些组,用进程的组标识符来指示进程所属组。进程最初是通过fork()和exec调用来继承其进程组标识符。但是,进程可以使用系统调用setpgrp()形成一个新的组,声明如下:

#include <unistd.h>

int setpgrp(void);      / * System V version */

        返回值newpg是新的进程组标识符,即为调用进程的进程标识符。这时调用进程就成为这个新组的进程组首(process group leader)。它所建立的所有进程,将继承newpg中的进程组标识符。

        一个进程可以用系统调用getpgrp()来获取当前的进程组标识符,声明如下:

#include <unistd.h>

int getpgrp(void);      / * POSIX.1 version */

        函数的返回值就是进程组的标识符。

        进程组对于进程间的通信机构——信号来说非常有用(暂时不讨论)。现在讨论进程组的另一个应用。当某个用户退出系统时,则相应的shell进程所启动的全部进程都要被强行终止,而系统是根据进程的组标识符来选定应该终止的进程的。如果一个进程具有跟其祖先shell进程相同的组标识符,那么它的声明期将可超过用户的注册期,这对于需要长时间运行的后台任务是非常有用的。

3.3 进程环境

        进程的环境是一个以NULL字符结尾的字符串集合。在程序中可以用一个以NULL结尾的字符型指针数组来表示它。使用shell命令env可以查看当前所有环境变量。当然Linux系统也提供了一个environ指针,通过它可以在程序中访问其环境内容。当然,在使用environ指针之前,应该先声明它。

#include <stdio.h>

extern char **environ;

int main()
{
	char **env = environ;

	while (*env)
	{
		printf("%s\n", *env++);
	}

	return 0;
}
        编译运行后可以看到所有环境变量,可以发现跟直接使用shell命令env的打印结果一样。

        在Linux的系统函数库stdlib.h中还提供了一组系统调用:getenv()与putenv()。声明如下:

#include <stdlib.h>

char *getenv(const char *name);

int putenv(char *string);

        getenv根据参数name寻找“name=string”这种形式的字符串,如果成功,返回一个指向这个字符串中“string”部分的指针,否则返回NULL指针。putenv则是用于改变和扩充环境,其使用方法为:

putenv("newvariable=value");

        如果调用成功则返回0。需要注意的是,它只能改变调用进程的环境,而父进程的环境并不随之改变。

3.4 进程的当前目录

        每个进程都有一个当前目录,一个进程的当前目录最初为其父进程的当前目录,可见当前目录的初始值是通过fork()和exec传送下去的。注意,当前目录是进程的一个属性,所以,如果子进程通过chdir()改变了它的当前目录,那么其父进程的当前目录并没有因此改变。鉴于此,系统的cd命令(改变当前目录命令)实际上是一个shell自身的内部命令,其代码在shell内部,而没有单独的程序文件。只有这样才能改变相应shell进程的当前目录。否则智能改变cd程序所运行进程自己的当前目录。

        类似的,每个进程还有一个根目录,它与绝对路径名的检索起点有关。与当前目录一样,进程的根目录的初始值为其父进程的根目录,可以用系统调用chroot()来改变进程的根目录,但是不会改变其父进程的根目录。

3.5 进程的有效标识符

        每个进程都有一哥实际用户标识符和一个实际组标识符,它们永远都是启动该进程用户的用户标识符和组标识符。

        进程的有效用户标识符和有效组标识符也许更重要些,它们被用来确定一个用户能够访问某个确定的文件。在通常情况下,它们与实际用户标识符和实际组标识符是一致的,但是一个进程或其祖先进程可以设置程序文件的置用户标识符权限或置组标识符权限,这样,当通过exec地哦啊用执行该程序时,其进程的有效用户标识符就取自该文件的文件组的有效用户标识符,而不是启动该进程的用户的有效用户标识符。

        与某一个进程相关联的ID至少有6个,它们分别是实际用户ID、实际组ID、有效用户ID、有效组ID、保存的设置用户ID和保存的设置组ID。并且,在通常情况下,有效用户ID等于实际用户ID,有效组ID等于实际组ID。获取进程相关信息的函数如下:

#include <sys/types.h>

#include <unistd.h>

uid_t getuid(void);

gid_t getgid(void);

uid_t gettuid(void);

gid_t getegid(void);

        四个函数的返回为进程的相关用户ID。其中,getuid用于获得实际用户标识符,getgid用于获得实际组标识符,gettuid用于获得有效用户标识符,getegid用于获得有效组标识符。在Linux系统中,特权(例如能改变某一文件的读写权限)是基于用户和组ID的,当程序需要增加特权或是访问当前并不允许访问的资源时,我们需要更换自己的用户ID或组ID,使得新的ID具有适合的特权或访问权限。与此类似,当程序需要降低其特权或阻止对某些资源的访问时,也需要更换用户ID或组ID,从而使得新ID不具有相应特权或访问这些资源的能力。可以使用setuid函数设置实际用户ID和有效用户ID,用setgid函数设置实际组ID和有效组ID,原型如下:

#include <sys/types.h>

#include <unistd.h>

int setuid(uid_t uid);

int setgid(gid_t gid);

        两个函数均返回:若成功返回0,失败返回-1。

       关于谁能更改ID有若干规则,现在先考虑有关改变用户ID的规则(在这里关于用户ID所说明的一切都适用于组ID)。

  • 若进程具有超级用户特权,则setuid函数将实际用户ID、有效用户ID,以及保存的设置用户ID设置为uid(对于setgid则为gid)。
  • 若进程没有超级用户特权,但是uid等于实际用户ID或保存的设置用户ID,则setuid只将有效用户ID设置为uid(对于setgid则为gid),不改变实际用户ID和保存的设置用户ID。
  • 如果上面两个条件都不满足,则errno设置为EPERM,并返回出错。

        关于内核所维护的三个用户ID,还要注意下列几点:

  • 只有超级用户进程可以更改实际用户ID。通常,实际用户ID是在用户登录时,由login(1)程序设置的,而且绝不会改变它。因为login是一个超级用户进程,当它调用setuid时,设置所有三个用户ID。
  • 仅当对程序文件设置了设置用户ID位时,exec函数设置有效用户ID。如果设置用户ID位没有位置,则exec函数不会改变有效用户ID,而将其维持为原先值。任何时候都可以调用setuid,将有效用户ID设置为实际用户ID或保存的设置用户ID。但不能将有效用户ID设置为任一随机值。
  • 保存的设置用户ID是由exec聪有效用户ID复制的。在exec按文件用户ID设置了有效用户ID后,即进行这种复制,并将此副本保存起来。

        此外,setreuid和setregid的功能是交换用户ID。具体来说,setreuid用于交换实际用户ID和有效用户ID的值,setregid用户交换实际组ID和有效组ID的值。函数原型如下:

#include <sys/types.h>

#include <unistd.h>

int setreuid(uid_t ruid, uid_t euid);

int setregid(gid_t rgid, gid_t egid);

        两个函数返回:若成功返回0,失败返回-1。

        参数ruid和rgid表示实际用户ID和实际组ID,euid和egid表示有效用户ID和有效组ID。setreuid和setregid的作用很简单:一个非特权用户总能交换实际用户(或组)ID和有效用户(或组)ID。这就允许一个设置用户ID程序转换成只具有用户的普通许可权,以后又可再次转换回设置用户ID所得到的额外许可权。

        另外,seteuid和setegid函数用户更改有效用户ID和有效组ID,原型如下:

#include <sys/types.h>

#include <unistd.h>

int seteuid(uid_t euid);

int setegid(gid_t egid);

        两个函数返回:成功返回0,失败返回-1。

        一个非特权用户可将其有效用户ID设置为其实际用户ID或其保存的设置用户ID。对于一个特权用户则可将有效用户ID设置为uid(这一点区别于setuid函数,它更改三个用户ID)。

3.6 进程的资源

        Linux提供了几个系统调用来限制一个进程对资源的使用。函数声明如下:

#include <sys/time.h>

#include <sys/resource.h>

int getrlimit(int resource, struct rlimit *rlim);

int getrusage(int who, struct rusage *usage);

int setrlimit(int resource, const struct rlimit *rlim);

        其中,getrlimit和setrlimit分别用来取得和设定进程对资源的限制。第一个参数resource指定了调用操作的资源类型,可以指定的集中资源类型见表3-6-1。

表3-6-1 resource参数的取值及其含义
RLIMIT_CPUCPU时间,以秒为单位
RLIMIT_FSIZE文件的最大尺寸,以字节为单位
RLIMIT_DATA数据区的最大尺寸,以字节为单位
RLIMIT_STACK堆栈区的最大尺寸,以字节为单位
RLIMIT_CORE最大的核心文件尺寸,以字节为单位
RLIMIT_RSSresident set的最大尺寸
RLIMIT_NPROC最大的进程数目
RLIMIT_NOFILE最多能打开的文件数目
RLIMIT_MEMLOCK最大的内存地址空间
        第二个参数rlim用于取得或设定具体的限制。struct rlimit的定义如下:

struct rlimit
{
	int rlim_cur;<pre name="code" class="cpp">	int rlim_max;
};
 

        rlim_cur是目前所使用的资源数,rlim_max是限制数。如果想取消某个资源的限制,可以把FLIM_INFINITY赋给rlim参数。只有超级用户可以取消或者放大对资源的限制。普通用户只能缩小对资源的限制。

        系统调用getrusage()返回当前的资源使用情况,第一个参数who指定了查看的对象,可以是:

RUSAGE_SELF        查看进程自身的资源使用状况。

RUSAGE_CHILDREN        查看进程的子进程的资源使用状况。

        第二个参数usage用于接收资源的使用状况,rusage结构的定义如下:

struct rusage
{
	struct timeval ru_utime;		// 使用的用户时间
	struct timeval ru_stime;		// 使用的系统时间
	long ru_maxrss;					// 最大的保留集合尺寸
	long ru_ixrss;					// 内部共享内存尺寸
	long ru_idrss;					// 内部非共享数据尺寸
	long ru_isrss;					// 内部非共享栈尺寸
	long ru_minflt;					// 重复声明页
	long ru_majflt;					// 错误调用页数
	long ru_nswap;					// 交换区
	long ru_inblock;				// 阻塞的输入操作数
	long ru_oublock;				// 阻塞的输出操作数
	long ru_msgsnd;					// 发送的消息
	long ru_msgrcv;					// 接受的消息
	long ru_nsignals;				// 接受的信号
	long ru_nvcsw;					// 志愿上下文开关
	long ru_nivcsw;					// 非志愿上下文开关
};

3.7 进程的优先级

        系统以整型变量nice为基础,来决定一个特定进程可得到的CPU时间的比例,nice的值从0到其最大值,nice的值称为进程的优先数。进程的优先数越大,其优先权就越低。普通进程可以使用系统调用nice()来降低它的优先权,以把更多的资源分给其它进程。具体的做法是给系统调用nice的参数定一个正数,nice()调用将其加到当前的nice值上,声明如下:

#include <unistd.h>

int nice(int inc);

        调用成功,则返回进程新的优先级,失败则返回-1。超级用户可以用系统调用nice()增加优先权,这时只需要给nice()一个负值的参数。

4 守护进程

4.1 简介

        守护进程是一种后台运行并且独立于所有终端控制之外的进程。UNIX/Linux系统通常有许多的守护进程,它们执行着各种系统服务和管理的任务。之所以需要有独立于终端之外的进程存在,原因有三。首先,出于安全性的考虑我们不希望这些进程在执行中的信息在任何一个终端上显示。其次,我们也不希望这些进程被终端所产生的中断信号所打断。最后,虽然我们可以通过&符号将程序转为后台执行,但我们有时还也会需要程序能够自动将其转入后台执行。

4.2 守护进程的启动

        药企动一个守护进程可以采取以下几种方式:

  1. 在系统期间通过系统的初始化脚本启动守护进程。这些脚本通常在目录etc/rc.d下,通过它们所启动的守护进程具有超级用户的权限。系统的一些基本服务程序通常都是通过这种方式启动的。
  2. 很多网络服务程序是由inetd守护程序启动的。它监听各种网络请求,如telnet、ftp等,在请求到达时启动相应的服务器程序(telnet server、ftp server等)。
  3. 由cron定时启动的处理程序。这些程序在运行时实际上也是一个守护进程。
  4. 有at启动的处理程序。
  5. 守护程序也可以从终端启动,通常这种方式只用于守护进程的测试,或者是重启因某种原因而停止的进程。
  6. 在终端上用nohup启动的进程。用这种方法可以把所有的程序都变成守护进程。

4.3 守护进程的错误输出

        守护进程不属于任何的终端,所以当需要输出某些信息时,它无法像通常程序那样将信息直接输出到标准输出和标准错误输出中。这就需要某些特殊的机制来处理它的出书。Linux系统提供了syslog()系统调用,守护进程就可以向系统的log文件写入信息,声明如下:

#include <syslog.h>

void syslog(int priority, const char *format, ...);

      priority参数指明了进程要写入信息的等级和用途,等级可以取值如表4-3-1。

表4-3-1 priority等级取值及其含义
等级描述
LOG_EMERG0系统崩溃(最高优先级)
LOG_ALERT1必须立即处理的动作
LOG_CRIT2危机的情况
LOG_ERR3错误
LOG_WARNING4警告
LOG_NOTICE5正常但是值得注意的情况(缺省)
LOG_INFO6信息
LOG_DEBUG7调试信息(最低优先级)
        如果等级没有被指定,就自动取缺省值LOG_NOTICE。

        用途可以取值如表4-3-2。

表4-3-2 priority用途的取值及其含义
用途描述
LOG_AUTH安全/管理信息
LOG_AUTHPRIV安全/管理信息(私人)
LOG_CRONcron守护进程
LOG_DAEMON系统守护进程
LOG_FTPftp守护进程
LOG_KERN内核守护进程
LOG_LOCAL0local use
LOG_LOCAL1local use
LOG_LOCAL2local use
LOG_LOCAL3local use
LOG_LOCAL4local use
LOG_LOCAL5local use
LOG_LOCAL6local use
LOG_LOCAL7local use
LOG_LPR行打印机系统
LOG_MAILmail系统
LOG_NEWSnetwork news系统
LOG_SYSLOGsyslogd进程产生的信息
LOG_USER随机用户信息(缺省)
LOG_UUCPUUCP系统
        如果没有指定用途,缺省的LOG_USER就会自动被指定。

        syslog()调用后面的参数用法和printf类似,message是一个格式串,指定了记录输出的格式。需要注意的是这个串的最后需要指定一个%m,其对应着errno错误码。

        在一个进程使用syslog()的时候,应该先使用openlog()打开系统记录,声明如下:

#include <syslog.h>

void openlog(const char *ident, int option, int facility);

        参数idnet是一个字符串,它将被加载所有用syslog()写入的信息前。通常这个参数就是程序的名字。参数option可以有多个参数或(|)的结果,参数见表4-3-3。

表4-3-3 option的取值及其含义
参数描述
LOG_CONS如果不能写入log信息,则直接将其发往主控台
LOG_NDELAY直接建立与syslogd进程的连接而不是打开log文件
LOG_PERROR将信息写入log的同时爷发送到标准错误输出
LOG_PID在每个信息中加入pid信息
        参数facility指定了syslog()调用的缺省用途值。

        在使用完log后,可以使用系统调用closelog()来关闭它,声明如下:

#include <syslog.h>

void closelog(void);

4.4 守护进程的建立

        先给出一个建立守护进程的示例代码:

#include <sys/types.h>
#include <signal.h>
#include <unistd.h>
#include <syslog.h>

#define MAXFD 64

void daemon_init(const char *pname, int facility)
{
	int i;
	pid_t pid;

	if (pid = fork())
		exit(0);	// 终止父进程

	setsid();		// 第一子进程

	signal(SIGHUP, SIG_IGN);
	if (pid = fork())
		exit(0);	// 终止第一子进程

	// 第二子进程
	daemon_proc = 1;
	// 将工作目录设定为“/”
	chdir("/");
	// 清除文件掩码
	umask(0);
	// 关闭所有文件句柄
	for (i = 0; i < MAXFD; i++)
	{
		close(i);
	}
	// 打开log
	openlog(pname, LOG_PID, facility);
}
        下面根据代码说明建立一个守护进程需要的操作:

  1. fork。首先需要fork一个子进程并将父进程关闭。如果进程是作为一个shell命令在命令行上前台启动的,当父进程终止时,shell就认为该命令已经结束。这样子进程就自动称为后台进程。而且,子进程从父进程那里继承了组标识符同时又拥有了自己的进程标识符,这样保证了子进程不会是一个进程组的首选进程。这一点是下一步setsid所必须的。
  2. setsid。shesid()调用创建了一个新的进程组,调用进程成为了该进程组的首进程。这样,就使该进程脱离了原来的终端,成为了独立于终端外的进程。
  3. 忽略SIGHUP信号,重新fork。这样使进程不再是进程组的首进程,可以防止在某些情况下进程意外的打开终端而重新与终端发生联系。
  4. 改变工作目录,清楚文件掩码。改变工作目录主要是为了切断进程与原有文件系统的联系,并且保证无论从什么地方启动进程都能正常的工作。清除文件掩码是为了消除进程自身掩码对其创建文件的影响。
  5. 关闭全部已打开的文件句柄。这是为了防止子进程继承了在父进程打开的文件而使这些文件始终保持打开从而产生某些冲突。
  6. 打开log系统。

        以上就是建立一个守护进程的基本步骤。当然,一个实际的守护进程要比这个例子复杂许多,但原理是相同的。


整理自 《Linux网络编程》、《Linux程序设计第4版》、《Linux C编程从初学到精通》。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值