两张图看懂网络编程本质。本文围绕经典的 TCP/UDP Socket 编程模型图,用 Go 语言带你从零手写服务端与客户端,彻底理解连接建立、数据收发、资源释放的全流程。
📋 目录
- 一、先读懂这两张图
- 二、TCP 编程模型:面向连接的"打电话"模式
- 三、UDP 编程模型:无连接的"发短信"模式
- 四、TCP vs UDP:核心差异一张表
- 五、Go 实战:从 Echo 服务到并发服务器
- 六、常见坑点与解决方案
- 总结
一、先读懂这两张图
这两张图是 Socket 编程中最经典的流程模型图,分别展示了 TCP 和 UDP 的通信时序与 API 调用顺序。
图 1:UDP 编程模型
核心特征:
- 无连接:Server 只需要
bind绑定端口,不需要listen/accept - 一对多:一个 UDP Server 可以同时与多个 Client 通信
- 数据报边界:
sendto发送的每个包,recvfrom接收时都是完整的一个包
流程解读:
UDP Server: Socket → bind → recvfrom → sendto → close
UDP Client: Socket → sendto → recvfrom → close
图 2:TCP 客户端编程模型
核心特征:
- 面向连接:必须先
connect→accept建立连接,才能收发数据 - 一对一连接:
accept返回新的 Socket(New Socket),原 Socket 继续监听 - 字节流:数据像水流一样没有边界,需要自行处理粘包
流程解读:
TCP Server: Socket → bind → listen → accept → [New Socket] → read/write → close
TCP Client: Socket → connect → write/read → close
二、TCP 编程模型:面向连接的"打电话"模式
TCP 就像打电话:先拨号建立连接,确认对方接听后,双方才能说话。挂电话时还要互相道别(四次挥手)。
2.1 服务端五步曲
对照图 2 的蓝色 Server 流程:
| 步骤 | 系统调用 | Go API | 作用 |
|---|---|---|---|
| 1 | socket() | net.Listen("tcp", addr) | 创建监听套接字 |
| 2 | bind() | 包含在 Listen 中 | 绑定 IP 和端口 |
| 3 | listen() | 包含在 Listen 中 | 开启监听,设置连接队列 |
| 4 | accept() | listener.Accept() | 阻塞等待客户端连接,返回新 Conn |
| 5 | read/write | conn.Read/Write | 在新连接上收发数据 |
⚠️ 关键细节:
accept返回的 New Socket 才是与客户端通信的通道,原监听套接字继续accept下一个连接。
2.2 客户端三步曲
对照图 2 的绿色 Client 流程:
| 步骤 | 系统调用 | Go API | 作用 |
|---|---|---|---|
| 1 | socket() | net.Dial("tcp", addr) | 创建套接字 |
| 2 | connect() | 包含在 Dial 中 | 发起三次握手,建立连接 |
| 3 | write/read | conn.Write/Read | 发送请求,接收响应 |
2.3 Go 代码:TCP Echo 服务端
package main
import (
"bufio"
"fmt"
"log"
"net"
)
func main() {
// Step 1: socket + bind + listen(Go 封装为一步)
listener, err := net.Listen("tcp", "0.0.0.0:8080")
if err != nil {
log.Fatalf("Listen failed: %v", err)
}
defer listener.Close()
fmt.Println("TCP Server listening on :8080...")
for {
// Step 2: accept —— 阻塞等待连接
// 返回的 conn 就是图中的 "New Socket"
conn, err := listener.Accept()
if err != nil {
log.Printf("Accept error: %v", err)
continue
}
fmt.Printf("New connection from %s\n", conn.RemoteAddr())
// Step 3: 在新连接上读写(每个连接一个 goroutine)
go handleConn(conn)
}
}
func handleConn(conn net.Conn) {
defer conn.Close() // 最后 close
reader := bufio.NewReader(conn)
for {
// 对应图中的 recv/read
line, err := reader.ReadString('\n')
if err != nil {
fmt.Printf("Client %s disconnected\n", conn.RemoteAddr())
return
}
fmt.Printf("Received: %s", line)
// 对应图中的 send/write
_, err = conn.Write([]byte("Echo: " + line))
if err != nil {
log.Printf("Write error: %v", err)
return
}
}
}
2.4 Go 代码:TCP 客户端
package main
import (
"bufio"
"fmt"
"net"
"os"
)
func main() {
// Step 1: socket + connect(Go 封装为 Dial)
conn, err := net.Dial("tcp", "127.0.0.1:8080")
if err != nil {
panic(err)
}
defer conn.Close()
fmt.Println("Connected to server")
// Step 2: write(对应图中的 send/write)
writer := bufio.NewWriter(conn)
_, err = writer.WriteString("Hello, TCP!\n")
if err != nil {
panic(err)
}
writer.Flush() // 注意:必须 Flush,否则数据还在缓冲区!
// Step 3: read(对应图中的 recv/read)
reader := bufio.NewReader(conn)
response, err := reader.ReadString('\n')
if err != nil {
panic(err)
}
fmt.Printf("Server: %s", response)
}
三、UDP 编程模型:无连接的"发短信"模式
UDP 就像发短信:不需要先建立关系,直接填写对方地址发送,也不管对方是否收到。
3.1 服务端与客户端几乎一样
对照图 1,UDP 的 Server 和 Client 流程高度对称:
| 角色 | 流程 | 说明 |
|---|---|---|
| Server | socket → bind → recvfrom → sendto → close | 必须 bind 端口,等待数据到来 |
| Client | socket → sendto → recvfrom → close | 无需 bind,系统分配临时端口 |
💡 核心差异:UDP 没有
listen/accept/connect,recvfrom会同时返回数据和对端地址,sendto必须指定目标地址。
3.2 Go 代码:UDP 服务端
package main
import (
"fmt"
"log"
"net"
"strings"
)
func main() {
// Step 1: socket + bind(UDP 不需要 listen)
addr, _ := net.ResolveUDPAddr("udp", "0.0.0.0:9999")
conn, err := net.ListenUDP("udp", addr)
if err != nil {
log.Fatalf("ListenUDP failed: %v", err)
}
defer conn.Close()
fmt.Println("UDP Server listening on :9999...")
buf := make([]byte, 1024)
for {
// Step 2: recvfrom —— 同时获取数据和客户端地址
// n: 读取字节数
// clientAddr: 谁发来的(对应图中的橙色箭头来源)
n, clientAddr, err := conn.ReadFromUDP(buf)
if err != nil {
log.Printf("Read error: %v", err)
continue
}
msg := string(buf[:n])
fmt.Printf("From %s: %s\n", clientAddr, msg)
// Step 3: sendto —— 向特定客户端回写
response := strings.ToUpper(msg)
_, err = conn.WriteToUDP([]byte(response), clientAddr)
if err != nil {
log.Printf("Write error: %v", err)
}
}
}
3.3 Go 代码:UDP 客户端
package main
import (
"fmt"
"net"
)
func main() {
// Step 1: 创建 UDP socket
// 客户端不需要 bind,系统分配临时端口
serverAddr, _ := net.ResolveUDPAddr("udp", "127.0.0.1:9999")
conn, err := net.DialUDP("udp", nil, serverAddr)
if err != nil {
panic(err)
}
defer conn.Close()
// Step 2: sendto —— 直接发送(无连接)
_, err = conn.Write([]byte("Hello, UDP!"))
if err != nil {
panic(err)
}
// Step 3: recvfrom —— 接收响应
buf := make([]byte, 1024)
n, _, err := conn.ReadFromUDP(buf)
if err != nil {
panic(err)
}
fmt.Printf("Server response: %s\n", string(buf[:n]))
}
🎯 UDP 天然并发:由于无连接状态,一个 UDP 服务端可以同时处理多个客户端,无需像 TCP 那样为每个连接创建 goroutine。
四、TCP vs UDP:核心差异一张表
| 维度 | TCP | UDP |
|---|---|---|
| 连接方式 | 面向连接(三次握手) | 无连接 |
| 编程流程 | bind→listen→accept→read/write | bind→recvfrom/sendto |
| Socket 类型 | SOCK_STREAM(流) | SOCK_DGRAM(数据报) |
| 数据边界 | ❌ 无边界(字节流,需处理粘包) | ✅ 有边界(每个包独立) |
| 可靠性 | ✅ 可靠(重传、确认、排序) | ❌ 不可靠(可能丢包乱序) |
| 并发模型 | 每个连接需独立处理(New Socket) | 一个 Socket 处理所有客户端 |
| Go API | net.Listen / net.Dial | net.ListenUDP / net.DialUDP |
| 适用场景 | HTTP、数据库、文件传输 | 视频直播、DNS、游戏、物联网 |
五、Go 实战:从 Echo 服务到并发服务器
5.1 TCP 并发:Goroutine 是最佳搭档
Go 的 goroutine 让 TCP 并发变得极其简单——每个 accept 返回的 New Socket 丢给一个 goroutine 处理:
func main() {
listener, _ := net.Listen("tcp", ":8080")
defer listener.Close()
for {
conn, _ := listener.Accept()
// 每个连接一个 goroutine,轻松支持 C10K/C100K
go func(c net.Conn) {
defer c.Close()
io.Copy(c, c) // 直接 Echo
}(conn)
}
}
5.2 UDP 并发:单协程即可
UDP 不需要为每个客户端创建 goroutine,一个循环处理所有数据包:
func main() {
conn, _ := net.ListenUDP("udp", &net.UDPAddr{Port: 9999})
defer conn.Close()
buf := make([]byte, 65535)
for {
n, addr, _ := conn.ReadFromUDP(buf)
// 处理逻辑可以直接在这里,也可以丢给 goroutine 异步处理
go processPacket(conn, addr, buf[:n])
}
}
六、常见坑点与解决方案
🔴 1. TCP 粘包问题
现象:连续发送 "Hello" 和 "World",接收方一次 Read 读到 "HelloWorld"。
原因:TCP 是字节流,内核会合并小包以提高效率。
Go 解决方案:
// 方案 A:固定长度 + 补零
// 方案 B:分隔符(如 \n)
reader := bufio.NewReader(conn)
line, _ := reader.ReadString('\n')
// 方案 C:长度前缀(最通用)
func sendWithLength(conn net.Conn, data []byte) {
length := uint32(len(data))
binary.Write(conn, binary.BigEndian, length) // 4 字节长度头
conn.Write(data)
}
func recvWithLength(conn net.Conn) []byte {
var length uint32
binary.Read(conn, binary.BigEndian, &length)
data := make([]byte, length)
io.ReadFull(conn, data)
return data
}
🔴 2. UDP 丢包与乱序
UDP 不保证到达,应用层需自行实现可靠性:
// 简易方案:序列号 + 超时重传
type Packet struct {
Seq uint32 // 序列号
Data []byte // 数据
Ack bool // 是否为 ACK
}
🔴 3. 端口复用(address already in use)
TCP 服务端重启时,端口可能处于 TIME_WAIT:
lc := net.ListenConfig{
Control: func(network, address string, c syscall.RawConn) error {
return c.Control(func(fd uintptr) {
syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1)
})
},
}
listener, _ := lc.Listen(context.Background(), "tcp", ":8080")
🔴 4. 忘记 Flush
使用 bufio.Writer 时,数据会先写入缓冲区,必须调用 Flush() 才能真正发送到内核:
writer := bufio.NewWriter(conn)
writer.WriteString("hello")
writer.Flush() // ❌ 忘记这行,对方永远收不到!
总结
回到最开头的两张图,核心差异可以概括为:
| TCP | UDP | |
|---|---|---|
| 连接 | 先 connect/accept 建立连接 | 直接 sendto/recvfrom |
| 通信对象 | 一对一(New Socket 专属) | 一对多(一个 Socket 服务所有) |
| 数据形态 | 无边界字节流 | 有边界数据报 |
| Go 代码量 | 稍多(需处理连接管理) | 极简(几行代码即可通信) |
一句话记忆:
- 🟢 TCP = 打电话:
bind→listen→accept是拨号接通,read/write是对话,close是挂电话。 - 🟡 UDP = 发短信:
bind是买个手机,sendto/recvfrom是直接收发,无需先加好友。
掌握这两张图的流程,你就掌握了 Socket 编程的底层逻辑。配合 Go 语言简洁的 net 包和轻量的 goroutine,无论是构建高并发 TCP 服务还是高性能 UDP 应用,都能游刃有余。
📌 如果本文对你有帮助,欢迎点赞 👍、收藏 ⭐、评论 💬!你在实际开发中更常用 TCP 还是 UDP?遇到过哪些网络编程的坑?欢迎在评论区交流!