詳說tcp粘包和半包

peng發表於2020-06-18

tcp服務端和客戶端建立連線後會長時間維持這個連線,用於互相傳遞資料,tcp是以流的方式傳輸資料的,就像一個水管裡的水一樣,從一頭不斷的流向另一頭。
理想情況下,傳送的資料包都是獨立的,

現實要複雜一些,傳送方和接收方都有各自的緩衝區。
傳送緩衝區:應用不斷的把資料傳送到緩衝區,系統不斷的從緩衝區取資料傳送到接收端。
接收緩衝區:系統把接收到的資料放入緩衝區,應用不斷的從緩衝區獲取資料。
當傳送方快速的傳送多個資料包時,每個資料包都小於緩衝區,tcp會將多次寫入的資料放入緩衝區,一次傳送出去,伺服器在接收到資料流無法區分哪部分資料包獨立的,這樣產生了粘包。

或者接收方因為各種原因沒有從緩衝區裡讀取資料,緩衝區的資料會積壓,等再取出資料時,也是無法區分哪部分資料包獨立的,一樣會產生粘包。
傳送方的資料包大於快取區了,其中有一部分資料會在下一次傳送,接收端一次接收到時的資料不是完整的資料,就會出現半包的情況。

我們可以還原一下粘包和半包,寫一個測試程式碼
服務端

func main() {
	l, err := net.Listen("tcp", ":8899")
	if err != nil {
		panic(err)
	}
	fmt.Println("listen to 8899")
	for {
		conn, err := l.Accept()
		if err != nil {
			panic(err)
		} else {
			go handleConn(conn)
		}
	}
}

func handleConn(conn net.Conn) {
	defer conn.Close()
	var buf [1024]byte
	for {
		n, err := conn.Read(buf[:])
		if err != nil {
			break
		} else {
			fmt.Printf("recv: %s \n", string(buf[0:n]))
		}
	}
}

客戶端

func main() {
	data := []byte("~測試資料:一二三四五~")
	conn, err := net.Dial("tcp", ":8899")
	if err != nil {
		panic(err)
	}
	for i := 0; i < 2000; i++ {
		if _, err = conn.Write(data); err != nil {
			fmt.Printf("write failed , err : %v\n", err)
			break
		}
	}
}

檢視一下輸出

recv: ~測試資料:一二三四五~
recv: ~測試資料:一二三四五~ ~測試資料:一二三四五~ 
recv: ~測試資料:一� 
recv: ��三四五~ ~測試資料:一二三四五~ 
recv: ~測試資料:一二三四五~
recv: ~測試資料:一二三四五~ ~測試資料:一二三四五~ ~測試資料:一二三四五~ ~測試資料:一二三四五~ 
recv: ~測試資料:一二三四五~

正常情況下輸出是recv: ~測試資料:一二三四五~,發生粘包的時候會輸出多個資料包,當有半包的情況下輸出的是亂碼資料,再下一次會把剩下的半包資料也輸出。
要解決也簡單的就想辦法確定資料的邊界,常見的處理方式:

  • 固定長度: 比如規定所有的資料包長度為100byte,如果不夠則補充至100長度。優點就是實現很簡單,缺點就是空間有極大的浪費,如果傳遞的訊息中大部分都比較短,這樣就會有很多空間是浪費的,同樣浪費的還有流量。
  • 分隔符:用分隔符來確定資料的邊界,這樣做比較簡單也不浪費空間,但資料包內就不能包含相應的分隔符,如果有會造成錯誤的解析。
  • 資料頭:通過資料頭部來解析資料包長度,比如用4個位元組來當資料頭,儲存每個實資料包的長度。

個人更推薦資料頭方式來確定資料邊界,在傳送和接收資料時做好規定,每個資料包是不定長的,比如4位元組的包頭+真實的資料可以根據自己的業務進行擴充套件,比如上更多的包頭或者包尾,加上資料校驗等。
我修改一下上面的程式碼:
客戶端

	data := []byte("~測試資料:一二三四五~")
	conn, err := net.Dial("tcp", ":8899")
	if err != nil {
		panic(err)
	}
	for i := 0; i < 2000; i++ {
		var total int64 = -1
		var buf [4]byte
		bufs := buf[:]
		binary.BigEndian.PutUint32(bufs, uint32(len(data)))
		n, err := conn.Write(bufs)
		total += int64(n)
		n, err = conn.Write(data)
		total += int64(n)
		if err != nil {
			fmt.Printf("write failed , err : %v\n", err)
			break
		}
	}

服務端

func main() {
	l, err := net.Listen("tcp", ":8899")
	if err != nil {
		panic(err)
	}
	fmt.Println("listen to 8899")
	for {
		conn, err := l.Accept()
		if err != nil {
			panic(err)
		} else {
			go handleConn(conn)
		}
	}
}
func handleConn(conn net.Conn) {
	defer conn.Close()
	for {
		var msgSize int32
		err := binary.Read(conn, binary.BigEndian, &msgSize)
		if err != nil {
			break
		}
		buf := make([]byte, msgSize)
		_, err = io.ReadFull(conn, buf)
		if err != nil {
			break
		}
		fmt.Printf("recv: %s \n", string(buf))
	}
}

執行再看一下輸出,沒有粘包或者半包的情況

recv: ~測試資料:一二三四五~ 
recv: ~測試資料:一二三四五~ 
recv: ~測試資料:一二三四五~ 
recv: ~測試資料:一二三四五~ 
recv: ~測試資料:一二三四五~ 
recv: ~測試資料:一二三四五~

也可以像第一個例子一樣用一個指定大小的buf var buf [1024]byte,每次從conn裡取出指定大小的資料,然後進行資料解析,如果發現有半包的情況,就再讀取一次,加上上次未解析的資料,再次重新解析。

相關文章