楚天

惟楚有材,于斯为盛

高性能服务器编程笔记一

时间:2026/04/10

关键词:epoll、LT / ET、Reactor、非阻塞 socket、timerfdeventfdsignalfd、线程池、HTTP、网络调优
核心目标:把 Linux 服务端里最常见的一条工程主线串起来,即“事件循环 + 非阻塞 I/O + 协议解析 + 任务分发 + 排障调优”。


1. epoll 的 LT / ET 模式与 Reactor

1.1 Reactor 到底在做什么

Reactor 的核心不是“某个库”,而是一种组织方式:

  1. epoll_wait() 等待一批就绪事件
  2. 根据 fd 和事件类型找到对应处理器
  3. 执行 accept/read/write/timer/signal 等处理逻辑
  4. 业务计算如果比较重,就把它移交给工作线程
  5. 结果回到 I/O 线程后,再负责发包

可以把它理解成:

epoll 负责告诉你“谁准备好了”,Reactor 负责定义“准备好之后该怎么处理”。

一个典型 TCP Reactor 流程:

1
2
3
4
5
listen fd -> accept -> conn fd
conn fd readable -> read into buffer
buffer -> parse protocol -> dispatch business task
business result -> enqueue response
conn fd writable -> flush output buffer

1.2 LT 和 ET 的区别

模式 含义 行为特点 优点 风险
LT Level Triggered,水平触发 只要 fd 仍可读/可写,就会反复通知 语义直观,代码更稳 重复通知较多
ET Edge Triggered,边沿触发 只在状态从“不可读”变“可读”时通知一次 通知更少,适合高并发 容易因为没读干净而丢事件

LT 是默认模式,更像“提醒式”:

  • 套接字里还有数据没读完,下一轮 epoll_wait() 还会继续通知
  • 对初学和排查问题更友好

ET 更像“状态变化通知”:

  • 一次从无到有的变化,只提醒一次
  • 如果你这次没把数据读到 EAGAIN,内核通常不会再额外提醒你

所以 ET 的两个前提几乎是硬规则:

  • fd 必须设成非阻塞
  • read/write/accept 都要循环到 EAGAINEWOULDBLOCK

1.3 ET 下为什么必须“读空 / 写尽”

ET 的常见错误写法是:

1
2
// 错误示意:只读一次
ssize_t n = recv(fd, buf, sizeof(buf), 0);

如果内核缓冲区里其实还有数据没读完,那么这批“剩余数据”不会再触发新边沿,连接就可能卡住。

正确思路是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
for (;;) {
ssize_t n = recv(fd, buf, sizeof(buf), 0);
if (n > 0) {
// 处理数据
continue;
}
if (n == 0) {
// 对端关闭
close(fd);
break;
}
if (errno == EINTR) {
continue;
}
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 真的读空了
break;
}
// 其他错误
close(fd);
break;
}

accept() 也是同样逻辑。监听 fd 用 ET 时,应该把已完成连接队列里的连接一次性 accept 干净。

1.4 epoll 中常见的事件组合

1
ev.events = EPOLLIN | EPOLLRDHUP | EPOLLERR;

常见关注点:

  • EPOLLIN:可读
  • EPOLLOUT:可写
  • EPOLLRDHUP:对端半关闭,常用于尽早发现连接关闭
  • EPOLLERR:错误事件
  • EPOLLHUP:挂起
  • EPOLLET:启用 ET
  • EPOLLONESHOT:触发一次后自动失活,适合多线程下避免同一连接被并发处理

工程上常见搭配:

  • 单线程 Reactor:EPOLLIN | EPOLLRDHUP
  • ET Reactor:EPOLLIN | EPOLLRDHUP | EPOLLET
  • 多线程连接处理:EPOLLIN | EPOLLRDHUP | EPOLLONESHOT

还有一个常见实践:

  • EPOLLOUT 往往只在“发送缓冲里还有没发完的数据”时才临时关注
  • 如果平时一直常驻监听 EPOLLOUT,很多 socket 会长期表现为“可写”,导致无意义唤醒

1.5 LT / ET 怎么选

建议顺序很明确:

  1. 先用 LT 把协议、状态机、错误处理做对
  2. 确认瓶颈真的在事件通知频率,再考虑 ET
  3. 如果用了 ET,先检查“是否所有 I/O 路径都循环到 EAGAIN

不要把 ET 当成“默认更高级”的模式。很多线上服务最后依然选择 LT,因为:

  • 业务瓶颈常常不在 epoll
  • LT 更容易做正确
  • 排障成本明显更低

2. 非阻塞 socket 的错误处理

2.1 非阻塞不是“没有阻塞”,而是“阻塞变成返回码”

设置非阻塞通常是:

1
2
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

设置后,很多原本会阻塞的操作不再睡眠,而是直接返回:

  • EAGAIN
  • EWOULDBLOCK
  • EINPROGRESS

这意味着:

非阻塞编程的关键不是“调用成功”,而是“正确解释暂时失败”。

2.2 connect() 的处理

非阻塞 connect() 最常见的返回是:

  • 0:立即连接成功
  • -1 + errno == EINPROGRESS:连接正在进行中,这是正常路径
  • -1 + errno == EINTR:被信号打断,通常继续按“连接中”处理或重试检查
  • -1 + ECONNREFUSED / ENETUNREACH / ETIMEDOUT:明确失败

EINPROGRESS 之后,不能仅凭“fd 可写”就认定连接成功。正确做法是:

1
2
3
4
5
6
7
8
int err = 0;
socklen_t len = sizeof(err);
getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len);
if (err == 0) {
// connect 成功
} else {
// connect 失败,err 才是真正错误码
}

2.3 accept() 的处理

非阻塞监听 fd 常见返回:

  • EAGAIN / EWOULDBLOCK:当前已没有新连接,退出循环
  • EINTR:被信号打断,继续
  • ECONNABORTED:连接在 accept 前就中止了,记录后继续接下一个
  • EMFILE / ENFILE:进程或系统 fd 用尽,这是高优先级故障

EMFILE 的工程应对通常包括:

  • 提前把 ulimit -n 和系统文件句柄上限调够
  • 预留一个“空闲 fd”作为应急位
  • 日志里把错误打出来,不要静默丢掉

2.4 recv() / read() 的处理

返回值 / 错误码 含义 处理建议
n > 0 收到数据 进入协议解析
n == 0 对端正常关闭 回收连接
EAGAIN/EWOULDBLOCK 当前读空了 等下次读事件
EINTR 被信号打断 继续读
ECONNRESET 对端复位 记录并关闭
其他错误 异常 记录并关闭

注意:

  • n == 0 不是“没数据”,而是 EOF
  • EAGAIN 不是错误,而是“暂时读不到”

2.5 send() / write() 的处理

非阻塞写最容易踩的坑有两个:

  1. 只写了一部分
  2. 对已关闭连接写,触发 EPIPE / SIGPIPE

正确思路:

  • 维护用户态发送缓冲区
  • send() 返回部分字节时,把未发送部分保留到下次继续
  • EAGAIN 时注册 EPOLLOUT,等可写后继续刷
  • MSG_NOSIGNAL 或忽略 SIGPIPE

一个典型发送循环:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
for (;;) {
ssize_t n = send(fd, out + sent, len - sent, MSG_NOSIGNAL);
if (n > 0) {
sent += (size_t)n;
if (sent == len) {
break;
}
continue;
}
if (n < 0 && errno == EINTR) {
continue;
}
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
// 还没发完,等 EPOLLOUT
break;
}
// EPIPE / ECONNRESET / 其他错误
close(fd);
break;
}

2.6 一些工程上必须记住的点

  • EINTR 通常不是失败,而是“重来一次”
  • EAGAIN 通常不是失败,而是“现在先停一下”
  • EPOLLERR 到来时,要用 getsockopt(... SO_ERROR ...) 取真实错误
  • 半关闭连接要配合 EPOLLRDHUP、协议状态和发送缓冲综合判断
  • 非阻塞只是避免线程睡死,不会替你处理半包、乱序状态和应用层背压

3. timerfd / eventfd / signalfd

3.1 为什么这三个 fd 很重要

它们的共同价值是:

把“时间、线程通知、信号”也统一成 fd,交给 epoll 一起处理。

这样事件循环里就不需要:

  • 到处插异步回调
  • 自己维护复杂的管道唤醒逻辑
  • 在信号处理函数里做太多不安全操作

3.2 timerfd

timerfd 用于把定时器变成可读 fd。

常见用途:

  • 心跳检测
  • 超时连接清理
  • 定时重传
  • 周期性统计采样

基本用法:

1
2
3
4
5
6
7
int tfd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC);

struct itimerspec its;
memset(&its, 0, sizeof(its));
its.it_value.tv_sec = 1; // 1 秒后首次触发
its.it_interval.tv_sec = 1; // 之后每秒触发一次
timerfd_settime(tfd, 0, &its, NULL);

事件到来后要 read() 一个 uint64_t

1
2
uint64_t expirations = 0;
read(tfd, &expirations, sizeof(expirations));

这个值表示自上次读取以来一共超时了多少次,避免丢 tick。

3.3 eventfd

eventfd 本质是一个 64 位计数器,特别适合做线程间唤醒。

常见用途:

  • 工作线程把结果投递给 I/O 线程后,唤醒它
  • 多生产者向事件循环提交异步任务
  • 替代“自建 pipe + 一个字节通知”的老办法
1
int efd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC);

写端:

1
2
uint64_t one = 1;
write(efd, &one, sizeof(one));

读端:

1
2
uint64_t cnt = 0;
read(efd, &cnt, sizeof(cnt));

读出来的是累计值,所以常见模式是:

  • 读一次把计数清掉
  • 顺手把任务队列里的任务批量取完

3.4 signalfd

signalfd 用来把信号也纳入事件循环。

典型场景:

  • 收到 SIGTERM 时优雅退出
  • 收到 SIGHUP 时重载配置
  • 不想把复杂逻辑塞进传统信号处理函数

用法关键点:

  1. 先用 sigprocmask() 阻塞这些信号
  2. 再创建 signalfd
  3. epoll 里监听它
1
2
3
4
5
6
7
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGTERM);
sigprocmask(SIG_BLOCK, &mask, NULL);

int sfd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC);

读取后可得到:

1
2
struct signalfd_siginfo si;
read(sfd, &si, sizeof(si));

然后根据 si.ssi_signo 做统一处理。

3.5 三者如何和 Reactor 配合

一个典型事件循环会把它们全放进 epoll

  • 连接 fd:读写事件
  • timerfd:超时检查、定时任务
  • eventfd:跨线程任务投递
  • signalfd:退出 / 重载 / 信号治理

这样主循环就更像一个统一调度器,而不是“网络逻辑 + 一堆额外异步机制拼起来的东西”。


4. 线程池与 I/O 线程分层

4.1 一个更稳的分层模型

高性能服务器里,一个常见的稳定结构是:

1
2
3
4
5
6
7
8
9
10
11
12
主线程/Acceptor
-> 接连接并分发到某个 I/O 线程

I/O 线程
-> 跑 event loop
-> 管 socket、收发包、协议解析、连接状态机

Worker 线程池
-> 跑 CPU 密集或潜在阻塞的业务逻辑

回到 I/O 线程
-> 序列化响应并发送

核心原则是:

socket 的生命周期和输出顺序,最好由固定 I/O 线程拥有。

4.2 为什么不让工作线程直接操作 socket

因为这样很容易引入:

  • 一个连接被多个线程同时读写
  • 发送顺序被打乱
  • 锁粒度变大,缓存局部性变差
  • 排障时很难定位“这个连接到底归谁管”

更稳的做法是:

  • I/O 线程负责网络层和协议层
  • Worker 线程只处理业务输入,生成业务结果
  • 结果通过无锁队列或锁队列回投给归属 I/O 线程
  • eventfd 唤醒 I/O 线程继续发送

4.3 一个常见的任务流

  1. I/O 线程收到请求,完成协议解析
  2. 组装一个“业务任务”对象,投递到线程池
  3. Worker 线程执行业务逻辑,得到响应对象
  4. 把响应对象投递回对应 I/O 线程的待发送队列
  5. 向该 I/O 线程的 eventfd 写入计数
  6. I/O 线程被唤醒,刷新待发送队列并 send

这个结构有两个直接好处:

  • 连接状态不跨线程乱跑
  • 网络收发和业务执行都能独立扩缩

4.4 线程数怎么定

经验上可以先这样起步:

  • I/O 线程数:先从 1 ~ CPU 核数 之间选,小步压测
  • 纯 CPU 业务线程池:通常接近 CPU 核数
  • 会阻塞的业务:单独拆出阻塞线程池,不要和 CPU 任务混用

不要一开始就把线程开得很多。线程越多,不一定吞吐越高,反而可能带来:

  • 调度开销
  • 锁竞争
  • cache miss
  • 尾延迟抖动

4.5 线程模型里的常见坑

  • 把数据库、磁盘、RPC 阻塞调用放进 I/O 线程
  • 一个大线程池同时跑 CPU 任务和慢 I/O 任务
  • 没有限制任务队列长度,导致内存膨胀
  • 没做背压,读得太快、处理太慢、写缓冲越积越大
  • 让 worker 直接 close(fd) 或直接修改连接对象状态

更稳的工程实践通常包括:

  • 每个连接有明确 owner I/O 线程
  • 回投结果时只传消息,不直接抢 socket
  • 对线程池队列、连接输入缓冲、输出缓冲都设上限
  • 把“超时、丢弃、降级”当成正式设计,而不是补丁

5. HTTP 解析与应用层协议设计

5.1 HTTP 解析不要靠“字符串碰运气”

HTTP/1.1 在服务端最常见的解析路径是增量状态机:

1
REQUEST_LINE -> HEADERS -> BODY -> DONE

原因很简单,TCP 是字节流,所以:

  • 一次 recv() 可能只收到半个请求行
  • 也可能一次收到“一个半请求”
  • keep-alive 连接上还可能连续收到多个请求

所以解析器一般要面对的是“缓冲区里的不完整字节流”,而不是“一次 recv() 就是一条完整 HTTP 请求”。

5.2 一个更合理的 HTTP 处理流程

  1. I/O 线程把字节流追加到连接输入缓冲
  2. 解析器尝试从当前缓冲推进状态机
  3. 如果请求还不完整,就停下来等更多数据
  4. 如果请求完整,就生成请求对象
  5. 业务层处理后,再序列化成 HTTP 响应

最少要覆盖这些点:

  • 请求行解析:方法、URL、版本
  • 头部解析:按 \r\n 切分
  • 空行判定:头部结束
  • body 长度:Content-Length
  • 分块传输:Transfer-Encoding: chunked
  • keep-alive:一条连接上连续请求
  • 大包限制:头部长度、body 长度都要有限制

5.3 HTTP 工程上常见坑

  • 只支持 Content-Length,完全忽略 chunked
  • 没有限制 header 大小,容易被慢请求或畸形请求拖死
  • 把一整个大 body 一次性读入内存,导致峰值过高
  • 没处理 pipeline/连续请求,缓冲区里残留数据被误丢
  • 业务处理过慢但连接持续可读,导致输入缓冲无限增长

如果是自己练习写 HTTP server,建议至少加上:

  • header 大小上限
  • body 大小上限
  • header 读取超时
  • 空闲连接超时
  • 非法请求快速返回 400

5.4 应用层协议设计,比“能传输”更重要

如果不是做 HTTP,而是做自定义 RPC / 游戏 / 推送协议,最重要的是先把“帧边界”定义清楚。

最常见的做法是长度字段协议:

1
| magic | version | type | request_id | body_len | body... |

这样解析时可以先读固定头,再按 body_len 判断包体是否完整。

比起纯文本协议,它的优势是:

  • 包边界明确
  • 解析开销更稳定
  • 扩展字段更容易版本化

5.5 自定义协议建议至少包含这些元素

  • magic:快速识别协议,防止串流或脏数据
  • version:协议演进时兼容旧客户端
  • type:请求类型 / 响应类型 / 心跳 / 控制消息
  • request_id:做请求响应关联,便于并发与排障
  • body_len:确定包体边界
  • 可选 flags:压缩、序列化格式、错误码等

还要配套以下约束:

  • 最大包长限制
  • 超时与重试语义
  • 幂等语义
  • 错误响应格式
  • 压缩/加密是否协商

5.6 协议设计和线程模型其实是连在一起的

如果协议天然支持:

  • 明确包边界
  • 快速校验合法性
  • request_id
  • 支持分片/流式 body

那么线程池和 I/O 线程的协作会简单很多。

反过来,如果协议定义含糊,后面就容易出现:

  • 半包难处理
  • 多请求复用难关联
  • 排障日志定位困难
  • 一旦扩协议版本就全链路改动

6. Linux 网络调优参数与排障方法

6.1 先记住一个原则:先观察,再调参

网络调优里最常见的误区是:

  • 一看到超时就改 sysctl
  • 一看到连接多就盲目加线程
  • 一看到吞吐不高就切 ET

更稳的方法是先回答三个问题:

  1. 瓶颈在应用、内核、网卡,还是对端?
  2. 问题是吞吐不够、延迟过高,还是抖动严重?
  3. 是连接建立阶段有问题,还是已建立连接的收发有问题?

6.2 常见调优项

下面这些参数最常见,但不应该脱离场景硬改。

方向 参数/项 作用
fd 上限 ulimit -nfs.file-max 控制可打开文件句柄数量
监听队列 net.core.somaxconn 限制 listen backlog 上限
SYN 队列 net.ipv4.tcp_max_syn_backlog 半连接队列容量
网卡收包积压 net.core.netdev_max_backlog 网卡到协议栈的积压队列
Socket 缓冲 net.core.rmem_maxnet.core.wmem_max 接收/发送缓冲上限
TCP 缓冲 net.ipv4.tcp_rmemnet.ipv4.tcp_wmem TCP 自适应缓冲范围
端口范围 net.ipv4.ip_local_port_range 客户端侧临时端口范围
TIME_WAIT net.ipv4.tcp_tw_reuse 特定场景下重用 TIME_WAIT,需谨慎
SYN 防护 net.ipv4.tcp_syncookies SYN flood 防护

还要配合关注:

  • SO_REUSEADDR
  • SO_REUSEPORT
  • TCP_NODELAY
  • TCP_QUICKACK
  • 进程实际打开 fd 数量
  • 网卡多队列、RSS、RPS/XPS 是否匹配

6.3 几类典型现象怎么查

1. 新连接进不来

先看:

  • ss -lnt
  • ss -s
  • cat /proc/sys/net/core/somaxconn
  • cat /proc/sys/net/ipv4/tcp_max_syn_backlog

再判断是:

  • 应用根本没及时 accept
  • accept 队列太小
  • SYN 队列顶满
  • 进程 fd 已耗尽

2. 连接很多,但吞吐很低

优先排查:

  • I/O 线程是否被业务阻塞
  • 发送缓冲是否长期积压
  • 是否出现大量重传
  • 对端是否慢读

常用命令:

  • ss -tin
  • sar -n DEV 1
  • ethtool -S eth0
  • ip -s link
  • perf top -p <pid>

3. 延迟抖动大、尾延迟高

常见方向:

  • 线程太多导致调度抖动
  • GC / 内存分配 / 大对象复制
  • 某些慢任务卡住 worker
  • 日志同步刷盘
  • I/O 线程偶发做了阻塞操作

这时经常要结合:

  • pidstat -t 1 -p <pid>
  • perf record/report
  • strace -tt -p <pid>

4. 连接莫名被断开

需要区分:

  • 对端主动关闭
  • 中间网络设备超时清理
  • 写关闭触发 EPIPE
  • ECONNRESET
  • 自己应用层超时策略太激进

这时 tcpdump 很有价值:

  • tcpdump -nn -i any tcp port 8080

抓包后重点看:

  • 谁先发 FIN
  • 是否有 RST
  • 是否反复重传
  • 三次握手是否完整

6.4 排障时建议按这条线走

  1. 先看应用日志和连接状态
  2. 再看 sssarpidstatperf
  3. 再看 /procsysctl、网卡统计
  4. 最后再决定是否抓包

不要一上来就抓包,也不要一上来就调内核参数。

6.5 一些很实用的排障清单

看到“连接接不进来”时,先检查:

  • 进程是否在监听正确地址和端口
  • backlog 是否足够
  • fd 是否打满
  • 是否被防火墙或安全组拦截

看到“CPU 很高”时,先检查:

  • 热点在业务代码、锁、内存分配,还是系统调用
  • epoll_wait 是否其实不忙,真正忙的是工作线程
  • 是否有大量空转 wakeup

看到“内存一直涨”时,先检查:

  • 输入缓冲 / 输出缓冲是否无上限
  • 请求队列是否积压
  • 是否慢连接太多
  • 是否大包被整包缓存

7. 小结

把这些主题串起来,其实就是一条完整的服务端工程路径:

  1. epoll 搭 Reactor,先 LT 做对,再考虑 ET
  2. 用非阻塞 socket 正确处理 EINPROGRESSEAGAIN、部分读写和连接关闭
  3. timerfdeventfdsignalfd 把“时间 / 线程通知 / 信号”并入统一事件循环
  4. 让 I/O 线程管连接,让线程池管业务,结果再回到 I/O 线程发出
  5. 用状态机解析 HTTP 或自定义协议,把包边界、版本和错误语义设计清楚
  6. 出问题时先观察现象和链路,再做内核调优

如果把这些基础打稳,后面再看:

  • sendfile/splice 零拷贝
  • SO_REUSEPORT + 多队列
  • io_uring
  • 更复杂的 RPC 框架

就会轻松很多。

UDP 通信

时间:2026/04/09

关键词:报文、无连接、不可靠传输、sendto/recvfrom、MTU、丢包重传、滑动窗口
核心目标:先把 UDP 的“简单”理解透,再搞清楚为什么很多实时协议愿意在 UDP 之上自己补可靠性。


1. UDP 是什么

UDP(User Datagram Protocol)是面向报文的传输层协议。

它的特点很直接:

  • 无连接:通信前不需要三次握手
  • 报文边界保留:发送一次 sendto,接收端看到的是一个完整报文,天然没有 TCP 粘包问题
  • 尽力而为:协议本身不保证送达、不保证顺序、不保证不重复
  • 开销小、时延低:头部只有 8 字节,协议栈处理也更轻

所以 UDP 很适合:

  • 实时音视频
  • 在线游戏
  • DNS
  • 广播 / 组播
  • 能容忍少量丢包,但很在意时延的业务

2. UDP 和 TCP 的核心差异

维度 UDP TCP
连接语义 无连接 面向连接
数据形式 报文 字节流
可靠性 不保证 保证送达、按序、去重
顺序 不保证 保证
重传 应用层自己做 内核协议栈负责
流量控制 没有
拥塞控制 没有
时延 更低 较稳定但更重

最重要的一点:

UDP 只是“帮你发包”,TCP 则是“帮你把传输这件事做完整”。


3. Linux 下 UDP 的常用接口

3.1 创建 socket

1
int fd = socket(AF_INET, SOCK_DGRAM, 0);

SOCK_DGRAM 表示 UDP 套接字。

3.2 服务端绑定地址

1
bind(fd, (struct sockaddr*)&addr, sizeof(addr));

和 TCP 一样,服务端通常需要 bind 到固定 IP/端口。

3.3 接收与发送

1
2
3
4
5
ssize_t n = recvfrom(fd, buf, sizeof(buf), 0,
(struct sockaddr*)&peer, &peer_len);

sendto(fd, data, len, 0,
(struct sockaddr*)&peer, peer_len);

这是一对最常见的 UDP I/O 接口:

  • recvfrom:收到数据时,顺便告诉你包来自谁
  • sendto:每次发送时明确指定目标地址

3.4 connect() 对 UDP 也有用

1
connect(fd, (struct sockaddr*)&peer, sizeof(peer));

UDP 上的 connect() 不是建立连接,而是:

  • 给这个 socket 绑定一个默认对端
  • 之后可以直接用 send/recv
  • 内核只接收该对端发来的报文
  • 某些错误能更直接反馈给调用方

所以很多客户端 UDP 程序也会先 connect(),这样代码更简洁。


4. UDP 通信的基本流程

4.1 服务端

  1. socket(AF_INET, SOCK_DGRAM, 0)
  2. bind()
  3. 循环 recvfrom()
  4. 根据来源地址决定回包对象
  5. sendto() 返回结果

4.2 客户端

  1. socket(AF_INET, SOCK_DGRAM, 0)
  2. 可选:connect()
  3. sendto()send()
  4. recvfrom()recv()

和 TCP 相比,UDP 没有:

  • listen()
  • accept()

因为它没有连接队列这一层。


5. UDP 的工程注意点

5.1 没有粘包,不等于没有协议设计

UDP 保留报文边界,但应用层仍然要定义:

  • 包头
  • 消息类型
  • 序号
  • 校验
  • 重传策略

否则出了问题很难排查。

5.2 丢包、乱序、重复都是正常现象

UDP 编程默认就要接受这些情况:

  • 某个包永远收不到
  • 后发的包先到
  • 同一个包被收到两次

这不是异常,而是 UDP 的正常工作方式。

5.3 尽量避免 IP 分片

如果 UDP 报文过大,IP 层可能分片。分片带来的问题:

  • 任一分片丢失,整个报文作废
  • 网络设备对分片不友好
  • 性能和稳定性都变差

工程上通常建议:

  • 单个应用层包尽量控制在 MTU 以下
  • 以太网常见安全值可以先按 1200 字节左右设计

这也是 QUIC 常见的保守做法之一。

5.4 高性能场景通常要配合事件循环

在 Linux 上,大量 UDP socket 或高频收发通常会配合:

  • epoll
  • 非阻塞 socket
  • 定时器管理重传
  • 批量收发(如 recvmmsg/sendmmsg

单线程阻塞式 recvfrom() 适合入门,不适合高并发服务。


6. 一个最小 UDP 回显服务端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>

int main() {
int fd = socket(AF_INET, SOCK_DGRAM, 0);

struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(9999);

bind(fd, (struct sockaddr*)&addr, sizeof(addr));

for (;;) {
char buf[2048];
struct sockaddr_in peer;
socklen_t len = sizeof(peer);

ssize_t n = recvfrom(fd, buf, sizeof(buf), 0,
(struct sockaddr*)&peer, &len);
if (n <= 0) {
continue;
}

sendto(fd, buf, (size_t)n, 0,
(struct sockaddr*)&peer, len);
}

close(fd);
return 0;
}

这个例子足够说明 UDP 服务端的基本模型:

  • 一个 socket
  • 不断收报文
  • 每个报文都自带来源地址
  • 回包时显式指定目标

7. 用 UDP 实现 TCP,应该怎么想

这里更准确的说法不是“把 TCP 原样重写一遍”,而是:

在 UDP 之上补齐 TCP 的核心能力,做出一个“可靠、有序、可控”的传输层。

这是很多实时网络框架的常见思路,例如:

  • KCP:在 UDP 上实现可靠传输,强调低延迟
  • QUIC:在 UDP 上实现更现代的连接、多路复用、拥塞控制与加密

7.1 先分清目标

如果你只是想让消息“更可靠”,那通常只需要做:

  • 序号
  • ACK
  • 超时重传
  • 简单窗口

如果你想完整模拟 TCP,则还要补:

  • 连接管理
  • 按序交付
  • 流量控制
  • 拥塞控制
  • RTT 估计与重传定时器
  • 关闭连接

真正困难的部分其实不是“发包”,而是:

  • 如何在复杂网络环境里稳定地控制发送节奏

7.2 最小协议头可以这样设计

1
2
3
4
5
6
7
8
9
10
struct PacketHeader {
uint32_t conn_id; // 连接标识
uint32_t seq; // 当前包序号
uint32_t ack; // 已确认到的对端序号
uint16_t flags; // SYN / ACK / FIN / RST 等
uint16_t wnd; // 通告接收窗口
uint32_t ts; // 时间戳,用于 RTT 估计
uint16_t len; // payload 长度
uint16_t checksum; // 包头+包体校验
};

如果只是做“可靠消息”而不是“字节流”,这个头已经够搭框架了。

7.3 把 TCP 的能力拆成 6 层功能

1. 连接管理

在 UDP 上自己定义连接状态:

  • CLOSED
  • SYN_SENT
  • SYN_RECV
  • ESTABLISHED
  • FIN_WAIT

可以直接借用 TCP 的思路:

  1. 客户端发 SYN(seq=x)
  2. 服务端回 SYN|ACK(seq=y, ack=x+1)
  3. 客户端再发 ACK(ack=y+1)

这样做的价值是:

  • 双方都知道初始序号
  • 可以建立会话状态
  • 便于后续超时、重传和断线清理

2. 可靠性

发送端维护一个未确认发送队列

  • 每发一个包,都放进 unacked_map
  • 记录发送时间、重传次数
  • 定时扫描超时包并重发

接收端收到包后返回 ACK。

最简单的 ACK 策略可以先做:

  • 累计确认ack = 下一个期待收到的序号

再进一步可做:

  • 选择确认(SACK):显式告诉对端哪些乱序包已经收到

3. 有序交付

如果包乱序到达:

  • 先放入接收缓冲区
  • 只有当 seq == expected_seq 时才向上层提交
  • 提交后继续检查后续缓存是否已连续

这就是 TCP “按序交付”的核心。

4. 流量控制

需要告诉对方:

  • 我这边接收缓冲还有多大

也就是在包头里放 wnd,类似 TCP 的接收窗口。

否则发送方可能发得太快,把接收方内存顶爆。

5. 拥塞控制

这是最难的一层。

最偷懒的版本可以先不做,只设固定发送速率,但这不是真正的 TCP 级能力。

更接近 TCP 的做法是引入:

  • 慢启动
  • 拥塞避免
  • 丢包后乘法减小

如果没有拥塞控制,你的协议在局域网看起来正常,到了公网通常就会失控。

6. 关闭连接

同样可以模仿 TCP:

  • 一端发 FIN
  • 对端回 ACK
  • 双方完成收尾和资源回收

否则 session 容易泄漏。


8. 一个“UDP 版 TCP”最小实现框架

如果从工程结构上设计,可以拆成下面几层:

  1. UdpSocket 层
    负责 socket/bind/sendto/recvfromepoll
  2. Session 层
    peer_addr + conn_id 管理会话状态
  3. Reliability 层
    管理 seq/ack、发送窗口、重传队列、乱序缓冲
  4. Timer 层
    负责 RTO、心跳、超时断线
  5. Congestion / Flow Control 层
    控制能发多少、发多快
  6. Application 层
    真正的业务协议

一个发送端主循环大概是:

1
2
3
4
5
6
7
应用层提交消息
-> 分配 seq
-> 封包并发送
-> 放入未确认队列
-> 等待 ACK
-> 超时则重传
-> 收到 ACK 后从队列删除

一个接收端主循环大概是:

1
2
3
4
5
6
7
收到 UDP 报文
-> 校验包头
-> 根据 conn_id 找到 session
-> 判断 seq 是否重复 / 乱序 / 正常
-> 更新接收窗口
-> 发送 ACK
-> 按序把数据交给上层

9. 真正落地时的几个关键取舍

9.1 先做“可靠消息”,再做“可靠字节流”

如果目标是游戏状态同步、命令包、RPC:

  • 可靠消息协议通常更简单

如果非要完全模拟 TCP 的“字节流语义”,复杂度会显著上升,因为你还要处理:

  • 流式重组
  • 半关闭
  • 更复杂的缓冲管理

9.2 不要一开始就试图复刻内核 TCP

更现实的路线是:

  1. 先做连接 + seq + ack + 重传
  2. 再做乱序缓存 + 滑动窗口
  3. 再补 RTT / RTO
  4. 最后再考虑拥塞控制

否则很容易一上来就把实现写散。

9.3 现代工程更常见的是“在 UDP 上做定制协议”

很多时候我们并不是要“重新发明 TCP”,而是要:

  • 保留 UDP 的低时延和灵活性
  • 只补自己需要的那部分可靠性

例如:

  • 关键帧可靠重传
  • 状态包不重传,只发送最新值
  • 控制消息必须有 ACK

这比完整复刻 TCP 更符合实时系统的实际需求。


10. 小结

可以把 UDP 记成一句话:

UDP 提供的是“轻量发报文”的能力,可靠、有序、流控、拥塞控制都要你自己决定要不要补。

如果要用 UDP 实现类似 TCP 的能力,最关键不是 API,而是这几个机制:

  • 序号 seq
  • 确认 ack
  • 超时重传
  • 滑动窗口
  • 按序交付
  • 流控 / 拥塞控制

把这些拆开理解之后,“UDP 实现 TCP”就不再神秘,本质上就是:

  • 在用户态自己维护传输状态机

参考

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
erDiagram
user_account ||--o{ documents : owns
user_account ||--o{ sessions : owns

documents ||--o{ doc_chunks : splits_into
documents ||--|| document_indexes : indexed_by
documents ||--o{ citations : referenced_by

sessions ||--o{ messages : contains
messages ||--o{ citations : has

doc_chunks ||--o{ citations : cited_from

user_account {
BIGINT id PK
VARCHAR username
TIMESTAMP created_at
}

documents {
BIGINT id PK
BIGINT user_id FK
VARCHAR filename
VARCHAR mime
CHAR sha256
BIGINT size_bytes
VARCHAR storage_path
VARCHAR status
TEXT error_message
TIMESTAMP created_at
TIMESTAMP updated_at
}

doc_chunks {
BIGINT id PK
BIGINT doc_id FK
INT chunk_index
LONGTEXT text
INT tokens_est
TIMESTAMP created_at
}

document_indexes {
BIGINT id PK
BIGINT doc_id FK
VARCHAR index_type
VARCHAR embedding_model
INT dimension
VARCHAR index_path
VARCHAR mapping_path
INT chunk_count
VARCHAR status
TIMESTAMP created_at
TIMESTAMP updated_at
}

sessions {
BIGINT id PK
BIGINT user_id FK
VARCHAR title
TEXT summary
TIMESTAMP created_at
TIMESTAMP updated_at
}

messages {
BIGINT id PK
BIGINT session_id FK
VARCHAR role
LONGTEXT content
VARCHAR status
JSON meta_json
TIMESTAMP created_at
TIMESTAMP updated_at
}

citations {
BIGINT id PK
BIGINT message_id FK
BIGINT doc_id FK
BIGINT chunk_id FK
INT chunk_index
DOUBLE score
TEXT snippet
TIMESTAMP created_at
}

tasks {
BIGINT id PK
VARCHAR celery_task_id
VARCHAR type
VARCHAR entity_type
BIGINT entity_id
VARCHAR state
INT progress
JSON meta_json
TEXT error
TIMESTAMP created_at
TIMESTAMP updated_at
}

ranges 与 views

时间:2026/04/09

关键词:std::rangesstd::views、惰性求值、管道风格、projection、view、dangling
核心目标:理解 ranges 为什么不是“语法糖”,以及 views 在工程里到底解决了什么问题。


1. 为什么会有 ranges

传统 STL 算法常见写法是:

1
2
std::sort(v.begin(), v.end());
auto it = std::find_if(v.begin(), v.end(), pred);

它的问题不是不能用,而是:

  • begin/end 很机械
  • 容器、区间、子区间表达不统一
  • 组合多步处理时可读性一般

std::ranges 的目标是:

  • 直接面向“区间”编程
  • 让算法和数据视图更自然地组合

2. 什么是 range

可以先粗略理解成:

一个可以拿到 beginend 的可遍历对象。

例如:

  • std::vector
  • std::array
  • std::string
  • 某些 view

所以 ranges 的核心不是新容器,而是:

  • 一套更统一的区间抽象

3. ranges 算法和传统算法的区别

传统写法:

1
std::sort(v.begin(), v.end());

ranges 写法:

1
std::ranges::sort(v);

优点:

  • 少写重复样板
  • 更容易配合子区间和 view
  • 接口更贴近“处理一段范围”这件事

4. views 是什么

view 可以先理解成:

一个轻量、通常不拥有数据、按需计算的区间视图。

它最重要的特性通常是:

  • 不拷贝底层数据
  • 惰性求值
  • 可组合

例如:

1
auto even = v | std::views::filter([](int x) { return x % 2 == 0; });

这里并没有立刻生成一个新容器。


5. 为什么 views 很有价值

如果没有 views,很多处理中间会写成:

  • 先过滤到一个新 vector
  • 再 transform 到另一个新 vector
  • 再截取前几个元素

这样的问题是:

  • 中间容器多
  • 拷贝和分配多
  • 代码意图被“存中间结果”打断

views 的思路是:

  • 先把处理流程串起来
  • 真正遍历时再逐步应用

6. 最常见的 view 适配器

6.1 filter

1
auto even = v | std::views::filter([](int x) { return x % 2 == 0; });

6.2 transform

1
auto sq = v | std::views::transform([](int x) { return x * x; });

6.3 take

1
auto first3 = v | std::views::take(3);

6.4 drop

1
auto tail = v | std::views::drop(5);

6.5 keys / values

1
2
auto ks = mp | std::views::keys;
auto vs = mp | std::views::values;

7. 管道风格最直观的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <ranges>
#include <vector>
#include <iostream>

int main() {
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8};

auto result = v
| std::views::filter([](int x) { return x % 2 == 0; })
| std::views::transform([](int x) { return x * x; })
| std::views::take(2);

for (int x : result) {
std::cout << x << '\n';
}
}

8. views 的一个关键点:惰性

下面这句:

1
auto result = v | std::views::filter(pred);

通常不会立刻把所有元素筛一遍。
真正发生计算,往往是在你:

  • 遍历它
  • 构造新容器
  • 调用需要实际消费元素的算法

所以 view 更像“处理规则的组合”,不是立即产出的结果集。


9. view 和容器的区别

容器更像:

  • 真正拥有数据
  • 独立存储结果

view 更像:

  • 一层观察或变换
  • 常常依赖底层对象继续存在

这一点直接影响生命周期安全。


10. 什么时候需要把 view 落地成容器

如果你需要:

  • 持久保存结果
  • 随机访问结果
  • 与不支持 ranges 的旧接口交互

就需要把 view materialize 成容器。

常见方式:

1
std::vector<int> out(std::ranges::begin(view), std::ranges::end(view));

如果是 C++23,还常见:

1
auto out = view | std::ranges::to<std::vector>();

11. projection:ranges 里非常实用但经常被忽略的点

很多 ranges 算法支持 projection。
意思是:

  • 比较或匹配前,先对元素投影出某个字段

例如按成员排序:

1
2
3
4
5
6
struct User {
int id;
std::string name;
};

std::ranges::sort(users, {}, &User::id);

这在工程里很实用。


12. 常见算法示例

1
2
3
4
auto it = std::ranges::find(v, 42);
std::ranges::sort(v);
bool ok = std::ranges::all_of(v, pred);
std::ranges::copy(v, std::back_inserter(out));

13. 生命周期问题:views 最大的坑之一

因为很多 view 不拥有数据,所以要小心底层对象生命周期。

危险例子:

1
2
3
4
auto make_view() {
std::vector<int> v = {1, 2, 3};
return v | std::views::filter([](int x) { return x > 1; }); // 危险
}

所以要记住:

  • view 很轻,但通常不负责延长底层容器生命周期

14. 常见坑

14.1 把 view 当拥有结果的容器

它通常不是。

14.2 底层容器变了,view 却还在继续用

例如容器被销毁、扩容、失效。

14.3 pipeline 过长且带副作用

会让调试和推理变难。

14.4 误以为 ranges 一定更快

语义更清晰不代表每个场景都自动最优。


15. 一页总结

ranges 与 views 最重要的理解链是:

  1. ranges 让算法直接面向区间
  2. views 提供不拥有数据、可组合、惰性的处理视图
  3. 它们最擅长表达“数据处理流水线”
  4. 真正要注意的是生命周期、materialize 时机和可读性边界

如果只记一句:

view 更像“处理规则”,容器才是“真正结果”。

错误处理与 expected、异常设计

时间:2026/04/09

关键词:异常、noexceptstd::expectedoptional、错误传播、恢复性错误、编程错误
核心目标:建立一套工程上可执行的判断标准,知道什么时候该抛异常,什么时候该返回错误值或 expected


1. 错误处理不是“选一个 API”那么简单

错误处理真正要先回答的是:

  • 这是不是预期内会发生的失败
  • 调用方是否应该恢复
  • 失败信息需要多详细
  • 代码库是否接受异常

所以讨论异常和 expected 时,重点不是站队,而是:

  • 哪种语义更适合这一层接口

2. 先把失败分类型

最有用的分类通常是:

2.1 编程错误 / 违反前置条件

例如:

  • 越界
  • 非法状态
  • 不满足接口约束

这类错误通常不属于“正常业务失败”。

2.2 可恢复的业务失败

例如:

  • 解析失败
  • 文件不存在
  • 权限不足
  • 网络超时

调用方通常有机会决定下一步怎么做。

2.3 致命错误

例如:

  • 系统资源耗尽
  • 程序已进入不一致状态

3. 异常适合什么场景

异常最适合:

  • 错误很少发生
  • 一旦发生,需要沿调用栈自动展开
  • 局部函数不适合层层手动返回错误码

典型例子:

  • 构造函数失败
  • 资源获取失败
  • 深层调用链中的异常退出

4. expected 适合什么场景

std::expected<T, E> 更适合:

  • 失败是正常、可预期分支
  • 调用方需要显式处理失败
  • 你希望错误成为接口类型的一部分

例如:

  • 配置解析
  • 用户输入校验
  • 业务规则检查
  • 网络协议解析

5. optionalexpected 的区别

optional<T> 表示:

  • 可能有值,也可能没值

但它不告诉你:

  • 为什么没值

expected<T, E> 表示:

  • 要么有 T
  • 要么有错误 E

所以经验上:

  • 只有“有没有结果”时,用 optional
  • 需要表达“为什么失败”时,用 expected

6. 一个最直接的例子

1
2
std::optional<User> find_user(int id);
std::expected<User, ParseError> parse_user(std::string_view text);

前者表达“可能没有”,后者表达“失败且要知道原因”。


7. 什么时候不适合抛异常

7.1 失败是高频正常分支

7.2 热路径里非常在意开销和可预测性

7.3 跨 ABI / 跨模块边界不方便统一异常策略

7.4 团队整体约束就是禁用异常

这时更适合:

  • expected
  • error code
  • status object

8. 什么时候异常特别合理

8.1 构造函数失败

8.2 无法在每层都写样板检查

8.3 资源清理由 RAII 承担

异常和 RAII 配合得最好:

  • 抛出异常
  • 栈展开
  • 局部资源自动释放

9. 一个 expected 风格的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <expected>
#include <string_view>

enum class ParseError {
Empty,
InvalidNumber
};

std::expected<int, ParseError> parse_int(std::string_view s) {
if (s.empty()) {
return std::unexpected(ParseError::Empty);
}
int value = 0;
for (char ch : s) {
if (ch < '0' || ch > '9') {
return std::unexpected(ParseError::InvalidNumber);
}
value = value * 10 + (ch - '0');
}
return value;
}

10. 异常风格的例子

1
2
3
4
5
6
7
8
9
10
11
int parse_int_or_throw(std::string_view s) {
if (s.empty()) throw std::invalid_argument("empty");
int value = 0;
for (char ch : s) {
if (ch < '0' || ch > '9') {
throw std::invalid_argument("invalid number");
}
value = value * 10 + (ch - '0');
}
return value;
}

11. 一层系统里最好统一风格

最容易出问题的是混乱:

  • 一半函数抛异常
  • 一半函数返回错误码
  • 一半函数返回空值

更稳妥的做法是:

  • 每一层接口尽量有统一错误处理约定

12. noexcept 的意义

noexcept 表示:

  • 这个函数承诺不抛异常

它既是语义约束,也会影响某些容器对移动操作的选择。


13. 析构函数为什么通常不能抛

析构阶段如果异常继续外逃,尤其在栈展开过程中,会很危险。
工程上通常遵循:

  • 析构函数不要让异常逃出

14. 一个很实用的决策表

14.1 用异常

当失败:

  • 不常发生
  • 不适合做常规分支
  • 需要栈展开自动清理

14.2 用 expected

当失败:

  • 很常见
  • 需要显式处理
  • 希望错误成为接口类型的一部分

14.3 用 optional

当失败:

  • 只是“没有结果”
  • 不需要详细错误原因

15. 常见坑

15.1 用异常做普通循环分支

15.2 用 optional 隐藏真实错误信息

15.3 一个模块里混用三四种错误风格

15.4 给所有函数乱加 noexcept


16. 一页总结

错误处理最重要的不是“异常 vs expected 谁更先进”,而是:

  1. 先判断失败是不是正常可恢复分支
  2. 再决定错误是否应该进入类型系统
  3. 再决定是否需要异常自动展开调用栈

可以直接记这条经验:

  • 不常发生、跨层传播、依赖 RAII 清理:优先考虑异常
  • 常见失败、需要显式处理、想把错误写进接口:优先考虑 expected

如果只记一句:

错误处理风格最怕的不是选错,而是同一层接口没有一致性。

生产者-消费者模式与阻塞队列

时间:2026/04/09

关键词:mutexcondition_variable、阻塞队列、bounded queue、shutdown、spurious wakeup
核心目标:写出一个正确、可复用的生产者-消费者队列,而不是“能跑但容易死锁或卡住”的版本。


1. 这个模式在解决什么问题

生产者-消费者模式适用于:

  • 生产方产生任务或消息
  • 消费方异步处理
  • 双方速度不一致

典型场景:

  • 日志队列
  • 任务队列
  • 网络消息分发
  • 线程池任务提交

它的核心不只是“一个队列”,而是三件事:

  • 互斥
  • 条件通知
  • 生命周期关闭

2. 最小阻塞队列骨架

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <condition_variable>
#include <mutex>
#include <queue>

template <class T>
class BlockingQueue {
public:
void push(T value) {
{
std::lock_guard<std::mutex> lk(mutex_);
queue_.push(std::move(value));
}
cv_.notify_one();
}

T pop() {
std::unique_lock<std::mutex> lk(mutex_);
cv_.wait(lk, [&] { return !queue_.empty(); });
T value = std::move(queue_.front());
queue_.pop();
return value;
}

private:
std::mutex mutex_;
std::condition_variable cv_;
std::queue<T> queue_;
};

3. 为什么一定要用谓词版 wait

错误直觉是:

1
cv.wait(lock);

然后醒来就认为一定有数据。
这是不安全的,因为存在:

  • 虚假唤醒
  • 多个线程竞争同一个条件

正确写法:

1
cv.wait(lock, [&] { return !queue_.empty(); });

也就是:

  • 醒来后重新检查条件

4. bounded queue:为什么需要容量上限

如果生产速度远大于消费速度,无界队列会不断膨胀。
这时常需要有界队列:

  • 队列满时,生产者阻塞或失败
1
2
3
while (queue_.size() >= capacity_) {
not_full_.wait(lock);
}

这样可以建立:

  • 背压
  • 内存上限

5. 一个更完整的阻塞队列设计

更工程化的队列通常需要这些接口:

  • push
  • try_push
  • pop
  • try_pop
  • close

close() 很关键,因为消费者可能永远在等:

1
2
3
if (closed_ && queue_.empty()) {
return std::nullopt;
}

否则程序退出时很容易卡死在线程等待上。


6. 推荐的关闭语义

常见设计是:

  • 关闭后不允许再 push
  • 还能把队列里剩余任务消费完
  • 队列空且关闭时,pop 返回“结束”

示意:

1
2
3
4
5
6
std::optional<T> pop() {
std::unique_lock<std::mutex> lk(mutex_);
cv_.wait(lk, [&] { return closed_ || !queue_.empty(); });
if (queue_.empty()) return std::nullopt;
...
}

这比靠塞一个 "EXIT" 哨兵值更通用。


7. 什么时候用 notify_one,什么时候用 notify_all

经验上:

  • 普通入队,通常 notify_one
  • 全局状态变化,比如 close(),通常 notify_all

因为关闭时可能有多个线程都在等待,需要全部唤醒重新判断。


8. 一个更稳妥的示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <condition_variable>
#include <mutex>
#include <optional>
#include <queue>

template <class T>
class BlockingQueue {
public:
explicit BlockingQueue(std::size_t capacity) : capacity_(capacity) {}

bool push(T value) {
std::unique_lock<std::mutex> lk(mutex_);
not_full_.wait(lk, [&] { return closed_ || queue_.size() < capacity_; });
if (closed_) return false;
queue_.push(std::move(value));
lk.unlock();
not_empty_.notify_one();
return true;
}

std::optional<T> pop() {
std::unique_lock<std::mutex> lk(mutex_);
not_empty_.wait(lk, [&] { return closed_ || !queue_.empty(); });
if (queue_.empty()) return std::nullopt;
T value = std::move(queue_.front());
queue_.pop();
lk.unlock();
not_full_.notify_one();
return value;
}

void close() {
std::lock_guard<std::mutex> lk(mutex_);
closed_ = true;
not_empty_.notify_all();
not_full_.notify_all();
}

private:
std::size_t capacity_;
std::queue<T> queue_;
bool closed_ = false;
std::mutex mutex_;
std::condition_variable not_empty_;
std::condition_variable not_full_;
};

9. 常见坑

9.1 if 代替 while/谓词

这是最常见的条件变量错误。

9.2 持锁太久

如果拿着锁做重计算或 I/O,会严重拖慢并发吞吐。

9.3 没有关闭语义

线程可能永远阻塞退出不了。

9.4 用哨兵值替代通用关闭协议

对简单 demo 可以,但扩展性差。


10. 一页总结

生产者-消费者模式的核心不是“有个队列”,而是:

  1. 用互斥保护共享队列
  2. 用条件变量等待状态变化
  3. 用关闭协议管理线程退出
  4. 必要时用容量上限建立背压

如果只记一句:

条件变量永远和“共享状态 + 谓词检查”一起使用,不能只靠通知本身。

线程同步消息队列与线程池

时间:2026/04/09

关键词:任务队列、worker thread、future、停止协议、背压、线程池
核心目标:理解线程池为什么几乎总是“队列 + 工作线程 + 生命周期管理”的组合。


1. 为什么线程池比“每个任务一个线程”更常见

直接为每个任务创建线程的问题在于:

  • 创建销毁开销高
  • 线程数不可控
  • 容易把系统调度器压爆

线程池的思路是:

  • 预先创建固定数量 worker
  • 任务进入共享队列
  • worker 从队列取任务执行

2. 线程池最小结构

一个线程池通常包含:

  • 任务队列
  • 多个工作线程
  • 停止标志
  • 提交接口

示意:

1
producer -> task queue -> workers

3. 推荐的任务表示

最常见的是:

1
std::function<void()>

这样线程池不关心任务具体类型,只负责执行。

如果要返回值,可以把真实任务包装进:

  • std::packaged_task
  • std::promise
  • std::future

4. 一个最小线程池骨架

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include <condition_variable>
#include <functional>
#include <mutex>
#include <queue>
#include <thread>
#include <vector>

class ThreadPool {
public:
explicit ThreadPool(std::size_t n) {
for (std::size_t i = 0; i < n; ++i) {
workers_.emplace_back([this] { worker_loop(); });
}
}

~ThreadPool() {
{
std::lock_guard<std::mutex> lk(mutex_);
stop_ = true;
}
cv_.notify_all();
for (auto& t : workers_) {
if (t.joinable()) t.join();
}
}

void submit(std::function<void()> task) {
{
std::lock_guard<std::mutex> lk(mutex_);
tasks_.push(std::move(task));
}
cv_.notify_one();
}

private:
void worker_loop() {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lk(mutex_);
cv_.wait(lk, [&] { return stop_ || !tasks_.empty(); });
if (stop_ && tasks_.empty()) return;
task = std::move(tasks_.front());
tasks_.pop();
}
task();
}
}

bool stop_ = false;
std::mutex mutex_;
std::condition_variable cv_;
std::queue<std::function<void()>> tasks_;
std::vector<std::thread> workers_;
};

5. 为什么停止协议很重要

如果没有明确的停止逻辑,线程池很容易在析构时:

  • worker 永远等在 wait
  • 主线程 join 不回来

正确退出条件通常是:

  • stop_ == true
  • 并且队列已空

6. 返回值怎么做

常见写法是:

  • 把用户任务包装成 packaged_task
  • 返回对应 future

这样提交方既能异步执行,也能之后 get() 结果。

线程池的接口常见长这样:

1
2
template <class F, class... Args>
auto enqueue(F&& f, Args&&... args) -> std::future<...>;

这也是完美转发的高频实战场景。


7. 有界任务队列与背压

如果任务生产速度远大于消费速度,线程池也可能把内存吃爆。
所以工程上经常要考虑:

  • 队列容量上限
  • 超限后阻塞
  • 超限后丢弃
  • 超限后降级

这其实就是背压策略。


8. 线程池不是越多线程越好

线程数通常取决于:

  • CPU 核心数
  • 任务是否 CPU 密集
  • 任务是否经常阻塞 I/O

经验上:

  • CPU 密集型:线程数通常接近核心数
  • I/O 密集型:线程数可适当更大

9. 消息队列 vs 线程池

这两个概念经常一起出现,但不完全一样。

  • 消息队列:强调数据传递与同步
  • 线程池:强调任务执行与线程复用

线程池内部几乎总会用到任务队列,但消息队列本身不一定等于线程池。


10. 常见坑

10.1 任务里抛异常没人管

如果没有 future 或显式捕获,异常可能直接导致线程终止。

10.2 析构时仍允许提交任务

这会让生命周期变得混乱。

10.3 持锁执行任务

这是严重错误。
正确做法是:

  • 取出任务后释放锁
  • 再执行任务

10.4 线程池里再无限提交内部任务

这可能制造级联膨胀和死锁风险。


11. 一页总结

线程池最关键的不是模板技巧,而是三个工程点:

  1. 任务队列
  2. worker 生命周期
  3. 明确的停止与背压策略

如果只记一句:

线程池本质上是“用受控线程数去消费一个受控任务流”。

游戏常见设计模式

时间:2026/04/09

关键词:单例、状态模式、命令模式、观察者、组件化、对象池
核心目标:从游戏开发常见场景出发,理解哪些模式真的有用,以及哪些模式容易被滥用。


1. 游戏代码为什么特别容易模式化

游戏逻辑常见特点:

  • 实体多
  • 状态多
  • 事件多
  • 生命周期复杂
  • 性能敏感

因此很多经典模式在游戏里非常常见,但也特别容易被滥用。


2. 单例模式:能用,但要克制

单例通常用于:

  • 配置中心
  • 日志系统
  • 全局资源管理器

最简单安全的写法通常是局部静态:

1
2
3
4
5
6
7
8
9
10
class GameConfig {
public:
static GameConfig& instance() {
static GameConfig cfg;
return cfg;
}

private:
GameConfig() = default;
};

优点:

  • 简单
  • 线程安全初始化

缺点:

  • 全局依赖隐蔽
  • 测试困难
  • 生命周期难拆

所以经验上:

  • 单例适合少量基础设施,不适合把一切都做成全局对象

3. 状态模式:替代大 switch

当一个角色会在多种状态之间切换,例如:

  • Idle
  • Chase
  • Attack
  • Dead

如果全写在一个大 switch 里,代码会越来越乱。
状态模式的思路是:

  • 每个状态自己负责更新逻辑和转移条件
1
2
3
4
5
6
struct Monster;

struct State {
virtual ~State() = default;
virtual void update(Monster& m) = 0;
};

这样“状态行为”会比“状态枚举 + 大分支”更容易扩展。


4. 命令模式:把输入和行为解耦

适用场景:

  • 输入映射
  • 回放系统
  • AI 行为排队
  • 网络同步操作记录

基本思路:

  • 把“做什么”封装成命令对象
1
2
3
4
struct Command {
virtual ~Command() = default;
virtual void execute() = 0;
};

这样可以做到:

  • 排队执行
  • 延迟执行
  • 撤销/重放

5. 观察者 / 事件模式

适合:

  • UI 更新
  • 成就系统
  • 音效触发
  • 状态广播

思路是:

  • 某个系统发事件
  • 多个订阅者响应

优点:

  • 降低模块直接耦合

风险:

  • 调用链变隐蔽
  • 调试困难

所以事件系统要控制好:

  • 事件粒度
  • 生命周期
  • 订阅关系

6. 组件化 / ECS 思路

传统继承层级:

  • Monster -> BossMonster -> FlyingBossMonster -> ...

很容易爆炸。
组件化更偏向:

  • Position
  • Render
  • Physics
  • Health

通过组合形成实体能力。

这样更灵活,也更适合:

  • 数据驱动
  • 批量更新
  • SoA / cache 友好设计

7. 对象池:减少频繁分配

游戏里这些对象往往高频创建销毁:

  • 子弹
  • 粒子
  • 临时特效

如果每次都 new/delete,可能带来:

  • 分配开销
  • 碎片
  • 抖动

对象池的思路是:

  • 提前分配一批对象
  • 使用时取出
  • 用完后归还

但要注意:

  • 池化会增加状态管理复杂度
  • 不是所有对象都值得池化

8. 模板方法模式

适用于:

  • 主流程固定
  • 个别步骤由派生类决定

例如角色更新:

1
2
3
4
5
6
7
8
9
10
11
12
struct Character {
virtual ~Character() = default;
virtual void think() = 0;
virtual void move() = 0;
virtual void draw() = 0;

void update() {
think();
move();
draw();
}
};

这种模式的优点是流程稳定,但要避免基类职责过重。


9. 游戏开发里最常见的误区

9.1 什么都做成单例

最后会形成巨型全局依赖网。

9.2 继承层级过深

很多时候组合比继承更稳。

9.3 事件系统滥用

会让控制流难以追踪。

9.4 为了模式而模式

很多小项目只需要清晰结构,不需要把所有经典模式全搬进来。


10. 一页总结

游戏里真正常用、且值得优先掌握的几个模式是:

  1. 状态模式
  2. 命令模式
  3. 观察者 / 事件模式
  4. 组件化 / ECS
  5. 对象池

单例不是不能用,但一定要克制。

如果只记一句:

游戏模式的价值不在“名词多高级”,而在于它能不能降低复杂状态和高频对象管理的混乱度。

工厂模式、多态与接口设计

时间:2026/04/09

关键词:抽象接口、虚函数、工厂函数、依赖倒置、override、虚析构、unique_ptr
核心目标:理解什么时候该把“创建对象”和“使用对象”分开。


1. 为什么需要工厂模式

很多代码的问题不是“不会 new”,而是:

  • 调用方知道太多具体类型
  • 构造逻辑散落各处
  • 后续替换实现很痛苦

工厂模式的核心价值是:

  • 把对象创建逻辑集中起来
  • 让调用方依赖抽象接口,而不是具体实现

2. 多态接口的基础

1
2
3
4
5
6
7
8
9
10
11
12
struct Pet {
virtual ~Pet() = default;
virtual void speak() = 0;
};

struct Cat : Pet {
void speak() override { std::puts("meow"); }
};

struct Dog : Pet {
void speak() override { std::puts("woof"); }
};

这里要点有两个:

  • 基类析构函数要么虚,要么不允许多态删除
  • 派生类重写时用 override

3. 一个最简单的工厂函数

1
2
3
4
5
6
7
8
#include <memory>
#include <string>

std::unique_ptr<Pet> make_pet(const std::string& kind) {
if (kind == "cat") return std::make_unique<Cat>();
if (kind == "dog") return std::make_unique<Dog>();
return nullptr;
}

调用方只关心:

  • 我要一个 Pet

而不关心:

  • 具体怎么构造 Cat / Dog

4. 为什么返回 unique_ptr

返回裸指针会引入一个问题:

  • 谁负责 delete

std::unique_ptr 更清晰:

  • 工厂负责创建
  • 调用方接管独占所有权

这是现代 C++ 工厂接口最常见的实践。


5. 简单工厂 vs 工厂方法

5.1 简单工厂

一个集中函数,根据参数分支创建对象。

优点:

  • 简单直接

缺点:

  • 新增类型时可能要改原工厂

5.2 工厂方法

把“创建哪种对象”交给子类。

适合:

  • 框架式扩展
  • 产品族较复杂

但对小项目来说,简单工厂已经够用。


6. 接口设计比模式名更重要

真正工程里,更该关注这些问题:

  • 基类是不是表达了稳定抽象
  • 调用方是否真的不需要知道具体类型
  • 返回所有权是否清晰
  • 是否需要注册表或插件化

很多时候模式本身不复杂,难的是接口边界。


7. 一个更贴近工程的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Reducer {
virtual ~Reducer() = default;
virtual int init() const = 0;
virtual int combine(int a, int b) const = 0;
};

struct SumReducer : Reducer {
int init() const override { return 0; }
int combine(int a, int b) const override { return a + b; }
};

struct MulReducer : Reducer {
int init() const override { return 1; }
int combine(int a, int b) const override { return a * b; }
};

这里“算法骨架”不变,“聚合策略”可替换。
这类设计常和工厂、策略模式一起出现。


8. 工厂模式的常见扩展

8.1 注册表工厂

把字符串或类型 id 映射到创建函数:

  • 更适合插件化
  • 更适合可扩展系统

8.2 抽象工厂

如果你需要创建一整组关联对象,就可能进入抽象工厂场景。

例如:

  • UI 皮肤
  • 跨平台组件族

9. 常见坑

9.1 基类没有虚析构

多态删除会出问题。

9.2 工厂返回裸指针

所有权不清晰。

9.3 为了“用模式而用模式”

小项目里过度抽象只会增加复杂度。

9.4 基类接口设计得太宽

会让派生类被迫实现很多并不需要的东西。


10. 一页总结

工厂模式最核心的收益不是“设计模式名词”,而是:

  1. 创建逻辑集中
  2. 调用方依赖抽象
  3. 所有权表达清晰

如果只记一句:

当“对象怎么创建”开始影响“对象怎么使用”时,就该考虑把创建逻辑抽出来。

对象布局、栈堆与未定义行为

时间:2026/04/09

关键词:栈、堆、静态区、对齐、padding、悬空指针、越界、strict aliasing
核心目标:建立“对象怎么放在内存里”的正确直觉,避免把现代 C++ 写成偶发崩溃的未定义行为集合。


1. 栈和堆最容易被误解的点

简单说:

  • 栈:作用域驱动的自动存储
  • 堆:动态分配、手动或 RAII 管理

但更准确的重点不是“栈连续还是不连续”,而是:

  • 对象生命周期
  • 所有权
  • 是否发生悬空和越界

2. 常见内存区域

粗略理解:

  • 代码区
  • 全局/静态区

局部变量通常在栈上:

1
int x = 1;

动态分配通常在堆上:

1
auto p = std::make_unique<int>(42);

3. 栈对象的最大优点

栈对象最大优点不是“快”这一个字,而是:

  • 生命周期清晰
  • 自动析构
  • 适合 RAII

所以现代 C++ 的默认倾向是:

  • 能值语义就值语义
  • 能局部对象就局部对象

4. 栈对象最大的风险

不是“栈内存不连续”,而是:

  • 返回局部变量地址
  • 返回局部引用
  • 离开作用域后继续访问
1
2
3
4
int* bad() {
int x = 1;
return &x; // 错
}

5. 堆对象的价值和代价

堆对象适合:

  • 跨作用域存活
  • 运行期决定大小
  • 多态对象

代价是:

  • 需要明确所有权
  • 分配释放有开销
  • 更容易泄漏或悬空

所以:

  • 堆不是默认选项
  • 只有确实需要时才动态分配

6. 对齐与 padding

对象内存布局受类型对齐影响。

1
2
3
4
struct A {
char c;
int x;
};

sizeof(A) 往往不只是 5,而可能是 8。
原因是:

  • 编译器会插入 padding 满足对齐要求

这会影响:

  • 内存占用
  • 缓存利用率
  • 二进制布局

7. 未定义行为最常见的几类

7.1 越界访问

1
2
int a[4];
int x = a[10];

7.2 悬空指针

1
2
3
int* p = new int(1);
delete p;
*p = 2; // 错

7.3 错误类型解释

1
2
double d = 3.14;
int* p = reinterpret_cast<int*>(&d); // 极危险

7.4 严格别名相关问题

某些类型转换会让编译器优化假设失效。


8. 为什么现代 C++ 强调封装

很多底层问题不是“不允许碰”,而是:

  • 一旦你直接操作裸内存,就必须承担全部正确性责任

所以现代实践更推荐:

  • std::vector
  • std::string
  • std::array
  • std::span
  • 智能指针

来包住底层细节。


9. 一页总结

最重要的三条:

  1. 栈对象优先,因为生命周期最清晰
  2. 堆对象只有在确实需要动态生命周期时才用
  3. 越界、悬空、错误类型解释都不是“小问题”,而是未定义行为

如果只记一句:

现代 C++ 不是不让你碰底层,而是要求你在碰底层时明确对象生命周期和内存语义。