网络与并发
后端岗的重头戏,AI 岗也常拿 GIL 和协程做区分题。这一篇两条主线:TCP 为什么要粘包、进程/线程/协程怎么选。
TCP 与 UDP
| 维度 | TCP | UDP |
|---|---|---|
| 连接 | 面向连接(三次握手) | 无连接 |
| 传输 | 面向字节流 | 面向报文 |
| 可靠性 | 可靠(确认、重传、排序、流控) | 尽最大努力交付,不保证 |
| 边界 | 无消息边界 → 粘包 | 有边界,一条报文一条数据 |
| 场景 | 网页、文件、API | 直播、DNS、游戏实时同步 |
三次握手:确认双方的收发能力都正常。SYN → SYN+ACK → ACK,缺任何一次都无法确认"我能收你也能发"。 四次挥手:因为 TCP 全双工,两个方向的数据要分别关闭。被动关闭方收到 FIN 时可能还有数据没发完,所以 ACK 和 FIN 分开发。 TIME_WAIT 等 2MSL:确保最后一个 ACK 若丢失,对方重发的 FIN 还能被收到;同时让旧连接的报文在网络中自然消亡,避免污染新连接。
粘包:字节流没有消息边界
TCP 只保证字节按序到达,不保证按"发送次数"分组。发方可能合并多次 write(Nagle 算法),收方一次 read 可能读到多条消息的开头(粘包)或半条(拆包)。
加结束符不够可靠:如果消息体本身包含了结束符序列,会把下一条消息的开头误当作正文切进来。工业标准解法是定长包头:
import struct
def send_msg(sock, data: bytes):
# 4 字节无符号整数包头,网络字节序,声明正文长度
sock.sendall(struct.pack("!I", len(data)) + data)
def recv_exact(sock, n: int) -> bytes:
buf = b""
while len(buf) < n: # 循环收,直到凑够 n 字节
chunk = sock.recv(n - len(buf))
if not chunk:
raise ConnectionError("对端关闭")
buf += chunk
return buf
def recv_msg(sock) -> bytes:
length, = struct.unpack("!I", recv_exact(sock, 4)) # 先收包头
return recv_exact(sock, length) # 再按长度收正文
一句话总结:TCP 是字节流所以粘包,方案是先收定长包头再按长度收正文。
进程 / 线程 / 协程
| 维度 | 进程 | 线程 | 协程 |
|---|---|---|---|
| 定位 | 资源分配单位 | CPU 调度单位 | 用户态轻量级执行流 |
| 切换 | 内核态,开销大(换页表、换上下文) | 内核态,需保存/恢复寄存器 | 用户态,自己 yield,开销极小 |
| 隔离 | 地址空间独立,稳定 | 共享进程内存,需加锁 | 单线程内,天然无竞态 |
| 数量级 | 几十~几百 | 几百~几千 | 几万~几十万 |
选择口诀:CPU 密集用进程(绕开 GIL 拿多核),I/O 密集用线程或协程。 批量任务(图像处理、数据清洗)用 multiprocessing 进程池;爬虫、网关、高并发 I/O 用协程。
进程状态与并发并行
- 三状态模型:就绪(万事俱备只差 CPU)→ 运行(正在 CPU 上)→ 阻塞(等 I/O/锁,让出 CPU)。
- 并行:多个核同时干活,物理上同时;并发:单核快速交替,逻辑上同时。多核才能并行,单核也能并发。
同步异步 vs 阻塞非阻塞
这两组词描述的是不同的维度,常被混为一谈:
- 同步 / 异步:看要不要主动等结果。同步 = 发起后自己等结果;异步 = 发起后立刻返回,结果通过回调/通知送达。
- 阻塞 / 非阻塞:看等的时候线程在干嘛。阻塞 = 线程挂起干不了别的;非阻塞 = 轮询或先干别的。
- 异步 ≠ 非阻塞:异步调用也可以阻塞在等待通知上;非阻塞 I/O 通常配合同步轮询。四象限组合都存在。
GIL:全局解释器锁
GIL 是 CPython 的互斥锁:任一时刻,一个进程内只有一个线程在执行 Python 字节码。
推论:
- 纯计算(CPU 密集)拿不到多核——开 8 个线程跑不了 8 核,甚至因锁竞争更慢。"多线程是假的"这句话只对 CPU 密集成立。
- I/O 阻塞时线程会释放 GIL(等待 socket/文件时),调 C 扩展(numpy 计算、hashlib)时也会释放——所以 I/O 密集场景多线程依然有效。
- 绕开方案:多进程(每个进程一份 GIL)、C 扩展、协程(单线程内切换)。Python 3.13 起提供 free-threaded(无 GIL)实验构建,但生态兼容还在路上。
面试标准答法:"GIL 只坑 CPU 密集,I/O 密集照样能用多线程;CPU 密集改多进程。"
锁与竞态
竞态条件来自"读-改-写"三步之间被切走:两个线程同时读到余额 100,各自减 10,都写回 90——丢了 10 块。
lock = threading.Lock()
def withdraw(amount):
with lock: # 临界区:读-改-写不可分割
global balance
balance -= amount
Lock:普通互斥锁,保证临界区互斥。RLock:可重入锁,同一线程可多次 acquire(递归函数里嵌套加锁不锁死自己)。死锁的经典成因是"两个线程互相等对方手里的锁",工程上靠固定加锁顺序或超时缓解,RLock解决的是同线程重入问题——两者别混。
协程
协程是用户态的轻量级执行流:切换由程序自己控制(yield/await),不进内核,不涉及线程栈切换,所以"轻"——单线程能挂起几万个协程,每个只占几 KB。
import asyncio
async def fetch(i):
await asyncio.sleep(1) # 模拟 I/O,主动让出控制权
return i
async def main():
results = await asyncio.gather(*(fetch(i) for i in range(100)))
# 100 个"各睡 1 秒"的任务,总共约 1 秒
gevent 的 monkey patch:monkey.patch_all() 把标准库的 socket/time 等 I/O 替换成协程友好的版本,必须在所有其他 import 之前执行,否则已经加载的模块还握着原生阻塞实现,补丁不生效。
高频面试题
| 问题 | 答题要点 |
|---|---|
| TCP 与 UDP 的区别? | 连接/无连接、字节流/报文、可靠/尽力、有无边界;各举一个场景 |
| 为什么会粘包?怎么解决? | 字节流无消息边界 + 发方合并/收方一次读多条;struct 4 字节定长包头再按长度收,结束符不保险 |
| 三次握手/四次挥手为什么是三次/四次? | 握手确认双方收发能力(3 次够);全双工两方向分别关(4 次);TIME_WAIT 2MSL 兜底 ACK 丢失 |
| 进程、线程、协程怎么选? | 资源分配 vs 调度 vs 用户态切换;CPU 密集进程、I/O 密集线程/协程 |
| 同步异步与阻塞非阻塞的区别? | 前者看要不要主动等结果,后者看等时线程是否挂起;异步 ≠ 非阻塞 |
| GIL 是什么?"多线程是假的"对吗? | 任一时刻仅一个线程执行字节码;只坑 CPU 密集,I/O 阻塞和 C 扩展会释放 GIL,多线程仍有效 |
| 死锁怎么产生?RLock 解决什么? | 互相等对方的锁 / 加锁顺序不一致;RLock 解决同线程重入,不是死锁 |
| 协程为什么轻? | 用户态切换、无内核开销、单线程内几万个各占几 KB |