用Golang写网络服务时,多连接并发处理是绕不开的话题。传统的线程模型中,每个连接对应一个系统线程,几百个连接就会消耗大量内存和调度开销。而Golang的goroutine是用户态轻量级线程,初始栈只有几KB,创建和切换的成本极低,单机轻松支撑数十万并发连接。再配合channel在goroutine之间安全地传递数据,并发处理的代码既简洁又不容易出错。本文将围绕goroutine和channel这两个核心原语,完整讲解如何实现一个支持多连接并发处理的TCP服务。

一、为什么一个连接一个goroutine是最佳实践
在Go的标准库中,net/http服务器对每个进来的请求都会启动一个goroutine去处理,这就是官方推荐的并发模型:Don't communicate by sharing memory, share memory by communicating(不要通过共享内存来通信,而要通过通信来共享内存)。对TCP服务器来说,同样适用。
这种模型的优势在于:每个连接的处理逻辑是独立的,写在同一个函数里,从上往下读就是完整的业务流程,不需要写回调,也没有状态机那么绕。主goroutine只负责accept新连接,一旦有连接进来,就启动一个新goroutine去读写数据,主循环立刻回到accept等待下一个连接,互不阻塞。
来看一个最基础的并发TCP服务器骨架:
package main
import (
"fmt"
"net"
)
func main() {
listener, err := net.Listen("tcp", ":9000")
if err != nil {
fmt.Println("监听失败:", err)
return
}
defer listener.Close()
for {
conn, err := listener.Accept()
if err != nil {
fmt.Println("接收连接失败:", err)
continue
}
// 每个连接启动一个goroutine独立处理
go handleConn(conn)
}
}
func handleConn(conn net.Conn) {
defer conn.Close()
buf := make([]byte, 1024)
for {
n, err := conn.Read(buf)
if err != nil {
return
}
// 原样回显, echo服务
conn.Write(buf[:n])
}
}这段代码只有几十行,却已经可以同时服务成千上万个客户端。注意defer conn.Close()的写法,把资源释放放在goroutine入口处声明,是Go里管理连接生命周期的标准做法,即使处理函数中途return或panic,连接也一定会被关闭。
二、用channel实现连接间的数据流转
单连接的读写解决了,接下来是多连接之间的协作。典型场景是聊天室:客户端A发的消息要广播给所有在线的客户端。如果直接用一个全局map记录所有连接,多个goroutine同时读写map就会产生数据竞争。正确的方式是引入一个中心的broadcaster goroutine,所有连接通过channel把消息发给它,由它统一管理消息分发。
type Client struct {
conn net.Conn
send chan []byte // 该客户端的发送队列
}
type Hub struct {
clients map[*Client]bool
register chan *Client
unregister chan *Client
broadcast chan []byte
}
func newHub() *Hub {
return &Hub{
clients: make(map[*Client]bool),
register: make(chan *Client),
unregister: make(chan *Client),
broadcast: make(chan []byte, 256),
}
}
func (h *Hub) run() {
for {
select {
case c := <-h.register:
h.clients[c] = true
case c := <-h.unregister:
if _, ok := h.clients[c]; ok {
delete(h.clients, c)
close(c.send)
}
case msg := <-h.broadcast:
for c := range h.clients {
select {
case c.send <- msg:
default:
// 发送队列已满,说明客户端处理太慢,直接踢掉
close(c.send)
delete(h.clients, c)
}
}
}
}
}这个Hub模式是很多IM系统的雏形。它的精髓在于:所有对clients这个map的读写都发生在run函数这一个goroutine里,天然串行,完全不需要加锁。其他goroutine想注册、注销连接或广播消息,只需要往对应的channel里发送即可。channel本身是并发安全的,这种设计把共享状态的保护问题转化成了消息传递问题。
每个客户端的写操作同样要用独立goroutine加channel来做发送队列,避免一个慢客户端把广播流程卡死:
func (c *Client) writePump() {
for msg := range c.send {
_, err := c.conn.Write(msg)
if err != nil {
break
}
}
c.conn.Close()
}
func (c *Client) readPump(hub *Hub) {
buf := make([]byte, 1024)
for {
n, err := c.conn.Read(buf)
if err != nil {
break
}
hub.broadcast <- append([]byte{}, buf[:n]...)
}
hub.unregister <- c
}注意append([]byte{}, buf[:n]...)这里重新拷贝了一份切片,因为buf会在下次Read时被复用,直接把引用发出去会造成数据被覆盖的经典bug。这类细节在并发编程中特别容易踩坑。
三、并发安全与优雅退出
连接多了以后,两个问题必须正面处理:一是goroutine泄漏,二是退出时的资源回收。先说泄漏,常见的坑是goroutine阻塞在一个没有人的channel上永远退不出去。比如Hub已经关闭了c.send,而写goroutine还在往里发数据就会panic。解决办法是控制好channel的关闭权限:channel由发送方关闭,绝不由接收方关闭,且一个channel只应有一个发送方负责关闭。
再说退出管理。用sync.WaitGroup可以优雅地等待所有连接处理完毕:
var wg sync.WaitGroup
func main() {
listener, _ := net.Listen("tcp", ":9000")
go func() {
// 捕获Ctrl+C信号
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
listener.Close() // 关闭后Accept会返回错误,主循环退出
}()
for {
conn, err := listener.Accept()
if err != nil {
break // listener已关闭,跳出循环
}
wg.Add(1)
go func() {
defer wg.Done()
handleConn(conn)
}()
}
wg.Wait() // 等待所有连接处理完成
fmt.Println("服务器已优雅退出")
}最后是超时控制。对空闲连接应该设置读写超时,防止慢连接长期占用资源:
func handleConn(conn net.Conn) {
defer conn.Close()
buf := make([]byte, 1024)
for {
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
n, err := conn.Read(buf)
if err != nil {
if ne, ok := err.(net.Error); ok && ne.Timeout() {
fmt.Println("连接空闲超时,主动断开")
}
return
}
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
conn.Write(buf[:n])
}
}总结一下,Golang的多连接并发处理可以归纳为三层结构:accept循环分发连接、每个连接独立的读写goroutine、以及中心化的Hub通过channel协调全局状态。掌握了这套模式,再去看WebSocket库、消息队列客户端的源码,会发现它们的并发结构几乎是同一个套路。动手把上面的代码跑起来,用多个终端同时连接测试,你就能直观感受到goroutine带来的并发简洁性。