一般而言,网络程序中数据通信时间比CPU运算时间占比更大,因此同时多个客户端提供服务是一种有效利用CPU的方式。
实现并发服务器端的模型和方法:
- 多进程服务端:通过创建多个进程提供服务;
- 多路复用服务器:通过捆绑并同一管理I/O对象提供服务;
- 多线程服务器:通过生成与客户端等量的线程提供服务;
注意:windows平台不持支多进程服务器;
进程:占用内存空间的正在运行的程序;
CPU核的个数与进程数:
拥有2个运算设备的CPU称为双核CPU,4个则为4核CPU。核的个数与可同时运行的进程数相同;如果进程数超过核数,进程将分时使用CPU资源。
所有进程都会从操作系统分配到ID,值均为大于2的整数。1要分配给操作系统后的首个进行(协助操作系统)。
#include <unistd.h>
pid_t fork(void);
-
成功返回进程ID,失败返回-1;
-
fork函数将创建调用的进程副本---复制正在运行的、调用fork函数的进程;此外,两个进程都将执行fork函数调用后的语句。
-
因为通过同一个进程、复制相同的内存空间,之后的进程流要通过fork函数的返回之进行区分。
- 父进程:fork函数返回子进程ID;
- 子进程:fork函数返回0;
- 父进程和子进程其实只是共享一段代码,并不会影响另一个进程的运行!
进程完成工作后应该被销毁,但有时这些进程将变成僵尸进程,占用系统中的重要资源。这总状态下的进程称为僵尸进程。
两种产生僵尸进程的原因:
- 传递参数并调用exit函数;
- main函数中执行return并返回值;
向exit函数传递的参数值和main函数和return语句返回值都会被传递给操作系统;但是操作系统不会销毁子进程,直到把这些值传递给产生该子进程的父进程。处于这种状态下的进程就是僵尸进程。
如何销毁僵尸进程呢?
- 应该向创建子进程的父进程传递子进程的exit参数值或return语句返回值;
那么如何向父进程传递这些值呢?
- 操作系统不会主动将这些值传递给父进程,只有父进程主动发起请求,操作系统才会传递该值;
- 也就是说父进程要负责回收子进程;
后台处理(background processing):
指将控制台窗口中的指令放到后台运行的方式;
如对于可执行文件zombie,让其在后台运行(&将出发后台处理):
[root@localhost C++]# ./zombie&
如果采用这种方式运行程序,可在同一控制台输入以下命令,而无需打开新控制台:
[root@localhost C++]# ps au
#include <sys/wait.h>
pid_t wait(int *statloc);
-
成功返回终止的子进程的ID,失败返回-1;
-
如果调用此函数时,如果已有子进程终止,那么子进程终止时传递的返回值将保存到该函数的参数所指的内存空间。但函数参数指向的单元还含有其他信息,可以通过下列宏进行分离:
-
WIFEXITED子进程正常终止返回”true“;
-
WEXITSTATUS返回子进程的返回值;
-
if (WIFEXITED(statloc)) { std::cout << "Normal termination!\n"; std::cout << "Child pass num: " << WEXITSTATUS(statloc) << '\n'; }
-
代码见Code/wait.cpp;
注意:调用wait函数时,如果没有已经终止的子进程,那么程序将阻塞直到有子进程终止!
wait函数会引起程序阻塞,因此可以考虑waitpid,这种方式也是防止阻塞的方法。
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *staloc, int options);
- pid:等待终止的目标子进程ID,若传递-1则同wait函数,可以等待任意子进程终止;
- statloc:同wait函数的staloc参数意义相同;
- options:传递头文件< sys/wait.h >中声明的常量WNOHANG,即使没有终止的子进程也不会进入阻塞状态,而实返回0并退出函数;
代码见Code/waitpid.cpp
上面解决了创建进程和终止进程,但是子进程究竟什么时候终止呢?一定要调用waitpid函数后无休止的等待吗?
- 因为父进程、子进程都很繁忙,因此不能只调用waitpid函数来等待子进程终止;
子进程终止的识别主体是操作系统,如果操作系统能把子进程终止的信息告诉父进程,此时父进程来处理进程终止的相关事项;为此引入信号处理机制;
#include <signal.h>
void (*signal(int signo, void (*func)(int)))(int);
- 返回值类型为函数指针;
- 函数名:signal;
- 参数:int signal, void(* func)(int);
- 返回类型:参数为int、返回void型函数指针;
- 调用时:第一个参数为特殊情况信息,第二个参数为特殊情况下将要调用的函数指针;发生第一个参数代表的情况时,调用第二个参数所指的函数。下面给出几个可以在signal函数中注册的部分特殊情况和对应的常数:
- SIGALRM:已经通过调用alarm函数注册的时间;
- SIGINT:输入CTRL + C;
- SIGCHLD:子进程终止;
实例:子进程终止调用mychild函数;
signal(SIGCHLD, mychild);
- mychild参数为int,返回值为void类型;
alarm函数:
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
- 返回0或以秒为单位的剧SIGALRM信号发生所剩的时间;
- 如果调用时传递一个正整型参数,相应时间(秒为单位)后将产生SIGALRM信号;传递0则之前对SIGALRM信号的预约将取消;
- 如果通过该函数预约信号后未指定该信号的对应处理函数,则终止进程,不做处理;
见Code/signal.cpp;
执行该程序时,输入CTRL + C,可以看到对应的输出:发生信号时,将唤醒由于调用sleep函数而进入阻塞状态的进程,并且一旦进程被唤醒,就不会再进入睡眠状态;
signal函数足以编写防止僵尸进程生成的代码;但同时sigaction完全可以替代signal函数,而且完全可以代替后者,也更稳定:
-
signal函数在Unix系列的不同操作系统中可能存在区别,但sigaction函数完全相同;
-
目前而言,signal函数仅仅用来保持对就程序的兼容;
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oldact);
struct sigaction {
void (*sa_handler)(int);
sigset_t sa_mask;
int sa_flags;
}
-
signo:与signal函数相同,传递信号信息;
-
act:对于第一个参数的信号处理信息;
-
oldact:通过此参数获取之前注册的信号处理函数指针,若不需要则传递0;
-
sa_handler:保存信号处理函数的指针值;
-
sa_mask和sa_flags的所有位均初始化为0即可;这两个成员用于指定信号相关的选项和特性,因为目的主要是防止产生僵尸进程,因此可省略;
struct sigaction act;
act.sa_handler = timeout; // sa_handler似乎是一个宏
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
见Code/sigaction.cpp;
我们以扩展回声服务器为例:
-
每当有客户端请求服务时,回声服务器端都创建子进程以提供服务;
-
为了完成这些任务,将会经过如下过程:
- 第一阶段:回声服务器端(父进程)通过调用accept函数受理连接请求;
- 第二阶段:此时获取的套接字文件描述符创建并传递给子进程;
- 第三阶段:子进程利用传递而来的文件描述符提供服务;
-
注意:因为子进程会复制父进程拥有的所有资源,实际上不用另外经过传递文件描述符的过程;
实例:Code/echo_mpserv.cpp;
如上的echo_mpserv.cpp中,父进程将2个套接字(服务端套接字、客户端连接的套接字)文件描述符复制给子进程;
但是是否也复制了套接字呢?
fork函数会复制父进程的所有资源,但是套接字属于底层操作系统的资源,不属于进程的!因此并不会复制套接字;而且如果能,那么将会出现一个端口同时对应多个套接字;
- 如上,一个套接字存在两个文件描述符,那么需要等两个文件描述符都终止后,才能销毁套接字;这也是为什么父进程、子进程都必须有自己的close(serv_sock);;
回声客户端的数据回声方式:
- 向服务端发送数据,等待服务器端回复。无条件等待,直到接收完服务器端的回声数据后,才能传输下一批数据。
现在可以创建多个进程,因此可以分割数据的收发过程,默认分割模型如下:
- 选择这种方式的原因之一:实现更加简单、提供频繁交换数据的程序性能;
- 这种方式:父进程只要编写接收数据的代码、子进程只要编写发送数据的代码;
- 右边是进行分割I/O的方式:客户端不用等待服务端发过来的数据被完全接收后,就可直接发送另一个数据;
实例见Code/echo_mpclient.cpp;