楚天

惟楚有材,于斯为盛

libevent 客户端与 bufferevent 进阶

时间:2026/05/04

关键词:bufferevent_socket_newbufferevent_socket_connect、异步 DNS、超时、水位线、重连、背压
核心目标:掌握如何用 libevent 主动连接服务器,并把读写、超时、DNS、重连和缓冲控制放进统一事件循环。


1. 客户端和服务端的 libevent 思路差异

服务端的主线是:

1
evconnlistener -> accept -> bufferevent

客户端的主线是:

1
2
3
4
5
bufferevent_socket_new(fd = -1)
-> bufferevent_socket_connect
-> BEV_EVENT_CONNECTED
-> write request
-> read response

也就是说:

  • 服务端先监听,再为每个连接创建 bufferevent
  • 客户端通常直接创建一个未连接的 bufferevent
  • 连接成功、失败、超时都从 event callback 里得知

2. 创建客户端 bufferevent

1
2
3
4
5
6
7
#include <event2/bufferevent.h>

struct bufferevent *bev = bufferevent_socket_new(
base,
-1,
BEV_OPT_CLOSE_ON_FREE | BEV_OPT_DEFER_CALLBACKS
);

这里 fd = -1 表示:

  • 让 libevent 自己创建 socket
  • 后续由 bufferevent_socket_connect() 发起连接

常用选项:

选项 作用
BEV_OPT_CLOSE_ON_FREE 释放 bev 时关闭 socket
BEV_OPT_DEFER_CALLBACKS 延迟执行回调,减少重入问题
BEV_OPT_THREADSAFE bev 加锁,需先启用 libevent 线程支持

3. 回调要先设置,再发起连接

推荐顺序:

1
2
3
4
bufferevent_setcb(bev, read_cb, write_cb, event_cb, ctx);
bufferevent_enable(bev, EV_READ | EV_WRITE);
bufferevent_set_timeouts(bev, &read_timeout, &write_timeout);
bufferevent_socket_connect(bev, (struct sockaddr *)&addr, sizeof(addr));

原因是:

  • 连接可能很快成功或失败
  • 先设置回调可以避免事件到来时没有处理逻辑
  • 超时也应该在连接发起前准备好

4. 最小客户端示例:连接 IPv4 地址

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
101
102
103
104
105
106
107
108
109
110
111
112
#include <event2/buffer.h>
#include <event2/bufferevent.h>
#include <event2/event.h>
#include <event2/util.h>

#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>

static void read_cb(struct bufferevent *bev, void *ctx) {
(void)ctx;

char buf[4096];
size_t n = 0;

while ((n = bufferevent_read(bev, buf, sizeof(buf))) > 0) {
fwrite(buf, 1, n, stdout);
}
}

static void write_cb(struct bufferevent *bev, void *ctx) {
(void)bev;
(void)ctx;
}

static void event_cb(struct bufferevent *bev, short events, void *ctx) {
struct event_base *base = ctx;

if (events & BEV_EVENT_CONNECTED) {
const char *msg = "hello from libevent client\n";
bufferevent_write(bev, msg, strlen(msg));
return;
}

if (events & BEV_EVENT_ERROR) {
int err = EVUTIL_SOCKET_ERROR();
fprintf(stderr, "connect/io error: %s\n",
evutil_socket_error_to_string(err));
}

if (events & BEV_EVENT_TIMEOUT) {
fprintf(stderr, "timeout\n");
}

if (events & BEV_EVENT_EOF) {
fprintf(stderr, "server closed\n");
}

if (events & (BEV_EVENT_EOF | BEV_EVENT_ERROR | BEV_EVENT_TIMEOUT)) {
bufferevent_free(bev);
event_base_loopexit(base, NULL);
}
}

int main(int argc, char **argv) {
const char *ip = "127.0.0.1";
int port = 9876;

if (argc > 1) {
ip = argv[1];
}
if (argc > 2) {
port = atoi(argv[2]);
}

struct event_base *base = event_base_new();
if (!base) {
fprintf(stderr, "could not create event_base\n");
return 1;
}

struct bufferevent *bev = bufferevent_socket_new(
base, -1, BEV_OPT_CLOSE_ON_FREE | BEV_OPT_DEFER_CALLBACKS);
if (!bev) {
event_base_free(base);
return 1;
}

bufferevent_setcb(bev, read_cb, write_cb, event_cb, base);
bufferevent_enable(bev, EV_READ | EV_WRITE);

struct timeval timeout = {10, 0};
bufferevent_set_timeouts(bev, &timeout, &timeout);

struct sockaddr_in sin;
memset(&sin, 0, sizeof(sin));
sin.sin_family = AF_INET;
sin.sin_port = htons((uint16_t)port);

if (evutil_inet_pton(AF_INET, ip, &sin.sin_addr) <= 0) {
fprintf(stderr, "invalid ip: %s\n", ip);
bufferevent_free(bev);
event_base_free(base);
return 1;
}

if (bufferevent_socket_connect(
bev, (struct sockaddr *)&sin, sizeof(sin)) < 0) {
fprintf(stderr, "connect start failed\n");
bufferevent_free(bev);
event_base_free(base);
return 1;
}

event_base_dispatch(base);

event_base_free(base);
return 0;
}

编译:

1
cc client.c -o client -levent

测试时可以先启动上一篇的 echo server:

1
2
./echo_server 9876
./client 127.0.0.1 9876

5. BEV_EVENT_CONNECTED 不等于服务端已回包

主动连接成功时,event callback 会收到:

1
BEV_EVENT_CONNECTED

这只表示 TCP 连接建立完成,之后你可以:

  • 发送请求
  • 启动应用层握手
  • 切换连接状态

它不表示:

  • 服务端已经处理业务
  • 服务端一定会持续在线
  • 应用协议已经完成

所以客户端通常要维护自己的状态:

1
CONNECTING -> CONNECTED -> REQUEST_SENT -> RESPONSE_READING -> DONE

6. 使用主机名连接:异步 DNS

如果直接调用阻塞式 getaddrinfo(),事件循环可能被卡住。
libevent 提供了异步 DNS:

1
2
3
4
5
6
7
8
9
10
11
#include <event2/dns.h>

struct evdns_base *dns = evdns_base_new(base, 1);

int rc = bufferevent_socket_connect_hostname(
bev,
dns,
AF_UNSPEC,
"example.com",
80
);

参数说明:

参数 含义
dns DNS 解析器对象
AF_UNSPEC IPv4 / IPv6 都可以
hostname 主机名
port 端口

DNS 错误可以这样拿:

1
2
3
4
int err = bufferevent_socket_get_dns_error(bev);
if (err) {
fprintf(stderr, "dns error: %s\n", evutil_gai_strerror(err));
}

清理时:

1
evdns_base_free(dns, 0);

7. 输入缓冲解析:不要假设一次读完

客户端同样要处理半包和粘包。
如果协议是按行返回,可以用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static void line_read_cb(struct bufferevent *bev, void *ctx) {
(void)ctx;

struct evbuffer *input = bufferevent_get_input(bev);

for (;;) {
size_t n = 0;
char *line = evbuffer_readln(input, &n, EVBUFFER_EOL_CRLF);
if (!line) {
break;
}

printf("line: %.*s\n", (int)n, line);
free(line);
}
}

如果协议是二进制长度前缀,就要像服务端一样:

  • 先检查包头够不够
  • 再检查包体够不够
  • 一次循环内尽量把完整消息都解析掉

8. 输出缓冲与写回调

bufferevent_write() 的语义是:

把数据追加到输出缓冲,之后由 libevent 在 socket 可写时异步发送。

1
bufferevent_write(bev, data, len);

它不是阻塞式 send()
调用成功只表示数据进入输出缓冲,不表示对端已经收到。

write callback 的触发条件是:

  • 输出缓冲长度降到写低水位

这适合做发送完成后的动作:

1
2
3
4
5
6
static void write_done_cb(struct bufferevent *bev, void *ctx) {
struct evbuffer *output = bufferevent_get_output(bev);
if (evbuffer_get_length(output) == 0) {
// 请求已经全部刷入内核发送路径
}
}

注意:

  • 这仍然不等于对端应用层已处理
  • 如果需要确认,协议层要有 ACK 或 response

9. 水位线:控制读写压力

读水位:

1
bufferevent_setwatermark(bev, EV_READ, 1, 64 * 1024);

含义:

  • 输入缓冲至少 1 字节时触发读回调
  • 输入缓冲超过 64 KiB 时暂停继续读

写水位:

1
bufferevent_setwatermark(bev, EV_WRITE, 0, 0);

多数简单客户端不用特别设置写水位。
但如果客户端会持续大批量发送,就应该给业务层加队列上限,避免输出缓冲无限增长。


10. 超时:连接、读、写都要考虑

1
2
3
struct timeval rto = {10, 0};
struct timeval wto = {10, 0};
bufferevent_set_timeouts(bev, &rto, &wto);

常见理解:

  • 非阻塞 connect 阶段通常靠写超时兜底
  • 等响应阶段通常靠读超时兜底
  • 大量数据发送阶段可能触发写超时

event callback 里处理:

1
2
3
if (events & BEV_EVENT_TIMEOUT) {
bufferevent_free(bev);
}

不要让客户端无限等服务端响应。
超时本身就是协议状态机的一部分。


11. 重连策略

客户端常常需要重连,但不能无脑死循环重连。

推荐策略:

  • 连接失败后延迟重连
  • 指数退避,例如 1s、2s、4s、8s
  • 设置最大退避时间
  • 手动关闭和网络错误要区分
  • 重连前清理旧 bufferevent

用 libevent 定时器表达重连:

1
2
3
struct event *timer = evtimer_new(base, reconnect_cb, ctx);
struct timeval delay = {2, 0};
evtimer_add(timer, &delay);

重连时再重新创建:

1
new bufferevent -> set callbacks -> enable -> connect

不要试图在已经进入错误状态的 bufferevent 上反复复用所有状态。


12. bufferevent_pair_new:进程内自通信

libevent 还可以创建一对互联的 bufferevent

1
2
struct bufferevent *pair[2];
bufferevent_pair_new(base, BEV_OPT_CLOSE_ON_FREE, pair);

pair[0] 写入的数据会从 pair[1] 读到,反之亦然。
它常用于:

  • 测试协议解析器
  • 模拟连接
  • 进程内组件通信

这不是网络 socket,但接口和 bufferevent 一致。


13. 客户端常见坑

13.1 connect() 前没设置回调和超时

事件可能很快到来,尤其是本机连接。

13.2 把 bufferevent_write() 当成发送完成

它只是写入输出缓冲。
应用层确认要靠协议 response。

13.3 使用阻塞 DNS

阻塞解析会卡住整个事件循环。
推荐使用 bufferevent_socket_connect_hostname()

13.4 错误后复用旧 bev

收到 EOF/ERROR/TIMEOUT 后通常释放旧对象,重连时创建新的 bufferevent

13.5 没有输出队列上限

如果对端慢或网络异常,输出缓冲可能持续增长。


14. 一页总结

libevent 客户端最重要的是:

  1. bufferevent_socket_new(base, -1, ...) 创建未连接 I/O 对象
  2. 先设置回调、启用事件和超时,再调用 connect
  3. BEV_EVENT_CONNECTED 表示 TCP 连接建立完成
  4. DNS 连接推荐用 bufferevent_socket_connect_hostname
  5. bufferevent_write 只是写入输出缓冲,不等于对端收到
  6. 读回调里仍然要做协议拆包
  7. 重连要有退避,不要无限快速重试

如果只记一句:

libevent 客户端的核心是把“连接中、已连接、读响应、写请求、失败重连”全部变成事件循环里的状态机。


15. 参考资料

  1. libevent book: Ref6 bufferevent
    https://libevent.org/libevent-book/Ref6_bufferevent.html

  2. libevent book: Ref9 dns
    https://libevent.org/libevent-book/Ref9_dns.html

  3. libevent book: Ref4 event
    https://libevent.org/libevent-book/Ref4_event.html

Socket 基础与 TCP 编程

时间:2026/05/04

关键词:socket、TCP、bind/listen/acceptconnect、非阻塞、粘包、半关闭、socket options
核心目标:把 Linux TCP socket 的基本生命周期、常见错误码和工程坑点串起来,为后续 epoll、libevent 和高性能服务端打底。


1. socket 到底是什么

在 Linux 里,socket 本质上也是一种文件描述符:

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

它和普通文件 fd 的相似点:

  • 都可以用整数 fd 表示
  • 都可以被 close()
  • 都能放进 epoll

它的不同点:

  • 背后连接的是协议栈,而不是磁盘文件
  • 读写行为受 TCP 状态、内核缓冲区和网络影响
  • 错误码比普通文件 I/O 更丰富

常见 socket 类型:

类型 含义
SOCK_STREAM TCP,面向连接的字节流
SOCK_DGRAM UDP,面向报文
SOCK_RAW 原始套接字,通常用于底层网络工具

2. TCP 服务端基本流程

TCP 服务端标准流程:

1
2
3
4
5
6
7
socket
-> setsockopt
-> bind
-> listen
-> accept
-> recv/send
-> close

最小阻塞式 echo server:

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 <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>

#include <stdio.h>
#include <string.h>

int main(void) {
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) {
perror("socket");
return 1;
}

int yes = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));

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(9876);

if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind");
close(listen_fd);
return 1;
}

if (listen(listen_fd, SOMAXCONN) < 0) {
perror("listen");
close(listen_fd);
return 1;
}

for (;;) {
int conn_fd = accept(listen_fd, NULL, NULL);
if (conn_fd < 0) {
perror("accept");
continue;
}

char buf[4096];
ssize_t n = 0;
while ((n = recv(conn_fd, buf, sizeof(buf), 0)) > 0) {
send(conn_fd, buf, (size_t)n, 0);
}

close(conn_fd);
}

close(listen_fd);
return 0;
}

这个版本只能用于理解流程。
真正高并发服务端通常要配合:

  • 非阻塞 socket
  • epoll
  • 用户态输入/输出缓冲
  • 协议解析状态机

3. TCP 客户端基本流程

客户端流程:

1
2
3
4
socket
-> connect
-> send/recv
-> close

最小示例:

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
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>

#include <stdio.h>
#include <string.h>

int main(void) {
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
perror("socket");
return 1;
}

struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(9876);
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);

if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("connect");
close(fd);
return 1;
}

const char *msg = "hello\n";
send(fd, msg, strlen(msg), 0);

char buf[4096];
ssize_t n = recv(fd, buf, sizeof(buf), 0);
if (n > 0) {
fwrite(buf, 1, (size_t)n, stdout);
}

close(fd);
return 0;
}

4. 地址、端口和字节序

IPv4 地址结构:

1
2
3
4
5
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(9876);
addr.sin_addr.s_addr = htonl(INADDR_ANY);

注意:

  • 端口要用 htons
  • IPv4 地址整数要用 htonl
  • 网络字节序是大端

常用转换:

1
2
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
inet_ntop(AF_INET, &addr.sin_addr, buf, sizeof(buf));

如果要同时支持 IPv4 / IPv6,更推荐用 getaddrinfo()

1
2
3
4
struct addrinfo hints;
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;

这样可以避免到处手写 sockaddr_in / sockaddr_in6 分支。


5. bind()listen()accept() 分别做什么

5.1 bind()

把 socket 绑定到本地 IP 和端口:

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

服务端通常需要 bind()
客户端一般不需要手动 bind,内核会自动分配本地临时端口。

5.2 listen()

把 TCP socket 变成监听 socket:

1
listen(fd, SOMAXCONN);

它不是开始读写数据,而是让内核维护连接队列。

5.3 accept()

从已完成连接队列中取出一个连接:

1
int conn_fd = accept(listen_fd, NULL, NULL);

返回的 conn_fd 才是和客户端通信的 socket。
listen_fd 继续用于接收新连接。


6. recv()send() 的真实语义

6.1 recv()

1
ssize_t n = recv(fd, buf, sizeof(buf), 0);

返回值含义:

返回 含义
n > 0 收到 n 字节
n == 0 对端有序关闭,收到 EOF
n < 0 出错或非阻塞下暂时无数据

重要点:

  • n == 0 不是“现在没数据”
  • TCP 上一次 recv() 不等于一条完整消息

6.2 send()

1
ssize_t n = send(fd, data, len, MSG_NOSIGNAL);

返回值可能小于 len,表示只写入了一部分。
非阻塞服务端必须保存剩余数据,下次可写时继续发送。

建议:

  • Linux 下写 socket 时考虑 MSG_NOSIGNAL
  • 或者全局忽略 SIGPIPE
  • 不要假设一次 send() 发完全部数据

7. TCP 是字节流:粘包和半包

TCP 不保留应用层消息边界。

应用层发送:

1
2
send("hello")
send("world")

接收端可能看到:

1
"helloworld"

也可能看到:

1
2
3
"he"
"llowor"
"ld"

这不是 TCP 的 bug,而是字节流语义。

常见协议设计:

方式 说明
固定长度 每条消息长度固定
分隔符 例如 \r\n 结尾
长度前缀 包头里写 payload 长度
TLV Type-Length-Value,适合扩展

工程里最常见的是长度前缀:

1
uint32_t length(network byte order) + payload

解析时必须基于用户态输入缓冲区做状态机。


8. 设置非阻塞

1
2
3
4
5
6
7
8
9
#include <fcntl.h>

int set_nonblock(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
if (flags < 0) {
return -1;
}
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

非阻塞后:

  • 没有新连接时 accept() 返回 EAGAIN
  • 没数据可读时 recv() 返回 EAGAIN
  • 暂时写不进去时 send() 返回 EAGAIN
  • connect() 可能返回 EINPROGRESS

非阻塞不是“不会失败”,而是“不能立刻完成时不要睡眠”。


9. 非阻塞 connect() 怎么判断成功

客户端非阻塞连接常见流程:

1
2
3
4
5
6
7
8
int rc = connect(fd, (struct sockaddr *)&addr, sizeof(addr));
if (rc == 0) {
// 立即成功
} else if (errno == EINPROGRESS) {
// 正在连接,等待 fd 可写
} else {
// 立即失败
}

等到 epoll 报可写后,必须用 SO_ERROR 确认结果:

1
2
3
4
5
6
7
8
9
int err = 0;
socklen_t len = sizeof(err);
getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len);

if (err == 0) {
// connect 成功
} else {
// connect 失败,err 是真实错误码
}

不能简单认为“可写 == 连接成功”。


10. 常用 socket options

10.1 SO_REUSEADDR

服务端重启时常用:

1
2
int yes = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));

它能减少端口被 TIME_WAIT 等状态影响导致无法 bind 的情况。

10.2 SO_REUSEPORT

允许多个 socket 绑定同一个 IP/端口,常用于多进程或多线程 accept 扩展:

1
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &yes, sizeof(yes));

注意:

  • 要在 bind() 前设置
  • 负载分配由内核完成
  • 不同系统支持和语义可能有差异

10.3 TCP_NODELAY

关闭 Nagle 算法:

1
2
3
#include <netinet/tcp.h>

setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &yes, sizeof(yes));

适合低延迟小包交互。
但如果业务能合并小包,应用层批量发送通常更好。

10.4 SO_KEEPALIVE

启用 TCP keepalive:

1
setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &yes, sizeof(yes));

它适合发现长时间断开的死连接,但默认探测时间通常很长。
服务端仍然应该有应用层心跳或读写超时。

10.5 SO_RCVBUF / SO_SNDBUF

调整内核收发缓冲:

1
2
3
int size = 1 << 20;
setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &size, sizeof(size));
setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &size, sizeof(size));

缓冲区不是越大越好。
它会影响吞吐、延迟、内存占用和背压表现。


11. 半关闭与 shutdown()

close(fd) 表示关闭整个 socket。
shutdown() 可以关闭某个方向:

1
2
shutdown(fd, SHUT_WR); // 不再发送,但仍可接收
shutdown(fd, SHUT_RD); // 不再接收

常见场景:

  • 客户端发送完请求后,用 SHUT_WR 告诉服务端请求结束
  • 服务端继续返回响应

但很多应用协议并不依赖 TCP 半关闭,而是在协议层定义结束标记。
在事件驱动服务端里,是否允许半关闭要和连接状态机一起设计。


12. 常见错误码速记

错误码 场景 处理
EINTR 被信号打断 通常重试
EAGAIN/EWOULDBLOCK 非阻塞下暂时不可读/写/接 等下一次事件
EINPROGRESS 非阻塞 connect 正在进行 等可写后查 SO_ERROR
ECONNRESET 对端 reset 关闭连接
EPIPE 向已关闭连接写 关闭连接,避免 SIGPIPE
ETIMEDOUT 超时 关闭或重试
ECONNREFUSED 对端端口未监听 连接失败
EMFILE 进程 fd 用尽 高优先级故障
ENFILE 系统 fd 用尽 系统级故障

最重要的是:

  • EAGAIN 不等于失败
  • EINTR 不等于失败
  • n == 0 是对端关闭,不是暂时没数据

13. backlog、连接队列和 fd 上限

listen(fd, backlog) 里的 backlog 不是“最大并发连接数”。
它更接近内核已完成连接队列的长度提示。

高并发服务端要同时关注:

  • listen() backlog
  • net.core.somaxconn
  • ulimit -n
  • 进程最大 fd 数
  • accept() 是否及时

如果 accept() 不及时,即使业务线程很空,也可能表现为客户端连接超时或被拒绝。


14. Socket 编程常见坑

14.1 忘记检查返回值

系统调用失败非常正常,不检查错误码就是把 bug 延后。

14.2 TCP 当消息协议用

TCP 是字节流,必须设计应用层消息边界。

14.3 非阻塞写不处理部分发送

一次 send() 可能只发送一部分。

14.4 对 SIGPIPE 没处理

向关闭连接写数据可能让进程收到 SIGPIPE
Linux 下可以用 MSG_NOSIGNAL 或忽略该信号。

14.5 SO_REUSEADDRbind() 后才设置

很多 socket option 必须在 bind()connect() 前设置才有意义。

14.6 不限制连接和缓冲区

高并发服务端必须有上限:

  • 最大连接数
  • 每连接输入缓冲上限
  • 每连接输出缓冲上限
  • 请求大小上限
  • 空闲超时

15. 一页总结

Socket 基础最值得记住的是:

  1. socket 是协议栈背后的 fd
  2. TCP 服务端主线是 socket -> bind -> listen -> accept -> recv/send
  3. TCP 客户端主线是 socket -> connect -> send/recv
  4. TCP 是字节流,应用层必须处理消息边界
  5. 非阻塞 I/O 的核心是正确处理 EAGAIN/EINTR/EINPROGRESS
  6. send() 可能部分成功,recv()==0 表示对端关闭
  7. socket options 要在正确时机设置,并理解它们解决的问题

如果只记一句:

Linux 高性能服务器的底层不是某个框架,而是把 TCP socket 的状态、错误码、缓冲区和协议边界处理正确。


16. 参考资料

  1. Linux man-pages: socket
    https://man7.org/linux/man-pages/man2/socket.2.html

  2. Linux man-pages: bind
    https://man7.org/linux/man-pages/man2/bind.2.html

  3. Linux man-pages: listen
    https://man7.org/linux/man-pages/man2/listen.2.html

  4. Linux man-pages: accept
    https://man7.org/linux/man-pages/man2/accept.2.html

  5. Linux man-pages: tcp
    https://man7.org/linux/man-pages/man7/tcp.7.html

STL 容器、迭代器与算法实践

时间:2026/05/04

关键词:容器选择、迭代器失效、标准算法、比较器、查找、排序、erase-remove、复杂度
核心目标:建立工程里选择容器和使用标准算法的基本判断,不把 STL 只当成“会 push_back 的工具箱”。


1. 为什么还要单独学 STL 容器和算法

现代 C++ 里,很多代码质量问题不是语法问题,而是:

  • 容器选错
  • 复杂度误判
  • 迭代器失效
  • 手写循环替代标准算法
  • 比较器写错导致排序或查找出问题

标准库容器和算法的价值不只是“少写代码”,而是:

  • 让意图更明确
  • 让复杂度更可预期
  • 让边界条件更少出错

2. 容器选择先看访问模式

常见容器可以先这样粗略分类:

容器 适合场景 典型代价
std::vector 连续存储、随机访问、尾部追加 中间插删慢,扩容会搬迁
std::deque 两端插删、随机访问 不保证整体连续
std::list 已有迭代器位置的频繁插删 缓存不友好,随机访问慢
std::map 有序键值、稳定迭代顺序 红黑树,查找 O(log n)
std::unordered_map 哈希查找、平均快速访问 无序,rehash 会失效
std::set 有序唯一集合 插入查找 O(log n)
std::unordered_set 哈希唯一集合 依赖 hash 质量

经验上:

  • 默认先考虑 std::vector
  • 需要按 key 查找时考虑 unordered_map / map
  • 不要因为“中间插删”就马上用 list

很多时候 vector 即使中间移动元素,也可能因为缓存友好而比链表快。


3. vector 为什么经常是默认答案

vector 的优势来自连续内存:

  • 遍历快
  • 随机访问快
  • 缓存友好
  • 容易和 C API 交互
1
2
3
4
5
6
7
8
#include <vector>

std::vector<int> xs;
xs.reserve(1000);

for (int i = 0; i < 1000; ++i) {
xs.push_back(i);
}

如果你大概知道元素数量,reserve 通常是最简单有效的优化。

需要注意:

  • reserve 改 capacity,不改 size
  • resize 改 size,会真的构造元素
  • 扩容会让原来的指针、引用、迭代器失效

4. 迭代器失效规则要主动记

迭代器失效是 STL 里非常常见的 bug 来源。

4.1 vector

发生扩容时:

  • 所有迭代器失效
  • 所有指针和引用也失效

中间 insert/erase 时:

  • 插入点或删除点之后的迭代器通常失效

4.2 deque

deque 的失效规则比 vector 更复杂。
如果代码依赖长期保存迭代器,要查清具体操作的规则。

4.3 map / set

节点式有序容器一般比较稳定:

  • 插入通常不影响已有迭代器
  • 删除某个元素只让指向该元素的迭代器失效

4.4 unordered_map / unordered_set

rehash 时:

  • 迭代器会失效

但指向元素的引用和指针通常仍然有效,前提是元素没有被删除。


5. 删除元素:erase-remove 惯用法

vector 删除满足条件的元素,经典写法是:

1
2
3
4
5
6
7
8
9
10
11
#include <algorithm>
#include <vector>

void remove_even(std::vector<int>& xs) {
xs.erase(
std::remove_if(xs.begin(), xs.end(), [](int x) {
return x % 2 == 0;
}),
xs.end()
);
}

std::remove_if 不会真的缩短容器,它只是把要保留的元素前移,并返回新的逻辑尾部。
真正删除尾部多余元素的是 erase

C++20 起可以直接用:

1
2
3
4
5
#include <vector>

std::erase_if(xs, [](int x) {
return x % 2 == 0;
});

更短,也更不容易写错。


6. 优先使用标准算法表达意图

与其写:

1
2
3
4
5
6
7
bool found = false;
for (int x : xs) {
if (x == target) {
found = true;
break;
}
}

不如写:

1
2
3
#include <algorithm>

bool found = std::find(xs.begin(), xs.end(), target) != xs.end();

常用算法:

算法 用途
std::find 查找等于某值的元素
std::find_if 查找满足条件的元素
std::any_of 是否至少一个满足
std::all_of 是否全部满足
std::none_of 是否全部不满足
std::count_if 统计满足条件的数量
std::sort 排序
std::stable_sort 稳定排序
std::lower_bound 有序区间二分下界
std::partition 按条件分区

算法名字本身就是文档。


7. 排序和比较器

std::sort 要求比较器满足严格弱序。

正确写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <algorithm>
#include <string>
#include <vector>

struct User {
int id;
std::string name;
};

void sort_users(std::vector<User>& users) {
std::sort(users.begin(), users.end(), [](const User& a, const User& b) {
return a.id < b.id;
});
}

危险写法:

1
return a.id <= b.id; // 错

比较器不是“是否排在前面或相等”,而是:

a 是否严格排在 b 前面。

如果比较器违反规则,排序结果可能不稳定,甚至触发未定义行为。


8. 二分查找的前提

std::lower_boundstd::binary_search 的前提是:

  • 区间已经按同一规则有序
1
2
3
4
5
6
#include <algorithm>
#include <vector>

bool contains_sorted(const std::vector<int>& xs, int value) {
return std::binary_search(xs.begin(), xs.end(), value);
}

如果区间没有排序,二分查找没有意义。

常见模式:

1
2
3
4
auto it = std::lower_bound(xs.begin(), xs.end(), value);
if (it != xs.end() && *it == value) {
// found
}

9. mapunordered_map 怎么选

优先问几个问题:

  1. 是否需要按 key 有序遍历?
  2. 是否需要范围查询?
  3. key 有没有稳定可靠的 hash?
  4. 是否关心最坏情况复杂度?

大致规则:

  • 需要顺序或范围查询:std::map
  • 只需要 key 到 value 的快速查询:std::unordered_map
  • 数据量很小:有时 vector<pair<K,V>> 线性查找也够用

小数据上不要盲目迷信哈希表。
哈希计算、桶、节点分配都不是免费的。


10. 不要滥用 operator[]

map / unordered_mapoperator[] 如果 key 不存在,会插入默认值。

1
2
std::unordered_map<std::string, int> count;
int n = count["missing"]; // 插入 {"missing", 0}

如果只是查询,优先用:

1
2
3
4
auto it = count.find("missing");
if (it != count.end()) {
// use it->second
}

C++20 可以用:

1
2
3
if (count.contains("missing")) {
// found
}

11. emplace 不是永远比 push_back

emplace_back 的价值是原位构造:

1
2
std::vector<std::string> names;
names.emplace_back(10, 'x');

但如果你已经有一个对象:

1
2
3
std::string s = "hello";
names.push_back(s);
names.push_back(std::move(s));

这时 push_back 更清楚。
不要把 emplace 当成“性能更强的 push”。


12. 常见坑

12.1 遍历时删除元素写错

删除元素后,原迭代器可能失效。
需要使用 erase 返回的新迭代器,或者使用 erase_if

12.2 对无序容器的遍历顺序有期待

unordered_map 不保证顺序。
不同平台、不同运行时、rehash 后顺序都可能变化。

12.3 保存 vector 元素地址后继续 push

后续扩容可能让地址悬空。

12.4 比较器写成 <=

排序比较器必须表达严格小于关系。

12.5 在小数据量上过度复杂化

几十个元素的查找,用简单 vector 可能已经非常好。


13. 一页总结

STL 容器和算法最值得记住的是:

  1. 默认优先考虑 vector,除非访问模式明确不适合
  2. 先看复杂度,再看数据布局和缓存
  3. 迭代器、引用、指针失效规则必须主动记
  4. 标准算法能更准确表达意图
  5. 排序比较器必须满足严格弱序
  6. map / unordered_map 的选择取决于是否需要有序和范围查询

如果只记一句:

容器选择不是背 API,而是把访问模式、生命周期和复杂度放在一起判断。


14. 参考资料

  1. cppreference: containers
    https://en.cppreference.com/w/cpp/container

  2. cppreference: algorithms
    https://en.cppreference.com/w/cpp/algorithm

  3. cppreference: iterator invalidation
    https://en.cppreference.com/w/cpp/container#Iterator_invalidation

编译模型、链接与 CMake 入门

时间:2026/05/04

关键词:头文件、源文件、声明、定义、链接、ODR、静态库、动态库、CMake、target
核心目标:理解 C++ 工程从源码到可执行文件的大致流程,避免头文件乱放、重复定义和 CMake 全局变量式写法。


1. 为什么现代 C++ 也必须懂编译模型

很多 C++ 工程问题表面上是“编译报错”,本质上是:

  • 声明和定义混乱
  • 头文件包含关系失控
  • 重复定义违反 ODR
  • 链接阶段找不到符号
  • CMake 里 include path 和 link library 没有按 target 管理

懂编译模型的价值是:

  • 知道错误发生在预处理、编译还是链接
  • 知道哪些内容该放头文件,哪些该放 .cpp
  • 知道库和可执行文件怎么组织

2. 从源码到程序的几个阶段

一个 C++ 文件大致经历:

  1. 预处理:展开 #include、宏、条件编译
  2. 编译:把翻译单元编译成目标文件
  3. 汇编:生成机器码形式的 .o / .obj
  4. 链接:把多个目标文件和库合成可执行文件或库

可以粗略理解成:

1
2
3
4
5
6
7
8
9
10
main.cpp + include 的头文件
-> 一个翻译单元
-> main.o

foo.cpp + include 的头文件
-> 一个翻译单元
-> foo.o

main.o + foo.o + libraries
-> app

头文件本身通常不会单独编译。
它是被各个 .cpp 包含后,成为不同翻译单元的一部分。


3. 声明和定义

声明告诉编译器:

有这个名字,它的类型长这样。

1
int add(int a, int b); // 函数声明

定义真正提供实体:

1
2
3
int add(int a, int b) {
return a + b;
}

变量也一样:

1
2
extern int global_count; // 声明
int global_count = 0; // 定义

常见组织方式:

1
2
3
include/math.hpp   -> 声明
src/math.cpp -> 定义
src/main.cpp -> 使用

4. 头文件里应该放什么

通常适合放头文件:

  • 类声明
  • 函数声明
  • 模板定义
  • inline 函数定义
  • constexpr 小函数
  • 常量声明或 inline constexpr 变量

通常不适合放头文件:

  • 普通全局变量定义
  • inline 普通函数定义
  • 大量不必要的实现细节
  • 会导致编译依赖爆炸的重型 include

错误例子:

1
2
// bad.hpp
int counter = 0; // 被多个 cpp include 后会重复定义

更合适:

1
2
3
4
5
// counter.hpp
extern int counter;

// counter.cpp
int counter = 0;

C++17 起,如果确实想在头文件定义全局常量,可以用:

1
inline constexpr int max_retry = 3;

5. ODR:一个定义规则

ODR 是:

One Definition Rule

粗略说:

  • 一个具有外部链接的实体,在整个程序里通常只能有一个定义
  • 类、模板、inline 函数可以出现在多个翻译单元,但定义必须一致

典型 ODR 问题:

1
2
3
4
// util.hpp
int twice(int x) {
return x * 2;
}

如果这个头文件被多个 .cpp 包含,就可能链接时报重复定义。

修正:

1
2
3
4
// util.hpp
inline int twice(int x) {
return x * 2;
}

或者:

1
2
3
4
5
6
7
// util.hpp
int twice(int x);

// util.cpp
int twice(int x) {
return x * 2;
}

6. 为什么模板通常写在头文件

模板不是普通函数。
编译器需要在使用点看到模板定义,才能根据具体类型实例化代码。

1
2
3
4
template <class T>
T max_value(T a, T b) {
return a < b ? b : a;
}

如果只把模板声明放头文件、定义放 .cpp,其他翻译单元通常没法实例化它。

所以模板库常见形态是:

  • 大量实现放在头文件
  • 或者放在 .ipp / .inl 后再被头文件 include

7. 链接错误怎么读

常见链接错误:

7.1 undefined reference / undefined symbol

意思是:

  • 编译器看到了声明
  • 链接器找不到定义

常见原因:

  • .cpp 没加入构建
  • 忘记链接某个库
  • 函数签名声明和定义不一致
  • 模板定义不可见

7.2 duplicate symbol / multiple definition

意思是:

  • 同一个外部符号有多个定义

常见原因:

  • 普通函数定义写进头文件但没加 inline
  • 全局变量定义写进头文件
  • 同一个 .cpp 被错误地编进多个目标

8. 静态库和动态库

静态库:

  • Linux/macOS 常见 .a
  • Windows 常见 .lib
  • 链接时把需要的目标代码合进最终产物

动态库:

  • Linux .so
  • macOS .dylib
  • Windows .dll
  • 程序运行时加载库代码

工程实践里要关心:

  • 头文件提供编译期声明
  • 库文件提供链接期或运行期实现
  • ABI 边界要谨慎暴露 STL 类型、异常、内存分配策略

9. CMake 的核心是 target

现代 CMake 不建议把所有配置堆进全局变量。
更推荐围绕 target 写:

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
cmake_minimum_required(VERSION 3.20)
project(demo LANGUAGES CXX)

add_library(core
src/math.cpp
)

target_include_directories(core
PUBLIC
include
)

target_compile_features(core
PUBLIC
cxx_std_20
)

add_executable(app
src/main.cpp
)

target_link_libraries(app
PRIVATE
core
)

这里的依赖关系是:

1
app -> core

core 的 public include path 会传递给链接它的 target。


10. PUBLIC / PRIVATE / INTERFACE

这三个词是现代 CMake 的核心。

关键字 当前 target 使用 依赖当前 target 的别人使用
PRIVATE
PUBLIC
INTERFACE

例子:

1
2
3
4
target_include_directories(core
PUBLIC include
PRIVATE src
)

含义:

  • core 自己能 include includesrc
  • 依赖 core 的 target 只能继承 include

经验规则:

  • 头文件里需要暴露给使用者的 include path:PUBLIC
  • 只有 .cpp 内部使用的 include path:PRIVATE
  • header-only 库:常用 INTERFACE

11. 一个常见工程结构

1
2
3
4
5
6
7
8
9
10
project/
CMakeLists.txt
include/
demo/
math.hpp
src/
math.cpp
main.cpp
tests/
math_test.cpp

头文件:

1
2
3
4
5
6
7
8
// include/demo/math.hpp
#pragma once

namespace demo {

int add(int a, int b);

}

实现:

1
2
3
4
5
6
7
8
9
10
// src/math.cpp
#include "demo/math.hpp"

namespace demo {

int add(int a, int b) {
return a + b;
}

}

使用:

1
2
3
4
5
6
// src/main.cpp
#include "demo/math.hpp"

int main() {
return demo::add(1, 2);
}

12. include guard 和 #pragma once

传统 include guard:

1
2
3
4
5
6
#ifndef DEMO_MATH_HPP
#define DEMO_MATH_HPP

int add(int a, int b);

#endif

现代工程里也常用:

1
#pragma once

两者目的都是避免同一个头文件在一个翻译单元内被重复包含。
注意它们解决的是“重复包含”,不是跨多个 .cpp 的重复定义。


13. 降低编译依赖

C++ 大项目编译慢,常常是 include 依赖太重。

常见做法:

  • 头文件少 include,能前向声明就前向声明
  • 实现细节放 .cpp
  • 大型第三方头只在 .cpp 包含
  • 公共头文件保持稳定

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// widget.hpp
#pragma once

#include <memory>

class Impl;

class Widget {
public:
Widget();
~Widget();

private:
std::unique_ptr<Impl> impl_;
};

这就是常说的 Pimpl 思路。
它能减少头文件暴露的实现细节,但会引入一次间接访问和动态分配成本。


14. 常见坑

14.1 把普通函数定义放头文件

如果没有 inline,多个翻译单元包含后可能重复定义。

14.2 .cpp 没加入 CMake target

这通常会导致 undefined symbol。

14.3 include path 靠全局变量到处扩散

现代 CMake 更推荐 target 管理依赖。

14.4 头文件包含太重

会让一点小改动触发大量重编译。

14.5 混淆编译错误和链接错误

编译错误通常发生在单个翻译单元。
链接错误通常发生在多个目标文件合并时。


15. 一页总结

这篇最值得记住的是:

  1. .cpp 加上它 include 的头文件形成翻译单元
  2. 声明让编译通过,定义让链接通过
  3. 普通函数和全局变量定义不要随便放头文件
  4. 模板通常需要在使用点可见,所以常放头文件
  5. 现代 CMake 应围绕 target 管理 include、features 和 link
  6. PUBLIC / PRIVATE / INTERFACE 表达依赖是否传递

如果只记一句:

C++ 工程不是把文件堆在一起编译,而是把翻译单元、符号和 target 依赖组织清楚。


16. 参考资料

  1. cppreference: translation phases
    https://en.cppreference.com/w/cpp/language/translation_phases

  2. cppreference: definitions and ODR
    https://en.cppreference.com/w/cpp/language/definition

  3. CMake: target_include_directories
    https://cmake.org/cmake/help/latest/command/target_include_directories.html

  4. CMake: target_link_libraries
    https://cmake.org/cmake/help/latest/command/target_link_libraries.html

测试、调试与 Sanitizer 工具链

时间:2026/05/04

关键词:单元测试、集成测试、断言、日志、调试器、AddressSanitizer、UBSan、TSan、CI
核心目标:把“代码看起来对”变成“有测试、有诊断、有工具能抓问题”的工程流程。


1. 为什么 C++ 更需要工具链意识

C++ 的自由度很高,也意味着很多错误不会自动变成清晰异常:

  • 越界访问
  • use-after-free
  • 数据竞争
  • 未定义行为
  • 资源泄漏
  • ODR 或链接问题

所以现代 C++ 实践里,测试和诊断工具不是附属品,而是基本能力。

一个比较健康的本地开发流程是:

  1. 写小而明确的单元测试
  2. Debug 模式开启断言和诊断
  3. 定期跑 Sanitizer 版本
  4. CI 固定跑核心测试集

2. 测试分层

常见测试可以粗略分三层:

类型 目标 特点
单元测试 验证一个函数或类 快、边界清楚
集成测试 验证多个模块协作 更接近真实路径
回归测试 固定历史 bug 防止问题再次出现

不要一上来只写“大而全”的测试。
越底层的逻辑,越适合用小测试钉住行为。


3. 一个最小测试例子

即使不用测试框架,也可以先写最小可运行测试:

1
2
3
4
5
6
7
8
9
10
#include <cassert>

int add(int a, int b) {
return a + b;
}

int main() {
assert(add(1, 2) == 3);
assert(add(-1, 1) == 0);
}

这种测试很朴素,但比“手动运行看一眼输出”可靠。

工程里常用测试框架:

  • GoogleTest
  • Catch2
  • doctest

框架的价值是:

  • 更好的失败信息
  • 测试组织
  • fixture
  • 参数化测试
  • 和 CI 集成更自然

4. 测试什么

优先测试这些东西:

  • 边界条件
  • 空输入
  • 错误路径
  • 所有权转移
  • 异常或错误返回
  • 并发关闭流程
  • 之前出过 bug 的路径

例子:

1
2
3
4
5
6
7
8
#include <optional>
#include <string_view>

std::optional<int> parse_digit(std::string_view s) {
if (s.size() != 1) return std::nullopt;
if (s[0] < '0' || s[0] > '9') return std::nullopt;
return s[0] - '0';
}

至少应该覆盖:

  • "0"
  • "9"
  • ""
  • "12"
  • "x"

5. 断言的作用

断言适合检查:

  • 前置条件
  • 内部不变量
  • 理论上不该发生的状态
1
2
3
4
5
6
7
#include <cassert>
#include <vector>

int get_first(const std::vector<int>& xs) {
assert(!xs.empty());
return xs.front();
}

断言不是错误处理。
如果空输入是正常业务失败,应该返回错误或抛异常,而不是只写 assert

注意:

  • 定义 NDEBUG 后,标准 assert 会被移除
  • 不要把有副作用的表达式写进 assert

6. 日志和调试器各自解决什么

日志适合:

  • 线上或长时间运行问题
  • 记录关键状态转移
  • 还原错误发生前后的上下文

调试器适合:

  • 本地复现
  • 观察变量
  • 单步执行
  • 查看调用栈

不要把日志当成测试,也不要用调试器代替可重复测试。
测试负责固定行为,日志和调试器负责定位问题。


7. AddressSanitizer:先抓内存错误

AddressSanitizer 常用于发现:

  • 越界访问
  • use-after-free
  • double-free
  • 部分内存泄漏问题

常见编译方式:

1
2
clang++ -std=c++20 -g -O1 -fno-omit-frame-pointer -fsanitize=address main.cpp -o app
./app

建议加上:

1
-fno-omit-frame-pointer

这样栈回溯通常更清楚。

如果需要 leak 检测:

1
ASAN_OPTIONS=detect_leaks=1 ./app

8. UndefinedBehaviorSanitizer

UBSan 常用于发现未定义行为,例如:

  • 有符号整数溢出
  • 非法类型转换
  • 错误对齐访问
  • 除零
  • 某些无效 enum 值

常见编译方式:

1
2
clang++ -std=c++20 -g -O1 -fsanitize=undefined main.cpp -o app
./app

也可以和 ASan 一起用:

1
clang++ -std=c++20 -g -O1 -fno-omit-frame-pointer -fsanitize=address,undefined main.cpp -o app

很多 UB 在普通运行时看不出问题,但会让优化器基于错误假设重写代码。
所以 UBSan 对 C++ 很有价值。


9. ThreadSanitizer

TSan 用于发现数据竞争:

1
2
clang++ -std=c++20 -g -O1 -fsanitize=thread main.cpp -o app
./app

适合检查:

  • 非原子共享变量并发读写
  • 锁保护不一致
  • 错误的对象发布

注意:

  • TSan 运行开销较大
  • 不建议和 ASan 在同一个构建里混用
  • 对某些平台和第三方库支持有限

并发 bug 很难靠肉眼检查完整,TSan 是非常重要的辅助工具。


10. Debug / Release / RelWithDebInfo

常见构建类型:

类型 特点
Debug 无优化或低优化,调试友好
Release 高优化,性能接近发布
RelWithDebInfo 带调试信息的优化构建

排查性能或线上问题时,RelWithDebInfo 很有用:

  • 接近真实优化路径
  • 保留栈信息和符号

不要只在 Debug 下测试。
有些 UB、时序问题、未初始化问题会在 Release 下更容易暴露。


11. CMake 里加测试

一个最小结构:

1
2
3
4
5
6
7
8
9
10
11
12
enable_testing()

add_executable(math_test
tests/math_test.cpp
)

target_link_libraries(math_test
PRIVATE
core
)

add_test(NAME math_test COMMAND math_test)

运行:

1
ctest --test-dir build --output-on-failure

如果使用 GoogleTest,通常还会用它提供的测试发现工具。
但底层原则不变:

  • 测试本身也是一个 target
  • 测试链接被测库
  • ctest 统一运行测试

12. CMake 里加 Sanitizer 选项

小项目可以先写得简单:

1
2
3
4
5
6
option(ENABLE_ASAN "Enable AddressSanitizer" OFF)

if(ENABLE_ASAN)
add_compile_options(-fsanitize=address -fno-omit-frame-pointer)
add_link_options(-fsanitize=address)
endif()

更工程化的写法是把 Sanitizer 配置绑定到具体 target,避免污染第三方库。

例如:

1
2
target_compile_options(app PRIVATE -fsanitize=address -fno-omit-frame-pointer)
target_link_options(app PRIVATE -fsanitize=address)

不要只加编译选项,链接阶段也要加对应 sanitizer。


13. 最小 CI 检查清单

一个实用的 C++ CI 至少可以包含:

  1. 普通 Debug 构建
  2. 普通 Release 或 RelWithDebInfo 构建
  3. 单元测试
  4. ASan + UBSan 测试
  5. 关键并发模块定期跑 TSan

如果项目更成熟,还可以加:

  • clang-tidy
  • clang-format 检查
  • 覆盖率
  • benchmark 回归
  • 包管理和依赖版本锁定

先把最关键的测试和 sanitizer 跑起来,比一开始追求全套流程更重要。


14. 常见坑

14.1 只测正常路径

错误路径和边界条件更容易藏 bug。

14.2 把 assert 当成用户输入校验

Release 下断言可能被移除。
可恢复错误应该走错误处理流程。

14.3 只在 Debug 下运行

Release 优化可能暴露完全不同的问题。

14.4 Sanitizer 只加了编译选项

链接阶段也需要对应选项。

14.5 把 TSan 报告轻易忽略

数据竞争不是“小概率问题”,而是未定义行为。


15. 一页总结

测试和工具链最值得记住的是:

  1. 单元测试负责固定小范围行为
  2. 回归测试负责防止历史 bug 回来
  3. 断言检查内部不变量,不替代错误处理
  4. ASan 抓内存错误,UBSan 抓未定义行为,TSan 抓数据竞争
  5. Debug 和 Release 都要测
  6. CMake 里测试和 sanitizer 最好按 target 管理

如果只记一句:

现代 C++ 工程要靠测试、断言、日志、调试器和 Sanitizer 共同兜底,不能只靠“我看代码应该没问题”。


16. 参考资料

  1. Clang AddressSanitizer
    https://clang.llvm.org/docs/AddressSanitizer.html

  2. Clang UndefinedBehaviorSanitizer
    https://clang.llvm.org/docs/UndefinedBehaviorSanitizer.html

  3. Clang ThreadSanitizer
    https://clang.llvm.org/docs/ThreadSanitizer.html

  4. CMake: testing
    https://cmake.org/cmake/help/latest/manual/ctest.1.html

std::pmr 与内存池

时间:2026/05/04

关键词:std::pmr、内存池、memory_resourcemonotonic_buffer_resourcepool_resource、分配开销、局部性
核心目标:理解如何把 STL 容器的内存来源换成更适合高性能场景的资源。


1. 为什么内存分配会影响性能

很多 C++ 程序的慢,不一定慢在计算本身,而是慢在频繁分配:

  • 每次 new/delete 都可能进入通用堆分配器
  • 小对象分配容易带来额外元数据和碎片
  • 分配释放可能涉及锁或线程缓存
  • 分散的堆对象会降低缓存命中率

例如:

1
2
3
4
5
std::vector<std::string> names;

for (int i = 0; i < n; ++i) {
names.push_back(make_name(i));
}

这里至少可能有两类分配:

  • vector 自己扩容
  • string 内容超过小字符串优化时单独分配

高性能场景里常见思路是:

  • 已知规模时先 reserve
  • 减少对象数量和间接访问
  • 把一批生命周期相同的对象放进同一个内存资源

std::pmr 就是标准库提供的这类工具。


2. 传统 allocator 的问题

普通容器的 allocator 是模板参数:

1
std::vector<int, MyAllocator<int>> v;

这意味着:

  • 分配策略参与类型
  • 换 allocator 后容器类型也变了
  • 状态型 allocator 写起来容易复杂

比如:

1
2
std::vector<int> a;
std::vector<int, MyAllocator<int>> b;

ab 是两个不同类型。
如果大量接口都暴露这种类型,工程复杂度会很快上升。


3. std::pmr 的核心想法

pmr 全称是:

polymorphic memory resource

它把“从哪里分配内存”抽象成一个运行时对象:

1
std::pmr::memory_resource*

常见组件:

组件 作用
std::pmr::memory_resource 内存资源基类
std::pmr::polymorphic_allocator<T> 把资源接到标准容器 allocator 接口上
std::pmr::vector<T> 使用 polymorphic_allocator 的 vector 别名
std::pmr::string 使用 pmr allocator 的 string 别名
std::pmr::monotonic_buffer_resource 适合批量分配、整体释放
std::pmr::unsynchronized_pool_resource 非线程同步的池资源
std::pmr::synchronized_pool_resource 带同步的池资源

所以 pmr 的重点不是“换一个容器”,而是:

容器接口基本不变,但内存来源可以由外部资源控制。


4. 第一个 pmr::vector 例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <array>
#include <cstddef>
#include <iostream>
#include <memory_resource>
#include <vector>

int main() {
std::array<std::byte, 1024> buffer{};
std::pmr::monotonic_buffer_resource pool{
buffer.data(),
buffer.size()
};

std::pmr::vector<int> xs{&pool};

for (int i = 0; i < 10; ++i) {
xs.push_back(i);
}

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

这里的关键是:

  • buffer 是一块预先准备好的内存
  • pool 从这块 buffer 中切内存
  • xs 的元素存储区优先来自 pool
  • 如果 buffer 不够,默认会继续向上游资源申请

默认上游资源通常是:

1
std::pmr::get_default_resource()

它最终一般还是会走普通堆分配。


5. memory_resource 是真正的分配策略

memory_resource 的核心接口可以粗略理解为:

1
2
3
4
5
6
class memory_resource {
public:
void* allocate(std::size_t bytes, std::size_t alignment);
void deallocate(void* p, std::size_t bytes, std::size_t alignment);
bool is_equal(const memory_resource& other) const noexcept;
};

实际标准接口底层是虚函数:

1
2
3
virtual void* do_allocate(std::size_t bytes, std::size_t alignment) = 0;
virtual void do_deallocate(void* p, std::size_t bytes, std::size_t alignment) = 0;
virtual bool do_is_equal(const memory_resource& other) const noexcept = 0;

这说明:

  • pmr 本身用了运行时多态
  • 容器不用知道具体资源类型
  • 换资源时不必改变容器类型

这里的虚调用通常不是主要瓶颈,因为一次分配本身就比一次普通函数调用贵得多。
真正要关注的是能不能减少分配次数、改善局部性、避免堆碎片。


6. polymorphic_allocator 做了什么

std::pmr::vector<T> 大致等价于:

1
std::vector<T, std::pmr::polymorphic_allocator<T>>

polymorphic_allocator<T> 内部保存一个:

1
std::pmr::memory_resource*

当容器需要分配内存时,它会把请求转发给这个资源。

所以可以这样理解:

1
2
3
4
pmr::vector<int>
-> polymorphic_allocator<int>
-> memory_resource
-> 具体内存池 / arena / 堆

这和手写 allocator 的区别是:

  • allocator 类型固定
  • 资源对象可在运行期替换
  • 更适合在工程里传递容器和资源

7. monotonic_buffer_resource:线性增长的 arena

monotonic_buffer_resource 的行为很像 arena:

  • 每次分配只向前移动当前位置
  • 单个 deallocate 通常不真正回收
  • 整个资源析构或 release() 时统一释放

适合:

  • 一次请求中的临时对象
  • 一帧游戏逻辑中的临时数据
  • 一次解析、构图、搜索过程
  • 生命周期高度一致的一批对象

例子:

1
2
3
4
5
6
7
8
9
10
11
12
#include <memory_resource>
#include <string>
#include <vector>

struct RequestContext {
std::pmr::monotonic_buffer_resource scratch;
std::pmr::vector<std::pmr::string> words;

RequestContext()
: scratch(4096),
words(&scratch) {}
};

这类资源的好处是:

  • 分配路径短
  • 几乎没有单个对象释放成本
  • 同一批数据更可能靠近

但代价也很明确:

  • 单个对象释放不一定还给池
  • 长生命周期资源里容易堆积无用内存
  • 必须保证容器不比资源活得更久

8. pmr::string 和嵌套容器

pmr 容器最容易忽略的一点是:

外层容器用 pmr,不代表内层对象自动都用同一个资源。

例如:

1
std::pmr::vector<std::string> names{&pool};

这里:

  • vector 的数组存储用 pool
  • 但每个 std::string 自己的动态内存仍然用默认分配器

如果想让字符串内容也走同一个资源,应该用:

1
std::pmr::vector<std::pmr::string> names{&pool};

更完整的例子:

1
2
3
4
5
6
7
8
9
10
11
#include <memory_resource>
#include <string>
#include <vector>

int main() {
std::pmr::monotonic_buffer_resource pool{4096};

std::pmr::vector<std::pmr::string> names{&pool};
names.emplace_back("alpha");
names.emplace_back("beta");
}

这里 vector 的 allocator 会参与构造元素,所以新放入的 pmr::string 也会使用同一个资源。
如果你在外面单独创建 pmr::string,就要显式把资源传给它:

1
std::pmr::string name{"alpha", &pool};

9. pool resource:复用固定大小块

monotonic_buffer_resource 偏向“只涨不退”。
如果你的场景里有大量同尺寸或近似尺寸的小对象反复分配释放,可以考虑 pool resource。

标准库提供两类:

资源 特点
std::pmr::unsynchronized_pool_resource 不做内部同步,适合单线程或外部已同步
std::pmr::synchronized_pool_resource 内部带同步,可被多线程共享

例子:

1
2
3
4
5
6
7
8
9
10
11
#include <memory_resource>
#include <vector>

int main() {
std::pmr::unsynchronized_pool_resource pool;
std::pmr::vector<int> xs{&pool};

for (int i = 0; i < 1000; ++i) {
xs.push_back(i);
}
}

选择时可以粗略记:

  • 批量构建、整体释放:优先 monotonic_buffer_resource
  • 小对象反复分配释放:考虑 unsynchronized_pool_resource
  • 多线程共享同一个资源:才考虑 synchronized_pool_resource

在高性能代码里,更常见的方式是:

  • 每个线程一个无锁资源
  • 避免多个线程抢同一个 pool

10. 资源生命周期是第一安全边界

pmr 容器通常只保存:

1
memory_resource*

所以资源必须比使用它的容器活得更久。

错误示意:

1
2
3
4
5
6
7
8
9
#include <memory_resource>
#include <vector>

std::pmr::vector<int> bad() {
std::pmr::monotonic_buffer_resource pool{1024};
std::pmr::vector<int> xs{&pool};
xs.push_back(1);
return xs; // 错:返回后 xs 指向已经销毁的 pool
}

更安全的做法是把资源和容器放在同一个拥有者里,并保证资源先声明:

1
2
3
4
5
6
7
8
9
10
11
#include <memory_resource>
#include <vector>

struct WorkBuffer {
std::pmr::monotonic_buffer_resource pool;
std::pmr::vector<int> data;

WorkBuffer()
: pool(4096),
data(&pool) {}
};

成员析构顺序和声明顺序相反:

  • data 先析构
  • pool 后析构

这正好满足“容器先死,资源后死”。


11. 上游资源与禁止堆回退

默认情况下,monotonic_buffer_resource 初始 buffer 不够时,会向上游资源继续申请。

有时你希望测试或实时系统里明确禁止堆分配,可以用:

1
std::pmr::null_memory_resource()

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <array>
#include <cstddef>
#include <memory_resource>
#include <vector>

int main() {
std::array<std::byte, 128> buffer{};

std::pmr::monotonic_buffer_resource pool{
buffer.data(),
buffer.size(),
std::pmr::null_memory_resource()
};

std::pmr::vector<int> xs{&pool};
xs.reserve(16);
}

如果超出这块 buffer,分配会失败并抛出 std::bad_alloc
这很适合用来发现“以为没有堆分配,实际还有”的路径。


12. 和 reserve 的关系

pmr 不是 reserve 的替代品。

即使用了内存池,vector 扩容时仍然可能:

  • 分配新连续内存
  • 移动旧元素
  • 让旧指针、引用、迭代器失效

所以:

1
2
std::pmr::vector<int> xs{&pool};
xs.reserve(n);

依然是非常重要的优化。

可以把两者关系理解成:

  • reserve 减少容器扩容次数
  • pmr 降低每次分配的成本,并控制内存来源

13. 多线程使用建议

内存池和并行编程放在一起时,最容易踩的是共享资源争用。

常见策略:

  1. 每个线程一个 unsynchronized_pool_resource
  2. 每个任务一个 monotonic_buffer_resource
  3. 合并结果时再转移到长期存储

尽量避免:

  • 所有线程共享一个同步 pool
  • 临时对象跨线程持有另一个线程的资源
  • 在线程结束后继续使用线程局部资源里的对象

简单记忆:

pmr 优化的是分配路径,但共享同一个资源仍然可能把并行程序重新串行化。


14. 什么时候值得用 std::pmr

比较值得考虑:

  • profiler 显示分配占比明显
  • 临时对象数量很多
  • 对象生命周期天然成批
  • 需要把一组容器绑定到同一片内存
  • 想限制某段代码不能偷偷走堆分配

暂时不必急着用:

  • 数据量很小
  • 算法复杂度才是主要瓶颈
  • 容器已经提前 reserve
  • 没有明确的生命周期边界

高性能优化的一般顺序仍然是:

  1. 选对算法
  2. 选对数据布局
  3. 减少不必要的对象和拷贝
  4. 再考虑 allocator / pmr 这类内存来源优化

15. 常见坑

15.1 资源比容器先析构

这是 pmr 里最危险的问题。
容器内部只是保存资源指针,不拥有资源。

15.2 外层用了 pmr,内层没用

std::pmr::vector<std::string> 只解决外层数组分配,字符串内容仍可能走默认堆。
需要时应使用 std::pmr::string

15.3 把 monotonic_buffer_resource 当成通用释放器

它适合整体释放,不适合频繁单独归还对象。

15.4 以为内存池一定更快

不测量就下结论很危险。
如果瓶颈在算法、锁、缓存 miss 或 I/O,换 allocator 可能几乎没有收益。

15.5 多线程共享同一个 pool

共享资源可能引入锁争用。
很多并行场景中,线程本地资源更自然。


16. 一页总结

std::pmr 最值得记住的是:

  1. 它把容器的内存来源抽象成运行时 memory_resource
  2. pmr::vector<T> 本质上是使用 polymorphic_allocator<T> 的标准容器
  3. monotonic_buffer_resource 适合批量分配、整体释放
  4. pool resource 适合小对象复用,但要注意线程同步成本
  5. 资源生命周期必须覆盖所有使用它的容器

如果只记一句:

std::pmr 不是魔法加速器,而是让你把“一批对象从哪里分配、什么时候一起释放”这件事说清楚。


17. 参考资料

  1. cppreference: memory resource
    https://en.cppreference.com/w/cpp/memory/memory_resource

  2. cppreference: polymorphic allocator
    https://en.cppreference.com/w/cpp/memory/polymorphic_allocator

  3. cppreference: monotonic buffer resource
    https://en.cppreference.com/w/cpp/memory/monotonic_buffer_resource

  4. cppreference: pool resources
    https://en.cppreference.com/w/cpp/memory/synchronized_pool_resource

虚函数、多态与动态派发

时间:2026/05/04

关键词:虚函数、动态多态、vptrvtable、虚析构、对象切片、动态派发、去虚化、CRTP、std::variant
核心目标:理解虚函数的语义、底层直觉和性能代价,知道什么时候该用动态多态,什么时候该换成静态分发。


1. 虚函数解决什么问题

虚函数解决的是:

调用方只知道基类接口,但运行时对象可能是不同派生类。

例子:

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
#include <iostream>
#include <memory>
#include <vector>

struct Task {
virtual ~Task() = default;
virtual void run() = 0;
};

struct LoadTask : Task {
void run() override {
std::cout << "load\n";
}
};

struct SaveTask : Task {
void run() override {
std::cout << "save\n";
}
};

int main() {
std::vector<std::unique_ptr<Task>> tasks;
tasks.push_back(std::make_unique<LoadTask>());
tasks.push_back(std::make_unique<SaveTask>());

for (const auto& task : tasks) {
task->run();
}
}

这里调用方只面对:

1
Task

但实际执行哪个 run(),取决于对象的真实动态类型。


2. 普通函数和虚函数的区别

普通成员函数:

1
2
3
struct A {
void f();
};

调用时,编译器通常在编译期就知道目标函数:

1
2
A a;
a.f();

虚函数:

1
2
3
struct Base {
virtual void f();
};

通过基类指针或引用调用时:

1
2
3
void call(Base& b) {
b.f();
}

编译器不能只看 Base& 就确定具体执行哪个版本,因为 b 可能引用任何派生类对象。

这就是动态派发:

  • 编译期确定接口
  • 运行期确定实现

3. 纯虚函数与抽象类

纯虚函数写法:

1
2
3
4
struct Shape {
virtual ~Shape() = default;
virtual double area() const = 0;
};

含有纯虚函数的类是抽象类,不能直接实例化:

1
// Shape s; // 错

派生类需要实现这个接口:

1
2
3
4
5
6
7
8
9
struct Circle : Shape {
double r;

explicit Circle(double radius) : r(radius) {}

double area() const override {
return 3.1415926 * r * r;
}
};

这里的 override 很重要,它能让编译器检查你是否真的重写了基类虚函数。


4. overridefinal

重写虚函数时推荐总是写 override

1
2
3
4
5
6
7
struct Base {
virtual void update(int dt);
};

struct Derived : Base {
void update(int dt) override;
};

如果参数写错:

1
2
3
struct Bad : Base {
void update(double dt) override; // 编译报错
};

这能避免“以为重写了,实际新增了一个函数”的问题。

final 表示不允许继续重写:

1
2
3
struct Leaf final : Base {
void update(int dt) override;
};

或者只禁止某个虚函数继续被重写:

1
2
3
struct Mid : Base {
void update(int dt) final;
};

在某些情况下,final 也能帮助编译器做去虚化优化。


5. 虚析构为什么重要

如果一个类要被当作多态基类使用,析构函数通常必须是虚函数:

1
2
3
4
5
6
7
8
9
struct Base {
virtual ~Base() = default;
};

struct Derived : Base {
~Derived() {
// 释放 Derived 自己的资源
}
};

否则这样删除会出问题:

1
2
Base* p = new Derived;
delete p; // 基类析构非 virtual 时有未定义行为风险

现代 C++ 更常写成:

1
std::unique_ptr<Base> p = std::make_unique<Derived>();

但即使用智能指针,底层仍然会通过基类析构。
所以多态基类的析构函数依然要设计清楚。

经验规则:

  • 要通过基类指针删除对象:基类析构必须是 virtual
  • 不允许通过基类删除:可以把基类析构设为 protected 且非虚

6. 函数和对象在内存里大概在哪里

先建立一个底层直觉:

  • 函数代码通常位于程序的代码段
  • 普通对象里不会保存成员函数的机器码
  • 成员函数不是“每个对象各存一份”
  • 对象保存的是数据成员,以及实现多态所需的额外信息

例如:

1
2
3
4
struct Counter {
int value;
void inc() { ++value; }
};

每个 Counter 对象通常只需要保存:

1
value

inc() 的机器码在代码段里,所有 Counter 对象共享同一份函数代码。

调用成员函数时,可以粗略理解成编译器额外传了一个隐藏参数:

1
c.inc();

近似成:

1
Counter_inc(&c);

这个隐藏的对象指针就是常说的:

1
this

7. vptrvtable 的近似模型

对含虚函数的对象,主流实现通常会在对象中放一个隐藏指针:

1
vptr

它指向一张虚函数表:

1
vtable

虚函数表里保存函数地址。
可以粗略理解成:

1
2
3
4
5
对象
├── vptr -----> vtable
│ ├── &Derived::run
│ └── &Derived::~Derived
└── 数据成员

示例:

1
2
3
4
5
6
7
8
9
struct Base {
virtual ~Base() = default;
virtual void run() = 0;
};

struct Derived : Base {
int x = 0;
void run() override {}
};

一个 Derived 对象里通常会有:

  • 一个隐藏的 vptr
  • 数据成员 x

vtable 通常位于只读数据区附近,由编译器生成。
这些位置属于实现细节,不同编译器、ABI、多重继承场景都会有差异。

这部分只需要记住性能直觉:

虚函数不是把函数代码塞进对象,而是对象多带了一个“查表入口”。


8. 虚调用大致发生了什么

通过基类引用调用虚函数:

1
2
3
void call(Base& b) {
b.run();
}

底层可以粗略想成:

1
2
3
4
1. 从对象地址找到 vptr
2. 通过 vptr 找到 vtable
3. 从 vtable 中取出 run 对应的函数地址
4. 间接 call 到这个函数地址

也就是:

1
对象 -> vptr -> vtable -> 函数地址 -> 间接调用

相比普通直接调用,虚调用多了:

  • 一次或多次内存读取
  • 一次间接跳转
  • 编译器更难内联
  • CPU 分支预测更难判断目标

所以虚函数的成本通常不是“虚函数本身很慢”这么简单,而是:

  • 它阻碍了内联
  • 它让调用目标到运行期才确定
  • 它常常伴随指针间接访问和对象分散存储

9. 动态派发的真实性能成本

单次虚调用的额外开销通常不大。
真正容易放大的场景是:

1
2
3
for (auto& p : objects) {
p->update();
}

如果 objects 是一堆指向不同堆对象的指针,成本可能来自:

  • 指针追踪导致缓存 miss
  • 对象分散在堆上
  • 每次虚调用目标不同,间接分支预测困难
  • 无法内联 update()
  • 编译器难以做循环优化和向量化

在高性能热循环里,问题往往不是一个 virtual 关键字,而是这一整套数据布局:

1
2
3
4
vector<unique_ptr<Base>>
-> 堆对象
-> vptr
-> 间接函数地址

这比:

1
std::vector<Particle>

更难被 CPU 和编译器优化。


10. 对象切片

对象切片是多态初学者常见坑。

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

struct Base {
virtual ~Base() = default;
virtual void print() const {
std::cout << "Base\n";
}
};

struct Derived : Base {
int value = 42;

void print() const override {
std::cout << "Derived\n";
}
};

int main() {
Derived d;
Base b = d; // 切片:只拷贝 Base 子对象
b.print(); // 调用 Base::print
}

Base b = d 会创建一个真正的 Base 对象,派生类部分被切掉。
如果要保留动态类型,应使用:

  • Base&
  • Base*
  • std::unique_ptr<Base>
  • std::shared_ptr<Base>

11. 构造和析构中的虚调用

构造函数和析构函数里调用虚函数要非常小心:

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

struct Base {
Base() {
init();
}

virtual ~Base() = default;

virtual void init() {
std::cout << "Base init\n";
}
};

struct Derived : Base {
int x = 1;

void init() override {
std::cout << "Derived init: " << x << '\n';
}
};

构造 Derived 时,先构造 Base 子对象。
Base 构造期间,派生类部分还没构造好,所以虚调用不会派发到 Derived::init()

析构也类似:

  • 派生类部分先析构
  • 基类析构期间对象已经不再是完整的派生类

经验规则:

不要依赖构造函数或析构函数里的虚调用来触发派生类行为。


12. 去虚化:编译器什么时候能优化掉虚调用

去虚化指的是:

编译器证明某次虚调用的目标只有一个,于是把它变成直接调用,甚至内联。

常见条件:

  • 对象的动态类型在当前作用域里明确
  • 类或函数被标记为 final
  • 链接期优化能看到完整继承关系
  • 编译器根据上下文推断没有其他派生实现

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Base {
virtual ~Base() = default;
virtual int value() const = 0;
};

struct Derived final : Base {
int value() const override {
return 1;
}
};

int f() {
Derived d;
Base& b = d;
return b.value();
}

这里编译器很可能知道 b 实际引用 Derived,从而把调用优化掉。
但这不是语言层保证,而是优化器能力。


13. 什么时候适合用虚函数

适合:

  • 运行期才知道具体类型
  • 需要稳定插件接口
  • 类型集合经常扩展
  • 调用频率不在最热路径
  • 接口边界比极致性能更重要

典型例子:

  • 渲染后端接口
  • 文件系统抽象
  • 任务系统里的任务基类
  • 插件注册和对象工厂

这类场景中,虚函数提供的解耦价值往往大于一点动态派发成本。


14. 热循环里的替代方案

如果某段代码是极热路径,并且类型集合在编译期基本固定,可以考虑其他方式。

14.1 模板和策略类

1
2
3
4
5
6
template <class Integrator>
void step(Particle* ps, std::size_t n, const Integrator& integrator) {
for (std::size_t i = 0; i < n; ++i) {
integrator(ps[i]);
}
}

如果 Integrator 类型在编译期确定,编译器更容易内联。

14.2 CRTP

1
2
3
4
5
6
7
8
9
10
11
12
template <class Derived>
struct Updatable {
void update() {
static_cast<Derived*>(this)->update_impl();
}
};

struct Player : Updatable<Player> {
void update_impl() {
// ...
}
};

CRTP 是静态多态:

  • 没有虚表
  • 调用目标编译期确定
  • 适合类型集合比较固定的高性能代码

代价是:

  • 不能像 Base* 那样自然存放异构对象
  • 编译期耦合更强

14.3 std::variant

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

struct A {
void update() {}
};

struct B {
void update() {}
};

using Object = std::variant<A, B>;

void update_all(std::vector<Object>& objects) {
for (auto& obj : objects) {
std::visit([](auto& x) {
x.update();
}, obj);
}
}

variant 适合:

  • 类型集合有限
  • 希望对象直接存进连续容器
  • 不想为每个对象单独堆分配

代价是:

  • 新增类型时要改 variant 类型列表
  • 访问逻辑可能变复杂

15. 数据布局通常比虚调用本身更重要

假设有很多对象要更新:

1
std::vector<std::unique_ptr<Entity>> entities;

这给 CPU 的信息是:

  • 先遍历一段连续指针
  • 再跳到分散的堆对象
  • 再通过 vptr 找函数地址

如果改成按类型分组:

1
2
3
std::vector<Player> players;
std::vector<Enemy> enemies;
std::vector<Bullet> bullets;

可能带来的收益:

  • 对象连续
  • 循环更简单
  • 调用目标更稳定
  • 更容易内联和向量化

所以高性能代码里常见做法是:

  • 外层系统边界用虚函数表达抽象
  • 内层热循环用连续数组和静态分发

16. 常见坑

16.1 基类没有虚析构

只要可能通过基类指针删除派生对象,就要认真处理析构函数。

16.2 忘记写 override

这会让拼写错误、参数不一致、const 不一致的问题隐藏很久。

16.3 把对象按值放进基类容器

1
std::vector<Base> xs;

这会切片,不能保存派生类动态类型。

16.4 在构造函数里期待派生类虚函数被调用

构造和析构期间的动态类型规则和普通成员函数调用不同。

16.5 在热循环里混合大量不同动态类型

这可能让缓存、分支预测、内联和向量化一起变差。


17. 一页总结

虚函数最值得记住的是:

  1. 虚函数提供运行期多态,让调用方依赖稳定接口
  2. 函数代码通常在代码段,对象不保存函数本体
  3. 多态对象通常通过 vptr 指向 vtable
  4. 虚调用大致是“对象 -> vptr -> vtable -> 函数地址 -> 间接调用”
  5. 真正的性能问题常常来自无法内联、对象分散和缓存 miss
  6. 热路径可以考虑模板、CRTP、std::variant 或按类型分组的数据布局

如果只记一句:

虚函数是很好的接口工具,但在最热的数据循环里,要同时审视动态派发和对象布局。


18. 参考资料

  1. cppreference: virtual functions
    https://en.cppreference.com/w/cpp/language/virtual

  2. cppreference: override specifier
    https://en.cppreference.com/w/cpp/language/override

  3. cppreference: final specifier
    https://en.cppreference.com/w/cpp/language/final

  4. cppreference: variant
    https://en.cppreference.com/w/cpp/utility/variant

单例模式

时间:2026/05/03

关键词:Singleton、Meyers Singleton、线程安全初始化、std::call_once、初始化失败、重复初始化、析构顺序
核心目标:掌握 C++ 里最常见的单例写法,并能回答面试里关于线程安全、初始化失败和生命周期的问题。


1. 单例模式在解决什么问题

单例模式想解决的是:

  • 某个类在整个进程里只需要一个实例
  • 所有地方访问的是同一个对象
  • 对象创建和生命周期由类自己控制

常见场景:

  • 日志系统
  • 配置管理
  • 资源管理器
  • 全局 ID 生成器
  • 游戏里的全局服务入口

但要注意:

单例本质上是一种“受控的全局对象”,不要因为方便就到处用。

如果一个对象只是普通依赖,优先考虑构造函数传参、依赖注入或明确的所有权关系。


2. C++11 后最推荐的基础写法

最常见、最推荐的是函数局部静态变量,也叫 Meyers Singleton。
在同一个进程中,同一个函数内的 static 局部变量只初始化一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Singleton {
public:
static Singleton& instance() {
static Singleton inst;
return inst;
}

void do_something() {
// ...
}

private:
Singleton() = default;
~Singleton() = default;

Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;

Singleton(Singleton&&) = delete;
Singleton& operator=(Singleton&&) = delete;
};

调用:

1
Singleton::instance().do_something();

这段代码的重点:

  • 构造函数私有,外部不能随便创建
  • 拷贝和移动都删除,避免复制出第二个实例
  • instance() 里用 static Singleton inst
  • C++11 起,函数局部静态变量初始化是线程安全的

3. 为什么不推荐手写裸指针单例

老式写法经常是:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Singleton {
public:
static Singleton* instance() {
if (ptr_ == nullptr) {
ptr_ = new Singleton();
}
return ptr_;
}

private:
Singleton() = default;
static Singleton* ptr_;
};

问题很多:

  • 多线程下可能重复创建
  • 需要手动释放,容易内存泄漏
  • 释放时机难控制
  • 如果加锁写不好,还可能产生性能或竞态问题

所以现代 C++ 里,基础单例优先用函数局部静态变量。


4. 面试常问:单例是否线程安全

如果是 C++11 及之后的 Meyers Singleton:

1
static Singleton inst;

初始化本身是线程安全的。

也就是说:

  • 多个线程同时第一次调用 instance()
  • 只会有一个线程真正执行构造
  • 其他线程会等待初始化完成

但是要分清楚:

单例对象的“初始化线程安全”不等于“对象内部所有方法都线程安全”。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Counter {
public:
static Counter& instance() {
static Counter c;
return c;
}

void add() {
++value_;
}

private:
int value_ = 0;
};

这里 Counter 的创建是线程安全的,但多个线程同时调用 add() 仍然有数据竞争。

如果内部状态会被并发修改,仍然需要:

  • std::mutex
  • std::atomic
  • 或者更清晰的并发设计

5. 面试常问:单例初始化失败怎么办

初始化失败通常指构造函数里抛异常。

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

class Config {
public:
static Config& instance() {
static Config cfg;
return cfg;
}

private:
Config() {
if (!load_file()) {
throw std::runtime_error("load config failed");
}
}

bool load_file() {
return false;
}
};

如果 static Config cfg; 初始化时抛异常:

  • 当前这次 instance() 调用会把异常抛出去
  • 这个局部静态对象不会被视为初始化完成
  • 下一次再调用 instance() 时,会重新尝试初始化

也就是说,C++ 的局部静态变量初始化失败后不是永久失败,而是下次会重试。

面试回答可以这样说:

C++11 的函数局部静态变量初始化是线程安全的;如果构造过程抛异常,本次初始化失败,异常向外传播,下次进入该声明时会再次尝试初始化。

5.1 初始化失败要不要重试

这取决于业务语义。

适合重试的情况:

  • 配置文件短暂不可用
  • 网络资源暂时失败
  • 外部服务可能恢复

不适合无限重试的情况:

  • 程序启动参数错误
  • 必要文件不存在
  • 配置格式根本不合法

工程里可以选择:

  • 直接让异常向外抛,启动失败
  • 在外层捕获异常并打印日志
  • 提供显式 init(),让初始化失败变成可控返回值

6. 面试常问:重复初始化怎么办

“重复初始化”有两种情况。

6.1 多次调用 instance()

对于 Meyers Singleton:

1
2
auto& a = Singleton::instance();
auto& b = Singleton::instance();

这不会重复初始化。

第一次调用时构造对象,后面所有调用都返回同一个对象引用。

6.2 显式 init() 被调用多次

如果单例需要配置参数,就容易出现重复初始化问题。

错误倾向是:

1
2
Logger::instance("a.log");
Logger::instance("b.log");

第一次和第二次传了不同参数,到底该听谁的?这会让语义混乱。

更清晰的方式是拆成:

  • init(config):启动阶段显式初始化
  • instance():使用阶段只获取对象

示例:

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
#include <mutex>
#include <stdexcept>
#include <string>
#include <utility>

class Logger {
public:
static void init(std::string file) {
std::lock_guard<std::mutex> lk(mutex_);

if (initialized_) {
throw std::runtime_error("Logger already initialized");
}

file_ = std::move(file);
initialized_ = true;
}

static Logger& instance() {
if (!initialized_) {
throw std::runtime_error("Logger not initialized");
}

static Logger logger;
return logger;
}

private:
Logger() = default;

inline static std::mutex mutex_;
inline static bool initialized_ = false;
inline static std::string file_;
};

这类写法的核心是:

  • 重复初始化要么直接忽略,要么明确报错
  • 不要让不同配置悄悄覆盖已有配置
  • 初始化和使用阶段要有清晰边界

不过上面这版还有个细节:instance() 读取 initialized_ 时没有加锁。更严谨的工程代码要么也加锁,要么用 std::atomic<bool>,要么使用 std::call_once


7. 使用 std::call_once 的写法

如果不想依赖局部静态变量,或者要做更复杂的初始化,可以用 std::call_once

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <memory>
#include <mutex>

class Singleton {
public:
static Singleton& instance() {
std::call_once(flag_, [] {
ptr_ = std::unique_ptr<Singleton>(new Singleton());
});
return *ptr_;
}

private:
Singleton() = default;

inline static std::once_flag flag_;
inline static std::unique_ptr<Singleton> ptr_;
};

特点:

  • 初始化逻辑只会成功执行一次
  • 多线程同时调用时,只有一个线程执行初始化
  • 如果初始化函数抛异常,once_flag 不会被标记完成,下次会继续尝试

不过对于普通单例,Meyers Singleton 更简洁。


8. 面试常问:双重检查锁为什么容易出问题

经典写法类似:

1
2
3
4
5
6
if (ptr == nullptr) {
std::lock_guard<std::mutex> lk(mutex);
if (ptr == nullptr) {
ptr = new Singleton();
}
}

它叫 Double-Checked Locking。

问题在于:

  • 对象构造和指针赋值涉及内存可见性
  • 没有正确的原子操作和内存序,其他线程可能看到“指针非空但对象还没完全构造好”
  • 写对很麻烦,写错很隐蔽

现代 C++ 面试里可以直接说:

不建议手写双重检查锁。C++11 后用函数局部静态变量或 std::call_once 更简单、更安全。


9. 面试常问:单例什么时候销毁

Meyers Singleton 的对象是函数局部静态变量:

1
static Singleton inst;

它会在程序结束时自动析构。

但这里有一个经典问题:

静态对象析构顺序不容易控制。

如果多个全局对象或单例互相依赖,程序退出时可能出现:

  1. 单例 A 已经析构
  2. 单例 B 的析构函数里还想用 A
  3. 访问已经销毁的对象,产生未定义行为

应对方式:

  • 避免单例之间在析构阶段互相调用
  • 把释放逻辑放到明确的 shutdown() 阶段
  • 对某些进程级对象,接受“不主动析构”,让操作系统在进程退出时回收

有些日志系统会故意写成泄漏式单例:

1
2
3
4
static Logger& instance() {
static Logger* logger = new Logger();
return *logger;
}

这样对象不会在程序退出时自动析构,可以避开析构顺序问题。

但代价是:

  • 内存检查工具会看到泄漏
  • 资源释放不够优雅
  • 不适合所有场景

所以这是工程取舍,不是默认推荐写法。


10. 面试常问:单例能不能带参数

可以,但要小心。

不推荐这样:

1
2
Config& c1 = Config::instance("dev.yaml");
Config& c2 = Config::instance("prod.yaml");

因为第二次调用传入的参数通常不会生效,容易误导调用方。

更推荐:

1
2
Config::init("dev.yaml");
auto& config = Config::instance();

也就是:

  • 初始化参数只在启动阶段传一次
  • 之后使用时不再传参数
  • 重复初始化时明确报错

11. 单例的优缺点

优点:

  • 使用方便
  • 保证进程内只有一个实例
  • 适合管理全局唯一资源

缺点:

  • 本质上是全局状态
  • 容易隐藏依赖关系
  • 测试时不好替换
  • 生命周期复杂时容易踩坑
  • 多线程下内部状态仍然需要额外保护

面试里不要只说“单例简单方便”,最好补一句:

单例适合管理少数真正全局唯一的服务,但滥用会让依赖关系变隐式,降低可测试性。


12. 常见面试问题速答

12.1 C++ 单例怎么写最简单安全

用函数局部静态变量:

1
2
3
4
static Singleton& instance() {
static Singleton inst;
return inst;
}

C++11 后初始化线程安全。

12.2 如何防止创建多个实例

  • 构造函数私有
  • 删除拷贝构造
  • 删除拷贝赋值
  • 删除移动构造
  • 删除移动赋值

12.3 初始化失败怎么办

构造函数抛异常时,本次初始化失败,异常向外传播;下一次调用 instance() 会再次尝试初始化。

如果失败不可恢复,可以在程序启动阶段捕获异常并直接终止启动。

12.4 重复初始化怎么办

如果只是多次调用 instance(),不会重复初始化。

如果是显式 init(config) 被调用多次,要明确策略:

  • 要么幂等,重复相同配置直接返回
  • 要么报错
  • 不要悄悄覆盖已有配置

12.5 单例对象内部方法一定线程安全吗

不一定。

单例初始化线程安全,只代表对象创建过程安全。对象内部如果有共享可变状态,仍然要自己加锁或使用原子变量。

12.6 单例和全局变量有什么区别

单例可以控制创建时机、禁止复制、封装访问入口。
但它仍然带有全局状态的缺点。


13. 一页总结

现代 C++ 写单例优先记住:

  1. 用函数局部静态变量实现基础单例
  2. 私有构造,删除拷贝和移动
  3. C++11 后局部静态变量初始化线程安全
  4. 初始化失败抛异常后,下次调用会重试
  5. 重复初始化要有明确策略
  6. 单例创建安全不等于内部方法线程安全
  7. 小心析构顺序和隐藏依赖

如果只记一句:

单例不是“到处方便访问”的借口,而是“进程内确实只应该有一个实例”的受控设计。

Server 实现分析

time:2026_1_20

核心文件:

  • apps/server_main.cpp

服务端的职责可以概括为:

1
2
3
4
分配玩家 slot;
接收每个玩家按 tick 上传的输入;
用同一套 World::Step 推进唯一权威世界;
向客户端广播 ACK 和 State。

1. ClientConn

ClientConn 表示一个客户端连接。

关键字段:

字段 作用
addr 客户端 UDP 地址
inputBuf 按 tick 保存该玩家输入
lastInputTick 服务端已收到该玩家最大的输入 tick
lastAppliedTick 服务端上次实际用于模拟的输入 tick
lastApplied HoldLast 时复用的输入
assigned 是否已经分配 player slot
playerId 玩家编号,当前是 1 或 2
lastHeardSec 连接超时判断

服务端不相信客户端上传的位置,只接收输入。


2. ServerCtx

ServerCtx 是服务端运行态上下文。

关键字段:

字段 作用
sock UDP socket
clients addr key 到 ClientConn 的映射
playerKey player slot 到 addr key 的映射
world 服务端权威世界
tick 服务端权威 tick
started 是否开局
prevacc 固定时间步 accumulator
mazeSeed 当前对局迷宫种子

服务端的 world 是唯一权威状态来源。


3. 玩家 slot 分配

主要函数:

  • AssignSlot
  • GetPlayer
  • OnlineCount
  • KickPlayer

服务端用 UdpAddr::Key() 作为客户端身份 key。

流程:

1
2
3
4
收到 InputPacket
-> 根据 from.Key() 找 ClientConn
-> 如果未分配,则 AssignSlot
-> 写入该玩家 inputBuf

当前版本固定 2 人,第三个连接会被拒绝。


4. 开局同步

函数:

1
MaybeStartMatch()

当在线人数达到 kRequiredPlayers 后:

  1. 计算 startTick = tick + kStartDelayTicks
  2. 把服务端 tick 设置为 startTick
  3. 标记 started = true
  4. 给每个客户端发送 StartPacket

注意:

当前实现里的 kStartDelayTicks 更像 tick 编号偏移。服务端会直接跳到 startTick 并开局,不是墙钟意义上的倒计时等待。


5. 输入接收

收包函数:

1
OnUdp()

服务端只解析 InputPacket

收到包后会遍历 in->cmds

1
2
3
for cmd in inputPacket.cmds:
inputBuf.Put(cmd)
lastInputTick = max(lastInputTick, cmd.tick)

因为客户端每包带最近 K 帧输入,所以服务端可能重复收到同一个 tick 的输入。按 tick 写入环形缓冲即可覆盖同 tick 旧值。


6. 缺输入处理:HoldLast / Default

函数:

1
GetCmdForTick(ClientConn& cc, Tick tick)

逻辑:

  1. 如果 inputBuf.Get(tick) 有值,使用该输入。
  2. 如果没有,但上次输入仍在 kHoldInputTicks 窗口内,复用上一帧输入并改成当前 tick。
  3. 如果超过窗口,使用默认空输入。

这个策略用于抵抗 UDP 短暂丢包和延迟。

注意:

HoldLast 不能无限持续,否则玩家断线后会一直沿着旧方向移动。


7. 权威 tick 推进

服务端 OnTick 也使用 accumulator。

每个逻辑 tick:

1
2
3
4
5
6
7
8
9
10
11
12
cmds = []
for each player:
cmds.push_back(GetCmdForTick(player, serverTick))

world.Step(cmds, dt)
snap = world.Snapshot()
h = Hasher::Hash(snap)

send Ack every tick
send State every kStateEvery tick

tick++

当前 kStateEvery = 2,也就是每 2 tick 下发一次完整权威状态。


8. ACK 和 State

ACK

每 tick 发送。

包含:

  • playerId
  • serverTickProcessed
  • serverLastInputTick
  • serverStateHash

ACK 的重点是告诉客户端服务端处理进度。

State

按频率发送。

包含:

  • tick
  • mazeSeed
  • 玩家状态
  • 子弹状态
  • stateHash

State 用于客户端恢复权威快照并回滚重放。


9. 服务端注意事项

  • 服务端只能信任输入,不能信任客户端上传的最终状态。
  • 输入应校验 tick 范围,避免异常客户端写入过大 tick 覆盖环形缓冲。
  • 输入值应校验范围,例如 moveX/moveY 只允许 -1/0/1buttons 只允许合法位。
  • 迷宫如果只同步 seed,跨平台时要注意 RNG 和 shuffle 的确定性。
  • World::Step 必须是唯一权威推进入口,不要在网络层或渲染层偷偷改状态。
  • 如果扩展多人,需要同步修改 kMaxPlayers、State 编解码、渲染和压测。
  • Ack 不是完整状态,客户端不能只靠 ACK 回滚。
  • State 包太频繁会增加带宽,太稀疏会增加修正延迟,需要按玩法调参。

10. 权威状态回滚更新详细流程

这一节是服务端权威同步最关键的闭环。

先分清职责:

1
2
服务端:生产权威 State。
客户端:接收权威 State,把预测世界回滚到权威历史点,再重放本地输入追到当前 tick。

所以“权威状态回滚更新”不是服务端自己回滚,而是:

1
2
3
4
5
6
7
8
9
10
11
Server authoritative state

StatePacket

Client ApplyAuthoritativeState

World::Restore(auth)

Replay local input history

新的客户端预测世界

10.1 为什么需要这条链路

客户端为了手感会提前预测:

1
2
3
玩家按键
-> 客户端立即 World::Step
-> 画面马上移动

但服务端才是最终权威。

如果服务端稍后告诉客户端:

1
tick 120 的真实位置不是你预测的位置

客户端不能简单把当前画面拉回 tick 120,因为本地已经跑到更后面的 tick 了。

正确做法是:

1
2
3
恢复到服务端 tick 120 的权威快照
重新执行 tick 121 ~ 当前 tick 的本地输入
得到一个新的当前预测状态

这就是 rollback + replay。


10.2 服务端生成权威 State

服务端每个 tick 先收集输入,再推进唯一权威世界:

1
2
3
4
5
6
7
8
9
10
11
12
13
std::vector<InputCmd> cmds;
cmds.reserve(ServerCtx::kMaxPlayers);

for (uint8_t pid = 1; pid <= ServerCtx::kMaxPlayers; ++pid) {
ClientConn* p = GetPlayer(ctx, pid);
cmds.push_back(p ? GetCmdForTick(*p, ctx->tick)
: InputBuffer::DefaultForTick(ctx->tick));
}

ctx->world.Step(cmds, float(ServerCtx::dt));

auto snap = ctx->world.Snapshot();
uint64_t h = Hasher::Hash(snap);

这里的 snap 就是服务端权威快照。

注意:

  • 服务端只使用客户端上传的输入。
  • 服务端不会相信客户端上传的位置、HP、命中结果。
  • Hasher::Hash(snap) 用来给客户端做状态对账。

10.3 服务端把 Snapshot 压缩成 StatePacket

服务端不是直接发送 WorldSnapshot,而是构造网络包 StatePacket

关键代码:

1
2
3
4
5
6
7
8
lab::net::StatePacket st{};
st.playerId = pid;
st.tick = snap.tick;
st.mazeSeed = snap.mazeSeed;
st.playerCount = static_cast<uint8_t>(
std::min<size_t>(ServerCtx::kMaxPlayers, snap.players.size()));
st.projectileCount = static_cast<uint8_t>(
std::min<size_t>(snap.projectiles.size(), 255));

玩家状态会被量化后写入:

1
2
3
4
5
6
7
8
9
ps.x_mm = (int32_t)std::lround(wp.x * 1000.0f);
ps.v_mm = (int32_t)std::lround(wp.v * 1000.0f);
ps.y_mm = (int32_t)std::lround(wp.y * 1000.0f);
ps.vy_mm = (int32_t)std::lround(wp.vy * 1000.0f);
ps.hp = wp.hp;
ps.action = static_cast<uint8_t>(wp.action);
ps.shotCooldown = wp.shotCooldown;
ps.aimX = wp.aimX;
ps.aimY = wp.aimY;

子弹也会被写入:

1
2
3
4
5
6
pr.x_mm = (int32_t)std::lround(wp.x * 1000.0f);
pr.y_mm = (int32_t)std::lround(wp.y * 1000.0f);
pr.vx_mm = (int32_t)std::lround(wp.vx * 1000.0f);
pr.vy_mm = (int32_t)std::lround(wp.vy * 1000.0f);
pr.owner = wp.owner;
pr.life = wp.life;

最后写入权威 hash 并发送:

1
2
3
4
st.stateHash = h;

auto bytes = lab::net::EncodeState(st);
ctx->sock.SendTo(pc->addr, bytes);

当前服务端每 kStateEvery = 2 个 tick 发送一次完整 State。


10.4 客户端收到 State

客户端收包入口在 OnUdp

收到 State 后先做三件事:

  1. 如果还没有 localPlayerId,从 State 中记录。
  2. 如果这个 State 比已应用的权威 State 更旧,直接丢弃。
  3. 根据 State 更新远端玩家输入预测。

对应代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
if (auto st = lab::net::DecodeState(data, len)) {
if (ctx->localPlayerId == 0) {
ctx->localPlayerId = st->playerId;
}
if (ctx->lastAuthoritativeTick != 0 &&
st->tick <= ctx->lastAuthoritativeTick) {
return;
}

// 更新远端输入预测...
ApplyAuthoritativeState(*ctx, *st);
return;
}

旧 State 必须丢弃。

原因是 UDP 可能乱序。如果 tick 130 的 State 已经应用,之后才收到 tick 126 的旧 State,就不能让旧权威覆盖新权威。


10.5 客户端把 StatePacket 还原成权威 Snapshot

ApplyAuthoritativeState 会把网络包还原成 WorldSnapshot auth

核心逻辑:

1
2
3
4
5
6
7
8
9
WorldSnapshot auth = ctx.worldPred.Snapshot();
if (auth.mazeSeed != st.mazeSeed || auth.maze.empty()) {
ctx.worldPred.SetMazeSeed(st.mazeSeed);
auth = ctx.worldPred.Snapshot();
}

auth.tick = st.tick;
auth.mazeSeed = st.mazeSeed;
auth.players.resize(ClientCtx::kMaxPlayers);

然后还原玩家状态:

1
2
3
4
5
6
7
8
9
10
11
auth.players[i].x = FromMM(ps.x_mm);
auth.players[i].v = FromMM(ps.v_mm);
auth.players[i].y = FromMM(ps.y_mm);
auth.players[i].vy = FromMM(ps.vy_mm);
auth.players[i].hp = ps.hp;
auth.players[i].action = toAction(ps.action);
auth.players[i].facing = ps.facing;
auth.players[i].stateTimer = ps.stateTimer;
auth.players[i].shotCooldown = ps.shotCooldown;
auth.players[i].aimX = ps.aimX;
auth.players[i].aimY = ps.aimY;

还原子弹状态:

1
2
3
4
5
6
7
8
9
ProjectileState prd{};
prd.x = FromMM(pr.x_mm);
prd.y = FromMM(pr.y_mm);
prd.vx = FromMM(pr.vx_mm);
prd.vy = FromMM(pr.vy_mm);
prd.life = pr.life;
prd.owner = pr.owner;
prd.alive = pr.life > 0 ? 1 : 0;
auth.projectiles.push_back(prd);

注意:

StatePacket 里没有直接发送完整迷宫网格,只发送 mazeSeed。客户端会根据 seed 生成迷宫,然后 WorldSnapshot 中仍然保留迷宫网格用于恢复和 hash。


10.6 权威 hash 校验

客户端还原出 auth 后,会重新计算 hash:

1
2
3
4
5
6
const uint64_t authHash = Hasher::Hash(auth);
if (authHash != st.stateHash &&
ctx.lastHashMismatchTick != st.tick) {
ctx.hashMismatchCount++;
ctx.lastHashMismatchTick = st.tick;
}

如果这里 mismatch,说明:

  • 服务端和客户端还原出来的快照不同。
  • 或者某个字段没有正确进入网络包。
  • 或者迷宫 seed 生成结果不一致。
  • 或者 hash 覆盖字段和 StatePacket 字段不匹配。

这是定位状态分叉的重要工具。


10.7 判断是否计入 rollback

项目只用“本地玩家”的误差来判断是否增加 rollbackCount

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bool needRollback = false;
if (auto localOpt = ctx.stateHist.Get(st.tick)) {
const auto& local = *localOpt;

float dx = std::fabs(local.players[idx].x - auth.players[idx].x);
float dy = std::fabs(local.players[idx].y - auth.players[idx].y);

const bool hpDiff = local.players[idx].hp != auth.players[idx].hp;
const bool actionDiff = local.players[idx].action != auth.players[idx].action;
const bool groundDiff = local.players[idx].onGround != auth.players[idx].onGround;

needRollback = (dx > 0.15f) || (dy > 0.15f) ||
hpDiff || actionDiff || groundDiff;
}

为什么只比较本地玩家?

因为远端玩家本来就是客户端猜出来的。远端预测错很正常,如果把远端差异也算进 rollback,会导致 rollback 统计一直增长。

注意:

needRollback 只影响 rollbackCount 统计。

真正的 rebase + replay 不管 needRollback 是否为 true,都会执行。


10.8 写入权威快照并执行回滚重放

客户端会先把权威快照写入 stateHist

1
2
3
4
5
6
7
8
9
ctx.stateHist.Put(auth);
ctx.lastAuthoritativeTick = st.tick;

if (needRollback) {
ctx.rollbackCount++;
ctx.lastRollbackTick = st.tick;
}

RestoreAndReplay(ctx, auth);

这里有两个重点:

  1. stateHist.Put(auth) 会覆盖该 tick 原来的预测快照。
  2. RestoreAndReplay 会从权威历史点重新模拟到当前客户端 tick。

10.9 RestoreAndReplay 的完整流程

核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static void RestoreAndReplay(ClientCtx& ctx, const WorldSnapshot& auth) {
ctx.worldPred.Restore(auth);

for (Tick t = auth.tick + 1; t < ctx.tick; ++t) {
InputCmd localCmd =
ctx.localHist.Get(t).value_or(InputBuffer::DefaultForTick(t));

std::vector<InputCmd> remoteCmds(
ClientCtx::kMaxPlayers,
InputBuffer::DefaultForTick(t));

for (uint8_t pid = 1; pid <= ClientCtx::kMaxPlayers; ++pid) {
if (pid == ctx.localPlayerId) continue;
remoteCmds[pid - 1] = GetRemoteCmdForTick(ctx, pid, t);
}

auto cmds = BuildCmdVec(ctx.localPlayerId, localCmd, remoteCmds);
ctx.worldPred.Step(cmds, float(ClientCtx::dt));
ctx.stateHist.Put(ctx.worldPred.Snapshot());
}
}

可以拆成三步:

1
2
3
4
5
6
7
8
1. Restore(auth)
把预测世界恢复到服务端权威 tick。

2. Replay auth.tick + 1 到 ctx.tick - 1
用本地输入历史 + 远端预测输入重新推进。

3. Put Snapshot
每重放一帧都保存新的预测快照。

这里的 ctx.tick 是客户端下一帧要模拟的 tick,所以循环条件是:

1
t < ctx.tick

也就是追到客户端当前已经预测过的最后一帧。


10.10 World::Restore 恢复了哪些内容

World::Restore 不是只恢复玩家位置。

它会恢复:

  • snap_
  • mazeSeed_
  • 迷宫宽高
  • 迷宫网格
  • 玩家状态
  • lastDirX_ / lastDirY_
  • 子弹列表

关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void World::Restore(const WorldSnapshot& s) {
snap_ = s;
mazeSeed_ = s.mazeSeed;
mazeW_ = s.mazeWidth ? s.mazeWidth : mazeW_;
mazeH_ = s.mazeHeight ? s.mazeHeight : mazeH_;

if (!s.maze.empty()) {
maze_ = s.maze;
} else {
rng_.seed(mazeSeed_);
GenerateMaze();
}

lastDirX_.assign(snap_.players.size(), 1.0f);
lastDirY_.assign(snap_.players.size(), 0.0f);

projectiles_.clear();
for (const auto& pr : s.projectiles) {
if (!pr.alive) continue;
// 恢复 projectile
}
}

这个函数说明一个核心原则:

1
回滚恢复必须恢复所有会影响未来模拟的状态。

如果只恢复玩家位置,不恢复子弹、冷却、瞄准方向、迷宫,重放结果仍然会分叉。


10.11 一个具体例子

假设:

1
2
客户端当前 tick = 140
客户端收到服务端 State tick = 132

客户端执行:

1
2
3
4
5
6
7
8
9
10
11
12
1. 解码 StatePacket。
2. 还原 auth snapshot at tick 132。
3. hash(auth) 对比服务端 stateHash。
4. 对比本地 stateHist[132] 的本地玩家状态。
5. stateHist[132] = auth。
6. worldPred.Restore(auth)。
7. 依次重放 tick 133 ~ 139:
- 本地输入来自 localHist
- 远端输入来自 remoteHist / HoldLast / Default
- 每帧调用 World::Step
- 每帧重新写 stateHist
8. 渲染新的 worldPred。

最终客户端画面仍然在 tick 140 附近,不会倒退显示 tick 132。


10.12 总结流程图

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
Server OnTick
-> GetCmdForTick
-> world.Step
-> snap = world.Snapshot
-> hash = Hasher::Hash(snap)
-> EncodeState
-> SendTo(client)

Client OnUdp
-> DecodeState
-> 丢弃旧 State
-> 更新远端输入预测
-> ApplyAuthoritativeState
-> StatePacket 还原 WorldSnapshot auth
-> Hasher::Hash(auth) 对账
-> 对比本地玩家误差
-> stateHist.Put(auth)
-> lastAuthoritativeTick = st.tick
-> RestoreAndReplay
-> worldPred.Restore(auth)
-> for t = auth.tick + 1; t < ctx.tick; ++t
-> localHist.Get(t)
-> GetRemoteCmdForTick(t)
-> BuildCmdVec
-> worldPred.Step
-> stateHist.Put(snapshot)

10.13 常见易错点

  • 旧 State 没有丢弃,导致客户端被延迟包拉回旧历史。
  • 只恢复玩家位置,没有恢复子弹、冷却、aim、maze。
  • 新增字段只加到 World,忘了加到 WorldSnapshot
  • 新增字段进入 Snapshot,但忘了加到 StatePacket 编解码。
  • 新增字段影响模拟,但忘了加入 Hasher
  • 把远端预测误差也计入 rollback,导致 rollback 统计失真。
  • StatePacket 频率太低,客户端纠偏延迟变大。
  • StatePacket 频率太高,带宽和解码压力变大。
  • 回滚窗口太小,localHiststateHist 被环形覆盖后无法重放。

10.14 一句话记忆

1
2
3
4
服务端每隔几帧下发权威 State;
客户端收到后,不是直接显示旧 State;
而是恢复旧 State,再重放旧 State 之后的输入;
这样既接受服务端权威,又保留本地即时操作手感。

Fighting Netcode 项目知识笔记

1. 这个项目是什么

Fighting 是一个用 C++ 写的联机动作游戏同步 Demo。

项目地址:

/Volumes/拯救者PSSD/Fighting

它不是 AI 模型训练项目,而是一个很适合学习“实时系统工程”的项目。它实现的是:

  • 固定帧推进
  • UDP 通信
  • 服务端权威状态
  • 客户端本地预测
  • 输入冗余
  • 回滚重放
  • 状态快照
  • 状态哈希对账
  • 压力测试

可以把它理解成一个“小型实时分布式系统”。

如果放到 AI 模型开发学习体系里,它最值得参考的不是模型算法,而是工程能力:

  • 如何让多个端的状态保持一致
  • 如何处理延迟、丢包和乱序
  • 如何用快照、日志和 hash 定位状态分叉
  • 如何用固定 tick 管理实时流程
  • 如何把核心逻辑拆成可测试的模块

一句话概括:

这个项目是一个 60Hz 回滚同步网络游戏 Demo,但它背后的工程思想可以迁移到 AI 推理服务、Agent 执行系统、实时任务调度和分布式状态一致性设计里。


2. 项目整体目标

这个项目不是为了做一个完整游戏,而是为了跑通联机动作游戏最难的几件事。

核心目标是:

  • 玩家输入能快速反馈
  • 服务端保持最终权威
  • 网络延迟不会让本地操作卡顿
  • 客户端预测错了以后可以回滚修正
  • 服务端和客户端可以通过 hash 判断状态是否分叉

联机动作游戏里,一个常见问题是:

如果每次按键都等服务端确认,游戏会非常卡;如果完全相信客户端,又容易作弊和状态不一致。

这个项目采用的方案是:

  • 客户端先预测,保证操作手感
  • 服务端做权威推进,保证最终正确
  • 客户端收到权威状态后回滚重放,修正预测误差

这就是 Rollback Netcode 的核心思想。


3. 项目目录结构

项目主要目录如下:

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
apps/
client_main.cpp
server_main.cpp

include/lab/
app/
core/
io/
net/
sim/
time/
util/

src/
app/
core/
io/
net/
sim/
time/

tests/
core_tests.cpp
stress_tests.cpp

docs/
ARCHITECTURE.md

README.md
日志.md
CMakeLists.txt

可以按三层理解:

层级 目录 作用
模拟层 include/lab/simsrc/sim 世界状态、输入、快照、回滚、hash
网络层 include/lab/netsrc/net UDP、协议包、二进制编解码
应用层 appsinclude/lab/appsrc/app 服务端循环、客户端预测、渲染、输入采样

这个分层比较清晰:

  • 模拟层不关心网络和窗口
  • 网络层不关心游戏规则
  • 应用层负责把模拟、网络、渲染拼起来

这也是很多 AI 工程项目应该学习的拆法:

  • 模型推理核心逻辑
  • API / 网络协议层
  • 应用编排层
  • 测试和压测层

4. 快速运行方式

项目依赖:

  • CMake 3.20+
  • C++20
  • libevent
  • SDL2
  • SDL2_ttf
  • nlohmann_json

构建:

1
2
cmake -S . -B build
cmake --build build

启动服务端:

1
./build/lab_server

启动两个客户端:

1
2
./build/lab_client
./build/lab_client

运行测试:

1
ctest --test-dir build --output-on-failure

运行压力测试:

1
./build/lab_stress

更长时间压力测试:

1
./build/lab_stress --ticks 200000 --history 8192 --state-delay 12

5. 核心概念总览

这个项目最重要的概念有 8 个。

5.1 Tick

Tick 是逻辑帧编号。

代码里定义:

1
using Tick = uint32_t;

游戏不是直接按真实时间推进,而是按离散帧推进。

例如 60Hz 表示:

1
2
1 秒 = 60 个 tick
1 tick = 1 / 60 秒

这样做的好处是:

  • 逻辑顺序明确
  • 输入可以绑定到某个 tick
  • 状态可以按 tick 保存
  • 回滚时可以从某个 tick 重新模拟
  • 网络包可以明确说明自己属于哪一帧

AI 工程迁移理解:

Tick 类似任务系统里的 step id、事件序号、offset、version。只要系统存在异步、重试、回放,就需要这种明确的逻辑编号。

5.2 InputCmd

InputCmd 是每一帧的最小输入单位。

1
2
3
4
5
6
struct InputCmd {
Tick tick = 0;
uint16_t buttons = 0;
int8_t moveX = 0;
int8_t moveY = 0;
};

它包含:

  • 属于哪个 tick
  • 按键位图
  • 水平方向
  • 垂直方向

这个结构很小,适合频繁发送。

设计重点:

  • 输入要带 tick
  • 输入结构要尽量小
  • 输入可以重复发送,服务端按 tick 去重覆盖

5.3 WorldSnapshot

WorldSnapshot 是世界状态快照。

它包含:

  • 当前 tick
  • 玩家状态
  • 子弹状态
  • 迷宫 seed
  • 迷宫网格

它的作用是:

  • 网络状态同步
  • 回滚恢复
  • hash 对账
  • 测试验证

这说明项目把“状态数据”和“推进逻辑”分开了。

这种设计很重要:

只要系统支持回滚、回放、审计、调试,就应该有明确的 Snapshot 结构。

5.4 World::Step

World::Step(cmds, dt) 是世界推进入口。

它接收所有玩家在同一个 tick 的输入,然后推进一帧。

关键要求:

  • cmds.size() 必须等于玩家数量
  • 所有 cmd.tick 必须一致
  • 同样输入和同样初始状态,应该得到同样输出

这就是确定性模拟的基础。

5.5 InputBuffer

InputBuffer 是按 tick 存输入的环形缓冲。

核心逻辑:

1
idx = tick % capacity

读取时不只看槽位,还要校验 tick:

1
if (ring_[idx].cmd.tick != tick) return std::nullopt;

这个校验非常关键。

因为环形缓冲会覆盖旧数据,如果只看下标,不看 tick,就可能把旧帧误当成当前帧。

5.6 StateHistory

StateHistory 是按 tick 存世界快照的环形缓冲。

它和 InputBuffer 思路一样:

  • 写入时按 tick % cap
  • 读取时校验真实 tick

它用于:

  • 客户端保存预测状态
  • 收到权威状态后比较误差
  • 从某个权威 tick 恢复并重放
  • hash 对账

5.7 State Hash

HasherWorldSnapshot 做 hash。

它不是直接 hash float 的内存,而是先做毫米级量化:

1
2
3
int32_t QuantizeMm(float v) {
return std::lround(v * 1000.0f);
}

这样做是为了避免浮点误差造成假 mismatch。

hash 覆盖内容包括:

  • tick
  • 玩家位置和速度
  • 玩家 HP
  • action
  • cooldown
  • aim 方向
  • 子弹状态
  • 迷宫 seed 和网格

如果新增一个会影响模拟结果的字段,但忘了加入快照、网络包或 hash,就容易出现服务端和客户端状态分叉。

5.8 Rollback Replay

回滚重放是客户端收到权威状态后的修正流程。

基本公式:

1
2
3
4
5
6
Restore(authoritativeSnapshot)
for t in authoritativeTick + 1 .. localTick - 1:
取本地输入
取远端预测输入
World::Step(cmds, dt)
保存快照

它解决的问题是:

  • 客户端先预测,保证响应快
  • 权威状态来了以后,以权威状态为准
  • 重新模拟未来帧,让当前画面追上本地时间

6. 服务端流程

服务端是权威端。

核心文件:

apps/server_main.cpp

服务端主要做 6 件事:

  1. 启动 UDP socket
  2. 接收客户端 hello / input
  3. 按地址分配玩家 slot
  4. 收齐玩家后发送 Start
  5. 每个 tick 收集输入并推进权威世界
  6. 下发 AckState

6.1 玩家分配

服务端用客户端地址作为 key。

每个客户端连接后分配:

  • player1
  • player2

如果人数满了,后续客户端会被拒绝。

6.2 开局同步

服务端收齐 kRequiredPlayers = 2 后,不是立刻从当前 tick 开始,而是设置:

1
startTick = tick + kStartDelayTicks;

这个设计用于给客户端一点时间接收开局包,减少起步不同步。

6.3 缺输入处理

服务端每个 tick 都要给每个玩家拿到输入。

如果该 tick 有输入:

  • 直接使用

如果没有输入,但上次输入还在 hold 窗口内:

  • 复用上一帧输入
  • 把 tick 改成当前 tick

如果超出 hold 窗口:

  • 使用默认空输入

这段逻辑本质上是在处理 UDP 丢包和延迟。

6.4 权威推进

服务端每帧执行:

1
2
3
4
cmds = 每个玩家当前 tick 的输入
world.Step(cmds, dt)
snapshot = world.Snapshot()
hash = Hasher::Hash(snapshot)

服务端的状态是最终可信状态。

客户端的预测状态只是为了体验。

6.5 状态下发

服务端每帧发 Ack,每隔若干帧发完整 State

当前配置:

1
kStateEvery = 2

也就是每 2 tick 下发一次完整状态。

这种设计平衡了:

  • 状态同步频率
  • 带宽消耗
  • 回滚修正速度

7. 客户端流程

客户端负责“先动起来,然后等权威校正”。

核心文件:

apps/client_main.cpp

客户端主要做 8 件事:

  1. 未开局时定期发送 hello
  2. 收到 Start 后对齐 startTick
  3. 每 tick 采样键盘输入
  4. 本地保存输入历史
  5. 预测远端玩家输入
  6. 本地推进预测世界
  7. 发送带冗余的输入包
  8. 收到权威 State 后恢复并重放

7.1 本地预测

本地玩家的输入来自键盘。

远端玩家的输入无法立即知道,只能预测。

当前预测逻辑比较简单:

  • 根据远端玩家速度推测移动方向
  • 如果远端处于 Hitstun,则预测不动
  • 如果速度接近 0,则预测停止

相关文件:

src/app/InputPrediction.cpp

这不是高级 AI,而是一个经验规则。

7.2 输入冗余发送

客户端每个 tick 不只发送当前输入,而是发送最近 K 帧输入。

配置:

1
kInputRedundancy = 4

这样即使 UDP 丢了某个包,后续包也可能补上之前几帧输入。

输入包里包含:

  • playerId
  • seq
  • newestTick
  • clientAckServerTick
  • 最近 K 个 InputCmd

7.3 权威状态应用

客户端收到 State 后,会把网络包还原成 WorldSnapshot

然后做几件事:

  • 校验 stateHash
  • 判断本地玩家是否偏离权威状态
  • 把权威快照写入 StateHistory
  • 从权威快照恢复
  • 重放后续输入到当前 tick

注意:

项目只用本地玩家差异来判断是否计入 rollback。

原因是:

远端玩家本来就是预测的,如果把远端误差也算进回滚触发条件,客户端会频繁回滚。

7.4 为什么即使没有计入 rollback 也要 rebase

代码里有一个重要设计:

即使本地玩家误差没有超过阈值,客户端仍然会从权威快照 rebase,然后 replay。

这样做的原因是:

  • 远端玩家状态应该尽快采用权威值
  • 子弹状态也应该采用权威值
  • 不然画面可能长期偏离真实世界

也就是说:

  • rollbackCount 只是统计“本地玩家明显错误”的次数
  • rebase + replay 是状态校正流程本身

8. 网络协议设计

核心文件:

  • include/lab/net/Packets.h
  • src/net/NetCode.cpp

当前协议版本:

1
kVersion = 3

协议包类型:

方向 作用
Input Client -> Server 上传玩家输入
Start Server -> Client 分配 playerId,告知 startTick
Ack Server -> Client 告知服务端处理进度和 hash
State Server -> Client 下发权威状态

8.1 二进制编解码

项目手写了二进制协议。

特点:

  • 使用固定 magic
  • 使用协议 version
  • 使用 packet type
  • 整数统一转网络字节序
  • 64 位整数拆成两个 32 位写入
  • float 不直接传,而是转毫米整数

例如状态里的位置:

1
ps.x_mm = std::lround(wp.x * 1000.0f);

接收端还原:

1
float x = x_mm / 1000.0f;

这比直接传 float 更稳定,也更容易做 hash 对账。

8.2 为什么要有 magic 和 version

magic 用来识别是不是本协议的数据包。

version 用来防止不同协议版本之间误解码。

这在 AI 服务协议里也很重要。

例如模型推理请求如果格式变了,最好也有:

  • api version
  • schema version
  • feature version
  • model version

否则新旧客户端混用时很难排查问题。

8.3 Input 包为什么带冗余

UDP 不保证可靠。

如果每个输入只发一次:

  • 丢包就会丢输入
  • 服务端只能默认空输入
  • 玩家表现会突然停顿

所以 Input 包会带最近 K 帧输入。

这是一种简单可靠的抗丢包策略。

AI 工程里类似的思想是:

  • 请求重试带幂等 id
  • 消息队列消费保留 offset
  • 事件流消费允许重复事件
  • 服务端按版本号或序号去重

9. 世界模拟逻辑

核心文件:

src/sim/World.cpp

当前世界是一个顶视角小型“迷宫坦克”。

包含:

  • 迷宫地图
  • 玩家移动
  • 墙体碰撞
  • 玩家之间 pushbox 分离
  • 子弹发射
  • 子弹碰撞
  • HP 扣减
  • hitstun
  • cooldown

9.1 迷宫生成

迷宫由固定 seed 生成。

服务端通过 mazeSeed 下发给客户端。

这样客户端不需要每次接收完整地图,只要用同样 seed 就可以生成同样迷宫。

但项目的 WorldSnapshot 里也保留了迷宫网格。

这样更稳:

  • seed 用于重建
  • maze 数据用于快照、hash 和回滚

9.2 玩家移动

玩家速度不是瞬间切换,而是用摩擦插值靠近目标速度:

1
v += (targetV - v) * alpha

这样移动更平滑。

但它依然是确定性的,因为每一帧都只由:

  • 当前状态
  • 当前输入
  • 固定 dt

共同决定。

9.3 子弹状态

子弹包含:

  • 位置
  • 速度
  • owner
  • life

子弹撞墙或命中玩家后消失。

命中后:

  • 被击中玩家 HP 减少
  • 进入 Hitstun
  • 产生轻微击退

子弹也进入快照、网络包和 hash。

这是必须的。

因为子弹会影响未来游戏状态。

9.4 aimX / aimY 的意义

玩家状态里有:

1
2
int8_t aimX;
int8_t aimY;

它保存最近的瞄准方向。

这个字段很关键。

如果玩家停下来以后再开火,当前 moveX / moveY 可能是 0。

这时开火方向就要依赖上一次方向。

如果回滚恢复时没有保存 aim 方向,重放结果就可能和服务端不同。

这个例子说明:

只要某个字段会影响未来模拟结果,它就必须进入 Snapshot、网络包和 hash。


10. 固定帧推进

服务端和客户端都使用 accumulator 控制固定时间步。

基本结构:

1
2
3
4
5
6
frame = now - prev
acc += min(frame, maxFrame)

while acc >= dt:
step one tick
acc -= dt

项目里:

1
2
dt = 1.0 / 60.0
maxFrame = 0.25

为什么不用真实 frameTime 直接推进?

因为真实时间不稳定:

  • 系统调度会抖动
  • 渲染耗时会变化
  • 网络回调时间不可控

固定 dt 的好处是:

  • 模拟稳定
  • 回滚可重放
  • hash 更容易一致
  • 测试更容易复现

AI 工程迁移理解:

在 Agent、多轮任务执行、流式处理、训练调度里,也应该尽量区分:

  • 真实时间
  • 逻辑步数
  • 状态版本

系统内部最好基于逻辑 step 推进,而不是让 wall clock 到处影响业务逻辑。


11. 回滚同步完整链路

完整链路可以这样理解。

11.1 正常预测阶段

每个客户端每 tick 做:

1
2
3
4
5
6
7
采样本地输入
保存 localHist[tick]
预测远端输入
World::Step(allCmds, dt)
保存 stateHist[tick]
发送最近 K 帧输入给服务端
tick++

11.2 服务端权威阶段

服务端每 tick 做:

1
2
3
4
5
6
7
8
读取每个玩家输入
缺输入则 hold/default
World::Step(allCmds, dt)
生成权威 snapshot
计算 hash
发送 Ack
按频率发送 State
tick++

11.3 客户端校正阶段

客户端收到权威 State:

1
2
3
4
5
6
7
解码 State
还原 auth snapshot
校验 stateHash
比较本地玩家误差
写入权威快照
Restore(auth)
Replay auth.tick + 1 到当前 tick

这个流程的本质是:

客户端永远允许自己先猜,但最终必须回到服务端认可的历史上。


12. 测试设计

项目测试分两类。

12.1 core_tests

文件:

tests/core_tests.cpp

主要验证:

  • InputBuffer 零容量时会被修正为 1
  • StateHistory 零容量时会被修正为 1
  • InputPacket 编解码正确
  • StatePacket 编解码正确
  • Hasher 使用毫米精度
  • Hasher 覆盖 shotCooldown
  • World(0) 会至少创建 1 个玩家

这些是小而关键的回归测试。

12.2 stress_tests

文件:

tests/stress_tests.cpp

压力测试更重要。

它在单进程里模拟:

  • 权威世界
  • 客户端预测世界
  • 输入历史
  • 网络包编解码
  • State 延迟
  • State 抖动
  • 回滚重放
  • hash 校验

测试重点不是窗口和真实 socket,而是核心逻辑链路。

它验证:

  • 从原始权威快照恢复并重放,能追上权威历史
  • 从网络量化后的 State 恢复并重放,hash 仍然一致
  • 输入冗余包可以正常编解码
  • 长时间 tick 推进不会破坏状态一致性

这类测试对 AI 工程也有参考意义:

真正可靠的系统,不只测单个函数,还要测“数据经过网络、延迟、恢复、重放之后是否仍然正确”。


13. 这个项目值得学习的重点知识点

13.1 分层设计

项目没有把所有逻辑写在一个 main 里,而是拆成:

  • sim
  • net
  • app
  • tests

这种拆分让核心模拟可以脱离网络和渲染测试。

AI 工程对应:

  • 模型调用逻辑不要和 HTTP handler 混在一起
  • 数据处理逻辑不要和 UI 混在一起
  • 推理服务核心最好能被单元测试和压测直接调用

13.2 状态快照

WorldSnapshot 是这个项目的核心中间结构。

它让系统可以:

  • 保存状态
  • 传输状态
  • 恢复状态
  • 比较状态
  • hash 状态

AI 工程对应:

  • Agent 运行状态
  • RAG 查询状态
  • 会话上下文快照
  • 任务执行 checkpoint
  • 分布式训练 checkpoint

13.3 输入日志

项目不是只保存当前输入,而是保存输入历史。

这让系统可以从任意历史点重新计算。

AI 工程对应:

  • prompt history
  • tool call history
  • message queue event log
  • workflow step log
  • 训练样本处理日志

13.4 确定性推进

同样状态 + 同样输入 + 同样 dt,应该得到同样结果。

这对回滚极其重要。

AI 工程里虽然模型推理本身可能有随机性,但工程层也可以尽量确定:

  • 固定 prompt 模板版本
  • 固定模型版本
  • 固定采样参数
  • 固定工具调用输入
  • 固定数据预处理逻辑

否则线上问题很难复现。

13.5 网络协议版本化

项目协议有:

  • magic
  • version
  • packet type

这能避免错误解码。

AI 服务接口也应该重视:

  • API version
  • request schema version
  • model version
  • embedding version
  • prompt version

13.6 冗余和幂等

Input 包重复携带最近 K 帧输入。

这是一种用冗余换可靠性的设计。

AI 工程对应:

  • 任务重试要有 task id
  • 消息重复消费要能去重
  • 请求超时后重发不能重复扣费或重复写库
  • 流式输出恢复要能从 offset 继续

13.7 Hash 对账

项目用 hash 判断状态是否一致。

AI 工程也可以用类似思想:

  • 对输入文档算 hash
  • 对 chunk 结果算 hash
  • 对 embedding 输入算 hash
  • 对模型配置算 hash
  • 对 prompt 模板算 hash
  • 对 workflow 状态算 hash

这能帮助定位:

  • 是输入变了
  • 是模型变了
  • 是参数变了
  • 是代码逻辑变了
  • 是缓存污染了

13.8 压力测试

lab_stress 很值得参考。

它没有依赖窗口和真实 socket,而是直接压核心逻辑。

AI 工程里也可以这样做:

  • 不一定先压真实 API
  • 可以先压核心 pipeline
  • 模拟延迟、乱序、失败和重试
  • 验证恢复后结果是否一致

14. 和 AI 模型开发的关联

这个项目虽然不是 AI 项目,但可以帮助理解 AI 工程里的几个难点。

14.1 和推理服务的关系

推理服务也有类似问题:

  • 请求并发
  • 网络延迟
  • 状态追踪
  • 版本一致性
  • 超时重试
  • 结果校验

可以借鉴:

  • 每个请求带 request id
  • 每个模型有 model version
  • 每次调用记录输入 hash
  • 缓存 key 包含模型和参数版本
  • 异步任务保存 checkpoint

14.2 和 Agent 系统的关系

Agent 执行多步任务时,很像 tick 推进。

每一步可以看成:

1
2
3
4
5
6
step_id
observation
decision
tool_call
tool_result
state_update

如果中途失败,就需要:

  • 从某个 step 恢复
  • 重放历史
  • 判断状态是否一致
  • 避免重复执行副作用工具

这和回滚同步的思路很接近。

14.3 和 RAG 系统的关系

RAG 里也需要状态和版本管理。

例如:

  • 文档版本
  • chunk 版本
  • embedding 模型版本
  • 向量库索引版本
  • 查询改写版本
  • reranker 版本

如果没有这些版本,线上效果变化时很难排查。

可以参考这个项目的 hash 思想,对关键中间结果做记录。

14.4 和分布式训练的关系

分布式训练也重视:

  • step
  • checkpoint
  • deterministic replay
  • 状态恢复
  • 参数同步
  • 异常恢复

WorldSnapshot 类似训练 checkpoint。

InputCmd 类似每一步的 batch / event。

StateHistory 类似最近 checkpoint 历史。

Hasher 类似一致性校验。


15. 如果继续完善这个项目

这个项目已经把核心链路跑通了,但如果要继续做,可以考虑这些方向。

15.1 网络层增强

可以增加:

  • 延迟统计
  • 丢包率统计
  • RTT 估计
  • 抖动缓冲
  • 包乱序处理
  • 客户端 ping / pong
  • 输入确认窗口

15.2 协议层增强

可以增加:

  • 协议 schema 文档
  • 包大小统计
  • 包字段版本兼容
  • State 增量同步
  • 压缩
  • 加密或签名

15.3 回滚体验增强

可以增加:

  • 插值平滑
  • 远端玩家视觉修正
  • 回滚次数可视化
  • 回滚帧数统计
  • 输入延迟动态调整

15.4 测试增强

可以增加:

  • 真实 UDP bot 压测
  • 随机丢包模拟
  • 随机乱序模拟
  • 多平台 hash 对比
  • fuzz 解码测试
  • 长时间 soak test

15.5 工程结构增强

可以增加:

  • 更明确的协议文档
  • 更统一的配置来源
  • CI 自动测试
  • clang-format
  • sanitizers
  • 性能 profiling

16. 学习路线建议

如果你要用这个项目作为学习材料,可以按这个顺序看。

16.1 第一遍:先看整体

先读:

  • README.md
  • docs/ARCHITECTURE.md
  • CMakeLists.txt

目标是知道:

  • 项目怎么构建
  • 有哪些模块
  • 服务端和客户端怎么交互

16.2 第二遍:看数据结构

重点看:

  • InputCmd.h
  • StateSnapshot.h
  • InputBuffer.h
  • StateHistory.h
  • Packets.h

目标是理解:

  • 输入怎么表示
  • 状态怎么表示
  • 网络包怎么表示
  • 历史怎么保存

16.3 第三遍:看核心模拟

重点看:

  • World.h
  • World.cpp
  • Rules.h
  • Hasher.cpp

目标是理解:

  • 一帧怎么推进
  • 哪些字段影响确定性
  • 为什么 hash 要覆盖这些字段

16.4 第四遍:看服务端

重点看:

  • server_main.cpp

目标是理解:

  • 玩家怎么分配
  • 输入怎么接收
  • 缺输入怎么处理
  • 权威状态怎么广播

16.5 第五遍:看客户端

重点看:

  • client_main.cpp
  • InputPrediction.cpp

目标是理解:

  • 本地预测怎么做
  • 远端输入怎么猜
  • 收到权威 State 后怎么回滚
  • hash mismatch 怎么统计

16.6 第六遍:看测试

重点看:

  • core_tests.cpp
  • stress_tests.cpp

目标是理解:

  • 这个项目如何验证状态一致性
  • 压测为什么不依赖真实窗口和 socket
  • 如何模拟延迟和重放

17. 重点代码阅读清单

建议重点阅读这些文件:

1
2
3
4
5
6
7
8
9
10
11
include/lab/sim/InputCmd.h
include/lab/sim/StateSnapshot.h
include/lab/sim/InputBuffer.h
include/lab/sim/StateHistory.h
include/lab/net/Packets.h
src/sim/World.cpp
src/sim/Hasher.cpp
src/net/NetCode.cpp
apps/server_main.cpp
apps/client_main.cpp
tests/stress_tests.cpp

阅读时重点问自己几个问题:

  • 每个 tick 的输入从哪里来?
  • 服务端和客户端的 tick 如何对齐?
  • 客户端什么时候预测?
  • 权威状态什么时候覆盖预测状态?
  • 什么字段进入 Snapshot?
  • 什么字段进入网络包?
  • 什么字段进入 hash?
  • 如果新增一个状态字段,需要改哪些地方?
  • 如果 UDP 丢包,会发生什么?
  • 如果 State 延迟到达,客户端如何追上当前 tick?

18. 常见易错点

18.1 新增字段只改了 World,没有改 Snapshot

如果新增字段会影响模拟,但没有进入 Snapshot,回滚恢复后就会丢状态。

18.2 新增字段只改了 Snapshot,没有改网络包

服务端有这个状态,但客户端收不到,预测和权威会分叉。

18.3 新增字段没有进入 hash

状态已经不同,但 hash 检查不出来。

18.4 直接 hash float 内存

不同平台或编解码后可能有微小浮点差异,导致假 mismatch。

项目用毫米量化规避了这个问题。

18.5 环形缓冲读取时不校验 tick

只用 tick % cap 会读到被覆盖的旧数据。

项目里读取时会校验真实 tick,这是正确做法。

18.6 把远端预测误差也算作 rollback 条件

远端玩家本来就是猜的。

如果把远端差异也作为回滚计数依据,会导致 rollback 统计爆炸。

项目只比较本地玩家,比较合理。


19. 可以写成博客的版本

标题

从一个 C++ 格斗游戏 Demo 学实时系统:固定帧、预测、回滚与状态一致性

开头

很多人以为游戏网络同步只是“把坐标发给别人”。

但动作游戏真正困难的地方在于:

  • 玩家按键必须立刻有反馈
  • 网络包可能延迟或丢失
  • 服务端必须保持权威
  • 客户端预测错了还要能修回来

这个项目用一个小型 60Hz 顶视角对战 Demo,把这些问题完整串了起来。

第一部分:为什么需要固定帧

实时系统最怕逻辑跟真实时间强绑定。

如果每一帧都直接用真实耗时推进,状态会受到系统调度、渲染耗时和网络回调影响。

所以项目采用固定 tick:

1
2
3
1 秒 60 帧
每帧 dt = 1 / 60
所有输入和状态都绑定 tick

这让回放、回滚和调试都变得可控。

第二部分:客户端为什么要预测

如果玩家按键后必须等服务端确认,游戏会非常卡。

所以客户端先假设自己的输入有效,立即推进本地世界。

这样玩家看到的是即时反馈。

但客户端不是最终权威。

服务端稍后会下发真实状态,客户端再根据权威状态修正。

第三部分:回滚的本质

回滚不是简单地把当前位置拉回服务端位置。

真正的流程是:

1
2
3
恢复到服务端给的历史快照
重新执行这之后的输入
追上当前本地 tick

这要求系统必须保存:

  • 历史输入
  • 历史状态
  • 可恢复的快照
  • 确定性的 Step 函数

第四部分:为什么 hash 很重要

服务端和客户端状态一旦分叉,肉眼很难定位原因。

项目用 Hasher 对关键状态做 hash。

只要 hash 不一致,就说明某个状态字段不同。

更重要的是,hash 不是直接混合 float,而是和网络包一样做毫米量化。

这样可以避免浮点误差造成误报。

第五部分:这个项目对 AI 工程的启发

虽然这是游戏项目,但它的工程思想非常适合迁移到 AI 系统。

AI Agent 也有 step。

RAG 也有中间状态。

推理服务也有请求版本、模型版本和缓存一致性。

分布式训练也有 checkpoint 和恢复。

如果一个 AI 系统需要稳定运行,就不能只关心模型输出,还要关心:

  • 输入是否可追踪
  • 中间状态是否可恢复
  • 版本是否明确
  • 错误是否可复现
  • 结果是否可校验

结尾

这个 Fighting 项目真正值得学习的,不只是 Rollback Netcode,而是它展示了一套实时系统的基本工程方法:

  • 用 tick 管理时间
  • 用输入日志支撑回放
  • 用快照支撑恢复
  • 用 hash 支撑一致性检查
  • 用压力测试验证长链路正确性

这些能力放到 AI 模型开发中,同样非常重要。


20. 可扩展博客选题

后续可以基于这份笔记拆成几篇博客。

20.1 选题一:Rollback Netcode 入门

重点讲:

  • 为什么动作游戏不能只靠服务端确认
  • 本地预测是什么
  • 回滚重放是什么
  • 输入历史和状态快照怎么设计

20.2 选题二:用 C++ 实现一个确定性模拟核心

重点讲:

  • World::Step
  • 固定 dt
  • 状态字段管理
  • 碰撞和子弹逻辑
  • 如何避免不确定性

20.3 选题三:UDP 协议如何对抗丢包

重点讲:

  • 为什么用 UDP
  • Input 包为什么带冗余
  • 服务端如何 hold last input
  • Ack 和 State 分别解决什么问题

20.4 选题四:状态 hash 如何定位分布式系统分叉

重点讲:

  • 为什么需要 hash
  • 为什么不能直接 hash float
  • 哪些字段必须进入 hash
  • hash mismatch 如何排查

20.5 选题五:从游戏同步看 AI Agent 状态恢复

重点讲:

  • tick 和 Agent step 的类比
  • InputCmd 和 tool call 的类比
  • Snapshot 和 checkpoint 的类比
  • Replay 和失败恢复的类比

21. 总结

这个项目可以用一句话总结:

用 C++ 实现了一个小型实时联机同步系统,通过固定帧、输入冗余、本地预测、服务端权威、回滚重放和 hash 对账来解决网络延迟下的状态一致性问题。

最应该记住的重点:

  • Tick 是逻辑时间基础
  • InputCmd 是最小输入事件
  • WorldSnapshot 是恢复和同步的核心
  • World::Step 必须尽量确定性
  • InputBufferStateHistory 用环形缓冲保存历史
  • 客户端预测是为了体验,服务端权威是为了正确
  • 回滚不是瞬移,而是恢复历史状态后重放输入
  • hash 对账要和网络量化精度一致
  • 压力测试应该覆盖完整链路,而不是只测单个函数

放到 AI 模型开发学习里,这个项目最有价值的地方是:

它训练的是工程化思维:状态、版本、恢复、重放、对账、压测。

22. 项目设计模式应用与注意事项

这一节专门从设计模式角度看项目。这里的重点不是硬套 GoF 名词,而是看每个模式在实时竞技游戏里解决了什么工程问题。

22.1 命令模式:把玩家操作变成 InputCmd

项目里最明显、最值得记住的模式是命令模式。

传统命令模式是把“动作请求”封装成对象。这个项目里的 InputCmd 就是玩家每一帧的动作命令:

1
2
3
4
5
6
struct InputCmd {
Tick tick;
uint16_t buttons;
int8_t moveX;
int8_t moveY;
};

它的价值是:

  • 输入可以被网络发送。
  • 输入可以被环形缓冲保存。
  • 输入可以按 tick 重放。
  • 服务端和客户端都能用同一份 World::Step(cmds, dt) 消费输入。
  • 压力测试可以直接构造输入序列,不依赖键盘和窗口。

这就是 rollback netcode 能成立的基础:系统保存的不是“玩家最后位置”,而是“玩家在每个 tick 做了什么”。只要初始快照一样,输入日志一样,就可以重新推导后续状态。

22.2 命令模式的注意点

InputCmd 必须保持小、确定、可序列化。

不要把这些内容放进输入命令:

  • 真实时间戳,例如 std::chrono::now()
  • 客户端计算出的最终坐标、HP、命中结果。
  • 指针、引用、窗口事件对象、平台相关对象。
  • 随机数结果。

输入命令应该只描述玩家意图,例如移动方向和按钮。最终位置、碰撞、子弹命中必须由权威模拟算出来。

如果后续新增技能或攻击,推荐做法是:

  1. buttons 或输入结构中增加“意图”字段。
  2. World::Step 中解释这个意图。
  3. 把会影响未来模拟的状态加入 WorldSnapshot
  4. 把需要下发给客户端的状态加入 StatePacket
  5. 把确定性相关字段加入 Hasher
  6. 给编解码、hash、回滚压测补测试。

22.3 分层架构:sim / net / app

项目采用很清楚的分层架构:

代表文件 责任
模拟层 src/sim/World.cpp 按输入推进世界,生成快照
网络层 src/net/NetCode.cpp UDP 协议包编解码
应用层 apps/server_main.cppapps/client_main.cpp 组织主循环、收发包、预测、回滚、渲染

这个分层的好处是测试成本低。lab_stress 可以绕开 SDL 和真实 UDP,直接压核心同步链路。

注意点:

  • 模拟层不要依赖 socket、SDL、窗口、日志 UI。
  • 网络层不要偷偷写游戏规则。
  • 应用层可以编排流程,但不要把碰撞、伤害、hash 规则散落在 main 里。

22.4 Reactor / 事件驱动:libevent 和 SDL

服务端和客户端都使用事件驱动:

  • UDP 收包通过 UdpSocket::StartEventRead 注册回调。
  • 固定 tick 通过 libevent 定时器驱动。
  • 客户端窗口输入通过 SDL event 和键盘状态采样。

这种模式适合实时游戏,因为网络、输入和计时都不是线性阻塞流程。

注意点:

  • 回调里尽量只做小而确定的状态更新。
  • 不要在收包回调里做长时间阻塞操作。
  • tick 推进要以固定 dt 为准,真实时间只负责 accumulator。
  • maxFrame 要限制单次追帧上限,避免窗口卡顿后一次补太多 tick。

22.5 Snapshot / DTO:WorldSnapshot 和网络包

WorldSnapshot 是项目里的状态传输对象,也可以理解成 DTO。

它把世界状态从 World 对象里抽出来,供这些场景复用:

  • 服务端下发权威状态。
  • 客户端回滚恢复。
  • 压力测试保存历史。
  • Hasher 做状态对账。

注意点:

  • Snapshot 里必须包含所有会影响未来模拟的状态。
  • 网络包可以比 Snapshot 更紧凑,但不能漏掉客户端恢复权威所需的字段。
  • hash 覆盖范围要和“确定性状态”一致。
  • float 不要直接按内存 hash,本项目用毫米量化是正确方向。

22.6 RAII:资源生命周期管理

项目里 UdpSocket 适合按 RAII 思路理解:socket fd 的打开、关闭、事件注册应该由对象生命周期管理。SDL 的窗口、renderer、font 目前由 InitRenderer / ShutdownRenderer 成对管理,也体现了同样思想。

注意点:

  • socket、event、SDL 资源不能泄露。
  • 初始化失败时要释放已经创建的资源。
  • 后续可以把 event*event_base*、SDL texture 等进一步封装成 RAII wrapper。

22.7 为什么本项目不适合滥用 Singleton

这个项目最不应该做成单例的是:

  • World
  • InputBuffer
  • StateHistory
  • ClientCtx
  • ServerCtx
  • UdpSocket

原因是 rollback、预测和压测都需要多实例。

例如同一个进程里可能同时存在:

  • 一个权威世界。
  • 一个客户端预测世界。
  • 多个历史快照。
  • 多个输入缓冲。

如果把这些做成单例,会让状态隐藏起来,回滚恢复不完整,测试互相污染。

可以考虑单例的对象非常少,例如只读配置表或日志入口。但只要对象带有对局状态、网络连接、玩家状态,就不要做成单例。

22.8 设计模式复习口诀

这个项目里可以这样记:

1
2
3
4
5
6
InputCmd 是命令模式:保存玩家意图,支持发送、缓存、重放。
WorldSnapshot 是快照/DTO:保存权威状态,支持恢复、同步、hash。
sim/net/app 是分层架构:隔离模拟、协议和应用流程。
libevent/SDL 是事件驱动:网络、输入、tick 都由事件推进。
UdpSocket/RenderCtx 要按 RAII 思维管理:资源成对创建和释放。
不要滥用 Singleton:回滚系统需要多实例和显式状态。