本章所有示例代码>>github
10.1 进程概念及应用
1. 两种类型的服务器端
按序处理客户端请求,如果每个客户端的平均服务时间为0.5秒,则第100个客户端会对服务器产生相当大的不满。
所有连接请求的受理时间不超过1秒,但平均服务时间为2~3秒。
2. 并发服务器端的实现
同时向多个客户端提供服务的并发服务器端,具有代表性的并发服务器端实现模型和方法:
A、多进程服务器: 通过创建多个进程提供服务(不适用Windows);
B、多路复用服务器: 通过捆绑并统一管理I/O对象提供服务;
C、多线程服务器: 通过生成与客户端等量的线程提供服务;
3. 理解进程(Process)
进程——“占用内存空间的正在运行的程序”
从操作系统的角度看,进程是程序流的基本单位,若创建多个进程,则操作系统将同时运行。有时一个程序运行过程中也会产生多个进程。
CPU核心数与进程数:拥有2个、4个运算器的CPU分别称作双核(Daul)CPU、4核(Quad)CPU。核的个数与可同时运行的进程数相同。若进程数超过核数,进程将分时使用CPU资源。
4. 进程ID
无论进程是如何创建的,所有进程都会从操作系统分配到ID。此ID称为“进程ID”,其值为大于2的整数。1要分配给操作系统启动后的首个进程(用于协助操作系统),因此用户进程无法分配ID值1。
通过ps指令可以查看当前运行的所有进程:
ps -au
-au参数列出了所有进程详细信息。
5. 通过调用fork函数创建进程
创建进程的方法有很多,此处介绍创建多进程服务器端的fork函数。
#include <unistd.h>
pid_t fork(void);
fork函数将创建调用的进程副本。
也就是说,并非根据完全不同的程序创建进程,而是复制正在运行的、调用fork函数的进程。另外,两个进程都将执行fork函数调用后的语句(准确地说是在fork函数返回后)。因为通过同一个进程、复制相同的内存空间,之后的程序流要根据fork函数的返回值加以区分。
在父进程中,fork函数返回子进程ID;
在子进程中,fork函数返回0;
“父进程”(Parent Process)指原进程,即调用fork函数的主体。而“子进程”(Child Process)是通过fork函数复制出的进程。
调用fork函数后,父子进程拥有完全独立的内存空间。
10.2 进程和僵尸进程
文件操作中,关闭文件和打开文件同等重要。同样,进程销毁和进程创建同等重要。如果未认真对待进程销毁,它们将变成僵尸进程造成困扰。
1. 僵尸(Zombie)进程
进程完成工作后(执行完main函数中的程序后)应被销毁,但有时这些进程将变成僵尸进程,占用系统中的重要资源。这种状态下的进程称作“僵尸进程”。
2. 产生僵尸进程的原因
“应该向创建子进程的父进程传递子进程的exit参数值或return语句的返回值。”
如果父进程未主动要求获得子进程的结束状态值,操作系统将一直保存,并让子进程长时间处于僵尸进程状态。
3. 销毁僵尸进程1:利用wait函数
为了销毁子进程,父进程应主动请求获取子进程的返回值。
#include <sys/wait.h>
pid_t wait(int *statloc); // 成功返回终止的子进程ID,失败返回-1
子进程终止时传递的返回值(exit函数的参数值、main函数的return返回值)将保存到该函数的参数所指内存空间。但函数参数所指向的单元中还包含其他信息,因此需要通过以下的宏进行分离:
WIFEXITED子进程正常终止时返回“真”(true);
WEXITSTATUS返回子进程的返回值。
即向wait函数传递变量status的地址时,调用wait函数后应编写如下代码:
if(WIFEXITED(status))
{
puts(“Normaltermination!”);
printf(“Childpass num: %d”, WEXITSTATUS(status));
}
注意:调用wait函数时,如果没有已终止的子进程,那么程序将阻塞(Blocking)直到有子进程终止,因此需谨慎调用该函数。
4. 销毁僵尸进程2:使用waitpid函数
wait函数会引起程序阻塞,还可以考虑调用waitpid函数。既能防止僵尸进程,又能防止程序阻塞。
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *statloc, int options); // 成功时返回终止的子进程ID(或0),失败时返回-1
-
pid: 等待终止的目标子进程的ID,若传递-1,则与wait函数相同,可以等待任意子进程终止;
-statloc: 与wait函数的statloc参数相同意义;
-option: 传递头文件sys/wait.h中声明的常量WNOHANG,即使没有终止的子进程也不会进入阻塞状态,而是返回0并退出函数;
10.3 信号处理
父进程往往与子进程一样繁忙,因此不能只调用waitpid函数以等待子进程终止。
1. 向操作系统求助
若操作系统能把子进程终止信息高速忙于工作的父进程,将有助于构建高效的程序。
为实现该想法,引入信号处理(SignalHandling)机制,此处的信号是在特定事件发生时由操作系统向进程发送的消息。
2. 信号与signal函数
信号注册函数:
#include <signal.h>
void (*signal(int signo, void (*fun(int)))(int); // 为了在产生信号时调用,返回之前注册的函数指针
- 函数名: signal
- 参数: int signo、void(*func)(int)
- 返回类型: 返回void型函数指针
调用上述函数时,第一个参数为特殊情况信息,第二个参数为特殊情况下将要调用的函数的地址值(指针)。(发生第一个参数代表的情况时,调用第二个参数所指的函数)
部分特殊情况:
- SIGALARM: 已到通过调用alarm函数注册的时间;
- SIGINT: 输入CTRL+C;
- SIGCHLD: 子进程终止;
alarm函数:
#include <unistd.h>
unsigned int alarm(unsigned int seconds); // 返回0或以秒为单位的距signal信号发生所剩时间
如果调用该函数的同时向它传递一个正整型参数,相应时间后(以秒为单位)将产生SIGALARM信号。
“发生信号时将唤醒由于调用sleep函数而进入阻塞状态的进程”
产生信号时,为了调用信号处理器,将唤醒由于调用sleep函数而进入阻塞状态的进程。而且,进程一旦被唤醒,就不会再进入睡眠状态。即使还未到sleep函数中规定的时间也是如此。
实际上,现在很少使用signal函数编写程序,它只是为了保持对旧程序的兼容。
3. 利用sigaction函数进行信号处理
“signal函数在UNIX系统的不同操作系统中可能存在区别,但sigaction函数完全相同”
#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;
};
4. 利用信号处理技术消灭僵尸进程
子信号终止时将产生SIGCHLD信号。
10.4 基于多任务的并发服务器
1. 基于进程的并发服务器模型
每当有客户端请求服务(连接请求)时,服务器端都创建子进程以提供服务。
- 第一阶段:服务器端(父进程)通过调用accept函数受理连接请求;
- 第二阶段:此时获取的套接字文件描述符创建并传递给子进程;
- 第三阶段:子进程利用传递来的文件描述符提供服务;
2. 实现并发服务器
if(pid == 0)
{
// 由子进程向客户端提供回声服务
close(serv_sock); //可关闭服务器套接字,是因为服务器套接字文件描述符同样也传递到子进程
while((str_len =read(data_sock, buf, BUF_SIZE)) != 0)
write(data_sock, buf, str_len);
close(data_sock);
puts("clientdisconnected...");
return 0;
}
else
{
// 由accept函数创建的套接字文件描述符已经复制给了子进程
close(data_sock);
}
3. 通过fork函数复制文件描述符
通过fork函数复制文件描述符。父进程将2个套接字(一个是服务器端套接字,另一个是与客户端连接的套接字)文件描述符复制给子进程。
只复制文件描述符,并未复制套接字。套接字并非进程所有——从严格意义上说,套接字属于操作系统——只是进程拥有代表相应套接字的文件描述符。
1个套接字中存在2个文件描述符,只有2个文件描述符都终止(销毁)后,才能销毁套接字。调用fork函数后,要将无关的套接字文件描述符关掉。
10.5 分割TCP的I/O程序
回声客户端I/O分割模型如下图。客户端的父进程负责接收数据,额外创建的子进程负责发送数据。分割后,不同进程分别负责输入和输出,这样,无论客户端是否从服务器端接受完数据都可以进行传输。
其实,按照这种实现方式,程序的实现更加简单。父进程中只需编写接收数据的代码,子进程只需编写发送数据的代码,所以会简化。实际上,在1个进程内同时实现数据收发逻辑需要考虑更多细节。
其实,回声客户端并不用分割I/O(仅作示例)。
分割I/O程序的一个优点是,可以提高频繁交换数据的程序性能。