Unix/Linux编程:TCP并不总是“可靠”的

你可能会认为,TCP是一种可靠的协议,这种可靠体现在端对端的通信上。这似乎给我们带来了一种错觉,从发送端来看,应用程序通过调用send函数发送的数据流总能可靠的到达接收端;而从接收端来看,总是可以把对端发送的数据流完整无损的传递给应用程序来处理。

事实上,如果我们对TCP传输环节进行详细的分析,就会发现,上面论断是不正确的。

  • 发送端通过调用 send 函数之后,数据流并没有马上通过网络传输出去,而是存储在套接字的发送缓冲区中,由网络协议栈决定何时发送、如何发送。当对应的数据发送给接收端,接收端回应 ACK,存储在发送缓冲区的这部分数据就可以删除了,但是,发送端并无法获取对应数据流的 ACK 情况,也就是说,发送端没有办法判断对端的接收方是否已经接收发送的数据流,如果需要知道这部分信息,就必须在应用层自己添加处理逻辑,例如显式的报文确认机制。
  • 从接收端来说,也没有办法保证ACK过的数据部分可以被应用程序处理,因为数据需要接收端程序从接收缓冲区拷贝,可能出现的状况是,已经ACK的数据保存在接收到缓冲区中,接收端处理程序突然崩溃了,这部分数据就没法被应用程序继续处理

你有没有发现,TCP 协议实现并没有提供给上层应用程序过多的异常处理细节,或者说,TCP 协议反映链路异常的能力偏弱,这其实是有原因的。要知道,TCP 诞生之初,就是为了美国国防部服务的,考虑到军事作战的实际需要,TCP 不希望暴露更多的异常细节,而是能够以无人值守、自我恢复的方式运作。

TCP连接建立之后,能够感知TCP链路的方式是有限的,一种是以read为核心的读操作,另一种是以write为核心的写操作。接下来,我们就看下如何通过读写操作来感知异常情况,以及对应的处理方式。

故障模式

TCP是一个端对端协议,也就是说它自己要在对等体之间提供可靠的传输机制。但是,认识到“端点”位于对等的TCP层,而不是对等的应用程序是非常重要的。要求进行段堆段确认的应用程序必须自身提供此项功能。

故障模式:只要两个对等实体仍然连着,TCP就能保证将数据按序、无损坏的传送。只有连接中断时才会出现故障。什么类型的事件会造成这种中断呢?有三种情况可能会引发此类问题

  • 永久或者临时的网络中断
  • 对等的实体程序崩溃
  • 运行对等应用程序的主机崩溃

=在这里我把这几种异常情况归结为两大类:
在这里插入图片描述
第一类,是对端无 FIN 包发送出来的情况;第二类是对端有 FIN 包发送出来。而这两大类情况又可以根据应用程序的场景细分,接下来我们详细讨论。

网络中断造成的对端无FIN包

  • 路由器或者骨干链路损毁,网线送了…很多原因都会造成网络的中断。在端点之外发生的损毁往往是临时的,因为路由协议会发现问题,并使路由绕开出问题的节点。(这里的端点是指应用程序所处的LAN或者主机)
  • 端点出问题时,通常没有备用路径,所以问题会一直存在,直到端点修复为止。
  • 在这种情况下,TCP 程序并不能及时感知到异常信息。除非网络中的其他设备,如路由器发出一条 ICMP 报文,说明目的网络或主机不可达,这个时候通过 read 或 write 调用就会返回 Unreachable 的错误。
  • 可惜大多数时候并不是如此,在没有 ICMP 报文的情况下,TCP 程序并不能理解感应到连接异常。如果程序是阻塞在 read 调用上,那么很不幸,程序无法从异常中恢复。这显然是非常不合理的,不过,我们可以通过给 read 操作设置超时来解决
  • 如果程序先调用了 write 操作发送了一段数据流,接下来阻塞在 read 调用上,结果会非常不同。Linux 系统的 TCP 协议栈会不断尝试将发送缓冲区的数据发送出去,大概在重传 12 次、合计时间约为 9 分钟之后,协议栈会标识该连接异常,这时,阻塞的 read 调用会返回一条 TIMEOUT 的错误信息。如果此时程序还执着地往这条连接写数据,写操作会立即失败,返回一个 SIGPIPE 信号给应用程序。

系统崩溃造成的对端为FIN包

  • 当系统突然崩溃,比如断电时,网络连接上来不及发出任何东西。这里和通过系统调用杀死应用程序非常不同的是,没有任何FIN包被发送出来
  • 这里和网络中断造成的结果非常相似,在没有ICMP报文的情况下,TCP程序只能通过read和write调用得到网络连接异常的信息,超时错误是一个常见的结果。
  • 不过还有一种情况需要考虑,那就是系统在崩溃之后又重启,当重传的TCP分组到达重启后的系统,由于系统中没有该TCP分组对应的连接数据,系统会返回一个RST重置分节,TCP程序通过read或者write调用可以分别对RST进行错误处理
  • 如果是阻塞的 read 调用,会立即返回一个错误,错误信息为连接重置(Connection Resest)。
  • 如果是一次 write 操作,也会立即失败,应用程序会被返回一个 SIGPIPE 信号。

对端有FIN包发出

  • 对端如果有 FIN 包发出,可能的场景是对端调用了 close 或 shutdown 显式地关闭了连接,也可能是对端应用程序崩溃,操作系统内核代为清理所发出的。从应用程序角度上看,无法区分是哪种情形。FIN作为EOF使用,表示发送它的那一端已经没有数据要发送了(这并不一定表示发送FIN的这端已经退出了,甚至无法说明它不愿意接收更多数据)
  • 阻塞的 read 操作在完成正常接收的数据读取之后,FIN 包会通过返回一个 EOF 来完成通知,此时,read 调用返回值为 0。这里强调一点,收到 FIN 包之后 read 操作不会立即返回。你可以这样理解,收到 FIN 包相当于往接收缓冲区里放置了一个 EOF 符号,之前已经在接收缓冲区的有效数据不会受到影响。

例子

// 服务端程序
int main(int argc, char **argv) {
    int connfd;
    char buf[1024];
 
    connfd = tcp_server(SERV_PORT);
 
    for (;;) {
        int n = read(connfd, buf, 1024);
        if (n < 0) {
            error(1, errno, "error read");
        } else if (n == 0) {
            error(1, 0, "client closed \n");
        }
 
        sleep(5);
 
        int write_nc = send(connfd, buf, n, 0);
        printf("send bytes: %zu \n", write_nc);
        if (write_nc < 0) {
            error(1, errno, "error write");
        }
    }
 
    exit(0);
}

服务端程序是一个简单的应答程序,在收到数据流之后回显给客户端,在此之前,休眠 5 秒,以便完成后面的实验验证。

客户端程序从标准输入读入,将读入的字符串传输给服务器端:

// 客户端程序
int main(int argc, char **argv) {
    if (argc != 2) {
        error(1, 0, "usage: reliable_client01 <IPaddress>");
    }
 
    int socket_fd = tcp_client(argv[1], SERV_PORT);
    char buf[128];
    int len;
    int rc;
 
    while (fgets(buf, sizeof(buf), stdin) != NULL) {
        len = strlen(buf);
        rc = send(socket_fd, buf, len, 0);
        if (rc < 0)
            error(1, errno, "write failed");
        rc = read(socket_fd, buf, sizeof(buf));
        if (rc < 0)
            error(1, errno, "read failed");
        else if (rc == 0)
            error(1, 0, "peer connection closed\n");
        else
            fputs(buf, stdout);
    }
    exit(0);
}

read 直接感知 FIN 包

我们依次启动服务器端和客户端程序,在客户端输入 good 字符之后,迅速结束掉服务器端程序,这里需要赶在服务器端从睡眠中苏醒之前杀死服务器程序。

屏幕上打印出:peer connection closed。客户端程序正常退出。

$./reliable_client01 127.0.0.1
$ good
$ peer connection closed

这说明客户端程序通过 read 调用,感知到了服务端发送的 FIN 包,于是正常退出了客户端程序。
在这里插入图片描述
注意如果我们的速度不够快,导致服务器端从睡眠中苏醒,并成功将报文发送出来后,客户端会正常显示,此时我们停留,等待标准输入。如果不继续通过 read 或 write 操作对套接字进行读写,是无法感知服务器端已经关闭套接字这个事实的。

通过 write 产生 RST,read 调用感知 RST

这一次,我们仍然依次启动服务器端和客户端程序,在客户端输入 bad 字符之后,等待一段时间,直到客户端正确显示了服务端的回应“bad”字符之后,再杀死服务器程序。客户端再次输入 bad2,这时屏幕上打印出”peer connection closed“。

$./reliable_client01 127.0.0.1
$bad
$bad
$bad2
$peer connection closed

在这里插入图片描述
在很多书籍和文章中,对这个程序的解读是,收到 FIN 包的客户端继续合法地向服务器端发送数据,服务器端在无法定位该 TCP 连接信息的情况下,发送了 RST 信息,当程序调用 read 操作时,内核会将 RST 错误信息通知给应用程序。这是一个典型的 write 操作造成异常,再通过 read 操作来感知异常的样例。

不过,我在 Linux 4.4 内核上实验这个程序,多次的结果都是,内核正常将 EOF 信息通知给应用程序,而不是 RST 错误信息。

我又在 Max OS 10.13.6 上尝试这个程序,read 操作可以返回 RST 异常信息。输出和时序图也已经给出。

$./reliable_client01 127.0.0.1
$bad
$bad
$bad2
$read failed: Connection reset by peer (54)

向一个已关闭连接连续写,最终导致 SIGPIPE

为了模拟这个过程,我对服务器端程序和客户端程序都做了如下修改。

nt main(int argc, char **argv) {
    int connfd;
    char buf[1024];
    int time = 0;
 
    connfd = tcp_server(SERV_PORT);
 
    while (1) {
        int n = read(connfd, buf, 1024);
        if (n < 0) {
            error(1, errno, "error read");
        } else if (n == 0) {
            error(1, 0, "client closed \n");
        }
 
        time++;
        fprintf(stdout, "1K read for %d \n", time);
        usleep(1000);
    }
 
    exit(0);
}

服务器端每次读取 1K 数据后休眠 1 秒,以模拟处理数据的过程。

客户端程序在第 8 行注册了 SIGPIPE 的信号处理程序,在第 14-22 行客户端程序一直循环发送数据流。

int main(int argc, char **argv) {
    if (argc != 2) {
        error(1, 0, "usage: reliable_client02 <IPaddress>");
    }
 
    int socket_fd = tcp_client(argv[1], SERV_PORT);
 
    signal(SIGPIPE, SIG_IGN);
 
    char *msg = "network programming";
    ssize_t n_written;
 
    int count = 10000000;
    while (count > 0) {
        n_written = send(socket_fd, msg, strlen(msg), 0);
        fprintf(stdout, "send into buffer %ld \n", n_written);
        if (n_written <= 0) {
            error(1, errno, "send error");
            return -1;
        }
        count--;
    }
    return 0;
}

如果在服务端读取数据并处理过程中,突然杀死服务器进程,我们会看到客户端很快也会退出,并在屏幕上打印出“Connection reset by peer”的提示。

$./reliable_client02 127.0.0.1
$send into buffer 5917291
$send into buffer -1
$send: Connection reset by peer

这是因为服务端程序被杀死之后,操作系统内核会做一些清理的事情,为这个套接字发送一个 FIN 包,但是,客户端在收到 FIN 包之后,没有 read 操作,还是会继续往这个套接字写入数据。这是因为根据 TCP 协议,连接是双向的,收到对方的 FIN 包只意味着对方不会再发送任何消息。 在一个双方正常关闭的流程中,收到 FIN 包的一端将剩余数据发送给对面(通过一次或多次 write),然后关闭套接字。

当数据到达服务器端时,操作系统内核发现这是一个指向关闭的套接字,会再次向客户端发送一个 RST 包,对于发送端而言如果此时再执行 write 操作,立即会返回一个 RST 错误信息。
在这里插入图片描述
在很多书籍和文章中,对这个实验的期望结果不是这样的。大部分的教程是这样说的:在第二次 write 操作时,由于服务器端无法查询到对应的 TCP 连接信息,于是发送了一个 RST 包给客户端,客户端第二次操作时,应用程序会收到一个 SIGPIPE 信号。如果不捕捉这个信号,应用程序会在毫无征兆的情况下直接退出。

我在 Max OS 10.13.6 上尝试这个程序,得到的结果确实如此。你可以看到屏幕显示和时序图。

#send into buffer 19 
#send into buffer -1 
#send error: Broken pipe (32)

这说明,Linux4.4 的实现和类 BSD 的实现已经非常不一样了。因此,我们需要记得为 SIGPIPE 注册处理函数,通过 write 操作感知 RST 的错误信息,这样可以保证我们的应用程序在 Linux 4.4 和 Mac OS 上都能正常处理异常。

可靠性:是什么,不是什么

如下图所示,数据流从应用程序A通过它所在主机的TCP/IP栈向下传输,经过几台中间路由器,通过应用程序B所在的主机的TCP/IP栈向上传输,最后抵达应用程序B。一个TCP段离开应用程序A所在的主机的TCP层时,会被封装到一个IP数据报中,传递给对等主机。它可能需要经过很多路由器,这些路由器没有TCP层,它们只是转发了IP数据报
在这里插入图片描述
我们知道IP是个不可靠的协议,那就应该很清楚,在数据传输路径上,第一个可以讨论确保可靠传输问题的地方就是应用程序B所在主机的TCP层。当一个段到达应用程序B所在主机的TCP层时,唯一可以确定的就是这个段已经到达了,但它可能损坏了,可能是重复的数据,可能是错序的,或者是其他一些原因无法接收的。注意,发送端TCP无法对这些抵达接收端TCP的段做出任何保证

但接收方TCP要向发送方TCP确认,也就是说它ACK的数据以及在此数据之前到达的所有数据在TCP层已经正确收到了,发送端TCP可以安全的删除这些数据的副本了,这并不意味着已经将数据传送,或者总是可以将数据传送给应用程序。比如,接收端主机可能在刚刚对数据进行了ACK,但应用程序还没有将其读走之前,就崩溃了。这个问题值得进一步讨论:TCP向发送端提供的唯一一个数据接收通知就是这个ACK。发送端应用程序无法从TCP自身排队对等实体应用程序是否真的收到数据了。稍后我们会说明,这是应用程序编写者要弄清楚的一种TCP故障模式。

另一个可以确保可靠传输问题的地方是应用程序B。我们知道,无法保证应用程序A发送的所有数据都会到达。TCP能够向应用程序B保证的是所有到达的数据都是按序而且未受损的

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值