TCP/UDP 网络编程:从 echo 服务器到 Prometheus 指标导出¶
这一章教「裸」网络编程:不借 HTTP 框架,直接对着操作系统的 socket 说话。 TCP 是打电话:先接通,再对话,每个字都保证送达;UDP 是寄明信片: 每张独立,可能丢,但便宜又快。前半章各写一个服务器和一个客户端, 后半章把服务器接进监控体系——让 Prometheus 能定期来问「你现在有多少条连接」。 读完你会拿到 socket 编程的完整心智图,这是下一章 HTTP 服务的地基。
1. 这一章在原书里的位置¶
原书第 8 章,第二部分的倒数第二章。第 5–7 章处理的是一台机器上的资源 (文件、信号、数据库),本章开始处理机器之间的通信。 书的开头放了一个真实的排障故事当定场诗:某公司每天固定时间后全网无法联网, 重启的机器也连不上——最后发现是一台虚拟机的操作系统里偷偷跑着一个 第二个 DHCP 服务器,两台服务器抢答 IP 配置1。故事的教训两条: 网络问题九成是 DNS;「包含多个错误的 bug 最难调,要跳出盒子隔离环境」2。
2. 顶层全景:三次升级¶
① 串行 TCP echo 服务器 Listen → Accept → 处理(下一个客户端干等)
② 并发 TCP echo 服务器 Accept 后 go handleConnection(每连接一 goroutine)
③ UDP 数据报服务器 ListenUDP → ReadFromUDP/WriteToUDP(无连接)
④ 可观测性 gopsutil 采集 → gauge → /metrics → Prometheus 拉取
图说:①到②只差一个 go 关键字,但这一个字母的区别是第 3 章并发模型的全部意义;
④是给任何网络程序都该有的「仪表盘」。
3. TCP 服务器:Listen—Accept—处理¶
TCP(传输控制协议)是面向连接的:通信前先握手建立连接,之后字节流有序、 不丢、不重。服务器三步3:
listener, err := net.Listen("tcp", ":8080") // ① 开监听口,告诉 OS「8080 的来访我接」
defer listener.Close() // 收场必关
for {
conn, err := listener.Accept() // ② 阻塞,直到有客户端敲门
if err != nil { continue } // 接客失败不致命,继续等
handleConnection(conn) // ③ 和这个客户端对话(串行版)
}
net.Listen("tcp", ":8080") 是高层便捷函数——自动解析地址、绑定所有网卡、
开始监听;内部其实调了 net.ResolveTCPAddr + net.ListenTCP。
要绑特定 IP 或细控 IPv4/IPv6 时才需要下沉到那一层4。
handleConnection 是个 echo(回音):bufio.NewScanner(conn) 按行读客户端输入,
原样加前缀写回去,收到 quit 就收摊5。
测试不用先写客户端——nc(1)(netcat)就是现成的万能客户端:
$ nc localhost 8080 ← 终端 A 起服务器;终端 B 敲这行
Hi!
Echo: Hi! ← 服务器原样送回
quit ← 服务器打印「Close connection request」
服务器侧日志:Client: 127.0.0.1:50469(远端地址 = IP:端口)6。
一个字母的升级:并发版¶
for {
conn, err := listener.Accept()
if err != nil { logWithTime("..."); continue }
go handleConnection(conn) // ← 就这一个 go:每个客户端一条 goroutine
}
实测输出里两个客户端(50474、50475)的消息时间线交错出现, 各自 quit 各自走人,互不阻塞7。这就是第 3 章「每连接一 goroutine」的工业形态。 但书里紧接着泼了冷水:无限制地开 goroutine 会招来 slowloris 式 DoS—— 恶意客户端占着连接不发数据,goroutine 和内存就被慢慢耗尽; 生产环境要用 worker pool 或连接数上限兜底8。 (第 3 章 worker pool 一节的选择判据在这里有了具体的敌人。)
TCP 客户端:双向通信的接线方式¶
net.Dial("tcp", "localhost:8080") 返回 net.Conn,读写和文件一模一样
(它就是 io.Reader/io.Writer!第 5、7 章的缓冲手法全部通用)。
客户端有个设计难点:用户打字(往服务器写)和收服务器推送(读)会互相等。
书上的解法是把读放进一条 goroutine,主循环只管读用户输入、往连接里写9:
主 goroutine: 扫 stdin → fmt.Fprintln(conn, 用户输入)
读 goroutine: 扫 conn → 打印「Server: …」;连接断 → os.Exit(0)
4. UDP 服务器:无连接的明信片¶
UDP 不建立连接,每个数据报文(datagram)独立投递——可能丢、可能乱序, 换来的是零握手的低延迟。适合 DNS 查询、直播、游戏这类「丢了就丢了」的场景; 要可靠就得自己在应用层攒(确认、重传、排序)10。
API 形态因此不同11:
addr := net.UDPAddr{Port: 1234, IP: net.ParseIP("0.0.0.0")} // 0.0.0.0 = 所有网卡
conn, err := net.ListenUDP("udp", &addr)
buffer := make([]byte, 1024)
for {
n, clientAddr, err := conn.ReadFromUDP(buffer) // 收:一次一个数据报
response := fmt.Sprintf("Random: %d | Time: %s\n", rand.Intn(1000), ...)
conn.WriteToUDP([]byte(response), clientAddr) // 发:寄回给这个地址
}
UDP 的缓冲区语义和 TCP 完全不同,书里点了两次:ReadFromUDP 一次读一个数据报,
报文比 1024 字节大的部分被 OS 直接截断丢弃,没有「下次再读剩下的」——
协议设计要么限制报文大小,要么换大块缓冲12。
客户端:net.ResolveUDPAddr 解析地址 → net.DialUDP("udp", nil, serverAddr)
(nil = 让 OS 分配本地端口)。一个必备防御:conn.SetReadDeadline(now+5s)——
UDP 没有「连接断开」通知,服务器不响应时读会永远阻塞,
deadline 是唯一的闸门13。nc -u 和自写客户端各测一遍,
回包形如 Random: 529 | Time: Mon, 20 Oct 2025 20:57:06 EEST14。
ResolveUDPAddr/DialUDP vs 高层 net.Dial("udp", …) 的取舍,
书里给了清楚的判据:要绑指定网卡、要 socket 级控制才用低层三件套;
随手发几个包就用高层15。
5. 主线例子:把程序接进 Prometheus¶
最后一节回答「服务器跑起来之后,怎么知道它健不健康」——答案是指标(metrics): 连接数、状态分布这些数字,让运维能发现「ESTABLISHED 突然暴涨 = 流量来了 或有攻击」这类模式16。
架构职责划分(书里特意加粗澄清):Go 程序只负责暴露指标, 存和查是 Prometheus 的活——它每 5 秒来 /metrics 拉一次, 存进自己的时间序列库,Grafana 再画图17。四个零件:
gopsutil/net net.Connections("tcp") → [{Status: ESTABLISHED/LISTEN/…}, …]
│ 每 5 秒一次(goroutine + Sleep)
▼
prometheus.NewGauge ×4 tcpEstablished / tcpListen / tcpOther / udpListening
│ gauge.Set(值) ← gauge = 可涨可落的单值,正合「当前连接数」
▼
promhttp.Handler() 挂在 /metrics,输出 Prometheus 文本格式
▲
│ 每 5 秒拉一次
Prometheus 容器(scrape_interval: 5s)
图说:一条「采集 → 缓存 → 暴露 → 拉取」的单向管道; 程序侧不存任何历史,天然无状态18。
两个实现细节值得记:① init() 函数在 main 之前自动执行,
prometheus.MustRegister(...) 放这里,指标随程序启动即就绪;
② UDP 没有连接状态,指标定义就退化为「数 socket 个数」——
指标设计跟着协议语义走19。
Docker 化与「指标怎么才会变」¶
Dockerfile(golang:1.25-alpine 单阶段) + docker-compose 双服务:
Go 指标容器(9100)+ Prometheus(9090,配置文件只读挂载进去)。
起容器后日志每 5 秒一行 TCP metrics updated: EST=0 LISTEN=2 OTHER=0——
然后书里点破一个容易困惑的点:容器是隔离的,从容器外发连接不会改变容器里看到的
连接表;要看到数字动,得 docker exec -it tcp_metrics sh 钻进容器,
在里面 nc -l 8080,EST/LISTEN 才会变20。
(「采集逻辑该嵌进你想监控的真服务里,独立容器只是个自足示例」——这句补注很诚实。)
6. 作者的判断与证据¶
| 判断 | 性质 | 依据 |
|---|---|---|
| 每连接一 goroutine 是 Go 服务器的默认形态 | 语言特性直接推论 + 社区共识 | 一个 go 关键字的升级演示7 |
| 但不设限的并发必须配节流 | 安全警告(作者明写) | slowloris 段落8 |
| 指标程序只暴露、Prometheus 存取 | 架构主张(与 Prometheus 官方模型一致) | 「not to store them」原句17 |
| 监控采集「不求全,求可行动」 | 工程立场 | 「The key is not to collect everything」16 |
| 网络排障先查 DNS;多因 bug 要隔离环境 | 经验之谈(借其兄故事) | 章首故事2 |
7. 边界与局限¶
- TCP 服务器没有教连接超时、keepalive、优雅关闭(半开连接怎么侦测、 关停时怎么等 in-flight 请求)——第 9 章 HTTP 层才补上超时和 context。
- slowloris 提了名字但没给防护代码( worker pool 的具体接法没有示例)。
- Prometheus 一节是「拉」模型单边教学:Pushgateway、指标标签(label) 设计、直方图/计数器的选择都没展开,gauge 之外的指标类型缺席。
- gopsutil 读的是宿主机连接表:容器里跑看到的是容器的网络命名空间 (书里用 docker exec 演示顺带讲清了,但没有点破命名空间这个名词)。
- UDP 可靠性自建(KCP、QUIC 的思路)一句未提,只说了「要自己实现」。
8. 可带走的¶
- TCP 服务器骨架:
net.Listen→ 循环Accept→ goroutine 处理; net.Conn 就是 io.Reader/Writer,缓冲、Scanner 全通用。 - 测试网络程序先想 nc(1)——不必先写客户端。
go handleConnection前想想限流;slowloris 靠连接数上限或池子防。- UDP 一次一个数据报,超长截断丢弃;读必配
SetReadDeadline(没有断连通知)。 - 高层
net.Listen/Dial自动解析地址;要低层控制(绑网卡、socket 参数)才用 ResolveAddr + Listen/Dial*。 - 双向通信的接线:读放 goroutine,主循环只管写。
- Prometheus 四件套:采集(gopsutil)→ gauge 缓存 →
promhttp.Handler()暴露 → Prometheus 定时拉取。程序无状态,存储在 Prometheus。 - 指标跟着协议语义设计(TCP 按状态分桶,UDP 数个数);采集不求全,求能行动。
9. 原文地图¶
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| DHCP 双服务器排障故事 | A story | text/89-ch08.txt:22(搜「my brother」) · text/90-fm-developing-a-tcp-server.txt:4(搜「two DHCP servers」) |
| TCP 服务器骨架(Listen/Accept) | Developing a TCP server | text/90-fm-developing-a-tcp-server.txt:27(搜「net.Listen」) · :35(搜「listener.Accept」) |
| echo 处理与 nc 测试 | Developing a TCP server | text/90-fm-developing-a-tcp-server.txt:68(搜「handleConnection」) · :120(搜「nc localhost 8080」) |
| 并发版与 slowloris 警告 | Developing a TCP server | text/90-fm-developing-a-tcp-server.txt:159(搜「go handleConnection」) · :166(搜「slowloris-style」) · :177(搜「50474」) |
| TCP 客户端双向接线 | Developing a TCP server | text/90-fm-developing-a-tcp-server.txt:225(搜「net.Dial」) · :235(搜「go func」) · text/91-fm-developing-a-udp-server.txt:7(搜「Hi from Go!」) |
| UDP 服务器与截断语义 | Developing a UDP server | text/91-fm-developing-a-udp-server.txt:45(搜「net.ListenUDP」) · :73(搜「ReadFromUDP」) · :102(搜「truncated and discarded」) |
| UDP 客户端与 SetReadDeadline | Developing a UDP server | text/91-fm-developing-a-udp-server.txt:149(搜「ResolveUDPAddr」) · :190(搜「SetReadDeadline」) · :115(搜「Random: 529」) |
| 高层 vs 低层 API 取舍 | Developing a UDP server | text/91-fm-developing-a-udp-server.txt:221(搜「ResolveUDPAddr() whenever」) · :237(搜「net.ListenTCP」) |
| 职责划分与「不求全」 | Collecting TCP/IP metrics | text/92-fm-collecting-tcp-ip-metrics.txt:32(搜「not to collect everything」) · :33(搜「expose metrics, not」) |
| gauge 定义与 init 注册 | Collecting TCP/IP metrics | text/92-fm-collecting-tcp-ip-metrics.txt:68(搜「NewGauge」) · :90(搜「func init」) |
| 采集与主循环 | Collecting TCP/IP metrics | text/92-fm-collecting-tcp-ip-metrics.txt:115(搜「updateTCPMetrics」) · :177(搜「func main」) |
| Docker 化与容器内验证 | Collecting TCP/IP metrics | text/92-fm-collecting-tcp-ip-metrics.txt:221(搜「golang:1.25-alpine」) · :356(搜「docker exec」) · :358(搜「nc -l 8080」) |
| Prometheus 配置与拉取 | Collecting TCP/IP metrics | text/92-fm-collecting-tcp-ip-metrics.txt:275(搜「scrape_interval」) · :286(搜「tcp_metrics:9100」) |
-
出处:「A story」(
text/89-ch08.txt:22,搜「my brother」;text/90-fm-developing-a-tcp-server.txt:4,搜「two DHCP servers」)。虚拟机 OS 自带的 DHCP 是祸根。 ↩ -
出处:「A story」(
text/90-fm-developing-a-tcp-server.txt:9,搜「DNS」;:12,搜「more than one error situation」)。 ↩↩ -
出处:「Developing a TCP server」(
text/90-fm-developing-a-tcp-server.txt:27,搜「net.Listen」;:45,搜「open a TCP listening socket」;:53,搜「blocks until a new client」)。 ↩ -
出处:「Developing a UDP server」(
text/91-fm-developing-a-udp-server.txt:236,搜「net.Listen("tcp"」;:243,搜「net.ListenTCP」)。便捷函数内部调 ResolveTCPAddr 的说明在同段。 ↩ -
出处:「Developing a TCP server」(
text/90-fm-developing-a-tcp-server.txt:68,搜「func handleConnection」)。Scanner 按行读、quit 特判、Echo 前缀写回,逐行见第 70–87 行。 ↩ -
出处:「Developing a TCP server」(
text/90-fm-developing-a-tcp-server.txt:120,搜「nc localhost 8080」;:129,搜「127.0.0.1:50469」)。 ↩ -
出处:「Making the TCP server concurrent」(
text/90-fm-developing-a-tcp-server.txt:159,搜「go handleConnection」;:162,搜「per-connection goroutine」;:177,搜「Concurrent TCP server listening」)。两客户端交错的时间线输出见第 178–186 行。 ↩↩ -
出处:「Making the TCP server concurrent」第 165–169 段(
text/90-fm-developing-a-tcp-server.txt:166,搜「slowloris-style」)。「many clients keep connections open without sending data」;缓解手段(worker pool、节流)在同段。 ↩↩ -
出处:「Developing a TCP client」(
text/90-fm-developing-a-tcp-server.txt:225,搜「net.Dial」;:235,搜「go func」;:259,搜「bidirectional communication」)。stdin 循环见text/90-fm-developing-a-tcp-server.txt:265。 ↩ -
出处:「Developing a UDP server」第 17–31 段(
text/91-fm-developing-a-udp-server.txt:18,搜「connectionless」;:22,搜「implement their own reliability」)。 ↩ -
出处:「Developing a UDP server」(
text/91-fm-developing-a-udp-server.txt:41,搜「net.UDPAddr」;:45,搜「net.ListenUDP」;:73,搜「ReadFromUDP(buffer)」;:83,搜「WriteToUDP」)。 ↩ -
出处:「Developing a UDP server」第 102–108 段(
text/91-fm-developing-a-udp-server.txt:104,搜「truncated and discarded」)。 ↩ -
出处:「Developing a UDP server」(
text/91-fm-developing-a-udp-server.txt:190,搜「SetReadDeadline」;:204,搜「indefinite blocking」)。 ↩ -
出处:「Developing a UDP server」(
text/91-fm-developing-a-udp-server.txt:115,搜「Random: 529」;:210,搜「Random: 169」)。 ↩ -
出处:「Developing a UDP server」第 216–245 段(
text/91-fm-developing-a-udp-server.txt:216,搜「net.ResolveUDPAddr」;:232,搜「In short, use」)。 ↩ -
出处:「Collecting TCP/IP metrics」第 9–32 段(
text/92-fm-collecting-tcp-ip-metrics.txt:26,搜「virtually endless」;:32,搜「not to collect everything」;:14,搜「sudden spike」)。 ↩↩ -
出处:「Collecting TCP/IP metrics」第 33–39 段(
text/92-fm-collecting-tcp-ip-metrics.txt:33,搜「expose metrics, not」)。「This separation of responsibilities」句在同段。 ↩↩ -
出处:「Collecting TCP/IP metrics」(
text/92-fm-collecting-tcp-ip-metrics.txt:68,搜「NewGauge」;:91,搜「MustRegister」;:116,搜「net.Connections("tcp")」;:177,搜「func main」;:190,搜「/metrics」)。 ↩ -
出处:「Collecting TCP/IP metrics」(
text/92-fm-collecting-tcp-ip-metrics.txt:100,搜「gauge represents」;:162,搜「UDP doesn't have a "state"」;:106,搜「before the main() function」)。 ↩ -
出处:「Collecting TCP/IP metrics」(
text/92-fm-collecting-tcp-ip-metrics.txt:221,搜「golang:1.25-alpine」;:348,搜「tricky part」;:356,搜「docker exec」;:367,搜「integrated into the actual systems」)。单阶段构建与多阶段构建的取舍见第 248–255 行。 ↩