UNP_5_TCP客户&服务器程序示例

Posted on Mar 5, 2020

5.1 概述

  本章使用上一章的函数来编写一个完整的 TCP 客户端服务器端的程序示例(回射服务器)

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

#include "unp.h"
int 
main(int argc, char** argv)
{
    int listenfd, connfd;
    pid_t childpid;
    socklen_t clilen;
    struct sockaddr_in cliaddr,servaddr;
    //创建一个TCP套接字,并捆绑通配地址,并将其转换为监听套接字
    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 函数来将描述符绑定至本地协议地址上
    Bind(listenfd, (SA*)&servaddr, sizeof(servaddr));
    // 转换成 listen 状态
    Listen(listenfd, LISTENQ);

    for(;;){
        client = sizeof(cliaddr);
        //到达这一步,服务器阻塞在accept调用,并等待客户连接的完成
        connfd = Accept(listenfd, (SA*) &cliaddr, &client);
        //服务器的并发部分处理,这里子进程并没有Close(connfd);
        if( (childpid = Fork()) == 0){
            // 关闭监听套接字描述符
            Close(listenfd);
            // 处理客户
            str_echo(connfd);
            exit(0);
        }
        // 父进程关闭套接字描述符
        Close(connfd);
    }
}

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

回射服务器逻辑部分:从客户读入数据,并回射给客户

#include "unp.h"
void str_echo(int sockfd){
    ssize_t n;
    char buf[MAXLINE];
again:
    //如果客户关闭连接,则read会返回 0,则此函数结束,否则,继续循环。。。
    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");
}

5.4 TCP回射客户程序:main函数

话不多说直接上代码。。。

#include "unp.h"
int main(int argc, char **argv){
    int sockfd;
    struct sockaddr_in servaddr;

    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);
    exit(0);
}

创建一个TCP套接字,用服务器的IP地址和端口号装填一个网际网套接字地址结构,我们可从命令行参数取得服务器的IP地址,从头文件中获取端口号:SERV_PORT
str_cli是客户端的逻辑代码部分

5.5 TCP回射客户程序:str_cli函数

从标准输入读入一行文本,写到服务器上,读回服务器对该行的回射,并把回射写到标准输出上

#include "unp.h"
void str_cli(FILE* fp, int sockfd){
    char sendline[MAXLINE], resvline[MAXLINE];
    // 不断地读入文本
    while(Fgets(sendline, MAXLINE, fp) != NULL){
        // 把文本发送给服务器
        Writen(sockfd, sendline, strlen(sndline));
        // 从服务器读取数据
        if(Readline(sockfd, recvline, MAXLINE) == 0){
            err_quit("something");
        }
        // 写到标准输出
        Fputs(recvline, stdout);
    }
}

读入一行文本,把该行发送给服务器,使用readline从服务器读入回射行,fputs把它写到标准输出
fgets 出错返回空指针,循环终止,遇到文件终止符 EOF 也会返回空指针

5.8 POSIX信号处理

信号就是某个进程发生了某个事件的通知,有时也称为软件中断,信号通常是异步发生的
信号可以由另一个进程发送给一个进程(或自身),也可以由内核发送给一个进程
SIGCHLD 是由内核在子进程终止时发送给其父进程的

  • 调用 sigaction 处理信号有关的三种选择
    • 提供一个函数,只有特定的信号才进行调用(捕获信号),有两个信号不能被捕获(SIGKILL,SIGSTOP)信号处理函数由信号值这个单一的整数参数进行调用,且没有返回值:void handler(int signo)
    • 可以将信号的处置设为SIG_IGN来进行忽略,SIGKILL和SIGSTOP这两个信号不能进行忽略
    • 可以吧某个信号的处置设定为SIG_DEF来进行默认处理,通产是在收到信号后进行终止进程(SIGCHID和SIGURG的默认处理是忽略)

一旦安装了信号处理函数,就一直安装着
在一个信号处理函数运行期间,被递交的信号阻塞
多个相同信号阻塞,恢复后仅递交一次
可以使用 sigprocmask 选择性的阻塞或解阻塞一组信号,带一些特殊代码片段进行操作

5.9 处理SIGCHLD信号

    设置僵死状态的子进程是为了维护子进程的信息,以便父进程随时获取。这些信息包括子进程的进程ID,终止状态以及资源利用信息(CPU,内存使用等等),如果父进程终止,那么它所有的僵死子进程的父进程ID会被重置为1(init进程)。继承这些子进程并清理它们(init进程会wait它们,从而去除它们的僵死状态)

处理僵死进程

    僵死进程会占用内核空间,会导致资源耗尽。无论何时fork子进程都需要wait它们以等待资源的释放,防止其变为僵死进程。要达到此目的,我们需要捕获SIGCHLD信号的信号处理函数。在函数中我们调用wait。
#include "unp.h"
Signal(SIGCHLD, sig_chld);

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

在信号处理函数中不建议使用printf。

  1. 我们键入EOF来终止客户,客户TCP发送一个FIN给服务器,服务器响应一个ACK
  2. 收到客户的FIN导致服务器TCP递送一个EOF给子进程阻塞中的readline,从而子进程终止
  3. 当SIGCHLD信号递交是,父进程阻塞于accept调用。sig_chld函数执行,其wait调用取到子进程的PID和终止状态,随后是printf调用并返回
  4. 既然信号是在父进程阻塞于慢系统调用(accept)时由父进程捕获的,内核就会使accept返回一个EINTR错误(被中断的系统调用),而父进程不处理该错误,于是中止
在信号处理函数中加入return语句虽然对于void返回函数来说并没有用处,但可以帮助我们来确定是那个函数来进行的处理(方便调试)

处理被中断的系统调用

我们用术语慢系统调用来描述accept函数,该属于也适用于那些可能永远阻塞的系统调用。永远阻塞的系统调用是指调用可能永远无法返回,多数网络支持函数都属于这一类,如果没有客户连接到服务器上,那么服务器的accept调用就没有返回的保证。

适用于慢系统调用的基本规则是:当阻塞于某个慢系统调用的一个进程捕获某个信号且响应信号处理函数返回时,系统调用可能返回一个EINTR错误,有些内核自动重启被中断的系统调用。不过为了便于移植,当我们编写捕获信号时,我们必须对慢系统调用返回EINTR有所准备,例子如下:
for( ; ; ){
    client = sizeof(cliaddr);
    if( (connfd = accept(listenfd, (SA*) &cliaddr, &clilen)) < 0) {
        if(errno == EINTR)
            continue;
        else
            err_sys("accept error");
    }
}

这段代码自己重启被中断的系统调用。对于accept以及read,write。select,open之类的函数来说合适,有一个函数我们不能重启:connect。如果该函数返回EINTR,不能再次调用它,会报错。当connect捕获一个信号并且不自动重启时,我们必须调用select来等待连接完成

5.10 wait和waitpid函数

使用这两个函数来处理已终止的子进程

#include <sys/wait.h>
pid_t wait(int *statloc);
pid_t waitpid(pid_t pid, int* statloc, int options);
//均返回:成功返回1,出错为0或-1

函数wait和waitpid均返回两个值:已终止子进程的进程ID号,以及通过statloc指针返回的子进程终止状态(一个整数)。 如果调用wait的进程没有已终止的子进程,不过有一个或多个子进程仍在执行,那么wait将阻塞到现有子进程第一个终止出现为止 waitpid函数就等待那个进程以及是否阻塞给了更多控制方式。pid 允许我们指定想等待的进程 ID,-1 代表第一个终止的子进程。options 可以添加选项,常用的有 WNOHANG 表示内核在没有终止子进程时不阻塞。

函数wait和waitpid的区别

wait函数可能在多个信号同时接收时只会执行一次,这个时候在信号处理函数中使用一个循环来调用waitpid来将所有僵死进程释放

重点速记

  1. 当fork子进程时必须捕获SIGCHLD信号
  2. 当捕获信号时,必须处理被中断的系统调用
  3. SIGCHLD的信号处理函数必须正确编写,应使用waitpid函数,以免留下僵死进程

服务器终极版本:

#include "unp.h"
int main(int argc, char** argv){
    int listenfd, connfd;
    pid_t childpid;
    socklen_t client;
    struct sockaddr_in cliaddr, servaddr;
    void sig_chld(int);
    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);
        if( (connfd = accept(listenfd, (SA*) &cliaddr, &clilen)) < 0){
            if(errno == EINTR)
                continue;
            else
                err_sys("accept error");
        }
        if( (childpid = Fork()) == 0){
            Close(listenfd);
            str_echo(connfd);
            exit(0);
        }
        Close(connfd);
    }
}

5.11 accept 返回前连接终止

当三次握手完毕,服务器准备调用 accept 时,客户端发送了 RST
不同的系统处理方式不同,有的不做回应,有的会返回特定错误

5.12 服务器进程终止

服务器进程崩溃处理

  1. 当服务器子进程终止时,子进程中所有打开的描述符都会被关闭。这导致向客户发送一个FIN,而客户TCP则响应一个ACK。完成TCP断开连接的前半部分。
  2. SIGCHLD信号被发送给服务器父进程,并正确处理
  3. 这时客户还阻塞在获取输入的状态,此时输入一行字符,这时客户会尝试向服务器发送数据,但是服务器已经关闭会响应一个RST,但是客户端看不到,因为它还在等待信息,所以阻塞在了readline,并且由于之前接收了FIN,所调用的readline立即返回0,我们的客户此时并未收到EOF,所以报错

本节重点:当FIN到达套接字时,客户阻塞在fgets上,客户实际在应对两个描述符,(套接字和用户输入),它不能单纯阻塞在这两个源中某个特定源的输入上。而是应该阻塞在其中任何一个源的输入上。这正是poll和select的目的之一,后面的处理方式是:一旦杀死服务器子程序,客户会立即被告知已收到FIN

5.13 SIGPIPE信号

当一个进程向某个已收到RST的套接字执行写操作时,内核向该进程发送一个SIGPIPE信号,默认行为是终止进程,所以必须要捕获它,防止进程被意外终止,忽略即可

5.14 服务器主机崩溃

  1. 服务器崩溃,此时客户写入一行文本,并且由writen写入内核,再由TCP客户作为一个数据分节送出,客户随后阻塞于readline调用,等待回射应答
  2. 这时客户会持续重传文本,试图从服务器接受一个ACK。当客户TCP最终放弃重传的时候(这段时间里,服务器没有重启成功),给客户进程返回一个错误。则调用readline返回一个错误,如果是服务器崩溃不可达,返回ETIMEOUT,如果是中间路由器表示不可达则响应一个“destination unreachable"的ICMP消息,这时返回的错误为EHOSTUNREACH或ENETUNREACH。

使用 keepalive 选项来检测之际是否连通

如果觉得客户重传时间过久,可以设置一个超时即可

5.15 服务器主机崩溃后重启

这里的关键之处在于如果客户端不知道服务器崩溃了,在服务器重启后,服务器会丢失所有连接信息,此时客户发送TCP,服务器会响应一个RST,这时阻塞于readline的客户会返回ECONNRESET错误。
如果客户需要实时监测服务器是否崩溃,则要采用一些技术(SO_KEEPALIVE套接字选项或/某些客户/服务器心博函数。

5.16 服务器主机关机

unix系统关机时,init进程会给所有进程发送SIGTERM信号,然后等待一段时间,然后给所有仍在运行的进程发送SIGKILL信号(该信号不可捕获),这么做是为了将一小段时间给进程做清除和终止,它所有打开的描述符都需要关闭释放,我们必须在客户中使用select或poll函数,使得服务器一终止,客户端可以检查到,否则会阻塞在 fgets 上。。。

5.18 数据格式

书上给了两个例子

  1. 在客户与服务器之间传递文本串,其中包含空格分开的两个数字,服务器返回二者之和
  2. 在客户与服务器之间传递二进制结构 但是存在问题
    1. 不同机器存在大端小端问题
    2. 不同实现存储的C数据类型上可能存在差异,它们各自的大小不确定相同
    3. 不同实现打包方式存在差异

解决办法

  • 把数值数据作为文本串来传递,确保机器上的字符集相同即可
  • 显示定义所支持数据类型的二进制格式(位数,大小端字节序)RPC 通常使用这种方式

总结

本章解决了几个问题 point

  1. 僵死子进程问题,通过捕获 SIGCHLD 处理,
  2. 处理信号的函数推荐使用 waitpid 函数进行处理
  3. 防止服务器崩溃,客户端不知道的情况
  4. 在网络中传输数值数据会产生一些问题
  5. 等等。。。