跳到主要内容
版本:1.0

网络与并发

后端岗的重头戏,AI 岗也常拿 GIL 和协程做区分题。这一篇两条主线:TCP 为什么要粘包、进程/线程/协程怎么选。

TCP 与 UDP​

维度TCPUDP
连接面向连接(三次握手)无连接
传输面向字节流面向报文
可靠性可靠(确认、重传、排序、流控)尽最大努力交付,不保证
边界无消息边界 → 粘包有边界,一条报文一条数据
场景网页、文件、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 字节码。

推论:

  1. 纯计算(CPU 密集)拿不到多核——开 8 个线程跑不了 8 核,甚至因锁竞争更慢。"多线程是假的"这句话只对 CPU 密集成立。
  2. I/O 阻塞时线程会释放 GIL(等待 socket/文件时),调 C 扩展(numpy 计算、hashlib)时也会释放——所以 I/O 密集场景多线程依然有效。
  3. 绕开方案:多进程(每个进程一份 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