前言


用shell模拟了一个多进程的并发操作数据库的脚本,执行效果非常不理想,所以有必要把linux下的进程学习一下,参考的书籍是《linux下c一站式编程》



引言


每个进程在内核中都有一个进程控制块(PCB)来维护进程相关的信息,Linux内核的进程控制块是task_struct结构体,其中有这些信息


  • 进程id。系统中每个进程有唯一的id,在c语言中用pid_t类型表示,其实就是一个非负整数
  • 进程的状态,有运行、挂起、停止、僵尸等状态
  • 进程切换时需要保存和恢复的一些CPU寄存器
  • 描述虚拟地址空间的信息
  • 描述控制终端的信息
  • 当前工作目录(current working directory)
  • umask掩码
  • 文件描述符表,包含很多指向file结构体的指针
  • 和信号相关的信息
  • 用户id和组id
  • 控制终端、session和进程组
  • 进程可以使用的资源上限

fork和exec是两个重要的系统调用。fork的作用是根据一个现有的进程复制出一个新进程,原来的进程称为父进程(Parent Process),新进程称为子进程(Child Process).系统中同时运行着很多进程,这些进程都是从最初只有一个进程开始一个一个复制出来的。在Shell下输入命令也可以运行一个程序,是因为Shell进程在读取用户输入的命令之后会fork复制出一个新的Shell进程,然后新的Shell进程调用exec执行新的程序



我们知道一个程序可以多次加载到内存,成为同时运行的多个进程,例如可以同时开多个终端窗口运行/bin/bash,另一方面,一个进程在调用exec前后也可以分别执行两个不同的程序,例如在Shell提示符下输入命令ls,首先fork创建子进程,这时子进程仍在执行/bin/bash程序,然后子进程调用exec执行新的程序/bin/ls,如下图所示:





centos 通过pid看进程名称 linux查看pid进程_#include





环境变量


exec系统调用执行新程序时会把命令行参数和环境变量传递给main函数,它们在整个进程地址空间中的位置如下图所示:



centos 通过pid看进程名称 linux查看pid进程_父进程_02




libc中定义的全局变量environ指向环境变量表,environ没有包含在任何头文件中,所以在使用时要用extern声明:



#include <stdio.h>

int main()
{
	extern char **environ;
	int i;

	for (i = 0; environ[i] != NULL; i ++) {
		printf("%s\n", environ[i]);
	}

	return 0;
}


执行结果:




centos 通过pid看进程名称 linux查看pid进程_子进程_03




由于父进程在调用fork创建子进程时会把自己的环境变量表也复制给子进程,所以envrion打印的环境变量和Shell进程的环境变量是相同的



按照惯例,环境变量字符串都是name=value这样的形式,大多数name由大写字母加下划线组成,一般把name的部分叫做环境变量,value的部分则是环境变量的值。环境变量定义了进程的运行环境,一些比较重要的环境变量的含义如下:



  • PATH :可执行文件的搜索路径
  • SHELL :当前的shell,通常是/bin/bash
  • TREM:当前终端类型,在图形界面终端下它的值通常是xterm,终端类型决定了一些程序的输出显示方式,比如图形界面终端可以显示汉字,而字符终端一般不行
  • LANG:语言和locale,决定了字符编码以及时间,货币等信息的显示格式
  • HOME:当前用户主目录的路径

修改环境变量 



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

int main()
{
	printf("%s\n", getenv("PATH"));
	setenv("PATH", "hello", 1);
	printf("%s\n", getenv("PATH"));

	return 0;
}


运行结果




centos 通过pid看进程名称 linux查看pid进程_父进程_04




可以看出,Shell进程的环境变量PATH传递给change_environ,然后change_environ修改了PATH的值,在change_environ中打印出修改的值,但是Shell进程中PATH的值没有变。父进程在创建子进程时会复制一份环境变量给子进程,但是两者的环境变量互不影响



进程控制


fork函数


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

pid_t fork(void);


fork调用失败则返回-1,调用成功的返回值见下面的解释。我们通过一个例子来理解fork是怎样创建新进程的



示例代码



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

int main()
{
	pid_t pid;
	char *message;
	int n;

	pid = fork();

	if (pid < 0) {
		perror("fork failed");
		exit(1);
	}
	if (pid == 0) {
		message = "This is the child\n";
		n = 6;
	} else {
		message = "This is the parent\n";
		n = 3;
	}

	for(; n > 0; n --) {
		printf("%s", message);
		sleep(1);
	}

	return 0;

}


运行结果:




centos 通过pid看进程名称 linux查看pid进程_centos 通过pid看进程名称_05




程序运行过程分析:


  1. 父进程初始化
  2. 父进程调用fork,这是一个系统调用,因此进入内核
  3. 内核根据父进程复制出一个子进程,父进程和子进程的PCB信息相同,用户态代码和数据也相同。因此,子进程现在的状态看起来和父进程一样,做完了初始化,刚调用了fork进入内核,还没有从内核返回
  4. 现在有两个一模一样的进程看起来都调用了fork进入内核等待从内核返回(其实fork只调用了一次),此外系统中还有很多别的进程也等待从内核返回。是父进程先返回还是子进程先返回,还是这两个进程都等待,先去调度执行别的进程,这都不一定,取决于内核的调度算法
  5. 如果某个时刻父进程被调度执行了,从内核返回后就从fork函数返回,保存在变量pid中的返回值是子进程的id,是一个大于0的整数,因此执行下面的else分支,然后for循环,打印“This is the parent\n”三次之后停止
  6. 如果某个时刻子进程被调度执行了,从内核返回后就从fork函数返回,保存在变量pid中的返回值是0,因此执行下面的if(pid == 0)分支,然后执行for循环,打印“This is the child\n”六次之后停止。fork调用把父进程的数据复制一份给子进程,但此后两者互不影响,在这个例子中,fork调用之后父进程和子进程的变量message和n被赋予不同的值,互不影响
  7. 父进程每打印一条信息就睡眠一秒,这时内核调度别的进程进行,在1秒这么长的间隙里(对于计算机来说1秒很长了)子进程很有可能被调度到。同样地,子进程每打印一条消息就睡眠一秒,在这一秒期间父进程也很有可能被调度到。所以程序运行的结果基本上是父进程交替打印,但这也不是一定的,取决于系统中其它进程的运行情况和内核的调度算法,如果系统中其它进程非常繁忙则有可能观察到不同的结果。另外,读者也可以把sleep(1);去掉看程序的运行结果如何
  8. 这个程序在Shell下运行的,因此Shell进程是父进程的父进程。父进程运行时Shell进程处于等待状态(wait和waitpid函数会讲到这种等待是怎么实现的),当父进程终止时Shell进程认为命令执行结束了,于是打印Shell提示符,而事实上子进程这时还没结束,所以子进程的消息打印到Shell提示符后面。最后光标停止"This is the child"的下一行,这时用户仍然可以敲命令,即使命令不是紧跟在提示符后面,Shell也能正确的读取

fork函数特点:



概括起来就是“调用一次,返回两次”,在父进程中调用一次,在父进程和子进程中各返回1次。可以理解出来,程序一开始执行是一个控制流程,调用fork之后发生了分叉,变成了两个控制流程,这也就是“fork”分叉这个名字的由来了。子进程的fork的返回值是0,而父进程中fork的返回值则是子进程的id(从根本上说fork是从内核返回的,内核自有办法让父进程和子进程返回不同的值),这样当fork函数返回后,程序员可以根据返回值的不同让父进程和子进程执行不同的代码



fork的返回值这样规定是有道理的。fork在子进程返回0,子进程仍然可以调用getpid函数得到自己的进程id,也可以调用getppid函数得到父进程的id。父进程中用getpid可以得到自己的进程id,然而要想得到子进程的id,只有将fork的返回值记录下来,别无它法



想测试一下自己是否真正的掌握了fork,可以看一下这到关于fork的面试题: http://blog.jobbole.com/24686/



exec函数


用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数以执行另一个程序。当进程调用一中exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变。


#include <unistd.h>

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 execvp(const char *file, char *const argv[]);
int execve(const char *path, char *const argv[], char *const envp[]);


这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回,如果调用出错则返回-1,所以exec函数只有出错的返回值而没有成功的返回值



wait函数和waitpid函数


一个进程在终止时会关闭所有文件描述符,释放在用户空间分配的内存,但它的PCB还保留着,内核在其中保存了一些信息。如果是正常终止则保存着退出状态,如果是异常终止则保存着导致该进程终止的信号是哪个。这个进程的父进程可以调用wait或waitpid获取这些信息,然后彻底清除这个进程。我们知道一个进程的退出状态可以在shell里用特殊变量$?查看,因为Shell是它的父进程,当它终止时Shell调用wait或waitpid得到它的退出状态同时彻底清除掉这个进程



如果一个进程已经终止,但是它的父进程尚未调用wait或waitpid对它进行清理,这时的进程状态称为僵尸进程(Zombie)。任何进程在刚终止时都是僵尸进程,正常情况下,僵尸进程都立刻被父进程清理掉,为了观察僵尸进程,我们写一个不正常的程序测试一下:



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

int main()
{
	pid_t pid = fork();
	int i;

	if (pid < 0) {
		perror("fork");
		exit(1);
	}else if (pid > 0) {
		// 父进程
		for (i = 0; i < 6; i ++) {
			sleep(10);
		}
	}else {
		// fork出的子进程
		return 0;
	}
	
	return 0;
}


查看进程的运行状态:




centos 通过pid看进程名称 linux查看pid进程_子进程_06




可以看到父进程的pid是27316,子进程的pid是27317,ps命令显示僵尸进程的状态为z,在命令行一栏还现实了<defunct>


僵尸进程是不能用kill命令清除掉的,因为kill命令只是用来终止进程的,而僵尸进程已经终止了。思考一下,用什么办法可以清除掉僵尸进程?


我给出的答案是kill掉父进程,父进程会清理所有的子进程



centos 通过pid看进程名称 linux查看pid进程_子进程_07




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或waitpid时可能会:


  • 阻塞(如果它所有的子进程还在运行)
  • 带子进程的终止信息立刻返回(如果一个子进程已终止,正等待父进程读取其终止信息)
  • 出错立即返回(如果它没有任何子进程)



进程间通信


每个进程各自有不同的用户地址空间,任何一个进程的全局变量在另一个进程中都看不到,所以进程之间要交换数据必须通过内核,在内核中开辟一块缓冲区,进程1把数据从用户空间拷贝到内核缓冲区,进程2再从内核缓冲区把数据读走,内核提供的这种机制称为进程通信(IPC,InterProcess Communication)。如下图所示:




centos 通过pid看进程名称 linux查看pid进程_子进程_08





管道


管道是一种最基本的IPC机制,由pipe函数创建:



#include <unistd.h>

int pipe(int filedes[2]);


调用pipe函数时在内核中开辟一块缓冲区(称为管道)用于通信,它有一个读端一个写端,然后通过filedes参数传出给用户程序两个文件描述符,filedes[0]指向管道的读端,filedes[1]指向管道的写端(很好记,就像0是标准输入1是标准输出一样)。所以管道在用户程序看起来就像一个打开的文件,通过read(filedes[0]);或者write(fieldes[1]);向这个文件读写数据其实是在读写内核缓冲区。pipe调用成功返回0,失败返回-1



两个进程之间通信的步骤:


  1. 父进程调用pipe开辟管道,得到两个文件描述符指向管道的两端
  2. 父进程调用fork创建子进程,那么子进程也有两个文件描述符指向同一管道
  3. 父进程关闭管道读端,子进程关闭管道写端。父进程可以往管道里写,子进程可以从管道里读,管道是环形队列实现的,数据从写端流入从读端流出,这样就实现了进程间通信

实现代码(c语言)



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

#define MAXLINE 80

int main()
{
	int n;
	int fd[2];
	pid_t pid;
	char line[MAXLINE];

	if (pipe(fd) < 0) {
		perror("pipe");
		exit(1);
	}

	if ((pid = fork()) < 0) {
		perror("fork");
		exit(1);
	}

	if (pid > 0) {	// 父进程
		close(fd[0]);
		write(fd[1], "hello wangzhengyi\n", 18);
		wait(NULL);
	} else { // 子进程
		close(fd[1]);
		n = read(fd[0], line, MAXLINE);
		write(STDOUT_FILENO, line, n);
	}

	return 0;
}


使用管道需要注意一下4种特殊情况:


  1. 如果所有指向管道写端的文件描述符都关闭了(管道写端的引用计数等于0),而仍然有进程从管道的读端读数据,那么管道中剩余的数据被读取后,再次read会返回0,就像读到文件末尾一样
  2. 如果右指向管道写端的文件描述符没关闭(管道写端的引用计数大于0),而持有管道写端的进程也没有向管道中写数据,这时有进程从管道读端读数据,那么管道中剩余的数据都被读取后,再次read会阻塞,直到管道中有数据可读了才读取数据返回
  3. 如果所有指向管道读端的文件描述符都关闭了(管道读端的引用计数等于0),这时有进程向管道的写端write,那么该进程会收到信号SIGPIPE,通常会导致进程异常终止
  4. 如果有指向管道读端的文件描述符没关闭(管道读端的引用计数大于0),而持有管道读端的进程也没有从管道中读数据,这时有进程向管道写端写数据,那么管道被写满时再次write会阻塞,直到管道中有空位置了才写入数据并返回