1.进程的涵义
什么是一个进程?在操作系统原理使用这样的术语来描述的:正在运行的程序及其占用的资源(CPU、内存、系统资源等)叫做进 程。站在程序员的角度来看,我们使用vim编辑生成的C文件叫做源码,源码给程序员来看的但机器不识别,这时我们需要使用 编译器gcc编译生成CPU可识别的二进制可执行程序并保存在存储介质上,这时编译生成的可执行程序只能叫做程序而不能叫进 程。而一旦我们通过命令(./a.out)开始运行时,那正在运行的这个程序及其占用的资源就叫做进程了。进程这个概念是针对系统 而不是针对用户的,对用户来说,他面对的概念是程序。很显然,一个程序可以执行多次,这也意味着多个进程可以执行同一个 程序。
2.为什么要多进程编程
在我们平常写的简单的socket客户端服务器程序,往往只能处理一个客户的请求,他的实现简单但是效率却不高,通常这种服务器被称为迭代服务器。 然而在实际应用中,不可能让一个服务器长时间 地为一个客户服务,而需要其具有同时处理 多个客户请求的能力,这种同时可以处理多个客户请求的服务器称为并发服务器,其 效率很 高却实现复杂。在实际应用中,并发服务器应用的广泛。linux有3种实现并发服务器的方式:多进程并发服务器,多线 程并发服务器,IO复用,这里我们讲的是多进程并发服务器的实现。
3.进程空间的内部布局
在深入理解Linux下多进程编程之前,我们首先要了解Linux下进程在运行时的内存布局。Linux 进程内存管理的对象都是虚拟 内存,每个进程先天就有 0-4G 的各自互不干涉的虚拟内存空间,0—3G 是用户空间执行用户自己的代码, 高 1GB 的空间是内核 空间执行 Linu x 系统调用,这里存放在整个内核的代码和所有的内核模块,用户所看到和接触的都是该虚拟地址,并不是实际 的物理内存地址。 Linux下一个进程在内存里有三部分的数据,就是”代码段”、”堆栈段”和”数据段”。其实学过汇编语言 的人一定知道,一般的CPU都有上述三种段寄存器,以方便操作系统的运行。这三个部分是构成一个完整的执行序列的必要的部 分。”代码段”,顾名思义,就是存放了程序代码的数据,假如机器中有数个进程运行相同的一个程序,那么它们就可以使用相
同的代码段。”堆栈段”存放的就是子程 序的返回地址、子程序的参数以及程序的局部变量和malloc()动态申请内存的地址。而 数据段则存放程序的全局变量,静态变量及常量的内存空间。
下图为大概布局:
栈区:栈内存由编译器在程序编译阶段完成,进程的栈空间位于进程用户空间的顶部并且是向下增长,每个函数的每次调用 都会在栈空间中开辟自己的栈空间,函数参数、局部变量、函数返回地址等都会按照先入者为栈顶的顺序压入函数栈中, 函数返回后该函数的栈空间消失,所以函数中返回局部变量的地址都是非法的。
堆区:堆内存是在程序执行过程中分配的,用于存放进程运行中被动态分配的的变量,大小并不固定,堆位于非初始化数据 段和栈之间,并且使用过程中是向栈空间靠近的。当进程调用 malloc 等函数分配内存时,新分配的内存并不是该函数的 栈帧中,而是被动态添加到堆上,此时堆就向高地址扩张;当利用 free 等函数释放内存时,被释放的内存从堆中被踢 出,堆就会缩减。因为动态分配的内存并不在函数栈帧中,所以即使函数返回这段内存也是不会消失。
非初始化数据段:通常将此段称为 bss 段,用来存放未初始化的全局变量和 static 静态变量。并且在程序开始执行之前, 就是在 main()之前,内核会将此段中的数据初始化为 0 或空指针。
初始化数据段:用来保已初始化的全局变量和 static 静态变量。
Linux 内存管理的基本思想就是只有在真正访问一个地址的时候才建立这个地址的物理映射,Linux C/C++语言的分配方式共有 3 种方式。 (1)从静态存储区域分配。就是数据段的内存分配,这段内存在程序编译阶段就已经分配好,在程序的整个运行期间都存在, 例如全局变量,static 变量。 (2)在栈上创建。在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。 栈内存分配运算内置于处理器的指令集中,效率很高,但是系统栈中分配的内存容量有限,比如大额数组就会把栈空间撑爆导致
段错误。 (3)从堆上分配,亦称动态内存分配。程序在运行的时候用 malloc 或 new 申请任意多少的内存,程序员自己负责在何时用 free 或 delete 释放内存。此 区域内存分配称之为动态内存分配。动态内存的生存期由我们决定,使用非常灵活,但问题也 多,比如指向某个内存块的指针取值发生了变化又没有其他指针指向 这块内存,这块内存就无法访问,发生内存泄露。
4.fork()系统调用
在多进程编程中fork()系统调用是主要的函数,它的作用是在父程序的基础上来创建一个子程序,来实现多个进程的运行。当然了,相同的函数还有vfork()函数,但常用的是fork()函数,而比较方便。
Linux内核在启动的后阶段会创建init进程来执行程序/sbin/init,该进程是系统运行的第一个进程,进程号为 1,称为 Linux 系统的初始化进程,该进程会创建其他子进程来启动不同写系统服务,而每个服务又可能创建不同的子进程来执行不同的 程序。所以init进程是所有其他进程的“祖先”,并且它是由Linux内核创建并以root的权限运行,并不能被杀死。Linux 中维护 着一个数据结构叫做 进程表,保存当前加载在内存中的所有进程的有关信息,其中包括进程的 PID(Process ID)、进程的状态、 命令字符串等,操作系统通过进程的 PID 对它们进行管理,这些 PID 是进程表的索引。 Linux下有两个基本的系统调用可以用于创建子进程:fork()和vfork()。fork在英文中是"分叉"的意思。为什么取这个名字呢? 因为一个进程在运行中,如果使用了fork,就产生了另一个进程,于是进程就”分叉”了,所以这个名字取得很形象。在我们编 程的过程中,一个函数调用只有一次返回(return),但由于fork()系统调用会创建一个新的进程,这时它会有两次返回。一次返回 是给父进程,其返回值是子进程的PID(Process ID),第二次返回是给子进程,其返回值为0。所以我们在调用fork()后,需要通 过其返回值来判断当前的代码是在父进程还是子进程运行,如果返回值是0说明现在是子进程在运行,如果返回值>0说明是父进 程在运行,而如果返回值<0的话,说明fork()系统调用出错。fork 函数调用失败的原因主要有两个:
- 系统中已经有太多的进 程;
- 该实际用户 ID 的进程总数超过了系统限制。
每个子进程只有一个父进程,并且每个进程都可以通过getpid()获取自己的进程PID,也可以通过getppid()获取父进程的 PID,这样在fork()时返回0给子进程是可取的。一个进程可以创建多个子进程,这样对于父进程而言,他并没有一个API函数可 以获取其子进程的进程ID,所以父进程在通过fork()创建子进程的时候,必须通过返回值的形式告诉父进程其创建的子进程PID。 这也是fork()系统调用两次返回值设计的原因。
下面通过一段代码来了解fork()函数:
#include<stdio.h>
#include<errno.h>
#include<unistd.h>
#include<string.h>
int g_var = 6;
char g_buf[] = "A string write to stdout.\n"
int main(int argc, char **argv)
{
int var = 88;
pid_t pid;
if(write(STDOUT_FILENO, g_buf, sizeof(g_buf)-1)<0)
{
printf("Write string to stdout error:%s\n",strerror(errno));
return -1;
}
printf("Before fork\n");
if((pid=fork())<0)
{
printf("fork() error:%s\n",strerror(errno));
return -2;
}
else if(0==pid)
{
printf("Child process PID[%d] running...\n",getpid());
g_var++;
var++;
}
else
{
printf("Parent process PID[%d] waiting...\n");
sleep(1)
}
printf("PID=%1d, g_var=%d,var=%d\n",(long)getgid(),g_var,var);
return 0;
}
代码运行结果:
在上面的编译运行过程我们可以看到,父进程在代码第21行创建了子进程后,系统会将父进程的文本段、数据段、堆栈都拷贝 一份给子进程,这样子进程也就继承了父进程数据段中的的全局变量g_var和局部变量var的值。
- 因为进程创建之后究竟是父进程还是子进程先运行没有规定,所以父进程在第35行调用了sleep(1)的目的是希望让子进程 先运行,但这个机制是不能100%确定能让子进程先执行,如果系统负载较大时1秒的时间内操作系统可能还没调度到子进 程运行,所以sleep()这个机制并不可靠,这时候我们需要使用到今后学习的进程间通信机制来实现这种父子进程之间的同 步问题;
- 程序中38行的printf()被执行了两次,这是因为fork()之后,子进程会复制父进程的代码段,这样38行的代码也被复制给子 进程了。而子进程在运行到第30行后并没有调用return()或exit()函数让进程退出,所以程序会继续执行到38行至39行调 用return 0退出子进程;同理父进程也是执行38行至39行才让父进程退出,所以38行的printf()分别被父子进程执行了两 次。
- 子进程在第29行和30行改变了这两个变量的值,这个改变只影响子进程的空间的值,并不会影响父进程的内存空间,所 以子进程里g_var和var分别变成了7和89,而父进程的g_var和var都没改变。
子进程继承父进程的哪些东西
- 进程的资格(真实(real)/有效(effective)/已保存(saved) 用户号(UIDs)和组号(GIDs))
- 环境(environment)变量
- 堆栈
- 内存
- 打开文件的描述符(注意对应的文件的位置由父子进程共享, 这会引起含糊情况)
- 执行时关闭(close-on-exec) 标志
- 信号(signal)控制设定
- nice值 (译者注:nice值由nice函数设定,该值表示进程的优先级, 数值越小,优先级越高)
- 进程调度类别(scheduler class)优先级高的进程优 先执行)
- 进程组号
- 对话期ID(Session ID) (译者注:译文取自《高级编程》,指:进程所属的对话期 (session)ID, 一个对话期包括一个或多 个进程组, 更详细说明参见《APUE》 9.5节)
- 当前工作目录
- 根目录 (根目录不一定是“/”,它可由chroot函数改变)
- 文件方式创建屏蔽字(file mode creation mask (umask))
- 资源限制
- 控制终端
子进程所独有的
- 进程号
- 不同的父进程号(译者注: 即子进程的父进程号与父进程的父进程号不同, 父进程号可由getppid函数得到)
- 自己的文件描述符和目录流的拷贝(译者注: 目录流由opendir函数创建,因其为顺序读取,顾称“目录流”)
- 子进程不继承父进程的进程,正文(text), 数据和其它锁定内存(memory locks) (译者注:锁定内存指被锁定的虚拟内存 页,锁定后, 不允许内核将其在必要时换出(page out), 详细说明参见《The GNU C Library Reference Manual》 2.2 版, 1999, 3.4.2节)
- 在tms结构中的系统时间(译者注:tms结构可由times函数获得, 它保存四个数据用于记录进程使用中央处理器 (CPU: Central Processing Unit)的时间,包括:用户时间,系统时间, 用户各子进程合计时间,系统各子进程合计时间)
- 资源使用(resource utilizations)设定为0
- 阻塞信号集初始化为空集(译者注:原文此处不明确, 译文根据fork函数手册页稍做修改)
- 不继承由timer_create函数创建的计时器
- 不继承异步输入和输出
- 父进程设置的锁(因为如果是排他锁,被继承的话就矛盾了)
5.vfork()系统调用
在上面的例子中我们可以看到,在fork()之后常会紧跟着调用exec来执行另外一个程序,而exec会抛弃父进程的文本段、数据 段和堆栈等并加载另外一个程序,所以现在的很多fork()实现并不执行一个父进程数据段、堆和栈的完全副本拷贝。作为替代, 使用了写时复制(CopyOnWrite)技术: 这些数据区域由父子进程共享,内核将他们的访问权限改成只读,如果父进程和子进程 中的任何一个试图修改这些区域的时候,内核再为修改区域的那块内存制作一个副本。
vfork()是另外一个可以用来创建进程的函数,他与fork()的用法相同,也用于创建一个新进程。 但vfork()并不将父进程的地址 空间完全复制到子进程中,因为子进程会立即调用exec或exit(),于是也就不会引用该地址空间了。不过子进程再调用exec()或 exit()之前,他将在父进程的空间中运行,但如果子进程想尝试修改数据域(数据段、堆、栈)都会带来未知的结果,因为他会影响 了父进程空间的数据可能会导致父进程的执行异常。此外,vfork()会保证子进程先运行,在他调用了exec或exit()之后父进程才 可能被调度运行。如果子进程依赖于父进程的进一步动作,则会导致死锁。
vfork()函数原型
#include<unistd.h>
#include<sys/tpyes.h>
pid_t vfork(void);
6.wait()与waitpid()
当一个进程正常或异常退出时,内核就会向其父进程发送SIGCHLD信号。因为子进程退出是一个异步事件,所以这种信号也 是内核向父进程发送的一个异步通知。父进程可以选择忽略该信号,或者提供一个该信号发生时即将被执行的函数,父进程可以 调用wait()或waitpid()可以用来查看子进程退出的状态。
pid_t wait(int *static);
pid_t waitpid(pid_t pid, int *static, int optiona);
在一个子进程终止前,wait使其调用者阻塞,而waitpid有一选项可使调用者不用阻塞。 waitpid并不等待在其调用的之后的 第一个终止进程,他有若干个选项,可以控制他所等待的进程。 如果一个已经终止、但其父进程尚未对其调用wait进行善后处理 (获取终止子进程的有关信息如CPU时间片、释放它锁占用的资源如文件描述符等)的进程被称僵死进程(zombie),ps命令将僵死 进程的状态打印为Z。如果子进程已经终止,并且是一个僵死进程,则wait立即返回该子进程的状态。所以,我们在编写多进程 程序时,好调用wait()或waitpid()来解决僵尸进程的问题。
此外,如果父进程在子进程退出之前退出了,这时候子进程就变成了孤儿进程。当然每一个进程都应该有一个独一无二的父进 程,init进程就是这样的一个“慈父”,Linux内核中所有的子进程在变成孤儿进程之后都会被init进程“领养”,这也意味着孤 儿进程的父进程终会变成init进程。
7.多进程改写服务器程序
流程图:
代码示例:
#include<stdio.h>
#include<errno.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<stdlib.h>
#include<getopt.h>
#define WXJ "hello wangruijie\n"
void print_usage(char *progname)
{
printf("%s usage:\n",progname);
printf("-p(--port):sepcify server listen port.\n");
printf("-h(--help):print this help information.\n");
return;
}
int main(int argc, char **argv)
{
int sockfd = -1;
int rv = -1;
struct sockaddr_in servaddr;
struct sockaddr_in cliaddr;
socklen_t len;
int port = 0;
int clifd;
int ch;
int on=1;
pid_t pid;
struct option opts[]=
{
{"port", required_argument, NULL, 'p'},
{"help", no_argument, NULL, 'h'},
{NULL, 0, NULL, 0}
};
while((ch=getopt_long(argc,argv,"p:h", opts,NULL))!=-1)
{
switch(ch)
{
case 'p':
port=atoi(optarg);
break;
case 'h':
print_usage(argv[0]);
return 0;
}
}
if(!port)
{
print_usage(argv[0]);
return 0;
}
sockfd=socket(AF_INET, SOCK_STREAM,0);
if(sockfd<0)
{
printf("Create socket failture:%s\n",strerror(errno));
return -1;
}
printf("Create socket[%d] successfully!\n", sockfd);
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &on,sizeof(on));
memset(&servaddr,0,sizeof(servaddr));
servaddr.sin_family=AF_INET;
servaddr.sin_port=htohs(port);
servaddr.sin_addr.s_addr=htonl(INADDR_ANY);
rv=bind(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
if(rv<0)
{
printf(" bind error\n");
return -2;
}
listen(sockfd, 13);
printf("Start to listen on port [%d]\n",port);
while(1)
{
printf("Start accept new client incoming...\n");
clifd=accept(sockfd, (struct sockaddr *)&cliaddr,&len);
if(clifd<0)
{
printf("accept error\n");
continue;
}
pid=fork();
if(pid<0)
{
printf("failture\n");
close(clifd);
continue;
}
else if(pid>0)
{
close(clifd);
continue;
}
else if(0==pid)
{
char buf[1024];
close(sockfd);
printf("Child process start to commuicate with socket client...\n");
memset(buf, 0, sizeof(buf));
rv=read(clifd,buf,sizeof(buf));
if(rv<0)
{
printf("failture\n");
close(clifd);
exit(0);
}
else if(rv==0)
{
printf("get disconnected\n");
close(clifd);
exit(0);
}
else if(rv>0)
{
printf("Read %d bytes data from server:%s\n",rv,buf);
}
rv=write(clifd, WXJ, strlen(WXJ));
if(rv<0)
{
printf("Write to client by sockfd[%d] failtur\n",sockfd);
close(clifd);
exit(0)
}
sleep(1);
close(clifd);
exit(0);
}
}
close(sockfd);
return 0;
}
在该程序中,父进程accept()接收到新的连接后,就调用fork()系统调用来创建子进程来处理与客户端的通信。因为子进程会继 承父进程处于listen状态的socket 文件描述符(sockfd),也会继承父进程accept()返回的客户端socket 文件描述符(clifd), 但子进程只处理与客户端的通信,这时他会将父进程的listen的文件描述符sockfd关闭;同样父进程只处理监听的事件,所以会 将clifd关闭。 此时父子进程同时运行完成不同的任务,子进程只负责跟已经建立的客户端通信,而父进程只用来监听到来的socket客户端连 接。所以当有新的客户端到来时,父进程就有机会来处理新的客户连接请求了,同时每来一个客户端都会创建一个子进程为其服 务。
运行结果:
在我们使用各种描述符的过程中,用完切记close!