UNIX网络编程卷一 第五章 TCP客户/服务器程序示例

本章是用一个具体示例讲述编写TCP程序的方法以及注意事项,通过仔细研究这个例子对我们掌握TCP套接字编程帮助巨大。

这个示例程序很简单,就是回显输入内容,比如输入hello 就显示hello.

下面先上源码, 然后在详细分析,并且说明程序存在的问题,以及如何修改。

client:

#include "unp.h"


int
main(int argc, char **argv)
{
int sockfd;
struct sockaddr_inservaddr;


if (argc != 2)
err_quit("usage: tcpcli <IPaddress>");


sockfd = Socket(AF_INET, SOCK_STREAM, 0);


bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(SERV_PORT);
Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);


Connect(sockfd, (SA *) &servaddr, sizeof(servaddr));


str_cli(stdin, sockfd);/* do it all */


exit(0);
}



server:

#include "unp.h"


int
main(int argc, char **argv)
{
int listenfd, connfd;
pid_t childpid;
socklen_t clilen;
struct sockaddr_incliaddr, servaddr;


listenfd = Socket(AF_INET, SOCK_STREAM, 0);


bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family      = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port        = htons(SERV_PORT);


Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));


Listen(listenfd, LISTENQ);


for ( ; ; ) {
clilen = sizeof(cliaddr);
connfd = Accept(listenfd, (SA *) &cliaddr, &clilen);


if ( (childpid = Fork()) == 0) {/* child process */
Close(listenfd);/* close listening socket */
str_echo(connfd);/* process the request */
exit(0);
}
Close(connfd);/* parent closes connected socket */
}
}


str_cli:

void
str_cli(FILE *fp, int sockfd)
{
char sendline[MAXLINE], recvline[MAXLINE];


while (Fgets(sendline, MAXLINE, fp) != NULL) {


Writen(sockfd, sendline, strlen(sendline) );


if (Readline(sockfd, recvline, MAXLINE) == 0)
err_quit("str_cli: server terminated prematurely");


Fputs(recvline, stdout);
}
}


void
str_echo(int sockfd)
{
ssize_t n;
char buf[MAXLINE];


again:
while ( (n = read(sockfd, buf, MAXLINE)) > 0)
Writen(sockfd, buf, n);


if (n < 0 && errno == EINTR)
goto again;
else if (n < 0)
err_sys("str_echo: read error");
}



一、  程序整体工作流程

1. 服务端先在后台运行。 调用socket 创建套接字, 调用bind 设置服务的端口号为9877, IP可以为主机的任意一个网卡的IP, 调用listen,将套接字改为被动连接套接字,维护队列,这一步完成后就可以接收客户的connect了,然后调用accept,初次调用时并没有已连接的套接字,进入睡眠。

2. 客户端先创建套接字, 然后设置服务器IP和端口好,调用connect发起连接,调用connect后会发送SYN字节,在收到服务端的ACK后,connect就返回,进入established状态。

3. 客户端的主要工作。

从标准输入中读取一行文本->将它写到套接字中->从套接字中读一行文本->写到标准输出。

4. 服务端的主要工作。

为了支持并发,服务器端将accept放在一个无限循环中,一旦accept返回成功,就fork一个子进程,在子进程中处理已建立连接的任务,父进程就继续等待下一个连接。

在子进程中需要关闭socket创建的描述符,父进程中关闭connect返回的描述符,因为fork创建进程时这两个描述符都会复制到子进程中,如果不关闭,在子进程退出时由于父进程还打开了connect描述符,将不会发送FIN字节,而且每一个连接都会消耗一个描述符资源永远不会释放。

在str_echo中,服务器从套接字中读取内容,若没有内容就阻塞,然后直接写回套接字。


二、 程序正常结束过程分析。


1. 客户端正常是阻塞在fgets,等待用户输入。

在用户输入EOF后,fgets返回NULL,客户端程序调用exit结束程序,退出之前会先关闭打开的套接字描述符,引发FIN发送到套接字中

,进入FIN_WAIT_1状态,收到服务器的ACK后进入FIN_WAIT_2状态,再收到FIN后发送ACK然后进入TIME_WAIT状态,等待2MSL。


客户端程序运行时查看套接字状态:

$ netstat -a |grep 9877
tcp        0      0 *:9877                  *:*                     LISTEN     
tcp        0      0 localhost:36368         localhost:9877          ESTABLISHED
tcp        0      0 localhost:9877          localhost:36368         ESTABLISHED

客户端程序终止运行后查看套接字状态:

$ netstat -a |grep 9877
tcp        0      0 *:9877                  *:*                     LISTEN     
tcp        0      0 localhost:36368         localhost:9877          TIME_WAIT



三、 问题

1. 产生僵尸进程:

此时用 ps 查看程序状态发现存在僵尸进程

$ ps -o pid,ppid,stat,args
  PID  PPID STAT COMMAND
30143 30142 Ss   -bash
34810 30143 S    ./tcpserv01
34812 34810 Z    [tcpserv01] <defunct>
34813 30143 R+   ps -o pid,ppid,stat,args

如何杀死这个僵尸进程?

从上面的PID,PPID可以看出进程的父子关系,僵尸进程本身是杀不死的,要杀死它,只需要杀死他的父进程即可。

原因:僵尸进程产生的原因是子进程需要将运行状态向父进程汇报,其实每个子进程结束后都会进入这个状态,只不过父进程如果及时处理了,我们并观察不到这一过程。当子进程结束后如果父进程没有获取子进程状态,子进程就一直保持僵死状态,这需要占用资源。在父进程挂了之后,子进程的父进程会变为init进程,init进程会wait这个子进程,从而解放这个僵尸进程。

因此,我们只需要 kill 34810 就可以了

如何避免产生僵尸进程?

为了不产生僵尸进程,必须要有一个进程wait子进程,通常是父进程,如果父进程也死了,就转为init进程处理。

因此有2个方法:

1. 让 父进程调用wait或waitpid.

子进程在结束后内核会向父进程发送一个SIGCHLD信号,通知父进程子进程已经结束。这时父进程如果设置了信号处理函数那么就可以在信号处理函数中调用 wait或waitpid.,这里需要注意如果创建的子进程不止一个,那么需要在一个循环中调用waitpid来处理,并且设置WNOHANG参数,因为一个wait/waitpid只处理一个僵尸进程,而且调用wait时会挂起,这在信号处理函数中是不妥的。如果父进程不设置信号处理函数,那么就可以再父进程退出时调用wait,或waitpid,通常这种情况下父进程都是很快就退出,不然还是会产生僵尸进程。

2. 让init进程处理僵尸进程。

这种情况下是由于父进程没有处理SIGCHLD信号,或者在信号处理函数中没有waitpid,且父进程已经结束后才存在的情况。这时init就会成为僵尸进程的父进程,我们就不用管了。其实这中情况多半是由于父进程忘记处理了。


这里我们可以不处理SIGCHLD信号,因为这个信号并不会导致程序结束,只要在父进程中close后面调用wait 或waitpid,就可以了。


2. 如果设置了信号处理函数,必须考虑慢系统调用被中断的情况


为了说明这个问题,我们引入信号处理函数,其实信号处理就相当于一个软件中断,中断随时都可能发生,因此我们编写代码过程中需要考虑中断的情况。

服务端增加信号处理函数后代码如下:

int
main(int argc, char **argv)
{
int listenfd, connfd;
pid_t childpid;
socklen_t clilen;
struct sockaddr_incliaddr, servaddr;
void sig_chld(int signo);
Sigfunc * Signal(int signo, Sigfunc *func);

listenfd = Socket(AF_INET, SOCK_STREAM, 0);


bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family      = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port        = htons(SERV_PORT);


Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));


Listen(listenfd, LISTENQ);
Signal(SIGCHLD, sig_chld);


for ( ; ; ) {
clilen = sizeof(cliaddr);
connfd = Accept(listenfd, (SA *) &cliaddr, &clilen);

if ( (childpid = Fork()) == 0) {/* child process */
Close(listenfd);/* close listening socket */
str_echo(connfd);/* process the request */
exit(0);
}
Close(connfd);/* parent closes connected socket */
}
}


void
sig_chld(int signo)
{
pid_t pid;
int stat;


printf("enter sig_chld\n");
while ( (pid = waitpid(-1, &stat, WNOHANG)) > 0)
//while( (pid = wait(NULL)) > 0)
printf("child %d terminated\n", pid);
printf("quit sig_chld\n");
return;
}


上面例子中中断处理函数中调用printf是不太合适的,因为printf是不可重入函数,在程序规模比较大,进程多时可能出现奇怪错误,这里只为了查看程序状态。

Signal 是一个书中作者写的一个包裹函数,采用signation函数实现,实现代码中可以设置是否设置SA_RESTART,

这个配置就表示当系统调用被中断以后是否自动重新启动。

因为不同的UNIX系统实现可能不一样,有些系统默认重启有些则默认不重启,因此我们自己配置就可以更好控制,当然也为了不用直接配置signation,才将其包装起来。

对于accept、read、write、select等慢系统调用通常我们都希望他们被中断之后能继续返回中断前的状态继续执行,因为并不会产生错误,而对于connect在中断之后我们则不能重启,因为在中断之后其连接肯定会失败。

当服务器在accept阻塞时,假如进程突然崩溃,那么这时 子进程退出时会引发FIN字节发送到套接字,客户端收到后回应以一个ACK,同时内核向父进程发送一个SIGCHLD信号,父进程调用sig_chil处理,处理完成后返回accept调用,那么这时问题就来了,如果没有配置自动重启标识,accept调用将出错,并将errno 设为EINTR,正确的处理应该是退出程序,但是显然我们不希望这个结果。


如何解决呢:

通过上面的分析可以有2个方法:

1. 在配置信号处理函数时,设置act.sa_flags |= SA_RESTART;这样当accept被中断返回后,能继续阻塞。

2. 修改accept的判断条件。

当accept返回错误时,我们可以判断一下是否errno 为EINTR,如果是我们就手动重启accept。

connfd = accept(listenfd, (SA *) &cliaddr, &clilen);

if(connfd < 0)
{
if (errno == EINTR)
{
continue;
}
else
{
err_sys("accept error");
}
}

注意这里我们调用的时accept 而不是 包裹函数Accept.


3. 服务器进程意外终止会怎样?


这个问题也可以用上面的情形测试,我们通过kill掉服务器子进程来模拟。

对于客户端,当服务器终止后会受到服务器的FIN字节,客户端自动以ACK回应,表面服务端不在发送内容,但客户端并不知道服务器进程已经结束,因为客户端此时是阻塞于fgets的,并不会发送FIN字节给服务器,此时客户端认为链接并没有关闭,因此一直等待用户从标准输入输入字符,如果用户一直不输入那么程序永远不知道服务器已经挂了。

当用户输入一些字符的时候,服务器就会回应一个RST,客户才知道服务器已经挂了,如果客户继续发送内容将引发SIGPIPE信号(这种情况很可能发生,因为客户发给服务端的内容可能是分几次发送的,第一次发的时候就回收到RST,在收到RST期间还可能发送很多内容)。


如何解决:

这个问题的根本原因在于客户端,它不能仅仅阻塞与fgets,它应该同时关注stdin 和 socket ,任意一个退出都应该及时知道。因此可以使用select 来管理这2个描述符。


4. 发送数据格式有限制

当发送字符串时一般没什么问题,只要不同主机都支持同一中字符编码,但是如果发送的是二进制就有很多问题,比如不同主机字节序可能不同、CPU位数不同,各种数据类型占用空间以及对齐格式可能不同,这其实也是二进制文件的兼容性问题,因此兼容难度非常大。


5. 服务器崩溃 或者网络中断会怎样?


TCP有重传机制,当网络不通时,客户端将不停地重传未收到确认的分组,直到放弃。。。这里可能需要很久的时间,我们当然希望能尽快知道服务器崩溃的消息了,利用SO_KEEPALIVE套接字选项就可以解决这个问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值