Go BIO/NIO探讨(3): 基于系统调用实现tcp echo server
2023-1-20 08:54:52 Author: Go语言中文网(查看原文) 阅读量:15 收藏

Go net库对 tcp server 的支持非常完善,其中最核心的部分依赖系统调用 socket/bind/listen/accept。这些系统调用被完好地封装在syscall库里, 而且这层封装屏一定程度上蔽掉了底层操作系统的差异性。

如果你读过前一篇文章,会发现net库应用了面向对象编程的思路,对系统调用做了很多层封装。这篇文章中,我们利用面向过程编程的思路,只依赖syscall库而不是net库,实现一个简单的 echo server,以更好地理解 tcp server 的工作原理。

  1. 创建套接字: syscall.Socket()

  2. 绑定套接字和ip:port: syscall.Bind()

  3. 监听套接字: syscall.Listen()

  4. for循环:

    • 接收tcp connection: syscall.Accept()

    • 处理tcp connection: go echo(clientSocketFd)

通用的一些变量有:

var (
// IPV4协议
family = syscall.AF_INET
// 基于TCP, 提供有序、可靠、双向、基于连接的字节流,不限制消息长度,支持消息的优先级传输
sotype = syscall.SOCK_STREAM
// protocol = tcp
_ = "tcp"
// ESTABLISHED状态的tcp conn队列的最大长度
listenBacklog = syscall.SOMAXCONN
// server ip:port
serverip = net.IPv4(0, 0, 0, 0)
serverport = 8080
)

为了方便代码跳转到linux的实现,可以修改Goland上的GOOS选项:

创建套接字

  // 创建套接字
sockfd, err := syscall.Socket(family, sotype, 0)
if err != nil {
panic(fmt.Errorf("fails to create socket: %s", err))
}
syscall.CloseOnExec(sockfd)

net库将套接字设置为 SOCK_NONBLOCK,非阻塞模式下 accept/read/write 有时候会返回 EWOULDBLOCK 或 EAGAIN 错误,需要利用 wait 机制去实现goroutine的阻塞,增加了编程的复杂度。这里我们使用默认的阻塞模式。如果想要实验非阻塞模式,可以参考下面这段代码:

// Nonblock 处理起来太复杂了,先注释掉这一段
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))
}

监听套接字

syscall.Listen函数修改sockfd的状态为 LISTEN,内核开始监听套接字。

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循环 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)
}

一个 ESTABLISHED 套接字代表一个client端的连接,我们将这个字段传给echo函数,实现复读机功能。echo 会持续从套接字读取数据到 byte buffer 结构中,然后再写回到套接字。如果client端关闭连接,Read/Write 就会失败,导致函数退出。

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
}
}
}

关闭套接字

作为一个 Server,我们通常会要求 Graceful Shutdown (不过Gin框架没有实现这一点)。做法也比较简单,就是

  1. 创建一个容量为0的channel

  2. 注册监听哪些操作系统信号

  3. 在 goroutine 里从channel读取信号,并做出相应的反应

// 接收到Ctrl+C信号后,关闭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 会导致所有goroutine都会立即停止执行
os.Exit(0)
}()

我们这里的处理比较简单,没有判断具体是什么信号,只是关闭套接字,然后退出程序。

这段代码放在 syscall.Socket 和 syscall.Bind 之间即可。

echo client的功能是:

  1. 通过 socket, connect 系统调用建立与tcp server的连接

  2. 创建 bufio.Reader,从os.Stdin读取输入

  3. for循环: 从stdin读取输入,写入套接字;遇到Ctrl+D退出

代码如下:

func main() {
var (
family = syscall.AF_INET
sotype = syscall.SOCK_STREAM
_ = "tcp"
serverip = net.IPv4(0, 0, 0, 0)
serverport = 8080
)

// 创建套接字
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下运行代码,可以在机器上安装docker,在容器里跑。docker官方提供了 golang:1.19 镜像,GOPATH 是 /go,我们直接用这个,并把本机的目录映射进去:

# 删除之前的容器,如果有
docker rm -f go_app

# 启动容器
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

# 进入容器的命令行
docker exec -it go_app bash

# cd echo_server directory
go run main.go

Golang镜像不提供 netstat vim 等命令,需要手动在容器里安装 net-tools 和 vim:

# 查看linux发行版
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

关于四次挥手的一些观察:有client连接时,server关闭后,需要等待一段时间才能释放端口。这里挖个坑,后续可能不会填了。

下面是一个server和一个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 关掉server,netstat 返回这样的结果:

# 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)

短时间内再次启动server, bind时会报错 address already in use。再等一段时间,client端自动断开,server才能启动:

# 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)

推荐阅读

福利
我为大家整理了一份从入门到进阶的Go学习资料礼包,包含学习建议:入门看什么,进阶看什么。关注公众号 「polarisxu」,回复 ebook 获取;还可以回复「进群」,和数万 Gopher 交流学习。


文章来源: http://mp.weixin.qq.com/s?__biz=MzAxMTA4Njc0OQ==&mid=2651453997&idx=1&sn=16fba3362b83cbc59ee3e9bb7e9b70eb&chksm=80bb24dfb7ccadc99da9fc24b9d1c6cf03d53da3ad1bde4f035a34ef596512d50e48e9e540f1#rd
如有侵权请联系:admin#unsafe.sh