跳转至

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. 可带走的

  1. TCP 服务器骨架:net.Listen → 循环 Accept → goroutine 处理; net.Conn 就是 io.Reader/Writer,缓冲、Scanner 全通用。
  2. 测试网络程序先想 nc(1)——不必先写客户端。
  3. go handleConnection 前想想限流;slowloris 靠连接数上限或池子防。
  4. UDP 一次一个数据报,超长截断丢弃;读必配 SetReadDeadline(没有断连通知)。
  5. 高层 net.Listen/Dial 自动解析地址;要低层控制(绑网卡、socket 参数)才用 ResolveAddr + Listen/Dial*。
  6. 双向通信的接线:读放 goroutine,主循环只管写。
  7. Prometheus 四件套:采集(gopsutil)→ gauge 缓存 → promhttp.Handler() 暴露 → Prometheus 定时拉取。程序无状态,存储在 Prometheus。
  8. 指标跟着协议语义设计(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」)


  1. 出处:「A story」(text/89-ch08.txt:22,搜「my brother」;text/90-fm-developing-a-tcp-server.txt:4,搜「two DHCP servers」)。虚拟机 OS 自带的 DHCP 是祸根。 ↩

  2. 出处:「A story」(text/90-fm-developing-a-tcp-server.txt:9,搜「DNS」;:12,搜「more than one error situation」)。 ↩↩

  3. 出处:「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」)。 ↩

  4. 出处:「Developing a UDP server」(text/91-fm-developing-a-udp-server.txt:236,搜「net.Listen("tcp"」;:243,搜「net.ListenTCP」)。便捷函数内部调 ResolveTCPAddr 的说明在同段。 ↩

  5. 出处:「Developing a TCP server」(text/90-fm-developing-a-tcp-server.txt:68,搜「func handleConnection」)。Scanner 按行读、quit 特判、Echo 前缀写回,逐行见第 70–87 行。 ↩

  6. 出处:「Developing a TCP server」(text/90-fm-developing-a-tcp-server.txt:120,搜「nc localhost 8080」;:129,搜「127.0.0.1:50469」)。 ↩

  7. 出处:「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 行。 ↩↩

  8. 出处:「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、节流)在同段。 ↩↩

  9. 出处:「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。 ↩

  10. 出处:「Developing a UDP server」第 17–31 段(text/91-fm-developing-a-udp-server.txt:18,搜「connectionless」;:22,搜「implement their own reliability」)。 ↩

  11. 出处:「Developing a UDP server」(text/91-fm-developing-a-udp-server.txt:41,搜「net.UDPAddr」;:45,搜「net.ListenUDP」;:73,搜「ReadFromUDP(buffer)」;:83,搜「WriteToUDP」)。 ↩

  12. 出处:「Developing a UDP server」第 102–108 段(text/91-fm-developing-a-udp-server.txt:104,搜「truncated and discarded」)。 ↩

  13. 出处:「Developing a UDP server」(text/91-fm-developing-a-udp-server.txt:190,搜「SetReadDeadline」;:204,搜「indefinite blocking」)。 ↩

  14. 出处:「Developing a UDP server」(text/91-fm-developing-a-udp-server.txt:115,搜「Random: 529」;:210,搜「Random: 169」)。 ↩

  15. 出处:「Developing a UDP server」第 216–245 段(text/91-fm-developing-a-udp-server.txt:216,搜「net.ResolveUDPAddr」;:232,搜「In short, use」)。 ↩

  16. 出处:「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」)。 ↩↩

  17. 出处:「Collecting TCP/IP metrics」第 33–39 段(text/92-fm-collecting-tcp-ip-metrics.txt:33,搜「expose metrics, not」)。「This separation of responsibilities」句在同段。 ↩↩

  18. 出处:「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」)。 ↩

  19. 出处:「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」)。 ↩

  20. 出处:「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 行。 ↩