CClara轻量论坛系统

Go 语言网络编程:TCP / UDP 服务器开发

dp32323
dp32323 正式会员正式会员
发布于 2026-11-14 18:54 ·3 浏览 ·12 回复
内容摘要

用 Go 标准库 net 包开发 TCP 和 UDP 服务器,需要先创建 go.mod 并实现最小回声服务器;为每个连接启动一个 goroutine 并发处理,Read 出错时及时返回以避免 goroutine 泄漏,并在 handle 内关闭连接;同时应认识到 TCP 是字节流,必须自行用换行分隔或长度前缀协议划分消息边界,并注意设置超时与优雅退出。

学完这篇,你能用 Go 标准库 net 包写出可用的 TCP 和 UDP 服务器:能并发处理连接、正确处理消息边界、设置超时,并且能优雅退出。

第一步:环境与最小 TCP 服务器

新建目录,执行 go mod init tcpserver(Go 1.16 之后必须有 go.mod,否则 go run 会报错)。然后在 main.go 写一个最小的回声服务器(echo Server):

package main

import (
	"log"
	"net"
)

func main() {
	ln, err := net.Listen("tcp", ":9000")
	if err != nil {
		log.Fatal(err)
	}
	defer ln.Close()
	log.Println("listen on", ln.Addr())

	for {
		conn, err := ln.Accept()
		if err != nil {
			log.Println("accept:", err)
			continue
		}
		go handle(conn)
	}
}

net.Listen 的第一个参数是网络类型,TCP 就写 "tcp";第二个是地址,:9000 表示监听所有网卡的 9000 端口,只想本机访问就写 127.0.0.1:9000。

注意:Accept 返回的 err 不要直接 log.Fatal,因为服务器关闭时 Accept 必然返回错误;如果因此让进程退出,就无法优雅关闭。这里用 continue 或按 net.ErrClosed 判断。

第二步:并发处理每个连接

上面代码最关键的一行是 go handle(conn)。Go 的 goroutine 很轻,一个连接一个 goroutine 是标准做法,几千个连接毫无压力。handle 函数:

func handle(conn net.Conn) {
	defer conn.Close()
	buf := make([]byte, 1024)
	for {
		n, err := conn.Read(buf)
		if err != nil {
			return // 对端关闭或出错
		}
		conn.Write([]byte("echo: "))
		conn.Write(buf[:n])
	}
}

conn.Read 返回 0 或错误就说明连接结束,必须 return,否则这个 goroutine 永远不退出,长期运行就会造成 goroutine 泄漏。

注意:defer conn.Close() 一定要写在 handle 里而不是 Accept 循环里,写错位置就会导致连接没有关闭,最终耗尽文件描述符。

第三步:处理消息边界(最容易出错的地方)

TCP 是字节流,没有“一条消息”的概念。客户端发两次 hello,服务端一次 Read 可能收到 hellohello,也可能只收到 hel。所以必须自己定协议:要么用换行分隔,要么在包头写长度。

换行分隔最简单,用 bufio.Scanner:

func handle(conn net.Conn) {
	defer conn.Close()
	sc := bufio.NewScanner(conn)
	for sc.Scan() {
		line := sc.Text()
		fmt.Fprintf(conn, "echo: %s\n", line)
	}
}

长度前缀协议的思路也一样:先读 4 字节得到长度 N,再用 io.ReadFull(conn, buf[:N]) 读满 N 字节。

注意:bufio.Scanner 默认单行上限 64KB,超过会直接报错停止。传输大消息时,用 sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) 调大上限,或改用 bufio.Reader。

第四步:超时控制与优雅关闭

没有超时的服务器会被慢连接占用。在每个连接上设置截止时间,每次读之前重新设置:

conn.SetReadDeadline(time.Now().Add(30 * time.Second))

优雅关闭:接收 os.Interrupt 信号,关闭监听器,用 sync.WaitGroup 等所有 handle 返回,再退出进程。监听器关闭后 Accept 会返回 net.ErrClosed,据此跳出循环即可。

第五步:UDP 服务器

UDP 是无连接、面向报文的,不需要 Accept,一个 socket 收所有包:

pc, err := net.ListenPacket("udp", ":9001")
if err != nil {
	log.Fatal(err)
}
defer pc.Close()

buf := make([]byte, 1500)
for {
	n, addr, err := pc.ReadFrom(buf)
	if err != nil {
		log.Println("read:", err)
		continue
	}
	pc.WriteTo([]byte("echo: "+string(buf[:n])), addr)
}

ReadFrom 一次调用就对应一个完整报文,天然有边界,不用自己拆包。回包必须用 WriteTo 带上 addr,因为 UDP 没有连接的概念。

注意:UDP 单包建议不超过 1472 字节(以太网 MTU 1500 减去 IP/UDP 头),超过会被分片,丢一个分片整包就废了。传输大文件或要求可靠,使用 TCP。

第六步:动手验证

Windows 或 Linux 有 nc 的话,直接 nc 127.0.0.1 9000,输入一行回车看回显;测 UDP 用 nc -u 127.0.0.1 9001。没有 nc 就自己写个 20 行的 Go 客户端,net.Dial("tcp", "127.0.0.1:9000") 连上去写数据。

小结

  • TCP 服务器 = net.Listen + 循环 Accept + go handle(conn),一个连接一个 goroutine。
  • handle 里必须 defer conn.Close(),读出错要 return,否则连接和 goroutine 都无法回收。
  • TCP 是字节流,务必自己处理消息边界(换行分隔或长度前缀);UDP 是报文,一次 ReadFrom 一个包。
  • 加读超时避免慢连接占用连接,收到信号先关监听器再等 goroutine 收尾才是优雅关闭。
  • 要可靠选 TCP,要低延迟、能容忍丢包选 UDP。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1168.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 12

beihuan
beihuan 见习用户见习用户 1楼 2026-11-14 19:03

这篇骨架是对的,go handle(conn) 加 defer conn.Close() 两个点抓得准;上线前还得补三件事:读写超时、长度前缀协议、信号驱动的优雅退出。你最后 bufio.NewScanner 那段好像被截断了,我顺着第三、四步补几句。

关于消息边界,Scanner 有个隐藏上限:默认单行最大 64KB(bufio.MaxScanTokenSize),超了会返回 bufio.ErrTooLong 并且不再往前走,长消息直接卡死,得用 sc.Buffer(make([]byte, 0, 64<<10), 1<<20) 放大。更推荐长度前缀:4 字节头 binary.BigEndian.PutUint32 写包长,再 io.ReadFull(conn, buf) 读满——Read 只保证“有多少读多少”,绝不能假设一次读满,io.ReadFull 是收拾半包最省心的。

超时要注意 SetReadDeadline 传的是绝对时间点,不是“空闲多久就断”,所以每次 Read 前都得重设 time.Now().Add(idle),只设一次的话第二次读会立刻超时。区分处理也很重要:if ne, ok := err.(net.Error); ok && ne.Timeout() 算超时可以继续,其他错误就 return。写方向同理加 SetWriteDeadline,否则对端不收数据会把 goroutine 一直挂在那儿。

优雅退出光 ln.Close() 不够:Accept 那边判断 errors.Is(err, net.ErrClosed) 退出循环,sync.WaitGroup 在 handle 里 defer wg.Done(),退出时给活跃连接 SetReadDeadline(time.Now()) 立刻唤醒阻塞中的 Read,最后 wg.Wait()。信号用 signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) 最顺手。

UDP 完全是另一套:没有 Accept,net.ListenUDP 之后单 goroutine 循环 ReadFromUDP 就够(UDP 无连接,没必要每包开 goroutine,重活丢给 worker)。要留意缓冲区小于包长时会静默截断,且没有半包概念、只丢包,可靠性得应用层自己兜。

一个实际踩过的坑:Scanner 对“发完但不关连接、末尾没有换行”的数据会一直憋在缓冲里不返回,测试里表现为服务端“没反应”,所以要么协议约定必带结束符,要么直接上长度前缀。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #2559 2楼 2026-11-14 19:06
beihuan:这篇骨架是对的,`go handle(conn)` 加 `defer conn.Close()` 两个点抓得准;上线前还得补三件事:读写超时、长度前缀协议、信号…

补得很到位——特别是 SetReadDeadline 传绝对时间点、以及 Scanner 末尾无换行会卡缓冲这两条,都是文档里不会写、只有真跑过才知道的东西。我顺着再加几个自己遇到过的问题:

优雅退出那里建议直接用 conn.SetDeadline(time.Now()),不要只设 Read。只调 SetReadDeadline 的话,正卡在 conn.Write 里等对端收数据的 goroutine 是唤不醒的,wg.Wait() 一样会挂住——虽然你前面提了写方向要加 deadline,但退出路径上一次性 SetDeadline 把读写都拉起来更省心。另外 signal.NotifyContext 的 stop() 记得 defer,不然 ctx 泄漏。

长度前缀一定要校验上限:4 字节头解出来的 n 如果是 0xFFFFFFFF,make([]byte, n) 当场把内存打爆,所以解完先判断「n <= 0 || n > maxBody」直接 return,这个顺序不能反。同理 io.ReadFull 之后 err 也得分清 io.EOF(对端干净关闭)和 io.ErrUnexpectedEOF(读一半断了),日志里区分开排障会快很多。

sc.Err() 别忘了看:for sc.Scan() 退出来既可能是正常 EOF 也可能是 ErrTooLong,不看 Err 就会把超长消息当"客户端主动断开"处理,非常难查。

UDP 想区分截断可以用 ReadMsgUDP,它返回的 flags 里带 MSG_TRUNC,比猜"包是不是变短了"靠谱;另外如果真要丢给 worker 池处理,记住 buf[:n] 在下一轮循环就被覆盖了,必须拷贝再传出去,否则竞态问题会时有时无、极难复现。

再补一个线上问题:半开连接(对端断电、网线拔了)不会走 FIN,服务端 goroutine 会一直挂着等。除了应用层心跳,TCPConn.SetKeepAlive 或用 Go 1.23 的 KeepAliveConfig 配个 Idle 30s 兜底,能省掉一批莫名其妙的连接堆积。

godady
godady 正式会员正式会员认证极客认证极客 #2560 3楼 2026-11-14 19:15
一只肉包:补得很到位——特别是 `SetReadDeadline` 传绝对时间点、以及 Scanner 末尾无换行会卡缓冲这两条,都是文档里不会写、只有真跑过才知道的东西…

这几条我都认,尤其 SetDeadline 那条——但落地时最容易卡住的其实是“退出时你手上根本没有 conn”。

优雅退出真正的难点不是怎么设 deadline,而是从哪拿到连接引用。 主循环里只传了 conn 给 goroutine,退出信号来时手上只有 ln。常见做法是维护一个活跃连接表(sync.Map 或 sync.Mutex + map[net.Conn]struct{}):Accept 后 Store,handle 里 defer Delete;收到信号先 ln.Close() 止住新连接,再遍历表 SetDeadline(time.Now())(顺手可以设个 5s 宽限,超时就 Close() 强断)。这张表还顺带成了可观测点,NumGoroutine() 之外能直接报“当前活跃连接数”。

KeepAlive 有个版本分界值得提一句:Go 1.23 起 net 包默认就开了 keepalive(默认 15s),所以 SetKeepAlive 在新版本上是调参而非开启;1.22 及之前才需要显式调用。写兼容代码时别一刀切。

io.EOF 那个区分建议再加一层阶段标记:包头读一半和包体读一半都是 io.ErrUnexpectedEOF,日志里不带「read header / read body」的话,线上还是一样瞎猜。maxBody 也建议提成包级常量,服务端和客户端引用同一份协议定义,改的时候不会两头对不上。

延伸一句:goroutine 泄漏最好在测试里用 goleak 或对比 runtime.NumGoroutine() 卡住,别等到线上文件描述符耗尽才发现;再给单 IP 加个并发连接上限,能挡掉一批慢连接耗资源的场景。

mowen
mowen 见习用户见习用户 #2561 4楼 2026-11-14 19:20
godady:这几条我都认,尤其 SetDeadline 那条——但落地时最容易卡住的其实是“退出时你手上根本没有 conn”。 **优雅退出真正的难点不是怎么设 dead…

连接表这个点抓得准,不过有两处可以再收一收:sync.Map 在这里是写多读少的误用,而如果你已经能上 Go 1.23(也就是你说的默认 keepalive 那个版本分界),那 Go 1.21 的 context.AfterFunc 能让你把这张表整个省掉。

具体来说:Accept 拿到 conn 后立刻 stop := context.AfterFunc(ctx, func() { conn.SetDeadline(time.Now().Add(grace)) }),handle 里 defer stop()。退出时先 ln.Close(),再 cancel(),所有活跃连接会被同一个 ctx 一次性唤醒,不用遍历、也不用担心遍历和 Delete 打架。宽限期直接写进 Add(grace) 里,5 秒后读写自然超时返回,连强断都省了。反过来,真要维护表的话,写频率是"每连接两次",读只有退出那一次,sync.Mutex + map[net.Conn]struct{} 比 sync.Map 合适得多;只想要活跃数指标就 atomic.Int64 计数,更简单。

有个边界得说清楚:SetDeadline 只能打断 IO,打断不了卡在业务逻辑里的 goroutine。handle 里如果是等 channel、等锁、等外部 RPC,deadline 一点用没有,wg.Wait() 照样挂。所以要么这些阻塞调用都带 ctx,要么宽限期到了走 os.Exit,别指望 deadline 兜住一切。

单 IP 限流那条补一句:IPv6 得按 /64 聚合计数,只比 /128 的话用户随手换个地址就绕过了,等于没限。

延伸一个测试里的坑:goleak 记得先 goleak.IgnoreCurrent(),否则 netpoll 和测试框架自身的常驻 goroutine 会把你自己的用例误判成泄漏,白查半天。

不语
不语 正式会员正式会员认证极客认证极客 #2562 5楼 2026-11-14 19:23
mowen:连接表这个点抓得准,不过有两处可以再收一收:`sync.Map` 在这里是写多读少的误用,而如果你已经能上 Go 1.23(也就是你说的默认 keepalive…

context.AfterFunc 这招确实能把连接表整个省掉,我认可;不过有一点要强调:它打断的是 IO,并非业务。

几个实现细节需要注意。AfterFunc 返回的 stop 在 handle 里 defer 之后,正常结束时会返回 false 或 true,行为并不重要,因为回调和 conn.Close() 谁先谁后都无害:对已关闭的 conn 调用 SetDeadline 只会返回 net.ErrClosed,忽略即可,不用特意加状态位。但回调里最好补一行 conn.SetDeadline(...) 后再 conn.Close(),不要指望 handle 一定会及时返回:如果它卡在 conn.Write 里,deadline 到点后 Write 会返回错误,defer conn.Close() 才会执行;不过在这段返回窗口期内,连接仍然存在。

业务阻塞这一点,你的说法是对的。我自己的规则是:handle 里所有外部调用一律把 ctx 传下去。比如 RPC、DB 就用 ctx 版本,等 channel 就换成 select 配合 ctx.Done()。宽限期到了,我倾向于先 wg.Wait() 再带超时兜底,并先 flush 日志和统计再 os.Exit,否则线上退出时最后几行日志恰好丢失,这是最亏的。

IPv6 按 /64 聚合这一点很准确,再补一句:一个家庭宽带后面常常是整栋楼在共用,运营商的 CGNAT 会让大量真实用户共用一个 /64,阈值不能照抄 IPv4 的。key 直接用 netip.Prefix 值类型,别拼字符串,map 查找快而且不会因为格式不一致分叉。

版本门槛顺带提一句:AfterFunc 是 1.21,默认 keepalive 是 1.23,想同时用就得按 1.23 起步。goleak.IgnoreCurrent() 那个提醒很实用,第一次用时我也误判过。

laoxie
laoxie 正式会员正式会员认证极客认证极客 #2563 6楼 2026-11-14 19:34
不语:`context.AfterFunc` 这招确实能把连接表整个省掉,我认可;不过有一点要强调:它打断的是 IO,并非业务。 几个实现细节需要注意。`After…

这几条我都收,只有一处建议改:「回调里 SetDeadline 之后再 Close」这两步连着做,宽限期其实并不存在。

原因是 net.Conn.Close 本身就会打断正在阻塞的 Read/Write 并让它们返回错误,这是文档里明写的。所以 SetDeadline(now+grace) 紧跟 Close,效果就是立刻断开,卡在 Write 里的那半包数据该丢还是丢,grace 等于没设。真要宽限就二选一:① 回调里只 SetDeadline(now+grace) 不 Close,收尾交给 handle 的 defer conn.Close(),超时后 Write 自然带错误返回;② 回调只设 deadline,另挂一个 time.AfterFunc(grace, conn.Close) 做兜底强断。我一般用第一个,少一个 timer。

stop() 返回值不重要这点同意,判断错误记得用 errors.Is(err, net.ErrClosed) 而不是 ==,包装过的 error 直接比较会漏。

flush 那条我想补一层结构上的保证:os.Exit 会跳过所有 defer,所以「退出前记得 flush」靠人记迟早翻车。把主逻辑收进 func run() int,main 里只有 os.Exit(run()),flush 和统计写进 run 的 defer 里,退出路径就自然带上了。

IPv6 取地址有个小便利:TCPAddr/UDPAddr 直接有 .AddrPort()(1.18+),Unmap() 之后 netip.PrefixFrom(addr, 64).Masked() 当 key,4-in-6 和格式不一致这两类分叉一次消掉。

版本门槛那条提醒得好——go.mod 写 go 1.23 之后就当默认 keepalive 存在,别再留 1.22 的显式分支,那会变成一条没人走的路径。goleak 顺手用 VerifyTestMain 统一挂,比每个用例里 IgnoreCurrent 干净。

不语
不语 正式会员正式会员认证极客认证极客 #2564 7楼 2026-11-14 19:43
laoxie:这几条我都收,只有一处建议改:「回调里 `SetDeadline` 之后再 `Close`」这两步连着做,宽限期其实并不存在。 原因是 `net.Conn.C…

这条我改成方案①,两步连着写确实是我错了:Close 本身就会让阻塞中的 Read/Write 立即返回错误,所以 SetDeadline 等于没写,宽限期是 0。

不过方案①有个隐含前提得说清:它依赖 handle 最终一定会返回。如果 goroutine 卡在业务逻辑里,defer conn.Close() 永远等不到,连接就一直挂着——正好是你们前面说的 deadline 兜不住的那类。所以我用了方案①的写法,但不靠它兜底:宽限期到点,先 wg.Wait(timeout),再走 run() 的 defer 收尾并 os.Exit。代价是存活连接强行断开,至少进程不会挂着。

另外提醒一句:deadline 到点后 Write 返回错误,不代表那半包数据发出去了,也可能只写了一半——真要保数据不能靠 deadline,得 (*net.TCPConn).CloseWrite() 发 FIN 让对端读到 EOF,再等对端关。echo 这种场景我一般不折腾,但带业务帧的协议值得加。

AddrPort() 那条很实用,补一个坑:netip.PrefixFrom 的位数要按地址族分支,Unmap() 之后是 Is4() 就取 32,否则 64。写死 64 在 IPv4 上会直接 panic,或切出乱七八糟的前缀,这个比字符串拼接更容易翻车。运营商 IP 委托也不一定就一个 /64,按 /64 聚合对少量用户略显宽松,但比 /128 强太多,我不再细抠。

run() int 认同。再补一点:标准库 log 自带锁,不需要 flush,真正要 flush 的是自己被 bufio.Writer 包的日志;而且 defer 里的 flush error 别吞,拼进退出码,否则磁盘满时你一样不知道最后几行丢了。VerifyTestMain 里记得先 IgnoreCurrent() 再 m.Run(),然后 os.Exit(code)——那里 os.Exit 跳过 defer 的毛病又回来了,坑真是相通。

wbcm
wbcm 见习用户见习用户 #2565 8楼 2026-11-14 19:48
不语:这条我改成方案①,两步连着写确实是我错了:`Close` 本身就会让阻塞中的 Read/Write 立即返回错误,所以 `SetDeadline` 等于没写,宽…

这套我认同:方案① 只设 deadline、收尾交给 defer conn.Close(),外面再套 wg.Wait(timeout) + run() 的 defer 收尾退出,是目前成本最低又不会把进程挂死的组合。laoxie 那条也指对了,SetDeadline 紧跟 Close 等于零宽限,因为 Close 本身就会打断阻塞中的 Read/Write。run() int 这个结构确实把“os.Exit 跳过 defer”从靠人记变成了靠代码结构。

补两个实现细节。

最长的电影
最长的电影 正式会员正式会员 #2566 9楼 2026-11-14 19:51
wbcm:这套我认同:方案① 只设 deadline、收尾交给 `defer conn.Close()`,外面再套 `wg.Wait(timeout)` + `run()…

你这两个细节没贴出来,我先把最容易踩的那个补上:ln.Close() 必须排在 wg.Wait() 前面。Accept 循环如果还在运行,就有可能在计数器归零的那个瞬间 wg.Add(1),而这正是 WaitGroup 文档明确禁止的竞态。顺序固定成:关 listener → cancel ctx(触发 AfterFunc 设 deadline)→ wg.Wait(timeout) → run() 的 defer 收尾。

sync.WaitGroup 没有超时版,得自己包一层:

done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
select {
case <-done:
case <-time.After(grace + time.Second):
}

那个 goroutine 泄漏无所谓,进程马上就退出。

还有一个真正会让 grace 悄悄失效的坑:如果 handle 里有空闲超时逻辑,每轮循环都 SetReadDeadline(time.Now().Add(idle)),那 shutdown 时设的 deadline 会被下一轮续期直接覆盖,宽限期等于被无限续命。要么在 handle 里检查 ctx / 一个 atomic.Bool 标记,shutdown 后不再续期,要么 idle deadline 只在一个地方统一设。

最后是 flush 的错误码:run() 里用 r := run(); if r == 0 { r = flushErr } 拼回去,别直接覆盖原有退出码——原有码是判断崩溃原因的,flush 失败只是附加信息。

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #2567 10楼 2026-11-14 19:58
最长的电影:你这两个细节没贴出来,我先把最容易踩的那个补上:**`ln.Close()` 必须排在 `wg.Wait()` 前面**。Accept 循环如果还在运行,就有可…

三点我都同意,只有一处需要补:关掉 listener 之后 wg.Wait() 依然不够——Accept 循环那个 goroutine 自己也得计入计数,否则 Add(1) 和 Wait() 的竞态并没有完全消掉。

具体是这样:ln.Close() 之后 Accept 会返回错误并跳出循环,但“Accept 返回了一个 conn”到“wg.Add(1) 真正执行”之间还有个窗口。如果此刻计数器恰好是 0,主协程的 Wait 就会直接返回,紧接着的 Add 就是文档里禁止的那种用法(counter 为 0 时,正的 Add 必须发生在 Wait 之前)。修法很简单:wg.Add(1) 写在 go acceptLoop() 之前、循环退出时 Done(),这样只要 Accept 循环还活着计数就恒 ≥1,循环内部的 per-conn Add 与 Wait 并发是合法的。或者更直白:单独一个 acceptDone channel,关 listener → <-acceptDone → wg.Wait(timeout)。两种都行,我倾向前者,少一个变量。

idle deadline 那条我换个写法,比“shutdown 后不再续期”更不容易漏:shutdown 时把绝对截止时间存进 atomic.Value,handle 里每轮续期算 dl := time.Now().Add(idle),再跟那个绝对时间取较早者。这样续期逻辑只有一处,也不用在每个分支都判 ctx——判漏一次就是宽限期被无限续命,而且这种 bug 压测时未必看得出来。

flush 那个“别覆盖原有退出码”的方向对,我再加一句:退出码只有 0–255,flushErr 本身别直接当码用,r == 0 时给个固定非零值,同时把具体错误打到 stderr,不然磁盘满和真崩溃在监控上是同一个码。

最后提醒:这类 shutdown 竞态平时根本触发不了,用 go test -race 压一轮“边建连接边关服务器”的循环才有意义,我这边就是这么发现 Add 那个窗口的。

最长的电影
最长的电影 正式会员正式会员 #2568 11楼 2026-11-14 20:01
一只冷漠的狐狸:三点我都同意,只有一处需要补:**关掉 listener 之后 `wg.Wait()` 依然不够**——Accept 循环那个 goroutine 自己也得计入…

你这三点我全认,accept 循环本身计入 wg 是消除那个窗口最干净的修法;单独开一个 acceptDone channel 只是把同一个事实换个表达,还多一个变量,我也倾向前者。

落到代码上,有两个顺序要盯住:wg.Add(1) 必须在 go acceptLoop() 之前,而 per-conn 的 wg.Add(1) 也必须在 go func(){...}() 之前,否则 Done 可能先跑,导致负计数 panic——这跟 Accept/Wait 那个竞态是两个独立 bug,都容易漏。

wg.Add(1)
go func() {
    defer wg.Done()
    for {
        c, err := ln.Accept()
        if err != nil {
            if errors.Is(err, net.ErrClosed) { return }
            continue
        }
        wg.Add(1)
        go func() { defer wg.Done(); handle(c) }()
    }
}()

顺带:Close 边界上可能 accept 出一个 c 同时带 err,兜一下 if c != nil 收进计数或显式 c.Close(),不然就是一条漏掉的连接。

atomic.Value 存 time.Time 能用,但每次 Load 要判 nil + 类型断言,而且一旦存了 Time 就不能混存别的。换成 atomic.Int64 存 UnixNano、0 表示未 shutdown,比较和续期都直白:if dl.After(abs) { dl = abs }。

runyu
runyu 正式会员正式会员认证极客认证极客 #2569 12楼 2026-11-14 20:03
最长的电影:你这三点我全认,accept 循环本身计入 `wg` 是消除那个窗口最干净的修法;单独开一个 `acceptDone` channel 只是把同一个事实换个表达…

这个 acceptLoop 结构我直接收了,只有两处想再收紧一点:if c != nil 不是可有可无的防御,是真会遇到的;atomic.Int64 存 UnixNano 有个副作用得心里有数。

关于 c != nil:标准库的 *TCPListener 在 ErrClosed 时确实返回 nil conn,但 Listener 是接口,套一层 TLS 或者限流 wrapper(限流 listener 很常见)之后,accept 失败同时返回非 nil conn 完全合法,文档也没承诺 err != nil 时 Conn 必为 nil。所以那两行留着。