[关闭]
@FunC 2018-05-15T11:19:27.000000Z 字数 6628 阅读 1637

UNP CH05 TCP客户/服务器程序示例

unix


下面先是贴出代码,主要就是简单的TCP服务建立,可以先跳过:

TCP回射服务器程序:main函数

tcpserv01.c

  1. #include "unp.h"
  2. int main(int argc, char **argv) {
  3. int listenfd, connfd;
  4. pid_t childpid;
  5. socklen_t clilen;
  6. struct sockaddr_in cliaddr, servaddr;
  7. listenfd = Socket(AF_INET, SOCK_STREAM, 0);
  8. bzero(&servaddr, sizeof(servaddr));
  9. servaddr.sin_family = AF_INET;
  10. servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
  11. servaddr.sin_port = htons(SERV_PORT);
  12. Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));
  13. Listen(listenfd, LISTENQ);
  14. for ( ; ; ) {
  15. clilen = sizeof(cliaddr);
  16. connfd = Accept(listenfd, (SA *) &cliaddr, &clilen);
  17. if ( (childpid = Fork()) == 0) { /* child process */
  18. Close(listenfd); /* close listening socket */
  19. str_echo(connfd); /* process the request */
  20. exit(0);
  21. }
  22. Close(connfd); /* parent closes connected socket */
  23. }
  24. }

TCP回射服务器程序:str_echo函数

str_echo.c

  1. #include "unp.h"
  2. void str_echo(int sockfd) {
  3. ssize_t n;
  4. char buf[MAXLINE];
  5. again:
  6. while ( (n = read(sockfd, buf, MAXLINE)) > 0)
  7. Writen(sockfd, buf, n);
  8. // 处理 EINTR,继续read
  9. if (n < 0 && errno == EINTR)
  10. goto again;
  11. else if (n < 0)
  12. err_sys("str_echo: read error");
  13. }

TCP回射客户程序:main函数

tcpcli01.c

  1. #include "unp.h"
  2. int main(int argc, char **argv) {
  3. int sockfd;
  4. struct sockaddr_in servaddr;
  5. if (argc != 2)
  6. err_quit("usage: tcpcli <IPaddress>");
  7. sockfd = Socket(AF_INET, SOCK_STREAM, 0);
  8. bzero(&servaddr, sizeof(servaddr));
  9. servaddr.sin_family = AF_INET;
  10. servaddr.sin_port = htons(SERV_PORT);
  11. Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);
  12. Connect(sockfd, (SA *) &servaddr, sizeof(servaddr));
  13. str_cli(stdin, sockfd); /* do it all */
  14. exit(0);
  15. }

TCP回射客户程序:str_cli函数

str_cli函数负责从标准输入读入一行文本,写在服务器上,读回服务器对该行的回射,并把回射行写到标准输出上:
str_cli.c

  1. #include "unp.h"
  2. void str_cli(FILE *fp, int sockfd) {
  3. char sendline[MAXLINE], recvline[MAXLINE];
  4. while (Fgets(sendline, MAXLINE, fp) != NULL) {
  5. Writen(sockfd, sendline, strlen(sendline));
  6. if (Readline(sockfd, recvline, MAXLINE) == 0)
  7. err_quit("str_cli: server terminated prematurely");
  8. Fputs(recvline, stdout);
  9. }
  10. }

正常终止

当我们通过输入EOF字符来终止子进程时,调用 ps -o stat能看到我们的子进程的状态是 Z(表示僵死)。
这是因为服务器子进程终止时,给父进程发送一个SIGCHLD信号。而我们没有在代码中捕获该信号,同时该信号默认行为是被忽略,于是子进程进入僵死状态。

至此准备工作完成。

目的

本小节目的是示范我们在网络编程时可能会遇到的三种情况:
* 当fork进程时,必须捕获SIGCHLD信号,避免耗尽进程资源
* 当捕获信号时,系统调用会被中断,必须进行相应的处理
* SIGCHLD的信号处理函数必须正确编写,应该使用waitpid而不是wait,以免留下僵死进程

下面分别讲述这三种情况以及相关知识。

POSIX 信号处理

信号

信号(signal)通知某个进程发生了某个事件。但信号是异步发生的,即进程预先不知道信号发生的准确时间。

信号传递方向

信号与其处置

每个信号都有一个与之关联的处置(disposition)(也称为行为(action))。我们通过通过调用 sigaction 函数来设定一个信号的处置。大体来说有三种选择:
1. 提供一个信号处理函数(signal handler)。它在特定信号发生时将其捕获并调用函数(但信号SIGKILL和SIGSTOP不能被捕获)。
2. 忽略某个信号的处置。通过设置其处置为SIG_IGN实现(SIGKILL和SIGSTOP不能被忽略)。
3. 使用信号的默认处置。通过设置其处置为SIG_DEF实现。

signal 函数

建立信号处理的POSIX方法就是调用sigaction函数,但这个方法比较繁琐。简单的方法是调用古老的signal函数,但它有着各种各样的问题。这里我们定义自己的signal函数,内部调用sigaction函数,做到用期望的POSIX语义提供简单的接口。(见书中图5-6,lib/signal.c)

POSIX 信号语义

处理 SIGCHLD 信号

上面提到子进程终止后处于僵死(zombie)状态,该状态的目的是维护子进程的信息,以便父进程在以后某个时候获取。
为了避免进程僵死,我们需要给SIGCHLD信号一个信号处理函数,在函数体中功能调用wait。
信号处理函数在调用listen之后安装。

下面先介绍wait和waitpid

wait 和 waitpid 函数

  1. #include <sys/wait.h>
  2. pid_t wait(int *statloc);
  3. pid_t waitpid(pid_t pid, int *statloc, int options);
  4. /* 返回:若成功则为进程ID,若出错则为0或-1 */

函数wait和waitpid均返回两个值:已终止子进程的进程ID号,以及通过statloc指针返回的子进程终止状态(一个整数)。(我们可以调用三个宏来检查终止状态,并辨别子进程是正常终止、由某个信号杀死还是仅仅由作业控制停止)

wait 和 waitpid 的区别

如果在调用wait时没有已终止的子进程,不过有子进程仍在执行,那么wait将阻塞到有子进程终止为止
而waitpid函数就等待哪个进程以及是否阻塞给了我们更多的控制。pid参数允许我们指定想等待的进程ID(值为-1表示等待第一个终止的子进程);options参数允许我们指定附加选项(如选项WNOHANG告知内核在没有已终止子进程时不要阻塞

处理僵死进程

我们应用waitpid处理僵死进程:

  1. #include "unp.h"
  2. void sig_chld(int signo) {
  3. pid_t pid;
  4. int stat;
  5. // 循环调用waitpid的原因见下方说明
  6. while( (pid = waitpid(-1, &stat, WNOHANG)) > 0)
  7. printf("child %d terminated\n", pid);
  8. return;
  9. }

在fork出多个子进程时(如并发服务器),主进程不调用close,直接通过exit(0)来关闭所有描述符时,建立一个信号处理函数,并在其中调用一次wait并不足以防止出现僵死进程
原因在于:Unix信号一般是不排队的。多个SIGCHLD信号在信号处理函数执行之前产生,信号处理函数只来得及捕获一个信号(只执行一次)。
而waitpid可行的原因在于它可以指定在有尚未中终止的子进程时不要阻塞,一次执行中循环调用,获取所有已终止子进程的状态。

解决子进程的问题后,还可能有主进程的问题:
[image:D7C780AB-9BB8-4724-9116-FFAB02BC0EBD-3133-00003A1FD1EE5055/6FB9F856-A271-430B-A9F1-E8E60F6D9B73.png]

我们在键入EOF时,子进程终止,发送SIGCHLD信号。
在SIGCHLD信号递交到父进程时,父进程阻塞于accept调用(属于慢系统调用),内核会使accept返回一个EINTR错误(被中断的系统调用)。因为父进程没有处理该错误,于是意外终止。

这种情况仅在特定环境中出现(如上述例子在Solaris 9环境中),其标准C函数库中提供的signal函数没有让内核将被中断的系统调用自动重启。

另外,在信号处理函数中我们显式给出return语句(尽管返回值类型为void),以确定系统调用具体被哪个信号处理函数的return语句中断。

处理被中断的系统调用

上面提到的慢系统调用(slow system call),是指:可能永远阻塞的系统调用,多数网络支持函数都属于这类。
使用于慢系统调用的基本规则是:当阻塞于某个慢系统调用的一个进程捕获某个信号且相应信号处理函数返回时,该系统调用可能返回一个EINTR错误。所以我们必须对慢系统调用返回EINTR有所准备:

  1. // ...
  2. for ( ; ; ) {
  3. clilen = sizeof(cliaddr);
  4. if( (connfd = accept(listenfd, (SA *) &cliaddr, &clilen) < 0) {
  5. if (errno == EINTR)
  6. continue; /* 处理EINTR,继续for循环 */
  7. else
  8. err_sys("accept error");
  9. }
  10. //...

大部分系统调用都应该重启,除了connect。connect在出现EINTR时若再次调用,会立即返回一个错误。

accept返回前连接终止

如果三路握手完成从而连接建立后,客户TCP却发送了一个RST(复位)。在服务端看来,连接进入TCP排队,等待服务端进程调用accept的时候RST到达:
[image:BC9C93AF-62EB-4293-B685-9582EB74EA53-3133-00003F9F387B63DA/5FD8F61D-ED2B-445B-A657-8D09A2B59084.png]

这时会导致accept返回一个非致命的错误(errno值为ECONNABORTED),服务端可以忽略它,再次调用accept就行。

服务器进程终止

我们模拟服务器进程崩溃的情况:启动客户/服务器对,然后杀死服务器子进程。
[image:871C3021-C86C-497E-AE59-67B789ACFD10-3133-00003FEEF67534E8/BAC412B7-BEA6-4B01-819D-F258A4B6C09E.png]

杀死服务器子进程时,服务器向客户发送一个FIN,然后客户TCP接收并响应一个ACK。
然而问题是客户进程阻塞在了fgets调用上,它在等待从中断接收一行文本
这时我们输入“another line”时,客户TCP接着把数据发送给服务器(这是允许的,因为此时服务端的FIN只表示服务端不再发送数据,但告知客户TCP服务器进程已经终止)

于是服务器TCP会响应RST。

但是客户进程看不到RST,因为它调用writen后立即调用readline,同时因为接收到的FIN,readline立即返回0(表示EOF)。而因为未预期收到EOF,所以输出错误信息并退出(通过err_quit)

将在下一章解决这个问题。

SIGPIPE信号

下面是另一种情况:客户不理会readline函数返回的错误,反而写更多的数据到服务器上。(例如在读回任何数据之前执行两次对服务器的写操作,而第一次写操作引发RST)

这种情况的规则是:当一个进程向某个已收到RST的套接字执行写操作时,内核向该进程发送一个SIGPIPE信号。该信号的默认行为时终止进程,因此进程必须捕获它以避免不情愿地被终止。

于是我们可以把str_cli函数作出以下修改:

  1. #include "unp.h"
  2. void str_cli(FILE *fp, int sockfd) {
  3. char sendline[MAXLINE], recvline[MAXLINE];
  4. while (Fgets(sendline, MAXLINE, fp) != NULL) {
  5. Writen(sockfd, sendline, 1); /* 引发RST */
  6. sleep(1);
  7. Writen(sockfd, sendline+1, strlen(sendline)-1); /* 产生SIGPIPE */
  8. if (Readline(sockfd, recvline, MAXLINE) == 0)
  9. err_quit("str_cli: server terminated prematurely");
  10. Fputs(recvline, stdout);
  11. }
  12. }

如果使用了多个套接字,该信号的递交无法告诉我们是哪个套接字出的错。
如果我们确实需要知道哪个write出了错,那么必须要么不理会该信号,要么从信号处理函数返回后再处理来自write的EPIPE。

服务器主机崩溃

服务器主机崩溃,意味着发送数据时服务器主机不可达的情景。
在客户上键入文本后,阻塞于readline调用,等待回射的应答。这时通过tcpdump观察可以看到,客户TCP在持续重传(源自Berkeley的实现重传12次,等待约9分钟)。
放弃重传时,给客户进程返回一个错误。如果是根本没有响应,错误是ETIMEOUT;如果中间路由器判定服务器主机不可达,则返回的错误是EHOSTUNREACH或ENETUNREACH。

如果我们想更早检测出这种情况,可以对readline调用设置一个超时(14章中讨论)

如果想不主动发送数据就能检测出服务器主机的崩溃,那么需要使用SO_KEEPALIVE套接字选项(第7章讨论)

服务器主机崩溃后重启

如果服务器主机崩溃后重启,且在重启后客户才向服务器发送信息,服务器主机会因为丢失了所有连接信息而响应RST,客户的readline调用返回ECONNRESET错误

如果想客户不主动发送数据也能检测出服务器主机崩溃,可以使用SO_KEEPALIVE套接字选项或者某些客户/服务器心搏函数。

服务器主动关机

Unix 系统关机时,init进程通常先给所有进程发送SIGTERM信号(可捕获),等待一段固定时间(5~20s),然后给所有仍在运行的进程发送SIGKILL信号(不可捕获)。

这样留出了一小段时间来清除和终止正在运行的进程。

数据格式:在客户于服务器之间传递二进制结构

如果客户和服务器程序穿越套接字传递二进制值(而不是字符串),这时如果两者运行在字节序不一样或者所支持长整数的大小不一致的两个主机上时,工作将失常。

常用的解决方法有两种:
1. 使用文本串来串传递
2. 显式定义所支持数据类型的二进制格式(位数,大端/小端字节序)。远程过程调用(RPC)软件包通常使用这种技术。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注