前言
用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,如下图所示:
环境变量
exec系统调用执行新程序时会把命令行参数和环境变量传递给main函数,它们在整个进程地址空间中的位置如下图所示:
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;
}
执行结果:
由于父进程在调用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;
}
运行结果
可以看出,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;
}
运行结果:
程序运行过程分析:
- 父进程初始化
- 父进程调用fork,这是一个系统调用,因此进入内核
- 内核根据父进程复制出一个子进程,父进程和子进程的PCB信息相同,用户态代码和数据也相同。因此,子进程现在的状态看起来和父进程一样,做完了初始化,刚调用了fork进入内核,还没有从内核返回
- 现在有两个一模一样的进程看起来都调用了fork进入内核等待从内核返回(其实fork只调用了一次),此外系统中还有很多别的进程也等待从内核返回。是父进程先返回还是子进程先返回,还是这两个进程都等待,先去调度执行别的进程,这都不一定,取决于内核的调度算法
- 如果某个时刻父进程被调度执行了,从内核返回后就从fork函数返回,保存在变量pid中的返回值是子进程的id,是一个大于0的整数,因此执行下面的else分支,然后for循环,打印“This is the parent\n”三次之后停止
- 如果某个时刻子进程被调度执行了,从内核返回后就从fork函数返回,保存在变量pid中的返回值是0,因此执行下面的if(pid == 0)分支,然后执行for循环,打印“This is the child\n”六次之后停止。fork调用把父进程的数据复制一份给子进程,但此后两者互不影响,在这个例子中,fork调用之后父进程和子进程的变量message和n被赋予不同的值,互不影响
- 父进程每打印一条信息就睡眠一秒,这时内核调度别的进程进行,在1秒这么长的间隙里(对于计算机来说1秒很长了)子进程很有可能被调度到。同样地,子进程每打印一条消息就睡眠一秒,在这一秒期间父进程也很有可能被调度到。所以程序运行的结果基本上是父进程交替打印,但这也不是一定的,取决于系统中其它进程的运行情况和内核的调度算法,如果系统中其它进程非常繁忙则有可能观察到不同的结果。另外,读者也可以把sleep(1);去掉看程序的运行结果如何
- 这个程序在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;
}
查看进程的运行状态:
可以看到父进程的pid是27316,子进程的pid是27317,ps命令显示僵尸进程的状态为z,在命令行一栏还现实了<defunct>
僵尸进程是不能用kill命令清除掉的,因为kill命令只是用来终止进程的,而僵尸进程已经终止了。思考一下,用什么办法可以清除掉僵尸进程?
我给出的答案是kill掉父进程,父进程会清理所有的子进程
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)。如下图所示:
管道
管道是一种最基本的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
两个进程之间通信的步骤:
- 父进程调用pipe开辟管道,得到两个文件描述符指向管道的两端
- 父进程调用fork创建子进程,那么子进程也有两个文件描述符指向同一管道
- 父进程关闭管道读端,子进程关闭管道写端。父进程可以往管道里写,子进程可以从管道里读,管道是环形队列实现的,数据从写端流入从读端流出,这样就实现了进程间通信
实现代码(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种特殊情况:
- 如果所有指向管道写端的文件描述符都关闭了(管道写端的引用计数等于0),而仍然有进程从管道的读端读数据,那么管道中剩余的数据被读取后,再次read会返回0,就像读到文件末尾一样
- 如果右指向管道写端的文件描述符没关闭(管道写端的引用计数大于0),而持有管道写端的进程也没有向管道中写数据,这时有进程从管道读端读数据,那么管道中剩余的数据都被读取后,再次read会阻塞,直到管道中有数据可读了才读取数据返回
- 如果所有指向管道读端的文件描述符都关闭了(管道读端的引用计数等于0),这时有进程向管道的写端write,那么该进程会收到信号SIGPIPE,通常会导致进程异常终止
- 如果有指向管道读端的文件描述符没关闭(管道读端的引用计数大于0),而持有管道读端的进程也没有从管道中读数据,这时有进程向管道写端写数据,那么管道被写满时再次write会阻塞,直到管道中有空位置了才写入数据并返回