Golang 开发 Tcp 服务器及拆包粘包、优雅关闭的解决方案
Golang 作为广泛用于服务端和云计算领域的编程语言,tcp socket 是其中至关重要的功能。您可以在 github.com/hdt3213/godis/tcp 中看到本文所述 TCP 服务器的完整代码及其应用。
早期的 Tomcat/Apache 服务器使用的是阻塞 IO 模型。它使用一个线程处理一个连接,在没有收到新数据时监听线程处于阻塞状态,直到数据就绪后线程被唤醒。因为阻塞 IO 模型需要开启大量线程并且频繁地进行上下文切换,所以效率很差。
IO 多路复用技术为了解决上述问题采用了一个线程监听多路连接的方案。一个线程持有多个连接并阻塞等待,当其中某个连接可读写时线程被唤醒进行处理。因为多个连接复用了一个线程所以 IO 多路复用需要的线程数少很多。
主流操作系统都提供了IO多路复用技术的实现,比如 Linux上的 epoll,freeBSD 上的 kqueue 以及 Windows 平台上的 iocp。有得必有失,因为 epoll 等技术提供的接口面向 IO 事件而非面向连接,所以需要编写复杂的异步代码,开发难度很大。
Golang 的 netpoller
基于IO多路复用和 goroutine scheduler 构建了一个简洁高性能的网络模型,并给开发者提供了 goroutine-per-connection
风格的极简接口。
更多关于 netpoller
的剖析可以参考Golang实现四种负载均衡的算法(随机,轮询等), 接下来我们尝试用 netpoller
编写我们的服务器。
Echo 服务器
作为开始我们来实现一个简单的 Echo 服务器。它会接受客户端连接并将客户端发送的内容原样传回客户端。
package main import ( "fmt" "net" "io" "log" "bufio" ) func ListenAndServe(address string) { // 绑定监听地址 listener, err := net.Listen("tcp", address) if err != nil { log.Fatal(fmt.Sprintf("listen err: %v", err)) } defer listener.Close() log.Println(fmt.Sprintf("bind: %s, start listening...", address)) for { // Accept 会一直阻塞直到有新的连接建立或者listen中断才会返回 conn, err := listener.Accept() if err != nil { // 通常是由于listener被关闭无法继续监听导致的错误 log.Fatal(fmt.Sprintf("accept err: %v", err)) } // 开启新的 goroutine 处理该连接 go Handle(conn) } } func Handle(conn net.Conn) { // 使用 bufio 标准库提供的缓冲区功能 reader := bufio.NewReader(conn) for { // ReadString 会一直阻塞直到遇到分隔符 '\n' // 遇到分隔符后会返回上次遇到分隔符或连接建立后收到的所有数据, 包括分隔符本身 // 若在遇到分隔符之前遇到异常, ReadString 会返回已收到的数据和错误信息 msg, err := reader.ReadString('\n') if err != nil { // 通常遇到的错误是连接中断或被关闭,用io.EOF表示 if err == io.EOF { log.Println("connection close") } else { log.Println(err) } return } b := []byte(msg) // 将收到的信息发送给客户端 conn.Write(b) } } func main() { ListenAndServe(":8000") }
使用 telnet 工具测试我们编写的 Echo 服务器:
$ telnet 127.0.0.1 8000 Trying 127.0.0.1... Connected to 127.0.0.1. Escape character is '^]'. > a a > b b Connection closed by foreign host.
拆包与粘包问题
某些朋友可能看到"拆包与粘包"后表示极度震惊,并再三强调: TCP是个字节流协议,不存在粘包问题。
我们常说的 TCP 服务器并非「实现 TCP 协议的服务器」而是「基于TCP协议的应用层服务器」。TCP 是面向字节流的协议,而应用层协议大多是面向消息的,比如 HTTP 协议的请求/响应,Redis 协议的指令/回复都是以消息为单位进行通信的。
作为应用层服务器我们有责任从 TCP 提供的字节流中正确地解析出应用层消息,在这一步骤中我们会遇到「拆包/粘包」问题。
socket 允许我们通过 read 函数读取新收到的一段数据(当然这段数据并不对应一个 TCP 包)。在上文的 Echo 服务器示例中我们用\n
表示消息结束,从 read 函数读取的数据可能存在下列几种情况:
- 收到两段数据: "abc", "def\n" 它们属于一条消息 "abcdef\n" 这是拆包的情况
- 收到一段数据: "abc\ndef\n" 它们属于两条消息 "abc\n", "def\n" 这是粘包的情况
应用层协议通常采用下列几种思路之一来定义消息,以保证完整地进行读取:
- 定长消息
- 在消息尾部添加特殊分隔符,如示例中的Echo协议和FTP控制协议。bufio 标准库会缓存收到的数据直到遇到分隔符才会返回,它可以帮助我们正确地分割字节流。
- 将消息分为 header 和 body, 并在 header 中提供 body 总长度,这种分包方式被称为 LTV(length,type,value) 包。这是应用最广泛的策略,如HTTP协议。当从 header 中获得 body 长度后, io.ReadFull 函数会读取指定长度字节流,从而解析应用层消息。
在没有具体应用层协议的情况下,我们很难详细地讨论拆包与粘包问题。在本系列的第二篇文章: 实现 Redis 协议解析器 中我们可以看到 Redis 序列化协议(RESP)对分隔符和 LTV 包的结合应用,以及两种分包方式的具体解析代码。
优雅关闭
在生产环境下需要保证TCP服务器关闭前完成必要的清理工作,包括将完成正在进行的数据传输,关闭TCP连接等。这种关闭模式称为优雅关闭,可以避免资源泄露以及客户端未收到完整数据导致故障。
TCP 服务器的优雅关闭模式通常为: 先关闭listener阻止新连接进入,然后遍历所有连接逐个进行关闭。首先修改一下TCP服务器:
// handler 是应用层服务器的抽象 type Handler interface { Handle(ctx context.Context, conn net.Conn) Close()error } // 监听并提供服务,并在收到 closeChan 发来的关闭通知后关闭 func ListenAndServe(listener net.Listener, handler tcp.Handler, closeChan <-chan struct{}) { // 监听关闭通知 go func() { <-closeChan logger.Info("shutting down...") // 停止监听,listener.Accept()会立即返回 io.EOF _ = listener.Close() // 关闭应用层服务器 _ = handler.Close() }() // 在异常退出后释放资源 defer func() { // close during unexpected error _ = listener.Close() _ = handler.Close() }() ctx := context.Background() var waitDone sync.WaitGroup for { // 监听端口, 阻塞直到收到新连接或者出现错误 conn, err := listener.Accept() if err != nil { break } // 开启 goroutine 来处理新连接 logger.Info("accept link") waitDone.Add(1) go func() { defer func() { waitDone.Done() }() handler.Handle(ctx, conn) }() } waitDone.Wait() } // ListenAndServeWithSignal 监听中断信号并通过 closeChan 通知服务器关闭 func ListenAndServeWithSignal(cfg *Config, handler tcp.Handler) error { closeChan := make(chan struct{}) sigCh := make(chan os.Signal) signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGINT) go func() { sig := <-sigCh switch sig { case syscall.SIGHUP, syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGINT: closeChan <- struct{}{} } }() listener, err := net.Listen("tcp", cfg.Address) if err != nil { return err } logger.Info(fmt.Sprintf("bind: %s, start listening...", cfg.Address)) ListenAndServe(listener, handler, closeChan) return nil }
接下来修改应用层服务器:
// 客户端连接的抽象 type Client struct { // tcp 连接 Conn net.Conn // 当服务端开始发送数据时进入waiting, 阻止其它goroutine关闭连接 // wait.Wait是作者编写的带有最大等待时间的封装: // https://github.com/HDT3213/godis/blob/master/src/lib/sync/wait/wait.go Waiting wait.Wait } type EchoHandler struct { // 保存所有工作状态client的集合(把map当set用) // 需使用并发安全的容器 activeConn sync.Map // 关闭状态标识位 closing atomic.AtomicBool } func MakeEchoHandler()(*EchoHandler) { return &EchoHandler{} } func (h *EchoHandler)Handle(ctx context.Context, conn net.Conn) { // 关闭中的 handler 不会处理新连接 if h.closing.Get() { conn.Close() return } client := &Client { Conn: conn, } h.activeConn.Store(client, struct{}{}) // 记住仍然存活的连接 reader := bufio.NewReader(conn) for { msg, err := reader.ReadString('\n') if err != nil { if err == io.EOF { logger.Info("connection close") h.activeConn.Delete(client) } else { logger.Warn(err) } return } // 发送数据前先置为waiting状态,阻止连接被关闭 client.Waiting.Add(1) // 模拟关闭时未完成发送的情况 //logger.Info("sleeping") //time.Sleep(10 * time.Second) b := []byte(msg) conn.Write(b) // 发送完毕, 结束waiting client.Waiting.Done() } } // 关闭客户端连接 func (c *Client)Close()error { // 等待数据发送完成或超时 c.Waiting.WaitWithTimeout(10 * time.Second) c.Conn.Close() return nil } // 关闭服务器 func (h *EchoHandler)Close()error { logger.Info("handler shutting down...") h.closing.Set(true) // 逐个关闭连接 h.activeConn.Range(func(key interface{}, val interface{})bool { client := key.(*Client) client.Close() return true }) return nil }
到此这篇关于Golang 编写Tcp服务器的解决方案的文章就介绍到这了,更多相关go tcp服务器内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!