第1章:网络编程基础——一切故事的起点
单机游戏和网络游戏的区别,不只是"有没有网"。
它是"你的CPU要不要听别人的话"。
本章是全书的地基。彭放从最基础的socket讲起,没有一上来就甩框架,而是先让你理解一个根本问题:为什么游戏服务器不能像你写的本地计算器那样,一行行顺序执行?
答案是:因为网络有别人。而"别人"是不可控的。
一、单机游戏 vs 网络游戏:本质差异
单机游戏的假设
┌─────────────────────────────┐
│ 游戏进程 │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │输入 │─▶│逻辑 │─▶│渲染 │ │
│ └─────┘ └─────┘ └─────┘ │
│ 完全可控 │
└─────────────────────────────┘单机游戏的世界里只有一个进程,所有数据在内存里,所有时序由CPU说了算。你按W,角色立刻往前——延迟就是代码执行时间,纳秒级。
网络游戏的现实
┌──────────┐ 网络(20-200ms) ┌──────────┐
│ 客户端 │◄───────────────────────►│ 服务端 │
│ 你的机器 │ │ 别人的机器 │
└──────────┘ └──────────┘
↑ 延迟不可控、丢包不可控、顺序不可控 ↑网络游戏的时序被"网络"这个黑箱切断了。你按W,消息发出去,服务端收到、处理、广播给其他玩家——这个往返,在国内大概是20-80ms,跨国可能200ms以上。
20ms是什么概念?
60FPS的渲染周期是16.67ms。也就是说,网络延迟比你的一帧还长。如果服务端没收到你的操作就强行渲染,你看到的就是"卡顿";如果客户端不等服务端确认就预测渲染,看到的就是"回溯"。
MOBA里的"走A取消后摇"、FPS里的"客户端预测+服务端校验"——这些机制的存在,全都是因为网络延迟这个物理事实。
核心差异总结
| 维度 | 单机游戏 | 网络游戏 |
|---|---|---|
| 状态所有权 | 本地 | 服务端(权威) |
| 输入响应 | 即时 | 延迟 |
| 并发性 | 单用户 | 多用户竞争资源 |
| 安全性 | 信任本地 | 必须校验一切 |
| 复杂度 | 逻辑+渲染 | 逻辑+网络+并发+持久化 |
所以游戏服务端的第一个原则就是:服务端拥有最终解释权。 客户端可以预测、可以插值、可以做很多优化,但一切争议以服务端状态为准。
二、IP地址与TCP/IP:先搞懂地址和路
IP地址:网络世界的门牌号
IPv4地址32位,写成192.168.1.1这种四段式。关键要理解:
- 公网IP:全球唯一,比如服务器的IP
- 内网IP:局域网内部用,比如你家路由器的
192.168.x.x - 127.0.0.1:localhost,永远指向本机
- 0.0.0.0:绑定所有可用接口,服务端监听时常用
// 最简单的IP地址表示
struct sockaddr_in {
sa_family_t sin_family; // AF_INET (IPv4)
in_port_t sin_port; // 端口号,要用htons转换
struct in_addr sin_addr; // IP地址
};端口号16位,范围0-65535。熟知端口(0-1023)需要root权限,比如80是HTTP,443是HTTPS。游戏服一般开在5000以上的高位端口。
TCP/IP协议栈:四层模型
┌─────────────────────────────────────┐
│ 应用层 │ HTTP / FTP / 自定义协议 │ ← 游戏协议在这里
├─────────────────────────────────────┤
│ 传输层 │ TCP / UDP │ ← 游戏一般用TCP(或UDP+重传)
├─────────────────────────────────────┤
│ 网络层 │ IP / ICMP │ ← 路由选择
├─────────────────────────────────────┤
│ 链路层 │ Ethernet / WiFi │ ← 网卡驱动
└─────────────────────────────────────┘TCP提供的是可靠的字节流传输:顺序保证、不重不漏、有拥塞控制。代价是延迟和开销。
UDP提供的是尽力而为的数据报:不保证顺序、不保证到达、没有拥塞控制。代价是你自己处理丢包和乱序。
游戏为什么一般用TCP?
MMORPG里,聊天、交易、技能释放、属性变化——这些都不能丢包。用TCP,内核帮你做可靠性,你专心写业务逻辑。
MOBA/FPS为什么有时候用UDP?
因为位置同步包每50ms发一个,丢了一两个没关系,下一个包会跟上。TCP的重传机制反而会造成"等待旧包、阻塞新包"的队头阻塞。这类游戏通常用UDP+应用层重传(只重传关键包),或者干脆不补,靠预测插值平滑。
本书以TCP为主,这是正确的教学路径——先学会可靠传输,再去掉可靠性,才能理解UDP为什么省。
三、阻塞式网络编程:最直观的起点
什么是阻塞?
想象你在一家餐厅点单:
- 阻塞模式:你站在柜台前,盯着厨师做。在菜做好之前,你什么都不干,就干等。
- 非阻塞模式:你点完单拿到叫号器,去旁边坐着刷手机。叫号器响了再回来取餐。
网络编程里的"阻塞",就是socket函数在条件不满足时,把进程挂起,直到条件满足才返回。
服务端基础代码结构
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <cstring>
#include <iostream>
// ========== 阻塞式服务端:一次只能服务一个客户端 ==========
int main() {
// 1. 创建socket
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) {
perror("socket failed");
return -1;
}
// 2. 绑定地址
struct sockaddr_in server_addr{};
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8888); // 端口8888
server_addr.sin_addr.s_addr = INADDR_ANY; // 0.0.0.0
if (bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("bind failed");
close(listen_fd);
return -1;
}
// 3. 开始监听,backlog=5表示内核最多缓存5个未accept的连接
if (listen(listen_fd, 5) < 0) {
perror("listen failed");
close(listen_fd);
return -1;
}
std::cout << "Server listening on 0.0.0.0:8888 (blocking mode)\n";
// 4. 接受连接——这里是阻塞的!没有客户端连进来,进程就挂在这里
struct sockaddr_in client_addr{};
socklen_t client_len = sizeof(client_addr);
int client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_len);
std::cout << "Client connected!\n";
// 5. 收发数据——recv也是阻塞的!没有数据就挂起
char buffer[1024];
while (true) {
memset(buffer, 0, sizeof(buffer));
int recv_len = recv(client_fd, buffer, sizeof(buffer) - 1, 0);
if (recv_len <= 0) {
std::cout << "Client disconnected.\n";
break; // 客户端断开
}
std::cout << "Received: " << buffer << "\n";
// Echo回去
send(client_fd, buffer, recv_len, 0);
}
// 6. 关闭连接
close(client_fd);
close(listen_fd);
return 0;
}关键函数速查
| 函数 | 作用 | 阻塞点 |
|---|---|---|
socket() | 创建通信端点 | 不阻塞 |
bind() | 绑定IP和端口 | 不阻塞 |
listen() | 开始监听 | 不阻塞 |
accept() | 等待客户端连接 | 阻塞! |
recv() | 等待接收数据 | 阻塞! |
send() | 发送数据 | 通常不阻塞(除非缓冲区满) |
connect() | 客户端发起连接 | 阻塞! |
阻塞式客户端
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <iostream>
int main() {
int sock_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr{};
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8888);
inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr); // 本机测试
// connect会阻塞,直到三次握手完成或超时
if (connect(sock_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("connect failed");
return -1;
}
std::cout << "Connected to server!\n";
// 发消息
const char* msg = "Hello, Game Server!";
send(sock_fd, msg, strlen(msg), 0);
// 收回复——阻塞等待
char buffer[1024];
int recv_len = recv(sock_fd, buffer, sizeof(buffer) - 1, 0);
if (recv_len > 0) {
buffer[recv_len] = '\0';
std::cout << "Server reply: " << buffer << "\n";
}
close(sock_fd);
return 0;
}阻塞的问题
上面的服务端有一个致命缺陷:一次只能服务一个客户端。
accept()阻塞在第一个客户端上,第二个客户端连进来的时候,内核的backlog队列会暂存它,但服务端根本没时间调用第二个accept()——因为第一个recv()也在阻塞。
Client A连接 ──▶ accept()返回A ──▶ recv(A)阻塞
↑
Client B连接 ──▶ backlog队列等待 ◀──┘ 服务端没空理B!在真实游戏里,这意味着:玩家A在发消息,玩家B连上来但收不到任何响应——B看到的画面是"连接中……"一直转圈。
解决方案有两个方向:
- 多进程/多线程:每个连接一个线程(资源开销大)
- 非阻塞+IO多路复用:单线程监控多个socket(本章讲方向2的起步)
四、非阻塞式网络编程:解放CPU的第一步
设置非阻塞模式
Linux下用fcntl:
#include <fcntl.h>
// 设置socket为非阻塞
int set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
if (flags < 0) return -1;
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}Windows下用ioctlsocket:
#include <winsock2.h>
u_long mode = 1; // 1=非阻塞,0=阻塞
ioctlsocket(sock_fd, FIONBIO, &mode);非阻塞模式下的行为变化
// 非阻塞accept:如果没有新连接,立即返回-1,errno=EAGAIN/EWOULDBLOCK
int client_fd = accept(listen_fd, ...);
if (client_fd < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 没有新连接,不阻塞,可以做别的事
}
}
// 非阻塞recv:如果没有数据,立即返回-1,errno=EAGAIN
int recv_len = recv(client_fd, buffer, sizeof(buffer), 0);
if (recv_len < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 暂时没有数据,去处理其他客户端
}
}非阻塞服务端骨架
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <vector>
#include <cstring>
#include <iostream>
int set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int main() {
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
set_nonblocking(listen_fd); // 监听socket也设为非阻塞
// ... bind和listen省略 ...
std::vector<int> client_fds; // 维护所有客户端连接
while (true) {
// 尝试accept,不阻塞
struct sockaddr_in client_addr{};
socklen_t len = sizeof(client_addr);
int client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &len);
if (client_fd >= 0) {
set_nonblocking(client_fd);
client_fds.push_back(client_fd);
std::cout << "New client: " << client_fd << "\n";
} else if (errno != EAGAIN && errno != EWOULDBLOCK) {
perror("accept error");
break;
}
// 轮询所有客户端,尝试recv
for (int fd : client_fds) {
char buffer[1024];
int recv_len = recv(fd, buffer, sizeof(buffer) - 1, 0);
if (recv_len > 0) {
buffer[recv_len] = '\0';
std::cout << "From " << fd << ": " << buffer << "\n";
send(fd, buffer, recv_len, 0); // echo
} else if (recv_len == 0) {
// 客户端主动断开
close(fd);
// 从client_fds移除(实际用更高效的数据结构)
} else if (errno != EAGAIN && errno != EWOULDBLOCK) {
// 真正的错误
close(fd);
}
// recv_len < 0 && errno == EAGAIN → 暂时没有数据,跳过
}
// 这里可以插入:处理游戏逻辑、发送广播、存档……
// 因为没有阻塞,CPU不会空等
}
return 0;
}非阻塞的本质
阻塞模式:CPU在等网络,干不了别的 → CPU利用率低
非阻塞模式:CPU一直在轮询,没有网络事件时做无用功 → CPU利用率高,但忙等浪费
阻塞模式时间线:
accept()阻塞 ━━━━━━━━━━━━━━━━━━━┓
┃ 处理逻辑
recv()阻塞 ━━━━━━━━━━━┓ ┃
┃ 处理 ┃
recv()阻塞 ━━━━━━━┓ ┃ 逻辑 ┃
┃ 处┃ ┃
┃ 理┃ ┃
时间:▓▓░░░░░░░░░░░▓▓░░░▓▓░░░▓▓ (▓=干活 ░=阻塞)
非阻塞模式时间线:
accept()?有! → recv()?无! → recv()?有! → 处理 → recv()?无! → ...
时间:▓░▓░▓▓▓░▓░▓░▓▓▓░▓░▓░▓▓▓░▓░▓░▓▓▓ (▓=干活 ░=立刻返回)非阻塞解决了"一阻塞全家等死"的问题,但引入了新问题:轮询所有socket太蠢了。
100个连接,每次循环检查100个recv(),99个返回EAGAIN——CPU在做"没有意义的检查"。如果连接数到10000呢?这个循环本身的执行时间就变成不可忽略了。
这就是IO多路复用存在的理由。 第2章会讲:与其你问每个socket"有没有数据",不如让内核告诉你"哪些socket有数据"。
五、Win/Linux系统差异:跨平台的坑
socket类型定义
// Linux
#include <sys/socket.h>
int fd = socket(AF_INET, SOCK_STREAM, 0);
// Windows
#include <winsock2.h>
SOCKET sock = socket(AF_INET, SOCK_STREAM, 0);
// 注意:SOCKET是UINT_PTR类型,不是int!
// 错误检查不能和Linux一样用 < 0关闭socket
// Linux
close(fd);
// Windows
closesocket(sock);
// 而且Windows需要先WSAStartup()初始化Winsock错误码
// Linux:全局变量errno
#include <errno.h>
if (errno == EAGAIN) { ... }
// Windows:WSAGetLastError()
#include <winsock2.h>
if (WSAGetLastError() == WSAEWOULDBLOCK) { ... }字节序转换
// 两套API名字不同,功能一样
// Linux/通用
htons(8888); // Host TO Network Short (16位)
htonl(...); // Host TO Network Long (32位)
ntohs(...); // Network TO Host Short
ntohl(...); // Network TO Host Long
// Windows 同名,但实现可能不同作者的处理策略
彭放在书中用条件编译封装了这些差异:
#ifdef _WIN32
#include <winsock2.h>
#define CLOSE_SOCKET closesocket
#define GET_ERROR WSAGetLastError()
typedef SOCKET SocketType;
#else
#include <unistd.h>
#include <sys/socket.h>
#define CLOSE_SOCKET close
#define GET_ERROR errno
typedef int SocketType;
#endif工程建议:直接上Linux开发。 Windows的Winsock2是上世纪90年代的API设计,行为细节和POSIX差异很多。生产环境99%是Linux,学习阶段没必要在兼容性上浪费时间。
六、服务端/客户端代码结构的工程化思考
从"能跑"到"能维护"
上面示例代码都是平铺直叙的main函数。真实项目中,网络层至少要拆成这几个模块:
Network/
├── BaseSocket.h/cpp # socket的RAII封装
├── Network.h/cpp # 网络事件循环(第2章细化)
├── NetworkListen.h/cpp # 监听端口的抽象
├── NetworkConnector.h/cpp # 主动连接的抽象(客户端用)
├── Buffer.h/cpp # 收发缓冲区
└── Packet.h/cpp # 应用层协议封包第2章的预告:Buffer和Packet为什么重要
非阻塞模式下,recv()返回的数据不一定是"一条完整消息"。TCP是字节流,不是消息边界:
客户端发了两条消息:[HEADER]["Hello"][HEADER]["World"]
服务端recv()可能收到:
情况1: [HEADER]["Hello"][HEADER]["Wo"] ← 第二条没全到
情况2: [HEADER]["Hel"] ← 第一条都没全到
情况3: [HEADER]["Hello"][HEADER]["World"] ← 凑巧全到了没有Buffer和Packet层,你的"解析逻辑"和"网络逻辑"混在一起,代码很快变成意大利面条。
Buffer负责:不管recv()一次给多少,我先攒着,凑够一条完整消息再交给上层。
Packet负责:定义消息格式(比如2字节长度+变长payload),让Buffer知道"凑到多少算一条"。
这是第2章的核心,但第1章要先埋个种子:TCP是流,你要自己造消息的"边界"。
七、工程建议与常见坑点
✅ 做对的事
总是检查返回值。网络函数没有"肯定成功"这回事。
accept()可能在信号中断后返回-1且errno==EINTR,这时候要重试。设置SO_REUSEADDR。服务端重启时,旧连接还在TIME_WAIT状态,不设置这个选项会绑定失败:
int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));处理SIGPIPE。向一个已断开的连接发数据,Linux默认发
SIGPIPE信号终止进程。游戏服不能因为这个崩:signal(SIGPIPE, SIG_IGN); // 忽略SIGPIPE,让send()返回EPIPE错误码心跳机制。TCP连接可能"假死"——网线拔了、路由器重启了,但两端都不知道。游戏服必须有应用层心跳:客户端每30秒发一个心跳包,服务端60秒没收到就踢掉。
❌ 常见的坑
忘记转换字节序。
bind()里的端口号必须用htons(),否则在x86小端机器上端口会错。这是个经典到不能再经典的bug,但每年都有人踩。backlog设太小。
listen(fd, 5)在并发测试时不够。生产环境一般设到128或更大,但真正的瓶颈不是backlog,是你accept()的速度。缓冲区溢出。上面的示例用
char buffer[1024],真实消息可能更长。要么动态分配,要么设计协议时限定单条消息上限。没有优雅关闭。
close(fd)直接关闭可能丢数据。正确的关闭流程是:先shutdown(fd, SHUT_WR)发FIN,等对方也关,再close()。在非阻塞socket上用阻塞式send。如果发送缓冲区满,
send()也会返回EAGAIN。非阻塞模式下,send不是"一定立刻发完"——它只保证"塞到内核缓冲区里,塞不下就告诉我"。
八、本章核心回顾
flowchart LR
A[单机游戏] --"网络引入延迟和不确定性"--> B[网络游戏]
B --"需要可靠传输"--> C[TCP字节流]
C --"socket API"--> D[阻塞模式]
D --"一次只能服务一个客户端"--> E[非阻塞模式]
E --"轮询所有socket效率低"--> F[第2章: IO多路复用]- 单机到网络:状态权威在服务端,客户端只是"视图"
- TCP是流:没有消息边界,必须自己设计封包
- 阻塞的本质:CPU在等资源,利用率低
- 非阻塞的代价:解放了CPU,但引入了忙等
- 下一步:IO多路复用——让内核告诉你"谁准备好了"
九、一个可以跑的小作业
基于本章代码,实现一个"猜数字"游戏服务器:
- 服务端随机生成1-100的数字
- 客户端连接后,可以发送猜测的数字
- 服务端回复"大了""小了"或"猜对了"
- 支持多客户端同时连接(用非阻塞+vector存储客户端)
这个作业会逼你面对:连接管理、消息解析、状态维护——游戏服务器最基础的三件事。
阻塞和非阻塞的区别,本质上是"控制权在谁手里"。
阻塞模式,控制权交给内核,你被动等待;
非阻塞模式,控制权回到你的代码,但你要自己决定"什么时候去查"。
IO多路复用是第三种答案:控制权给内核,但内核只汇报"有事的",不报"没事的"。三种模式没有绝对的好坏——只有对场景的理解够不够深。
"先让代码跑起来,再去想怎么让它跑得更好。" 🖤