【计算基础|网络15】【上手】Socket 网络编程:亲手写 TCP/UDP 程序

【计算基础|网络15】【上手】Socket 网络编程:亲手写 TCP/UDP 程序

前面讲了这么多协议,你可能还是觉得"虚"------IP、TCP、握手,到底在代码里长什么样?这篇直接上手:用 Python 标准库 socket,不装任何第三方包,从零写出能跑的 TCP echo 服务端/客户端、UDP 通信,然后亲手复现面试必考的 TCP 粘包问题 ,最后用 selectors 写出第一个能同时服务多个客户端的并发服务器。

所有代码我都在本机实跑过,输出原样贴在下面。

环境:macOS + Python 3.10+,仅用标准库 socket、struct、selectors。无需 pip 安装任何东西。端口统一用 8888~8893,冲突时自行换。


一、Socket 是什么:网络编程的文件描述符

一句话:Socket(套接字)是应用层与传输层之间的编程接口(API) 。你写代码收发网络数据,本质上就是在操作系统给你的一个"文件描述符"上做 read/write------和读写文件几乎一样,只不过数据走的是网络。

三种常用 Socket 类型:

类型 常量 对应协议 特点
流式套接字 SOCK_STREAM TCP 可靠、面向字节流、有连接
数据报套接字 SOCK_DGRAM UDP 不可靠、保留消息边界、无连接
原始套接字 SOCK_RAW IP/ICMP 等 自己构造 IP 首部,需 root,ping 就靠它

1.1 Socket 方法速查表

这张表贴墙上,写代码时不用查文档:

方法 作用 关键参数 返回
socket() 创建套接字 family(AF_INET/AF_INET6), type socket 对象
bind() 绑定地址和端口 (host, port) None
listen() 开始监听(TCP 服务端) backlog 等待队列长度 None
accept() 接受连接(TCP 服务端) 无,阻塞 (conn, addr)
connect() 连接服务器(TCP 客户端) (host, port) None
send()/sendall() 发送数据(TCP) bytes 已发字节数 / None
recv() 接收数据(TCP) bufsize bytes,空表示对端关闭
sendto() 发送数据(UDP) (bytes, addr) 已发字节数
recvfrom() 接收数据(UDP) bufsize (bytes, addr)
close() 关闭套接字 无 None
setsockopt() 设置选项 level, optname, value None
getpeername()/getsockname() 拿对端/本端地址 无 (host, port)

🔴 send vs sendall 的区别:send 返回实际发了多少字节(可能没发完),sendall 内部循环直到全发完或报错。日常用 sendall,省心。


二、TCP 服务端与客户端:第一个能跑的网络程序

2.1 交互流程

2.2 TCP echo 服务端

收到什么就原样回什么,最经典的入门例子:

python 复制代码
import socket

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# ⚠️ 端口刚杀掉 TIME_WAIT 时不设这个会报 Address already in use
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("127.0.0.1", 8888))
server.listen(5)
print("TCP echo server listening on 127.0.0.1:8888")

while True:
    conn, addr = server.accept()          # 阻塞,直到有客户端连进来
    print(f"Connected by {addr}")
    while True:
        data = conn.recv(1024)            # 阻塞,收到数据才返回;空 bytes 表示对端关闭
        if not data:
            break
        conn.sendall(data)                # 收到啥回啥
    conn.close()
    print(f"Disconnected {addr}")

2.3 TCP echo 客户端

python 复制代码
import socket

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("127.0.0.1", 8888))            # 主动连服务端

for msg in [b"hello", b"network", b"socket"]:
    s.sendall(msg)
    data = s.recv(1024)
    print(f"发送: {msg.decode():<8} 收到: {data.decode()}")

s.close()

2.4 实跑输出

先起服务端,再起客户端。客户端输出:

text 复制代码
发送: hello    收到: hello
发送: network  收到: network
发送: socket   收到: socket

服务端输出:

text 复制代码
TCP echo server listening on 127.0.0.1:8888
Connected by ('127.0.0.1', 57543)
Disconnected ('127.0.0.1', 57543)

✅ 成功标志:客户端每发一条都立刻收到一模一样的回显,服务端打印出 Connected by 和对端随机端口(57543)。注意服务端的端口是固定的 8888,而每个客户端的端口是系统随机分配的。


三、UDP 编程:没有连接的"寄明信片"

UDP 不需要 listen/accept/connect,发数据要带目的地,收数据顺带拿到来源地址。

3.1 UDP echo 服务端

python 复制代码
import socket

s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(("127.0.0.1", 8889))
print("UDP echo server listening on 127.0.0.1:8889")

for _ in range(3):
    data, addr = s.recvfrom(1024)          # 一次收到一个完整数据报 + 来源地址
    print(f"收到 {data} 来自 {addr}, 原样回发")
    s.sendto(data, addr)                 # 回发必须带 addr
s.close()

3.2 UDP echo 客户端

python 复制代码
import socket

s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
for msg in [b"hello", b"udp", b"world"]:
    s.sendto(msg, ("127.0.0.1", 8889))  # 每次都要带目的地
    data, _ = s.recvfrom(1024)
    print(f"发送: {msg.decode():<6} 收到: {data.decode()}")
s.close()

3.3 实跑输出

服务端:

text 复制代码
UDP echo server listening on 127.0.0.1:8889
收到 b'hello' 来自 ('127.0.0.1', 56917), 原样回发
收到 b'udp' 来自 ('127.0.0.1', 56917), 原样回发
收到 b'world' 来自 ('127.0.0.1', 56917), 原样回发

客户端:

text 复制代码
发送: hello  收到: hello
发送: udp    收到: udp
发送: world  收到: world

⚠️ UDP 的坑:它不保证送达、不保证顺序、不保证不重复 。上面能通是因为本机回环。真实网络里你必须自己处理丢包、超时、重传。另外 UDP 也能 connect(),作用只是"绑定默认对端",之后可以直接用 send/recv 而不用每次写地址,但它不建立真实连接。


四、TCP 粘包/分包:面试必考,亲手复现(本篇重点)

4.1 先给结论

🔴 TCP 是面向字节流的协议,它没有"消息边界"的概念 。你以为发了两条消息,对端内核缓冲区里就是一串连续的字节,recv 一次能取走多少算多少。于是出现两种现象:

  • 粘包 :你发了两条独立消息,对方一次 recv 把两条都收了(粘在一起)。
  • 分包 :你发了一条大消息,对方要多次 recv 才能收完。

根因只有一个:TCP 不帮你切消息。

4.2 复现粘包

服务端只 recv 一次,客户端连续发两条短消息:

python 复制代码
# 服务端:故意只 recv 一次
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 8890)); s.listen(1)
conn, addr = s.accept()
data1 = conn.recv(4096)
print(f"[服务端] 第1次 recv 收到 {len(data1)} 字节: {data1!r}")
data2 = conn.recv(4096)
print(f"[服务端] 第2次 recv 收到 {len(data2)} 字节: {data2!r}")
conn.close(); s.close()

# 客户端:连续发两条,中间不停顿
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("127.0.0.1", 8890))
s.sendall(b"msgA"); s.sendall(b"msgB")
print("[客户端] 已连续发送 msgA 和 msgB")
s.close()

实跑结果------两条消息被粘成一次收到:

text 复制代码
[客户端] 已连续发送 msgA 和 msgB
[服务端] 第1次 recv 收到 8 字节: b'msgAmsgB'
[服务端] 第2次 recv 收到 0 字节: b''

看,服务端第 1 次 recv 就拿到了 b'msgAmsgB'(8 字节),根本不知道这是两条消息。第 2 次 recv 收到空 bytes------那是客户端 close() 后内核发来的 FIN,表示对端关了。

4.3 复现分包

服务端一次发 1MB,客户端每次只 recv(1024):

python 复制代码
# 服务端:发 1MB
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 8891)); s.listen(1)
conn, addr = s.accept()
big = b"x" * (1024*1024)
conn.sendall(big)
print(f"[服务端] 已发送 {len(big)} 字节")
conn.close(); s.close()

# 客户端:每次只收 1KB,循环收
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("127.0.0.1", 8891))
total = 0; n = 0
while True:
    chunk = s.recv(1024)
    if not chunk:
        break
    total += len(chunk); n += 1
print(f"[客户端] 共 recv {n} 次, 累计 {total} 字节")
s.close()

实跑结果------一条 1MB 的消息被拆成 1025 次才收完:

text 复制代码
[服务端] 已发送 1048576 字节
[客户端] 共 recv 1025 次, 累计 1048576 字节

数据量没错(1048576 字节正好 1MB),但收了 1025 次。这就是分包。

4.4 三种解决方案

方案 做法 优点 缺点
① 定长消息 每条固定 N 字节,不足补零 简单 浪费空间、消息大小受限
② 分隔符 用 \n/\0 分隔消息 适合文本 二进制数据里可能含分隔符,不通用
③ 长度头(推荐) 消息前加固定长度的头,写明消息体长度 通用、二进制友好 要自己处理粘包逻辑

生产上 99% 用长度头 :发方先发 4 字节的消息体长度,再发消息体;收方先 recv(4) 读出长度,再循环 recv 收满这么多字节。

4.5 长度头完整实现

python 复制代码
import socket, struct

def recv_exact(conn, n):
    """循环 recv 直到收满 n 字节(关键!一次 recv 不一定拿全)"""
    buf = b""
    while len(buf) < n:
        chunk = conn.recv(n - len(buf))
        if not chunk:
            return None
        buf += chunk
    return buf

# ---- 服务端 ----
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("127.0.0.1", 8892)); server.listen(1)
print("[服务端] 监听 127.0.0.1:8892 (长度头协议)")
conn, addr = server.accept()
for _ in range(3):
    header = recv_exact(conn, 4)                 # 先读 4 字节长度头
    (length,) = struct.unpack("!I", header)    # ! = 大端网络序, I = 无符号32位
    body = recv_exact(conn, length)             # 再按长度读满消息体
    print(f"[服务端] 拆出一条消息, 长度={length}, 内容={body!r}")
    conn.sendall(struct.pack("!I", len(body)) + body)
conn.close(); server.close()
python 复制代码
import socket, struct

# ---- 客户端 ----
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("127.0.0.1", 8892))

def send_msg(conn, msg: bytes):
    conn.sendall(struct.pack("!I", len(msg)) + msg)   # 长度头 + 消息体

for msg in [b"short", b"a message longer than before", b"x"*2000]:
    send_msg(s, msg)
    header = s.recv(4)
    (n,) = struct.unpack("!I", header)
    body = b""
    while len(body) < n:
        body += s.recv(n - len(body))
    print(f"[客户端] 发送 {len(msg)} 字节, 收到回显 {len(body)} 字节")
s.close()

实跑输出------不管消息多长,服务端都能准确拆出一条完整消息:

text 复制代码
[服务端] 监听 127.0.0.1:8892 (长度头协议)
[服务端] 拆出一条消息, 长度=5, 内容=b'short'
[服务端] 拆出一条消息, 长度=28, 内容=b'a message longer than before'
[服务端] 拆出一条消息, 长度=2000, 内容=b'xxxx...(略)...xxxx'

[客户端] 发送 5 字节, 收到回显 5 字节
[客户端] 发送 28 字节, 收到回显 28 字节
[客户端] 发送 2000 字节, 收到回显 2000 字节

🔴 两个必背细节:

  1. struct.pack("!I", ...) 的 ! 表示大端网络字节序------多字节整数跨机器必须约定字节序,否则大小端机器互通会乱。
  2. 绝不能假设一次 recv 能收满你要的长度 ,必须写 recv_exact 这种循环收满的函数。

五、并发服务器雏形:一次服务多个客户端

5.1 为什么需要并发

前面的服务端是单线程的:accept 之后就在这个连接里 recv/send 循环,第二个客户端只能干等着,直到第一个断开。真实服务器要同时接几百上千个客户端,必须解决并发。

两条路子:

方案 思路 特点
多线程 每个连接开一个线程处理 简单,但连接多了线程开销大
IO 多路复用 单线程同时监控一堆 socket,谁就绪处理谁 高性能,select/poll/epoll/kqueue

5.2 用 selectors 写并发服务器

Python 的 selectors 模块封装了各平台的多路复用机制(Linux 自动用 epoll、macOS 用 kqueue),是写并发服务器的标准入门工具:

python 复制代码
import selectors, socket

sel = selectors.DefaultSelector()

def accept(sock):
    conn, addr = sock.accept()
    conn.setblocking(False)                     # 关键:非阻塞
    print(f"[服务端] 新连接 {addr}")
    sel.register(conn, selectors.EVENT_READ, data=addr)

def read(conn):
    data = conn.recv(1024)
    if data:
        print(f"[服务端] 收到 {data!r}, 来自 {conn.getpeername()}")
        conn.sendall(data)
    else:
        print(f"[服务端] 对端关闭 {conn.getpeername()}")
        sel.unregister(conn); conn.close()

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("127.0.0.1", 8893)); sock.listen(5)
sock.setblocking(False)
sel.register(sock, selectors.EVENT_READ, data=None)
print("[服务端] selectors 并发服务器监听 8893")

count = 0
while count < 6:                               # 演示:处理完若干事件后退出
    for key, mask in sel.select(timeout=1):   # 阻塞直到有 socket 就绪
        if key.data is None:
            accept(key.fileobj)                 # data 为 None 表示是监听 socket
        else:
            read(key.fileobj); count += 1
sock.close()

两个客户端几乎同时连接:

bash 复制代码
python3 -c "
import socket
for i in range(2):
    c = socket.socket(); c.connect(('127.0.0.1',8893))
    c.sendall(f'client-{i}'.encode()); c.recv(64); c.close()
"

实跑输出------单线程服务端同时处理了两个不同端口的连接:

text 复制代码
[服务端] selectors 并发服务器监听 8893
[服务端] 新连接 ('127.0.0.1', 57743)
[服务端] 收到 b'client-0', 来自 ('127.0.0.1', 57743)
[服务端] 对端关闭 ('127.0.0.1', 57743)
[服务端] 新连接 ('127.0.0.1', 57744)
[服务端] 收到 b'client-1', 来自 ('127.0.0.1', 57744)
[服务端] 对端关闭 ('127.0.0.1', 57744)

这就是 IO 多路复用的雏形。selectors 后面操作系统篇讲 epoll 时还会深挖------为什么 epoll 比 select/poll 快,红黑树和就绪队列是怎么回事,这里先建立直觉:一个线程盯着所有连接,谁有活就干谁。


六、常用协议与端口速查表

写服务端绑端口、客户端连端口时,这张表能直接查:

协议 端口 传输层 用途
HTTP 80 TCP 网页
HTTPS 443 TCP 加密网页
SSH 22 TCP 远程登录
FTP 20/21 TCP 文件传输
DNS 53 UDP/TCP 域名解析
SMTP 25 TCP 发邮件
POP3 110 TCP 收邮件
MySQL 3306 TCP 数据库
Redis 6379 TCP 缓存
NTP 123 UDP 时间同步

01023 是知名端口(特权端口,绑定通常需要 root),102465535 是普通端口。自己玩实验用 8000+ 就好。


七、Socket 编程常见报错速查

报错/现象 原因 解决
Address already in use 端口被占用 / TIME_WAIT 加 setsockopt(SO_REUSEADDR, 1),或换端口
Connection refused 服务端没启动 / 端口写错 确认服务端在跑、bind 和 connect 端口一致
Connection reset by peer 对端异常关闭(发了 RST) 检查对端是不是崩了
Broken pipe 向已经关闭的连接写数据 先判断连接状态,做好异常捕获
recv 返回 b"" 对端正常关闭(发了 FIN) 这不是错误,据此 break 出循环

🔴 最后一条最值钱:recv 返回空 bytes 不是没数据,而是对端关闭了连接 。我们前面粘包实验里第 2 次 recv 收到 b'' 就是这个意思------客户端已经 close() 了。


总结:你真正需要记住的 8 件事

  1. Socket 是应用层和传输层之间的 API,本质就是个能 read/write 的网络文件描述符。
  2. TCP 服务端流程:socket → bind → listen → accept → recv/send → close;客户端:socket → connect → send/recv → close。
  3. UDP 没有连接,sendto 发数据必带地址,recvfrom 收数据顺带拿到来源。
  4. TCP 粘包/分包的根因是它面向字节流、没有消息边界------这是面试高频,要能手写复现。
  5. 解决粘包用长度头 :struct.pack("!I", len) 先写 4 字节长度,接收方先读长度再循环收满。
  6. 跨机器传多字节整数必须约定字节序,网络序用大端 !。
  7. recv 返回空 bytes = 对端正常关闭(FIN),据此退出循环。
  8. 并发服务器用 IO 多路复用(selectors),单线程盯多个连接,为 epoll 埋伏笔。

验证清单

  • 能默写出 TCP 服务端和客户端的完整调用流程
  • 能解释 send 和 sendall 的区别
  • 能亲手复现一次粘包(连续 send 两条,一次 recv 收到)
  • 能写出 recv_exact 函数并解释为什么不能假设一次 recv 收满
  • 能用长度头方案实现一个不粘包的 TCP echo
  • 能说出 recv 返回空 bytes 的含义
  • 能用 selectors 写一个能同时服务两个客户端的 echo 服务器
  • 能说出 Address already in use、Connection refused、Broken pipe 各自的原因和解决办法

参考资源


下一篇《【工程实战】Nginx 部署与反向代理》:Socket 是手写网络程序的底层,真实生产里我们几乎不自己写 HTTP 服务的网络层,而是用 Nginx。这篇从安装讲起,配 FastAPI 后端,把反向代理、负载均衡、location 匹配、静态资源、HTTPS、跨域、日志全部真机配置一遍并实测,最后用 wrk 压测对比直连和走代理的性能------这也是整个网络模块的收官篇。

相关推荐
盛世宏博智慧档案1 小时前
恒湿净化一体机联动开发:档案馆温湿度阈值判断与启停控制实践
java·后端·struts·恒湿机·加湿除湿消毒净化一体机
兔子零10241 小时前
FastAPI 零基础入门教程:写过 Node.js,就能轻松学会 Python 后端开发(一)
前端·后端·python
vipxieliang1 小时前
ValidX 应用于 HR系统员工信息验证:入职、离职、社保数据
java·spring boot·后端
huaweichenai1 小时前
spring boot实现base64上传
java·spring boot·后端
ThinkerQAQ_1 小时前
并发编程(九):BlockingQueue 与 Channel——语言层的原子性、可见性与有序性
后端
花间相见2 小时前
【计算基础|网络16】【工程实战】Nginx 部署与反向代理
后端
点纭2 小时前
spring ai1.0实战:ChatClient(2)
java·后端·spring
长安米粒贵2 小时前
数据库连接池为什么会突然耗尽?聊聊 5 个常见泄漏点
后端
Gopher_HBo2 小时前
Resolver 与 Balancer —— 连接管理三层联动
后端