【计算基础|网络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 字节
🔴 两个必背细节:
struct.pack("!I", ...)的!表示大端网络字节序------多字节整数跨机器必须约定字节序,否则大小端机器互通会乱。- 绝不能假设一次
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 | 时间同步 |
0
1023 是知名端口(特权端口,绑定通常需要 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 件事
- Socket 是应用层和传输层之间的 API,本质就是个能
read/write的网络文件描述符。 - TCP 服务端流程:
socket → bind → listen → accept → recv/send → close;客户端:socket → connect → send/recv → close。 - UDP 没有连接,
sendto发数据必带地址,recvfrom收数据顺带拿到来源。 - TCP 粘包/分包的根因是它面向字节流、没有消息边界------这是面试高频,要能手写复现。
- 解决粘包用长度头 :
struct.pack("!I", len)先写 4 字节长度,接收方先读长度再循环收满。 - 跨机器传多字节整数必须约定字节序,网络序用大端
!。 recv返回空 bytes = 对端正常关闭(FIN),据此退出循环。- 并发服务器用 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各自的原因和解决办法
参考资源
- Python
socket官方文档 docs.python.org/3/library/s... - Python
selectors官方文档 docs.python.org/3/library/s... - Python
struct(字节序与打包)docs.python.org/3/library/s... - Beej's Guide to Network Programming(经典入门)beej.us/guide/bgnet...
- TCP/IP 协议概览(Cloudflare)www.cloudflare.com/learning/dd...
下一篇《【工程实战】Nginx 部署与反向代理》:Socket 是手写网络程序的底层,真实生产里我们几乎不自己写 HTTP 服务的网络层,而是用 Nginx。这篇从安装讲起,配 FastAPI 后端,把反向代理、负载均衡、location 匹配、静态资源、HTTPS、跨域、日志全部真机配置一遍并实测,最后用 wrk 压测对比直连和走代理的性能------这也是整个网络模块的收官篇。