go语言处理粘包问题

语言: CN / TW / HK

粘包的定义

  • 粘包是指网络通信中,发送方发送的多个数据包在接收方的缓冲区黏在一起,多个数据包首尾相连的现象。
  • 例如,基于tcp的套接字实现的客户端向服务器上传文件时,内容往往是按照一段一段的字节流发送的,如果不做任何处理,从接收方来看,根本不知道该文件的字节流从何处开始,在何处结束。
  • 因此,所谓粘包问题主要是因为接收方不知道消息之间的界限,不知道一次提取多少字节的数据造成的。

产生的原因

粘包产生的原因有发送方和接收方两方面:

  • 发送方引起粘包的原因主要是由tcp协议本身造成的。众所周知, tcp协议是面向连接,面向流,提供高可靠性服务的 。tcp数据包转发使用Nagle算法,Nagle算法是为了提高tcp的传输效率,简单来说,当应用层有一个数据包要发送时,Nagle算法并不会立刻发送,而是继续收集要发送的消息,直到上一个包得到确认时,才会发送数据,此时Nagle算法会将收集的多个的数据包形成一个分组,将这个分组一次性转发出去。因此,Nagle算法造成了发送方可能存在粘包现象。具体过程如下图所示:

  • 接收方引起粘包的原因主要是由于接收方对数据包的处理速度远小于数据包的接收速度导致接收缓冲区的数据积压而造成的。
  • 然而udp协议不会出现粘包,因为 udp是无连接,面向消息,提供高效服务的 。无连接意味着当有数据包要发送时,udp会立即发送,数据包不会积压;面向消息意味着数据包一般很小,因此接收端处理也不会很耗时,一般不会由于接收端来不及处理而造成粘包。 最重要的时,udp不使用合并优化算法,每个消息都有单独的包头,即使出现很短时间内收到多个数据包的情况,接收方也能根据包头信息区分数据包之间的边界。 因此,udp不会出现粘包,只可能会出现丢包。

粘包示例(go语言实现)

服务端代码如下:

// socket_test/server/main.go

func process(conn net.Conn) {
    defer conn.Close()
    // 使用bufio的读缓冲区(防止系统缓冲区溢出)
    reader := bufio.NewReader(conn)
    var buf [1024]byte
    for {
        n, err := reader.Read(buf[:])
        if err == io.EOF {
            break
        }
        if err != nil {
            fmt.Println("读取客户数据失败,err:", err)
            break
        }
        recvData := string(buf[:n])
        fmt.Println("收到client发来的数据:", recvData)
    }
}

func main() {
    listen, err := net.Listen("tcp", "127.0.0.1:12345")
    if err != nil {
        fmt.Println("监听失败, err:", err)
        return
    }
    defer listen.Close()
    for {
        conn, err := listen.Accept()
        if err != nil {
            fmt.Println("建立会话失败, err:", err)
            continue
        }
        // 单独建一个goroutine来维护客户端的连接(不会阻塞主线程)
        go process(conn)
    }
}

客户端代码如下:

// socket_test/client/main.go

func main() {
    conn, err := net.Dial("tcp", "127.0.0.1:12345")
    if err != nil {
        fmt.Println("连接服务器失败, err", err)
        return
    }
    defer conn.Close()
    for i := 0; i < 20; i++ {
        msg := `Hello, Hello. How are you?`
        conn.Write([]byte(msg))
    }
}

将以上服务器和客户端的代码分别编译并运行,结果如下:

收到client发来的数据: Hello, Hello. How are you?Hello, Hello. How are you?Hello, Hello. How are you?Hello, Hello. How are you?Hello, Hello. How are you?
收到client发来的数据: Hello, Hello. How are you?Hello, Hello. How are you?Hello, Hello. How are you?Hello, Hello. How are you?Hello, Hello. How are you?Hello, Hello. How are you?Hello, Hello. How are you?Hello, Hello. How are you?
收到client发来的数据: Hello, Hello. How are you?Hello, Hello. How are you?
收到client发来的数据: Hello, Hello. How are you?Hello, Hello. How are you?Hello, Hello. How are you?
收到client发来的数据: Hello, Hello. How are you?Hello, Hello. How are you?

我们在客户端发送了10次数据,但在服务器在输出了5次,多条数据“粘”到了一起。

粘包的解决办法

基于之前的分析,我们只要给每个数据包封装一个包头,包头里只需包含数据包长度的信息,这样接收方在收到数据时就可以先读取包头的数据,通过包头就可以定位数据包的边界,粘包问题也就迎刃而解。说白了,就是需要我们定义一个应用之间通信的协议,完成对每个消息的编码和解码。代码如下:

// socket_test/proto/proto.go
package proto

import (
    "bufio"
    "bytes"
    "encoding/binary"
)

func Encode(msg string) ([]byte, error) {
    length := int32(len(msg))
    // 创建一个数据包
    pkg := new(bytes.Buffer)
    // 写入数据包头,表示消息体的长度
    err := binary.Write(pkg, binary.LittleEndian, length)
    if err != nil {
        return nil, err
    }
    // 写入消息体
    err = binary.Write(pkg, binary.LittleEndian, []byte(msg))
    if err != nil {
        return nil, err
    }
    return pkg.Bytes(), nil
}

func Decode(reader *bufio.Reader) (string, error) {
    // 读取前4个字节的数据(表示数据包长度的信息)
    // peek操作只读数据,但不会移动读取位置!!!
    lengthByte, _ := reader.Peek(4)
    // 将前4个字节数据读入字节缓冲区
    lengthBuf := bytes.NewBuffer(lengthByte)
    var dataLen int32
    // 读取数据包长度
    err := binary.Read(lengthBuf, binary.LittleEndian, &dataLen)
    if err != nil {
        return "", err
    }
    // 判断数据包的总长度是否合法
    if int32(reader.Buffered()) < dataLen + 4 {
        return "", err
    }
    pack := make([]byte, 4 + dataLen)
    // 读取整个数据包
    _, err = reader.Read(pack)
    if err != nil {
        return "", err
    }
    return string(pack[4:]), nil
}

客户端在发送消息时调用编码函数对消息进行编码,代码如下:

// socket_test/client2/main.go

package main

import (
    "fmt"
    "../proto"
    "net"
)

func main() {
    conn, err := net.Dial("tcp", "127.0.0.1:8888")
    if err != nil {
        fmt.Println("connect err=", err)
        return
    }
    fmt.Println("conn suc=", conn)

    for i := 0; i < 10; i++ {
        data, err := proto.Encode("hello, server!")
        if err != nil {
            fmt.Println("Encode failer, err = ", err)
            return
        }
        _, err = conn.Write(data)
        if err != nil {
            fmt.Println("send data failed, err= ", err)
            return
        }
    }
}

服务器接收消息时调用解码函数对消息进行解码,代码如下:

// socket_test/server2/main.go
package main

import (
    "bufio"
    "fmt"
    "../proto"
    "io"
    "net"
)

func process(conn net.Conn) {
    defer conn.Close()
    reader := bufio.NewReader(conn)
    for {
        msg, err := proto.Decode(reader)
        if err == io.EOF {
            return
        }
        if err != nil {
            fmt.Println("decode failed, err = ", err)
            return
        }
        fmt.Println("收到数据", msg)
    }
}

func main() {
    fmt.Println("服务器开始监听......")
    listen, err := net.Listen("tcp", "127.0.0.1:8888")
    if err != nil {
        fmt.Println("listen err=", err)
        return
    }
    fmt.Printf("listen suc=%v\n", listen)

    // 延迟关闭
    defer  listen.Close()

    // 循环等待客户端连接
    for {
        fmt.Println("循环等待客户端连接...")
        conn, err := listen.Accept()
        if err != nil {
            fmt.Println("Accept() err=", err)
        } else {
            fmt.Printf("Accept() suc=%v, 客户端ip=%v\n", conn, conn.RemoteAddr().String())
        }
        // 创建goroutine处理客户端连接
        go process(conn)
    }
}

测试结果如下:

收到client发来的数据: hello, server!
收到client发来的数据: hello, server!
收到client发来的数据: hello, server!
收到client发来的数据: hello, server!
收到client发来的数据: hello, server!
收到client发来的数据: hello, server!
收到client发来的数据: hello, server!
收到client发来的数据: hello, server!
收到client发来的数据: hello, server!
收到client发来的数据: hello, server!

可以看到,此时服务器接收的数据已经没有了粘包。

参考文献

  1. https://www.liwenzhou.com/pos...
  2. https://www.cnblogs.com/yinbi...
  3. https://www.cnblogs.com/steve...

旅程到此就圆满结束了~~~

我是lioney,年轻的后端攻城狮一枚,爱钻研,爱技术,爱分享。 个人笔记,整理不易,感谢阅读、点赞和收藏。 文章有任何问题欢迎大家指出,也欢迎大家一起交流后端各种问题!

分享到: