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

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

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


目录


今天干了啥

说实话今天有点累,本来以为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

cpp 复制代码
int fd = socket(AF_INET, SOCK_STREAM, 0);
// AF_INET: IPv4
// SOCK_STREAM: TCP
// 0: 默认协议

2. bind() - 绑定地址

cpp 复制代码
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() - 开始监听

cpp 复制代码
listen(fd, 128);  // 128是backlog,等待队列长度

4. accept() - 接受连接

cpp 复制代码
int client_fd = accept(fd, NULL, NULL);
// 返回新的fd,这个fd专门和客户端通信

这几步都是标准流程,之前做五子棋项目的时候也写过。但是今天重点不在这,重点在epoll


核心系统调用详解

今天把这些API都用了一遍,发现每个API背后都有很多细节,不搞清楚容易踩坑。

socket() - 创建套接字

函数原型:

cpp 复制代码
#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被设置

重要细节:

  1. socket返回的fd和普通文件fd一样,可以用close()关闭
  2. 在Linux中,一切皆文件,socket也是文件描述符
  3. 创建socket后,fd就被占用了,忘记close会导致fd泄漏

常见错误码:

cpp 复制代码
EMFILE   // 进程打开的fd数达到上限(ulimit -n查看)
ENFILE   // 系统打开的fd总数达到上限
EACCES   // 权限不足(比如创建原始套接字需要root权限)

bind() - 绑定地址和端口

函数原型:

cpp 复制代码
#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),这样客户端就不知道连哪里了。

常见错误码:

cpp 复制代码
EADDRINUSE   // 地址已被占用(端口被其他程序用了)
EACCES       // 权限不足(比如绑定1024以下的端口需要root)
EINVAL       // socket已经绑定过了

重点:EADDRINUSE的两种情况

  1. 端口真的被占用了 → 换个端口或者kill掉占用的进程
  2. TIME_WAIT状态 → 用SO_REUSEADDR解决(后面详细讲)

listen() - 开始监听

函数原型:

cpp 复制代码
#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() - 接受连接

函数原型:

cpp 复制代码
#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() - 收发数据

函数原型:

cpp 复制代码
#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是流式协议,没有消息边界。所以需要循环读取:

cpp 复制代码
// 错误写法:
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(通用地址结构):

cpp 复制代码
struct sockaddr {
    sa_family_t sa_family;    // 地址族(AF_INET, AF_INET6等)
    char sa_data[14];         // 地址数据(IP+端口,但不方便用)
};

这个结构体设计得很抽象,sa_data把IP和端口混在一起,用起来很麻烦。

sockaddr_in(IPv4专用地址结构):

cpp 复制代码
struct sockaddr_in {
    sa_family_t sin_family;      // 地址族,AF_INET
    in_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,然后强制转换:

cpp 复制代码
struct sockaddr_in addr;
// ... 填充addr ...
bind(fd, (struct sockaddr*)&addr, sizeof(addr));  // 强制转换

内存布局对比:

复制代码
sockaddr:       [family][------- sa_data -------]
                2 bytes           14 bytes

sockaddr_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(正确!)
转换函数
cpp 复制代码
// 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)
应用层数据 数据是字节流,不需要转换

特殊值:

cpp 复制代码
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为什么这么快?三个原因:

  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模式必须这样写:

cpp 复制代码
while (true) {
    int len = recv(fd, buf, sizeof(buf), 0);
    if (len > 0) {
        // 处理数据
    } else if (len == -1 && errno == EAGAIN) {
        break;  // 读完了!
    }
}

Epoll的三个核心API

cpp 复制代码
// 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,刚开始没太理解,现在搞清楚了。

结构体定义:

cpp 复制代码
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)

多个事件可以组合(位或):

cpp 复制代码
ev.events = EPOLLIN | EPOLLET;  // 可读 + 边缘触发
ev.events = EPOLLIN | EPOLLOUT; // 可读 + 可写
data字段 - 用户数据

关键:data是联合体,同一时间只能用一个成员!

用法1:存fd(最常见)

cpp 复制代码
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:存指针(更灵活)

cpp 复制代码
// 定义一个结构体存储更多信息
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
cpp 复制代码
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() - 文件控制函数

函数原型:

cpp 复制代码
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );

常用cmd:

cmd 功能 参数
F_GETFL 获取文件状态标志
F_SETFL 设置文件状态标志 flags
F_GETFD 获取文件描述符标志
F_SETFD 设置文件描述符标志 flags

设置非阻塞的完整流程:

cpp 复制代码
// 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回去。

错误写法:

cpp 复制代码
// 错误!会覆盖其他标志
fcntl(fd, F_SETFL, O_NONBLOCK);

// 正确:保留原有标志
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

阻塞 vs 非阻塞

阻塞I/O(默认):

cpp 复制代码
// 如果没有数据,recv()会一直等待(阻塞)
int len = recv(fd, buf, sizeof(buf), 0);
// 这里会卡住,直到有数据到来或者对方关闭连接

非阻塞I/O:

cpp 复制代码
// 如果没有数据,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,搞得我有点懵。

cpp 复制代码
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"?

场景重现:

bash 复制代码
# 第一次运行服务器
./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

cpp 复制代码
int on = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));

SO_REUSEADDR的作用:

  • 允许绑定处于TIME_WAIT状态的端口
  • 允许多个socket绑定同一个端口(不同IP)
  • 不影响TCP的可靠性(内核会正确处理重复数据包)

查看TIME_WAIT连接

bash 复制代码
# 查看所有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(慎用!):

bash 复制代码
# 缩短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:代码复用

cpp 复制代码
// 没封装之前:
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) perror("socket");
// ... 一堆重复代码

// 封装之后:
Socket sock;
sock.Create();  // 简洁!

好处2:错误处理统一

cpp 复制代码
bool Socket::Create() {
    fd_ = socket(AF_INET, SOCK_STREAM, 0);
    if (fd_ < 0) {
        perror("socket");  // 统一处理错误
        return false;
    }
    return true;
}

好处3:资源管理自动化

cpp 复制代码
Socket::~Socket() {
    Close();  // 析构时自动关闭socket,不用担心忘记close
}

代码实现过程

今天填了15个TODO,一个个来说。

Socket类实现(9个TODO)

1. Create() - 创建socket
cpp 复制代码
bool Socket::Create() {
    fd_ = socket(AF_INET, SOCK_STREAM, 0);
    if (fd_ < 0) {
        perror("socket");
        return false;
    }
    return true;
}

这个简单,调用系统API,检查错误。

2. Bind() - 绑定地址
cpp 复制代码
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()
cpp 复制代码
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() - 地址复用(重要!)
cpp 复制代码
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模式必须!)
cpp 复制代码
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()
cpp 复制代码
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实例
cpp 复制代码
bool Epoll::Create() {
    epfd_ = epoll_create(1);
    if (epfd_ < 0) {
        perror("epoll_create");
        return false;
    }
    return true;
}

参数是1,但其实这个参数已经没用了(历史遗留),内核会动态调整大小。

2. AddFd() - 添加fd到epoll(ET模式)
cpp 复制代码
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
cpp 复制代码
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() - 等待事件
cpp 复制代码
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
cpp 复制代码
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

代码实现:

cpp 复制代码
int main() {
    // 1. 创建并初始化监听socket
    Socket 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. 创建epoll
    Epoll 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直到EAGAIN
                while (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直到EAGAIN
                char 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选项。

cpp 复制代码
listen_sock.SetReuseAddr();  // 加这一行

坑2:ET模式数据丢失

一开始我只调用了一次recv(),结果发现客户端发大数据时,只收到一部分。

原因: ET模式只通知一次,必须循环读取直到EAGAIN。

解决: 改成循环读取。

cpp 复制代码
// 错误写法:
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()


测试结果

编译运行:

bash 复制代码
g++ EpollServer.cpp -o epoll_server -std=c++11
./epoll_server

输出:

复制代码
Server started on port 8080

测试(另一个终端):

bash 复制代码
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会一直被返回,浪费CPU

ET模式的优势:
- 只通知一次,强制你一次性读完
- 即使有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封装出来,一步步搭建起完整的网络框架!


相关推荐
学传打活3 天前
【边打字.边学昆仑正义文化】_进阶篇5 宇宙星系的运转(续3)
微信公众平台·1024程序员节·汉字·昆仑正义文化
学传打活8 天前
【边打字.边学昆仑正义文化】_进阶篇5 宇宙星系的运转(续1)
微信公众平台·1024程序员节·汉字·昆仑正义文化
fangzhanpeng16816 天前
快速掌握C#语言基础知识点(1.基础数据类型)
开发语言·c#·1024程序员节
无己心18 天前
面向多平台 AI 搜索引擎的适配层架构设计
前端·人工智能·搜索引擎·ai·1024程序员节
u01030552724 天前
AI驱动的安卓UI自动生成功能实现
人工智能·1024程序员节
u01030552724 天前
长株潭智能体赋能腾讯元器链接共享
1024程序员节
云贝贝贝25 天前
OceanBase认证体系介绍与备考指南
运维·服务器·oceanbase·1024程序员节
u0103055271 个月前
Java图像处理实战指南
人工智能·1024程序员节
u0103055271 个月前
Java AWT鼠标事件全解析
人工智能·1024程序员节
u0103055271 个月前
Java实用类应用精讲
人工智能·1024程序员节