05. 网络编程:从 0 到 socket / 非阻塞 / epoll / 长连接(深挖版)¶
这一章是给之前几乎没接触过网络编程的人写的。
目标不是让你一上来就手搓高性能框架,而是分三步:
- 先搞明白网络编程到底在干什么
- 再搞明白 C++ 服务端常见技术栈:socket、非阻塞、epoll、事件循环
- 最后把它整理成能扛住面试追问的回答
如果你之前只学过 TCP/UDP 协议、HTTP、epoll 名词,但没真正把这些东西串起来,这一章就是那张“总装图”。
0. 先建立一个总图:网络编程到底在做什么?¶
很多人第一次学网络编程,会把它理解成:
- 调几个 API
- 发点字符串
- 收点字符串
但这只是表面。
更准确地说:
网络编程是在做“两个进程之间,通过操作系统提供的通信接口,在不同主机或同一主机之间交换数据”。
其中最常见的接口就是:
socket
你可以把 socket 先粗暴理解成:
操作系统给你的一种“网络通信文件描述符”。
它和文件描述符很像:
- 可以创建
- 可以读写
- 可以关闭
- 可以被
select/poll/epoll监听
所以网络编程的很多问题,本质上都不是“网络很玄学”,而是:
- 我怎么建立连接?
- 我怎么发数据?
- 我怎么知道对方发来了数据?
- 我怎么在大量连接里高效处理事件?
- 我怎么处理半包、粘包、断开、超时、心跳?
1. 先从最基础的问题开始:客户端和服务端分别在干什么?¶
1.1 一个最朴素的理解¶
服务端做什么?¶
服务端通常做三件事:
- 在某个 IP + 端口上等待别人连接
- 有客户端连上来之后,接收请求
- 处理请求并返回结果
客户端做什么?¶
客户端通常也做三件事:
- 知道服务端地址
- 主动发起连接
- 发请求、收响应
1.2 最经典的 TCP 通信流程¶
服务端流程¶
socket():创建监听 socketbind():绑定 IP 和端口listen():开始监听accept():接受客户端连接recv()/read():接收数据send()/write():发送数据close():关闭连接
客户端流程¶
socket():创建 socketconnect():连接服务端send()/write():发请求recv()/read():收响应close():关闭连接
一句话总结¶
服务端先把门开好并站在门口等人;客户端主动敲门;建立连接后双方就可以通过 socket 收发数据。
2. socket 到底是什么?¶
2.1 别把 socket 只当函数名¶
很多人会说:“socket 不就是创建套接字的函数吗?”
这不算错,但太浅。
更好的理解是:
socket 既是一类编程接口,也是内核维护的一类通信对象。
从程序员视角看,它常表现为一个整数 fd:
- Linux 下本质上是文件描述符
- 你可以对它 read/write
- 它可以进入 epoll 监听集合
这也是为什么网络编程和文件 IO、事件驱动模型会天然连在一起。
2.2 常见 socket 类型¶
1)流式 socket:SOCK_STREAM¶
通常对应 TCP。 特点:
- 面向连接
- 可靠传输
- 字节流
2)数据报 socket:SOCK_DGRAM¶
通常对应 UDP。 特点:
- 无连接
- 面向报文
- 不保证可靠
先记住一句¶
面试里如果没特别说明,网络编程大多数默认在聊 TCP 服务端编程。
3. 一段最小可理解的 TCP 服务端代码¶
下面这段代码不是生产级代码,但它很适合你第一次建立整体印象。
#include <arpa/inet.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cstring>
#include <iostream>
int main() {
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) {
std::cerr << "socket failed\n";
return 1;
}
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(8080);
if (bind(listen_fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0) {
std::cerr << "bind failed\n";
close(listen_fd);
return 1;
}
if (listen(listen_fd, 128) < 0) {
std::cerr << "listen failed\n";
close(listen_fd);
return 1;
}
std::cout << "server listening on 8080\n";
sockaddr_in client_addr{};
socklen_t client_len = sizeof(client_addr);
int conn_fd = accept(listen_fd, reinterpret_cast<sockaddr*>(&client_addr), &client_len);
if (conn_fd < 0) {
std::cerr << "accept failed\n";
close(listen_fd);
return 1;
}
char buf[1024] = {0};
ssize_t n = recv(conn_fd, buf, sizeof(buf) - 1, 0);
if (n > 0) {
std::cout << "received: " << buf << "\n";
const char* resp = "hello from server\n";
send(conn_fd, resp, std::strlen(resp), 0);
}
close(conn_fd);
close(listen_fd);
return 0;
}
3.1 这段代码每一步在干嘛?¶
socket(AF_INET, SOCK_STREAM, 0)¶
AF_INET:IPv4SOCK_STREAM:流式 socket,通常就是 TCP- 返回一个 fd
bind(...)¶
把这个 socket 绑定到某个地址和端口上。
listen(...)¶
告诉内核:
- 这是一个监听 socket
- 你可以开始接收连接请求了
accept(...)¶
从监听 socket 上取出一个“已经建立好的连接”。
要特别注意:
listen_fd是监听用的conn_fd是和具体客户端通信用的
面试里这是个很基础但容易混的点。
recv(...)¶
从连接里读数据。
send(...)¶
往连接里写数据。
close(...)¶
关闭 fd,释放资源。
4. 为什么服务端要有两个 fd:listen_fd 和 conn_fd?¶
这是初学者很容易糊掉的点。
4.1 listen_fd 是“门口”¶
它只负责:
- 接受新的连接请求
- 不直接承载具体业务收发
4.2 conn_fd 是“客人进门后的专属会话”¶
一个客户端连接建立后,内核会返回一个新的连接 fd:
- 这个 fd 才用于
recv/send - 一个客户端对应一个连接 fd
类比一下¶
listen_fd:饭店大门conn_fd:某一桌客人的包厢
你不能拿饭店大门直接给某一桌上菜。
5. 客户端最小代码示例¶
#include <arpa/inet.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cstring>
#include <iostream>
int main() {
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
return 1;
}
sockaddr_in server_addr{};
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);
if (connect(fd, reinterpret_cast<sockaddr*>(&server_addr), sizeof(server_addr)) < 0) {
std::cerr << "connect failed\n";
close(fd);
return 1;
}
const char* msg = "hello server\n";
send(fd, msg, std::strlen(msg), 0);
char buf[1024] = {0};
ssize_t n = recv(fd, buf, sizeof(buf) - 1, 0);
if (n > 0) {
std::cout << "response: " << buf;
}
close(fd);
return 0;
}
6. 只会写这个最小示例,为什么还远远不够?¶
因为真实服务端会遇到下面这些问题:
- 不止一个客户端
- 客户端不会刚好一次把完整消息发完
- 连接可能随时断掉
- 你不能一个连接傻等一个线程
- 读写可能返回一部分,不是一次完成
- 你要处理超时、心跳、背压、异常关闭
所以最小示例只是“会打电话”,不是“会做通信系统”。
7. 阻塞 IO 到底有什么问题?¶
7.1 什么叫阻塞?¶
如果你调用 recv(),但数据还没到,线程会卡住等数据。
这就是阻塞。
7.2 阻塞最直观的问题¶
如果服务端这么写:
while (true) {
int conn_fd = accept(listen_fd, ...);
recv(conn_fd, buf, ...); // 卡住
process();
send(conn_fd, ...);
}
那么:
- 当前客户端没发完,线程就卡住
- 其他客户端即使来了,也处理不到
这在单连接示例里没问题, 但在多连接服务端里会非常差。
8. 最朴素的多连接办法:一连接一线程¶
8.1 思路¶
每来一个连接,就开一个线程专门服务它。
while (true) {
int conn_fd = accept(listen_fd, ...);
std::thread([conn_fd]() {
char buf[1024];
while (true) {
ssize_t n = recv(conn_fd, buf, sizeof(buf), 0);
if (n <= 0) break;
send(conn_fd, buf, n, 0);
}
close(conn_fd);
}).detach();
}
8.2 这种模型为什么早期常见?¶
因为:
- 写起来简单
- 思维直观
- 每个连接像一段同步逻辑
8.3 为什么高并发下不行?¶
因为线程不是免费的:
- 线程栈占内存
- 线程切换有成本
- 连接很多时线程数爆炸
- 大量连接其实多数时间不活跃,线程在空等
面试里的经典一句¶
一连接一线程的问题,不在于“功能不对”,而在于“资源模型太差”。
9. 非阻塞 socket 是什么?¶
9.1 核心理解¶
非阻塞不是“数据自动来了你不用管”,而是:
这次调用如果做不了,不要把线程挂住,立刻返回给我。
比如对一个非阻塞 socket 调 recv():
- 如果当前没数据
- 不会一直卡住
- 而是返回
-1,并设置errno = EAGAIN或EWOULDBLOCK
9.2 怎么设置非阻塞?¶
这通常会对:
- 监听 fd
- 已连接 fd
都做非阻塞设置。
9.3 非阻塞为什么不是异步?¶
这个特别爱考。
非阻塞¶
- 你来问
- 没准备好就立刻返回
- 你之后还得自己再来问
异步¶
- 你把任务交出去
- 底层自己完成
- 完成后通知你
所以:
非阻塞只是“不等”,不代表“整个 IO 过程都由内核替你做完”。
10. 光有非阻塞为什么还不够?¶
因为如果你这么写:
while (true) {
ssize_t n = recv(fd, buf, sizeof(buf), 0);
if (n < 0 && errno == EAGAIN) {
continue;
}
}
这会变成忙等:
- CPU 白白空转
- 不断轮询
- 很浪费
所以我们真正需要的是:
当 socket 真正“有事可做”时,再通知我。
这就是 IO 多路复用要解决的事。
11. select / poll / epoll 到底在帮什么忙?¶
它们的共同目标是:
让一个线程可以同时关注多个 fd,谁准备好了就处理谁。
也就是说¶
不是线程傻等某一个连接, 而是线程在等“事件”。
这些事件最常见是:
- 可读
- 可写
- 异常
- 对端关闭
12. 为什么 epoll 这么重要?¶
在 Linux 高并发服务端里,最常见的组合就是:
- 非阻塞 socket
- epoll
- 事件循环
- 线程池 / Reactor 架构
原因很简单:
- 连接数很多
- 活跃连接只占一部分
- 不想每次全量扫描所有 fd
所以 epoll 很适合这种:
- “连接很多”
- “真正活跃的少数连接触发事件”
13. epoll 的核心 API¶
13.1 epoll_create1¶
创建一个 epoll 实例。
13.2 epoll_ctl¶
向 epoll 注册、修改、删除 fd。
epoll_event ev{};
ev.events = EPOLLIN;
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
13.3 epoll_wait¶
等待就绪事件。
返回后:
events[i]里就是就绪事件- 你只处理这些有事的 fd
14. 一个最小 epoll 服务端框架¶
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <unistd.h>
#include <fcntl.h>
#include <cerrno>
#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);
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(8080);
bind(listen_fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr));
listen(listen_fd, 128);
set_nonblocking(listen_fd);
int epfd = epoll_create1(0);
epoll_event ev{};
ev.events = EPOLLIN;
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
epoll_event events[1024];
while (true) {
int n = epoll_wait(epfd, events, 1024, -1);
for (int i = 0; i < n; ++i) {
int fd = events[i].data.fd;
if (fd == listen_fd) {
while (true) {
sockaddr_in client_addr{};
socklen_t len = sizeof(client_addr);
int conn_fd = accept(listen_fd, reinterpret_cast<sockaddr*>(&client_addr), &len);
if (conn_fd < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
break;
}
set_nonblocking(conn_fd);
epoll_event client_ev{};
client_ev.events = EPOLLIN;
client_ev.data.fd = conn_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &client_ev);
}
} else {
char buf[1024];
while (true) {
ssize_t cnt = recv(fd, buf, sizeof(buf), 0);
if (cnt > 0) {
send(fd, buf, cnt, 0); // echo
} else if (cnt == 0) {
close(fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr);
break;
} else {
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
close(fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr);
break;
}
}
}
}
}
}
15. 这段 epoll 代码的关键理解点¶
15.1 为什么监听 fd 和连接 fd 都要进 epoll?¶
- 监听 fd:有新连接到来时可读
- 连接 fd:有业务数据到来时可读
15.2 为什么 accept 要循环?¶
因为一次监听事件可能不止一个连接到来。
如果是非阻塞模式,通常要一直 accept 到:
- 返回
EAGAIN
这意味着“目前接收队列已经取空”。
15.3 为什么 recv 也常要循环?¶
同理,一次可读事件可能缓冲区里不止一点数据。 通常也要反复读到:
- 返回
EAGAIN
尤其在 ET 模式下更重要。
16. LT 和 ET 到底怎么理解?¶
16.1 LT:水平触发¶
只要 fd 还处于可读/可写状态,就会持续通知。
优点:
- 不容易漏处理
- 对初学者更友好
缺点:
- 可能有重复通知
16.2 ET:边沿触发¶
只有状态从“不可读”变成“可读”这种变化发生时,才通知一次。
优点:
- 事件通知更精简
缺点:
- 更容易写错
- 你必须一次尽量把数据读干净/写干净
16.3 初学者应该怎么选?¶
如果你是刚上手:
先用 LT 把整体流程写明白。
因为 ET 最大的坑是:
- 你以为读了一次就够了
- 其实缓冲区还有数据
- 但后面不一定再提醒你
- 然后你程序就“假死”了
面试里比较稳的回答¶
LT 更稳、更容易写对;ET 更强调减少重复通知,但要求使用者把非阻塞读写循环处理得更完整。
17. 为什么 TCP 网络编程一定会讲“粘包 / 拆包 / 半包”?¶
因为 TCP 是字节流。
它不保留你业务层“一条消息”的边界。
比如发送端:
接收端可能会收到:
- 一次收到
helloworld - 或先收到
hel - 再收到
loworld
所以你不能把一次 recv() 当作“一条完整消息”。
18. 业务层协议怎么设计消息边界?¶
最常见有三种思路:
18.1 固定长度¶
每条消息固定大小。
优点:
- 简单
缺点:
- 不灵活
- 容易浪费空间
18.2 分隔符¶
比如每条消息以 \n 结尾。
优点:
- 文本协议直观
缺点:
- 要考虑消息内容里出现分隔符
- 要考虑转义
18.3 长度字段 + 消息体¶
最常见。
比如协议头里有 4 字节长度字段:
- 先读头
- 得到 body 长度
- 再读满 body
这才是工程里最常见的做法¶
因为它既灵活,又适合二进制协议。
19. 一个长度字段协议的接收思路示例¶
假设协议:
- 前 4 字节:消息体长度(网络字节序)
- 后面 N 字节:消息体
伪代码:
std::vector<char> inbuf;
void onReadable(int fd) {
char tmp[4096];
while (true) {
ssize_t n = recv(fd, tmp, sizeof(tmp), 0);
if (n > 0) {
inbuf.insert(inbuf.end(), tmp, tmp + n);
} else if (n == 0) {
// 对端关闭
close(fd);
return;
} else {
if (errno == EAGAIN) break;
close(fd);
return;
}
}
while (true) {
if (inbuf.size() < 4) break;
uint32_t len = 0;
std::memcpy(&len, inbuf.data(), 4);
len = ntohl(len);
if (inbuf.size() < 4 + len) break;
std::string msg(inbuf.begin() + 4, inbuf.begin() + 4 + len);
handleMessage(msg);
inbuf.erase(inbuf.begin(), inbuf.begin() + 4 + len);
}
}
19.1 这里面体现了什么核心思想?¶
1)一次 recv 不等于一条消息¶
先把收到的字节放进输入缓冲区。
2)协议解析和 socket 读写是两层事¶
- socket 层只负责搬字节
- 协议层负责从字节里切消息
3)要允许“半包”存在¶
如果头到了,body 没到齐,先缓存,等下次可读再继续。
4)要允许“一次读出多条包”¶
不能只解析一条就结束,要循环解析直到缓冲区不足以构成完整消息。
面试高分句¶
网络编程里要把“收字节”和“解消息”分开看,输入缓冲区就是两者之间的桥梁。
20. send 也不是永远一次发完,这点很多人会漏¶
20.1 常见误区¶
很多人默认:
就一定把len 个字节全发出去了。
其实不一定。
尤其:
- 非阻塞 socket
- 发送缓冲区满
- 大包发送
都可能只发出一部分。
20.2 发送部分成功怎么办?¶
你要记录“还没发完的部分”,下次 socket 可写时继续发。
伪代码:
struct Conn {
std::string outbuf;
};
void sendMessage(int fd, Conn& conn, const std::string& msg) {
conn.outbuf += msg;
tryFlush(fd, conn);
}
void tryFlush(int fd, Conn& conn) {
while (!conn.outbuf.empty()) {
ssize_t n = send(fd, conn.outbuf.data(), conn.outbuf.size(), 0);
if (n > 0) {
conn.outbuf.erase(0, n);
} else {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 等待下次 EPOLLOUT
break;
}
close(fd);
break;
}
}
}
21. 什么是背压(backpressure)?为什么很重要?¶
背压就是:
下游来不及收,你不能还无限往里塞。
在网络编程里典型表现为:
- 对方处理慢
- 内核发送缓冲区满
- 你的应用层待发送队列越来越长
如果你不控制:
- 内存暴涨
- 延迟越来越大
- 最后整个服务被拖死
常见处理方式¶
- 每连接设置发送缓冲上限
- 超限后断开连接或丢弃低优先级消息
- 做限流
- 拒绝继续接收上游数据
面试一句话¶
高并发网络服务不只是“能收能发”,还要有能力在对方慢的时候控制自己别被拖死。
22. 长连接到底带来了什么好处和什么问题?¶
22.1 好处¶
- 减少频繁建连/断连开销
- 降低 RTT 成本
- 更适合高频请求
- 支持实时双向通信
22.2 问题¶
- 连接长期占用 fd
- 需要心跳和空闲连接清理
- 需要处理半开连接
- 负载均衡更复杂
- 连接上下文占内存
所以面试里不要只说长连接好¶
更稳的说法是:
长连接用空间和连接管理复杂度,换取时延和握手成本上的收益。
23. 心跳机制为什么存在?¶
因为长连接场景下,连接可能“表面还在,实际上已经不可靠”。
比如:
- NAT 超时
- 中间设备断开
- 对端程序异常退出
- 网络半开
如果只靠“等下一次业务请求时再发现”,可能太晚。
所以常见做法是:
- 定期发 ping / heartbeat
- 超过若干周期收不到 pong / 响应,就认为连接失效
- 主动清理连接
24. 超时管理一般怎么做?¶
网络服务端常见几类超时:
- 建连超时
- 读超时
- 写超时
- 空闲超时
- 心跳超时
常见实现思路¶
- 每个连接记录最后活跃时间
- 定时扫描超时连接
- 或时间轮 / 最小堆管理超时任务
面试里别答太死¶
不一定非得实现时间轮,重点是你要知道:
长连接服务必须有超时清理机制,否则连接资源会慢慢被僵尸连接吃掉。
25. 网络编程为什么总爱和 Reactor 一起讲?¶
因为 Reactor 非常适合描述这种模式:
- 事件来了(可读/可写/新连接)
- 事件分发器发现谁有事
- 调对应 handler 去处理
对应到 epoll 模型¶
- epoll:事件通知器
- event loop:事件循环
- handler:读回调、写回调、连接回调
所以很多 C++ 网络库本质上就是:
- 非阻塞 socket
- epoll
- Reactor
- 回调/事件驱动
26. 一个比较现实的 C++ 网络服务结构长什么样?¶
一个常见结构是:
26.1 主线程 / IO 线程负责¶
- accept 新连接
- epoll_wait 拿事件
- 收发数据
- 协议编解码
26.2 业务线程池负责¶
- 比较重的业务逻辑
- 数据库访问
- 缓存访问
- RPC 调用
为什么这样分工?¶
因为 IO 线程应该尽量轻:
- 快收
- 快发
- 快分发
- 不要被重逻辑卡住
一句话总结¶
epoll 线程负责“把事件接住”,线程池负责“把活干完”。
27. 为什么高并发服务端常用“epoll + 线程池”,而不是“一连接一线程”?¶
这是高频面试题。
标准回答¶
因为大量连接场景下,连接数和活跃度通常不匹配。多数连接很多时候是空闲的,如果为每个连接都绑定一个线程,会造成大量线程资源浪费和上下文切换开销。epoll + 线程池 可以让少量线程管理大量连接,并把真正耗时的业务处理交给工作线程执行。
更像面试现场的展开¶
- 网络连接很多,但真正活跃的连接通常只占一部分
- IO 等待不是 CPU 计算,不值得一连接绑一个线程去傻等
- epoll 可以让一个线程管理很多连接的就绪事件
- 业务线程池处理真正的计算和阻塞型任务
- 这样资源利用率和可扩展性更好
28. accept、recv、send、close 这几个点各有什么常见坑?¶
28.1 accept 的坑¶
- 一次事件不止一个新连接
- 非阻塞时要循环 accept 到
EAGAIN - 文件描述符耗尽时要有保护意识
28.2 recv 的坑¶
- 返回 >0 不代表完整消息
- 返回 0 表示对端关闭连接
- 返回 -1 需要看
errno - 非阻塞下
EAGAIN不算错误
28.3 send 的坑¶
- 不保证一次全发完
- 非阻塞下可能
EAGAIN - 需要发送缓冲区与继续发送逻辑
28.4 close 的坑¶
- 关闭后要从 epoll 中移除/清理状态
- 避免 fd 重用导致逻辑串台
- 注意连接上下文释放时机
29. Unix Domain Socket 和 TCP Socket 怎么选?¶
这个也很像网络编程/IPC 交叉题。
TCP Socket¶
适合:
- 跨机器通信
- 通用网络模型
Unix Domain Socket¶
适合:
- 同机进程通信
- 保留 socket 编程模型
- 不需要完整网络协议栈那套跨机能力
面试稳妥回答¶
同机服务间通信如果希望保留 socket 编程接口,同时减少跨机协议栈开销,Unix Domain Socket 往往是很好的选择;跨机则通常选 TCP。
30. 网络字节序为什么会被问?¶
因为不同机器 CPU 可能字节序不同。
网络协议通常统一使用大端序作为网络字节序。
所以常见转换函数:
htons:host to network shorthtonl:host to network longntohsntohl
为什么面试会问这个?¶
因为这代表你是否知道:
网络传输不只是“传数字”,还要保证跨机器解释一致。
31. socket 编程里还常见哪些基础名词?¶
backlog¶
listen(fd, backlog) 里的 backlog 通常可粗略理解为“连接排队容量相关参数”。
面试里不必抠内核细节到极致,但要知道:
- 它和服务端连接接纳能力有关
- 太小可能在高峰时丢连接/拒连接更明显
SO_REUSEADDR¶
常见用于让服务端重启后更容易重新绑定端口。
keepalive¶
内核级保活机制,用于探测长时间无数据交互的连接是否还活着。
注意¶
应用层心跳和 TCP keepalive 不是一回事:
- keepalive 更底层、周期通常较长
- 应用层心跳更灵活、能携带业务语义
32. 一个更贴近面试的“聊天室服务器”思考题¶
如果让你设计一个简化聊天室服务端,至少要考虑:
- 多客户端并发接入
- 长连接
- 收到一条消息后广播给其他人
- 用户突然断线
- 某个客户端很慢,广播时不能把全局拖死
- 消息边界怎么切
- 心跳怎么做
- 在线用户表怎么维护
这题想考什么?¶
其实不只是考 socket API,更多是在考:
- 连接管理
- 协议设计
- 广播策略
- 慢连接治理
- 线程模型
这也是为什么网络编程最终会走向系统设计题。
33. 一个从 0 上手的学习路线¶
如果你之前完全没做过网络编程,我建议按这个顺序来:
第 1 步:先会写最小 TCP client/server¶
目标:
- 理解
socket/bind/listen/accept/connect/send/recv/close - 能在本机跑通
第 2 步:理解 TCP 字节流¶
目标:
- 明白为什么会粘包拆包
- 自己实现一个“长度字段 + body”的协议解析器
第 3 步:理解阻塞与非阻塞¶
目标:
- 知道
EAGAIN是什么 - 知道一次收发不一定完成
第 4 步:理解 epoll 模型¶
目标:
- 能说清
epoll_create / epoll_ctl / epoll_wait - 能写一个 echo server
第 5 步:理解事件驱动架构¶
目标:
- 知道 Reactor 是什么
- 知道 IO 线程和业务线程怎么分工
第 6 步:补长连接工程问题¶
目标:
- 心跳
- 超时
- 慢连接
- 背压
- 连接清理
第 7 步:整理成面试答案¶
目标:
- 能从“最小流程”讲到“高并发服务设计”
34. Boost.Asio / Asio:C++ 网络库里绕不开的名字¶
34.1 Asio 是什么?¶
Asio 是 C++ 里最主流的跨平台异步 IO 库之一。
- Boost.Asio:Boost 库的一部分,历史最久、用户最多
- 独立版 Asio:不依赖 Boost 也能用(
asiostandalone) - Asio 的核心设计者 Christopher Kohlhoff 同时也是 C++ 标准网络提案的主要推动者
一句话定位¶
Asio 把底层的 socket、epoll(Linux)/ kqueue(macOS)/ IOCP(Windows)封装成统一的异步编程模型,让你不用直接和系统调用打交道。
34.2 Asio 和裸 epoll 是什么关系?¶
你可以这么理解:
| 层级 | 你自己写 | 用 Asio |
|---|---|---|
| socket 创建 | socket() |
tcp::socket |
| 非阻塞设置 | fcntl(O_NONBLOCK) |
Asio 内部自动处理 |
| 事件注册 | epoll_ctl |
async_read / async_write |
| 事件循环 | while(epoll_wait(...)) |
io_context::run() |
| 回调分发 | 自己 switch(fd) |
Asio 自动调你的回调/handler |
也就是说¶
Asio 帮你把前面 20 多节讲的那些"裸 epoll + 非阻塞 + 事件循环 + 缓冲区管理"封装好了。
但底层机制是一样的:
- Linux 下 Asio 内部就是 epoll
- macOS 下是 kqueue
- Windows 下是 IOCP
面试里该说的¶
Asio 不是一种新的 IO 模型,而是对操作系统异步 IO 机制的跨平台封装。理解了 epoll + Reactor 之后再看 Asio,会发现它只是把你自己会写的那套事件驱动包装成了更易用的接口。
34.3 Asio 的核心概念¶
1)io_context(旧版叫 io_service)¶
事件循环的核心。相当于你自己写的那个 while(epoll_wait(...)) 循环。
2)tcp::acceptor¶
相当于 listen_fd。
3)tcp::socket¶
相当于 conn_fd。
4)async_read / async_write¶
相当于你用 epoll 注册 EPOLLIN/EPOLLOUT 再在回调里 recv/send。
5)strand¶
用于在多线程 io_context 下保证某些回调不被并发执行,相当于轻量级串行化。
34.4 一个最小 Asio echo server 示例¶
// echo_asio.cpp
// 编译:g++ -std=c++17 -o echo_asio echo_asio.cpp -lpthread
// 如果用 Boost.Asio:加 -lboost_system
// 如果用独立 Asio:加 -DASIO_STANDALONE
#include <asio.hpp>
#include <iostream>
#include <memory>
using asio::ip::tcp;
class Session : public std::enable_shared_from_this<Session> {
public:
explicit Session(tcp::socket sock) : socket_(std::move(sock)) {}
void start() { doRead(); }
private:
void doRead() {
auto self = shared_from_this();
socket_.async_read_some(asio::buffer(buf_, sizeof(buf_)),
[this, self](const asio::error_code& ec, std::size_t n) {
if (!ec && n > 0) {
doWrite(n);
}
// ec 或 n==0 时 Session 自动析构(shared_ptr 引用归零)
});
}
void doWrite(std::size_t len) {
auto self = shared_from_this();
asio::async_write(socket_, asio::buffer(buf_, len),
[this, self](const asio::error_code& ec, std::size_t) {
if (!ec) {
doRead(); // 写完继续读
}
});
}
tcp::socket socket_;
char buf_[4096];
};
class Server {
public:
Server(asio::io_context& io, uint16_t port)
: acceptor_(io, {tcp::v4(), port}) {
doAccept();
}
private:
void doAccept() {
acceptor_.async_accept(
[this](const asio::error_code& ec, tcp::socket sock) {
if (!ec) {
std::make_shared<Session>(std::move(sock))->start();
}
doAccept(); // 继续等下一个连接
});
}
tcp::acceptor acceptor_;
};
int main() {
asio::io_context io;
Server server(io, 8080);
std::cout << "asio echo server listening on :8080\n";
io.run();
return 0;
}
34.5 这段代码和裸 epoll 版的对比¶
| 你在裸 epoll 版里做的事 | Asio 里谁帮你做了 |
|---|---|
socket() + bind() + listen() |
tcp::acceptor 构造函数 |
setNonBlocking() |
Asio 内部自动设置 |
epoll_create + epoll_ctl |
Asio 内部自动管理 |
while(epoll_wait(...)) |
io_context::run() |
accept() 循环 |
async_accept + 回调 |
recv() 循环 + EAGAIN 处理 |
async_read_some + 回调 |
send() + 发送缓冲区 + EPOLLOUT |
async_write + 回调 |
连接上下文管理(unordered_map) |
shared_ptr<Session> 生命周期 |
核心感受¶
用 Asio 写网络服务,代码量大概是裸 epoll 的 ⅓ 到 ⅕,而且自动跨平台。
34.6 Asio 的异步模型:Proactor 还是 Reactor?¶
这是面试偶尔会问的进阶点。
Asio 的设计更接近 Proactor¶
- 你发起一个异步操作(比如
async_read) - 底层帮你完成 IO
- 完成后回调你
但在 Linux 上¶
Linux 内核并没有真正的 Proactor 式异步 IO(io_uring 之前)。
所以 Asio 在 Linux 上的实现是:
- 底层用 epoll(Reactor 风格)
- 但在 API 层封装成 Proactor 风格
面试稳妥回答¶
Asio 的编程接口是 Proactor 风格:你发起异步操作,完成后收到回调。但在 Linux 下它的底层实现仍然是基于 epoll 的 Reactor 模型,只是在库层面做了 Proactor 的包装。
34.7 Asio 里的 shared_ptr 生命周期管理¶
这是 Asio 代码里最常见的模式,也是初学者最容易搞不懂的点。
为什么 Session 要继承 enable_shared_from_this?¶
因为异步回调是"发起操作后过一段时间才调用"的。
如果你不持有 Session 的引用:
- 回调还没来
- Session 对象已经析构了
- 回调触发时访问已释放内存 → 崩溃
所以常见做法:
- Session 继承
enable_shared_from_this - 每次发起异步操作时,capture
shared_from_this() - 只要还有未完成的异步操作,Session 就不会被析构
这就是为什么你会看到这种写法¶
self 保证了"只要这个回调还没执行,Session 就活着"。
面试里怎么说¶
Asio 的异步回调模型下,连接对象的生命周期通常用
shared_ptr管理:只要还有未完成的异步操作持有引用,连接对象就不会被析构。这比手动管理 fd 和连接上下文更安全,但要注意避免循环引用。
34.8 Asio 的多线程模型¶
单线程¶
最简单:一个线程调 io.run()。
所有回调都在这个线程里串行执行。
多线程¶
多个线程同时调 io.run():
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back([&io]() { io.run(); });
}
这时回调可能在不同线程并发执行。 如果同一个连接的读写回调被并发调用,就需要同步。
strand:Asio 的串行化工具¶
asio::strand<asio::io_context::executor_type> strand(io.get_executor());
asio::async_read(sock, buf, asio::bind_executor(strand, handler));
strand 保证绑定到它的回调不会并发执行,即使多线程环境下也是串行的。
面试一句话¶
Asio 多线程模型下,
strand用于保证同一个连接的回调不被并发调用,避免加锁。
34.9 Asio 和其他 C++ 网络库的关系¶
| 库 | 特点 | 和 Asio 的关系 |
|---|---|---|
| Boost.Asio | 最主流、最成熟 | Asio 的 Boost 版本 |
| 独立 Asio | 不依赖 Boost | 同一个作者,API 几乎一样 |
| muduo | 陈硕写的教学级网络库 | Reactor 风格,裸 epoll,风格和 Asio 不同 |
| libevent / libev | C 语言事件库 | 更底层,不如 Asio 面向对象 |
| gRPC C++ | Google 的 RPC 框架 | 底层用了类似 Asio 的异步思路 |
| C++ 标准网络提案 | 未来可能进标准 | 基于 Asio 的设计 |
面试里如果被问"你了解哪些 C++ 网络库"¶
可以说:
我了解 Boost.Asio,它是 C++ 里最主流的异步 IO 库,底层在 Linux 上基于 epoll,API 是 Proactor 风格。另外也知道 muduo 是国内比较流行的教学级 Reactor 网络库。
34.10 面试里 Asio 相关的典型问题¶
问:Asio 的 io_context 是什么?¶
它是 Asio 的事件循环核心,相当于自己写 epoll 时的
while(epoll_wait(...))主循环。调用io.run()后,它会不断从内部事件队列中取出就绪事件并执行对应回调。
问:Asio 是 Reactor 还是 Proactor?¶
API 层面是 Proactor:你发起异步操作,完成后收到回调。但在 Linux 下底层实现基于 epoll,本质上是 Reactor + 库层 Proactor 包装。
问:为什么 Session 要用 shared_ptr?¶
因为异步回调可能在未来某个时刻才执行,必须保证回调触发时 Session 对象仍然存活。用
shared_ptr+shared_from_this()可以让未完成的异步操作持有 Session 引用,防止提前析构。
问:多线程下怎么保证安全?¶
用
strand。它保证绑定到同一个strand的回调串行执行,即使io_context在多个线程上运行。
问:你觉得用 Asio 和自己写 epoll 各有什么好处?¶
自己写 epoll 对底层机制理解更深,适合学习和极致性能调优;Asio 开发效率更高、跨平台、生命周期管理更安全,适合工程项目。两者不矛盾,理解了底层再用 Asio 会更清楚它在帮你做什么。
35. 一组很典型的面试追问链¶
- socket 编程的基本流程是什么?
- 服务端为什么要有 listen fd 和 conn fd?
- 阻塞 socket 有什么问题?
- 一连接一线程为什么不适合高并发?
- 非阻塞 socket 是什么?为什么非阻塞不等于异步?
- epoll 在解决什么问题?
- LT 和 ET 区别?为什么 ET 容易写错?
- 为什么
recv一次不等于一条完整消息? - 粘包拆包怎么处理?
- 为什么
send也可能只发一部分? - 什么是背压?慢连接怎么处理?
- 长连接为什么需要心跳和超时清理?
- 为什么高并发服务端常用 epoll + 线程池?
- Unix Domain Socket 和 TCP Socket 怎么选?
如果你能把这 14 个问题接住,网络编程这一块就已经比很多校招候选人强很多了。
36. 一份更像面试现场的总结回答¶
网络编程本质上是在用操作系统提供的 socket 接口,让两个进程通过网络或本机通信。对 TCP 服务端来说,基本流程是 socket、bind、listen、accept、recv、send、close。真正的难点不在于把最小 demo 写出来,而在于多连接场景下如何高效、稳定地管理连接和数据流。阻塞模型虽然简单,但在大量连接下资源利用率很差,所以工程上通常会用非阻塞 socket 配合 epoll 做事件驱动。再往上,还要处理 TCP 字节流带来的粘包拆包、发送不完整、长连接心跳、超时清理、慢连接和背压等问题。高并发 C++ 网络服务常见的整体方案,就是 epoll + Reactor + 线程池:IO 线程负责接住事件和收发数据,业务线程负责执行重逻辑。这套回答链既能覆盖从 0 上手,也能接住面试里的深入追问。
37. 复习建议:你应该至少掌握到什么程度?¶
如果你目标是“能接受面试拷打”,至少做到:
入门必须会¶
- socket 编程基本流程
- 服务端 listen fd / conn fd 区别
- 阻塞 / 非阻塞区别
recv == 0的含义EAGAIN是什么
进阶必须会¶
- epoll 的核心作用
- LT / ET 区别
- 粘包拆包原因与处理
- 输入缓冲区 / 输出缓冲区思路
- 长连接为什么要心跳和超时
面试加分会更强¶
- 背压和慢连接治理
- Reactor 思想
- epoll + 线程池分工
- Unix Domain Socket vs TCP Socket
- keepalive 和应用层心跳区别
- Asio 的 io_context / Proactor 包装 / shared_ptr 生命周期
- Asio 和裸 epoll 的关系
38. 你现在可以怎么练?¶
给你一个最实用的练习顺序:
- 自己手打一遍最小 TCP server/client
- 把 server 改成 echo server
- 给协议加上“4 字节长度头 + body”
- 把阻塞版改成非阻塞 + epoll 版
- 给每个连接加输入缓冲区和输出缓冲区
- 再补心跳、超时和慢连接处理思路
如果你真把这 6 步做过一遍,哪怕项目经验不多,面试里这一块也会明显更稳。
39. 和现有章节怎么串起来?¶
这一章建议和下面几篇一起看:
03_computer_network/01_tcp_udp_http.md- 看 TCP 字节流、粘包拆包、三次握手、长连接基础
03_computer_network/02_http_details.md- 看长连接、WebSocket、HTTP 层语义
02_operating_system/02_io_multiplexing.md- 看 epoll、Reactor、零拷贝
05_design_patterns_architecture/01_patterns_architecture.md- 看高并发系统线程模型和架构分层
这样你就能把:
- 协议
- 系统调用
- IO 模型
- 工程架构
四条线真正串起来。