既然我们要实现多进程服务器,就要考虑这样带来的好处。
如果对于每个客户端,处理器的服务时间为 1s,那么第 50 个客户端需要等待 50s,第 100 个客户端就需要等待 100s。所以,越靠后的客户端就会越不满意于服务器的效率,至此就发展出了并发服务器。
一、并发服务器的实现方法
为了对服务器端进行改进,我们就需要充分利用到计算机。考虑到网络程序中数据通信时间比 CPU 运算时间占比更大,所以向多个客户端提供服务是一种有效利用 CPU 的方式。
下面列出的就是几种具有代表性的并发服务器端实现模型和方法:
- 多进程服务器:通过创建多个进程提供服务
- 多路复用服务器:通过捆绑并统一管理 I/O 对象提供服务
- 多线程服务器:通过生成与客户端等量的线程提供服务
下面是针对一些基础知识的补充:
CPU 的组成
中央处理单元(CPU)是进行运算和逻辑操作的部件,包含:
- 寄存器(只能够存储有限数据)
- 一个高频时钟(clock):对 CPU 内部操作与系统其他组件进行同步
- 一个控制单元(control unit,CU):协调参与机器指令执行的步骤序列
- 一个算术逻辑单元(arithmetic logic unit,ALU):执行算术运算(如加法和减法),以及逻辑运算(如与、或、非)
进程和线程的区别
CPU 核的个数与进程数
拥有 2 个运算设备(ALU)的CPU 称作双核 CPU,拥有 4 个运算器的 CPU 称作 4 核 CPU,而核的个数与可同时运行的进程数相同。但是,若进程数超过核数,进程将分时使用 CPU 资源。但是因为 CPU 运转速度极快,我们会感到所有进程同时运行。当然,核数越多,这种感觉越明显。
二、进程概念
因为 Windows 不支持多进程,所以多进程服务器只能够在 Linux 下进行。
查看进程 ID
我们可以通过如下命令查看进程 ID
ps au
Linux 系统会给创建的进程分配一个 ID,这个 ID 会被称为“进程ID”,其值为大于 2 的整数。1 要分配给操作系统启动后的首个进程(该进程用于协助操作系统),因此用户进程无法得到值为 1 的进程 ID。查看的进程如下所示:
通过指定参数 a 和 u 能够列出所有进程的详细信息。
通过调用 fork 函数创建进程
创建进程的方法很多,这里用于介绍创建多进程服务器端的 fork 函数。
#include <unistd.h>
pid_t fork(void); // 成功后返回进程 ID,失败返回 -1
fork 函数将复制进程,从而产生一个子进程,而 fork 函数便会返回这个子进程的进程 ID。
#include <unistd.h>
#include <stdio.h>
int gval = 10;
int main(void) {
int lval = 25;
pid_t pid = fork(); // pid 为子进程 ID
if (pid == 0) // 子进程
gval+=2, lval+=2;
else // 父进程
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;
}
该函数的输出为:
从上面的运行结果可以看出来,调用了 fork 函数后,父子进程拥有完全独立的内存结构。
三、进程和僵尸进程
文件操作中,关闭文件和打开文件同等重要,同样,进程销毁和进程创建也同等重要。如果未认真对待进程销毁,它们就会变成僵尸进程。
僵尸进程:
进程完成工作后,应该被销毁,但是有些进程就会变成僵尸进程,占用系统中的重要资源。这种状态下的进程称作“僵尸进程”。
产生僵尸进程的原因
为了防止僵尸进程的出现,先来解释僵尸进程产生的原因。对于上述调用的 fork 函数产生子进程,程序执行完成后,需要终止进程,而进程终止的方式有 2 种
- 传递参数并调用 exit 函数
- 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) {
puts("Hi, I'm a child process");
}
else {
printf("Child Process ID: %d \n", pid); // 输出子进程 ID
sleep(20);
// 父进程暂停 20s,如果父进程终止,处于僵尸状态的子进程将同时销毁
// 因此,延缓父进程的执行以验证僵尸进程
}
if (pid == 0) {
puts("End Child Process");
}
else {
puts("End parent process");
}
return 0;
}
上述代码输出为
在 sleep(20)
暂停期间,我们可以在其他窗口查看创建的子进程 ID。
可以看到,PID 为 429712 的进程状态为僵尸进程,经过 20 秒的等待时间后,PID 为 429711 的父进程和僵尸子进程 429712 同时销毁。
销毁僵尸进程方法 1:利用 wait 函数
为了销毁子进程,父进程应该主动请求获取子进程的返回值,让父进程发起请求的方法有 2 种,第一种方法就是 wait 函数。
#include <sys/wait.h>
pid_t wait(int *statloc); // 成功时返回终止的子进程 ID,失败则返回 -1
调用此函数时如果已有子进程终止,那么子进程终止时传递的返回值(exit 函数的参数值、main 函数的 return 返回值)将保存到该函数的参数所指内存空间,但函数参数指向的单元中还包含其他信息,因此需要通过下列宏进行分离。
- WIFEEXITED 子进程正常终止时返回 “真”
- 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 argc, char *argv[]) {
int status;
// 创建子进程1
pid_t pid = fork();
if (pid == 0) {
return 3; // 通过调用 return 终止子进程1
}
else {
printf("Child PID: %d \n", pid);
pid = fork(); // 创建子进程2
if (pid == 0) {
exit(7); // 通过 exit 终止子进程2
}
else {
printf("Child PID: %d \n", pid);
// 调用 wait 函数,之前终止的子进程1相关信息将被保存到 status 变量
// 同时相关子进程被完全销毁
wait(&status);
// 通过 WIFEXITED 宏验证子进程是否正常终止
// 如果正常退出,则调用 WIEXITSTATUS 宏输出子进程1的返回值
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;
}
运行以上程序,输出结果如下
此时,我们在程序 sleep(30)
期间,使用 ps au
查看进程是否被销毁
可以看到系统中并没有上述结果中的 PID 对应的进程,这是因为调用了 wait 函数销毁了子进程 1 和子进程 2,并且取得了子进程的返回值 3 和 7.
上述就是通过 wait 函数销毁僵尸进程的方法。调用 wait 函数时,如果没有已终止的子进程,那么程序将阻塞到直有子进程的时候终止,因此必须谨慎调用该函数。
销毁僵尸进程 2:使用 waitpid 函数
wait 函数会引起程序阻塞,还可以考虑使用 waitpid 函数。这是防止僵尸进程的第二种方法,也是防止阻塞的方法。
#include <sys/wait.h>
// 成功时返回终止的子进程 ID,失败时返回-1
pid_t waitpid(pid_t pid, int *statloc, int options);
上述函数的参数含义如下
- pid:等待终止的目标子进程的 ID,若传递 -1,则与 wait 函数相同,可以等待任意子进程终止。
- statloc:与 wait 函数的 statloc 参数有相同含义,即保存子进程的返回值
- options:传递头文件 sys/wait.h 中声明的常量 WNOHANG,即使没有终止的子进程也不会进入阻塞状态,而是返回 0 并退出函数
下面介绍调用上述函数的示例,调用 waitpid 函数时,程序不会阻塞。
#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); // 调用 sleep 函数推迟子进程的执行,导致程序延迟 15 秒
return 24; // 使用 return 终止子进程
}
else {
// 调用 waitpid 函数,向第三个参数传递 WNOHANG
// 所以如果之前没有终止的子进程则将返回 0
while (!waitpid(-1, &status, WNOHANG)) {
sleep(3);
puts("Sleep 3 seconds.");
}
if (WIFEXITED(status)) {
printf("Child send %d \n", WEXITSTATUS(status));
}
}
return 0;
}
查看上述的运行结果:
可以看到上述父进程一共执行了 5 次 puts("Sleep 3 seconds.")
,从而可以看到 waitpid 函数并未阻塞。
四、利用信号机制,销毁运行完的子进程
给进程定一个闹钟, 结束时发出信号
由于等待进程时间不确定,所以要引入信号机制,给进程一个闹钟,等进程结束的时候闹钟响起(即发起信号),从而销毁进程,防止僵尸化。
我们之前看到了进程的创建以及销毁方法,但还有一个问题没有解决
子进程究竟什么时候终止,调用 waitpid 函数后要无休止等待吗?
父进程往往与子进程一样繁忙,因此不能只调用 waitpid 函数以等待子进程终止。
针对这个问题,我们使用信号处理的方式来解决。
Java 题外话
因为进程或线程是由操作系统提供支持的,所以 Windows 和 Linux 使用不同的方式来创建进程或线程。而我们学习的 C 或 C++ 是不支持跨平台的,所以在这两个平台上的代码没法直接移植,而 Java 由于其虚拟机保持了平台移植性,而它能够以独立于操作系统的方式提供进程和线程的创建方法。
既然如此,Java 网络编程是否相对简单?正如我们学过的,网络编程需要一定的操作系统只是,因此有些人会把网络编程当作系统编程的一部分。基于 Java 进行网络编程,的确会拜托特定的操作系统,所以有人误以为 Java 网络编程相对简单。
如果在语言层面支持网络编程所需的所有机制,将延长学习时间。要通过面向对象的方法编写高性能网络程序,需要更多努力和知识。大家尝试使用 Java 语言网路编程,编写不局限于 Linux 或 Windows 平台的程序。Java 在分布式环境(手机,Linux 电脑,Windows 电脑等各种设备)提供理想的网络编程模型。
利用 sigaction 函数进行信号处理
之前学习的 signal 函数 已经足以编写防止僵尸进程生成的代码,现在用到的 sigaction 函数类似于 signal 函数,并且完全可以代替后者,使之更加稳定。
为什么 sigaction 函数相比于 signal 函数要更加稳定?
signal 函数在 UNIX 系列的不同操作系统中可能存在区别,但 sigaction 函数却是跨平台的,所以其是完全相同的。
signal 函数 sigaction 函数
现在很少用 signal 函数编写程序,现在用到的 signal 函数也多是为了保持对旧程序的兼容。而跨平台的 sigaction 函数并不是能够完全代替 signal 函数,所以下面就是介绍的可替换的部分。
sigaction 函数介绍
函数原型
#include <signal.h>
// sigaction 函数执行成功时返回 0,失败时返回 -1
int sigaction (int signo,
const struct sigaction *act,
struct sigaction *oldact)
函数参数:
- signo:与 signal 函数相同,传递信号信息。
- act:对应于第一个参数的信号处理函数(信号处理器)信息
- oldact:通过此参数获取之前注册的信号处理函数指针,若不需要则传递 0
我们将使用上述 sigaction 函数初始化 sigaction 结构体变量,该结构体定义如下。
struct sigaction {
void (*sa_handler)(int); // sa_handler 是信号处理函数的指针
sigset_t sa_mask; // sa_mask 和 sa_flags 用来指定信号相关的选项和特性,
int sa_flags; // 我们的目的主要是防止产生僵尸进程,故省略。
}
下面给出的示例,包含了尚未讲解的使用 sigaction 函数所需的全部内容。
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void timeout(int sig) {
if (sig == SIGALAM)
puts("Time Out!");
alarm(2);
}
int main(int argc, char *argv[]) {
int i;
// 注册信号处理函数,声明 sigaction 结构体变量
struct sigaction act;
act.sa_handler = timeout; // 在 sa_handler 成员中保存函数 timeout 指针值
sigemptyset(&act.sa_mask); // 调用 sigempty 函数将 sa_mask 成员所有位初始化 0
act.sa_flags = 0; // sa_flags 成员同样初始化为 0
// 注册 SIGALRM 信号的处理器
sigaction(SIGALRM, &act, 0);
// 调用 alarm 函数预约 2 秒后发生 SIGALRM 信号
alarm(2);
for (i=0; i<3; i++) {
puts("wait ...");
sleep(100);
}
return 0;
}
运行结果
利用信号处理技术消灭僵尸进程
下面就编写程序来消灭僵尸进程,子进程终止时将产生 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;
// 注册信号对应的处理器,若子进程终止,调用定义的函数 read_childproc。
// 处理函数中调用了 waitpid 函数,所以子进程将正常终止,不会成为僵尸进程。
struct sigaction act;
act.sa_handler = read_childproc;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(SIGCHLD, &act, 0);
pid = fork(); // 创建子进程 1
if (pid == 0) { // 子进程 1 执行区域
puts("HI!I'm child process");
sleep(10);
return 12;
}
else { // 父进程执行区域
printf("Child proc id:%d \n", pid);
pid = fork(); // 创建子进程 2
if (pid == 0) { // 子进程 2 执行区域
puts("Hi! I'm child process");
sleep(10);
exit(24);
}
else {
int i;
printf("Child proc id:%d \n", pid);
// 为了等待发生 SIGCHLD 信号,使父进程共暂停 5 次,每次间隔 5 秒,
// 发生信号时,父进程将被唤醒,因此实际暂停时间不到 25 秒
for (i=0; i<5; i++) {
puts("Wait ...");
sleep(5);
}
}
}
}
运行结果
从中可以看到,子进程并没有变成僵尸进程,而不是正常终止了。下面就编写多进程服务端。
五、基于多任务的并发服务器
并发服务器模型
现在扩展服务器为并发服务器,使之能够向多个客户端提供服务。下图是基于多进程的并发回声服务器端的实现模型。
从这里可以看到,当客户端发起连接请求的时候,回声客户端就创建子进程服务。
为了完成并发任务,回声服务器端需要完成以下操作:
- 第一阶段:回升服务器端(父进程)通过调用 accept 函数受理连接请求。
- 第二阶段:此时获取的套接字文件描述符创建并传递给子进程。
- 第三阶段:子进程利用传递来的文件描述符提供服务。
我们的父进程取得了客户端的套接字,而通过简单的 fork() ,则子进程就能得到这个资源,所以这个过程很简单。
实现并发服务器
下面给出的是并发回声服务器端的实现代码,该程序基于多进程实现并发服务。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.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];
// 1.检查输入格式
if (argc != 2) {
printf("Usage: %s <port> \n", argv[0]);
exit(1);
}
// 2.注册信号
act.sa_handler = read_childproc;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
state = sigaction(SIGCHLD, &act, 0);
// 3.设定服务器端套接字
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]));
// 4.bind() 函数将套接字与特定的 IP 地址和端口绑定起来
if (bind( serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr) ) == -1)
error_handling("bind() error");
// 5.listen() 函数可以让套接字进入被动监听状态
if (listen(serv_sock, 5) == -1)
error_handling("listen() error");
while(1) {
// 6.等待客户端发起连接,从而使用 accept 建立连接
adr_sz = sizeof(clnt_adr);
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
if (clnt_sock == -1)
continue;
else
puts("new client connected...");
// 7.创建子进程,每个子进程对应一个客户端
pid = fork();
if (pid == -1) {
close(clnt_sock);
continue;
}
if (pid == 0) { // 8.子进程干活
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;
}
else
// 9.注意:因为子进程复制了套接字,所以父进程的套接字需要销毁
close(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);
}
为什么多进程会改变 accept() 的阻塞状态呢?
这是因为,父进程在 accept 建立连接后,后面的工作就交给它的小弟—子进程了,而它就进入了新的 accept 状态,从而等待下一个客户端的连接
验证上述的并发服务器,对于 Windows 客户端,使用如下代码。
#include <stdio.h>
#include <stdlib.h>
#include <WinSock2.h>
#pragma comment(lib, "ws2_32.lib") // 加载 ws2_32.dll
#define BUF_SIZE 100
int main() {
// 初始化 DLL
WSADATA wsaData;
WSAStartup(MAKEWORD(2, 2), &wsaData);
// 创建 TCP 套接字
SOCKET sock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);
// 向服务器发起请求
struct sockaddr_in sockAddr;
memset(&sockAddr, 0, sizeof(sockAddr)); // 每个字节都用 0 填充
sockAddr.sin_family = PF_INET;
sockAddr.sin_addr.s_addr = inet_addr("116.62.223.115");
sockAddr.sin_port = htons(1234);
connect(sock, (SOCKADDR*)&sockAddr, sizeof(SOCKADDR));
// 获取用户输入的字符串并发送给服务器
char bufSend[BUF_SIZE] = {0};
printf("Input a string: ");
scanf("%s", bufSend);
send(sock, bufSend, strlen(bufSend), 0);
// 接收服务器传回的数据
char bufRecv[BUF_SIZE] = {0};
recv(sock, bufRecv, BUF_SIZE, 0);
// 输出接收到的数据
printf("Message from server: %s\n", bufRecv);
// 关闭套接字
closesocket(sock);
// 终止使用 DLL
WSACleanup();
system("pause");
return 0;
}
使用 gcc client.c -o client.exe -lwsock32
编译上述的代码,然后运行。
可以看到服务器端的运行结果如下:
通过 fork 复制文件描述符
在上述的代码中,我们看到通过 fork() 函数,父进程将 2 个套接字文件描述符(一个是服务器端套接字,另一个是与客户端连接的套接字)复制给子进程。
只复制文件描述符吗?是否也复制了套接字呢?
文件描述符的实际复制多少有些难以理解。调用 fork 函数时复制父进程的所有资源,有些人可能认为也会同时复制套接字。但套接字并非进程所有—从严格意义上来说,套接字属于操作系统,只是进程拥有代表相应套接字的文件描述符,因此不会复制套接字。而且,一个套接字对应一个端口,所以复制套接字的话,就会造成端口对应不明确了。
所以,fork 函数只会复制文字描述符,所以就是多个文字描述符对应 1 套套接字。
我们看到可以实现 多个描述符->一个套接字 的情况,因此要想清除套接字,就必须得把对应的所有描述符给销毁,这就对应了上述代码中的 close(clnt_sock)
。
六、分割 TCP 的 I/O 程序
上面谈论的是服务器端的相关内容,下面讨论客户端中分割 I/O 程序的方法。
分割 I/O 程序的客户端模型
我们现在的客户端在向服务器端发送完消息后,本身就一直处于等待回信的状态,这何尝不是一种阻塞?所以我们要创建非阻塞的客户端。
而实现这个目标也很简单,和服务器端一样,我们同样通过多进程的方式将写和读的过程分割开来。多进程的客户端模型如下
在上述的模型中,回声客户端有 2 个进程,其中
- 父进程:接收数据
- 子进程:发送数据
相比于原来 1 个进程中同时实现数据收发逻辑,其细节上已经减少了很多,所以它的优点非常明显。分割 I/O 程序后,也能够提升程序的收发性能。
实现分割 I/O 程序的客户端
下面是读写分离客户端的代码实现
#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_adr = 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); // 子进程写
else
read_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); // 客户端只读不写,shutdown函数向服务器传递EOF
return;
}
write(sock, buf, strlen(buf));
}
}
void error_handling(char *message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}