什么是tcp
TCP,全稱Transmission Control Protocol澳叉,是一種傳輸控制協(xié)議隙咸,TCP協(xié)議也是計算機網(wǎng)絡中非常復雜的一個協(xié)議
tcp的特點
- tcp是面向連接的協(xié)議
- tcp是端到端的鏈接
- tcp提供可靠的傳輸服務
- tcp協(xié)議提供雙工通信
- tcp是面向字節(jié)流的協(xié)議
tcp粘包
tcp有這么多的特點,但是為什么還會出現(xiàn)粘包呢成洗?其實這是對tcp傳輸?shù)囊环N優(yōu)化而引起的一些問題五督。
為什么要優(yōu)化?
我們前面說了瓶殃, tcp是面向字節(jié)流的協(xié)議充包,而不是消息包的協(xié)議,為什么是面向字節(jié)流遥椿?因為一個tcp連接基矮,它負責傳輸數(shù)據(jù),但是這些數(shù)據(jù)的大小是未知的冠场,可能很大家浇,也可能很小,而且是沒有邊界的慈鸠,它只會將你的數(shù)據(jù)編程字節(jié)流發(fā)到對面去蓝谨,而且保證順序不會亂,而對于字節(jié)流的解析青团,就需要我們自己來搞定了譬巫,那數(shù)據(jù)怎么傳輸呢?方法來了督笆,不管你是什么數(shù)據(jù)芦昔,我都給你轉(zhuǎn)換成二進制。然后由tcp切割為tcp認為合適的長度娃肿。那么這個長度怎么確定咕缎?
tcp協(xié)議簡介有興趣的同學可以看一下阮一峰的文章。
我們知道料扰,從應用層到物理層凭豪,數(shù)據(jù)都是一層一層經(jīng)過打包過的,我們可能一下子沒法知道tcp最大傳輸多少晒杈,但是我們可以反推一下嫂伞,以太網(wǎng)數(shù)據(jù)包(packet)的大小是固定的,最初是1518字節(jié),后來增加到1522字節(jié)帖努。其中撰豺, 1500 字節(jié)是負載(payload),22字節(jié)是頭信息(head)拼余。IP 數(shù)據(jù)包在以太網(wǎng)數(shù)據(jù)包的負載里面污桦,它也有自己的頭信息,最少需要20字節(jié)匙监,所以 IP 數(shù)據(jù)包的負載最多為1480字節(jié)凡橱。TCP 數(shù)據(jù)包在 IP 數(shù)據(jù)包的負載里面。它的頭信息最少也需要20字節(jié)舅柜,因此 TCP 數(shù)據(jù)包的最大負載是 1480 - 20 = 1460 字節(jié)梭纹。由于 IP 和 TCP 協(xié)議往往有額外的頭信息,所以 TCP 負載實際為1400字節(jié)左右致份。這里插播一個http2的一個改進 相關參考
在 HTTP/1 中变抽,HTTP 請求和響應都是由「狀態(tài)行、請求 / 響應頭部氮块、消息主體」三部分組成绍载。一般而言,消息主體都會經(jīng)過 gzip 壓縮滔蝉,或者本身傳輸?shù)木褪菈嚎s過后的二進制文件(例如圖片击儡、音頻),但狀態(tài)行和頭部卻沒有經(jīng)過任何壓縮蝠引,直接以純文本傳輸阳谍。而http2里面的一個重大改進,就是壓縮http的協(xié)議的頭信息螃概,怎么實現(xiàn)的頭部壓縮呢矫夯?主要是基于以下幾點:維護一份相同的靜態(tài)字典(Static Table),包含常見的頭部名稱吊洼,以及特別常見的頭部名稱與值的組合训貌;
維護一份相同的動態(tài)字典(Dynamic Table),可以動態(tài)地添加內(nèi)容冒窍;
支持基于靜態(tài)哈夫曼碼表的哈夫曼編碼(Huffman Coding)
[圖片上傳失敗...(image-e64940-1677756025803)]好了递沪,我們前面說了,一個tcp包負載是1400字節(jié)左右综液,那么你發(fā)送2000個字節(jié)款慨,就需要發(fā)送兩個數(shù)據(jù)包,第二個數(shù)據(jù)包可能就是600個字節(jié)谬莹。那么問題來了檩奠,明明一次可以發(fā)送1400字節(jié)约素,但是實際只發(fā)送600個字節(jié),是不是有點浪費網(wǎng)絡之間的IO,怎么辦笆凌?John Nagle(約翰.納格) 提出了一種簡單有效的解決方法。也就是Nagle 算法士葫。相關參考
Nagle 算法
Nagle 算法的基本定義是任一時刻乞而,最多只能有一個未被確認的小段。所謂“小段”慢显,指的是長度小于 MSS 尺寸的數(shù)據(jù)塊爪模,而未被確認則是指沒有收到對方的 ACK 數(shù)據(jù)包。Nagle 算法的規(guī)則(參考 tcp_output.c 文件里 tcp_nagle_check 函數(shù)注釋):
如果包長度達到 MSS荚藻,則允許發(fā)送屋灌;
如果該數(shù)據(jù)包含有 FIN,則允許發(fā)送应狱;
設置了 TCP_NODELAY 選項共郭,則允許發(fā)送;
未設置 TCP_CORK 選項時疾呻,若所有發(fā)出去的小數(shù)據(jù)包(包長度小于 MSS)均被確認除嘹,則允許發(fā)送;
上述條件都未滿足岸蜗,但發(fā)送了超時(一般為 200 ms)尉咕,則立即發(fā)送。
該算法的精妙之處在于它實現(xiàn)了自時鐘(self-clocking)控制:ACK 返回得快璃岳,數(shù)據(jù)傳輸也越快年缎。在相對高延遲的廣域網(wǎng)中,更需要減少微型報的數(shù)目铃慷,該算法使得單位時間內(nèi)發(fā)送的報文段數(shù)據(jù)更少单芜。也就是說,RTT 控制著發(fā)包速率枚冗。簡單理解 就是如果你普通的數(shù)據(jù)流缓溅,小于傳輸?shù)呢撦d量,我就不傳輸赁温,等到下次有數(shù)據(jù)滿足了我的負載量我再傳輸坛怪,但是我也不能一直等,如果時間超過200ms都么有數(shù)據(jù)流過來股囊,那我就傳輸袜匿。
tcp粘包的演示
服務端
package main
import (
"bufio"
"fmt"
"io"
"net"
)
func main() {
network:="tcp"
address:="127.0.0.1:30000"
//綁定和監(jiān)聽tpc和端口
listen, err := net.Listen(network, address)
if err != nil {
fmt.Println("listen err")
}
//關閉監(jiān)聽
defer listen.Close()
for{
//等待連接
conn,err:=listen.Accept()
if err != nil {
fmt.Println("accept error")
}
//從連接里面讀取數(shù)據(jù)
go process(conn)
}
}
func process(conn net.Conn){
defer conn.Close()//關閉連接
//讀取連接數(shù)據(jù)
reader:=bufio.NewReader(conn)
//定義每次接收的長度
buf:=make([]byte, 7)
for {
//用buf接收連接發(fā)送的內(nèi)容
read, err := reader.Read(buf)
//讀完了
if err == io.EOF {
break
}
if err != nil {
fmt.Println("read conn err")
}
fmt.Printf("the msg i read length is %d \n",read)
str:=string(buf[:read])
fmt.Println(str)
}
}
客戶端代碼
package main
import (
"fmt"
"net"
)
func main() {
network:="tcp"
address:="127.0.0.1:30000"
//撥號 請求創(chuàng)建tcp連接
conn, err := net.Dial(network,address )
if err != nil {
fmt.Println("connect err")
}
//關閉連接
defer conn.Close()
//想tcp寫入數(shù)據(jù)
conn.Write([]byte("123456789"))
}
- 我們先后發(fā)送123,1234567稚疹,123456789和123居灯,456并打印出來看看祭务,我們來看截圖里面的內(nèi)容,發(fā)現(xiàn)出現(xiàn)了問題怪嫌,這就是粘包造成的問題义锥,可能會把你的消息分段發(fā)送,也可能會把多段消息合并 岩灭。
[圖片上傳失敗...(image-9b5fc6-1677756025803)]
tcp粘包的解決
- tcp是只負責按順序傳輸數(shù)據(jù)拌倍,并沒有邊界的概念,那么我們?nèi)绻胍_定消息邊界噪径,就得發(fā)送一種信號柱恤,或者說一種約定,當接收者接到這種信號找爱,就能知道是消息的開始還是結(jié)尾梗顺,比如我們的http請求有一個content-length
[圖片上傳失敗...(image-a834cd-1677756025803)]
那么我們約定消息邊界一般有三種模式 - 定長消息:協(xié)議提前約定好包的長度為多少,每當接收端接收到固定長度的字節(jié)就確定一個包车摄,就像咱們上面截圖的那個
- 消息分隔符:利用特殊符號標志著消息的開始或者結(jié)束寺谤,例如 HTTP 協(xié)議中的換行符;
- 長度前綴:先發(fā)送N個字節(jié)代表包的大辛钒恪(注意大端和小端問題)矗漾,后續(xù)解析也按長度讀取解析。
粘包解決方案相關參考
- 這里我們使用第三種來實現(xiàn)薄料,即給消息體添加一個長度前綴敞贡。
- 我們先來寫一個文件,基于長度前綴來編碼和解碼消息
package tcp_code
import (
"bufio"
"bytes"
"encoding/binary"
)
// Encode 將消息編碼后返回byte類型
func Encode(msg string)([]byte,error){
//1.讀取消息的長度,用int32存放消息長度,這個長度大概能支持4G的數(shù)據(jù)傳輸,如果用int64就代表16777216T
length:=int32(len(msg))
//定義一個Buffer結(jié)構(gòu)體用來存儲數(shù)據(jù),Buffer是一個變長緩沖區(qū),可讀可寫
var pkg =new(bytes.Buffer)
//把長度以二進制的形式寫入消息頭
err := binary.Write(pkg, binary.LittleEndian, length)
if err != nil {
return nil, err
}
//把消息以二進制的形式寫入pkg
err = binary.Write(pkg, binary.LittleEndian, []byte(msg))
if err != nil {
return nil, err
}
//將緩沖區(qū)的數(shù)據(jù)返回
return pkg.Bytes(),nil
}
// Decode 參數(shù)是從連接中獲取的原始消息,用這個方法將消息體解碼
func Decode(reader bufio.Reader)(string,error){
//1.獲取消息的長度
//按照約定,讀取前32的長度
//Peek是返回字節(jié)類型,一個字節(jié)是8個bit,所以是4個字節(jié)即代表32位的長度的數(shù)據(jù)
lengthByte,_:=reader.Peek(4)
//轉(zhuǎn)換為buff類型
lengthBuff:=bytes.NewBuffer(lengthByte)
//這個長度是指消息體的長度
var length int32
//將長度賦值給length
err := binary.Read(lengthBuff, binary.LittleEndian, &length)
if err != nil {
return "", err
}
//消息體的長度加上4個字節(jié) 就是完整的消息體了
totalLen:=length+4
//查看當前緩存區(qū)中消息的長度,如果消息還沒有傳輸完畢,先不處理
if int32(reader.Buffered())<totalLen{
return "", err
}
//定義一個切片從緩沖區(qū)獲取數(shù)據(jù)
pack:=make([]byte,totalLen)
_, err = reader.Read(pack)
if err != nil {
return "", err
}
//返回消息 ,注意不要返回前4個byte,前4個byte代表的是消息體的長度
return string(pack[4:]),nil
}
- 服務端代碼
package main
import (
tcp_code "acurd.com/pkg/pkg/tcp-code"
"bufio"
"fmt"
"io"
"net"
)
func main() {
network:="tcp"
address:="127.0.0.1:30000"
//綁定和監(jiān)聽tpc和端口
listen, err := net.Listen(network, address)
if err != nil {
fmt.Println("listen err")
}
//關閉監(jiān)聽
defer listen.Close()
for{
//等待連接
conn,err:=listen.Accept()
if err != nil {
fmt.Println("accept error")
}
//從連接里面讀取數(shù)據(jù)
go process(conn)
}
}
func process(conn net.Conn){
defer conn.Close()//關閉連接
//讀取連接數(shù)據(jù)
reader:=bufio.NewReader(conn)
//定義每次接收的長度
for {
//使用decode解碼消息
msg, err := tcp_code.Decode(reader)
//讀完了
if err == io.EOF {
break
}
if err != nil {
fmt.Println("read conn err")
}
fmt.Println(msg)
}
}
- 客戶端代碼
package main
import (
tcp_code "acurd.com/pkg/pkg/tcp-code"
"bufio"
"fmt"
"io"
"net"
)
func main() {
network:="tcp"
address:="127.0.0.1:30000"
//綁定和監(jiān)聽tpc和端口
listen, err := net.Listen(network, address)
if err != nil {
fmt.Println("listen err")
}
//關閉監(jiān)聽
defer listen.Close()
for{
//等待連接
conn,err:=listen.Accept()
if err != nil {
fmt.Println("accept error")
}
//從連接里面讀取數(shù)據(jù)
go process(conn)
}
}
func process(conn net.Conn){
defer conn.Close()//關閉連接
//讀取連接數(shù)據(jù)
reader:=bufio.NewReader(conn)
//定義每次接收的長度
for {
//使用decode解碼消息
msg, err := tcp_code.Decode(reader)
//讀完了
if err == io.EOF {
break
}
if err != nil {
fmt.Println("read conn err")
}
fmt.Println(msg)
}
}
-
我們看一下效果摄职,發(fā)送了兩個消息誊役,一12345678,一個是abcdefghi
image.png
總結(jié)
通過上面,我們了解到了原來粘包的問題谷市,并不屬于tcp的鍋蛔垢。tcp是基于數(shù)據(jù)流的傳輸,保證數(shù)據(jù)流的順序迫悠,但是正式由于這種數(shù)據(jù)流的傳輸模式鹏漆,對于tcp來說,自己就像一個傳送帶创泄,傳遞的是一個個的快遞包裹艺玲,源源不斷。具體包裹到是什么鞠抑,到哪里去饭聚,就需要接收端和發(fā)送端通過定制的協(xié)議來編碼和解碼解決。