本文以一个小型 TCP 聊天室项目为例,拆解 Go 并发编程里最重要的两个概念:goroutine 与 channel,并讲透「每连接一个协程」「fan-out 广播」「select + 超时」「channel 与 mutex 如何配合」这些实战模式。
一、为什么要写这个聊天室
聊天室是学习并发的教科书级场景:服务器要同时服务成百上千个连接,每个连接都在收发消息,任何一条消息都要实时广播给所有人。这天然逼出三个问题:
- 如何同时处理大量连接? —— 答案:goroutine。
- 一个连接收到的消息,如何安全地送到其他所有连接? —— 答案:channel。
- 多个协程同时读写「在线用户表」怎么不打架? —— 答案:channel + mutex 配合。
先看整体架构。项目由三部分组成:
.
├── main.go # 服务端入口
├── server.go # 服务器:监听、广播、超时踢人
├── user.go # 用户:上线/下线、收发消息
├── client/client.go # 命令行客户端
└── testclient/main.go # 自动化测试
核心是两个结构体:
type Server struct {
Ip string
Port int
OnlineMap map[string]*User // 在线用户表
MapLock sync.RWMutex // 保护 OnlineMap 的锁
Message chan string // 广播消息的中央队列
}
type User struct {
Name string
Addr string
C chan string // 这个用户专属的收信通道
conn net.Conn // 底层 TCP 连接
server *Server
}
注意两个 channel 字段:Server.Message 是「全服广播的中央队列」,User.C 是「每个用户专属的收信通道」。整篇文章都会围绕它们展开。
二、Goroutine:每个连接一个协程
它为什么比线程便宜
传统服务器要么「一个线程一个连接」(线程贵、上下文切换重、成千连接就撑不住),要么用复杂的事件循环 + 回调(代码难写)。Go 的答案是 goroutine:由 Go 运行时调度的轻量级执行单元,初始栈只有几 KB(会按需增长),由 M:N 调度器把海量 goroutine 映射到少量 OS 线程上。
所以你可以「奢侈」地这样写:
func (this *Server) Start() {
Listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", this.Ip, this.Port))
// ...
go this.ListenMessager() // 后台广播分发协程
for {
conn, err := Listener.Accept()
// ...
go this.Handler(conn) // 每来一个连接,就开一个协程
}
}
go 一个关键字,就为每个连接独立开辟了一条执行路径。Handler 里的逻辑因此可以写成「看起来是顺序执行」的阻塞式代码(conn.Read 阻塞等待数据),而不用碰回调地狱。这是 Go 并发编程体验上最大的爽点。
每个用户也有自己的协程
连接进来时,NewUser 顺手又开了一个协程,专门负责「把这个用户要收的消息写回连接」:
func NewUser(conn net.Conn, server *Server) *User {
user := &User{
Name: conn.RemoteAddr().String(),
C: make(chan string),
conn: conn,
server: server,
}
go user.ListenMessage() // 专职「发信」的协程
return user
}
到这里,每个连接实际上有两条 goroutine 在协作:一条负责读(在 Handler 里),一条负责写(ListenMessage)。这就是 Go 里非常常见的「读写分离、用 channel 连接」的结构。
三、Channel:Go 并发的灵魂
Go 社区有句名言:
不要通过共享内存来通信,而要通过通信来共享内存。
channel 就是「通过通信共享内存」的载体——它是一个类型化的管道,一个 goroutine 往里 <- 发,另一个往外 <- 收,数据的所有权在交接时自然转移,不再需要手动加锁。
两种 channel
make(chan string) // 无缓冲:发送方必须等到有人接收才算完成(同步会合)
make(chan string, 10) // 有缓冲:塞满 10 个之前发送不阻塞
本项目用的是无缓冲 channel,语义最干净:
func (this *Server) Boardcast(user *User, msg string) {
sendMsg := "[" + user.Addr + "]" + user.Name + ":" + msg
this.Message <- sendMsg // 阻塞,直到分发协程取走
}
this.Message <- sendMsg 这行代码同时在做两件事:传递数据(消息本身)和做同步(发送方等接收方准备好)。很多人第一次接触 channel 会困惑「这不过是个队列」,但无缓冲 channel 真正强大的是它的同步语义——它像一个握手,把并发代码的时序约束写进了类型系统。
四、核心模式一:广播的 Fan-out
这是本项目最值得学的一段代码。消息从「某人发出」到「所有人收到」,走了一条清晰的流水线:
用户 A 发消息
│ Boardcast()
▼
Server.Message(中央队列)
│ ListenMessager() 循环取出
▼
分发到 User.C / User.B.C / ...(每人一条专属通道)
│ ListenMessage() 取出
▼
写回各自的 net.Conn
func (this *Server) ListenMessager() {
for {
msg := <-this.Message // ① 从中央队列取出一条
this.MapLock.Lock()
for _, cli := range this.OnlineMap {
cli.C <- msg // ② 投递给每个在线用户
}
this.MapLock.Unlock()
}
}
这个模式叫 fan-out(扇出):一个源头,分发给多个消费者。用 channel 实现它非常自然——不需要锁来保护消息、不需要担心「发给一半时有人下线」。
但这里有两个值得深挖的细节:
- 背压(backpressure):
cli.C <- msg是无缓冲阻塞发送。如果某个用户的ListenMessage协程写连接很慢,分发协程就会卡在这个<-上,导致整个广播被最慢的人拖住。这是无缓冲 channel 带来的天然限流,也是它在生产环境里需要谨慎处理的地方(通常要配合有缓冲 channel 或超时)。 - 发送写在锁内:
cli.C <- msg是在持有MapLock的情况下执行的。如果某个cli.C一直没人收,分发协程会一直占着锁,其他想上锁的协程全被挡住。教学项目里能跑,但工程上更稳妥的做法是「先取出快照、再解锁、再逐个投递」。
每个用户的「收信协程」则简单到极致:
func (this *User) ListenMessage() {
for {
msg := <-this.C // 阻塞等自己专属通道的消息
this.conn.Write([]byte(msg + "\n"))
}
}
它的生命周期和连接绑定:连接断开,协程结束。这就是「一个 goroutine 只做一件事,靠 channel 串起来」的经典范式。
五、核心模式二:select + time.After 实现超时
聊天室需要「多久没说话就把人踢了」。Handler 用 select 把「有活动」和「超时」两件事优雅地合并到了一起:
func (this *Server) Handler(conn net.Conn) {
user := NewUser(conn, this)
user.OnLine()
IsLive := make(chan bool) // 心跳通道
go func() {
buf := make([]byte, 1024)
for {
n, err := conn.Read(buf)
if n == 0 {
user.OffLine()
return
}
// ...
user.DoMessage(string(buf[:n-2]))
IsLive <- true // 每次收到消息都「报一声平安」
}
}()
for {
select {
case <-IsLive: // 有活动:重置计时
case <-time.After(time.Second * 20): // 20 秒没动静:踢人
user.SendMessage("你被踢了")
user.OffLine()
close(user.C)
conn.Close()
return
}
}
}
这段代码展示了 select 的两个要点:
- 多路复用:
select会阻塞,直到任意一个 case 的 channel 可用;多个同时可用时随机选一个。这里把「来消息」和「超时」当成两个平等的事件来处理。 time.After返回一个 channel:它会在指定时间后往 channel 里塞一个值,于是「定时器」也被统一抽象成了 channel,正好能被select消费。
一个小提醒:time.After 每次调用都会创建一个新的 time.Timer,这个例子把它放在循环里,每收到一次心跳都会新建一个计时器(旧的等 GC 回收)。功能正确,但如果追求严谨,应该用 time.NewTimer + Reset 复用同一个计时器。
六、Channel 与 Mutex 如何分工
初学者最常问:有了 channel 还要 mutex 干嘛?这个项目给出了一个很好的对照:
| 场景 | 用什么 | 为什么 |
|---|---|---|
| 消息传递(广播、私聊) | channel | 传递数据所有权,天然同步 |
| 共享状态(在线用户表 OnlineMap) | sync.RWMutex | 多读多写一张 map,用锁最简单直接 |
看用户上线/下线如何操作共享的 map:
func (this *User) OnLine() {
this.server.MapLock.Lock()
this.server.OnlineMap[this.Name] = this
this.server.MapLock.Unlock()
this.server.Boardcast(this, "上线")
}
func (this *User) OffLine() {
this.server.MapLock.Lock()
delete(this.server.OnlineMap, this.Name)
this.server.MapLock.Unlock()
this.server.Boardcast(this, "下线")
}
map 不是并发安全的,多个 goroutine 同时读写会 panic(concurrent map writes),所以这里用 RWMutex 保护。经验法则:传递数据用 channel,保护共享状态用锁。两者不是对立的,恰恰经常像这样配合使用。
七、常见陷阱(照着这个项目就能踩到)
- 向已关闭的 channel 发送 → panic。本项目里
Handler超时会close(user.C),如果此时ListenMessager恰好还在cli.C <- msg,就会 panic。生产代码务必用「谁写谁关」或select + done来规避。 - 从已关闭的 channel 接收 → 拿到零值,不会报错。要用双返回值判断:
msg, ok := <-ch,ok == false说明通道已关闭。 - 无缓冲 channel 的死锁:
ch := make(chan string); ch <- "hi"如果同一时刻没有接收方,这条 goroutine 永远卡住,程序检测到所有 goroutine 都睡死时会直接 fatal。 - goroutine 泄漏:开了协程却没有任何让它退出的机制(比如忘了
close或context),协程会一直挂着占用内存。 - 持锁阻塞:如前面所说,在
Lock里做channel的阻塞发送,是最容易埋下性能隐患的地方之一。
八、总结
回头看这个不到 400 行的聊天室,它几乎浓缩了 Go 并发编程的核心姿势:
- goroutine:
go Handler(conn)一个连接一个协程,go ListenMessage()一个用户一个收信协程,把并发写得像顺序代码。 - channel:
Server.Message做中央广播队列,User.C做个人收信通道,数据在 channel 间流动,所有权在交接中转移。 - fan-out:
ListenMessager从一条Message扇出到 N 个User.C,是广播的最简实现。 - select + time.After:把超时也变成 channel 事件,与业务事件平等地多路复用。
- channel 与 mutex 分工:传递数据用 channel,保护 map 用锁。
理解了这套「协程 + 通道」的思维模型,再去读 Redis、etcd、K8s 这些 Go 大项目,会发现它们的内核里到处都是这样的结构。建议你把这个项目跑起来,亲手在 ListenMessager 里加个日志打印,观察一条消息从 Boardcast 到每个连接写出的全过程——那时你会真正「看见」并发。
附:完整代码见仓库的 server.go、user.go,客户端在 client/client.go,自动化测试在 testclient/main.go。