TCP/IP 系列之包与流

最近工作有点忙,有些天没更新啦,趁着五一放假,接着写之前 TCP/IP 的相关话题。

前些天看到有人吐槽 TCP 粘包和拆包的话题,说所谓的粘包是新手发明出来的术语,有误人子弟之嫌。粘包一说,对我也是陌生的概念,第一反应可能由是英翻中导致的。比如 iOS 术语里将 GCD 翻译为大中枢派发,就显得怪异且难以适应,有些术语还是让它保持原汁原味的好。

在搜索相关话题之前,我一番脑洞,感觉粘包可能是在说 Nagle Algorithm。因为 Nagle Algorithm 应用的时候,确实会将两个来自于应用层的包合成为一个发送,和粘包一词有些贴近。这里不妨先科普下 Nagle Algorithm 和 Delayed Ack 的概念,以后万一遇到 TCP 性能相关的问题,可以朝着方面思考下,排除与之相关的坑。

Nagle Algorithm vs Delayed Ack

Nagle Algorithm 和 Delayed Ack 同时启用的时候,会导致 TCP 协议的整体性能表现严重下降,这也是和 TCP 相关的一个经典场景,有些平台上写网络代码的时候,甚至需要显式的禁用其中一项,不过 iOS 上一般不会遇到。

Nagle Algorithm 所要解决的是:频发发送小包(small packet or tinygram)所导致的流量浪费和网络阻塞问题。在使用 TCP 协议发送数据的时候,即使只发送一个字节,这一个字节要需要按照协议,被封装成一个包来发送,最少也需要加入一个 20 字节的 TCP header,和 20 字节的 IP header(IPv4),意味着要发送一个字节,需要额外的 40 个字节来组装包,流量无故大了近 40 倍。

用 ssh 登录远程服务器,在终端输入命令就是一个这种典型的场景,每敲一个字母,就产生一个包发送到服务器,如果不做任何处理,不光流量大很多,在带宽小,丢包率高的时候,还有可能引起 congestion。

Nagle 的策略很简单,发送一个小包后,如果这个小包还没有被 ack,后面等待发送的少量数据,则会在 buffer 中等待一小段时间,如此,等待的少量数据就有可能会被集中一起发送出去,这是一种用延迟来换取带宽利用率的机制。

而 Delayed Ack 刚好相反,是站在数据接收方的角度,尝试减少接收方所发送的 ack 数量。因为 TCP 每一个发送的包都需要被 ack,频发接收包的时候,一个明显可以实施的优化是,将多个 ack 打包一起发送,这也正是 Delayed Ack 的做法,在收到第一个包的时候,延迟发送 ack,同样以延迟来换取带宽利用率。

当发送方开启 Nagle Algorithm,同时接收方启用 Delayed ack 的时候,双方各自给通讯增加了一定的延迟,如果各几百毫秒的话,总延迟就不可小觑了。在一些实时性要求较高的应用里,这种额外的延迟几乎是不能忍受的。操作系统的实现,一般都提供接口来关闭两种机制。值得一提的是,无论 Nagle Algorithm,还是 Delayed Ack,针对的都是频繁发送小包的场景,而且是在带宽资源不够的场景去尝试提高利用率,现今的互联网基础设施已不可同日而语,大部分的流量(比如 HTTP)都不符合频繁发送小包的场景,Nagle Algorithm 和 Delayed Ack 所带来的优化意义不大,相反,其引入的延迟对于体验来说,却是至关重要的。

包与流

说回 TCP 粘包,我读了几篇相关中文技术文章之后(英文里找不到对应的提法),大概明白了,粘包与拆包其实是在说包与流的问题。包与流,在 TCP/IP 系列的开篇里就有提到过,这两个概念贯穿 TCP 协议,有些场景里我们谈论 packet,有些时候我们又会专注说 stream,二者总是交替出现,并不是完全独立的概念。在一个 stream 中,我们按照某种协议或者规定,把 stream 切割成一块块 buffer 的时候,就得到了一个个的 packet。

上面所说是包与流的抽象概念,这篇文章我们再深入下细节,以实际 iOS 代码为例子,看看包与流的区别于联系。

应用层在发送数据的时候,都是站在一个个包的视角,将包一个个发送出去,形成一个 stream,接收端收到 stream 之后,再按照具体的协议切割还原成发送方所发送的包。发送方每一次调用 send(),并不会在接收方有一次对应的 receive() 回调,有可能发送方 send() 10 次,每次 10 个字节,但是接受方只有一次 receive() 回调,一次将 100 个字节抛给应用层。receive() 收到的数据只能看做是一个 stream,所以,这里有一个包转换为流,然后流再还原成包的过程。HTTP 实现了这一过程,但 TCP 本身并不负责这一过程,当我们基于 TCP 或者其他传输层协议实现自己的通讯协议的时候,需要自己来处理这一转换过程。

这里介绍三种办法来做包与流之间的转换。

办法一:特殊切割符来分割包

这种办法粗暴简单,我们使用一个特殊字符来作为包与包之间的分隔符,不过这个分隔符要特殊,特殊到几乎不出现在包的内容当中,否则会影响接收方切割包的过程。

作为发送方,我们可以用如下代码(示意用):

#define kSeparatorChar @"¤"

+ (NSString*)encodeTextPayload:(NSString*)payload {
    NSString* str = [NSString stringWithFormat:@"%@%@", kSeparatorChar, payload];
    return str;
}

¤ 就是一个非常特殊的字符,一般应用层的文本都不会涉及到,所以可以用作我们的特殊分隔符。接收端只需要以 ¤ 为分隔符,再把数据做一次切割即可:

+ (NSString*)decodeTextPayloadString:(NSString*)str {
    NSString* payload;
    NSArray* arr = [str componentsSeparatedByString:kSeparatorChar];
    if (arr.count < 2) {
        return nil;
    }
    payload = arr[1];
    
    return payload;
}

这种做法的缺陷也是显而易见的,必须严格要求包体中不会出现该特殊字符,所以这种办法只能应用于非常特殊的场景。

办法二:每个包都是固定长度

这种办法也是粗暴简单,甚至不需要分隔符,每次接收方从 stream 中取出固定长度的字节,还原成一个包,代码也比较简单,在 receive() 回调里,每次检查是否达到了固定的长度,是则取出固定长度还原,否则继续等待,代码就不演示啦。

这种做法的缺陷就更大了,会造成包体的浪费,无法适应不同大小的包。

办法三:自定义协议,支持可变长度的包

之前一篇介绍自定义通讯协议的文章里,简单的提到过如何设计一个可用的协议,这里我们具体看下代码。

当我们需要描述可变长度的包时,需要定义一个 header 来详细描述包相关的信息,比如最简单的,记录包的长度。如何记录包的大小呢?我们可以用位操作的特性,来将应用层的 int 值放入到包的 header 中,代码如下(代码摘自以前的项目,稍有改动):

- (NSData*)encodeData:(NSData*)data withHeader:(NSString*)header {
    int dataSize = (int)data.length;
    char buffer[4];
    buffer[0] = dataSize >> 24;
    buffer[1] = (dataSize << 8) >> 24;
    buffer[2] = (dataSize << 16) >> 24;
    buffer[3] = (dataSize << 24) >> 24;
    
    NSMutableData* packet = [NSMutableData new];
    [packet appendBytes:[header UTF8String] length:2];
    [packet appendBytes:buffer length:4];
    [packet appendData:data];
    
    return packet;
}

这是一个通用的技巧,当我们需要在 stream 中记录可变长度的数据时,都可以用这种位操作来做转换,只需要 2 个字节的长度,即可记录长达 64 KB 的数据长度,4 个字节则能记录长达 4 GB 的长度。

接收方在收到 NSData 之后,可以先读取 4 个字节的长度信息,还原成 int 值,再读取 int 值所记录的字节数,这些字节就是我们的包了,代码如下:

- (TDecodedData*)decodeData:(NSData*)data {
    TDecodedData* d = [TDecodedData new];
    
    //check for complete packet
    if (data.length < 6) { //minimal packet length
        return nil;
    }
    
    if ([headerStr isEqualToString:kPacketStreamHeader] == true) {
        int realSize = 0;
        unsigned char buffer[4];
        [data getBytes:buffer range:NSMakeRange(2, 4)];
        realSize += buffer[0] << 24;
        realSize += buffer[1] << 16;
        realSize += buffer[2] << 8;
        realSize += buffer[3] << 0;
        
        if (data.length - 6 < realSize) {
            return nil;
        }
        
        d.header = kPacketStreamHeader;
        
        NSData* payloadBytes = [data subdataWithRange:NSMakeRange(6, realSize)];
        if (payloadBytes.length > 0) {
            d.decodedData = payloadBytes;
        }
        
        //remove from data
        int handledLength = 6 + realSize;
        NSData* nd = [NSData dataWithBytes:data.bytes + handledLength length:data.length-handledLength];
        d.handledData = nd;
        
    }
    return d;
}

上面的代码主要是向大家展示,如何以添加 header 的方式,来记录可变长度的包体信息。如此,发送方所发送的 NSData 就和接收方所接受的 NSData 一一对应起来了,就就不存在所谓的粘包和拆包问题了。

我们之所以可以对一个 stream 做切分,是因为 TCP 已经做了可靠传输的保证,接收方收到的 stream 和发送方发送的 stream 严格一致,一个字节都不会差,所以我们只需要先读取长度值,再按长度值读取后续的数据,就能把一个 stream 分割成一个个的 NSData,这些分割好的 NSData 就是发送方所发送的包了。

接收方将 stream 分割成 NSData 之后,需要进一步将 data 反序列化成应用层的包,这里就必须提到 google 开源的 protobuf 了,序列化和反序列化神器,造福了无数的框架和应用,甚至有 Objective C 的版本。

总结

这篇文章主要向大家介绍包与流的概念,大家也可以自己尝试下,如何在 iOS 设备上,一端发送 stream,另一端接收 stream,接收方能严格的将发送方所发送的包还原出来,能配合 protobuf,加上序列化功能就更好了,只有用代码实现过,才是真正掌握了。

欢迎关注公众号:MrPeakTech