您的位置:首页 > 理论基础 > 计算机网络

UNIX网络编程笔记(4)—TCP客户/服务器程序示例

2016-06-03 16:06 519 查看

TCP客户/服务器程序示例

这一章信息量开始大起来了,粗略来看它实现了简单的TCP客户/服务器程序,里面也有一些费解的细节。

1.概述

完整的TCP客户/服务器程序示例。这个简单的例子将执行如下步骤的一个回射服务器(这里的回射服务器就是服务简单的把客户端发送的消息返回给客户):

1)客户从标准输入读入一行文本,并写给服务器

2)服务器从网络输入读入这行文本,并回射给客户

3)客户从网络输入读入这行回射文本,并显示在标准输出上

这样实际上就构成了一个全双工的TCP连接。

本章就围绕了这个简单的TCP客户/服务器程序做了一些扩展:比如信号处理问题,启动和终止时TCP分节发送,服务器崩溃等等。

2.TCP回射服务器程序

2.1 main函数

书中为了方便读者忽略写代码时的繁文缛节,对socket函数和I/O函数都做了包裹,这样摒弃了一些细节,便于理解,当然缺点也是显而易见,咱们在工作或者项目时,不可能把书中的那一套东西一直出来(这将是个体力活儿),毕竟talk is cheap show me the code,打算自己把书中的包裹函数拆开,写写试试。

//testserv.c

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <stdio.h>
#include <netinet/in.h>
#include <unistd.h>
#include <string.h>
#include <signal.h>
#define MAXLEN 1024
#define SERV_PORT 1024 // 1024~49151未被使用的端口
void echo_str(int);//回射函数声明
int main(int argc,char *argv[])
{
struct sockaddr_in serveraddr; //服务器套接字结构
struct sockaddr_in cliaddr;//客户端套接字结构
int listenfd;//监听套接字描述符
int connfd; //连接套接字字描述符
pid_t pid_id; //进程号

//初始化
memset(&serveraddr,0x00,sizeof(serveraddr));
memset(&cliaddr,0x00,sizeof(cliaddr));

//填写套接字结构
serveraddr.sin_family=AF_INET;//协议族
serveraddr.sin_addr.s_addr=htonl(INADDR_ANY);//监听任意IP
serveraddr.sin_port=htons(SERV_PORT);//监听1024端口号

if((listenfd=socket(AF_INET,SOCK_STREAM,0))<0)//创建套接字
{
printf("socket error\r\n");
return -1;
}

if(bind(listenfd,(struct sockaddr *)&serveraddr,sizeof(serveraddr))<0)//绑定套接字
{
printf("bind error\r\n");
return -1;
}

if(listen(listenfd,5)<0)//监听套接字
{
printf("listen error\r\n");
return -1;
}
while(1)
{
connfd=accept(listenfd,NULL,NULL);
if(0==(pid_id=fork()))//子进程处理
{
close(listenfd);
echo_str(connfd);//回射函数
return 0;
}
close(connfd);
}
}


这里唯一需要注意的地方在前面章节介绍过,由于创建子进程后,监听套接字和已连接套接字的引用计数会由于子进程的拷贝分别增加,这时候在要在主进程中关闭已连接套接字,在子进程中关闭监听套接字。

2.2 str_echo回射函数

回射函数在子进程中调用,因为他的功能就是读取(read)客户端的数据,并将数据回射(write)给客户。

//testsrv.c
void echo_str(int connfd)
{
ssize_t nread;
char readbuff[MAXLEN];
memset(readbuff,0x00,sizeof(readbuff));
while((nread=read(connfd,readbuff,MAXLEN))>0)//读取数据
{
write(connfd,readbuff,strlen(readbuff));//回射数据
memset(readbuff,0x00,sizeof(readbuff));
}
}


3.TCP回射客户程序

3.1 main函数

//testcli.c

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <stdio.h>
#include <netinet/in.h>
#include <unistd.h>
#include <string.h>
#define MAXLEN 1024
#define SERV_PORT 1024 // 1024~49151未被使用的端口
void str_cli(FILE*,int);
int main(int argc,char *argv[])
{
int sockfd ;
struct sockaddr_in serveraddr;//服务器套接字结构
if(argc<2)
{
printf("usage: testcli <IPAddress>\r\n");
return -1;
}
if((sockfd=socket(AF_INET,SOCK_STREAM,0))<0)
{
printf("socket error \r\n");
return -1;
}
memset(&serveraddr,0x00,sizeof(serveraddr));
serveraddr.sin_family=AF_INET;//协议族
serveraddr.sin_port=htons(SERV_PORT);//端口号
if(inet_pton(AF_INET,argv[1],&serveraddr.sin_addr)<=0)//指定服务器ip
{
printf("inet_pton error\r\n");
return 0;
}
if((connect(sockfd,(struct sockaddr *)&serveraddr,sizeof(serveraddr)))<0)//发起主动TCP三路连接
{
printf("connect error\r\n");
return 0;
}
str_cli(stdin,sockfd);//发送数据并等待回射
return 0;
}


3.2 str_cli函数

//testcli.c

void str_cli(FILE*fp,int sockfd)
{
int nread;
int nwrite;
char readbuff[MAXLEN];

while( fgets(readbuff,sizeof(readbuff),fp)!=NULL)//读取数据
{
if( (nwrite= write(sockfd,readbuff,strlen(readbuff)))<0)//发送数据
{
printf("write error \r\n");
return ;
}
memset(readbuff,0x00,sizeof(readbuff));
if(( nread= read(sockfd,readbuff,sizeof(readbuff)))<0)//等待回射
{
printf("read error \r\n");
return ;
}
fputs(readbuff,stdout);//输出数据
}
}


上面4个函数体应该算是满足回射服务器的最低标配了,下面将学习更加深入的一些内容。

4.正常启动

分别编译上述客户端程序和服务器程序:

make testsrv;
make testcli;


先运行服务器程序,并让其转到后台运行:

./testsrv &


一旦运行后,服务器程序将阻塞在accept函数处,在终端输入:

netstat -a -t -p


额外的
-t
参数表示仅显示TCP相关的服务,
-p
则可以显示我们服务器的进程号,netstat命令详见:netstat命令详解

我们看到有一个套接字处于LISTEN状态,并且其本地地址显示为
*:1024
,*表示通配地址,1024表示服务器进程绑定的端口号。

然后启动客户端进程:

./testcli 127.0.0.1


进行本地环路测试。

此时,将发生下面一些事情:

1.客户端阻塞在文本输入

2.服务器中accept返回,并创建子进程进入回射函数,阻塞在read处

3.服务器父进程由于循环再次accept阻塞等待下一个连接。

这个时候我们再在终端输入:

netstat -a -t -p


就可以看到服务器的父进程处于监听状态,服务器的子进程和客户端进程处于ESTABLISHED状态(连接建立)。

5.正常终止

连接建立后,客户端程序就可以使用服务了:

linux % ./testcli 127.0.0.1
hello world //输入
hello world //回射并显示
good bye //输入
good bye //回射并显示
^D //ctrl + d


输入
ctrl+D
后,fgets返回空指针,于是,str_cli函数返回,紧接着客户端程序进入main函数,main函数也返回,客户端进程终止,
进程终止处理的部分工作是关闭所有打开的描述符,因此客户打开的套接字由内核关闭
。由于客户端进程结束,这将导致客户端TCP发起主动终止连接的FIN分节,服务器则以ACK回应。至此完成TCP终止连接的四次握手中的两次握手,客户端套接字处于FIN_WAIT_2状态,服务器套接字进入CLOSE_WAIT状态,客户与服务的状态转移这部分对照那个经典的状态转移图走一遍过程就懂了,注意是哪一方主动发起关闭连接的操作。

紧接着,由于服务器收到FIN分节,在子进程中阻塞的read函数将返回,于是str_echo返回,整个子进程返回终止,子进程中打开的描述符随之关闭,于是服务器发送一个FIN分节,并等待客户端的ACK,这就是四次握手的后两部分了。

至此,四次握手终止连接完成,客户端套接字进入TIME_WAIT状态。

连接正常终止,但这里存在潜在的问题:

服务器子进程终止时会给父进程发送一个
SIGCHLD
信号,我们在代码中没有捕获该信号,该信号就被忽略了,于是子进程就编程了僵尸进程,在终端输入:

ps -o pid,ppid,tty,stat,args,wchan


可以看到我们的子进程状态为Z(僵尸的英文Zombies)。僵尸是必须要清理,否则我们也会感染T病毒,呵呵。。

6.信号处理

信号(signal)就是告知某个进程发生了某个事件的通知,有时也称为软件中断(software interrupt);信号通常是异步发生的,也就是说进程预先不知道信号准确发生的时间。

信号可以是:

由一个进程发给另一个进程

由内核发给某个进程

而之前提到的SIGCHLD信号就是由内核在任何一个进程终止时发给其给父进程的一个信号,为此,在父进程中,就要做相关的信号处置了,书中依然给定了包裹函数,简单的做法是直接使用
signal
函数,来指定特定的信号和信号处理函数。

6.1 信号处理函数

//testsrv.c

void sig_child(int signo)
{
pid_t pid;
int stat;
pid=wait(&stat);
printf("child terminated,pid=%d\r\n",pid);
return ;
}

//在主函数中,在listen后面,我们进行如下调用:
typedef void (*signal_handler_t)(int);
int main()
{
//...
//listen
signal_handler_t sig_handler1=sig_child;
signal(SIGCHLD,sig_handler1);
//...
}


sig_child即是我们的信号处理函数了。信号处理函数由信号值这个单一的整数参数来调用,没有返回值,其一般声明原型如下:

void handler (int signo);


在sig_child中,通过调用wait方法,处理调僵尸进程。

6.2 代码的细节

下面就谈谈代码中的语法细节,书中用了包裹函数,在之前他提到:

void (*signal(int signo,void (*func)(int)))(int);


看到这里,我有点懵逼,关于它的用法,可以参考,stackoverflow中的解释,其实,我们也可以在linux终端输入:

man signal
//将获得以下信息
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);


这样我们知道:

1.signal函数有两个参数,其一是int行,其二是一个函数指针型(int为参数,void为返回值)

2.signal函数的返回值也是一个函数指针(int为参数,void为返回值)

然后再去理解:

void (*signal(int signo,void (*func)(int)))(int);


先把
(*signal(int signo,void (*func)(int)))
当做一个整体,该整体是一个函数(int为参数,void为返回值)。

然后由于
*
的存在,
signal(int signo,void (*func)(int))
这个整体可以解引用,来构造上述一个整体(int为参数,void为返回值)。

因此signal就是一个函数,它接收一些参数,并且能返回一个函数指针(int为参数,void为返回值),这就跟man signal中的内容一致了。

这些都是语法上的细节,不得不感慨:YC我学到了。

7.wait和waitpid

函数声明如下:

#include <sys/wait.h>
pid_t wait(int *statloc);
pid_t waitpid(pid_t pid,int *statloc,int options);


两者均返回子进程的进程号,通过statloc指针得到终止状态,既然说到两个函数,就必须得说说两者的区别了。

书中就举了一个很好的例子:

1.假设客户端同时向服务器建立了5个连接

2.然后客户进程关闭,引发其每个连接套接字主动发出5个FIN。

3.服务器子进程收到FIN返回终止,内核又向父进程发送5份SIGCHLD信号

4.由于UNIX信号是不排队同时有好几个子进程结束只留下一个信号,并且是异步的,我们不知道信号何时到来,假设现在子进程A引发的SIGCHLD被捕获并进入信号处理函数的时候,子进程B和C引发的SIGCHLD刚好来到,那么这个SIGCHLD在下次被捕获的时候,只处理其中一个进程(假设是C)。这样子进程B的僵尸进程就没办法处理了。

因此由wait到waitpid的改良的最大需求就是解决上述4提到的问题。

书中给出一个是示例:



图1 建立连接



图2 进程终止

所以,在书中给的例子中,5个连接对应5个服务器程序的子进程,只有1个僵尸进程被杀死,其他4个没办法处理。

7.1 wait与waitpid的区别

要解决上述问题,就要谈谈这两个函数的区别了。

从参数上来看,waitpid除了可以指定监视某一特定子进程(根据pid号,也可以设置为-1表示等待第一个终止的子进程),还可以设置options选项,最常用的是
WNOHANG
(也可以设置0,表示不使用任何选项。)

有一种说法是,wait是waitpid的某一特定包裹函数:

pid_t wait(int *statloc)
{
return waitpid(-1, statloc, 0);
}


上面哪个问题,使用waitpid的
WNOHANG
参数就可以解决,这个参数的意思是:

告知内核在没有已终止子进程时不要阻塞

基于上述特性,我们可以在信号处理函数中写一个while循环:

while ( (pid = waitpid(-1,&stat,WNOHANG)) > 0)
printf("child terminated\r\n");


这样设想2种情况:

1.当正在信号处理函数中时,并没有子进程终止,这样waitpid在清理完该子进程后立刻返回。

2.当正在信号处理函数中时,有若干个子进程终止,内核将发出一个SIGCHLD信号(不排队,只有一个),此时由于主进程进入信号处理函数(中断),通过while循环一次性收集所有僵尸子进程,就算在这个过程中,又不断有子进程终止,内核依然产生一个SIGCHLD信号,并在下次进入中断时一次性收集,如此循环。

这样我们就没办法用wait了,如果有5个进程,第一次终止两个,内核发送一个SIGCHLD,捕获到了之后,进入处理函数,wait清理掉其中一个;第二次终止三个,内核发送一个SIGCHLD,wait又清理掉其中一个。这样会留下三个僵尸进程。

那么为什么不能在wait外面也加一层while呢?

因为wait是阻塞的,在没有子进程终止时他是阻塞的,那么当第一次处理完两个之后,当另外三个子进程还没结束时,主进程就会阻塞在信号处理函数中了。

总结起来就是:

wait只能是阻塞调用,waitpid可以选择是非阻塞的,当出现很多子进程时,为了保证可以收集处理掉子进程的僵尸进程,应该使用waitpid。

7.2 完整代码

服务器程序

//testsrv2.c

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <sys/wait.h>
#include <stdio.h>
#include <netinet/in.h>
#include <unistd.h>
#include <string.h>
#include <signal.h>
#define MAXLEN 1024
#define SERV_PORT 1024 // 1024~49151未被使用的端口
void echo_str(int);
void sig_child(int);
typedef void (*signal_handler_t)(int);
int main(int argc,char *argv[])
{
struct sockaddr_in serveraddr; //服务器套接字结构
struct sockaddr_in cliaddr;//客户端套接字结构
int listenfd;//监听套接字描述符
int connfd; //连接套接字字描述符
pid_t pid_id; //进程号

//初始化
memset(&serveraddr,0x00,sizeof(serveraddr));
memset(&cliaddr,0x00,sizeof(cliaddr));

serveraddr.sin_family=AF_INET;
serveraddr.sin_addr.s_addr=htonl(INADDR_ANY);
serveraddr.sin_port=htons(SERV_PORT);

if((listenfd=socket(AF_INET,SOCK_STREAM,0))<0)//创建套接字
{
printf("socket error\r\n");
return -1;
}

if(bind(listenfd,(struct sockaddr *)&serveraddr,sizeof(serveraddr))<0)//绑定套接字
{
printf("bind error\r\n");
return -1;
}

if(listen(listenfd,5)<0)
{
printf("listen error\r\n");
return -1;
}
signal_handler_t sig_handler1=sig_child;
signal(SIGCHLD,sig_handler1);
while(1)
{
connfd=accept(listenfd,NULL,NULL);
if(0==(pid_id=fork()))//子进程
{
echo_str(connfd);//回射函数
return 0;
}
close(connfd);
}
}

void echo_str(int connfd)
{
ssize_t nread;

char readbuff[MAXLEN];

memset(readbuff,0x00,sizeof(readbuff));
while((nread=read(connfd,readbuff,MAXLEN))>0)
{
write(connfd,readbuff,strlen(readbuff));

memset(readbuff,0x00,sizeof(readbuff));
}

}
void sig_child(int signo)
{
pid_t pid;
int stat;

#if 1
while((pid=waitpid(-1,&stat,WNOHANG))>0)
printf("waitpid:child terminated,pid=%d\r\n",pid);
#endif
return ;
}


客户端程序

//testcli2.c

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <stdio.h>
#include <netinet/in.h>
#include <unistd.h>
#include <string.h>
#define MAXLEN 1024
#define SERV_PORT 1024 // 1024~49151未被使用的端口
void str_cli(FILE*,int);
int main(int argc,char *argv[])
{
int sockfd[5] ;
struct sockaddr_in serveraddr;
int i;
if(argc<2)
{
printf("usage: testcli <IPAddress>\r\n");
return -1;
}
for(i=0;i<5;i++)
{
if((sockfd[i]=socket(AF_INET,SOCK_STREAM,0))<0)
{
printf("socket error \r\n");
return -1;
}
}
memset(&serveraddr,0x00,sizeof(serveraddr));
serveraddr.sin_family=AF_INET;//协议族
serveraddr.sin_port=htons(SERV_PORT);//端口号
if(inet_pton(AF_INET,argv[1],&serveraddr.sin_addr)<=0)
{
printf("inet_pton error\r\n");
return 0;
}
for(i=0;i<5;i++)
{
if((connect(sockfd[i],(struct sockaddr *)&serveraddr,sizeof(serveraddr)))<0)
{
printf("connect error\r\n");
return 0;
}
}
str_cli(stdin,sockfd[0]);
return 0;
}

void str_cli(FILE*fp,int sockfd)
{
int nread;
int nwrite;
char readbuff[MAXLEN];
while( fgets(readbuff,sizeof(readbuff),fp)!=NULL)
{
if( (nwrite= write(sockfd,readbuff,strlen(readbuff)))<0)
{
printf("write error \r\n");
return ;
}
memset(readbuff,0x00,sizeof(readbuff));
if(( nread= read(sockfd,readbuff,sizeof(readbuff)))<0)
{
printf("read error \r\n");
return ;
}
fputs(readbuff,stdout);
}
}


8.小结

以最简单的TCP客户/服务器回射服务器程序为例子,主要介绍了:

1.UNIX网络编程TCP客户程序和服务器程序的基本套路。

2.TCP客户和服务器程序启动和终止时所发生的事情。

3.进程间信号处理的一些知识。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: