Go语言TCP网络编程(详细)
一、序言?
Golang的主要 設(shè)計目標(biāo)之一就是面向大規(guī)模后端服務(wù)程序,網(wǎng)絡(luò)通信這塊是服務(wù)端 程序必不可少也是至關(guān)重要的一部分。在日常應(yīng)用中,我們也可以看到Go中的net以及其subdirectories下的包均是“高頻+剛需”,而TCP socket則是網(wǎng)絡(luò)編程的主流,即便您沒有直接使用到net中有關(guān)TCP Socket方面的接口,但net/http總是用到了吧,http底層依舊是用tcp socket實現(xiàn)的
網(wǎng)絡(luò)編程方面,我們最常用的就是tcp socket編程了,在posix標(biāo)準(zhǔn)出來后,socket在各大主流OS平臺上都得到了很好的支持。關(guān)于tcp programming,最好的資料莫過于W. Richard Stevens 的網(wǎng)絡(luò)編程圣經(jīng)《UNIX網(wǎng)絡(luò) 編程 卷1:套接字聯(lián)網(wǎng)API》 了,書中關(guān)于tcp socket接口的各種使用、行為模式、異常處理講解的十分細(xì)致。Go是自帶runtime的跨平臺編程語言,Go中暴露給語言使用者的tcp socket api是建立OS原生tcp socket接口之上的。由于Go runtime調(diào)度的需要,golang tcp socket接口在行為特點與異常處理方面與OS原生接口有著一些差別。這篇博文的目標(biāo)就是整理出關(guān)于Go tcp socket在各個場景下的使用方法、行為特點以及注意事項
二、模型
從tcp socket誕生后,網(wǎng)絡(luò)編程架構(gòu)模型也幾經(jīng)演化,大致是:“每進程一個連接” –> “每線程一個連接” –> “Non-Block + I/O多路復(fù)用(Linux?epoll/windows iocp/freebsd darwin kqueue/solaris Event Port)”。伴隨著模型的演化,服務(wù)程序愈加強大,可以支持更多的連接,獲得更好的處理性能
目前主流web server一般均采用的都是”Non-Block + I/O多路復(fù)用”(有的也結(jié)合了多線程、多進程)。不過I/O多路復(fù)用也給使用者帶來了不小的復(fù)雜度,以至于后續(xù)出現(xiàn)了許多高性能的I/O多路復(fù)用框架, 比如libevent、libev、libuv等,以幫助開發(fā)者簡化開發(fā)復(fù)雜性,降低心智負(fù)擔(dān)。不過Go的設(shè)計者似乎認(rèn)為I/O多路復(fù)用的這種通過回調(diào)機制割裂控制流 的方式依舊復(fù)雜,且有悖于“一般邏輯”設(shè)計,為此Go語言將該“復(fù)雜性”隱藏在Runtime中了:Go開發(fā)者無需關(guān)注socket是否是 non-block的,也無需親自注冊文件描述符的回調(diào),只需在每個連接對應(yīng)的goroutine中以“block I/O”的方式對待socket處理即可,這可以說大大降低了開發(fā)人員的心智負(fù)擔(dān)。一個典型的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 connectiongo HandleConn(conn)} }- ?
用戶層眼中看到的goroutine中的“block socket”,實際上是通過Go runtime中的netpoller通過Non-block socket + I/O多路復(fù)用機制“模擬”出來的,真實的underlying socket實際上是non-block的,只是runtime攔截了底層socket系統(tǒng)調(diào)用的錯誤碼,并通過netpoller和goroutine 調(diào)度讓goroutine“阻塞”在用戶層得到的Socket fd上。比如:當(dāng)用戶層針對某個socket fd發(fā)起read操作時,如果該socket fd中尚無數(shù)據(jù),那么runtime會將該socket fd加入到netpoller中監(jiān)聽,同時對應(yīng)的goroutine被掛起,直到runtime收到socket fd 數(shù)據(jù)ready的通知,runtime才會重新喚醒等待在該socket fd上準(zhǔn)備read的那個Goroutine。而這個過程從Goroutine的視角來看,就像是read操作一直block在那個socket fd上似的。具體實現(xiàn)細(xì)節(jié)在后續(xù)場景中會有補充描述
三、TCP連接的建立
眾所周知,TCP Socket的連接的建立需要經(jīng)歷客戶端和服務(wù)端的三次握手的過程。連接建立過程中,服務(wù)端是一個標(biāo)準(zhǔn)的Listen + Accept的結(jié)構(gòu)(可參考上面的代碼),而在客戶端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、網(wǎng)絡(luò)不可達(dá)或?qū)Ψ椒?wù)未啟動?
如果傳給Dial的Addr是可以立即判斷出網(wǎng)絡(luò)不可達(dá),或者Addr中端口對應(yīng)的服務(wù)沒有啟動,端口未被監(jiān)聽,Dial會幾乎立即返回錯誤,比如:
- ?
如果本機8888端口未有服務(wù)程序監(jiān)聽,那么執(zhí)行上面程序,Dial會很快返回錯誤:
$go run client1.go 2015/11/16 14:37:41 begin dial... 2015/11/16 14:37:41 dial error: dial tcp :8888: getsockopt: connection refused- ?
2、對方服務(wù)的listen backlog滿?
還有一種場景就是對方服務(wù)器很忙,瞬間有大量client端連接嘗試向server建立,server端的listen backlog隊列滿,server accept不及時((即便不accept,那么在backlog數(shù)量范疇里面,connect都會是成功的,因為new conn已經(jīng)加入到server side的listen queue中了,accept只是從queue中取出一個conn而已),這將導(dǎo)致client端Dial阻塞。我們還是通過例子感受Dial的行為特點:?
服務(wù)端代碼:
- ?
客戶端代碼:
//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.Connfor i := 1; i < 1000; i++ {conn := establishConn(i)if conn != nil {sl = append(sl, conn)}}time.Sleep(time.Second * 10000) }- ?
從程序可以看出,服務(wù)端在listen成功后,每隔10s鐘accept一次。客戶端則是串行的嘗試建立連接。這兩個程序在Darwin下的執(zhí)行 結(jié)果:
$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 2015/11/16 21:55:44 1 :connect to server ok 2015/11/16 21:55:44 2 :connect to server ok 2015/11/16 21:55:44 3 :connect to server ok ... ...2015/11/16 21:55:44 126 :connect to server ok 2015/11/16 21:55:44 127 :connect to server ok 2015/11/16 21:55:44 128 :connect to server ok2015/11/16 21:55:52 129 :connect to server ok 2015/11/16 21:56:03 130 :connect to server ok 2015/11/16 21:56:14 131 :connect to server ok ... ...- ?
可以看出Client初始時成功地一次性建立了128個連接,然后后續(xù)每阻塞近10s才能成功建立一條連接。也就是說在server端 backlog滿時(未及時accept),客戶端將阻塞在Dial上,直到server端進行一次accept。至于為什么是128,這與darwin 下的默認(rèn)設(shè)置有關(guān):?
如果我在ubuntu 14.04上運行上述server程序,我們的client端初始可以成功建立499條連接。
如果server一直不accept,client端會一直阻塞么?我們?nèi)サ鬭ccept后的結(jié)果是:在Darwin下,client端會阻塞大 約1分多鐘才會返回timeout:?
而如果server運行在ubuntu 14.04上,client似乎一直阻塞,我等了10多分鐘依舊沒有返回。 阻塞與否看來與server端的網(wǎng)絡(luò)實現(xiàn)和設(shè)置有關(guān)
3、網(wǎng)絡(luò)延遲較大,Dial阻塞并超時?
如果網(wǎng)絡(luò)延遲較大,TCP握手過程將更加艱難坎坷(各種丟包),時間消耗的自然也會更長。Dial這時會阻塞,如果長時間依舊無法建立連接,則Dial也會返回“ getsockopt: operation timed out”錯誤
在連接建立階段,多數(shù)情況下,Dial是可以滿足需求的,即便阻塞一小會兒。但對于某些程序而言,需要有嚴(yán)格的連接時間限定,如果一定時間內(nèi)沒能成功建立連接,程序可能會需要執(zhí)行一段“異常”處理邏輯,為此我們就需要DialTimeout了。下面的例子將Dial的最長阻塞時間限制在2s內(nèi),超出這個時長,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") }- ?
執(zhí)行結(jié)果如下,需要模擬一個網(wǎng)絡(luò)延遲大的環(huán)境
$go run client3.go 2015/11/17 09:28:34 begin dial... 2015/11/17 09:28:36 dial error: dial tcp 104.236.176.96:80: i/o timeout- ?
四、Socket讀寫
連接建立起來后,我們就要在conn上進行讀寫,以完成業(yè)務(wù)邏輯。前面說過Go runtime隱藏了I/O多路復(fù)用的復(fù)雜性。語言使用者只需采用goroutine+Block I/O的模式即可滿足大部分場景需求。Dial成功后,方法返回一個net.Conn接口類型變量值,這個接口變量的動態(tài)類型為一個*TCPConn:
//$GOROOT/src/net/tcpsock_posix.go type TCPConn struct {conn }- ?
TCPConn內(nèi)嵌了一個unexported類型:conn,因此TCPConn”繼承”了conn的Read和Write方法,后續(xù)通過Dial返回值調(diào)用的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 }- ?
1、conn.Read的行為特點
1.1、Socket中無數(shù)據(jù)?
連接建立后,如果對方未發(fā)送數(shù)據(jù)到socket,接收方(Server)會阻塞在Read操作上,這和前面提到的“模型”原理是一致的。執(zhí)行該Read操作的goroutine也會被掛起。runtime會監(jiān)視該socket,直到其有數(shù)據(jù)才會重新?
調(diào)度該socket對應(yīng)的Goroutine完成read。由于篇幅原因,這里就不列代碼了,例子對應(yīng)的代碼文件:go-tcpsock/read_write下的client1.go和server1.go。
1.2、Socket中有部分?jǐn)?shù)據(jù)?
如果socket中有部分?jǐn)?shù)據(jù),且長度小于一次Read操作所期望讀出的數(shù)據(jù)長度,那么Read將會成功讀出這部分?jǐn)?shù)據(jù)并返回,而不是等待所有期望數(shù)據(jù)全部讀取后再返回。
1.3、Socket中有足夠數(shù)據(jù)?
如果socket中有數(shù)據(jù),且長度大于等于一次Read操作所期望讀出的數(shù)據(jù)長度,那么Read將會成功讀出這部分?jǐn)?shù)據(jù)并返回。這個情景是最符合我們對Read的期待的了:Read將用Socket中的數(shù)據(jù)將我們傳入的slice填滿后返回:n = 10, err = nil
1.4、Socket關(guān)閉?
如果client端主動關(guān)閉了socket,那么Server的Read將會讀到什么呢??
這里分為“有數(shù)據(jù)關(guān)閉”和“無數(shù)據(jù)關(guān)閉”。
有數(shù)據(jù)關(guān)閉是指在client關(guān)閉時,socket中還有server端未讀取的數(shù)據(jù)。當(dāng)client端close socket退出后,server依舊沒有開始Read,10s后第一次Read成功讀出了所有的數(shù)據(jù),當(dāng)?shù)诙蜶ead時,由于client端 socket關(guān)閉,Read返回EOF error
無數(shù)據(jù)關(guān)閉情形下的結(jié)果,那就是Read直接返回EOF error
1.5、讀取操作超時?
有些場合對Read的阻塞時間有嚴(yán)格限制,在這種情況下,Read的行為到底是什么樣的呢?在返回超時錯誤時,是否也同時Read了一部分?jǐn)?shù)據(jù)了呢??
不會出現(xiàn)“讀出部分?jǐn)?shù)據(jù)且返回超時錯誤”的情況
2、conn.Write的行為特點
2.1、成功寫?
前面例子著重于Read,client端在Write時并未判斷Write的返回值。所謂“成功寫”指的就是Write調(diào)用返回的n與預(yù)期要寫入的數(shù)據(jù)長度相等,且error = nil。這是我們在調(diào)用Write時遇到的最常見的情形,這里不再舉例了
2.2、寫阻塞?
TCP連接通信兩端的OS都會為該連接保留數(shù)據(jù)緩沖,一端調(diào)用Write后,實際上數(shù)據(jù)是寫入到OS的協(xié)議棧的數(shù)據(jù)緩沖的。TCP是全雙工通信,因此每個方向都有獨立的數(shù)據(jù)緩沖。當(dāng)發(fā)送方將對方的接收緩沖區(qū)以及自身的發(fā)送緩沖區(qū)寫滿后,Write就會阻塞
2.3、寫入部分?jǐn)?shù)據(jù)?
Write操作存在寫入部分?jǐn)?shù)據(jù)的情況。沒有按照預(yù)期的寫入所有數(shù)據(jù)。這時候循環(huán)寫入便是
綜上例子,雖然Go給我們提供了阻塞I/O的便利,但在調(diào)用Read和Write時依舊要綜合需要方法返回的n和err的結(jié)果,以做出正確處理。net.conn實現(xiàn)了io.Reader和io.Writer接口,因此可以試用一些wrapper包進行socket讀寫,比如bufio包下面的Writer和Reader、io/ioutil下的函數(shù)等
五、Goroutine safe
基于goroutine的網(wǎng)絡(luò)架構(gòu)模型,存在在不同goroutine間共享conn的情況,那么conn的讀寫是否是goroutine safe的呢?在深入這個問題之前,我們先從應(yīng)用意義上來看read操作和write操作的goroutine-safe必要性。
對于read操作而言,由于TCP是面向字節(jié)流,conn.Read無法正確區(qū)分?jǐn)?shù)據(jù)的業(yè)務(wù)邊界,因此多個goroutine對同一個conn進行read的意義不大,goroutine讀到不完整的業(yè)務(wù)包反倒是增加了業(yè)務(wù)處理的難度。對與Write操作而言,倒是有多個goroutine并發(fā)寫的情況。
每次Write操作都是受lock保護,直到此次數(shù)據(jù)全部write完。因此在應(yīng)用層面,要想保證多個goroutine在一個conn上write操作的Safe,需要一次write完整寫入一個“業(yè)務(wù)包”;一旦將業(yè)務(wù)包的寫入拆分為多次write,那就無法保證某個Goroutine的某“業(yè)務(wù)包”數(shù)據(jù)在conn發(fā)送的連續(xù)性。
同時也可以看出即便是Read操作,也是lock保護的。多個Goroutine對同一conn的并發(fā)讀不會出現(xiàn)讀出內(nèi)容重疊的情況,但內(nèi)容斷點是依 runtime調(diào)度來隨機確定的。存在一個業(yè)務(wù)包數(shù)據(jù),1/3內(nèi)容被goroutine-1讀走,另外2/3被另外一個goroutine-2讀 走的情況。比如一個完整包:world,當(dāng)goroutine的read slice size < 5時,存在可能:一個goroutine讀到 “worl”,另外一個goroutine讀出”d”。
六、Socket屬性?
原生Socket API提供了豐富的sockopt設(shè)置接口,但Golang有自己的網(wǎng)絡(luò)架構(gòu)模型,golang提供的socket options接口也是基于上述模型的必要的屬性設(shè)置。包括?
SetKeepAlive?
SetKeepAlivePeriod?
SetLinger?
SetNoDelay (默認(rèn)no delay)?
SetWriteBuffer?
SetReadBuffer
不過上面的Method是TCPConn的,而不是Conn的,要使用上面的Method的,需要type assertion:
tcpConn, ok := conn.(*TCPConn) if !ok {//error handle }tcpConn.SetNoDelay(true)- ?
對于listener socket, golang默認(rèn)采用了 SO_REUSEADDR,這樣當(dāng)你重啟 listener程序時,不會因為address in use的錯誤而啟動失敗。而listen backlog的默認(rèn)值是通過獲取系統(tǒng)的設(shè)置值得到的。不同系統(tǒng)不同:mac 128, linux 512等
七、關(guān)閉連接?
和前面的方法相比,關(guān)閉連接算是最簡單的操作了。由于socket是全雙工的,client和server端在己方已關(guān)閉的socket和對方關(guān)閉的socket上操作的結(jié)果有不同。看下面例子:
- ?
執(zhí)行結(jié)果如下
$go run server1.go 2015/11/17 17:00:51 accept a new connection 2015/11/17 17:00:51 start to read from conn 2015/11/17 17:00:51 conn read error: EOF 2015/11/17 17:00:51 write 10 bytes, content is$go run client1.go 2015/11/17 17:00:51 begin dial... 2015/11/17 17:00:51 close ok 2015/11/17 17:00:51 read error: read tcp 127.0.0.1:64195->127.0.0.1:8888: use of closed network connection 2015/11/17 17:00:51 write error: write tcp 127.0.0.1:64195->127.0.0.1:8888: use of closed network connection- ?
從client的結(jié)果來看,在己方已經(jīng)關(guān)閉的socket上再進行read和write操作,會得到”use of closed network connection” error;
從server的執(zhí)行結(jié)果來看,在對方關(guān)閉的socket上執(zhí)行read操作會得到EOF error,但write操作會成功,因為數(shù)據(jù)會成功寫入己方的內(nèi)核socket緩沖區(qū)中,即便最終發(fā)不到對方socket緩沖區(qū)了,因為己方socket并未關(guān)閉。因此當(dāng)發(fā)現(xiàn)對方socket關(guān)閉后,己方應(yīng)該正確合理處理自己的socket,再繼續(xù)write已經(jīng)無任何意義了
八、小結(jié)?
本文比較基礎(chǔ),但卻很重要,畢竟golang是面向大規(guī)模服務(wù)后端的,對通信環(huán)節(jié)的細(xì)節(jié)的深入理解會大有裨益。另外Go的goroutine+阻塞通信的網(wǎng)絡(luò)通信模型降低了開發(fā)者心智負(fù)擔(dān),簡化了通信的復(fù)雜性,這點尤為重要
總結(jié)
以上是生活随笔為你收集整理的Go语言TCP网络编程(详细)的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Golang精编100题
- 下一篇: GOPATH与工作空间