
本文详解如何用 go 构建一个支持双客户端连接的 tcp 服务器,使其能接收任一客户端消息后,既回显给发送方,又实时转发给另一已连接客户端——核心在于正确管理连接生命周期、避免阻塞、共享状态并安全并发通信。
本文详解如何用 go 构建一个支持双客户端连接的 tcp 服务器,使其能接收任一客户端消息后,既回显给发送方,又实时转发给另一已连接客户端——核心在于正确管理连接生命周期、避免阻塞、共享状态并安全并发通信。
传统 TCP 服务器常采用“每连接一 goroutine”模型,但本例中若简单为每个端口启动独立 acceptLoop(如监听 :3000 和 :8080),再在 listenConnection 中同步调用 writeMessage() 去反向拨号(如 Dial("localhost:8080")),会引发严重问题:
- ❌ 连接风暴:writeMessage() 每次都新建到 :8080 的 TCP 连接,而客户端并未设计为持续接受新连接(它只连一次,然后长连接读写),导致 Dial 失败或连接被拒绝;
- ❌ 逻辑错位:服务器不应主动“拨号”客户端,而应维护已建立的客户端连接句柄,复用其 net.Conn 写入;
- ❌ 竞态与阻塞:全局变量 message 未加锁,多 goroutine 并发读写引发数据竞争;writeMessage() 在 listenConnection 的 for 循环中同步执行,阻塞当前连接处理;
- ❌ 架构失当:两个监听器(:3000/:8080)本质是同一服务的两个入口,应统一管理连接,而非割裂为“服务端口”和“客户端口”。
✅ 正确方案:单监听器 + 连接注册 + 广播分发
服务器只需监听一个端口(如 :3000),让两个客户端均连接至此;服务端在 acceptLoop 中为每个新连接启动 goroutine,并将连接存入线程安全的映射(map[net.Conn]struct{}),配合 sync.RWMutex 管理。收到消息后,遍历其他活跃连接并广播。
以下是重构后的完整可运行示例(含错误处理与并发安全):
package main
import (
"fmt"
"log"
"net"
"sync"
)
var (
clients = make(map[net.Conn]struct{}) // 存储所有已连接客户端
mu sync.RWMutex
)
func main() {
fmt.Println("Server listening on :3000...")
listener, err := net.Listen("tcp", ":3000")
if err != nil {
log.Fatal("Failed to start server:", err)
}
defer listener.Close()
for {
conn, err := listener.Accept()
if err != nil {
log.Printf("Accept error: %v", err)
continue
}
log.Printf("New client connected: %s", conn.RemoteAddr())
// 注册连接
mu.Lock()
clients[conn] = struct{}{}
mu.Unlock()
// 为每个连接启动独立 goroutine 处理读写
go handleConnection(conn)
}
}
func handleConnection(conn net.Conn) {
defer func() {
// 连接关闭时清理
mu.Lock()
delete(clients, conn)
mu.Unlock()
conn.Close()
log.Printf("Client disconnected: %s", conn.RemoteAddr())
}()
buf := make([]byte, 1024)
for {
n, err := conn.Read(buf)
if err != nil {
log.Printf("Read error from %s: %v", conn.RemoteAddr(), err)
return
}
msg := buf[:n]
log.Printf("Received from %s: %s", conn.RemoteAddr(), string(msg))
// 1. 回显给发送方
if _, writeErr := conn.Write(msg); writeErr != nil {
log.Printf("Write echo error to %s: %v", conn.RemoteAddr(), writeErr)
return
}
// 2. 广播给其他所有客户端(非自身)
mu.RLock()
for other := range clients {
if other == conn {
continue // 跳过自己
}
if _, writeErr := other.Write(msg); writeErr != nil {
log.Printf("Broadcast write error to %s: %v", other.RemoteAddr(), writeErr)
// 可选:自动清理失效连接
mu.RUnlock()
mu.Lock()
delete(clients, other)
mu.Unlock()
mu.RLock()
}
}
mu.RUnlock()
}
}
? 客户端保持不变(仅需连接 :3000):两个客户端均运行相同代码,目标地址统一为 "localhost:3000" —— 不再需要分别监听 :3000 和 :8080。
✅ 关键改进点总结:
- 单入口统一管理:消除端口语义混淆,所有客户端平等接入;
- 连接即资源:net.Conn 是全双工通道,复用即可,无需反向 Dial;
- 并发安全共享状态:sync.RWMutex 保护 clients 映射,读多写少场景高效;
- 优雅清理:defer 确保连接关闭时从映射中移除,避免内存泄漏;
- 错误隔离:单个客户端写失败不影响其他连接,日志明确追踪异常源。
⚠️ 注意事项:
- 生产环境应增加心跳机制检测断连;
- 大消息需考虑粘包/拆包(本例使用换行或固定长度协议更稳妥);
- clients 映射不宜过大,可引入连接 ID 与超时淘汰策略;
- 若需区分客户端角色(如 A 发 B 收),可扩展为带元数据的结构体存储。
此模式是构建聊天室、实时通知等场景的基础范式,兼顾简洁性与可扩展性。











