12| 深入理解TCP协议中的动态数据传输 引用在上一篇文章里我在应用程序中模拟了 TCP Keep-Alive 机制完成 TCP 心跳检测达到发现不活跃连接的目的。在这一讲里我们将从 TCP 角度看待数据流的发送和接收。如果你学过计算机网络的话那么对于发送窗口、接收窗口、拥塞窗口等名词肯定不会陌生它们各自解决的是什么问题又是如何解决的在今天的文章里我希望能从一个更加通俗易懂的角度进行剖析。调用数据发送接口以后……在前面的内容中我们已经熟悉如何通过套接字发送数据比如使用 write 或者 send 方法来进行数据流的发送。我们已经知道调用这些接口并不意味着数据被真正发送到网络上其实这些数据只是从应用程序中被拷贝到了系统内核的套接字缓冲区中或者说是发送缓冲区中等待协议栈的处理。至于这些数据是什么时候被发送出去的对应用程序来说是无法预知的。对这件事情真正负责的是运行于操作系统内核的 TCP 协议栈实现模块。流量控制和生产者 - 消费者模型我们可以把理想中的 TCP 协议可以想象成一队运输货物的货车运送的货物就是 TCP 数据包这些货车将数据包从发送端运送到接收端就这样不断周而复始。我们仔细想一下货物达到接收端之后是需要卸货处理、登记入库的接收端限于自己的处理能力和仓库规模是不可能让这队货车以不可控的速度发货的。接收端肯定会和发送端不断地进行信息同步比如接收端通知发送端“后面那 20 车你给我等等等我这里腾出地方你再继续发货。”其实这就是发送窗口和接收窗口的本质我管这个叫做“TCP 的生产者 - 消费者”模型。发送窗口和接收窗口是 TCP 连接的双方一个作为生产者一个作为消费者为了达到一致协同的生产 - 消费速率、而产生的算法模型实现。说白了作为 TCP 发送端也就是生产者不能忽略 TCP 的接收端也就是消费者的实际状况不管不顾地把数据包都传送过来。如果都传送过来消费者来不及消费必然会丢弃而丢弃反过来使得生产者又重传发送更多的数据包最后导致网络崩溃。我想理解了“TCP 的生产者 - 消费者”模型再反过来看发送窗口和接收窗口的设计目的和方式我们就会恍然大悟了。拥塞控制和数据传输CP 的生产者 - 消费者模型只是在考虑单个连接的数据传递但是 TCP 数据包是需要经过网卡、交换机、核心路由器等一系列的网络设备的网络设备本身的能力也是有限的当多个连接的数据包同时在网络上传送时势必会发生带宽争抢、数据丢失等这样TCP 就必须考虑多个连接共享在有限的带宽上兼顾效率和公平性的控制这就是拥塞控制的本质。举个形象一点的例子有一个货车行驶在半夜三点的大路上这样的场景是断然不需要拥塞控制的。我们可以把网络设备形成的网络信息高速公路和生活中实际的高速公路做个对比。正是因为有多个 TCP 连接形成了高速公路上的多队运送货车高速公路上开始变得熙熙攘攘这个时候就需要拥塞控制的接入了。在 TCP 协议中拥塞控制是通过拥塞窗口来完成的拥塞窗口的大小会随着网络状况实时调整。拥塞控制常用的算法有“慢启动”它通过一定的规则慢慢地将网络发送数据的速率增加到一个阈值。超过这个阈值之后慢启动就结束了另一个叫做“拥塞避免”的算法登场。在这个阶段TCP 会不断地探测网络状况并随之不断调整拥塞窗口的大小。现在你可以发现在任何一个时刻TCP 发送缓冲区的数据是否能真正发送出去至少取决于两个因素一个是当前的发送窗口大小另一个是拥塞窗口大小而 TCP 协议中总是取两者中最小值作为判断依据。比如当前发送的字节为 100发送窗口的大小是 200拥塞窗口的大小是 80那么取 200 和 80 中的最小值就是 80当前发送的字节数显然是大于拥塞窗口的结论就是不能发送出去。这里千万要分清楚发送窗口和拥塞窗口的区别。发送窗口反应了作为单 TCP 连接、点对点之间的流量控制模型它是需要和接收端一起共同协调来调整大小的而拥塞窗口则是反应了作为多个 TCP 连接共享带宽的拥塞控制模型它是发送端独立地根据网络状况来动态调整的。一些有趣的场景注意我在前面的表述中提到了在任何一个时刻里TCP 发送缓冲区的数据是否能真正发送出去用了“至少两个因素”这个说法细心的你有没有想过这个问题除了之前引入的发送窗口、拥塞窗口之外还有什么其他因素吗我们考虑以下几个有趣的场景第一个场景接收端处理得急不可待比如刚刚读入了 100 个字节就告诉发送端“喂我已经读走 100 个字节了你继续发”在这种情况下你觉得发送端应该怎么做呢第二个场景是所谓的“交互式”场景比如我们使用 telnet 登录到一台服务器上或者使用 SSH 和远程的服务器交互这种情况下我们在屏幕上敲打了一个命令等待服务器返回结果这个过程需要不断和服务器端进行数据传输。这里最大的问题是每次传输的数据可能都非常小比如敲打的命令“pwd”仅仅三个字符。这意味着什么这就好比每次叫了一辆大货车只送了一个小水壶。在这种情况下你又觉得发送端该怎么做才合理呢第三个场景是从接收端来说的。我们知道接收端需要对每个接收到的 TCP 分组进行确认也就是发送 ACK 报文但是 ACK 报文本身是不带数据的分段如果一直这样发送大量的 ACK 报文就会消耗大量的带宽。之所以会这样是因为 TCP 报文、IP 报文固有的消息头是不可或缺的比如两端的地址、端口号、时间戳、序列号等信息 在这种情形下你觉得合理的做法是什么TCP 之所以复杂就是因为 TCP 需要考虑的因素较多。像以上这几个场景都是 TCP 需要考虑的情况一句话概况就是如何有效地利用网络带宽。第一个场景也被叫做糊涂窗口综合症这个场景需要在接收端进行优化。也就是说接收端不能在接收缓冲区空出一个很小的部分之后就急吼吼地向发送端发送窗口更新通知而是需要在自己的缓冲区大到一个合理的值之后再向发送端发送窗口更新通知。这个合理的值由对应的 RFC 规范定义。第二个场景需要在发送端进行优化。这个优化的算法叫做 Nagle 算法Nagle 算法的本质其实就是限制大批量的小数据包同时发送为此它提出在任何一个时刻未被确认的小数据包不能超过一个。这里的小数据包指的是长度小于最大报文段长度 MSS 的 TCP 分组。这样发送端就可以把接下来连续的几个小数据包存储起来等待接收到前一个小数据包的 ACK 分组之后再将数据一次性发送出去。第三个场景也是需要在接收端进行优化这个优化的算法叫做延时 ACK。延时 ACK 在收到数据后并不马上回复而是累计需要发送的 ACK 报文等到有数据需要发送给对端时将累计的 ACK捎带一并发送出去。当然延时 ACK 机制不能无限地延时下去否则发送端误认为数据包没有发送成功引起重传反而会占用额外的网络带宽。禁用 Nagle 算法有没有发现一个很奇怪的组合即 Nagle 算法和延时 ACK 的组合。这个组合为什么奇怪呢我举一个例子你来体会一下。比如客户端分两次将一个请求发送出去由于请求的第一部分的报文未被确认Nagle 算法开始起作用同时延时 ACK 在服务器端起作用假设延时时间为 200ms服务器等待 200ms 后对请求的第一部分进行确认接下来客户端收到了确认后Nagle 算法解除请求第二部分的阻止让第二部分得以发送出去服务器端在收到之后进行处理应答同时将第二部分的确认捎带发送出去。你从这张图中可以看到Nagle 算法和延时确认组合在一起增大了处理时延实际上两个优化彼此在阻止对方。从上面的例子可以看到在有些情况下 Nagle 算法并不适用 比如对时延敏感的应用。幸运的是我们可以通过对套接字的修改来关闭 Nagle 算法。int on 1; setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void *)on, sizeof(on));值得注意的是除非我们对此有十足的把握否则不要轻易改变默认的 TCP Nagle 算法。因为在现代操作系统中针对 Nagle 算法和延时 ACK 的优化已经非常成熟了有可能在禁用 Nagle 算法之后性能问题反而更加严重。将写操作合并其实前面的例子里如果我们能将一个请求一次性发送过去而不是分开两部分独立发送结果会好很多。所以在写数据之前将数据合并到缓冲区批量发送出去这是一个比较好的做法。不过有时候数据会存储在两个不同的缓存中对此我们可以使用如下的方法来进行数据的读写操作从而避免 Nagle 算法引发的副作用。ssize_t writev(int filedes, const struct iovec *iov, int iovcnt) ssize_t readv(int filedes, const struct iovec *iov, int iovcnt);这两个函数的第二个参数都是指向某个 iovec 结构数组的一个指针其中 iovec 结构定义如下struct iovec { void *iov_base; /* starting address of buffer */ size_t iov_len; /* size of buffer */ };”下面的程序展示了集中写的方式int main(int argc, char **argv) { if (argc ! 2) { error(1, 0, usage: tcpclient IPaddress); } int socket_fd; socket_fd socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in server_addr; bzero(server_addr, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_port htons(SERV_PORT); inet_pton(AF_INET, argv[1], server_addr.sin_addr); socklen_t server_len sizeof(server_addr); int connect_rt connect(socket_fd, (struct sockaddr *) server_addr, server_len); if (connect_rt 0) { error(1, errno, connect failed ); } char buf[128]; struct iovec iov[2]; char *send_one hello,; iov[0].iov_base send_one; iov[0].iov_len strlen(send_one); iov[1].iov_base buf; while (fgets(buf, sizeof(buf), stdin) ! NULL) { iov[1].iov_len strlen(buf); int n htonl(iov[1].iov_len); if (writev(socket_fd, iov, 2) 0) error(1, errno, writev failure); } exit(0); }这个程序的前半部分创建套接字建立连接就不再赘述了。关键的是 24-33 行使用了 iovec 数组分别写入了两个不同的字符串一个是“hello,”另一个通过标准输入读入。在启动该程序之前我们需要启动服务器端程序在客户端依次输入“world”和“network”world network接下来我们可以看到服务器端接收到了 iovec 组成的新的字符串。这里的原理其实就是在调用 writev 操作时会自动把几个数组的输入合并成一个有序的字节流然后发送给对端。received 12 bytes: hello,world received 14 bytes: hello,network总结今天的内容我重点讲述了 TCP 流量控制的生产者 - 消费者模型你需要记住以下几点发送窗口用来控制发送和接收端的流量阻塞窗口用来控制多条连接公平使用的有限带宽。小数据包加剧了网络带宽的浪费为了解决这个问题引入了如 Nagle 算法、延时 ACK 等机制。在程序设计层面不要多次频繁地发送小报文如果有可以使用 writev 批量发送。