网络编程——10. 多进程服务器端(1)

10.1 进程概念及其应用

可以构建按序向第一个客户端到第一百个客户端提供服务的服务器端。当然,第一个客户端不会去抱怨,但是如果每个客户端的服务时间都要0.5s,那第100个客户端就会疯狂抱怨了。

并发服务器端的实现方法

即使有可能延长服务时间,也有必要改进服务器端,使其同时向所有发起请求的客户端提供服务,以提高平均满意度。

向多个客户端提供服务是一种有效利用CPU的方式

接下来讨论同时向多个客户端提供服务的并发服务器端。下面列出的是具有代表性的并发服务器端实现模型和实现方法:

  • 多进程服务器:通过创建多个进程提供服务
  • 多路复用服务器:通过捆绑并统一管理IO对象提供服务
  • 多线程服务器:通过生成与客户端等量的线程提供服务

理解进程

进程:占用内存空间的正在运行的程序
从操作系统的角度看,进程是程序流的基本单位,若创建多个进程,则操作系统将同时运行。

Tips:
拥有2个运算设备(核)的CPU称作双核CPU,有4个核的叫做4核CPU。一个CPU中可能包含多个核,核的个数与可同时运行的进程数相同。
若进程数超过核数,进程将分时使用CPU资源。但因为CPU运算速度极快,我们会感到所有进程同时运行。

进程ID

无论进程是如何创建的,所有进程都会从操作系统分配到ID,这就称为进程ID(PID),其值为大于2的整数。1要分配给操作系统启动后的(用于写作操作系统)首个进程,因此用户进程无法得到ID值1。
ps au查看当前运行的所有进程

通过调用fork函数创建进程

创建进程的方法很多,只介绍用于创建多进程服务器端的fork函数。
网络编程——10. 多进程服务器端(1)
fork函数将创建调用的进程副本:即并非根据完全不同的程序创建进程,而是复制正在运行的、调用fork函数的进程。
另外,两个进程都将执行fork函数调用后的语句(准确说是在fork函数返回后)
因为通过同一个进程、复制相同的内存空间,之后的程序流要根据fork函数的返回值加以区分。即利用fork函数的如下特点区分程序执行流程:

  • 父进程:fork函数返回子进程ID
  • 子进程:fork函数返回0

父进程是指原进程,即调用fork函数的主体,而子进程是通过父进程调用fork函数复制出的进程。
网络编程——10. 多进程服务器端(1)
分析:
① 父进程调用fork函数的同时复制出子进程,并分别得到fork函数的返回值。
② 复制前,父进程将全局变量gval增加到11,将局部变量lval的值增加到25,在此状态下完成进程复制。
③ 复制完成后根据fork函数的返回类型区分父子进程。
父进程将lval的值加1,但这并不影响子进程lval的值
子进程将gval的值加1,也不会影响父进程gval的值
因为fork函数调用后分成了完全不同的进程,只是二者共享同一代码而已。

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

int gval = 10;

int main(int argc, char *argv[]) {
    pid_t pid;
    int lval = 20;
    gval++, lval += 5;

    pid = fork();
    if(pid == 0) {	// if child process
    	gval += 2;
    	lval += 2;
    } else {		// if parent process
    	gval -= 2;
    	lval -= 2;
    }

    if(pid == 0) {	// if child process
    	printf("child proc: [%d, %d] \n", gval, lval);
    } else {		// if parent process
    	printf("parent proc: [%d, %d] \n", gval, lval);
    }

    return 0;
}

运行结果:
child proc: [13, 27]
parent proc: [9, 23]



10.2 进程和僵尸进程

就像文件操作一样,打开文件和关闭文件同等重要。
对进程来说,进程的创建和销毁也同样重要,如果不认真对待销毁进程,那么进程就有可能编程僵尸进程。

僵尸进程

进程完成工作后(执行完main函数中的程序后)应该被销毁,但有时这些进程将变成僵尸进程,占用系统中的重要资源。这种状态下的进程就叫僵尸进程。

产生僵尸进程的原因

调用fork函数产生子进程的终止方式:
1)传递参数并调用exit函数
2)main函数中执行return语句并返回值
向exit函数传递的参数值和main函数的return语句返回的值都会传递给操作系统
操作系统不会销毁子进程直到把这些值传递给产生该子进程的父进程。处在这种状态下的进程就是僵尸进程。
也就是说,将子进程变成僵尸进程的正是操作系统。

操作系统不会主动把这些值(子进程的exit参数或return语句的返回值)传递给父进程。
只有父进程主动发起请求(函数调用)时,操作系统才会传递该值,否则,子进程将一直处于僵尸进程状态

创建僵尸进程的示例:

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

int main(int argc, char *argv[]) {
    pid_t pid = fork();

    if(pid == 0) {      // if child process
        puts("Hi, I am a child process");
    } else {
    	// 输出子进程ID,通过该值查看子进程状态(是否为僵尸进程)
    	printf("child process ID: %d \n", pid);
    	// 父进程暂停30s,如果父进程终止,处于僵尸状态的子进程将同时销毁。因此,延缓父进程的执行以验证僵尸进程。
    	sleep(30); 	// sleep 30 sec
    }
    
    if(pid == 0) {      // if child process
        puts("end child process");
    } else {
    	printf("end parent process");
    }
    return 0;
}

编译并执行它,可以看到下面的结果
网络编程——10. 多进程服务器端(1)
此时,父进程暂停30s,说明父进程还未终止。但子进程此时已经被销毁了,可以看到已经出现了end child process
在终端执行ps au
网络编程——10. 多进程服务器端(1)
可以看到,子进程对应的ID12763进程状态为僵尸进程(Z+)。另外,经过30s以后,PID为12762的父进程和之前的僵尸子进程同时销毁ps au

Tips:后台处理
后台处理是指将控制台窗口中的指令放到后台运行的方式。&将出发后台处理./zombie &
这样,即可在同一控制台下ps au

销毁僵尸进程1:利用wait函数

如前所述,为了销毁子进程,父进程应主动请求获取子进程的返回值。接下来讨论发起请求的方法:
网络编程——10. 多进程服务器端(1)
调用此函数时如果有子进程终止,那么子进程终止时传递的返回值(exit函数的参数值,main函数的return返回值)将保存到该函数的参数所指内存空间。但函数参数指向的单元中还包含其他信息,因此需要通过下列宏进行分离。

  • WIFEXITED 子进程正常终止时返回true
  • WEXITSTATUS 返回子进程的返回值

也就是说,向wait函数传递变量status的地址时,调用wait函数后应编写如下代码:

if(WIFEXITED(status)) {   // 是正常终止的吗
    puts("Normal temination!");
    printf("child pass num : %d", WEXITSTATUS(status));    // 那么返回值是多少
}

这样,子进程将不会再变成僵尸进程

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

int main(int argc, char *argv[]) {
    int status;
    pid_t pid = fork();	// ①

    if(pid == 0) {      // if child process
        // ①处创建的子进程将在此处通过main函数中的return语句终止
        return 3;
    } else {
    	// 输出子进程ID,通过该值查看子进程状态(是否为僵尸进程)
    	printf("child process ID: %d \n", pid);
    	pid = fork();	// ②
    	if(pid == 0)
    	    exit(7);	// ②处创建的子进程将在此处通过调用exit函数终止
    	else {
    	    printf("child process ID: %d \n", pid);
    	    // 调用wait函数。之前终止的子进程信息保存到status变量,同时相关的子进程完全销毁
    	    wait(&status);
    	    // 通过WIFEXITED宏验证子进程是否正常终止。
    	    // 如果正常退出,则调用WIFEXITED输出子进程的返回值
    	    if(WIFEXITED(status))
    	        printf("child send one: %d \n", WEXITSTATUS(status));

            // 由于之前创建了2个进程,所以再次调用wait函数和宏
	    wait(&status);
	    if(WIFEXITED(status))
    	        printf("child send two: %d \n", WEXITSTATUS(status));
    	    // 为了暂停父进程终止,此时可以查看子进程的状态
    	    sleep(30);
    	}
    }

    return 0;
}

编译并运行它:
网络编程——10. 多进程服务器端(1)
此时,去查看系统中运行的进程
网络编程——10. 多进程服务器端(1)
可以看到,对应的子进程并没有出现在PID列表中,这是因为调用了wait函数,完全销毁了该进程。另外两个子进程终止时返回的3和7传递到了父进程。

调用wait函数时,如果没有已终止的子进程,那么程序将阻塞直到有子进程终止,因此需谨慎调用

销毁僵尸进程2:使用waitpid函数

该函数是防止阻塞的方法
网络编程——10. 多进程服务器端(1)

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

int main(int argc, char *argv[]) {
    int status;
    pid_t pid = fork();
    
    if(pid == 0) {
        // 调用sleep函数推迟子进程的执行,这导致程序延迟15s
        sleep(15);
        return 24;
    } else {
    	while(!waitpid(-1, &status, WNOHANG)) {
    	    sleep(3);
    	    puts("sleep 3s");
    	}

	if(WIFEXITED(status))
	    printf("child send %d \n", WEXITSTATUS(status));
    }
    return 0;
}

编译并执行它
网络编程——10. 多进程服务器端(1)
可以看到,在子进程睡觉(15s)的时候,即子进程未销毁,sleep3做了5次,然后子进程睡醒了,返回24。