
Go net庫對 tcp server 的支持非常完善,其中最核心的部分依賴系統(tǒng)調(diào)用 socket/bind/listen/accept。這些系統(tǒng)調(diào)用被完好地封裝在syscall庫里, 而且這層封裝屏一定程度上蔽掉了底層操作系統(tǒng)的差異性。
如果你讀過前一篇文章,會發(fā)現(xiàn)net庫應(yīng)用了面向?qū)ο缶幊痰乃悸?,對系統(tǒng)調(diào)用做了很多層封裝。這篇文章中,我們利用面向過程編程的思路,只依賴syscall庫而不是net庫,實(shí)現(xiàn)一個(gè)簡單的 echo server,以更好地理解 tcp server 的工作原理。
服務(wù)流程
- 創(chuàng)建套接字: syscall.Socket()。
- 綁定套接字和ip:port: syscall.Bind()。
- 監(jiān)聽套接字: syscall.Listen()。
- for循環(huán):接收tcp connection: syscall.Accept()處理tcp connection: go echo(clientSocketFd)。
通用的一些變量有:
var (
// IPV4協(xié)議
family = syscall.AF_INET
// 基于TCP, 提供有序、可靠、雙向、基于連接的字節(jié)流,不限制消息長度,支持消息的優(yōu)先級傳輸
sotype = syscall.SOCK_STREAM
// protocol = tcp
_ = "tcp"
// ESTABLISHED狀態(tài)的tcp conn隊(duì)列的最大長度
listenBacklog = syscall.SOMAXCONN
// server ip:port
serverip = net.IPv4(0, 0, 0, 0)
serverport = 8080
)
為了方便代碼跳轉(zhuǎn)到linux的實(shí)現(xiàn),可以修改Goland上的GOOS選項(xiàng):

Goland配置
創(chuàng)建套接字
// 創(chuàng)建套接字
sockfd, err := syscall.Socket(family, sotype, 0)
if err != nil {
panic(fmt.Errorf("fails to create socket: %s", err))
}
syscall.CloseOnExec(sockfd)
net庫將套接字設(shè)置為 SOCK_NONBLOCK,非阻塞模式下 accept/read/write 有時(shí)候會返回 EWOULDBLOCK 或 EAGAIN 錯(cuò)誤,需要利用 wait 機(jī)制去實(shí)現(xiàn)goroutine的阻塞,增加了編程的復(fù)雜度。這里我們使用默認(rèn)的阻塞模式。如果想要實(shí)驗(yàn)非阻塞模式,可以參考下面這段代碼:
// Nonblock 處理起來太復(fù)雜了,先注釋掉這一段
if err := syscall.SetNonblock(sockfd, true); err != nil {
syscall.Close(sockfd)
log.Printf("setnonblock error=%v\n", err)
os.Exit(-1)
}
綁定套接字和ip:port
// ipToSockaddrInet4 是從 net/tcpsock_posix.go 抄的
addr, err := ipToSockaddrInet4(serverip, serverport)
if err != nil {
panic(fmt.Sprintf("fails to convert address %s:%d to socket addr, err=%s",
serverip, serverport,
err))
}
if err := syscall.Bind(sockfd, &addr); err != nil {
panic(fmt.Sprintf("fails to bind socket %d to address %s:%d, err=%s",
sockfd,
serverip, serverport,
err))
}
監(jiān)聽套接字
syscall.Listen函數(shù)修改sockfd的狀態(tài)為 LISTEN,內(nèi)核開始監(jiān)聽套接字。
if err := syscall.Listen(sockfd, listenBacklog); err != nil {
log.Printf("listen sockfd %d to addr error=%v\n", sockfd, err)
panic(fmt.Sprintf("fails to listen socket %d", sockfd))
} else {
log.Printf("Started listening on %s:%d", serverip, serverport)
}
for循環(huán) accept
這里 syscall.Accept 仍然采用了阻塞模式。如果要采用非阻塞模式,則需要改成 syscall.Accept4 并傳入 SOCK_NONBLOCK 和 SOCK_CLOEXEC flag。
for {
clientSockfd, clientSockAddr, err := syscall.Accept(sockfd)
if err != nil {
log.Printf("accept sockfd %d error=%v\n", sockfd, err)
continue
}
clientSockAddrInet4 := clientSockAddr.(*syscall.SockaddrInet4)
log.Printf("Connected with new client, sock addr = %v:%d\n", clientSockAddrInet4.Addr, clientSockAddrInet4.Port)
go echo(clientSockfd)
}
一個(gè) ESTABLISHED 套接字代表一個(gè)client端的連接,我們將這個(gè)字段傳給echo函數(shù),實(shí)現(xiàn)復(fù)讀機(jī)功能。echo 會持續(xù)從套接字讀取數(shù)據(jù)到 byte buffer 結(jié)構(gòu)中,然后再寫回到套接字。如果client端關(guān)閉連接,Read/Write 就會失敗,導(dǎo)致函數(shù)退出。
func echo(sockfd int) {
defer func() {
if err := syscall.Close(sockfd); err != nil {
log.Printf("[echo] close sock %v fails, err=%v\n", sockfd, err)
}
}()
var buf [32 * 1024]byte
for {
nRead, err := syscall.Read(sockfd, buf[:])
if err != nil {
log.Printf("fails to read data from sockfd %d, err=%v\n", sockfd, err)
return
}
if _, err := syscall.Write(sockfd, buf[:nRead]); err != nil {
log.Printf("fails to write data %s into sockfd %d, err=%v\n", buf[:nRead], sockfd, err)
return
}
}
}
關(guān)閉套接字
作為一個(gè) Server,我們通常會要求 Graceful Shutdown (不過Gin框架沒有實(shí)現(xiàn)這一點(diǎn))。做法也比較簡單。
- 創(chuàng)建一個(gè)容量為0的channel。
- 注冊監(jiān)聽哪些操作系統(tǒng)信號。
- 在 goroutine 里從channel讀取信號,并做出相應(yīng)的反應(yīng)。
// 接收到Ctrl+C信號后,關(guān)閉socket
c := make(chan os.Signal)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
go func() {
<-c
log.Println("\r- Ctrl+C pressed in Terminal")
if err := syscall.Close(sockfd); err != nil {
log.Printf("Close sockfd %d fails, err=%v\n", sockfd, err)
} else {
log.Printf("Server stopped successfully!!!")
}
// 收到信號后需要處理, 否則程序會永久hang住, 需要kill -9 <pid>
// os.Exit 會導(dǎo)致所有g(shù)oroutine都會立即停止執(zhí)行
os.Exit(0)
}()
我們這里的處理比較簡單,沒有判斷具體是什么信號,只是關(guān)閉套接字,然后退出程序。
這段代碼放在 syscall.Socket 和 syscall.Bind 之間即可。
實(shí)現(xiàn) echo client
echo client的功能是:
- 通過 socket, connect 系統(tǒng)調(diào)用建立與tcp server的連接。
- 創(chuàng)建 bufio.Reader,從os.Stdin讀取輸入。
- for循環(huán): 從stdin讀取輸入,寫入套接字;遇到Ctrl+D退出。
代碼如下:
func main() {
var (
family = syscall.AF_INET
sotype = syscall.SOCK_STREAM
_ = "tcp"
serverip = net.IPv4(0, 0, 0, 0)
serverport = 8080
)
// 創(chuàng)建套接字
sockfd, err := syscall.Socket(family, sotype, 0)
if err != nil {
panic(fmt.Errorf("fails to create socket: %s", err))
}
defer syscall.Close(sockfd)
serverAddr, err := ipToSockaddrInet4(serverip, serverport)
if err != nil {
panic(fmt.Sprintf("fails to convert address %s:%d to socket addr, err=%v", serverip, serverport, err))
}
if err := syscall.Connect(sockfd, &serverAddr); err != nil {
panic(fmt.Errorf("fails to connect sockfd %d to server, err=%v\n", sockfd, err))
}
reader := bufio.NewReader(os.Stdin)
readBuf := make([]byte, 1024)
for {
dataBytes, err := reader.ReadBytes('\n')
if err == io.EOF { // keyboard signal: CTRL-D
log.Printf("Client exits gracefully!!!\n")
return
} else if err != nil {
log.Printf("read error %v, shall exit\n", err)
return
} else {
nWrite, err := syscall.Write(sockfd, dataBytes)
if err != nil {
log.Printf("write sockfd %d fails, error=%#v\n", sockfd, err)
return
} else {
log.Printf("write %d bytes\n", nWrite)
}
nRead, err := syscall.Read(sockfd, readBuf[:])
if err != nil {
log.Printf("read sockfd %d fails, error=%#v\n", sockfd, err)
return
} else {
log.Printf("read %d bytes, data=%s\n", nRead, readBuf[:nRead])
}
}
}
}
測試
為了能夠在Linux下運(yùn)行代碼,可以在機(jī)器上安裝docker,在容器里跑。docker官方提供了 golang:1.19 鏡像,GOPATH 是 /go,我們直接用這個(gè),并把本機(jī)的目錄映射進(jìn)去:
# 刪除之前的容器,如果有
docker rm -f go_app
# 啟動(dòng)容器
docker run -d \
--mount type=bind,source=$HOME/go/src,target=/go/src \
--workdir /go/src/github.com/ \
--name go_app \
--restart always \
golang:1.19 \
sleep infinity
# 進(jìn)入容器的命令行
docker exec -it go_app bash
# cd echo_server directory
go run main.go
Golang鏡像不提供 netstat vim 等命令,需要手動(dòng)在容器里安裝 net-tools 和 vim:
# 查看linux發(fā)行版
cat /etc/issue
# 替換成阿里云 debian 11 的源
# https://developer.aliyun.com/mirror/debian/
cat > /etc/apt/sources.list << EOF \
deb https://mirrors.aliyun.com/debian/ bullseye main non-free contrib \
deb-src https://mirrors.aliyun.com/debian/ bullseye main non-free contrib \
deb https://mirrors.aliyun.com/debian-security/ bullseye-security main \
deb-src https://mirrors.aliyun.com/debian-security/ bullseye-security main \
deb https://mirrors.aliyun.com/debian/ bullseye-updates main non-free contrib \
deb-src https://mirrors.aliyun.com/debian/ bullseye-updates main non-free contrib \
deb https://mirrors.aliyun.com/debian/ bullseye-backports main non-free contrib \
deb-src https://mirrors.aliyun.com/debian/ bullseye-backports main non-free contrib \
EOF
apt update
apt install -y net-tools vim man
一個(gè)觀察
關(guān)于四次揮手的一些觀察:有client連接時(shí),server關(guān)閉后,需要等待一段時(shí)間才能釋放端口。這里挖個(gè)坑,后續(xù)可能不會填了。
下面是一個(gè)server和一個(gè)client的情況:
# netstat -anlop |grep 8080
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name Timer
tcp 0 0 0.0.0.0:8080 0.0.0.0:* LISTEN 1784/main off (0.00/0/0)
tcp 0 0 127.0.0.1:34132 127.0.0.1:8080 ESTABLISHED 1856/main off (0.00/0/0)
tcp 0 0 127.0.0.1:8080 127.0.0.1:34132 ESTABLISHED 1784/main off (0.00/0/0)
Ctrl+C 關(guān)掉server,netstat 返回這樣的結(jié)果:
# netstat -anlop |grep 8080
tcp 1 0 127.0.0.1:34132 127.0.0.1:8080 CLOSE_WAIT 1856/main off (0.00/0/0)
tcp 0 0 127.0.0.1:8080 127.0.0.1:34132 FIN_WAIT2 - timewait (33.07/0/0)
短時(shí)間內(nèi)再次啟動(dòng)server, bind時(shí)會報(bào)錯(cuò) address already in use。再等一段時(shí)間,client端自動(dòng)斷開,server才能啟動(dòng):
# netstat -anlop |grep 8080
tcp 1 0 127.0.0.1:34132 127.0.0.1:8080 CLOSE_WAIT 1856/main off (0.00/0/0)