讲讲Linux平台开发经验
进程基础
// fork() — 创建子进程
pid_t pid = fork();
if (pid == 0) {
// 子进程:完整拷贝父进程内存(CoW,写时复制)
execv("/usr/bin/game_server", args); // 替换进程映像
_exit(1); // exec 失败才到这里,用 _exit 不用 exit
} else if (pid > 0) {
// 父进程
int status;
waitpid(pid, &status, 0); // 等待子进程,避免僵尸进程
} else {
// fork 失败
}
写时复制(Copy-on-Write):fork 后父子进程共享内存页,只有某方写入时才真正复制该页。游戏服务器用 fork 做快照备份(Redis BGSAVE 就是这个原理)。
僵尸进程:子进程退出后父进程没有 wait,进程表项残留。解决:
- 父进程调用
waitpid - 或忽略
SIGCHLD:signal(SIGCHLD, SIG_IGN)
线程 vs 进程
线程共享:堆、代码段、全局变量、文件描述符 线程私有:栈、寄存器、errno、线程局部存储(TLS)
__thread / thread_local — 线程局部存储
thread_local int g_player_id = -1; // 每个线程独立一份,无锁访问 // 游戏服务器常用:在工作线程处理请求时存当前 player_id, // 避免层层传参
二、文件描述符与 I/O 多路复用
epoll — 高并发网络的核心
// 创建 epoll 实例
int epfd = epoll_create1(EPOLL_CLOEXEC);
// 注册感兴趣的 fd
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // ET 边缘触发
ev.data.fd = server_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, server_fd, &ev);
// 事件循环
struct epoll_event events[MAX_EVENTS];
while (true) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
if (events[i].data.fd == server_fd) {
// 新连接
int client_fd = accept4(server_fd, nullptr, nullptr,
SOCK_NONBLOCK | SOCK_CLOEXEC);
// 注册 client_fd 到 epoll...
} else {
// 可读数据
handle_client(events[i].data.fd);
}
}
}
ET vs LT(面试必考)
| 水平触发 LT(默认) | 边缘触发 ET | |
|---|---|---|
| 触发条件 | fd 可读就一直通知 | fd 状态变化时通知一次 |
| 未读完 | 下次 epoll_wait 还会通知 | 不再通知,必须一次读完 |
| 实现要求 | 简单 | 必须循环读到 EAGAIN |
| 性能 | 较低 | 更高(事件更少) |
ET 模式下必须这样读:
// ET 模式:必须循环读到 EAGAIN,否则丢数据
while (true) {
ssize_t n = read(fd, buf, sizeof(buf));
if (n == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK)
break; // 读完了
// 真正的错误
break;
}
if (n == 0) {
// 对端关闭
close(fd);
break;
}
process(buf, n);
}
select vs poll vs epoll 对比
| select | poll | epoll | |
|---|---|---|---|
| fd 上限 | 1024 | 无限制 | 无限制 |
| 遍历方式 | 线性扫描全部 | 线性扫描全部 | 只返回就绪 fd |
| 时间复杂度 | O(n) | O(n) | O(1) |
| 内核拷贝 | 每次全量 | 每次全量 | 注册一次,增量通知 |
| 适用场景 | 已基本淘汰 | 少量连接 | 高并发服务器标配 |
三、内存管理
虚拟内存布局
高地址 ┌─────────────────┐ │ 内核空间 │ ← 用户程序不可直接访问 ├─────────────────┤ │ 栈(向下增长) │ ← 局部变量、函数调用帧 │ ↓ │ ├─────────────────┤ │ 内存映射区 │ ← mmap、共享库、匿名映射 │ ↑ │ ├─────────────────┤ │ 堆(向上增长) │ ← malloc/new ├─────────────────┤ │ BSS 段 │ ← 未初始化全局变量 │ 数据段 │ ← 已初始化全局变量 │ 代码段(只读) │ ← 程序文本 └─────────────────┘ 低地址
mmap 的实际用途
// 1. 内存映射文件(零拷贝读大文件)
int fd = open("game_assets.pak", O_RDONLY);
struct stat st;
fstat(fd, &st);
void* data = mmap(nullptr, st.st_size, PROT_READ,
MAP_PRIVATE, fd, 0);
// 直接访问文件内容,操作系统按需换页,避免 read() 的内核→用户拷贝
// 2. 进程间共享内存
void* shm = mmap(nullptr, 4096, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_ANONYMOUS, -1, 0);
// fork 后父子进程共享这段内存
内存问题排查工具
# Valgrind:检测内存泄漏、越界访问(慢,开发阶段用) valgrind --leak-check=full --track-origins=yes ./game_server # AddressSanitizer(ASan):编译期插桩,性能损耗小得多 g++ -fsanitize=address -fno-omit-frame-pointer -g ./game_server.cpp # 查看进程内存映射 cat /proc/<pid>/maps cat /proc/<pid>/smaps_rollup # 内存汇总 # 堆使用情况 jemalloc / tcmalloc 的 heap profiler(C++ 游戏服务器常用 tcmalloc)
四、信号处理
#include <signal.h>
// 优雅关机处理
volatile sig_atomic_t g_running = 1; // 信号安全类型
void signal_handler(int sig) {
if (sig == SIGTERM || sig == SIGINT) {
g_running = 0; // 通知主循环退出
}
}
int main() {
struct sigaction sa{};
sa.sa_handler = signal_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART; // 系统调用被信号中断后自动重启
sigaction(SIGTERM, &sa, nullptr);
sigaction(SIGINT, &sa, nullptr);
// 忽略 SIGPIPE(对端关闭时写入会触发,必须忽略)
signal(SIGPIPE, SIG_IGN);
while (g_running) {
// 游戏服务器主循环
}
// 优雅关机:保存状态、通知客户端
graceful_shutdown();
}
SIGPIPE 是游戏服务器必须处理的:客户端断开后,服务器再往 socket 写数据会收到 SIGPIPE,默认行为是进程终止。忽略它,改为检查 write 返回值。
五、性能分析工具
perf — Linux 性能分析神器
# CPU 热点采样(采样 30 秒) perf record -g -p <pid> -- sleep 30 perf report # 查看缓存未命中、分支预测失败 perf stat -e cache-misses,cache-references,branch-misses ./game_server # 火焰图(最直观) perf record -F 99 -g -p <pid> -- sleep 30 perf script | stackcollapse-perf.pl | flamegraph.pl > flame.svg
strace — 系统调用追踪
# 追踪进程所有系统调用 strace -p <pid> -e trace=network,file # 统计每个系统调用耗时 strace -c ./game_server # 常见发现: # - 频繁小 read/write(应该用 writev / 批量) # - 意外的 stat 调用(配置文件热重载失控) # - futex 争用(锁竞争严重)
其他工具
top / htop # CPU、内存实时 vmstat 1 # 内存、IO、CPU 综合 iostat -x 1 # 磁盘 IO 详情 netstat / ss -tuln # 网络连接 lsof -p <pid> # 打开的文件描述符 /proc/<pid>/fd/ # 文件描述符列表
六、游戏后端常见 Linux 优化技巧
socket 调优
// 1. TCP_NODELAY — 禁用 Nagle 算法,降低延迟(游戏必开) int flag = 1; setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag)); // 2. SO_REUSEPORT — 多线程各自 accept,避免惊群,提升吞吐 setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &flag, sizeof(flag)); // 3. SO_KEEPALIVE — 检测死连接 setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &flag, sizeof(flag)); // 4. 发送/接收缓冲区 int buf_size = 4 * 1024 * 1024; // 4MB setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &buf_size, sizeof(buf_size));
系统参数调优(/etc/sysctl.conf)
# 最大文件描述符(默认 1024,游戏服务器要大得多) fs.file-max = 1000000 ulimit -n 1000000 # TCP 连接队列 net.core.somaxconn = 65535 # listen() backlog 上限 net.ipv4.tcp_max_syn_backlog = 65535 # SYN 半连接队列 # TIME_WAIT 快速复用(高并发短连接服务) net.ipv4.tcp_tw_reuse = 1 # 端口范围 net.ipv4.ip_local_port_range = 1024 65535
CPU 亲和性绑核
// 将线程绑定到指定 CPU 核,减少调度抖动和缓存失效 cpu_set_t cpuset; CPU_ZERO(&cpuset); CPU_SET(2, &cpuset); // 绑定到 CPU 2 pthread_setaffinity_np(pthread_self(), sizeof(cpuset), &cpuset);
游戏服务器常见架构:网络 I/O 线程绑 CPU 0-1,游戏逻辑线程绑 CPU 2-7,避免跨核迁移导致 L1/L2 缓存失效。
七、面试高频问题
Q:fork 之后 malloc 是否安全?
不一定。fork 在多线程程序中有”async-signal-safe”问题:如果 fork 时另一个线程持有 malloc 的内部锁,子进程拿到的是锁定状态的 heap,再调用 malloc 会死锁。安全做法:fork 后立即 exec,或只 fork 单线程程序。
Q:epoll 的 fd 可以跨线程使用吗?
可以。epoll fd 是内核对象,多个线程可以同时调用
epoll_wait,结合SO_REUSEPORT实现多线程并行 accept,这是高性能服务器的标准做法。
Q:为什么 write 返回成功不代表对方收到数据?
write 只是把数据写入内核发送缓冲区,返回成功意味着内核已接受这些字节。实际网络传输、对方 TCP 栈接收、应用层 read 是后续步骤。这就是为什么游戏协议需要应用层 ACK 来确认关键操作。
Q:如何定位 C++ 程序的 coredump?
# 开启 core dump ulimit -c unlimited echo "/tmp/core.%p" > /proc/sys/kernel/core_pattern # 用 gdb 分析 gdb ./game_server /tmp/core.12345 (gdb) bt full # 查看完整调用栈 (gdb) info threads # 查看所有线程状态 (gdb) thread 3 # 切换到线程 3
总结
对于 EA 游戏后端面试,Linux 开发经验的考察重点是:epoll 网络模型 → 内存管理与调试 → 多线程同步 → 性能调优工具链。能说出 ET 模式为什么必须循环读、SIGPIPE 为什么要忽略、TCP_NODELAY 是什么效果,就已经超过大部分候选人了。
继续深挖某个方向?比如 网络编程实战(自己实现一个简单 TCP 服务器)、性能优化案例,还是转向别的 EA 面试知识点?
