多进程编程
1 服务器并发访问的问题
服务器按照处理方式可以分为迭代服务器和并发服务器两类。上一篇用C写的Socket客户端服务器间通信,服务器每次只能处理一个客户的请求,它实现简单但效率很低,通常这类服务器被称为迭代服务器;然而在实际生活中,不可能让一个服务器只为一个客户服务,而需要其具有同时处理多个客户请求的能力,这种同时可以处理多个用户请求的服务器叫做并发服务器。
Linux下有三种实现并发服务器的方式:多进程并发服务器、多线程并发服务器、IO复用,今天就是多进程并发服务器的实现。
2 多进程编程概念
(1)什么是进程?
在操作系统原理中使用这样的术语来描述:正在运行的程序及其占用的资源(CPU、内存、系统资源等)叫做进程简单来说就是我们写好的代码叫程序,一旦开始运行,那正在运行的这个程序及其占用的资源就叫进程了。
Linux内核在启动的最后阶段会创建init进程来执行程/sbin/init,该进程是系统运行的第一个进程,进程号为1,称为Linux系统的初始化进程,该进程会创建其他子进程来启动不同的系统服务,而每个服务有可能创建不同的子进程来执行不同的程序。所以init进程是所有其他进程的“祖先”,并且它是由Linux内核创建并且以root的权限运行,并不能被杀死。好比一棵树,每个进程都能出创建一个或多个进程,所有进程都是树上的分叉结点,而最底下的树根是init进程。
Linux中维护着一个数据结构叫做进程表,保存着当前加载在所有内存中的所有进程的相关信息,其中包括进程的PID(Process ID)、进程的状态、命令字符串等,操作系统通过进程的PID对它们进行管理,这些PID是进程表的索引。
(2)fork()系统调用
Linux下有两个基本的系统调用可以创建子进程:fork()和vfork()
fork()通过复制调用进程来创建一个新进程。 新进程被称为子进程, 调用进程被称为父进程,子进程和父进程在不同的内存空间中运行,两个内存空间有相同的内容。
pid_t fork(void);
由于fork()系统调用创建了一个子进程,所以调用时有两次返回,一次是给父进程的其返回值是子进程的PID(Process ID),第二次返回是给子进程的,其返回值是0. 所以我们需要通过其返回值来判断当前的代码是父进程在运行还是子进程在运行。
返回值 | 运行状态 |
---|---|
0 | 子进程在运行 |
大于0 | 父进程在运行 |
小于0 | fork()系统调用出错:1.系统中已经有太多进程了;2.该实际用户ID的进程总数超过了系统限制。 |
每个子进程只有一个父进程,并且每个进程都可以通过getpid()来获取自己的进程PID,也可以通过getppid()来获取父进程的PID,这样在fork()时返回0给子进程是可取的。一个进程可以创建多个子进程,这样对于父进程而言,它并没有一个API函数可以获取其子进程的进程ID,所以父进程在通过fork()创建子进程的时候,必须通过返回值的形式来告诉父进程其创建的进程PID。这也是fork()系统调用两次返回值设计的原因。
下面我们来进行一个子进程的创建
1 #include <stdio.h>
2 #include <errno.h>
3 #include <unistd.h>
4 #include <string.h>
5
6 int main(int argc, char **argv)
7 {
8 pid_t pid;
9
10 printf("Parent process PID[%d] start running...\n", getpid());
11
12 pid = fork();
13 if(pid < 0)
14 {
15 printf("fork() create child process failure:%s\n", strerror(errno));
16 return -1;
17 }
18 else if(pid == 0)
19 {
20 printf("Child process PID[%d] start running,my parent PID is [%d]\n", getpid(), getppid());
21 return 0;
22 }
23 else if(pid > 0)
24 {
25 printf("Parent process PID[%d] continue running,and child process PID is [%d]\n", getppid(), getpid());
26 return 0;
27 }
28 }
运行结果:
fork()系统调用会创建一个子进程,这个子进程是父进程的一个副本。这也就意味着,系统在创建子进程成功后,会将父进程的文本段、数据段、堆栈都复制一份给子进程,但子进程有一份自己独立的空间。子进程对这些内存的修改并不会影响父进程空间的相应内存。 这时系统中出现两个基本完全相同的进程(父、子进程),这两个进程执行没有固定的先后顺序,哪个进程先执行要看系统的调度策略。 如果需要确保让父进程或子进程先执行。 则需要程序员在代码中通过进程间通信的机制来实现。
(3)vfork()系统调用
我们通常在fork()之后会紧跟着调用exec来执行另外一个程序,而exec会抛弃父进程的文本段、数据段和堆栈等并加载另外一个程序,所以现在的很多fork()实现并不执行一个父进程数据段、堆和栈的完全副本拷贝。作为替代,使用了写时复制技术:这段数据区域由父子进程共享,内核将他们的访问权改为只读,如果父进程和子进程中的任何一个试图修改这些区域的时候,内核再为修改区域的那块内存制作一个副本。
vfork()是另外一个可以用来创建进程的函数,它与fork()的用法相同,也用于创建一个新的进程。但vfork()并不将父进程的地址空间完全复制到子进程中,因为子进程会立刻调用exec或者exit(),于是也就不会引用地址空间了。不过子进程再调用exec()或exit()之前,他将在父进程的空间中运行,但如果子进程想尝试修改数据域(数据段、堆、栈)都会带来未知的结果,因为他会影响了父进程空间的数据可能导致父进程的执行异常。此外,vfork()会保证子进程先运行,在他调用了exec或exit()之后父进程才可能被调度运行。如果子进程以来父进程的进一步动作,则会导致死锁。
vfork()的函数原型和fork()原型一样:
#include <unistd.h>
#include <sys/types.h>
pid_t fork(void)
pid_t vfork(void)
(4)wait()和waitpid()
当一个进程正常或者异常退出时,内核就会向其父进程发送SIGCHLD信号。因为子进程退出对于父进程是一个异步事件,所以这种信号也是内核向父进程发送的一个异步通知。父进程可以选择忽略该信号,或者提供一个该信号发生时候即将被执行的函数,父进程可以调用wait()或者waitpid()可以查看子进程退出的状态。
pid_t wait(int *status);
pid_t waitpid(pid_t, pid, int *status, int options);
在一个子进程终止前,wait使其调用者阻塞,而waitpid有一选项可使调用者不用阻塞。waitpid并不等待在其调用之后的第一个进程,它有若干个选项,可以控制它所等待的进程。如果一个已经终止、但其父进程尚未对其调用wait进行善后处理(获取终止子进程相关信息如cpu时间片、释放他锁占有的资源如文件描述符等)的进程被称为僵死进程 ps命令将僵死进程的状态打印为Z.如果是一个僵死进程,则wait立刻返回该子进程的状态。所以,我们在编写多进程程序时,最好调用wait()和waitpid()来解决僵尸进程的问题。
此外在父进程在子进程退出之前推出了,这个时候子进程就变成了孤儿进程 。当然每一个进程都应该有一个独一无二的父进程,init进程就是这样的一个“慈父”,==Linux内核中所有子进程在变成孤儿进程之后都会被init进程“领养”,这也意味着孤儿进程的父进程最终都会变成init进程。
(5)子进程会继承父进程哪些东西?
请注意子进程得到的是父进程的拷贝,而不是它们本身
子进程继承父进程的:
·进程的资格(真实(real)/有效(effective)/已保存(saved) 用户号(UIDs)和组号(GIDs))
·环境(environment)变量
· 堆栈
· 内存
· 打开文件的描述符(注意对应的文件的位置由父子进程共享, 这会引起含糊情况)
· 执行时关闭(close-on-exec) 标志 (译者注:close-on-exec标志可通过fnctl()对文件描 述符设置,POSIX.1要求所有目录流都必须在exec函数调用时关闭。更详细说明, 参见《APUE》 W. R. Stevens, 1993, 尤晋元等译(以下简称《高级编程》), 3.13节和8.9节)
· 信号(signal)控制设定
nice值 (译者注:nice值由nice函数设定,该值表示进程的优先级, 数值越小,优先级越高)
· 进程调度类别(scheduler class) (译者注:进程调度类别指进程在系统中被调度时所属的类别,不同类别有不同优先级,根据进程调度类别和nice值,进程调度程序可计算出每个进程的全局优先级(Global process prority),优先级高的进程优先执行)
· 进程组号
对话期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函数创建的计时器
· 不继承异步输入和输出
· 父进程设置的锁(因为如果是排他锁,被继承的话就矛盾了)
3 多进程改写服务器程序
上一篇网络socket编程中只适用于服务器只能处理一个客户的请求,当我们了解多进程编程以后,我们就可以用其改写服务器,使其处理多个客户的请求!
(1)程序代码
1 #include <stdio.h>
2 #include <errno.h>
3 #include <string.h>
4 #include <unistd.h>
5 #include <sys/types.h>
6 #include <sys/socket.h>
7 #include <stdlib.h>
8 #include <getopt.h>
9 #include <ctype.h>
10 #include <arpa/inet.h>
11
12 int main(int argc, char **argv)
13 {
14 int sockfd = -1;
15 int rv = -1;
16 struct sockaddr_in servaddr;
17 struct sockaddr_in cliaddr;
18 socklen_t len;
19 int port;
20 int clifd;
21 int on = 1;
22 pid_t pid;
23
24 if(argc < 2)
25 {
26 printf("Program usage: %s [Server_port]\n", argv[0]);
27 return -1;
28 }
29 port = atoi(argv[1]);
30
31
32 sockfd = socket(AF_INET, SOCK_STREAM, 0);
33 if(sockfd < 0)
34 {
35 printf("Create socket failure: %s\n", strerror(errno));
36 return -1;
37 }
38 printf("Create socket[%d] successfully!\n", sockfd);
39
40 setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
41
42 memset(&servaddr, 0, sizeof(servaddr));
43 servaddr.sin_family = AF_INET;
44 servaddr.sin_port = htons(port);
45 servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
46
47 rv = bind(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
48 if(rv < 0)
49 {
50 printf("Socket[%d] bind on port[%d] failure: %s\n", sockfd, port,strerror(errno));
51 return -2;
52 }
53
54 listen(sockfd, 13);
55 printf("Start to listen on port [%d]\n", port);
56
57 while(1)
58 {
59 printf("Start accept new client incoming...\n");
60
61 clifd = accept(sockfd, (struct sockaddr *)&cliaddr, &len);
62 if(clifd < 0)
63 {
64 printf("Accept new client failure: %s\n", strerror(errno));
65 continue;
66 }
67
68 printf("Accept new client[%s:%d] successfully\n", inet_ntoa(cliaddr.sin_addr), ntohs(cliaddr.sin_port));
69
70 pid = fork();
71 if(pid < 0)
72 {
73 printf("fork() create child process failure: %s\n", strerror(errno));
74 close(clifd);
75 continue;
76 }
77 else if(pid > 0)
78 {
79 close(clifd);
80 continue;
81 }
82 else if(0 ==pid)
83 {
84 char buf[1024];
85 int i;
86
87 printf("Child process start to commuincate with socket client...\n");
88
89 close(sockfd);
90
91 while(1)
92 {
93 memset(buf, 0, sizeof(buf));
94 rv = read(clifd, buf, sizeof(buf));
95 if(rv < 0)
96 {
97 printf("Read data from client sockfd[%d] failure: %s\n", clifd, strerror(errno));
98 close(clifd);
99 exit(0);
100 }
101 else if(rv == 0)
102 {
103 printf("Socket[%d] get disconnected\n", clifd);
104 close(clifd);
105 exit(0);
106 }
107 else if( rv > 0)
108 {
109 printf("Read %d bytes data from Server: %s\n", rv, buf);
110 }
111
112 for(i=0; i<rv; i++)
113 {
114 buf[i]=toupper(buf[i]);
115 }
116
117 rv = write(clifd, buf, rv);
118 if(rv < 0)
119 {
120 printf("Write to client by sockfd[%d] failure: %s\n", clifd, strerror(errno));
121 close(clifd);
122 exit(0);
123 }
124 }
125
126 }
127
128 }
129 close(sockfd);
130 return 0;
131
132 }
代码分析
在该程序中,父进程accept()接收到新的连接后,就调用fork()系统调用来创建子进程来处理客户端的通信。因为子进程会继续继承父进程处于listen状态的socket文件描述符(sockfd),也会继承父进程accept()返回的客户端socket文件描述符(clifd),但是子进程只处理与客户端的通信,这是它会将父进程的listen的文件描述符sockfd关闭;同样父进程只处理监听的事件,所以会将clifd关闭。
此时父子进程同时运行完成不同的任务,子进程只负责跟已建立的客户端通信,而父进程只用来监听到来的socket客户端连接。所以当有新的客户端到来时,父进程就会有机会来处理新的客户端的连接请求了,同时每一位客户端都会创建一个子进程为其服务。
子进程使用while(1)循环让自己一直执行,他负责客户端发过来的小写字母改成大写再传回去。只有当读写socket出错或客户端断开时才退出,在退出之前都调用close()关闭相应的套接字,因为是在main()函数中,所以我们可以使用return()或exit()退出进程,但不能使用break跳出。
(2)运行结果
首先用TCP Test Tool模拟两个客户端连接写好的服务器
然后看已经启动的服务器:
连接成功,成功处理两个客户端的数据请求!