TCP粘包的解决方案

758

前言

学习了龙叔的文章 面试官多次问我TCP粘包,而我为何屡屡受挫?,看到后面有人询问细节,觉得有必要将龙叔的理论和具体的代码联系起来,成为一个完整的解决方案。所以看本文之前,请先读龙叔的文章作为理论基础。

基本概念

  1. TCP本质上是数据流,从原理上看,没有包的概念,TCP包对应用程序员可以是透明的。

  2. 粘包实际上是把底层包的实现和上层流的概念混在一起。

  3. 粘包问题本质上是如何确定数据流的边界。

确定边界的几种典型办法

1. 固定长度法:一般在简单的私有协议中实现,可以简化流程,方便实现。

通讯之前先通过第三方规定本次发送的包长

  • 阻塞发送与接收:

    发送:send(fd, wr_data_buf, wr_data_len, 0); /* wr_data_buf 数据缓存, wr_data_len预先设定的固定长度 */
    接收:recv(fd, wr_data_buf, wr_data_len, 0);
    

    如以上代码所示,发送和接收就直接调用socketAPI接口就可以了。这样写简单,但是有如下问题:

    • 容易阻塞主进程,引起多余的进程调度和不可控的系统超时;
    • 可以用独立的进程或者线程来优化,但会引起复杂的同步逻辑;
    • 无法适应大规模的发送端和接收端同时工作的场景。
  • 无阻塞的发送和接收: 这种方式编码复杂一点,但是解决了阻塞方式引起的问题,是目前的主流解决方案。

    • 发送端的流程图是这样的:

      Alice 固定长度法发送端流程图

      • 说明如下:

        • 流程图中假设发送的预设固定长度是1024个字节。
        • 如果利用EPOLL的Level方式,应该在EPOLLOUT的回调函数中调用alice_send_data,隐式实现3->4->3的循环流程。
        • 如果利用EPOLL的Edge方式,应该在EPOLLOUT的回调函数中调用alice_send_epoll,显式实现3-4-3的循环流程。
      • 伪代码是这样的:

         static int alice_send_data(int fd, char *wr_data_buf)
         {
             int n;
             n = send(fd, wr_data_buf + offset, 1024 - off, MSG_DONTWAIT); /* 无阻塞发送了n个字节*/
             if (n < 0) {
                 if (errno == EAGAIN || errno == EINTR)
                     return 0;
                 else {
                         return -1; /* error */
                 }
             } else if (n == 0) {
                     return 1; /* socket close */
             }
             offset += n; /*记住总共发了off个字节 */
             if (off < 1024)  /*如果小于预先给定的长度,返回0,继续调用本函数发送 */
                 return 0;
             return 1; /*发完了,返回1,继续下面的工作 */
         }
      
         static int alice_send_epoll(int fd, char *wr_data_buf) /* edge 方式 */
         {
             int offset = 0;
             int finish;
             
             do {
                 finish = alice_send_data(fd, wr_data_buf)
             } while (!finish);
         }
         ```
      
    • 接收端的流程图是这样的:

      Bob 固定长度法接收端流程图
      我们可以看出,接收部分可发送部分很相似,这样本文就不重复代码了。

2. 变长法: 在私有和公共协议中实现,比固定长度法稍微复杂一点,但比较灵活:

通讯之前先通过第三方规定长度的位置,以便接收端获取

  • 发送端知道发送数据的实际长度,然后加上记录长度的4个字节,算出数据总长,按照固定长度的办法发送。

  • 接收端则需要动态获得数据的实际长度,它的流程图是这样的:

    Bob 变长法接收端流程图

我们看出,变长法在接收端实际上是两步固定长度法,所以它比固定长度法复杂。但是由于发送端可以灵活的指定数据的长度,也就是每次发送的数据可以不同,应用更加广泛。

3. 特殊字符串法:在私有和公共协议中实现,比变长法更复杂,但是节省包头长度字段,处理更加灵活。

通讯之前通过第三方规定一个特殊的字符串,比如说'\r\n\r\n',接收端才能据此确定数据流的边界。

  • 发送端可以按照固定长度的办法发送。
  • 接收端则需要不断地查找接收缓冲里面的所有数据,看是否有特殊字符串的存在。它的流程图是这样的:
    Bob 特殊字符串接收端流程图

4. 混合法:在公共协议中实现比较多,兼顾灵活性和简单性的平衡。

  • 特殊字符串法的代码需要将缓冲区的所有数据和字符串比较,当发送比较大的数据时,效率会变得很低。为此又开发了一种混合方法。如著名的http包头就是用\r\n\r\来确定包头的边界,再在包头中通过指定content-lenght来指定数据的长度。
  • 它的具体实现是一般也是两步:
    • 发送一个较短的头部数据,用\r\n\r\n来确定边界。
    • 然后发送一个长的实际数据,用从头部中获得的长度来确定边界。

流程图和代码读者可以自行可以仿照变长法和特殊字符法来实现,这里就不介绍了。

结束语

本文的结论如下:

当我们设计一个系统的时候,简单性和灵活性是一对矛盾。我们的取舍应该根据系统的实际需要,而不是越灵活越好,或者越简单越好。

TCP粘包不是一个设计缺陷,而是TCP数据流的特点。其重点是接收端如何确定接收一个数据流的边界。因为发送端可以知道发送的数据长度,都可以用固定长度法来实现。