Skip to content
Go
net — 网络 I/O 基础

net — 网络 I/O 基础

net 提供可移植的网络 I/O 接口:TCP、UDP、Unix Domain Socket、IP 层、DNS 解析。它是 net/http、crypto/tls 的地基——那两个包最终都在操作 net.Conn。

Go 网络编程最重要的一个前提是:netpoller。conn.Read() 看起来阻塞,实际是把当前 goroutine 挂起、把 fd 交给内核的事件循环(Linux epoll / macOS kqueue / Windows IOCP),线程立刻被别的 goroutine 拿去用。所以"一个连接一个 goroutine"这种写法在 Go 里是正确且高效的,不需要为此写状态机或回调——这是 Go 相对传统 C/Java NIO 的核心优势,也是本篇所有示例都这么写的原因。


1. 两个地址类型:先选对,再写代码

Go 里表示 IP 有两套体系,混用是很多 bug 的源头:

net.IP / net.UDPAddr netip.Addr / AddrPort / Prefix(Go 1.18+)
底层类型 []byte(切片,堆上有背衬数组) 定长值类型(实测 unsafe.Sizeof = 24 字节)
能否 == 比较 不可靠(切片不能直接 ==,转 string 又受长度影响) 可以,可直接做 map key(实测)
nil 语义 nil 与 16 个零字节是两回事 零值 = “无效地址”,IsValid() 判定
IPv6 zone(fe80::1%eth0) ParseIP 返回 nil(实测) 支持,Zone()/WithZone()
是否产生分配 每次转换/切片都可能分配 值传递,通常零分配

选型建议:新代码里凡是"只是表示一个地址、要做 map key 或集合运算"的场合,用 net/netip;真正要建立连接、Dial/Listen 的接口还是收字符串地址("host:port")。两套之间靠 Addr.Addr() / AddrFromIPPort() 转换,别在结构体里同时存两份。

实测过的几个坑

// 1) net.ParseIP 不认 zone,直接给 nil —— 而且不报错
net.ParseIP("fe80::1%eth0")                  // nil!
netip.ParseAddr("fe80::1%eth0").Zone()       // "eth0",IsLinkLocalUnicast()=true

// 2) net.IP 里 IPv4 是 16 字节的 4-in-6 形式
len(net.ParseIP("1.2.3.4"))                  // 16,不是 4
len(net.IPv4(1, 2, 3, 4))                    // 16
net.ParseIP("1.2.3.4").To4()                 // 长度 4 的表示;不是 IPv4 时返回 nil
// 所以:不同长度的两种表示用 == / bytes.Equal 会判不等,比较地址一律用 ip.Equal(ip2)

// 3) netip 里 v4 和 v4-in-v6 是不同值
a, _ := netip.ParseAddr("::ffff:1.2.3.4")
a.Is4()      // false
a.Is4In6()   // true  ← 专门用来识别这种形式
a.Is6()      // true
a.Unmap()    // 1.2.3.4(Is4()=true)

// 4) netip 解析 ip:port 必须给 IPv6 加方括号
netip.ParseAddrPort("::1:80")     // 报错:invalid ip:port "::1:80", IPv6 addresses must be surrounded by square brackets
netip.ParseAddrPort("[::1]:80")   // OK,.Port() = 80
netip.AddrPortFrom(netip.MustParseAddr("::1"), 80) // 程序里拼地址用这个,别用 Sprintf

host:port 字符串的拼装与拆分

net.JoinHostPort("::1", "8080")   // "[::1]:8080" —— 自动补方括号
net.SplitHostPort("[::1]:8080")   // "::1", "8080", nil
net.SplitHostPort("::1:8080")     // 报错:address ::1:8080: too many colons in address
net.SplitHostPort("localhost")    // 报错:address localhost: missing port in address

永远不要写 fmt.Sprintf("%s:%d", host, port)。 IPv6 地址本身含冒号,拼出来的 fe80::1:8080 是非法/歧义的,Dial 会连到错误的地址或直接失败。Go 1.25 起 go vet 专门加了 hostport 分析器报这个问题,建议用 net.JoinHostPort(或 netip.AddrPortFrom)。

CIDR 判断:

_, cidr, err := net.ParseCIDR("192.168.1.100/24") // 注意:第一个返回值是原 IP,第二个才是网络
// cidr = "192.168.1.0/24"(自动按掩码归零),不是 .100
cidr.Contains(net.ParseIP("192.168.1.100")) // true
cidr.Contains(net.ParseIP("10.0.0.1"))      // false

netip.Prefix 版本更顺手(Go 1.26 起还多了 Compare,可以直接排序/去重前缀列表):

pfx, _ := netip.ParsePrefix("192.168.1.0/24")
pfx.Contains(netip.MustParseAddr("192.168.1.5")) // true
pfx.Bits()                                       // 24
pfx.Masked()                                     // 归一化后的网络前缀

注意:net.ParseCIDR 的两个返回值极易写反——ip, ipnet, err := net.ParseCIDR(...) 里的 ip 是你传进来的那个主机地址,ipnet 才是掩码后的网络。做白名单判断时要拿 ipnet,用错对象等于用单 IP 精确匹配。


2. TCP:监听、Accept、超时

2.1 服务端正确写法

func serve(ctx context.Context, addr string, handle func(net.Conn)) error {
	lc := net.ListenConfig{}
	listener, err := lc.Listen(ctx, "tcp", addr)
	if err != nil {
		return err
	}
	defer listener.Close()

	// ctx 取消时唤醒阻塞中的 Accept
	go func() {
		<-ctx.Done()
		listener.Close()
	}()

	backoff := time.Millisecond
	for {
		conn, err := listener.Accept()
		if err != nil {
			// 关键:区分"监听器被关/ctx 取消"和"偶发错误"
			if errors.Is(err, net.ErrClosed) {
				return ctx.Err() // 正常退出路径
			}
			// EMFILE/ENFILE 之类资源错误:短退避再试,连续太久就放弃
			log.Printf("accept error: %v", err)
			select {
			case <-ctx.Done():
				return ctx.Err()
			case <-time.After(backoff):
			}
			if backoff *= 2; backoff > time.Second {
				backoff = time.Second // 上限 1s,避免长时间不 accept 导致 backlog 溢出
			}
			continue
		}
		backoff = time.Millisecond
		go handle(conn)
	}
}

说明:这里刻意不用 net.Error.Temporary()——官方已把它标为 Deprecated(文档原话:“Temporary errors are not well-defined. Most ’temporary’ errors are timeouts, and the few exceptions are surprising.")。可判定的只有 Timeout();其余靠 errors.Is(err, net.ErrClosed) 和具体 syscall.Errno。

陷阱:Accept 出错后无脑 continue 是经典事故。监听器一旦被 Close(或 ctx 取消触发的关闭),Accept 会立即返回错误且永远不会成功,于是这个循环变成 100% CPU 的空转。实测关闭后错误是 accept tcp 127.0.0.1:61152: use of closed network connection,并且 errors.Is(err, net.ErrClosed) 为 true——判它,然后退出或返回。

处理连接:

func handleConn(conn net.Conn) {
	defer conn.Close()
	conn.SetDeadline(time.Now().Add(30 * time.Second)) // 读写共用一个截止时间

	buf := make([]byte, 4096)
	for {
		n, err := conn.Read(buf)
		if n > 0 {
			if _, werr := conn.Write(buf[:n]); werr != nil { // echo
				return
			}
		}
		if err != nil {
			if errors.Is(err, io.EOF) {
				return // 对端正常关闭,不是错误
			}
			var ne net.Error
			if errors.As(err, &ne) && ne.Timeout() {
				log.Printf("idle timeout, closing conn")
			}
			return
		}
	}
}

三个必须记住的语义:

  1. io 的契约:n > 0 和 err != nil 可以同时出现。上面循环里先处理 n 再判 err 才是对的;if err != nil { return } 写在前面会丢最后一个数据报/半包。
  2. 三种 deadline:SetDeadline(读写都管)、SetReadDeadline、SetWriteDeadline。超时后错误里 Timeout() 为 true,连接并没有坏,可以继续用(改期即可);但绝大多数业务选择直接关掉它。
  3. Read 不保证读满缓冲区。想要固定长度必须 io.ReadFull / io.ReadAtLeast(第 7 节消息边界)。

2.2 客户端

// 只用 net.Dial 是不够的:它只控制建连超时,不管后续读写
d := &net.Dialer{
	Timeout:       5 * time.Second,  // 建连超时(含 DNS 与多地址尝试)
	KeepAlive:     30 * time.Second, // TCP keep-alive 周期
	FallbackDelay: 300 * time.Millisecond, // Happy Eyeballs:v6 多久没连上就并发试 v4
}
conn, err := d.DialContext(ctx, "tcp", "example.com:443")

// 带超时的一次性写法
conn, err := net.DialTimeout("tcp", "example.com:443", 5*time.Second)

三个来自 go doc 的细节,很容易踩:

  • Timeout 只覆盖建连。一旦 Dial 返回,后续 Read/Write 可以无限期阻塞——这是"客户端偶发卡死"最常见的解释。DialTCP 等方法的文档更是直说:“Once successfully connected, any expiration of the context will not affect the connection.” 想给整次交互限时,必须自己设 conn.SetDeadline(或用带 deadline 的上层协议客户端)。
  • 多地址时 Timeout 会被拆开:“When using TCP and dialing a host name with multiple IP addresses, the timeout may be divided between them."(一个域名 8 个 A 记录时,5s 不代表每个地址都能等 5s)。同时文档提醒 OS 自己可能更早超时(TCP 通常约 3 分钟)。
  • Dialer.DualStack 已 Deprecated:Happy Eyeballs 从 Go 1.17 起就是默认行为,要关掉请设 FallbackDelay 为负值,别再写 DualStack: true(零值 false 也不影响默认的双栈尝试)。

Go 1.26 起 Dialer 多了四个直接用 netip 类型、且必须传 ctx 的方法,写新代码时比 ResolveTCPAddr 那套更清爽:

d := net.Dialer{Timeout: 3 * time.Second}
tc, err := d.DialTCP(ctx, "tcp", netip.AddrPort{}, netip.MustParseAddrPort("93.184.216.34:443"))
uc, err := d.DialUDP(ctx, "udp", netip.AddrPort{}, netip.MustParseAddrPort("8.8.8.8:53"))
ic, err := d.DialIP(ctx, "ip", netip.Addr{}, netip.MustParseAddr("93.184.216.34"))

ctx 必须非 nil(文档明说);本地地址传零值 netip.AddrPort{} 表示"由内核选”——TCPAddrFromAddrPort 对无效 AddrPort 返回 IP == nil,即 address family-agnostic 的未指定地址。返回值是具体类型(*TCPConn/*UDPConn/*IPConn),省掉一次类型断言。

2.3 TCP 参数怎么调

if tc, ok := conn.(*net.TCPConn); ok {
	tc.SetNoDelay(true)   // 默认已经是 true,写不写都一样
	tc.SetKeepAlive(true) // 默认开启
	tc.SetReadBuffer(256 * 1024)
	tc.SetWriteBuffer(256 * 1024)
}

go doc net.TCPConn.SetNoDelay 明确写着 default is true (no delay)——也就是说 Go 默认关掉了 Nagle 算法。网上大量"Go 要记得 SetNoDelay 降延迟"的建议其实早就多余了,反而是要注意小包高频写时该不该自己攒批(应用层合帧比指望 Nagle 更可控)。

Go 1.23 起 keep-alive 可以精细配置(对应内核 TCP_KEEPIDLE/TCP_KEEPINTVL/TCP_KEEPCNT,Linux/macOS 支持,Windows 只有 Idle):

kac := net.KeepAliveConfig{
	Enable:   true,
	Idle:     30 * time.Second, // 空闲多久开始探测
	Interval: 10 * time.Second, // 探测间隔
	Count:    3,                // 失败几次判定死链
}
// 三种落点:Dialer{KeepAliveConfig: kac}、ListenConfig{KeepAliveConfig: kac}、
// 或者建好连接后 tcpConn.SetKeepAliveConfig(kac)

注意 Dialer.KeepAlive/ListenConfig.KeepAlive 与 KeepAliveConfig 互斥:文档说 KeepAlive 在 KeepAliveConfig.Enable 为 true 时被忽略;KeepAlive 置负数则关闭探测。

参数 真实作用 建议
SetKeepAlivePeriod / KeepAliveConfig 探测死链,让半开连接(对端崩溃、NAT 老化、LB 静默丢流)能被回收 Go 的默认值写在 net/dial.go:Idle 15s、Interval 15s、Count 9(≈2 分半才判死)。要更快发现故障就显式给 KeepAliveConfig{Idle: 30s, Interval: 10s, Count: 3}
SetNoDelay 关 Nagle 默认已 true,无需设置
SetReadBuffer/WriteBuffer 内核 socket 缓冲 高吞吐/高 RTT 场景才有意义;设了只是"请求”,内核会给上限值
SetLinger(0) 关闭时立刻发 RST 而非正常四次挥手 慎用:未发送完的数据被丢弃,对端读到 connection reset 而不是干净 EOF。拿它"避免 TIME_WAIT"通常是治错了地方——TIME_WAIT 是给被动关闭方的,客户端端口耗尽该用连接复用/长连接

ListenConfig.Control 是给需要动 setsockopt 的人留的口子(比如 SO_REUSEPORT 做多进程分摊)。下面这段是 Linux 写法——fd 在 Linux 上是 int,Windows 的 syscall 签名不同(syscall.Handle),常量也要换平台对应的包:

import "golang.org/x/sys/unix"

lc := net.ListenConfig{
	Control: func(network, address string, c syscall.RawConn) error {
		var serr error
		if err := c.Control(func(fd uintptr) {
			serr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1)
		}); err != nil {
			return err
		}
		return serr // 两个错误都要收:c.Control 的返回值只说明回调有没有执行
	},
}

注意:Control 里拿到的 fd 是临时的,不要在里面对它做 read/write/poll 等可能阻塞的操作(文档明确要求),只做 setsockopt 之类的控制调用。另外传给 Control 的 network 会被规范化("tcp" 变成 "tcp4"/"tcp6",UDP/IP 同理,文档原话),别拿它做精确字符串匹配。还有一点容易撞:标准库 syscall 包在 Linux 上没有 SO_REUSEPORT 常量(GOOS=linux go build 实测报 undefined: syscall.SO_REUSEPORT),要用 golang.org/x/sys/unix。setsockopt 的真实错误必须从回调内部带出来(上面的 serr),只判 c.Control 的返回值会静默吞掉失败。

MPTCP:Go 1.24 起 ListenConfig/Dialer 在支持的平台上默认尝试 MPTCP(目前只有 Linux,需要内核 tcp.mptcp_enabled=1;系统不支持则自动退回普通 TCP)。要显式控制用 Dialer.SetMultipathTCP(true/false) 与 MultipathTCP() 查询。

2.4 netpoller 到底做了什么

一个 goroutine 在 conn.Read 上"阻塞"时,运行时做的事是:把 fd 注册进 pollDesc 的事件循环 → goroutine 挂起 → P 立刻被别的 goroutine 复用 → 数据到达时由 netpoll 唤醒那个 goroutine 放进就绪队列。所以:

  • 十万个并发连接 ≈ 十万个挂起的 goroutine(初始栈只有几 KB,按需增长),而不是十万个线程。
  • 反过来也提醒你:只有走 net 包的 IO 才有这个待遇。os.File 的磁盘 IO、cgo 调用里的阻塞 syscall 会真的占住一个系统线程(runtime 会扩容线程但 goroutine 无法复用),这是"cgo/文件 IO 打爆线程数"这类故障的根因。磁盘密集场景要靠限并发(信号量/worker pool),不是靠加 goroutine。
  • SetDeadline 的实现依赖运行时在到期时主动唤醒挂起的 poller 等待,所以超时不会漏在 “已经进了内核阻塞” 的情形里。

3. UDP

UDP 无连接、不可靠、面向数据报:一次 ReadFromUDP 拿到的是一个完整报文,超过缓冲区会被截断(返回的 err 里带 syscall.EMSGSIZE/OSError 且 n 等于缓冲区长度)。

addr, err := net.ResolveUDPAddr("udp", ":9090")
conn, err := net.ListenUDP("udp", addr)
defer conn.Close()

buf := make([]byte, 64*1024) // 数据报上限 65535,缓冲区别给小了
for {
	n, peer, err := conn.ReadFromUDP(buf)
	if err != nil {
		if errors.Is(err, net.ErrClosed) {
			return // 被 Close 了才退出
		}
		continue
	}
	_, _ = conn.WriteToUDP([]byte("ack"), peer)
}

要点:

  • DialUDP 是"伪连接":它不建连接、只固定对端地址并让你能用 Read/Write。好处是收到非预期来源的报文会报错(防欺骗),不是性能。
  • 服务端回包必须用 WriteToUDP(peer),别在广播/多播里用 Write。
  • Go 1.19+ 优先用 netip 版本:ReadFromUDPAddrPort/WriteToUDPAddrPort(少一次 *UDPAddr 分配,AddrPort 是值类型)。
  • 需要带外数据/接收时间戳/目的地址(多网卡、IP_PKTINFO)用 ReadMsgUDPAddrPort/WriteMsgUDPAddrPort。
  • 想"可靠 + 有序 + 加密 + 0-RTT",别自己在 UDP 上手搓:Go 1.24 起标准库提供 HTTP/3 与 QUIC 栈(net/http 的 Server.Protocols 与 http3 支持、crypto/tls 的 QUICConn,详见 crypto-tls.md)。
  • 生产事故榜:UDP 收包循环里 buf 复用是安全的(每次读完就处理),但把 peer 指针存进 map 长期持有——*UDPAddr 每次调用是新分配的,存下来本身没问题,问题在用 *UDPAddr.String() 当 key 时 zone/端口差异导致键对不上。用 netip.AddrPort 当 key(值类型,可直接比较)就没这些麻烦。

4. Unix Domain Socket

同机 IPC 的首选:跳过 TCP/IP 协议栈、没有端口占用、还能传文件描述符。

sock := filepath.Join(os.TempDir(), "myapp.sock")
os.Remove(sock) // socket 文件不会自动删,残留会导致 bind: address already in use

lc := net.ListenConfig{}
listener, err := lc.Listen(ctx, "unix", sock)
if err != nil {
	return err
}
defer listener.Close()
defer os.Remove(sock)

os.Chmod(sock, 0660) // 权限靠文件系统:UDS 的访问控制就是文件权限

实测与注意:

  • Windows 也支持(AF_UNIX,需 Windows 10 1803+):上面这段在 go1.27.0 windows/amd64 上实测可以 Listen/Dial/Read。但路径语义、权限模型、SO_PEERCRED 都不一样,跨平台产品仍要考虑命名管道(npipe,第三方库)这条备选路径。
  • Linux 支持抽象命名空间:UnixAddr.Name 以 @ 开头时,@ 会被翻译成 NUL 字节传给内核,socket 进抽象命名空间而不是文件系统(源码注释原话),由内核在最后一个引用消失时自动回收,所以不用 os.Remove、也不受目录权限影响。这是 Linux 独有特性——其他平台上 @xxx 会被当成普通文件路径处理。代价:没有文件系统权限这道闸门,访问控制得靠 SO_PEERCRED(Go 里是 UnixConn 的 SyscallConn + syscall.GetsockoptUcred)自己做。
  • Windows 上 unixgram/unixpacket 两种类型不支持(GOROOT 里 BUG(mikio) 注释明确列出),只有面向连接的 unix 可用。
  • UnixConn 读取到的 EOF 语义在 Go 1.27 变了:官方 notes——“net UnixConn read methods now return io.EOF directly instead of wrapping it in net.OpError"。实测 uc.Read(buf) 对端关闭后返回 err 类型是 *errors.errorString、值为 EOF,errors.Is(err, io.EOF) 为 true。1.26 及以前它包在 *net.OpError 里,所以老代码里那些 err == io.EOF / 判 OpError.Err 的分支现在可能不再命中——判 EOF 一律用 errors.Is,兼容两个版本。
  • 传描述符:UnixConn.WriteMsgUnix(b, oob, addr) + syscall.UnixCredentials,ReadMsgUnix 侧用 syscall.ParseUnixCredentials。这是 UDS 相对 TCP 真正的独门能力(SCM_RIGHTS),比如让特权守护进程替非特权进程打开文件/监听套接字。
  • UDS 比 TCP loopback 快,是因为省掉了协议栈处理、校验和与路由查找,且不必受端口/NAT 干扰;但它仍然有内核拷贝,不是"零拷贝”。差距量级依负载而定,做架构决策前该自己压。

5. DNS 查询

// 域名 → 地址(返回字符串,可能带 zone,也可能同时有 v4/v6)
addrs, err := net.LookupHost("example.com")

// A/AAAA → net.IP
ips, err := net.LookupIP("example.com")
for _, ip := range ips {
	if v4 := ip.To4(); v4 != nil { // 只要 IPv4:用 To4() 过滤
		fmt.Println(v4.String())
	}
}

names, _ := net.LookupAddr("8.8.8.8")     // 反向(PTR)
cname, _ := net.LookupCNAME("example.com") // CNAME
mxs, _ := net.LookupMX("gmail.com")         // MX:mx.Host / mx.Pref
txts, _ := net.LookupTXT("_dmarc.example.com")
_, srvs, _ := net.LookupSRV("http", "tcp", "example.com") // SRV:Target/Port/Priority/Weight
port, _ := net.LookupPort("tcp", "https")                 // 查 /etc/services:443

纠错提醒:net 没有 LookupIPv4/LookupIPv6 这类函数(go doc net 里的 Lookup* 只有 Host/IP/Addr/Port/CNAME/MX/NS/SRV/TXT)。只要 v4 就自己 To4() != nil 过滤,或者用 LookupIPAddr + netip 判断。

自定义解析器(生产上很常用:容器里 /etc/resolv.conf 不可控、要走指定 DNS、要超时控制):

r := &net.Resolver{
	PreferGo: true, // 用 Go 内置解析器(等价于 GODEBUG=netdns=go,但只影响这个实例)
	Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
		d := net.Dialer{Timeout: 2 * time.Second}
		return d.DialContext(ctx, "udp", "10.0.0.2:53") // 指定 DNS 服务器
	},
	StrictErrors: true, // 临时错误(超时/SERVFAIL)不要返回部分结果,直接报错
}
ips, err := r.LookupIPAddr(ctx, "example.com") // 用 ctx 版,超时/取消才生效

三个知识点:

  1. PreferGo 是"用哪个解析器"。内置解析器纯 Go、跨平台行为一致、会读 /etc/hosts 与 resolv.conf;系统解析器(cgo)在某些平台会走 NSS/getaddrinfo,行为依赖本机配置(比如 nsswitch.conf、mDNS)。排查"Go 服务解析结果和 nslookup 不一样"时,先设 GODEBUG=netdns=go+1(或 cgo+1)看它到底用了哪条路、为什么。
  2. 默认不严格:StrictErrors 关着时,A+AAAA 组合查询里一路超时/失败会静默返回部分结果(文档原话:causes such errors to abort the whole query instead of returning a partial result)。做负载均衡/连接池选址时,这种"少了一个地址"是隐性的,介意就打开。
  3. DNS 错误里能判超时了(Go 1.23):DNSError 现在会包装导致它的超时/取消错误,所以 errors.Is(dnsErr, context.DeadlineExceeded) 能成立。1.23 之前只能靠 IsTimeout() 或翻字符串。

工程建议:

  • 别在每个请求里做 DNS 查询。解析结果缓存(Go 内置解析器有极短的 hosts 缓存但没有 TTL 感知的通用缓存),长连接/连接池天然复用解析结果。HTTP 场景交给 net/http.Transport(它按 host 复用连接)。
  • 安全场景必须"先解析后校验":SSRF 白名单不能只看 host 字符串,要解析成 IP 后判 IsPrivate()/IsLoopback()/IsLinkLocalUnicast(),并且用解析出的 IP 去连接(否则 DNS rebinding 一秒切走)。net.LookupIP + net.Dialer{Control: ...} 是标准做法,详见 net-url.md。
  • netip 生态下推荐 Resolver.LookupIPAddr(返回 net.IPAddr)或配合 netip.ParseAddr;Go 1.26 的 Dialer.DialIP 可以直接吃 netip.Addr。

6. 网络接口与"本机 IP"

ifaces, _ := net.Interfaces()
for _, ifc := range ifaces {
	if ifc.Flags&net.FlagUp == 0 || ifc.Flags&net.FlagLoopback != 0 {
		continue // 跳过未启用的和 lo
	}
	addrs, _ := ifc.Addrs()
	for _, a := range addrs {
		ipnet, ok := a.(*net.IPNet) // Addrs 返回的是 *net.IPNet
		if !ok {
			continue
		}
		ones, bits := ipnet.Mask.Size() // 4 字节掩码 bits=32,16 字节掩码 bits=128
		fmt.Printf("%s: %s/%d (addrFamilyBits=%d)\n", ifc.Name, ipnet.IP, ones, bits)
	}
}

注意:Interface.Addrs() 返回的是 []Addr,对 IP 而言实际类型是 *net.IPNet(含掩码)。要判断"这个地址能不能对外",必须同时看掩码——ipnet.Mask 是 4 字节还是 16 字节(net.CIDRMask(24,32) vs net.CIDRMask(24,128))也取决于平台,做掩码运算前先统一 To4()。

取"本机对外 IP"的惯用技巧(不发任何包,只是让内核做一次路由选择):

func localIP() (net.IP, error) {
	conn, err := net.Dial("udp", "8.8.8.8:80") // UDP Dial 不建连、不出流量
	if err != nil {
		return nil, err
	}
	defer conn.Close()
	addr := conn.LocalAddr().(*net.UDPAddr)
	return addr.IP, nil
}

服务注册/心跳上报要填本机 IP 时,这比遍历接口猜"哪个是 eth0"可靠得多。缺点也很明显:它反映的是路由表选择的结果,容器里往往只有 cni0/eth0 之一,多网卡 + 策略路由时仍要显式指定网卡。


7. 消息边界:TCP 粘包/拆包

TCP 是字节流,没有消息概念。多次 Write 可能被合并,一次 Write 可能被拆分。业务必须自己定义边界。

方案 1:长度前缀(推荐)

// 帧格式:[4 字节大端长度][payload]
func writeFrame(conn net.Conn, data []byte) error {
	if len(data) > maxFrameSize { // 一定要有上限
		return fmt.Errorf("frame too large: %d", len(data))
	}
	head := make([]byte, 4)
	binary.BigEndian.PutUint32(head, uint32(len(data)))
	if _, err := conn.Write(head); err != nil {
		return err
	}
	_, err := conn.Write(data)
	return err
}

func readFrame(conn net.Conn) ([]byte, error) {
	head := make([]byte, 4)
	if _, err := io.ReadFull(conn, head); err != nil { // ReadFull:读满或报错
		return nil, err
	}
	n := binary.BigEndian.Uint32(head)
	if n == 0 || n > maxFrameSize {
		return nil, fmt.Errorf("bad frame size %d (max %d)", n, maxFrameSize)
	}
	data := make([]byte, n)
	if _, err := io.ReadFull(conn, data); err != nil {
		return nil, err
	}
	return data, nil
}

binary.BigEndian.PutUint32(b, v) 把 uint32 按大端序写进 b[:4](b 长度不足 4 直接 panic)。用大端是网络字节序惯例,跨平台一致;端序基础见 08-计算机基础/计算机基础知识点.md。

陷阱:readFrame 里必须校验长度上限。信任对端传来的 4 字节长度然后 make([]byte, n),等于给了远端一个"一条消息打爆你的内存"的开关——n 最大可以是 4GB。这一条比"记得 Close"更容易被漏掉,因为它看起来只是普通解析代码。

方案 2:分隔符(文本协议)

reader := bufio.NewReader(conn)
line, err := reader.ReadString('\n') // 注意:返回的 line 含分隔符

ReadString 遇到 EOF 也会返回已读到的部分(err == io.ErrUnexpectedEOF),别忘了这层。分隔符协议必须保证正文里不出现它(转义或长度限制),否则边界错乱。

方案 3:直接换协议

真要"自研 TCP 协议",先看能不能用现成的:gob/json 流式编解码(gob.NewDecoder(conn) 天然自带类型+长度,适合 Go 之间)、HTTP + net/http/httptest、gRPC、或者干脆用 etcd/Kafka 这类已有协议的客户端。标准库层面:gob 和 encoding/json 的 Encoder/Decoder 都直接吃 io.Reader/io.Writer,可以当成"自动带边界的帧协议"用(详见 encoding-gob.md)。


8. 常见陷阱汇总

陷阱 后果 正确做法
忘 conn.Close() fd 耗尽(too many open files) defer conn.Close();服务端尤其注意 panic 路径
Accept 出错后无脑 continue listener 关闭后 CPU 空转 判 errors.Is(err, net.ErrClosed) 后退出
Dialer.Timeout 当成整次请求的超时 客户端无限卡死 DialContext(ctx) + 读写 deadline
先判 err != nil 再处理 n 丢最后一段数据(n>0 && err!=nil 合法) 先消费 buf[:n],再判 err
fmt.Sprintf("%s:%d") 拼地址 IPv6 直接坏掉(vet hostport 会报) net.JoinHostPort / netip.AddrPortFrom
net.ParseIP 解析带 zone 的地址 得到 nil 却不报错 用 netip.ParseAddr
net.IP 用 ==/bytes.Equal 比较 4 字节与 16 字节表示不等 ip.Equal(ip2)
CIDR 白名单用 ParseCIDR 的第一个返回值 变成单 IP 精确匹配 用 ipnet/netip.Prefix
UDP 缓冲区小于报文大小 静默截断,数据不完整 64KB 缓冲,或校验 n == len(buf)
信任长度前缀不校验上限 一条消息打爆内存 校验 n <= maxFrameSize
SetLinger(0) “优化” TIME_WAIT 数据丢失 + 对端 RST 错误 复用连接;真要控端口另想办法
net.Error.Temporary() 语义不清,官方已 Deprecated 只判 Timeout();其余用 errors.Is/As + syscall.Errno
拿 net.Dial 连 UDP 期望"建立连接" 只是固定对端,没有可靠性 需要可靠语义就换 QUIC/HTTP3 或上层协议

9. 实战建议

  • 地址类型统一选 netip:解析、存储、比较、map key 全用它;只有 Dial/Listen 的边界上才转成字符串。这一条能消掉本篇一半的坑。
  • 超时要分层设:建连超时(Dialer.Timeout)、单次读写(deadline)、整次调用(ctx)。三者含义不同,只设一个必然有漏洞。
  • 错误判定用 errors.Is/errors.As:net.ErrClosed、os.ErrDeadlineExceeded、io.EOF、syscall.ECONNRESET、*net.DNSError。Go 这几年的改动方向就是"错误包装成可判定的链",== io.EOF 这种写法在 1.27 的 UDS 行为变化下已经被证明会失效。
  • 上线前用 go vet 跑一遍:hostport(拼地址)、errorsas、httpresponse 都是网络代码高频实错。
  • 容器里排查网络问题的顺序:先看 GOMAXPROCS/cgroup(Go 1.25 起 GOMAXPROCS 会感知 cgroup CPU 限制),再看 DNS 走的是 go 还是 cgo resolver(GODEBUG=netdns=2),最后看 conntrack/nf_conntrack 表满——很多"偶发 connection reset“其实是 conntrack 溢出,不是应用代码问题。
  • URL、query、转义、SSRF 不在本篇,见 net-url.md;服务端 HTTP 细节见 net-http.md;TLS/PQC/会话票据见 crypto-tls.md。