通过简单的socket可以实现一对一的c/s通信,当多个客户端同时进行服务器访问,那么服务器只能按序的一一进行处理,除了第一个客户端,其余客户端都会陷入等待。并且这样的程序只能实现半双工通信(数据能双向传输,但同一时刻只能单向传递,通过切换传输方向实现双工),而且实现方式繁琐,功能拘束,实用价值很低。那么要想实现一个服务器能同时接受多个客户端访问并且能够双工通信的并发服务器,其中一种实现方式----多进程。
一.fork()函数
函数原型:
#include <unistd.h>
pid_t fork(void); //失败时返回-1
fork函数将会复制正在运行的、调用fork函数的进程(实际Linux实现了写时赋值,创建新进程并不会直接创建全部内存副本,进程可随意读取内存数据,只有当有进程需要会内存进行修改时,操作系统才会为新进程创建新的内存副本)两个进程都将执行fork函数调用之后的代码。为加以区分父进程和子进程,fork函数在不同进程返回值不同。
- 父进程:fork函数返回子进程ID
- 子进程:fork函数返回0
int mian(void)
{
...
pid_t pid = fork();
if(pid == -1)
{
printf("进程创建失败\n");
}
if( 0 == pid )
{
...//子进程执行代码
}
else
{
...//父进程执行代码
}
return 0;
}
二、僵尸进程
当子进程执行完毕后,并不会释放所有资源,子进程会保留一部分资源(子进程的结束状态等信息)等待父进程回收,当子进程退出,父进程未回收这段时间,子进程就成为僵尸进程。
销毁僵尸进程1:wait函数
#include <sys/wait.h>
pid_t wait(int *status); //成功时返回终止的子进程ID,失败时返回-1
调用此函数时如果已有子进程终止,那么子进程终止时传递的返回值(exit函数的参数值、main函数的return返回值)将保存到该函数的参数所指内存空间。但函数参数指向的单元中还包含其他信息,需要通过下列宏进行分离。
- WIFEXITED 子进程正常终止时返回"true"。
- WEXITSTATUS 返回子进程的返回值。
示例:
if(WIFEXITED(status)) //是否正常终止
{
puts("Normal termination!");
printf("Child pass num: %d",WEXITSTATUS(status)); //返回值
}
调用wait函数时,如果没有已终止的子进程,那么程序将阻塞直到有子进程终止。
销毁僵尸进程2:waitpid函数
wait函数会引起程序阻塞,调用waitpid函数既可以销毁僵尸进程,又能防止阻塞。
#include <sys/wait.h>
pid_t waitpid(pid_t pid,int *status,int options); //成功时返回终止的子进程ID(或0),失败时返回-1。
- pid 等待终止的目标子进程ID,若传递-1,则与wait函数相同,可以等待任意子进程终止。
- status 传递子进程退出的返回值。
- options 传递头文件sys/wait.h中声明的常量WNOHANG,即使没有终止的子进程也不会进入阻塞状态,而是返回0并退出函数。
waitpid(-1,&status,WNOHANG); //销毁任意僵尸进程,若没有终止的子进程则返回0
信号处理
子进程不知何时结束时,waitpid函数要么循环调用,要么在进程中阻塞等待,无论哪一种都不是优秀的解决方案。
子进程终止的识别主体是操作系统,因此,若操作系统能把如下信息告诉正忙于工作的父进程,就能搞笑的解决问题。
所以这里引入了信号处理(Signal Handling)机制。当特定的事件发生时由操作系统向进程发出消息,进程执行相关操作响应该消息。
函数原型:
#include <signal.h>
void (*signal(int signo,void (*func)(int)))(int);
- 函数名:signal
- 参数:int signo , void (*func)(int) //返回值为void 参数列表为int的函数的指针
- 返回类型:参数类型位int 返回void型的函数指针
参数signo代表监听的信号,void (*func)(int)代表在信号发生时调用的函数地址值(指针)。
在signal函数中注册的部分特殊情况(信号)和对应的常数。
- SIGALRM:已到通过调用alarm函数注册的时间
- SIGINT:输入CTRL+C
- SIGCHLD:子进程终止
void child(int)
{
...
}
signal(SIGCHLD,child); //子进程终止时,调用child函数(child函数返回类型为void,参数列表为int)
发生SIGALRM信号,需要介绍alarm函数
#include <unistd.h>
unsigned int alarm(unsigned int seconds); //返回0或以秒为单位的距SIGALRM信号发生所剩时间
示例:
void timeout(int sig)
{
if(SIGALRM == sig)
puts("Time out!");
}
signal(SIGALRM,timeout);
alarm(2); //两秒后发出SIGALRM信号
利用sigaction函数进行信号处理
由于signal函数在UNIX系列的不同操作系统中可能存在区别,但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即可,这两个成员用于指定信号相关的选项和特性。
//销毁僵尸进程
void dest_childpro(int sig)
{
int status;
pid_t pid = waitpid(-1,&status,WNOHANG);
if(WIFEXITED(status))
{
printf("Removed proc id: %d \n",pid);
printf("Child send: %d \n",WEXITSTATUS(status));
}
}
int main(void)
{
int i;
struct sigaction act;
act.sa_handler = dest_childpro;
sigemptyset(&act.sa_mask); //调用sigemptyset函数将sa_mask成员的所有位初始化为0
act.sa_flags = 0;
sigaction(SIGCHLD,&act,0);
pid_t pid = fork();
if(0==pid)
{
puts("I'm child process");
sleep(3);
return 1;
}
else
{
for(i=0;i<5;i++)
sleep(5);
}
return 0;
}
利用以上知识加上socket编程即可完成多进程的并发服务器,值得一提的是套接字作为操作系统的资源,并不会在fork函数后被复制,但会复制其文件描述符,所以最好子进程中close监听套接字文件描述符,一个套接字同时存在多个文件描述符时,只有所有文件描述符都销毁时,才能销毁套接字。
I/O分割
pid_t pid = fork();
if( 0 == pid)
{
write(); //写
...
}
else
{
read(); //读
...
}
...
分割I/O后,子进程负责写,父进程负责读,就能实现双工服务端了。
进程间通信
进程通信(IPC)意味着两个不同进程间可以交换数据,为了完成这一点,有多种方式实现,在这介绍一种较为简单的单工通信--管道。
管道并非进程资源,和套接字一样,属于操作系统,也就是不会被fork函数复制。
函数原型:
#include <unistd.h>
int pipe(int filedes[2]); //成功时返回0,失败时返回-1
- filedes[0] 通过管道接收数据时使用的文件描述符,即管道出口
- filedes[1] 通过管道传输数据时使用的文件描述符,即管道入口
以2个元素的int数组地址作为参数调用上述函数时,数组中存在两个文件描述符,它们将被用作管道的出口和入口。父进程调用该函数创建管道,同时获取对应于出入口的文件描述符,通过fork函数,文件描述符也会被复制,子进程也能通过文件描述符进行管道通信。
char str1[] = "helloworld";
char str2[11];
int fds[2];
int *read_fd = &fds[0];
int *write_fd = &fds[1];
pid_t pid = fork();
if(0==pid)
{
read(write_fd,str2,10);
}
else
{
write(read_fd,str1,10);
}
以上为管道的单向通信,其实当父子进程都拥有管道的文件描述符后,不难实现可以利用管道双向通信(父子进程都利用两个文件描述符),但是数据进入管道后就成为无主数据,先进行读操作的进程会把数据取走。这样一来,就增加了编程的复杂度和降低了安全性。既然一个管道用作单向通信,那么我们可以再创建一个管道,两个管道分别作为父子进程的读写。(以此思路可以实现进程间双工通信)。