并发服务器可以同时向所有发起请求的服务器提供服务,大大降低了客户端整体等待服务器传输信息的时间,同时,由于网络程序中,数据通信时间比CPU运算时间长,采用并发服务器可以大大提高CPU的利用率。
实现并发服务器有三种方法:
- 多进程服务器(通过创建多个进程提供服务)
- 多路复用服务器(通过捆绑并统一管理I/O对象提供服务)
- 多线程服务器(通过生成与客户端等量的线程提供服务)
多进程服务器
fork()函数
多进程服务器比较适合于Linux系统,要想时间多进程,我们首先要创建进程,fork()
函数提供了这种方法:
#include <unistd.h>
pid_t fork(void); // 创建成功则返回进程ID,创建失败返回-1
fork()
函数实际上是复制正在运行的、调用fork()
函数的主进程,之后通过fork()
创建的进程的ID来执行不同的代码。
- 父进程调用
fork()
函数会返回创建的子进程的ID - 子进程调用
fork()
函数会返回0
通过上面这两种结果,我们就可以在程序中,根据进程ID
来判定当前是否为主进程。
实例
#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) // 子进程调用fork函数才会返回0
{
gval += 2;
lval += 2;
}
else{ // 父进程调用fork函数返回子进程ID
gval -= 2;
lval -= 2;
}
if(pid == 0)
{
printf("child process: [%d, %d] \n", gval, lval);
}
else{
printf("parent process: [%d, %d] \n", gval, lval);
}
return 0;
}
运行结果:
在代码中,父进程执行的是-
运算,子进程执行的是+
运算,结果证实了程序确实是这样执行,正确执行了函数。
僵尸进程
进程完成工作以后应该被销毁,但可能因为一些原因,没有被销毁,从而变成僵尸进程
,一直占用系统资源。终止子进程(不是销毁
)的方式有如下两种:
- 通过调用
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("this is child process");
}
else
{
printf("child process id: %d \n", pid);
sleep(30);
}
if(pid == 0)
{
puts("child process end");
}
else
{
puts("parent process end");
}
return 0;
}
由于上述程序中,父进程没有请求获取子进程的结束状态值,所以在父进程结束之前,子进程都属于僵尸进程状态:
从程序运行的输出可以得到创建的子进程ID是185,通过ps au
来查看进程状态
从中可以看到185进程的状态是Z+
,这就表示此进程是僵尸进程。
销毁僵尸进程
在上面讲了僵尸进程是由于父进程没有请求接收子进程的结束状态值和返回值,所以只需要父进程主动进行接收了,就可以结束僵尸进程,接收子进程的结束状态值和返回值有两种方式
结束僵尸进程 - wait()函数
#include <sys/wait.h>
pid_t wait(int* statloc); // 成功则返回子进程ID,失败则返回-1
通过wait()函数父进程可以主动请求接收子进程的结束状态值和返回值,二者都会保存在statloc
参数中,通过下面的宏可以分离出这两个信息:
- WIFEXITED获取子进程的结束状态值,正常结束为
true
- WEXITSTATUS获取子进程的返回值
实例
#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)
{
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;
}
运行结果
从结果可以看到,第一个创建的进程ID是254,通过WIFEXITED
获取了子进程的结束状态值,确定是正常结束后,通过WEXITSTATUS
获取了子进程的返回值。因为程序创建了两个子进程,所以后面又调用了一次wait函数,来获取第二个创建的子进程的结束状态值和返回值。
结束状态值 - waitpid()函数
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int* statloc, int options); // 成功时返回子进程ID,失败返回-1,没有终止的子进程返回0
- pid:要获取的子进程的ID,当设置为-1时,等待任意子进程终结
- statloc:与wait()函数的statloc相同
- options:设置为WNOHANG,就可以即使没有子进程结束,也不会阻塞等待
也就是说waitpid()
比wait()
函数多了 指定请求的进程 和 不阻塞 的功能
#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;
}
运行结果
通过将子进程设置睡眠时间,让其暂不返回状态值和返回值,从而测试父进程waitpid()
在没有接收到子进程信息的情况下,是否会阻塞,实例证明确实不会阻塞。
信号处理
signal函数
上述程序中,在调用waitpid
后,这个函数会一直等待子进程的终止,影响父进程的效率。而子进程终止的信息是会告知操作系统的。所以C++设计了一种机制,当子进程终止的时候,主进程让操作系统调用自己提前给他指定好的函数。然后将销毁僵尸进程的waitpid
函数放到指定的函数内,这样就可以让他不必一直等待了,当子进程终止的时候,调用此函数,在此函数内去销毁僵尸进程。那么通过信号调用函数的机制可以通过以下函数来进行这样的设置:
#include <signal.h>
void (*signal(int signo, void (*func)(int)))(int)
这里来解释一下这个函数:
- 这是声明了一个函数
signal
; signal
函数有两个输入参数,分别是int signo
和函数指针 func
,此指针所指的函数,其输入参数是int,无返回值;- 函数
signal
的返回值是个函数指针
,该指针所指的函数,其输入参数是int,无返回值。
其中signal
表示特殊情况信息,func
表示特殊情况下要调用的函数的地址值(指针),这个要调用的函数我们称之为信号处理器(Handler)
,也就是说发生signal
指明的特殊情况就调用func
指向的函数。常用的特殊情况信息如下:
- SIGALRM:已到达通过调用alarm函数注册的时间
- SIGINT:输入Ctrl + C
- SIGCHLD:子进程终止
实例
先了解一下 alarm
函数,通过实例去了解一下信号如何使用
- alarm函数
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
这个函数作用如下:
- 传入一个正整数来指定秒数,指定秒数过完后发送SIGALRM信号给调用它的进程
- 如果在上个计时器还没有计时完,重新设定计时器,就会覆盖之前的计时器
- 如果传入0,则表示取消计时器
- 如果只设置了计时器,而没有指定信号处理函数(只调用了alarm函数,没有调用signal函数),则在计时完成后,终止当前进程
然后我们了解一下信号signal
如何使用
#include <unistd.h>
#include <signal.h>
#include <stdio.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;
}
对其编译运行如下:
可以看到我们先定义了信号,然后设置计时来触发SIGALRM
信号,中断信号则由在运行期间中断程序来触发,并且使用sleep
设置了100秒的进程睡眠时间,只有当信号触发的时候,才会唤醒进程。
sigaction函数
signal
函数在不同的Unix操作系统中可能有所差别,他有一个替换函数sigaction
,这个函数则是在各操作系统中都相同。但使用方法亦有所差别,在调用此函数前,需要先定义sigaction
结构体,用来指定对特定信号的处理,信号所传递的信息,信号处理函数执行过程中应屏蔽掉哪些函数等这些选项。
- sigaction结构体
struct sigaction
{
void (*sa_handler)(int); // 保存信号处理函数的指针
sigset_t sa_mask; // 用来指定在信号处理程序执行过程中,哪些信号应当被阻塞。默认当前信号本身被阻塞。
int sa_flags; /* 包含了许多标志位,比较重要的一个是SA_SIGINFO,当设定了该标志位时,表示信号附
* 带的参数可以传递到信号处理函数中。即使sa_sigaction指定信号处理函数,如果不设
* 置SA_SIGINFO,信号处理函数同样不能得到信号传递过来的数据,在信号处理函数中对
* 这些信息的访问都将导致段错误。
*/
}
- sigaction函数
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oldact)
第一个参数与signal中相同,用来传递信号信息;第二个参数用来指明对应于第一个参数的信号处理函数;第三个参数用来指明第一个参数之前对应的信号处理函数,不需要可设置为0。
下面来了解一下其具体用法:
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void timeout(int sig)
{
if(sig == SIGALRM) puts("time out!");
alarm(2);
}
int main(int argc, char *argv[])
{
int i;
// 定义信号处理结构体
struct sigaction act;
act.sa_handler = timeout; // 指定信号处理函数为timeout
sigemptyset(&act.sa_mask); // 将选项设置为0,表示使用默认选项
act.sa_flags = 0; // 特性同样设置为0,表示信号不能传递数据
// 告知操作系统触发信号所要执行的内容
sigaction(SIGALRM, &act, 0);
alarm(2);
for(i = 0; i < 3; i++)
{
puts("wait...");
sleep(100); // 进程进入睡眠,当信号发生时唤醒
}
return 0;
}
执行结果如下:
使用信号销毁僵尸进程
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
void childProcess(int sig)
{
int status;
// 当有子进程结束的时候,获取其进程id
pid_t id = waitpid(-1, &status, WNOHANG);
if(WIFEXITED(status)) // 获取子进程结束状态值
{
printf("终止进程:%d \n", id);
printf("子进程返回:%d \n", WEXITSTATUS(status)); // 获取子进程返回值
}
}
int main(int argc, char *argv[])
{
pid_t pid;
// 设置信号处理相关内容
struct sigaction act;
act.sa_handler = childProcess;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(SIGCHLD, &act, 0); // 子进程结束会返回SIGCHLD信号
pid = fork();
if(pid == 0) // 子进程返回id为0
{
puts("这是子进程1");
sleep(10);
return 1;
}
else{
printf("子进程id: %d \n", pid);
pid = fork();
if(pid == 0)
{
puts("这是子进程2");
sleep(10);
exit(2);
}
else
{
int i;
printf("子进程id: %d \n", pid);
for(i = 0; i < 5; i++)
{
puts("wait...");
sleep(5);
}
}
}
return 0;
}
执行结果如下:
可以看到子进程结束的时候,触发SIGCHLD
信号,调用信号处理函数,在其中通过waitpid
函数消灭僵尸进程,这样waitpid
就不必一直等待子进程终止了,当信号处理函数接收到信号,才会在内部调用waitpid
函数。
实现并发服务器
这里我们实现一下并发的回升服务器
echo_mpserv.cpp
#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_handing (const char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
void childProcess(int sig)
{
pid_t pid;
int status;
pid = waitpid(-1, &status, WNOHANG);
printf("remove process: %d \n", pid);
}
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 = childProcess;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
state = sigaction(SIGCHLD, &act, 0);
// 设置套接字信息
serv_sock = socket(AF_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_handing("bind error");
if(listen(serv_sock, 5) == -1)
error_handing("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;
else
puts("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;
}
else
close(clnt_sock);
}
close(serv_sock);
return 0;
}
echo_mpclnt.cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024
void error_handling(const char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
int main(int argc, char *argv[])
{
int sock;
char message[BUF_SIZE];
int str_len;
struct sockaddr_in serv_adr;
if(argc != 3)
{
printf("Usage: %s <IP>, <port>\n", argv[0]);
exit(1);
}
sock = socket(AF_INET, SOCK_STREAM, 0);
if(sock == -1)
error_handling("socket error");
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");
else
puts("connected....");
while(1)
{
fputs("输入信息(Q退出): ", stdout);
fgets(message, BUF_SIZE, stdin);
if(!strcmp(message, "q\n") || !strcmp(message, "Q\n"))
break;
write(sock, message, strlen(message));
str_len = read(sock, message, BUF_SIZE-1);
message[str_len] = 0;
printf("message from server: %s", message);
}
close(sock);
return 0;
}
执行结果如下:
服务器
客户端
我们启动了两个客户端与服务器进行通信,此处可以看到,服务器通过两个进程,同时与客户端进行了连接。而客户端也返回了想要的信息。
从服务器的程序我们可以看到,在子进程中还关闭了serv_sock
,但是第二个客户端依然能够连接服务器,这是因为,fork
函数在创建子进程的时候,会将父进程的资源也复制一份到子进程中,所以子进程只是关闭了子进程中的serv_sock
文件描述符,父进程仍然是运行的。但此处需要说明:子进程与父进程这两个serv_sock
对应的是同一个套接字,子进程关闭的仅仅是套接字对应的文件描述符,一个套接字是可以对应多个文件描述符的。
如果说想要关闭套接字,就需要将套接字对应的文件描述符都关闭,所以要养成一个习惯,当创建子进程的时候,将复制过来的不使用的套接字都关闭掉,以防止忘记关闭文件描述符导致套接字无法关闭。
客户端程序分割
上面客户端性能上还是有待提升的,在发送数据后,服务器收到,然后返回给客户端,客户端收到返回的信息才可以再往下执行程序,发送下一条信息,即客户端阻塞再read
函数处。通过给服务器读取信息后,设置一个睡眠时间,睡眠时间过后再往客户端发送消息,以起到延缓客户端接收信息的目的,我们可以看到,客户端确实没有办法执行下面的函数。
我们可以利用所学到的多进程来将读写功能分割开来,一个进程管读,一个进程管写,这样客户端读取不到信息只会阻塞其所在进程,而不会阻塞写所在的进程,客户端就可以继续向服务器传输信息,提升了服务器与客户端通信时候的性能。客户端重写如下:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024
void error_handling(const char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
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));
}
}
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(AF_INET, SOCK_STREAM, 0);
if(sock == -1)
error_handling("socket error");
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");
else
puts("connected....");
pid = fork();
if(pid == 0)
write_routine(sock, buf);
else
read_routine(sock, buf);
close(sock);
return 0;
}
运行结果如下:
从结果可以看出,客户端即使没有接收到消息,依然可以向服务器发送消息,之后也收到了服务器的返回消息。