这篇文章中,我们利用面向过程编程的思路,只依赖Syscall库而不是Net库,实现一个简单的 Echo Server,以更好地理解 Tcp Server 的工作原理。
Go net库对 tcp server 的支持非常完善,其中最核心的部分依赖系统调用 socket/bind/listen/accept。这些系统调用被完好地封装在syscall库里, 而且这层封装屏一定程度上蔽掉了底层操作系统的差异性。
如果你读过前一篇文章,会发现net库应用了面向对象编程的思路,对系统调用做了很多层封装。这篇文章中,我们利用面向过程编程的思路,只依赖syscall库而不是net库,实现一个简单的 echo server,以更好地理解 tcp server 的工作原理。
服务流程
- 创建套接字: syscall.Socket()。
- 绑定套接字和ip:port: syscall.Bind()。
- 监听套接字: syscall.Listen()。
- 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选项:
Goland配置
创建套接字
// 创建套接字
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框架没有实现这一点)。做法也比较简单。
- 创建一个容量为0的channel。
- 注册监听哪些操作系统信号。
- 在 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
echo client的功能是:
- 通过 socket, connect 系统调用建立与tcp server的连接。
- 创建 bufio.Reader,从os.Stdin读取输入。
- 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)