golang中的socket程式設計

slowquery發表於2022-10-01

0.1、索引

waterflow.link/articles/1664591292...

1、tcp的3次握手(建立連線)

http://image-1313007945.cos.ap-nanjing.myqcloud.com/image/1664591520.png

  1. 客戶端的協議棧向伺服器端傳送了 SYN 包,並告訴伺服器端當前傳送序列號 j,客戶端進入 SYNC_SENT 狀態;
  2. 伺服器端的協議棧收到這個包之後,和客戶端進行 ACK 應答,應答的值為 j+1,表示對 SYN 包 j 的確認,同時伺服器也傳送一個 SYN 包,告訴客戶端當前我的傳送序列號為 k,伺服器端進入 SYNC_RCVD 狀態;
  3. 客戶端協議棧收到 ACK 之後,使得應用程式從 connect 呼叫返回,表示客戶端到伺服器端的單向連線建立成功,客戶端的狀態為 ESTABLISHED,同時客戶端協議棧也會對伺服器端的 SYN 包進行應答,應答資料為 k+1;
  4. 應答包到達伺服器端後,伺服器端協議棧使得 accept 阻塞呼叫返回,這個時候伺服器端到客戶端的單向連線也建立成功,伺服器端也進入 ESTABLISHED 狀態。

2、tcp的4次揮手(關閉連線)

http://image-1313007945.cos.ap-nanjing.myqcloud.com/image/1664591533.png

  1. 一方應用程式呼叫 close,我們稱該方為主動關閉方,該端的 TCP 傳送一個 FIN 包,表示需要關閉連線。之後主動關閉方進入 FIN_WAIT_1 狀態。
  2. 接收到這個 FIN 包的對端執行被動關閉。這個 FIN 由 TCP 協議棧處理,我們知道,TCP 協議棧為 FIN 包插入一個檔案結束符 EOF 到接收緩衝區中,應用程式可以透過 read 呼叫來感知這個 FIN 包。一定要注意,這個 EOF 會被放在已排隊等候的其他已接收的資料之後,這就意味著接收端應用程式需要處理這種異常情況,因為 EOF 表示在該連線上再無額外資料到達。此時,被動關閉方進入 CLOSE_WAIT 狀態。
  3. 被動關閉方將讀到這個 EOF,於是,應用程式也呼叫 close 關閉它的套接字,這導致它的 TCP 也傳送一個 FIN 包。這樣,被動關閉方將進入 LAST_ACK 狀態。
  4. 主動關閉方接收到對方的 FIN 包,並確認這個 FIN 包。主動關閉方進入 TIME_WAIT 狀態,而接收到 ACK 的被動關閉方則進入 CLOSED 狀態。進過 2MSL 時間之後,主動關閉方也進入 CLOSED 狀態。

3、socket中的連線建立和關閉

http://image-1313007945.cos.ap-nanjing.myqcloud.com/image/1664591553.png

我看先看下流程:

  1. 服務端呼叫socket、bind繫結ip埠、listen開啟服務端監聽。
  2. accept阻塞等待下次呼叫,並返回一個tcp連線。
  3. 客戶端呼叫connect連線服務端。
  4. 此時服務端accept結束阻塞,代表客戶端和服務端成功建立連線。
  5. 然後就是資料互動讀寫讀寫。
  6. 當客戶端連線關閉時,服務端的read方法會讀取一個io.EOF的錯誤,代表客戶端關閉連線。服務端收到關閉連線的錯誤後也呼叫close關閉連線。

4、golang中的連線建立

我們先看下服務端:

package main

import (
    "fmt"
    "net"
)

func main() {
    server := ":8330"
    tcpAddr, err := net.ResolveTCPAddr("tcp", server)
    if err != nil {
        fmt.Println("resolve err:", err)
        return
    }

  // 監聽某個埠的tcp網路
    listen, err := net.ListenTCP("tcp", tcpAddr)
    if err != nil {
        fmt.Println("listen err:", err)
        return
    }
    defer listen.Close()

    for {
    // 等待下次請求過來並建立連線
        conn, err := listen.Accept()
        if err != nil {
            fmt.Println("accept err:", err)
            continue
        }

    // 在這個連線上做一些事情
        go handler(conn)

    }
}

func handler(conn net.Conn) {
}
  1. 首先我們定義好ip和埠,開啟監聽
  2. 然後呼叫accept等待下次請求過來,並建立tcp連線

我們執行下上面的程式碼:

go run server.go

然後在另一個shell中執行下面的命令:

watch -d 'netstat -nat |grep "8330"'

Every 2.0s: netstat -nat |grep "8330"                                                 userdeMacBook-Pro.local: Thu Sep 29 16:38:42 2022

tcp46      0      0  *.8330                 *.*                    LISTEN

可以看到此時8330埠已經開啟監聽

客戶端:

package main

import (
    "fmt"
    "net"
)

func main() {
    serverAddr := ":8330"

    tcpAddr, err := net.ResolveTCPAddr("tcp", serverAddr)
    if err != nil {
        fmt.Println("resolve err:", err)
        return
    }

  // 發起一個tcp的網路撥號
    _, err = net.DialTCP("tcp", nil, tcpAddr)
    if err != nil {
        fmt.Println("dial err:", err)
        return
    }


    closed := make(chan bool)


  // 客戶端阻塞不直接關閉
    for  {
        select {
        case <-closed:
            fmt.Println("服務端關閉")
            return
        }
    }

}

其中核心的方法就是net.DialTCP,第一個引數會返回一個建立成功的連線,第二個引數會返回沒建立成功的錯誤資訊。

然後我們命令列執行下:

go run client.go

接著看下watch -d 'netstat -nat |grep "8330"'的返回,這個命令是實時的,所以不需要重複執行

Every 2.0s: netstat -nat |grep "8330"                                                 userdeMacBook-Pro.local: Thu Sep 29 16:45:57 2022

tcp4       0      0  127.0.0.1.8330         127.0.0.1.59146        ESTABLISHED
tcp4       0      0  127.0.0.1.59146        127.0.0.1.8330         ESTABLISHED
tcp46      0      0  *.8330                 *.*                    LISTEN

可以看到客戶端服務端,服務端和客戶端都成功建立了連線(連線是否建立成功不是看是否有條線真連上了,連線狀態是維護在各個端的)

同時我們也可以在wireshark中看到三次握手建立連線的流程:

http://image-1313007945.cos.ap-nanjing.myqcloud.com/image/1664591580.png

5、golang中的讀和寫

我們現在稍微修改下服務端的程式碼:

package main

import (
    "fmt"
    "io"
    "net"
    "time"
)

func main() {
    server := ":8330"
    tcpAddr, err := net.ResolveTCPAddr("tcp", server)
    if err != nil {
        fmt.Println("resolve err:", err)
        return
    }

    listen, err := net.ListenTCP("tcp", tcpAddr)
    if err != nil {
        fmt.Println("listen err:", err)
        return
    }
    defer listen.Close()

    for {
        conn, err := listen.Accept()
        if err != nil {
            fmt.Println("accept err:", err)
            continue
        }

        go handler(conn)

    }
}

func handler(conn net.Conn) {

    go func() {
        for  {
      // 指定從buffer中讀取資料的最大容量
            var buf = make([]byte, 1024)
      // 從buffer中讀取資料並儲存到buf中,n代表實際返回的資料大小
            n, err := conn.Read(buf)
            if err != nil {
        // 客戶端關閉會觸發EOF
                if err == io.EOF {
                    conn.Close()
                    return
                }
                fmt.Println("read err:", err)
                return
            }

            fmt.Println("read data ", n, ":", string(buf))
        }
    }()


    curTime := time.Now().String()
  // 資料寫到緩衝區
    _, err := conn.Write([]byte(curTime))
    if err != nil {
        fmt.Println("write err:", err)
        return
    }
    fmt.Println("send data:", curTime)

}

首先要明白,作業系統核心會為每個連線的客戶端和服務端分配傳送緩衝區接收緩衝區

  1. 當客戶端需要傳送資料到服務端,呼叫conn.Write從客戶端緩衝區傳送資料到作業系統核心的傳送緩衝區。實際所做的事情是把資料從應用程式緩衝區中複製到作業系統核心的傳送緩衝區中,並不一定是把資料透過套接字寫出去。
  2. 資料透過tcp傳送到服務端的接收緩衝區,然後服務端的程式從接收緩衝區讀取資料。

非阻塞I/O,當應用程式呼叫非阻塞 I/O 完成某個操作時,核心立即返回,不會把 CPU 時間切換給其他程式,應用程式在返回後,可以得到足夠的 CPU 時間繼續完成其他事情。

讀操作:如果套接字對應的接收緩衝區沒有資料可讀,在非阻塞情況下 read 呼叫會立即返回,一般返回 EWOULDBLOCK 或 EAGAIN 出錯資訊。

寫操作:在非阻塞 I/O 的情況下,如果套接字的傳送緩衝區已達到了極限,不能容納更多的位元組,那麼作業系統核心會盡最大可能從應用程式複製資料到傳送緩衝區中,並立即從 write 等函式呼叫中返回。可想而知,在複製動作發生的瞬間,有可能一個字元也沒複製,有可能所有請求字元都被複製完成,那麼這個時候就需要返回一個數值,告訴應用程式到底有多少資料被成功複製到了傳送緩衝區中,應用程式需要再次呼叫 write 函式,以輸出未完成複製的位元組。

非阻塞 I/O 操作:複製→返回→再複製→再返回。

阻塞 I/O 操作:複製→直到所有資料複製至傳送緩衝區完成→返回。

golang中底層使用的還是非阻塞的I/O,但是在程式碼層面做了一些處理,讓使用者感覺是以阻塞方式呼叫的。

...

for {
        n, err := ignoringEINTRIO(syscall.Read, fd.Sysfd, p)
        if err != nil {
            n = 0
      // 非阻塞方式呼叫,如果遇到syscall.EAGAIN報錯,代表沒拿到資料,繼續迴圈
            if err == syscall.EAGAIN && fd.pd.pollable() {
                if err = fd.pd.waitRead(fd.isFile); err == nil {
                    continue
                }
            }
        }
        err = fd.eofError(n, err)
        return n, err
    }

...

6、golang中的關閉

在socket中,當客戶端呼叫close()方法時,其實就是傳送一個FIN標誌位,意思就是我要主動關閉TCP連線了。Close方法會讓對端的所有讀寫操作結束阻塞,並返回。

在golang中呼叫Close方法,會讓對端的Read讀取到EOF的錯誤,此時就代表我想關閉連線。對端接收到關閉的請求後也可以呼叫Close方法關閉連線。

客戶端:

package main

import (
    "fmt"
    "io"
    "net"
)

func main() {
    serverAddr := ":8330"

    tcpAddr, err := net.ResolveTCPAddr("tcp", serverAddr)
    if err != nil {
        fmt.Println("resolve err:", err)
        return
    }

    conn, err := net.DialTCP("tcp", nil, tcpAddr)
    if err != nil {
        fmt.Println("dial err:", err)
        return
    }


    closed := make(chan bool)
    go func() {
        for  {
            var buf = make([]byte, 1024)
            n, err := conn.Read(buf)
            if err != nil {
        // 讀取到EOF,服務端關閉連線
                if err == io.EOF {
                    conn.Close()
                    closed <- true
                    return
                }
                fmt.Println("read err:", err)
                return
            }

            fmt.Println("read data ", n, ":", string(buf))
        }
    }()

    for  {
        select {
        case <-closed:
            fmt.Println("服務端關閉")
            return
        }
    }

}

服務端:

package main

import (
    "fmt"
    "io"
    "net"
    "time"
)

func main() {
    server := ":8330"
    tcpAddr, err := net.ResolveTCPAddr("tcp", server)
    if err != nil {
        fmt.Println("resolve err:", err)
        return
    }

    listen, err := net.ListenTCP("tcp", tcpAddr)
    if err != nil {
        fmt.Println("listen err:", err)
        return
    }
    defer listen.Close()

    for {
        conn, err := listen.Accept()
        if err != nil {
            fmt.Println("accept err:", err)
            continue
        }

        go handler(conn)

    }
}

func handler(conn net.Conn) {

    go func() {
        for  {
            var buf = make([]byte, 1024)
            n, err := conn.Read(buf)
            if err != nil {
        // 讀取到EOF,客戶端關閉連線
                if err == io.EOF {
                    conn.Close()
                    return
                }
                fmt.Println("read err:", err)
                return
            }

            fmt.Println("read data ", n, ":", string(buf))
        }
    }()


    curTime := time.Now().String()
    _, err := conn.Write([]byte(curTime))
    if err != nil {
        fmt.Println("write err:", err)
        return
    }
    fmt.Println("send data:", curTime)

}

參考:

《極客時間:網路程式設計實戰》

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章