Go語言TCP網路程式設計(詳細)
一、序言
Golang的主要 設計目標之一就是面向大規模後端服務程式,網路通訊這塊是服務端 程式必不可少也是至關重要的一部分。在日常應用中,我們也可以看到Go中的net以及其subdirectories下的包均是“高頻+剛需”,而TCP socket則是網路程式設計的主流,即便您沒有直接使用到net中有關TCP Socket方面的介面,但net/http總是用到了吧,http底層依舊是用tcp socket實現的。
網路程式設計方面,我們最常用的就是tcp socket程式設計了,在posix標準出來後,socket在各大主流OS平臺上都得到了很好的支援。關於tcp programming,最好的資料莫過於W. Richard Stevens 的網路程式設計聖經《UNIX網路 程式設計 卷1:套接字聯網API》 了,書中關於tcp socket介面的各種使用、行為模式、異常處理講解的十分細緻。Go是自帶runtime的跨平臺程式語言,Go中暴露給語言使用者的tcp socket api是建立OS原生tcp socket介面之上的。由於Go runtime排程的需要,golang tcp socket介面在行為特點與異常處理方面與OS原生介面有著一些差別。這篇博文的目標就是整理出關於Go tcp socket在各個場景下的使用方法、行為特點以及注意事項。
二、模型
從tcp socket誕生後,網路程式設計架構模型也幾經演化,大致是:“每程序一個連線” –> “每執行緒一個連線” –> “Non-Block + I/O多路複用(linux epoll/windows iocp/freebsd darwin kqueue/solaris Event Port)”。伴隨著模型的演化,服務程式愈加強大,可以支援更多的連線,獲得更好的處理效能。
目前主流web server一般均採用的都是”Non-Block + I/O多路複用”(有的也結合了多執行緒、多程序)。不過I/O多路複用也給使用者帶來了不小的複雜度,以至於後續出現了許多高效能的I/O多路複用框架, 比如libevent、libev、libuv等,以幫助開發者簡化開發複雜性,降低心智負擔。不過Go的設計者似乎認為I/O多路複用的這種通過回撥機制割裂控制流 的方式依舊複雜,且有悖於“一般邏輯”設計,為此Go語言將該“複雜性”隱藏在Runtime中了:Go開發者無需關注socket是否是 non-block的,也無需親自注冊檔案描述符的回撥,只需在每個連線對應的goroutine中以“block I/O”的方式對待socket處理即可,這可以說大大降低了開發人員的心智負擔。 一個典型的Go server端程式大致如下:
//go-tcpsock/server.go func HandleConn(conn net.Conn) { defer conn.Close() for { // read from the connection // ... ... // write to the connection //... ... } } func main() { listen, err := net.Listen("tcp", ":8888") if err != nil { fmt.Println("listen error: ", err) return } for { conn, err := listen.Accept() if err != nil { fmt.Println("accept error: ", err) break } // start a new goroutine to handle the new connection go HandleConn(conn) } }
使用者層眼中看到的goroutine中的“block socket”,實際上是通過Go runtime中的netpoller通過Non-block socket + I/O多路複用機制“模擬”出來的,真實的underlying socket實際上是non-block的,只是runtime攔截了底層socket系統呼叫的錯誤碼,並通過netpoller和goroutine 排程讓goroutine“阻塞”在使用者層得到的Socket fd上。比如:當用戶層針對某個socket fd發起read操作時,如果該socket fd中尚無資料,那麼runtime會將該socket fd加入到netpoller中監聽,同時對應的goroutine被掛起,直到runtime收到socket fd 資料ready的通知,runtime才會重新喚醒等待在該socket fd上準備read的那個Goroutine。而這個過程從Goroutine的視角來看,就像是read操作一直block在那個socket fd上似的。具體實現細節在後續場景中會有補充描述。
三、TCP連線的建立
眾所周知,TCP Socket的連線的建立需要經歷客戶端和服務端的三次握手的過程。連線建立過程中,服務端是一個標準的Listen + Accept的結構(可參考上面的程式碼),而在客戶端Go語言使用net.Dial()或net.DialTimeout()進行連線建立:
阻塞Dial:
conn, err := net.Dial("tcp", "www.baidu.com:80")
if err != nil {
//handle error
}
//read or write on conn
超時機制的Dial:
conn, err := net.DialTimeout("tcp", "www.baidu.com:80", 2*time.Second)
if err != nil {
//handle error
}
//read or write on conn
對於客戶端而言,連線的建立會遇到如下幾種情形:
1. 網路不可達或對方服務未啟動
如果傳給Dial的Addr是可以立即判斷出網路不可達,或者Addr中埠對應的服務沒有啟動,埠未被監聽,Dial會幾乎立即返回錯誤,比如:
//go-tcpsock/conn_establish/client1.go
... ...
func main() {
log.Println("begin dial...")
conn, err := net.Dial("tcp", ":8888")
if err != nil {
log.Println("dial error:", err)
return
}
defer conn.Close()
log.Println("dial ok")
}
如果本機8888埠未有服務程式監聽,那麼執行上面程式,Dial會很快返回錯誤:
$go run client1.go
2018/11/12 14:37:41 begin dial...
2018/11/12 14:37:41 dial error: dial tcp :8888: getsockopt: connection refused
2. 對方服務的listen backlog滿
還有一種場景就是對方伺服器很忙,瞬間有大量client端連線嘗試向server建立,server端的listen backlog佇列滿,server accept不及時((即便不accept,那麼在backlog數量範疇裡面,connect都會是成功的,因為new conn已經加入到server side的listen queue中了,accept只是從queue中取出一個conn而已),這將導致client端Dial阻塞。我們還是通過例子感受Dial的行為特點: 服務端程式碼
//go-tcpsock/conn_establish/server2.go
... ...
func main() {
l, err := net.Listen("tcp", ":8888")
if err != nil {
log.Println("error listen:", err)
return
}
defer l.Close()
log.Println("listen ok")
var i int
for {
time.Sleep(time.Second * 10)
if _, err := l.Accept(); err != nil {
log.Println("accept error:", err)
break
}
i++
log.Printf("%d: accept a new connection\n", i)
}
}
客戶端程式碼:
//go-tcpsock/conn_establish/client2.go
... ...
func establishConn(i int) net.Conn {
conn, err := net.Dial("tcp", ":8888")
if err != nil {
log.Printf("%d: dial error: %s", i, err)
return nil
}
log.Println(i, ":connect to server ok")
return conn
}
func main() {
var sl []net.Conn
for i := 1; i < 1000; i++ {
conn := establishConn(i)
if conn != nil {
sl = append(sl, conn)
}
}
time.Sleep(time.Second * 10000)
}
從程式可以看出,服務端在listen成功後,每隔10s鍾accept一次。客戶端則是序列的嘗試建立連線。這兩個程式在Darwin下的執行 結果:
$go run server2.go
2015/11/16 21:55:41 listen ok
2015/11/16 21:55:51 1: accept a new connection
2015/11/16 21:56:01 2: accept a new connection
... ...
$go run client2.go
2018/11/12 21:55:44 1 :connect to server ok
2018/11/12 21:55:44 2 :connect to server ok
2018/11/12 21:55:44 3 :connect to server ok
... ...
2018/11/12 21:55:44 126 :connect to server ok
2018/11/12 21:55:44 127 :connect to server ok
2018/11/12 21:55:44 128 :connect to server ok
2018/11/12 21:55:52 129 :connect to server ok
2018/11/12 21:56:03 130 :connect to server ok
2018/11/12 21:56:14 131 :connect to server ok
... ...
可以看出Client初始時成功地一次性建立了128個連線,然後後續每阻塞近10s才能成功建立一條連線。也就是說在server端 backlog滿時(未及時accept),客戶端將阻塞在Dial上,直到server端進行一次accept。至於為什麼是128,這與darwin 下的預設設定有關: 如果我在ubuntu 14.04上執行上述server程式,我們的client端初始可以成功建立499條連線。
如果server一直不accept,client端會一直阻塞麼?我們去掉accept後的結果是:在Darwin下,client端會阻塞大 約1分多鐘才會返回timeout: 而如果server執行在ubuntu 14.04上,client似乎一直阻塞,我等了10多分鐘依舊沒有返回。 阻塞與否看來與server端的網路實現和設定有關。
3. 網路延遲較大,Dial阻塞並超時
如果網路延遲較大,TCP握手過程將更加艱難坎坷(各種丟包),時間消耗的自然也會更長。Dial這時會阻塞,如果長時間依舊無法建立連線,則Dial也會返回“ getsockopt: operation timed out”錯誤。
在連線建立階段,多數情況下,Dial是可以滿足需求的,即便阻塞一小會兒。但對於某些程式而言,需要有嚴格的連線時間限定,如果一定時間內沒能成功建立連線,程式可能會需要執行一段“異常”處理邏輯,為此我們就需要DialTimeout了。下面的例子將Dial的最長阻塞時間限制在2s內,超出這個時長,Dial將返回timeout error:
//go-tcpsock/conn_establish/client3.go
... ...
func main() {
log.Println("begin dial...")
conn, err := net.DialTimeout("tcp", "104.236.176.96:80", 2*time.Second)
if err != nil {
log.Println("dial error:", err)
return
}
defer conn.Close()
log.Println("dial ok")
}
執行結果如下,需要模擬一個網路延遲大的環境
$go run client3.go
2018/11/12 09:28:34 begin dial...
2018/11/12 09:28:36 dial error: dial tcp 104.236.176.96:80: i/o timeout
四、 Socket讀寫
連線建立起來後,我們就要在conn上進行讀寫,以完成業務邏輯。前面說過Go runtime隱藏了I/O多路複用的複雜性。語言使用者只需採用goroutine+Block I/O的模式即可滿足大部分場景需求。Dial成功後,方法返回一個net.Conn介面型別變數值,這個介面變數的動態型別為一個*TCPConn。
//$GOROOT/src/net/tcpsock_posix.go
type TCPConn struct {
conn
}
TCPConn內嵌了一個unexported型別:conn,因此TCPConn”繼承”了conn的Read和Write方法,後續通過Dial返回值呼叫的Write和Read方法均是net.conn的方法:
//$GOROOT/src/net/net.go
type conn struct {
fd *netFD
}
func (c *conn) ok() bool { return c != nil && c.fd != nil }
// Implementation of the Conn interface.
// Read implements the Conn Read method.
func (c *conn) Read(b []byte) (int, error) {
if !c.ok() {
return 0, syscall.EINVAL
}
n, err := c.fd.Read(b)
if err != nil && err != io.EOF {
err = &OpError{Op: "read", Net: c.fd.net, Source: c.fd.laddr, Addr: c.fd.raddr, Err: err}
}
return n, err
}
// Write implements the Conn Write method.
func (c *conn) Write(b []byte) (int, error) {
if !c.ok() {
return 0, syscall.EINVAL
}
n, err := c.fd.Write(b)
if err != nil {
err = &OpError{Op: "write", Net: c.fd.net, Source: c.fd.laddr, Addr: c.fd.raddr, Err: err}
}
return n, err
}
conn.Read的行為特點
1. Socket中無資料
連線建立後,如果對方未傳送資料到socket,接收方(Server)會阻塞在Read操作上,這和前面提到的“模型”原理是一致的。執行該Read操作的goroutine也會被掛起。runtime會監視該socket,直到其有資料才會重新排程該socket對應的Goroutine完成read。由於篇幅原因,這裡就不列程式碼了,例子對應的程式碼檔案:go-tcpsock/read_write下的client1.go和server1.go。
2.Socket中有部分資料
如果socket中有部分資料,且長度小於一次Read操作所期望讀出的資料長度,那麼Read將會成功讀出這部分資料並返回,而不是等待所有期望資料全部讀取後再返回。
3.Socket中有足夠資料
如果socket中有資料,且長度大於等於一次Read操作所期望讀出的資料長度,那麼Read將會成功讀出這部分資料並返回。這個情景是最符合我們對Read的期待的了:Read將用Socket中的資料將我們傳入的slice填滿後返回:n = 10, err = nil。
4.Socket關閉
如果client端主動關閉了socket,那麼Server的Read將會讀到什麼呢? 這裡分為“有資料關閉”和“無資料關閉”。
有資料關閉是指在client關閉時,socket中還有server端未讀取的資料。當client端close socket退出後,server依舊沒有開始Read,10s後第一次Read成功讀出了所有的資料,當第二次Read時,由於client端 socket關閉,Read返回EOF error。
無資料關閉情形下的結果,那就是Read直接返回EOF error。
5. 讀取操作超時
有些場合對Read的阻塞時間有嚴格限制,在這種情況下,Read的行為到底是什麼樣的呢?在返回超時錯誤時,是否也同時Read了一部分資料了呢? 不會出現“讀出部分資料且返回超時錯誤”的情況。
conn.Write的行為特點
1. 成功寫
前面例子著重於Read,client端在Write時並未判斷Write的返回值。所謂“成功寫”指的就是Write呼叫返回的n與預期要寫入的資料長度相等,且error = nil。這是我們在呼叫Write時遇到的最常見的情形,這裡不再舉例了。
2. 寫阻塞
TCP連線通訊兩端的OS都會為該連線保留資料緩衝,一端呼叫Write後,實際上資料是寫入到OS的協議棧的資料緩衝的。TCP是全雙工通訊,因此每個方向都有獨立的資料緩衝。當傳送方將對方的接收緩衝區以及自身的傳送緩衝區寫滿後,Write就會阻塞。
3.寫入部分資料
Write操作存在寫入部分資料的情況。沒有按照預期的寫入所有資料。這時候迴圈寫入便是
綜上例子,雖然Go給我們提供了阻塞I/O的便利,但在呼叫Read和Write時依舊要綜合需要方法返回的n和err的結果,以做出正確處理。net.conn實現了io.Reader和io.Writer介面,因此可以試用一些wrapper包進行socket讀寫,比如bufio包下面的Writer和Reader、io/ioutil下的函式等。
五、Goroutine safe
基於goroutine的網路架構模型,存在在不同goroutine間共享conn的情況,那麼conn的讀寫是否是goroutine safe的呢?在深入這個問題之前,我們先從應用意義上來看read操作和write操作的goroutine-safe必要性。
對於read操作而言,由於TCP是面向位元組流,conn.Read無法正確區分資料的業務邊界,因此多個goroutine對同一個conn進行read的意義不大,goroutine讀到不完整的業務包反倒是增加了業務處理的難度。對與Write操作而言,倒是有多個goroutine併發寫的情況。
每次Write操作都是受lock保護,直到此次資料全部write完。因此在應用層面,要想保證多個goroutine在一個conn上write操作的Safe,需要一次write完整寫入一個“業務包”;一旦將業務包的寫入拆分為多次write,那就無法保證某個Goroutine的某“業務包”資料在conn傳送的連續性。
同時也可以看出即便是Read操作,也是lock保護的。多個Goroutine對同一conn的併發讀不會出現讀出內容重疊的情況,但內容斷點是依 runtime排程來隨機確定的。存在一個業務包資料,1/3內容被goroutine-1讀走,另外2/3被另外一個goroutine-2讀 走的情況。比如一個完整包:world,當goroutine的read slice size < 5時,存在可能:一個goroutine讀到 “worl”,另外一個goroutine讀出”d”。
六、Socket屬性
原生Socket API提供了豐富的sockopt設定介面,但Golang有自己的網路架構模型,golang提供的socket options介面也是基於上述模型的必要的屬性設定。包括 SetKeepAlive SetKeepAlivePeriod SetLinger SetNoDelay (預設no delay) SetWriteBuffer SetReadBuffer
不過上面的Method是TCPConn的,而不是Conn的,要使用上面的Method的,需要type assertion:
tcpConn, ok := conn.(*TCPConn)
if !ok {
//error handle
}
tcpConn.SetNoDelay(true)
對於listener socket, golang預設採用了 SO_REUSEADDR,這樣當你重啟 listener程式時,不會因為address in use的錯誤而啟動失敗。而listen backlog的預設值是通過獲取系統的設定值得到的。不同系統不同:mac 128, linux 512等。
七、關閉連線
和前面的方法相比,關閉連線算是最簡單的操作了。由於socket是全雙工的,client和server端在己方已關閉的socket和對方關閉的socket上操作的結果有不同。看下面例子:
//go-tcpsock/conn_close/client1.go
... ...
func main() {
log.Println("begin dial...")
conn, err := net.Dial("tcp", ":8888")
if err != nil {
log.Println("dial error:", err)
return
}
conn.Close()
log.Println("close ok")
var buf = make([]byte, 32)
n, err := conn.Read(buf)
if err != nil {
log.Println("read error:", err)
} else {
log.Printf("read % bytes, content is %s\n", n, string(buf[:n]))
}
n, err = conn.Write(buf)
if err != nil {
log.Println("write error:", err)
} else {
log.Printf("write % bytes, content is %s\n", n, string(buf[:n]))
}
time.Sleep(time.Second * 1000)
}
//go-tcpsock/conn_close/server1.go
... ...
func handleConn(c net.Conn) {
defer c.Close()
// read from the connection
var buf = make([]byte, 10)
log.Println("start to read from conn")
n, err := c.Read(buf)
if err != nil {
log.Println("conn read error:", err)
} else {
log.Printf("read %d bytes, content is %s\n", n, string(buf[:n]))
}
n, err = c.Write(buf)
if err != nil {
log.Println("conn write error:", err)
} else {
log.Printf("write %d bytes, content is %s\n", n, string(buf[:n]))
}
}
... ...
執行結果如下:
$go run server1.go
2018/11/12 17:00:51 accept a new connection
2018/11/12 17:00:51 start to read from conn
2018/11/12 17:00:51 conn read error: EOF
2018/11/12 17:00:51 write 10 bytes, content is
$go run client1.go
201811/12 17:00:51 begin dial...
2018/11/12 17:00:51 close ok
2018/11/12 17:00:51 read error: read tcp 127.0.0.1:64195->127.0.0.1:8888: use of closed network connection
2018/11/12 17:00:51 write error: write tcp 127.0.0.1:64195->127.0.0.1:8888: use of closed network connection
從client的結果來看,在己方已經關閉的socket上再進行read和write操作,會得到”use of closed network connection” error;
從server的執行結果來看,在對方關閉的socket上執行read操作會得到EOF error,但write操作會成功,因為資料會成功寫入己方的核心socket緩衝區中,即便最終發不到對方socket緩衝區了,因為己方socket並未關閉。因此當發現對方socket關閉後,己方應該正確合理處理自己的socket,再繼續write已經無任何意義了。
八、小結
本文比較基礎,但卻很重要,畢竟golang是面向大規模服務後端的,對通訊環節的細節的深入理解會大有裨益。另外Go的goroutine+阻塞通訊的網路通訊模型降低了開發者心智負擔,簡化了通訊的複雜性,這點尤為重要。