用 Go 写一个 TCP 聊天室:深入理解 Goroutine 与 Channel
本文最后更新于17 天前,其中的信息可能已经过时,如有错误请发送邮件到big_fw@foxmail.com

本文以一个小型 TCP 聊天室项目为例,拆解 Go 并发编程里最重要的两个概念:goroutinechannel,并讲透「每连接一个协程」「fan-out 广播」「select + 超时」「channel 与 mutex 如何配合」这些实战模式。

一、为什么要写这个聊天室

聊天室是学习并发的教科书级场景:服务器要同时服务成百上千个连接,每个连接都在收发消息,任何一条消息都要实时广播给所有人。这天然逼出三个问题:

  1. 如何同时处理大量连接? —— 答案:goroutine。
  2. 一个连接收到的消息,如何安全地送到其他所有连接? —— 答案:channel。
  3. 多个协程同时读写「在线用户表」怎么不打架? —— 答案: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 实现它非常自然——不需要锁来保护消息、不需要担心「发给一半时有人下线」。

但这里有两个值得深挖的细节:

  1. 背压(backpressure)cli.C <- msg 是无缓冲阻塞发送。如果某个用户的 ListenMessage 协程写连接很慢,分发协程就会卡在这个 <- 上,导致整个广播被最慢的人拖住。这是无缓冲 channel 带来的天然限流,也是它在生产环境里需要谨慎处理的地方(通常要配合有缓冲 channel 或超时)。
  2. 发送写在锁内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 实现超时

聊天室需要「多久没说话就把人踢了」。Handlerselect 把「有活动」和「超时」两件事优雅地合并到了一起:

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,保护共享状态用锁。两者不是对立的,恰恰经常像这样配合使用。

七、常见陷阱(照着这个项目就能踩到)

  1. 向已关闭的 channel 发送 → panic。本项目里 Handler 超时会 close(user.C),如果此时 ListenMessager 恰好还在 cli.C <- msg,就会 panic。生产代码务必用「谁写谁关」或 select + done 来规避。
  2. 从已关闭的 channel 接收 → 拿到零值,不会报错。要用双返回值判断:msg, ok := <-chok == false 说明通道已关闭。
  3. 无缓冲 channel 的死锁ch := make(chan string); ch <- "hi" 如果同一时刻没有接收方,这条 goroutine 永远卡住,程序检测到所有 goroutine 都睡死时会直接 fatal。
  4. goroutine 泄漏:开了协程却没有任何让它退出的机制(比如忘了 closecontext),协程会一直挂着占用内存。
  5. 持锁阻塞:如前面所说,在 Lock 里做 channel 的阻塞发送,是最容易埋下性能隐患的地方之一。

八、总结

回头看这个不到 400 行的聊天室,它几乎浓缩了 Go 并发编程的核心姿势:

  • goroutinego Handler(conn) 一个连接一个协程,go ListenMessage() 一个用户一个收信协程,把并发写得像顺序代码。
  • channelServer.Message 做中央广播队列,User.C 做个人收信通道,数据在 channel 间流动,所有权在交接中转移。
  • fan-outListenMessager 从一条 Message 扇出到 N 个 User.C,是广播的最简实现。
  • select + time.After:把超时也变成 channel 事件,与业务事件平等地多路复用。
  • channel 与 mutex 分工:传递数据用 channel,保护 map 用锁。

理解了这套「协程 + 通道」的思维模型,再去读 Redis、etcd、K8s 这些 Go 大项目,会发现它们的内核里到处都是这样的结构。建议你把这个项目跑起来,亲手在 ListenMessager 里加个日志打印,观察一条消息从 Boardcast 到每个连接写出的全过程——那时你会真正「看见」并发。


附:完整代码见仓库的 server.gouser.go,客户端在 client/client.go,自动化测试在 testclient/main.go

文末附加内容
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇