当前位置: 首页 > news >正文

网络编程实战02·从零搭建Epoll服务器

网络编程·从零搭建Epoll服务器

学习时间:下午3点半到晚上6点半(3小时)
状态:有点累,但是代码跑起来了还是挺爽的


在这里插入图片描述

目录

  • 今天干了啥
  • Socket基础回顾
    • 核心系统调用详解
    • sockaddr结构体深入理解
    • 网络字节序详解
  • Epoll到底是个啥
    • epoll_event结构体详解
    • ET模式内核原理
  • 非阻塞I/O深入理解
  • TIME_WAIT状态深度剖析
  • 封装设计思路
  • 代码实现过程
  • 遇到的坑
  • 测试结果
  • 今天的收获

今天干了啥

说实话今天有点累,本来以为socket编程很简单,结果一上来就是epoll,ET模式,非阻塞I/O,一堆概念砸过来。不过还好,一步步填代码,最后服务器跑起来了,看到hello world原样返回的时候还是挺有成就感的。

今天主要学了三块内容:

  1. Socket编程基础 - 复习了一下socket、bind、listen、accept这些基本操作
  2. Epoll多路复用 - 重点!理解了epoll的ET模式和为什么要非阻塞
  3. 面向对象封装 - 把socket和epoll封装成类,代码清晰多了

核心产出:一个完整的epoll echo服务器(338行代码)


Socket基础回顾

之前学过一些,今天算是复习+实战。Socket编程的流程其实不复杂,就是这几步:

socket创建
bind绑定地址
listen开始监听
accept接受连接
recv/send收发数据
close关闭连接

关键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);

参数详解:

参数常用值含义
domainAF_INETIPv4协议族
AF_INET6IPv6协议族
AF_UNIX本地通信(进程间)
typeSOCK_STREAMTCP流式套接字
SOCK_DGRAMUDP数据报套接字
SOCK_RAW原始套接字
protocol0默认协议(通常写0)
IPPROTO_TCP显式指定TCP
IPPROTO_UDP显式指定UDP

返回值:

  • 成功:返回socket文件描述符(非负整数)
  • 失败:返回-1,errno被设置

重要细节:

  1. socket返回的fd和普通文件fd一样,可以用close()关闭
  2. 在Linux中,一切皆文件,socket也是文件描述符
  3. 创建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的两种情况

  1. 端口真的被占用了 → 换个端口或者kill掉占用的进程
  2. 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的fd
  • addr:输出参数,存储客户端的地址信息(可以传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:出错,需要检查errno
    • errno == EAGAINEWOULDBLOCK:没有数据可读(非阻塞模式)
    • 其他错误:真的出错了

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为什么这么牛逼:

特性selectpollepoll
最大连接数1024(硬限制)无限制无限制
时间复杂度O(n)O(n)O(1)
数据结构位图数组红黑树+链表
跨平台❌(Linux专属)

epoll为什么这么快?三个原因:

  1. 红黑树存储fd - 增删改都是O(log n)
  2. 就绪链表 - epoll_wait只返回就绪的fd,不用遍历所有fd
  3. 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模式必须:

  1. 设置非阻塞
  2. 循环读取直到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的场景:

  1. 非阻塞recv:缓冲区没数据
  2. 非阻塞send:发送缓冲区满了
  3. 非阻塞accept:没有新连接
  4. 非阻塞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秒
  • 状态作用:
    1. 确保最后的ACK能到达对方
    2. 让网络中的重复数据包过期

为什么会出现"Address already in use"?

场景重现:

# 第一次运行服务器
./server  # 监听8080端口# Ctrl+C关闭服务器(主动关闭)
^C# 立刻重启
./server
bind: Address already in use  # 报错!

原因:

  1. 服务器主动关闭连接(按Ctrl+C)
  2. 服务器进入TIME_WAIT状态
  3. 端口8080被占用(在TIME_WAIT中)
  4. 重启时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_ctlepoll_wait,看着就头疼。

考虑到代码复用和维护性,决定封装成类,思路是这样的:

设计原则

  1. 单一职责 - Socket类只管socket操作,Epoll类只管epoll操作
  2. RAII - 构造函数创建资源,析构函数释放资源(这个后面会学到)
  3. 接口简洁 - 隐藏底层细节,暴露简单易用的接口

类设计

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函数 - 事件循环

整个服务器的核心逻辑:

有数据
连接关闭
EAGAIN
创建Socket
初始化监听socket
创建Epoll
添加监听socket到epoll
事件循环
epoll_wait等待事件
是监听socket?
循环accept新连接
设置非阻塞
添加到epoll
循环recv数据
数据处理
send回显
删除fd

代码实现:

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;
}

关键点:

  1. 监听socket也要设置非阻塞 - ET模式下需要循环accept
  2. 客户端socket也要设置非阻塞 - ET模式下需要循环recv
  3. 循环读取直到EAGAIN - ET模式的核心,不然会丢数据
  4. 连接关闭时要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

成功! 服务器能正常回显数据了!


今天的收获

技术收获

  1. Epoll的核心原理

    • 红黑树 + 就绪链表 + mmap共享内存
    • O(1)时间复杂度
    • 支持海量并发
  2. ET vs LT模式

    • LT:水平触发,安全但效率略低
    • ET:边缘触发,高效但要小心
    • ET模式必须:非阻塞 + 循环读写
  3. 面向对象封装

    • Socket类封装socket操作
    • Epoll类封装epoll操作
    • 代码清晰、易维护
  4. 非阻塞I/O

    • fcntl设置O_NONBLOCK
    • 配合EAGAIN判断数据是否读完

代码收获

核心代码量: 338行

  • Socket类:143行
  • Epoll类:85行
  • main函数:110行

面试必背的10个问题:

网络编程基础(4题):

  1. socket、bind、listen、accept的作用和返回值?

    • socket:创建fd,返回非负整数或-1
    • bind:绑定地址和端口,返回0或-1
    • listen:开始监听,backlog是连接队列上限
    • accept:返回新的fd,用于和客户端通信
  2. recv()返回值有哪些?分别代表什么?

    • > 0:读到的字节数
    • = 0:对方关闭连接(FIN)
    • < 0:出错,检查errno(EAGAIN、EINTR等)
  3. 为什么要用网络字节序?htons/htonl的作用?

    • 不同CPU字节序不同(小端/大端)
    • 网络统一用大端序
    • htons:主机序→网络序(端口)
    • htonl:主机序→网络序(IP)
  4. sockaddr和sockaddr_in的区别?

    • sockaddr:通用地址结构(历史遗留)
    • sockaddr_in:IPv4专用,方便使用
    • 两者大小一样(16字节),可以强制转换

Epoll核心(3题):

  1. epoll为什么比select/poll高效?

    • 红黑树存储fd(O(log n)增删)
    • 就绪链表(只返回就绪fd,O(1))
    • mmap共享内存(减少内核/用户态拷贝)
  2. ET和LT的区别?

    • LT(水平触发):只要有数据就一直通知,安全但效率略低
    • ET(边缘触发):状态变化才通知一次,高效但要小心
    • ET必须:非阻塞 + 循环读取直到EAGAIN
  3. 为什么ET必须非阻塞?

    • ET只通知一次,必须循环读取直到EAGAIN
    • 如果是阻塞模式,最后一次recv()没数据时会卡死
    • 非阻塞模式,没数据立刻返回-1(EAGAIN)

深度问题(3题):

  1. TIME_WAIT是什么?为什么是2MSL?

    • 主动关闭方发送最后ACK后进入TIME_WAIT
    • 持续2MSL(Linux默认120秒)
    • 原因:确保ACK到达对方 + 让重复数据包过期
  2. 为什么会出现"Address already in use"?如何解决?

    • 服务器主动关闭后进入TIME_WAIT,端口被占用
    • 重启时bind()失败
    • 解决:setsockopt(SO_REUSEADDR)
  3. 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)。但具体的旋转操作(左旋、右旋)和变色规则比较复杂,
我可能记不全,如果您需要我可以尝试推导。

总结:怎么背面试题?

  1. 先理解原理 - 看上面的详细解答
  2. 用自己的话复述 - 不要死记硬背答案
  3. 准备追问 - 面试官会深挖,要能自圆其说
  4. 联系项目 - “我在项目中用的是ET模式”
  5. 承认不足 - “红黑树我不会实现,但理解为什么用它”

最重要:理解 > 背诵!面试官能看出来你是真懂还是假懂!


感悟

今天虽然累,但是收获很大。从理论到实践,从粗糙的代码到封装的类,一步步看着服务器跑起来,还是很有成就感的。

特别是理解ET模式的时候,一开始真的懵,后来通过实际测试(发大数据包),看到数据丢失,才真正明白为什么要循环读取。纸上得来终觉浅,绝知此事要躬行,说的就是这个道理。

明天继续!要把Channel和EventLoop封装出来,一步步搭建起完整的网络框架!


http://www.dtcms.com/a/524741.html

相关文章:

  • IP数据报分片 题
  • 杭州设计 公司 网站建设适合小企业的erp软件
  • 全面掌握PostgreSQL关系型数据库,创建用户创建数据库操作,笔记09
  • 西安市网站制作公司购物商城排名
  • 思维大反转——往内走如实觉察
  • 计算机视觉——从环境配置到跨线计数的完整实现基于 YOLOv12 与质心追踪器的实时人员监控系统
  • 《商户查询缓存案例》使用案例学习Redis的缓存使用;缓存击穿、穿透、雪崩的原理的解决方式
  • 物联网固件安全更新中的动态密钥绑定与验证机制
  • YOLO学习——图像分割入门 “数据集制作和模型训练”
  • 网站域名的用处如何建设黔货出山电子商务网站
  • 三步搭建 AI 客服系统:用 PandaWiki 打造永不掉线的智能客服
  • 现在做一个网站多少钱网站制作商城
  • 详解EMQX2-EMQX功能使用
  • Vue3 中使用 CesiumJS
  • 【Trae+AI】Express框架01:教程示例搭建及基础原理
  • C# 中的 `as` 关键字:安全类型转换
  • Java 14 新特性Record、instanceof、switch语法速览
  • 网站的空间域名做公司网站的南宁公司
  • 中英文网站建站太原网站建设招聘
  • 建一个网站大概需要多长时间2016网站开发语言
  • 在Windows上使用Selenium + Chrome Profile实现自动登录爬虫
  • 八股-2025.10.24
  • 力扣2576. 求出最多标记下标
  • 做网站需要什么配置服务器工业产品设计软件
  • 大型语言模型基础之 Prompt Engineering:打造稳定输出 JSON 格式的天气预报 Prompt
  • [cpprestsdk] JSON类--数据处理 (`json::value`, `json::object`, `json::array`)
  • 2014 个人网站备案soho需要建网站吗
  • BugKu Web渗透之 文件包含2
  • 为什么建设营销型网站wordpress弹穿登陆
  • 高职示范校建设网站建设工程标准合同范本