当前位置: 首页 > news >正文

10.多进程服务器端

前言

大家已对套接字编程有了一定的理解,但要想实现真正的服务器端,只凭这些内容还不够哦。因此,现在开始学习构建实际网络服务所需内容吧!

一、进程的概念与应用

利用之前学习到的内容,我们可以构建按序向第一个客户端到第一百个客户端提供服务的服务器端。当然,第一个客户端不会抱怨服务器端,但如果每个客户端的平均服务时间为0.5秒,则第100个客户端会对服务器端产生相当大的不满。

1.两种类型的服务器端

如果真正为客户端着想,应提高客户端满意度平均标准。如果有下面这种类型的服务器端,各位应该感到满意了吧。
“第一个连接请求的受理时间为0秒,第50个连接请求的受理时间为50秒,第100个连接请求的受理时间为100秒!但只要受理,服务只需1秒钟。”
如果排在前面的请求数能用一只手数清,客户端当然会对服务器端感到满意。但只要超过这个数,客户端就会开始抱怨。还不如用下面这种方式提供服务。
“所有连接请求的受理时间不超过1秒,但平均服务时间为2~3秒。”
大家无需过多考虑到底哪种服务器端好一些,只需假设收看网络视频课程,而且其顺序是第100位,就能得出结论。因此,接下来讨论如何提高客户端满意度平均标准。

2.并发服务器端的实现方法

即使有可能延长服务时间,也有必要改进服务器端,使其同时向所有发起请求的客户端提供服务,以提高平均满意度。而且,网络程序中数据通信时间比CPU运算时间占比更大,因此,向多个客户端提供服务是一种有效利用CPU的方式。接下来讨论同时向多个客户端提供服务的并发服务器端。下面列出的是具有代表性的并发服务器端实现模型和方法。
■ 多进程服务器:通过创建多个进程提供服务。
■ 多路复用服务器:通过捆绑并统一管理I/O对象提供服务。
■ 多线程服务器:通过生成与客户端等量的线程提供服务。
先讲解第一种方法:多进程服务器。这种方法不适合在Windows平台下(Windows不支持)讲解,因此将重点放在Linux平台。如果各位不太关心基于Linux的实现,可以看之后的文章。不过还是希望大家尽可能浏览一下,因为内容有助于理解服务器端构建方法。

3.理解进程

接下来了解多进程服务器实现的重点内容一—进程,其定义如下:
“占用内存空间的正在运行的程序”
假如各位从网上下载了LBreakout游戏并安装到硬盘。此时的游戏并非进程,而是程序。因为游戏并未进人运行状态。下面开始运行程序。
此时游戏被加载到主内存并进人运行状态,这时才可称为进程。如果同时运行多个LBreakout程序,则会生成相应数量的进程,也会占用相应进程数的内存空间。
再举个例子。假设各位需要进行文档相关操作,这时应打开文档编辑软件。如果工作的同时还想听音乐,应打开MP3播放器。另外,为了与朋友聊天,再打开MSN软件。此时共创建3个进程。从操作系统的角度看,进程是程序流的基本单位,若创建多个进程,则操作系统将同时运行。
有时一个程序运行过程中也会产生多个进程。接下来要创建的多进程服务器就是其中的代表。编写服务器端前,先了解一下通过程序创建进程的方法。

4.进程ID

讲解创建进程方法前,先简要说明进程ID。无论进程是如何创建的,所有进程都会从操作系统分配到ID。此ID称为“进程ID”,其值为大于2的整数。1要分配给操作系统启动后的(用于协助操作系统)首个进程,因此用户进程无法得到ID值1。接下来观察Linux中正在运行的进程。
大家知道进程号0是什么进程吗? – init
在这里插入图片描述
可以看出,通过ps指令可以查看当前运行的所有进程。特别需要注意的是,该命令同时列出了PID(进程ID)。另外,上述示例通过指定a和u参数列出了所有进程详细信息。

4.通过fork函数创建进程

创建进程的方法很多,此处介绍用于创建多进程服务器端的fork函数。

#include <unistd.h>
pid_t fork(void);
// 成功时返回进程ID,失败时返回-1。

fork函数将创建调用的进程副本(概念上略难)。也就是说,并非根据完全不同的程序创建进程,而是复制正在运行的、调用fork函数的进程。另外,两个进程都将执行fork函数调用后的语句(准确地说是在fork函数返回后)。但因为通过同一个进程、复制相同的内存空间,之后的程序流要根据fork函数的返回值加以区分。即利用fork函数的如下特点区分程序执行流程。
大家可能疑惑为什么叫做fork,可以去维基上看看,这个单词最开始在古英语是什么意思…
■ 父进程:fork函数返回子进程ID。
■ 子进程:fork函数返回0。
此处“父进程”(ParentProcess)指原进程,即调用fork函数的主体,而“子进程”(Child Process)是通过父进程调用fork函数复制出的进程。接下来讲解调用fork函数后的程序运行流程。
在这里插入图片描述
从图中可以看到,父进程调用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()
{
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)
printf("Child proc: [%d,%d] \n", gval, lval);
else
printf("Parent proc: [%d, %d] \n", gval, lval);
return  0;
}

第11行:创建子进程。父进程的pid中存有子进程的ID,子进程的pid是0。
第12、18行:子进程执行这2行代码,因为pid为0。
第15、20行:父进程执行这2行代码,因为此时pid中存有子进程ID。
在这里插入图片描述
从运行结果可以看出,调用fork函数后,父子进程拥有完全独立的内存结构。

二、进程和僵尸进程

文件操作中,关闭文件和打开文件同等重要。同样,进程销毁也和进程创建同等重要。如果未认真对待进程销毁,它们将变成僵尸进程困扰各位。大家可能觉得这是在看玩笑,但事实的确如此。

1.僵尸进程

大家应该听说过恐怖电影中的僵尸,给主人公造成极大的麻烦。一两只还好对付,但它们一般都成群结队出现,给观众一种刺激和紧张感。但我们的主人公通常神通广大,即使几百只僵尸同时出现也能顺利脱险(僵尸通常行动缓慢),因为主人公知道如何对付僵尸。结局就是所有僵尸都会走向灭亡。
进程的世界同样如此。进程完成工作后(执行完main函数中的程序后)应被销毁,但有时这些进程将变成僵尸进程,占用系统中的重要资源。这种状态下的进程称作“僵尸进程”,这也是给系统带来负担的原因之一。就像电影中描述的那样,我们应该消灭这种进程。当然应掌握正确的方法,否则它会死灰复燃。

2.产生僵尸进程的原因

为了防止僵尸进程的产生,先解释产生僵尸进程的原因。利用如下两个示例展示调用fork函
数产生子进程的终止方式。
■ 传递参数并调用exit函数。
■ main函数中执行return语句并返回值。
向exit函数传递的参数值和main函数的return语句返回的值都会传递给操作系统。而操作系统不会销毁子进程,直到把这些值传递给产生该子进程的父进程。处在这种状态下的进程就是僵尸进程。也就是说,将子进程变成僵尸进程的正是操作系统。既然如此,此僵尸进程何时被销毁呢?
其实已经给出提示。
“应该向创建子进程的父进程传递子进程的exit参数值或return语句的返回值。”
如何向父进程传递这些值呢?操作系统不会主动把这些值传递给父进程。只有父进程主动发起请求(函数调用)时,操作系统才会传递该值。换言之,如果父进程未主动要求获得子进程的结束状态值,操作系统将一直保存,并让子进程长时间处于僵尸进程状态。也就是说,父母要负责收回自已生的孩子(也许这种描述有些不妥)。接下来的例子将创建僵尸进程。

#include <stdio.h>
#include <unistd.h>int main()
{pid_t pid=fork();if(pid==0) // IF CHILD PROCESS{puts("HI, I AM A CHILD PROCESS");}else{printf("CHILD PROCESS ID: %d \n", pid);sleep(30); // SLEEP 30 SEC.}if(pid==0)puts("End child process");elseputs("End parent process");return 0;
}

第14行:输出子进程ID。可以通过该值查看子进程状态(是否为僵尸进程)。
第15行:父进程暂停30秒。如果父进程终止,处于僵尸状态的子进程将同时销毁。因此,延缓父进程的执行以验证僵尸进程。
在这里插入图片描述
程序开始运行后,将在如上所示状态暂停。跳出这种状态前(30秒内),我们验证子进程是否为僵尸进程。验证在其他控制台窗口进行哦。
可以看出,PID为2639的进程状态为僵尸进程(Z+)。另外,经过30秒的等待时间后,PID为2638的父进程和之前的僵尸子进程同时销毁。

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

为了销毁子进程,父进程应主动请求获取子进程的返回值。接下来讨论发起请求的具体方法(幸好非常简单),共有2种,其中之一就是调用如下函数:

#include <sys/wait.h>
pid_t wait(int * statloc);
// 成功时返回终止的子进程ID,失败时返回-1。

调用此函数时如果已有子进程终止,那么子进程终止时传递的返回值(exit函数的参数值、main函数的retun返回值)将保存到该函数的参数所指内存空间。但函数参数指向的单元中还包含其他信息,因此需要通过下列宏进行分离。
■ WIFEXITED子进程正常终止时返回“真”(true)。
■ WEXITSTATUS返回子进程的返回值。
也就是说,向wait函数传递变量status的地址时,调用wait函数后应编写如下代码。

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

根据上述内容编写示例,此示例中不会再让子进程变成僵尸进程。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>int main()
{int status;pid_t pid=fork();if(pid==0){return 3;}else{printf("CHIlD PID: %d \n", pid);pid=fork();if(pid==0){exit(7);}else{printf("Child PID: %d \n", pid);wait(&status);if(WIFEXITED(status))printf("CHILD SEND ONE: %d \n",WEXITSTATUS(status));wait(&status);if(WIFEXITED(status))printf("CHILD SEND TWO: %d \n",WEXITSTATUS(status));sleep(30);// SLEep 30 SEC.}return 0;}
}

第9、13行:第9行创建的子进程将在第13行通过main函数中的return语句终止。
第18、21行:第18行中创建的子进程将在第21行通过调用exit函数终止。
第26行:调用wait函数。之前终止的子进程相关信息将保存到status变量,同时相关子进程被完全销毁。
第27、28行:第27行中通过WIFEXITED宏验证子进程是否正常终止。如果正常退出,则调用WEXITSTATUS宏输出子进程的返回值。
第30~32行:因为之前创建了2个进程,所以再次调用wait函数和宏。
第33行:为暂停父进程终止而插入的代码。此时可以查看子进程的状态。
在这里插入图片描述
系统中并无上述结果中的PID对应的进程,希望各位进行验证。这是因为调用了wait函数,完全销毁了该进程。另外2个子进程终止时返回的3和7传递到了父进程。
这就是通过调用wait函数消灭僵尸进程的方法。调用wait函数时,如果没有已终止的子进程,那么程序将阻塞(Blocking)直到有子进程终止,因此需谨慎调用该函数。

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

wait函数会引起程序阻塞,还可以考虑调用waitpid函数。这是防止僵尸进程的第二种方法,也是防止阻塞的方法。

#include <sys/wait.h>
pid_t waitpid(pid_t pid, int * statloc, int options);
// 成功时返回终止的子进程ID(或θ),失败时返回-1。
// pid -- 等待终止的目标子进程的ID,若传递-1,则与wait函数相同,可以等待任意子进程终止。
// Statloc -- 与wait函数的statloc参数具有相同含义。
// options -- 传递头文件sys/wait.h中声明的常量WNOHANG,即使没有终止的子进程也不会进入阻塞状态,而是返回0并退出函数。

下面介绍调用上述函数。调用waitpid函数时,程序不会阻塞。大家应该重点观察这点。

第12行:调用sleep函数推迟子进程的执行。这会导致程序延迟15秒。
第17行:while循环中调用waitpid函数。向第三个参数传递WNOHANG,因此,若之前没有终止的子进程将返回0。

代码如下(示例):

#include <sys/wait.h>
pid_t waitpid(pid_t pid, int * statloc, int options);
// 成功时返回终止的子进程ID(或θ),失败时返回-1。
// pid -- 等待终止的目标子进程的ID,若传递-1,则与wait函数相同,可以等待任意子进程终止。
// Statloc -- 与wait函数的statloc参数具有相同含义。
// options -- 传递头文件sys/wait.h中声明的常量WNOHANG,即使没有终止的子进程也不会进入阻塞状态,而是返回0并退出函数。
#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(15);return 24;}else{while(!waitpid(-1, &status,WNOHANG)){sleep(3);puts("sleep 3sec.");}if(WIFEXITED(status))printf("Child send %d \n", WEXITSTATUS(status));}return 0;
}

在这里插入图片描述
可以看出第20行共执行了5次。另外,这也证明waitpid函数并未阻塞。

三、信号处理

我们已经知道了进程创建及销毁方法,但还有一个问题没解决。
“子进程究竟何时终止?调用waitpid函数后要无休止地等待吗?”
父进程往往与子进程一样繁忙,因此不能只调用waitpid函数以等待子进程终止。接下来讨论解决方案。

1.向操作系统求助

子进程终止的识别主体是操作系统,因此,若操作系统能把如下信息告诉正忙于工作的父进程,将有助于构建高效的程序。
“嘿,父进程!你创建的子进程终止了!”
此时父进程将暂时放下工作,处理子进程终止相关事宜。这是不是既合理又很酷的想法呢?为了实现该想法,我们引人信号处理(SignalHandling)机制。此处的“信号”是在特定事件发生时由操作系统向进程发送的消息。另外,为了响应该消息,执行与消息相关的自定义操作的过程称为“处理”或“信号处理”。关于这两点稍后将再次说明,各位现在不用完全理解这些概念。

2.信号与signal函数

下列进程和操作系统间的对话是帮助大家理解信号处理而编写的,其中包含了所有信号处理相关内容。
■ 进程:“嘿,操作系统!如果我之前创建的子进程终止,就帮我调用zombie_handler函数。”
■ 操作系统:“好的!如果你的子进程终止,我会帮你调用zombie_handler函数,你先把该函数要执行的语句编好!”
上述对话中进程所讲的相当于“注册信号”过程,即进程发现自已的子进程结束时,请求操作系统调用特定函数。该请求通过如下函数调用完成(因此称此函数为信号注册函数)

#include <signal.h>
void (*signal(int signo, void (*func)(int)))(int);
// 为了在产生信号时调用,返回之前注册的函数指针。

上述函数的返回值类型为函数指针,因此函数声明有些繁琐。若各位不太熟悉返回值类型为函数指针的声明,希望加强学习。现在为了便于讲解,我将上述函数声明整理如下。
函数名:signal
■ 参数:int signo, void (* func)(int)
■ 返回类型:参数为int型,返回void型函数指针。
调用上述函数时,第一个参数为特殊情况信息,第二个参数为特殊情况下将要调用的函数的地址值(指针)。发生第一个参数代表的情况时,调用第二个参数所指的函数。下面给出可以在signal函数中注册的部分特殊情况和对应的常数。
■ SIGALRM:已到通过调用alarm函数注册的时间。
■ SIGINT:输人CTRL+C。
■ SIGCHLD:子进程终止。
接下来编写调用signal函数的语句完成如下请求:
“子进程终止则调用mychild函数。”
此时mychild函数的参数应为int,返回值类型应为void。只有这样才能成为signal函数的第二个参数。另外,常数SIGCHLD定义了子进程终止的情况,应成为signal函数的第一个参数。也就是说,signal函数调用语句如下。
signal(SIGCHLD, mychild);
接下来编写signal函数的调用语句,分别完成如下2个请求。
“已到通过alarm函数注册的时间,请调用timeout函数。”
“输入CTRL+C时调用keycontrol函数。”
代表这2种情况的常数分别为SIGALRM和SIGINT,因此按如下方式调用signal函数。
signal(SIGALRM, timeout);
signal(SIGINT, keycontrol);
以上就是信号注册过程。注册好信号后,发生注册信号时(注册的情况发生时),操作系统将调用该信号对应的函数。下面通过示例验证,先介绍alarm函数。

#include <unistd.h>
unsigned int alaRm(unsigned int seconds);
// 返回0或以秒为单位的距SIGALRM信号发生所剩时间。

如果调用该函数的同时向它传递一个正整型参数,相应时间后(以秒为单位)将产生SIGALRM信号。若向该函数传递0,则之前对SIGALRM信号的预约将取消。如果通过该函数预k约信号后未指定该信号对应的处理函数,则(通过调用signal函数)终止进程,不做任何处理。希望引起注意。
接下来给出信号处理相关示例,希望各位通过该示例彻底掌握之前的内容。

#include <stdio.h>
#include <unistd.h>
#include <signal.h>void timeout(int sig)
{if(sig==SIGALRM)puts("TIME OUT!");alarm(2);
}
void keycontrol(int sig)
{
if(sig==SIGINT)
puts("CTRL+C pressed");
}int main()
{int i;signal(SIGALRM, timeout);signal(SIGINT, keycontrol);alarm(2);for(i=0; i<3; i++){puts("wait..");sleep(100);}return 0;
}

第5、11行:分别定义信号处理函数。这种类型的函数称为信号处理器(Handler)。
第9行:为了每隔2秒重复产生SIGALRM信号,在信号处理器中调用alarm函数。
第20、21行:注册SIGALRM、SIGINT信号及相应处理器。
第22行:预约2秒后发生SIGALRM信号。
第27行:为了查看信号产生和信号处理器的执行并提供每次100秒、共3次的等待时间,在循环中调用sleep函数。也就是说,再过300秒、约5分钟后终止程序,这是相当长的一段时间,但实际执行时只需不到10秒。关于其原因稍后再解释。
在这里插入图片描述
上述是没有任何输人时的运行结果。下面在运行过程中输人CTRL+C。可以看到输出
“CTRL+Cpressed”字符串。有一点必须说明:
“发生信号时将唤醒由于调用sleep函数而进入阻塞状态的进程。”
调用函数的主体的确是操作系统,但进程处于睡眠状态时无法调用函数。因此,产生信号时,为了调用信号处理器,将唤醒由于调用sleep函数而进入阻塞状态的进程。而且,进程一旦被唤醒,就不会再进人睡眠状态。即使还未到sleep函数中规定的时间也是如此。所以,上述示例运行不到10秒就会结束,连续输人CTRL+C则有可能1秒都不到。

3.利用sigaction函数进行信号处理

前面所学的内容足以用来编写防止僵尸进程生成的代码。但我还想介绍sigaction函数,它类似于signal函数,而且完全可以代替后者,也更稳定。之所以稳定,是因为如下原因:
‘signal函数在UNIX系列的不同操作系统中可能存在区别,但sigaction函数完全
相同。”
实际上现在很少使用signal函数编写程序,它只是为了保持对旧程序的兼容。下面介绍sigaction函数,但只讲解可替换signal函数的功能,因为全面介绍会给大家带来不必要的负担。

#include <signal.h>
int sigaction(int signo, const struct sigaction * act, struct sigaction *
oldact);
// 成功时返回0,失败时返回-1。
// signo与signal函数相同,传递信号信息。
// act -- 对应于第一个参数的信号处理函数(信号处理器)信息。
// oldact -- 通过此参数获取之前注册的信号处理函数指针,若不需要则传递0。

声明并初始化sigaction结构体变量以调用上述函数,该结构体定义如下。

struct sigaction
{void (*sa_handler)(int);sigset_t sa_mask;int sa_flags;
}

这个结构体的sa_handler成员保存信号处理函数的指针值(地址值)。sa_mask和sa_flags的所有位均初始化为0即可。这2个成员用于指定信号相关的选项和特性,而我们的目的主要是防止产生僵尸进程,就省略了。
下面的示例,其中还包括了尚未讲解的使用sigaction函数所需全部内容:

#include <stdio.h>
#include <unistd.h>
#include <signal.h>void timeout(int sig)
{if(sig==SIGALRM)puts("TIME OUT! ");alarm(2);
}int main()
{
int i;
struct sigaction act;
act.sa_handler=timeout;
sigemptyset(&act.sa_mask);
act.sa_flags=0;
sigaction(SIGALRM,&act,0);alarm(2);for(i=0; i<3; i++)
{puts("wait...");sleep(100);
}
return 0;
}

第15、16行:为了注册信号处理函数,声明sigaction结构体变量并在sa_handler成员中保存函数指针值。
第17行:调用sigemptyset函数将sa_mask成员的所有位初始化为0。
第18行:sa_flags成员同样初始化为0。
第19、21行:注册SIGALRM信号的处理器。调用alarm函数预约2秒后发生SIGALRM信号。
在这里插入图片描述
这就是信号处理相关理论,以此为基础讨论消灭僵尸进程的方法。

4.利用信号处理技术消灭僵尸进程

我相信大家也可以独立编写消灭僵尸进程的示例。子进程终止时将产生SIGCHLD信号,知道这一点就很容易完成。接下来利用sigaction函数编写示例。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>void read_childproc(int sig)
{int status;pid_t id=waitpid(-1, &status,WNOHANG);if(WIFEXITED(status)){printf("REMOVeD PROC ID: %d \n", id);printf("CHILD SEND: %d \n",WEXITSTATUS(status));}
}int main(int argc, char *argv[])
{pid_t pid;struct sigaction act;act.sa_handler=read_childproc;sigemptyset(&act.sa_mask);act.sa_flags=0;sigaction(SIGCHLD, &act, 0);pid=fork();if(pid==0)/*子进程执行区域*/{puts("Hi! I'm child process");sleep(10);return 12;}else/*父进程执行区域*/{printf("CHILD PROC ID: %d \n", pid);pid=fork();if(pid==0)/*另一子进程执行区域*/{puts("HI! I'M CHILD PROCESS");sleep(10);exit(24);}else{int i;printf("CHILD PROC ID: %d \n", pid);for(i=0; i<5; i++){puts("wait... ");sleep(5);}}
}return 0;
}

第21~25行:注册SIGCHLD信号对应的处理器。若子进程终止,则调用第7行中定义的函数。处理函数中调用了waitpid函数,所以子进程将正常终止,不会成为僵尸进程。
第27、37行:父进程共创建了2个子进程。
第48、51行:为了等待发生SIGCHLD信号,使父进程共暂停5次,每次间隔5秒。发生信
号时,父进程将被唤醒,因此实际暂停时间不到25秒。
在这里插入图片描述
可以看出,子进程并未变成僵尸进程,而是正常终止了。接下来利用进程相关知识编写服务器端。

四、基于多任务的并发服务器

我们已做好了利用fork函数编写并发服务器的准备,现在可以开始编写像样的服务器端了哦。

1.基于进程的并发服务器模型

之前的回声服务器端每次只能向1个客户端提供服务。因此,我们将扩展回声服务器端,使其可以同时向多个客户端提供服务。
在这里插入图片描述
从图1可以看出,每当有客户端请求服务(连接请求)时,回声服务器端都创建子进程以提供服务。请求服务的客户端若有5个,则将创建5个子进程提供服务。为了完成这些任务,需要经过如下过程,这是与之前的回声服务器端的区别所在。
■ 第一阶段:回声服务器端(父进程)通过调用accept函数受理连接请求。
■ 第二阶段:此时获取的套接字文件描述符创建并传递给子进程。
■ 第三阶段:子进程利用传递来的文件描述符提供服务。
此处容易引起困惑的是向子进程传递套接字文件描述符的方法。但各位读完代码后会发现,这其实没什么大不了的,因为子进程会复制父进程拥有的所有资源。实际上根本不用另外经过传递文件描述符的过程。

2.实现并发服务器

虽然我已经给出了所有理论说明,但大家也许还没想出具体的实现方法,这就有必要理解具体代码。下面给出并发回声服务器端的实现代码。当然,程序是基于多进程实现的,可以结合第4章节的回声客户端运行。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <arpa/inet.h>
#include <sys/socket.h>#define BUF_SIZE 30
void error_handling(char *message);
void read_childproc(int sig);int main(int argc, char *argv[])
{int serv_sock, clnt_sock;struct sockaddr_in serv_adr, clnt_adr;pid_t pid;struct sigaction act;socklen_t adr_sz;int str_len, state;char buf[BUF_SIZE];if(argc!=2) {printf("Usage : %s <port>\n",argv[0]);exit(1);}act.sa_handler=read_childproc;sigemptyset(&act.sa_mask);act.sa_flags=0;state=sigaction(SIGCHLD,&act, 0);serv_sock=socket(PF_INET,SOCK_STREAM, 0);memset(&serv_adr, 0, sizeof(serv_adr));serv_adr.sin_family=AF_INET;serv_adr.sin_addr.s_addr=htonl(INADDR_ANY);serv_adr.sin_port=htons(atoi(argv[1]));if(bind(serv_sock, (struct sockaddr*) &serv_adr, sizeof(serv_adr))==-1)error_handling("bind() error");if(listen(serv_sock, 5)==-1)error_handling("listen() error");while(1){adr_sz=sizeof(clnt_adr);clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);if(clnt_sock==-1)continue;elseputs("new client connected...");pid=fork();if(pid==-1){close(clnt_sock);continue;}if(pid==0)/*子进程运行区域*/{close(serv_sock);while((str_len=read(clnt_sock, buf, BUF_SIZE))!=0)write(clnt_sock, buf, str_len);close(clnt_sock);puts("client disconnected...");return 0;}elseclose(clnt_sock);}close(serv_sock);return 0;
}void read_childproc(int sig)
{pid_t pid;int status;pid=waitpid(-1,&status,WNOHANG);printf("Removed proc id: %d \n", pid);
}void error_handling(char * message)
{fputs(message, stderr);fputc('\n', stderr);exit(1);
}

第29~32行:为防止产生僵尸进程而编写的代码。
第47、52行:第47行调用accept函数后,在第52行调用fork函数。因此,父子进程分别带有1个第47行生成的套接字(受理客户端连接请求时创建的)文件描述符。
第58~66行:子进程运行的区域。此部分向客户端提供回声服务。第60行关闭第33行创建的服务器套接字,这是因为服务器套接字文件描述符同样也传递到子进程。关于这一点稍后将单独讨论。
第69行:第47行中通过accept函数创建的套接字文件描述符已复制给子进程,因此服务器端需要销毁自己拥有的文件描述符。关于这一点稍后将单独说明。

启动服务器端后,要创建多个客户端并建立连接。可以验证服务器端同时向大多数客户端提供服务,不,一定要验证这一点。

3.通过fork函数复制文件描述符

示例echo_mpserv.c中给出了通过fork函数复制文件描述符的过程。父进程将2个套接字(一个是服务器端套接字,另一个是与客户端连接的套接字)文件描述符复制给子进程。
“只复制文件描述符吗?是否也复制了套接字呢?”
文件描述符的实际复制多少有些难以理解。调用fork函数时复制父进程的所有资源,有些人可能认为也会同时复制套接字。但套接字并非进程所有–从严格意义上说,套接字属于操作系统——只是进程拥有代表相应套接字的文件描述符。也不一定非要这样理解,仅因为如下原因,复制套接字也并不合理。
“复制套接字后,同一端口将对应多个套接字。”
示例echo_mpserv.c中的fork函数调用过程如图。调用fork函数后,2个文件描述符指向同一套接字。
在这里插入图片描述
如图,1个套接字中存在2个文件描述符时,只有2个文件描述符都终止(销毁)后,才能销毁套接字。如果维持图中的连接状态,即使子进程销毁了与客户端连接的套接字文件描述符,也无法完全销毁套接字(服务器端套接字同样如此)。因此,调用fork函数后,要将无关的套接字文件描述符关掉,如下图:
在这里插入图片描述
为了将文件描述符整理成上面图的形式,示例echo_mpserv.c的第60行和第69行调用了close函数。

四、分割TCP的 I/O程序

各位应该已经理解fork函数相关的所有有用内容。下面以此为基础,再讨论客户端中分割I/O程序(Routine)的方法。内容非常简单,大家不必有负担哦。

1.分割1/O程序的优点

我们已实现的回声客户端的数据回声方式如下:
“向服务器端传输数据,并等待服务器端回复。无条件等待,直到接收完服务器端的回声数据后,才能传输下一批数据。”
传输数据后需要等待服务器端返回的数据,因为程序代码中重复调用了read和write函数。只能这么写的原因之一是,程序在1个进程中运行。但现在可以创建多个进程,因此可以分割数据收发过程。默认的分割模型如图。
从图中可以看出,客户端的父进程负责接收数据,额外创建的子进程负责发送数据。分割后,不同进程分别负责输人和输出,这样,无论客户端是否从服务器端接收完数据都可以进行传输。
在这里插入图片描述
选择这种实现方式的原因有很多,但最重要的一点是,程序的实现更加简单。也许有人质疑:既然多产生1个进程,怎么能简化程序实现呢?其实,按照这种实现方式,父进程中只需编写接收数据的代码,子进程中只需编写发送数据的代码,所以会简化。实际上,在1个进程内同时实现数据收发逻辑需要考虑更多细节。程序越复杂,这种区别越明显,它也是公认的优点。
分割I/O程序的另一个好处是,可以提高频繁交换数据的程序性能,如图:
在这里插入图片描述
图1左侧演示的是之前的回声客户端数据交换方式,右侧演示的是分割I/O后的客户端数据传输方式。服务器端相同,不同的是客户端区域。分割I/O后的客户端发送数据时不必考虑接收数据的情况,因此可以连续发送数据,由此提高同一时间内传输的数据量。这种差异在网速较慢时尤为明显。

2.回声客户端的I1/O程序分割

我们已经知道I/O程序分割的意义,接下来通过实际代码进行实现,分割的对象是回声客户端。下列回声客户端可以结合之前的回声服务器端echo_mpserv.c运行。

struct sigaction
{#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>#define BUF_SIZE 30
void error_handling(char *message);
void read_routine(int sock, char *buf);
void write_routine(int sock, char *buf);int main(int argc, char *argv[])
{int sock;pid_t pid;char buf[BUF_SIZE];struct sockaddr_in serv_adr;if(argc!=3) {printf("Usage : %s <IP> <port>\n", argv[0]);exit(1);}sock=socket(PF_INET,SOCK_STREAM, 0);memset(&serv_adr, 0, sizeof(serv_adr));serv_adr.sin_family=AF_INET;serv_adr.sin_addr.s_addr=inet_addr(argv[1]);serv_adr.sin_port=htons(atoi(argv[2]));if(connect(sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr))==-1)error_handling("connect() error!");pid=fork();if(pid==0)write_routine(sock, buf);elseread_routine(sock, buf);close(sock);return 0;
}void read_routine(int sock, char *buf)
{while(1){int str_len=read(sock, buf,BUF_SIZE);if(str_len==0)return;buf[str_len]=0;printf("Message from server: %s",buf);}
}
void write_routine(int sock, char *buf)
{while(1){fgets(buf, BUF_SIZE,stdin);if(!strcmp(buf, "q\n")||!strcmp(buf, "Q\n")){shutdown(sock,SHUT_WR);return;}write(sock, buf, strlen(buf));}
}
void error_handling(char * message)
{fputs(message, stderr);fputc('\n', stderr);exit(1);
}
}

第34~37行:第35行调用的write_routine函数中只有数据输出相关代码,第37行调用的read_routine函数中只有数据输入相关代码。像这样分割I/O并分别在不同函数中定义,将有利于代码实现。
第62行:调用shutdown函数向服务器端传递EOF。当然,执行第63行的return语句后,可以调用第39行的close函数传递EOF。但现在已通过第33行的fork函数调用复制了文件描述符,此时无法通过1次close函数调用传递EOF,因此需要通过shutdown函数调用另外传递。
运行结果跟普通回声服务器端/客户端相同,故省略。只是上述示例分割了1/O,为了简化输出过程,与之前示例不同,不会输出如下字符串:
"Input message(Q to quit): "
无论是否接收消息,每次通过键盘输人字符串时都会输出上述字符串,可能造成输出混乱。基于多任务的服务器端实现方法讲解到此结束。


总结

这章节的内容不算少,大家可要好好吸收下…

相关文章:

  • 做外贸的网站有何用处东莞seo排名公司
  • 自己做app的网站北京网站推广排名外包
  • 重庆市门户网站制作百度推广客服电话
  • 青之峰做网站宁德市中医院
  • 合肥行业网站建设上海网络推广外包公司
  • 网站咨询弹窗怎么做外贸推广具体是做什么
  • 复制 生成二维码
  • 麒麟V10操作系统离线安装Docker、Docker compose和1Panel
  • 鸿蒙 Stack 组件深度解析:层叠布局的核心应用与实战技巧
  • 6.24_JAVA_微服务_Elasticsearch搜索
  • 用Rust写平衡三进制加法器
  • 华为云Flexus+DeepSeek征文|基于华为云Flexus Dify复用优秀 AI Agent 应用教程
  • TMS汽车热管理系统HILRCP解决方案
  • FastMCP+python简单测试
  • Jenkins+Jmeter+Ant接口持续集成
  • 信创建设,如何统一管理异构服务器的认证、密码、权限管理等?
  • 配置自己的NTP 服务器做时间同步
  • 从零学习linux(2)——管理
  • 缺少 XML 验证与资源注入修复
  • Revisiting Image Deblurring with an Efficient ConvNet论文阅读
  • Joblib库多进程/线程使用(一):使用generator参数实现边响应边使用
  • leetcode61.旋转链表
  • 物流业最后的“人工堡垒”即将失守?机器人正式接管卡车装卸工作
  • java数据类型详解篇
  • 【机器学习深度学习】机器学习核心概念图谱:样本、目标函数、损失函数、特征及训练
  • 【源码】Reactive 源码