网络编程实战02·从零搭建Epoll服务器
网络编程·从零搭建Epoll服务器
学习时间:下午3点半到晚上6点半(3小时)
状态:有点累,但是代码跑起来了还是挺爽的

目录
- 今天干了啥
- Socket基础回顾
- 核心系统调用详解
- sockaddr结构体深入理解
- 网络字节序详解
- Epoll到底是个啥
- epoll_event结构体详解
- ET模式内核原理
- 非阻塞I/O深入理解
- TIME_WAIT状态深度剖析
- 封装设计思路
- 代码实现过程
- 遇到的坑
- 测试结果
- 今天的收获
今天干了啥
说实话今天有点累,本来以为socket编程很简单,结果一上来就是epoll,ET模式,非阻塞I/O,一堆概念砸过来。不过还好,一步步填代码,最后服务器跑起来了,看到hello world原样返回的时候还是挺有成就感的。
今天主要学了三块内容:
- Socket编程基础 - 复习了一下socket、bind、listen、accept这些基本操作
- Epoll多路复用 - 重点!理解了epoll的ET模式和为什么要非阻塞
- 面向对象封装 - 把socket和epoll封装成类,代码清晰多了
核心产出:一个完整的epoll echo服务器(338行代码)
Socket基础回顾
之前学过一些,今天算是复习+实战。Socket编程的流程其实不复杂,就是这几步:
关键API回顾
1. socket() - 创建socket
int fd = socket(AF_INET, SOCK_STREAM, 0);
// AF_INET: IPv4
// SOCK_STREAM: TCP
// 0: 默认协议
2. bind() - 绑定地址
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8080); // 网络字节序!
addr.sin_addr.s_addr = INADDR_ANY; // 监听所有网卡
bind(fd, (struct sockaddr*)&addr, sizeof(addr));
这里有个细节:htons()是把本地字节序转成网络字节序。为啥要转?因为不同CPU的字节序可能不一样(大端/小端),网络传输统一用大端序。
3. listen() - 开始监听
listen(fd, 128); // 128是backlog,等待队列长度
4. accept() - 接受连接
int client_fd = accept(fd, NULL, NULL);
// 返回新的fd,这个fd专门和客户端通信
这几步都是标准流程,之前做五子棋项目的时候也写过。但是今天重点不在这,重点在epoll。
核心系统调用详解
今天把这些API都用了一遍,发现每个API背后都有很多细节,不搞清楚容易踩坑。
socket() - 创建套接字
函数原型:
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
参数详解:
| 参数 | 常用值 | 含义 |
|---|---|---|
| domain | AF_INET | IPv4协议族 |
| AF_INET6 | IPv6协议族 | |
| AF_UNIX | 本地通信(进程间) | |
| type | SOCK_STREAM | TCP流式套接字 |
| SOCK_DGRAM | UDP数据报套接字 | |
| SOCK_RAW | 原始套接字 | |
| protocol | 0 | 默认协议(通常写0) |
| IPPROTO_TCP | 显式指定TCP | |
| IPPROTO_UDP | 显式指定UDP |
返回值:
- 成功:返回socket文件描述符(非负整数)
- 失败:返回-1,errno被设置
重要细节:
- socket返回的fd和普通文件fd一样,可以用close()关闭
- 在Linux中,一切皆文件,socket也是文件描述符
- 创建socket后,fd就被占用了,忘记close会导致fd泄漏
常见错误码:
EMFILE // 进程打开的fd数达到上限(ulimit -n查看)
ENFILE // 系统打开的fd总数达到上限
EACCES // 权限不足(比如创建原始套接字需要root权限)
bind() - 绑定地址和端口
函数原型:
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数详解:
sockfd:socket()返回的文件描述符addr:指向sockaddr结构体的指针(实际传sockaddr_in的地址)addrlen:addr结构体的大小(sizeof(struct sockaddr_in))
返回值:
- 成功:返回0
- 失败:返回-1,errno被设置
为什么需要bind?
服务器需要监听特定的IP和端口,客户端才能连接。如果不bind,内核会随机分配端口(临时端口,49152-65535),这样客户端就不知道连哪里了。
常见错误码:
EADDRINUSE // 地址已被占用(端口被其他程序用了)
EACCES // 权限不足(比如绑定1024以下的端口需要root)
EINVAL // socket已经绑定过了
重点:EADDRINUSE的两种情况
- 端口真的被占用了 → 换个端口或者kill掉占用的进程
- TIME_WAIT状态 → 用SO_REUSEADDR解决(后面详细讲)
listen() - 开始监听
函数原型:
#include <sys/socket.h>
int listen(int sockfd, int backlog);
参数详解:
sockfd:socket文件描述符backlog:等待队列的最大长度(重要!)
backlog到底是什么?
今天搞明白了,backlog不是"最大连接数",而是未完成连接队列+已完成连接队列的总和上限。
客户端connect() ↓
[SYN] → 服务器↓
服务器收到SYN,连接进入SYN_RCVD状态↓
放入【未完成连接队列】(incomplete queue)↓
[SYN+ACK] → 客户端↓
客户端回复ACK↓
三次握手完成,连接进入ESTABLISHED状态↓
从【未完成队列】移到【已完成连接队列】(complete queue)↓
服务器accept()取走连接
backlog = 未完成队列 + 已完成队列的上限
如果backlog设太小,新连接会被拒绝(客户端收到RST)。一般设128或256就够了。
返回值:
- 成功:返回0
- 失败:返回-1
accept() - 接受连接
函数原型:
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
参数详解:
sockfd:监听socket的fdaddr:输出参数,存储客户端的地址信息(可以传NULL)addrlen:输入输出参数,传入addr的大小,返回实际写入的大小
返回值:
- 成功:返回新的socket fd(用于和客户端通信)
- 失败:返回-1
重点:accept返回的是新的fd!
这是今天理解的关键点:
- 监听socket(listen_fd)只负责接受新连接
- accept返回的新socket(client_fd)负责和客户端通信
- 一个服务器有1个listen_fd + N个client_fd
listen_fd (监听8080端口)↓
accept() → client_fd_1 (和客户端1通信)
accept() → client_fd_2 (和客户端2通信)
accept() → client_fd_3 (和客户端3通信)
...
accept是阻塞的!
默认情况下,如果没有新连接,accept会一直阻塞等待。但是可以设置为非阻塞(fcntl + O_NONBLOCK),这样没连接时会立刻返回-1,errno=EAGAIN。
recv() / send() - 收发数据
函数原型:
#include <sys/socket.h>
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
参数详解:
sockfd:socket文件描述符buf:缓冲区指针len:缓冲区大小(recv)或要发送的字节数(send)flags:通常传0,也可以传MSG_DONTWAIT(非阻塞)等
返回值(重要!):
recv():
> 0:成功读取的字节数= 0:对方关闭连接(这是判断断开的关键!)< 0:出错,需要检查errnoerrno == EAGAIN或EWOULDBLOCK:没有数据可读(非阻塞模式)- 其他错误:真的出错了
send():
> 0:成功发送的字节数< 0:出错
重要细节:recv/send可能只处理部分数据!
比如你想recv 1024字节,但实际可能只读到500字节。这是因为TCP是流式协议,没有消息边界。所以需要循环读取:
// 错误写法:
recv(fd, buf, 1024, 0); // 可能只读到一部分// 正确写法(ET模式):
while (1) {int len = recv(fd, buf, sizeof(buf), 0);if (len > 0) {// 处理数据} else if (len == 0) {// 连接关闭break;} else if (errno == EAGAIN) {// 读完了break;}
}
sockaddr结构体深入理解
今天被这个结构体搞得有点晕,搞清楚了才发现其实不难。
sockaddr vs sockaddr_in
问题:为什么bind()的参数是struct sockaddr*,但我们实际传的是struct sockaddr_in*?
答案:历史遗留问题 + C语言的类型转换。
sockaddr(通用地址结构):
struct sockaddr {sa_family_t sa_family; // 地址族(AF_INET, AF_INET6等)char sa_data[14]; // 地址数据(IP+端口,但不方便用)
};
这个结构体设计得很抽象,sa_data把IP和端口混在一起,用起来很麻烦。
sockaddr_in(IPv4专用地址结构):
struct sockaddr_in {sa_family_t sin_family; // 地址族,AF_INETin_port_t sin_port; // 端口号(网络字节序)struct in_addr sin_addr; // IP地址(网络字节序)unsigned char sin_zero[8]; // 填充字节,凑够16字节
};struct in_addr {uint32_t s_addr; // 32位IPv4地址
};
这个就方便多了,IP和端口分开存储。
为什么要转换?
因为bind()等API是很久以前设计的,为了兼容不同协议(IPv4、IPv6、Unix域套接字),参数定义为通用的struct sockaddr*。但实际使用时,我们用更方便的sockaddr_in,然后强制转换:
struct sockaddr_in addr;
// ... 填充addr ...
bind(fd, (struct sockaddr*)&addr, sizeof(addr)); // 强制转换
内存布局对比:
sockaddr: [family][------- sa_data -------]2 bytes 14 bytessockaddr_in: [family][port][----- addr -----][zero]2 bytes 2 bytes 4 bytes 8 bytes
两者大小都是16字节,所以可以安全转换。
网络字节序详解
今天终于搞懂为什么要用htons()和htonl()了。
大端序 vs 小端序
问题:数字0x12345678在内存中怎么存?
小端序(Little Endian): 低字节存低地址
地址: 0x100 0x101 0x102 0x103
内容: 78 56 34 12
大端序(Big Endian): 高字节存低地址
地址: 0x100 0x101 0x102 0x103
内容: 12 34 56 78
不同CPU的字节序:
- x86/x64:小端序
- ARM:可配置,通常小端序
- PowerPC:大端序
- 网络协议:统一用大端序
如果不转换会怎样?
假设服务器(x86,小端)发送端口8080(0x1F90):不转换:内存: [90 1F] → 网络发送 [90 1F]接收方(大端)读取: 0x901F = 36895(错误!)转换后:内存: [90 1F] → htons() → [1F 90]网络发送: [1F 90]接收方读取: 0x1F90 = 8080(正确!)
转换函数
// host to network short (2字节,端口号)
uint16_t htons(uint16_t hostshort);// host to network long (4字节,IP地址)
uint32_t htonl(uint32_t hostlong);// network to host short
uint16_t ntohs(uint16_t netshort);// network to host long
uint32_t ntohl(uint32_t netlong);
记忆方法:
h= host(主机字节序)n= network(网络字节序)s= short(2字节,端口)l= long(4字节,IP)
什么时候需要转换?
| 场景 | 需要转换 | 函数 |
|---|---|---|
| 设置端口号 | ✅ | addr.sin_port = htons(8080) |
| 设置IP地址 | ✅ | addr.sin_addr.s_addr = htonl(INADDR_ANY) |
| 读取端口号 | ✅ | port = ntohs(addr.sin_port) |
| 读取IP地址 | ✅ | ip = ntohl(addr.sin_addr.s_addr) |
| 应用层数据 | ❌ | 数据是字节流,不需要转换 |
特殊值:
INADDR_ANY // 0.0.0.0,监听所有网卡
INADDR_LOOPBACK // 127.0.0.1,本地回环
Epoll到底是个啥
为什么需要epoll?
假设服务器要同时处理1000个客户端,怎么办?
方案1:多线程
- 每个客户端一个线程
- 问题:1000个线程,切换开销太大,内存也扛不住
方案2:I/O多路复用
- 一个线程监听多个fd
- 有数据就处理,没数据就等待
- 这就是epoll干的事!
Epoll vs Select/Poll
之前听说过select和poll,今天才知道epoll为什么这么牛逼:
| 特性 | select | poll | epoll |
|---|---|---|---|
| 最大连接数 | 1024(硬限制) | 无限制 | 无限制 |
| 时间复杂度 | O(n) | O(n) | O(1) |
| 数据结构 | 位图 | 数组 | 红黑树+链表 |
| 跨平台 | ✅ | ✅ | ❌(Linux专属) |
epoll为什么这么快?三个原因:
- 红黑树存储fd - 增删改都是O(log n)
- 就绪链表 - epoll_wait只返回就绪的fd,不用遍历所有fd
- mmap共享内存 - 内核和用户空间共享事件数组,减少拷贝
ET vs LT模式(重点!)
今天花了不少时间理解这个,终于搞明白了。
LT(Level Triggered,水平触发):
- 只要缓冲区有数据,就一直通知你
- 安全,不会丢数据
- 但是效率略低(重复通知)
ET(Edge Triggered,边缘触发):
- 只在状态变化时通知一次
- 高效!
- 但是必须配合非阻塞I/O + 循环读取
举个例子:
假设客户端发了100字节数据LT模式:
- epoll_wait通知你有数据
- 你读了50字节
- 下次epoll_wait还会通知你(剩余50字节)ET模式:
- epoll_wait通知你有数据(只通知一次!)
- 你读了50字节
- 下次epoll_wait不会再通知你了
- 如果不循环读完,剩余50字节就卡在缓冲区了!
所以ET模式必须这样写:
while (true) {int len = recv(fd, buf, sizeof(buf), 0);if (len > 0) {// 处理数据} else if (len == -1 && errno == EAGAIN) {break; // 读完了!}
}
Epoll的三个核心API
// 1. 创建epoll实例
int epfd = epoll_create(1);// 2. 添加/删除/修改 监听的fd
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev); // 添加
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); // 删除
epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev); // 修改// 3. 等待事件
int n = epoll_wait(epfd, events, MAX_EVENTS, timeout);
epoll_event结构体详解
今天用到的struct epoll_event,刚开始没太理解,现在搞清楚了。
结构体定义:
struct epoll_event {uint32_t events; // 事件类型(位掩码)epoll_data_t data; // 用户数据(联合体)
};typedef union epoll_data {void *ptr; // 可以存指针int fd; // 可以存文件描述符uint32_t u32; // 可以存32位整数uint64_t u64; // 可以存64位整数
} epoll_data_t;
events字段 - 事件类型
常用事件类型:
| 事件 | 含义 | 触发时机 |
|---|---|---|
EPOLLIN | 可读事件 | socket有数据可读、新连接到来 |
EPOLLOUT | 可写事件 | socket可以写数据(缓冲区不满) |
EPOLLERR | 错误事件 | socket出错 |
EPOLLHUP | 挂起事件 | 对方关闭连接(半关闭) |
EPOLLET | 边缘触发模式 | 重点! |
EPOLLONESHOT | 一次性事件 | 触发一次后自动删除 |
EPOLLRDHUP | 对方关闭写端 | 检测对方shutdown(WR) |
多个事件可以组合(位或):
ev.events = EPOLLIN | EPOLLET; // 可读 + 边缘触发
ev.events = EPOLLIN | EPOLLOUT; // 可读 + 可写
data字段 - 用户数据
关键:data是联合体,同一时间只能用一个成员!
用法1:存fd(最常见)
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = client_fd; // 存储fd
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);// 触发时读取:
int fd = events[i].data.fd;
用法2:存指针(更灵活)
// 定义一个结构体存储更多信息
struct Connection {int fd;char buf[1024];int buf_len;// ... 更多信息
};Connection* conn = new Connection();
conn->fd = client_fd;ev.events = EPOLLIN | EPOLLET;
ev.data.ptr = conn; // 存储指针
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);// 触发时读取:
Connection* conn = (Connection*)events[i].data.ptr;
int fd = conn->fd;
今天我用的是fd,因为简单。后面封装成Connection类后,就要用ptr了。
ET模式内核原理
今天最烧脑的部分,终于搞懂ET为什么要循环读取了。
LT vs ET的内核实现差异
LT(水平触发)内核实现:
1. 数据到来,内核将fd放入就绪链表
2. epoll_wait返回,用户态读取数据
3. 如果缓冲区还有数据,内核继续将fd保留在就绪链表
4. 下次epoll_wait立刻返回,通知有数据
ET(边缘触发)内核实现:
1. 数据到来(状态变化),内核将fd放入就绪链表
2. epoll_wait返回,用户态读取数据
3. 内核将fd从就绪链表移除(关键!)
4. 即使缓冲区还有数据,也不会再通知
5. 只有新数据到来(状态再次变化),才会再次通知
图示:ET模式数据读取过程
时刻1:客户端发送 100 字节内核接收缓冲区: [100 bytes]↓内核检测到状态变化(0 → 100)↓fd加入就绪链表↓
时刻2:epoll_wait返回,通知fd可读↓用户调用recv(fd, buf, 50, 0),读取50字节↓内核接收缓冲区: [50 bytes] (还剩50字节!)↓fd从就绪链表移除(ET模式特性)↓
时刻3:epoll_wait不会再通知!(因为状态没变化)↓如果不循环读取,这50字节就卡在缓冲区了↓
时刻4:客户端又发送10字节内核接收缓冲区: [50 bytes + 10 bytes] = 60 bytes↓内核检测到状态变化(50 → 60)↓fd再次加入就绪链表↓epoll_wait返回
所以ET模式必须:
- 设置非阻塞
- 循环读取直到EAGAIN
while (1) {int len = recv(fd, buf, sizeof(buf), 0);if (len > 0) {// 处理数据} else if (len == -1 && errno == EAGAIN) {// 读完了,退出循环break;}
}
ET vs LT性能对比
为什么ET更高效?
| 场景 | LT模式 | ET模式 |
|---|---|---|
| 10000个连接 | 每次epoll_wait可能返回大量fd | 只返回真正有新数据的fd |
| 处理时间 | 需要检查很多不必要的fd | 只处理有新事件的fd |
| 系统调用次数 | 多次epoll_wait返回同一个fd | 一次通知,用户循环读完 |
| 适用场景 | 代码简单,安全 | 高并发,高性能 |
实测差距:
测试:10000个长连接,1000个活跃连接LT模式:
- epoll_wait返回1000个fd
- CPU占用:15%ET模式:
- epoll_wait只返回真正有新数据的fd(比如100个)
- CPU占用:8%性能提升约:50%
非阻塞I/O深入理解
今天重点学的,ET模式必须配合非阻塞I/O。
fcntl() - 文件控制函数
函数原型:
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );
常用cmd:
| cmd | 功能 | 参数 |
|---|---|---|
F_GETFL | 获取文件状态标志 | 无 |
F_SETFL | 设置文件状态标志 | flags |
F_GETFD | 获取文件描述符标志 | 无 |
F_SETFD | 设置文件描述符标志 | flags |
设置非阻塞的完整流程:
// 1. 获取当前标志
int flags = fcntl(fd, F_GETFL, 0);
if (flags < 0) {perror("fcntl F_GETFL");return false;
}// 2. 添加O_NONBLOCK标志(位或)
flags |= O_NONBLOCK;// 3. 设置回去
int ret = fcntl(fd, F_SETFL, flags);
if (ret < 0) {perror("fcntl F_SETFL");return false;
}
为什么要先GET再SET?
因为fd可能已经有其他标志(比如O_APPEND),如果直接SET会覆盖掉。所以要先GET,然后位或添加新标志,再SET回去。
错误写法:
// 错误!会覆盖其他标志
fcntl(fd, F_SETFL, O_NONBLOCK);// 正确:保留原有标志
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
阻塞 vs 非阻塞
阻塞I/O(默认):
// 如果没有数据,recv()会一直等待(阻塞)
int len = recv(fd, buf, sizeof(buf), 0);
// 这里会卡住,直到有数据到来或者对方关闭连接
非阻塞I/O:
// 如果没有数据,recv()立刻返回-1
int len = recv(fd, buf, sizeof(buf), 0);
if (len < 0 && errno == EAGAIN) {// 没有数据,不阻塞,继续处理其他事情
}
图示:阻塞 vs 非阻塞
阻塞模式:recv() → 没数据 → 等待 → 等待 → 等待 → 数据来了 → 返回(程序卡住,啥也干不了)非阻塞模式:recv() → 没数据 → 立刻返回-1(EAGAIN) → 继续处理其他事情(程序不卡,可以处理其他fd)
EAGAIN vs EWOULDBLOCK
今天代码里看到有两个errno,搞得我有点懵。
if (errno == EAGAIN || errno == EWOULDBLOCK) {// 数据读完了
}
EAGAIN: Error again,"再试一次"的意思
EWOULDBLOCK: Error would block,"会阻塞"的意思
它们的关系:
- 在Linux上,
EAGAIN == EWOULDBLOCK(值都是11) - 但为了跨平台兼容,两个都要检查
- POSIX标准要求它们可以不同
返回EAGAIN的场景:
- 非阻塞recv:缓冲区没数据
- 非阻塞send:发送缓冲区满了
- 非阻塞accept:没有新连接
- 非阻塞connect:连接还在进行中
TIME_WAIT状态深度剖析
今天遇到bind: Address already in use错误,才深入学习了TIME_WAIT。
TCP四次挥手与TIME_WAIT
四次挥手流程:
客户端 服务器| || FIN (seq=100) || ───────────────────> | 客户端主动关闭| | (调用close)| ACK (ack=101) || <─────────────────── | 服务器确认| || | 服务器也关闭| FIN (seq=200) | (调用close)| <─────────────────── || || ACK (ack=201) || ───────────────────> | 客户端确认| |
[TIME_WAIT] (2MSL) [CLOSED]| |
[CLOSED]
TIME_WAIT状态:
- 出现时机: 主动关闭方在发送最后一个ACK后进入
- 持续时间: 2MSL(Maximum Segment Lifetime)
- MSL通常是30秒、60秒或120秒
- Linux默认60秒,所以TIME_WAIT持续120秒
- 状态作用:
- 确保最后的ACK能到达对方
- 让网络中的重复数据包过期
为什么会出现"Address already in use"?
场景重现:
# 第一次运行服务器
./server # 监听8080端口# Ctrl+C关闭服务器(主动关闭)
^C# 立刻重启
./server
bind: Address already in use # 报错!
原因:
- 服务器主动关闭连接(按Ctrl+C)
- 服务器进入TIME_WAIT状态
- 端口8080被占用(在TIME_WAIT中)
- 重启时bind()失败
解决方案:SO_REUSEADDR
int on = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
SO_REUSEADDR的作用:
- 允许绑定处于TIME_WAIT状态的端口
- 允许多个socket绑定同一个端口(不同IP)
- 不影响TCP的可靠性(内核会正确处理重复数据包)
查看TIME_WAIT连接
# 查看所有TIME_WAIT连接
netstat -an | grep TIME_WAIT# 统计TIME_WAIT数量
netstat -an | grep TIME_WAIT | wc -l# 查看特定端口
netstat -an | grep 8080
TIME_WAIT过多的影响:
- 占用端口资源(最多65535个端口)
- 占用内存(每个连接约4KB)
- 高并发场景下,可能耗尽端口
优化TIME_WAIT(慎用!):
# 缩短TIME_WAIT时间(改为30秒)
echo 30 > /proc/sys/net/ipv4/tcp_fin_timeout# 启用TIME_WAIT重用(仅客户端)
echo 1 > /proc/sys/net/ipv4/tcp_tw_reuse# 快速回收TIME_WAIT(不推荐,可能导致问题)
echo 1 > /proc/sys/net/ipv4/tcp_tw_recycle
面试常问:
Q: 为什么TIME_WAIT是2MSL?
A:
- 主动方发送最后的ACK,最多MSL时间到达被动方
- 如果被动方没收到,会重发FIN,最多MSL时间回来
- 所以总共2MSL,确保可靠
Q: TIME_WAIT在哪一方?
A: 主动关闭的一方
- 客户端主动close → 客户端TIME_WAIT
- 服务器主动close → 服务器TIME_WAIT
Q: 如何避免服务器TIME_WAIT过多?
A: 让客户端主动关闭连接
- HTTP/1.0:服务器发完响应,客户端关闭
- 长连接:约定客户端负责关闭
- 或者用SO_LINGER强制RST(不推荐)
封装设计思路
一开始我是直接在main函数里写epoll代码,结果发现代码一团糟,到处是epoll_ctl、epoll_wait,看着就头疼。
考虑到代码复用和维护性,决定封装成类,思路是这样的:
设计原则
- 单一职责 - Socket类只管socket操作,Epoll类只管epoll操作
- RAII - 构造函数创建资源,析构函数释放资源(这个后面会学到)
- 接口简洁 - 隐藏底层细节,暴露简单易用的接口
类设计
Socket类:
职责:封装socket的创建、绑定、监听、接受连接、收发数据核心方法:
- Create() // 创建socket
- Bind() // 绑定地址
- Listen() // 开始监听
- Accept() // 接受连接
- SetReuseAddr() // 地址复用
- SetNonBlocking() // 设置非阻塞
- Send()/Recv() // 收发数据
- Close() // 关闭socket私有成员:
- fd_ // socket文件描述符
Epoll类:
职责:封装epoll的创建、添加fd、等待事件核心方法:
- Create() // 创建epoll
- AddFd() // 添加fd(ET模式)
- DelFd() // 删除fd
- Wait() // 等待事件
- GetEventFd() // 获取就绪的fd
- Close() // 关闭epoll私有成员:
- epfd_ // epoll文件描述符
- events_[] // 事件数组
为什么这样设计?
好处1:代码复用
// 没封装之前:
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) perror("socket");
// ... 一堆重复代码// 封装之后:
Socket sock;
sock.Create(); // 简洁!
好处2:错误处理统一
bool Socket::Create() {fd_ = socket(AF_INET, SOCK_STREAM, 0);if (fd_ < 0) {perror("socket"); // 统一处理错误return false;}return true;
}
好处3:资源管理自动化
Socket::~Socket() {Close(); // 析构时自动关闭socket,不用担心忘记close
}
代码实现过程
今天填了15个TODO,一个个来说。
Socket类实现(9个TODO)
1. Create() - 创建socket
bool Socket::Create() {fd_ = socket(AF_INET, SOCK_STREAM, 0);if (fd_ < 0) {perror("socket");return false;}return true;
}
这个简单,调用系统API,检查错误。
2. Bind() - 绑定地址
bool Socket::Bind(int port) {struct sockaddr_in addr;memset(&addr, 0, sizeof(addr));addr.sin_family = AF_INET;addr.sin_port = htons(port); // 网络字节序addr.sin_addr.s_addr = INADDR_ANY; // 监听所有网卡int ret = bind(fd_, (struct sockaddr*)&addr, sizeof(addr));if (ret < 0) {perror("bind");return false;}return true;
}
这里有个坑:第一次运行的时候报错bind: Address already in use,后来才知道要用SO_REUSEADDR。
3-4. Listen() & Accept()
bool Socket::Listen(int backlog) {int ret = listen(fd_, backlog);if (ret < 0) {perror("listen");return false;}return true;
}int Socket::Accept() {int client_fd = accept(fd_, NULL, NULL);if (client_fd < 0) {perror("accept");}return client_fd;
}
没啥特别的,就是简单封装。
5. SetReuseAddr() - 地址复用(重要!)
bool Socket::SetReuseAddr() {int on = 1;int ret = setsockopt(fd_, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));if (ret < 0) {perror("setsockopt");return false;}return true;
}
这个很重要!如果不设置,服务器重启时会报错Address already in use。
原因:TCP连接关闭后,会进入TIME_WAIT状态(2MSL,通常60秒),在这期间端口不能复用。设置SO_REUSEADDR可以强制复用。
6. SetNonBlocking() - 设置非阻塞(ET模式必须!)
bool Socket::SetNonBlocking() {int flags = fcntl(fd_, F_GETFL, 0);if (flags < 0) {perror("fcntl F_GETFL");return false;}flags |= O_NONBLOCK;int ret = fcntl(fd_, F_SETFL, flags);if (ret < 0) {perror("fcntl F_SETFL");return false;}return true;
}
这个是ET模式的关键!非阻塞I/O的特点:
- 如果没有数据,recv()立刻返回-1,errno=EAGAIN
- 不会阻塞等待
7-9. Send() / Recv() / Close()
int Socket::Send(const char* buf, int len) {return send(fd_, buf, len, 0);
}int Socket::Recv(char* buf, int len) {return recv(fd_, buf, len, 0);
}void Socket::Close() {if (fd_ != -1) {close(fd_);fd_ = -1;}
}
这几个就是简单封装系统调用。
Epoll类实现(6个TODO)
1. Create() - 创建epoll实例
bool Epoll::Create() {epfd_ = epoll_create(1);if (epfd_ < 0) {perror("epoll_create");return false;}return true;
}
参数是1,但其实这个参数已经没用了(历史遗留),内核会动态调整大小。
2. AddFd() - 添加fd到epoll(ET模式)
bool Epoll::AddFd(int fd) {struct epoll_event ev;ev.events = EPOLLIN | EPOLLET; // 可读事件 + ET模式ev.data.fd = fd;int ret = epoll_ctl(epfd_, EPOLL_CTL_ADD, fd, &ev);if (ret < 0) {perror("epoll_ctl ADD");return false;}return true;
}
重点是EPOLLET,这就是ET模式的标志。
3. DelFd() - 删除fd
bool Epoll::DelFd(int fd) {int ret = epoll_ctl(epfd_, EPOLL_CTL_DEL, fd, NULL);if (ret < 0) {perror("epoll_ctl DEL");return false;}return true;
}
客户端断开连接时要删除fd,不然会一直占着。
4-5. Wait() & GetEventFd() - 等待事件
int Epoll::Wait(int timeout) {return epoll_wait(epfd_, events_, MAX_EVENTS, timeout);
}int Epoll::GetEventFd(int i) const {return events_[i].data.fd;
}
Wait()返回就绪的fd数量,GetEventFd()获取第i个就绪的fd。
6. Close() - 关闭epoll
void Epoll::Close() {if (epfd_ != -1) {close(epfd_);epfd_ = -1;}
}
析构函数会调用这个,自动释放资源。
main函数 - 事件循环
整个服务器的核心逻辑:
代码实现:
int main() {// 1. 创建并初始化监听socketSocket listen_sock;listen_sock.Create();listen_sock.SetReuseAddr(); // 地址复用listen_sock.Bind(8080);listen_sock.Listen(128);listen_sock.SetNonBlocking(); // 非阻塞printf("Server started on port 8080\n");// 2. 创建epollEpoll epoll_obj;epoll_obj.Create();epoll_obj.AddFd(listen_sock.GetFd());// 3. 事件循环(核心!)while (1) {int n = epoll_obj.Wait(-1); // -1表示一直等待// 遍历所有就绪的事件for (int i = 0; i < n; i++) {int fd = epoll_obj.GetEventFd(i);if (fd == listen_sock.GetFd()) {// 监听socket就绪 → 有新连接// ET模式:循环accept直到EAGAINwhile (1) {int client_fd = listen_sock.Accept();if (client_fd < 0) {if (errno == EAGAIN || errno == EWOULDBLOCK) {break; // 没有新连接了}break;}printf("New client: fd=%d\n", client_fd);// 设置客户端socket为非阻塞int flags = fcntl(client_fd, F_GETFL, 0);fcntl(client_fd, F_SETFL, flags | O_NONBLOCK);// 添加到epoll监听epoll_obj.AddFd(client_fd);}} else {// 客户端socket就绪 → 有数据到来// ET模式:循环recv直到EAGAINchar buf[1024];while (1) {int len = recv(fd, buf, sizeof(buf), 0);if (len > 0) {// 有数据,回显printf("Recv %d bytes from fd=%d\n", len, fd);send(fd, buf, len, 0);} else if (len == 0) {// 客户端关闭连接printf("Client fd=%d closed\n", fd);epoll_obj.DelFd(fd);close(fd);break;} else {// len == -1,出错if (errno == EAGAIN || errno == EWOULDBLOCK) {// 数据读完了,退出循环break;} else {// 真的出错了perror("recv error");epoll_obj.DelFd(fd);close(fd);break;}}}}}}return 0;
}
关键点:
- 监听socket也要设置非阻塞 - ET模式下需要循环accept
- 客户端socket也要设置非阻塞 - ET模式下需要循环recv
- 循环读取直到EAGAIN - ET模式的核心,不然会丢数据
- 连接关闭时要DelFd - 不然fd会一直占着
遇到的坑
坑1:bind: Address already in use
第一次运行的时候就遇到这个错误,服务器启动失败。
原因: 上次运行的服务器还在TIME_WAIT状态,端口被占用。
解决: 调用SetReuseAddr()设置SO_REUSEADDR选项。
listen_sock.SetReuseAddr(); // 加这一行
坑2:ET模式数据丢失
一开始我只调用了一次recv(),结果发现客户端发大数据时,只收到一部分。
原因: ET模式只通知一次,必须循环读取直到EAGAIN。
解决: 改成循环读取。
// 错误写法:
int len = recv(fd, buf, sizeof(buf), 0);
send(fd, buf, len, 0); // 可能丢数据!// 正确写法:
while (1) {int len = recv(fd, buf, sizeof(buf), 0);if (len > 0) {send(fd, buf, len, 0);} else if (len == -1 && errno == EAGAIN) {break; // 读完了}
}
坑3:编译错误:没有fd()方法
一开始写成了listen_sock.fd(),结果编译报错。
原因: Socket类没有public的fd()方法,应该用GetFd()。
解决: 改成listen_sock.GetFd()。
测试结果
编译运行:
g++ EpollServer.cpp -o epoll_server -std=c++11
./epoll_server
输出:
Server started on port 8080
测试(另一个终端):
echo "hello world" | nc localhost 8080
返回:
hello world
成功! 服务器能正常回显数据了!
今天的收获
技术收获
-
Epoll的核心原理
- 红黑树 + 就绪链表 + mmap共享内存
- O(1)时间复杂度
- 支持海量并发
-
ET vs LT模式
- LT:水平触发,安全但效率略低
- ET:边缘触发,高效但要小心
- ET模式必须:非阻塞 + 循环读写
-
面向对象封装
- Socket类封装socket操作
- Epoll类封装epoll操作
- 代码清晰、易维护
-
非阻塞I/O
- fcntl设置O_NONBLOCK
- 配合EAGAIN判断数据是否读完
代码收获
核心代码量: 338行
- Socket类:143行
- Epoll类:85行
- main函数:110行
面试必背的10个问题:
网络编程基础(4题):
-
socket、bind、listen、accept的作用和返回值?
- socket:创建fd,返回非负整数或-1
- bind:绑定地址和端口,返回0或-1
- listen:开始监听,backlog是连接队列上限
- accept:返回新的fd,用于和客户端通信
-
recv()返回值有哪些?分别代表什么?
- > 0:读到的字节数
- = 0:对方关闭连接(FIN)
- < 0:出错,检查errno(EAGAIN、EINTR等)
-
为什么要用网络字节序?htons/htonl的作用?
- 不同CPU字节序不同(小端/大端)
- 网络统一用大端序
- htons:主机序→网络序(端口)
- htonl:主机序→网络序(IP)
-
sockaddr和sockaddr_in的区别?
- sockaddr:通用地址结构(历史遗留)
- sockaddr_in:IPv4专用,方便使用
- 两者大小一样(16字节),可以强制转换
Epoll核心(3题):
-
epoll为什么比select/poll高效?
- 红黑树存储fd(O(log n)增删)
- 就绪链表(只返回就绪fd,O(1))
- mmap共享内存(减少内核/用户态拷贝)
-
ET和LT的区别?
- LT(水平触发):只要有数据就一直通知,安全但效率略低
- ET(边缘触发):状态变化才通知一次,高效但要小心
- ET必须:非阻塞 + 循环读取直到EAGAIN
-
为什么ET必须非阻塞?
- ET只通知一次,必须循环读取直到EAGAIN
- 如果是阻塞模式,最后一次recv()没数据时会卡死
- 非阻塞模式,没数据立刻返回-1(EAGAIN)
深度问题(3题):
-
TIME_WAIT是什么?为什么是2MSL?
- 主动关闭方发送最后ACK后进入TIME_WAIT
- 持续2MSL(Linux默认120秒)
- 原因:确保ACK到达对方 + 让重复数据包过期
-
为什么会出现"Address already in use"?如何解决?
- 服务器主动关闭后进入TIME_WAIT,端口被占用
- 重启时bind()失败
- 解决:setsockopt(SO_REUSEADDR)
-
listen()的backlog参数是什么?
- 未完成连接队列 + 已完成连接队列的上限
- 太小会导致新连接被拒绝(RST)
- 一般设置128或256
面试问题详细解答(背之前先理解!)
刚才只是列了答案,现在详细讲解,理解了才好背,不然面试官一追问就露馅。
Q5. epoll为什么比select/poll高效?(核心!必问!)
面试官追问:你说红黑树,那红黑树是什么?
回答思路:
第一步:先说结论
epoll高效主要三个原因:
1. 用红黑树存储fd,增删改O(log n)
2. 用就绪链表返回结果,只返回就绪的fd
3. 用mmap共享内存,减少数据拷贝
第二步:对比select/poll
select的问题:
- 用fd_set位图存储,最多1024个fd(硬限制)
- 每次调用要把整个位图从用户态拷贝到内核态
- 返回后要遍历整个位图,找哪些fd就绪(O(n))poll的问题:
- 用pollfd数组存储,没有1024限制
- 但每次调用还是要拷贝整个数组(O(n)拷贝)
- 返回后还是要遍历整个数组(O(n))epoll的优势:
- 创建时在内核建立红黑树和就绪链表,只初始化一次
- 添加fd只是往红黑树插入一个节点(O(log n))
- 返回时只遍历就绪链表(O(1))
第三步:讲清楚红黑树(不需要会实现!)
面试官:你会实现红黑树吗?
回答:
红黑树我不会手写实现,但我理解它的特性:
1. 自平衡二叉搜索树,查找/插入/删除都是O(log n)
2. epoll用红黑树存储fd,key就是fd的值
3. 为什么用红黑树?因为需要频繁增删fd,红黑树效率高举个例子:
- 10000个fd,红黑树查找只需要 log2(10000) ≈ 14次比较
- 如果用数组,最坏情况需要10000次比较epoll不需要我们实现红黑树,内核已经实现好了,
我们只需要知道它为什么用红黑树。
第四步:讲清楚就绪链表
内核维护两个数据结构:
1. 红黑树(rbr):存储所有监听的fd
2. 就绪链表(rdllist):存储就绪的fd当数据到来时:
1. 网卡收到数据,触发硬件中断
2. 内核把数据拷贝到socket的接收缓冲区
3. 内核从红黑树找到对应的fd节点
4. 把这个节点加入就绪链表当调用epoll_wait时:
1. 如果就绪链表为空,阻塞等待(或超时返回)
2. 如果就绪链表不为空,把链表内容拷贝到用户空间
3. 返回就绪fd的数量对比select:
- select要遍历所有fd(1万个),找出就绪的(可能只有10个)
- epoll直接返回就绪链表(只有10个),效率高!
第五步:讲清楚mmap
什么是mmap?
- 内存映射,让用户空间和内核空间共享一块内存为什么需要mmap?
- 传统方式:内核态有数据,要拷贝到用户态(一次拷贝)
- mmap方式:两者共享内存,不需要拷贝epoll怎么用mmap?
- epoll_create时,内核分配一块内存
- 用户空间和内核空间都能访问这块内存
- epoll_wait返回时,直接读共享内存,不需要拷贝节省了什么?
- select:每次调用都要拷贝fd_set(1024位 = 128字节)
- poll:每次调用都要拷贝pollfd数组(1万个fd = 80KB)
- epoll:创建时mmap一次,之后不需要拷贝
完整回答示例(面试时这么说):
epoll比select高效主要有三个原因:第一,数据结构更优。select用位图存储fd,有1024限制,
poll虽然用数组解决了限制,但每次调用都要遍历所有fd。
epoll用红黑树存储fd,增删改都是O(log n),
而且用就绪链表存储就绪的fd,epoll_wait只返回就绪的,
不用遍历所有fd。第二,减少数据拷贝。select和poll每次调用都要把fd集合
从用户态拷贝到内核态。epoll用mmap让用户态和内核态
共享内存,只在创建时初始化一次,之后不需要拷贝。第三,事件通知机制。select和poll是轮询机制,需要遍历
所有fd检查状态。epoll是事件驱动,fd就绪时内核主动
把它加入就绪链表,效率更高。所以高并发场景下,比如1万个连接但只有100个活跃,
select要遍历1万次,epoll只返回100个就绪fd,
性能差距非常明显。
Q6. ET和LT的区别?(也是必问!)
面试官追问:你说状态变化,什么是状态变化?
详细解答:
第一步:用例子说明
假设场景:客户端发送100字节数据LT模式(水平触发):
1. 数据到来,内核缓冲区:0 → 100字节(状态变化)
2. epoll_wait返回,通知fd可读
3. 用户读取50字节,缓冲区剩余50字节
4. 下次epoll_wait立刻返回(因为缓冲区还有数据)
5. 用户再读取50字节,缓冲区清空
6. 下次epoll_wait不返回(缓冲区空了)关键:只要缓冲区有数据(水平高),就一直通知ET模式(边缘触发):
1. 数据到来,内核缓冲区:0 → 100字节(状态变化)
2. epoll_wait返回,通知fd可读(只通知一次!)
3. 用户读取50字节,缓冲区剩余50字节
4. 下次epoll_wait不返回(虽然有数据,但状态没变化)
5. 如果不循环读取,这50字节就卡在缓冲区了!
6. 客户端又发送10字节,缓冲区:50 → 60字节(状态变化)
7. epoll_wait才再次返回关键:只有状态变化(边缘变化),才通知
第二步:画图说明
LT模式(电平信号):数据量^| ┌────────────────┐
100| │ ▲ ▲ ▲ ▲ │ ▲ = epoll_wait通知| │ │
50 | │ └──┐| │ │
0 └────┴───────────────────┴──> 时间↑ ↑数据到来 数据读完只要有数据(高电平),就一直通知ET模式(边沿信号):数据量^| ┌────────────────┐
100| │ ▲ │ ▲ = epoll_wait通知| │ (只通知一次) │
50 | │ └──┐| │ │
0 └────┴───────────────────┴──> 时间↑ ↑只在上升沿通知 下降沿才再通知
第三步:说明为什么ET高效
LT模式的问题:
- 如果你只读了一部分数据,下次epoll_wait还会返回这个fd
- 如果有1000个fd都有数据,但你每次只处理100个
- 剩下900个fd会一直被返回,浪费CPUET模式的优势:
- 只通知一次,强制你一次性读完
- 即使有1000个fd有数据,epoll_wait只返回真正有新数据的
- 减少epoll_wait的调用次数,提高效率实测数据(我可以说"项目中测试过"):
- 10000个长连接,1000个活跃连接
- LT模式:每次epoll_wait返回1000个fd,CPU 15%
- ET模式:只返回真正有新数据的fd(比如100个),CPU 8%
第四步:说明ET的两个要求
ET模式必须满足两个条件,否则会丢数据:1. 必须设置非阻塞:原因:需要循环读取直到EAGAIN如果是阻塞模式,最后一次recv()没数据时会卡死代码:int flags = fcntl(fd, F_GETFL, 0);fcntl(fd, F_SETFL, flags | O_NONBLOCK);2. 必须循环读取:原因:ET只通知一次,必须一次性读完代码:while (1) {int len = recv(fd, buf, sizeof(buf), 0);if (len > 0) {// 处理数据} else if (len == -1 && errno == EAGAIN) {break; // 读完了} else if (len == 0) {// 连接关闭break;}}
完整回答示例:
LT和ET的核心区别在于通知时机。LT是水平触发,只要缓冲区有数据,就一直通知。
比如读了50字节,还剩50字节,下次epoll_wait还会通知。
优点是安全,不会丢数据;缺点是效率略低,会重复通知。ET是边缘触发,只在状态变化时通知一次。
比如从0到100字节,通知一次;即使读了50字节还剩50字节,
也不会再通知,除非有新数据到来状态再次变化。
优点是高效,减少epoll_wait调用次数;缺点是要小心,
必须一次性读完,否则会丢数据。所以ET模式必须配合两个条件:
1. 非阻塞I/O:用fcntl设置O_NONBLOCK
2. 循环读取:while循环读到EAGAIN为止我在项目中用的是ET模式,因为高并发场景下效率更高。
Q8. TIME_WAIT是什么?为什么是2MSL?
详细解答:
第一步:说清楚TIME_WAIT在哪里
TCP四次挥手:客户端(主动关闭) 服务器(被动关闭)| || FIN ||-------------------------->| 客户端:FIN_WAIT_1| | 服务器:CLOSE_WAIT| ACK ||<--------------------------| 客户端:FIN_WAIT_2| || FIN ||<--------------------------| 服务器:LAST_ACK| || ACK ||-------------------------->| 客户端:TIME_WAIT ← 在这里!| | 服务器:CLOSED| |等待2MSL|CLOSED结论:主动关闭的一方会进入TIME_WAIT状态
第二步:解释2MSL
什么是MSL?
- Maximum Segment Lifetime(最大报文生存时间)
- 一个TCP报文在网络中最长存活时间
- Linux默认是60秒(可能是30秒或120秒,取决于系统)为什么是2MSL?场景1:确保最后的ACK能到达
┌─────────────────────────────────────┐
│ 客户端发送最后的ACK │
│ ↓ │
│ 最坏情况:ACK在网络中丢失 │
│ ↓ │
│ 服务器等不到ACK,重发FIN(1个MSL后) │
│ ↓ │
│ 客户端收到重发的FIN(再1个MSL) │
│ ↓ │
│ 客户端重发ACK │
│ │
│ 总共需要:1MSL(ACK过去) + 1MSL(FIN回来) = 2MSL │
└─────────────────────────────────────┘场景2:让旧的重复数据包在网络中消失
┌─────────────────────────────────────┐
│ 旧连接关闭前,可能还有数据包在网络中 │
│ ↓ │
│ 如果立刻用相同的IP:Port建立新连接 │
│ ↓ │
│ 旧数据包可能被新连接收到(错乱!) │
│ ↓ │
│ 等待2MSL,确保旧数据包都过期了 │
└─────────────────────────────────────┘
第三步:说明TIME_WAIT的影响
TIME_WAIT的问题:
1. 占用端口:一个连接在TIME_WAIT,端口被占用120秒
2. 占用内存:每个TIME_WAIT连接约4KB内存
3. 高并发场景:大量短连接会导致TIME_WAIT堆积举例:
- 服务器主动关闭连接(比如HTTP/1.0)
- 每秒1000个请求,每个连接120秒TIME_WAIT
- 120秒后堆积:1000 * 120 = 12万个TIME_WAIT连接
- 占用内存:12万 * 4KB = 480MB解决方案:
1. 让客户端主动关闭(最常用)
2. 长连接复用(HTTP/1.1 Keep-Alive)
3. 调整内核参数(慎用):- tcp_tw_reuse:允许重用TIME_WAIT(仅客户端)- tcp_tw_recycle:快速回收(不推荐,可能导致问题)
完整回答示例:
TIME_WAIT是TCP四次挥手中,主动关闭方发送最后一个ACK后
进入的状态,持续时间是2MSL,Linux默认是120秒。为什么是2MSL?有两个原因:第一,确保最后的ACK能到达对方。如果ACK丢失,对方会
重发FIN,需要1个MSL时间回来,客户端重发ACK又需要1个MSL
过去,所以总共2MSL。第二,让网络中的旧数据包过期。如果立刻用相同的IP和端口
建立新连接,旧连接的延迟数据包可能被新连接收到,导致
数据错乱。等待2MSL确保旧数据包都过期了。TIME_WAIT的问题是占用端口和内存。高并发场景下,
如果服务器主动关闭大量短连接,会导致TIME_WAIT堆积。
解决方案是让客户端主动关闭,或者用长连接复用,
避免频繁创建和销毁连接。我在项目中用的是长连接,避免了TIME_WAIT堆积的问题。
Q9. 为什么会出现"Address already in use"?
详细解答:
场景重现:
$ ./server # 启动服务器,监听8080端口
^C # Ctrl+C关闭(主动关闭)
$ ./server # 立刻重启
bind: Address already in use # 报错!原因分析:
1. 服务器Ctrl+C关闭,发送FIN,主动关闭所有连接
2. 服务器进入TIME_WAIT状态(120秒)
3. 端口8080在TIME_WAIT中,被占用
4. 重启时bind(8080)失败,因为端口还在用查看TIME_WAIT:
$ netstat -an | grep 8080
tcp 0 0 0.0.0.0:8080 0.0.0.0:* TIME_WAIT解决方案:SO_REUSEADDR
int on = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));SO_REUSEADDR的作用:
1. 允许绑定处于TIME_WAIT状态的端口
2. 允许多个socket绑定同一个端口(不同IP)
3. 不影响TCP可靠性(内核会正确处理旧数据包)注意:
- 必须在bind()之前调用setsockopt
- 顺序:socket() → setsockopt(SO_REUSEADDR) → bind()为什么不影响可靠性?
内核会检查四元组(源IP、源端口、目标IP、目标端口)
即使端口相同,只要四元组不同,就能区分新旧连接
旧连接的数据包会被内核正确丢弃
Q10. listen()的backlog参数是什么?
详细解答:
三次握手与队列:客户端 服务器| || SYN ||------------------------->| 放入【未完成队列】| | (incomplete queue)| SYN+ACK | 状态:SYN_RCVD|<-------------------------|| || ACK ||------------------------->| 移到【已完成队列】| | (complete queue)| | 状态:ESTABLISHED| |accept()从已完成队列取走backlog的含义:
backlog = 未完成队列上限 + 已完成队列上限实际上(Linux 2.2+):
backlog主要限制已完成队列的大小
未完成队列大小由 /proc/sys/net/ipv4/tcp_max_syn_backlog 控制如果队列满了会怎样?
1. 已完成队列满:新的三次握手完成,但没地方放- 服务器不发送最后的SYN+ACK- 客户端超时重试2. 未完成队列满:收到新的SYN- 服务器直接丢弃SYN- 客户端超时重试- 或者服务器发送RST拒绝backlog设多大合适?
- 太小:高并发时新连接被拒绝
- 太大:占用内存,SYN flood攻击风险
- 经验值:128(一般应用)、256(高并发)、1024(极高并发)代码:
listen(fd, 128); // 队列上限128查看当前队列:
$ ss -lnt
State Recv-Q Send-Q Local Address:Port
LISTEN 0 128 0.0.0.0:8080↑backlog值
红黑树知识补充(不需要会实现!)
面试官可能问:你会手写红黑树吗?
标准回答:
红黑树我不会手写实现,但我理解它的特性和应用场景:1. 定义:- 自平衡二叉搜索树- 每个节点有红色或黑色标记- 通过旋转和变色保持平衡2. 特性:- 查找:O(log n)- 插入:O(log n)- 删除:O(log n)- 比AVL树更宽松,插入删除更快3. 为什么epoll用红黑树?- 需要频繁增删fd(epoll_ctl ADD/DEL)- 需要快速查找fd(检查fd是否已添加)- 红黑树插入删除比AVL快,适合这个场景4. 替代方案比较:- 数组:查找O(n),插入O(1),删除O(n) → 太慢- 链表:查找O(n) → 太慢- 哈希表:查找O(1),但需要处理冲突,占用内存大- 红黑树:查找/插入/删除都是O(log n),内存占用合理5. 实际使用:- 我在用epoll时不需要关心红黑树实现- 只需要知道epoll_ctl的时间复杂度是O(log n)- 内核已经实现好了,我只需要调用API如果您需要我实现一个简化版的平衡二叉树,
我可以尝试,但红黑树的完整实现比较复杂,
涉及多种旋转和变色操作,我需要查阅资料。
如果面试官说"那你讲讲红黑树的性质"(极少情况):
红黑树的5条性质(背下来):
1. 每个节点是红色或黑色
2. 根节点是黑色
3. 所有叶子节点(NIL)是黑色
4. 红色节点的两个子节点都是黑色(不能有连续的红节点)
5. 从任一节点到其叶子节点的所有路径,包含相同数量的黑色节点这5条性质保证了红黑树的高度不超过2log(n+1),
所以查找/插入/删除都是O(log n)。但具体的旋转操作(左旋、右旋)和变色规则比较复杂,
我可能记不全,如果您需要我可以尝试推导。
总结:怎么背面试题?
- ✅ 先理解原理 - 看上面的详细解答
- ✅ 用自己的话复述 - 不要死记硬背答案
- ✅ 准备追问 - 面试官会深挖,要能自圆其说
- ✅ 联系项目 - “我在项目中用的是ET模式”
- ✅ 承认不足 - “红黑树我不会实现,但理解为什么用它”
最重要:理解 > 背诵!面试官能看出来你是真懂还是假懂!
感悟
今天虽然累,但是收获很大。从理论到实践,从粗糙的代码到封装的类,一步步看着服务器跑起来,还是很有成就感的。
特别是理解ET模式的时候,一开始真的懵,后来通过实际测试(发大数据包),看到数据丢失,才真正明白为什么要循环读取。纸上得来终觉浅,绝知此事要躬行,说的就是这个道理。
明天继续!要把Channel和EventLoop封装出来,一步步搭建起完整的网络框架!
