关于项目

项目背景: 设计并实现了一个基于 Linux 平台的轻量级 HTTP 服务器,采用多 Reactor 多线程高并发模型,通过 epoll 提供高效的 I/O 复用。结合自动增长缓冲区定时器和异步日志等技术,实现了高性能和稳定运行的目标。

主要工作

内存优化:设计了内存池和 LFU 缓存,减少内存碎片,提升内存使用效率。

高效事件处理:利用 epoll 多路复用机制,高效监听和处理客户端连接及数据传输事件。

高并发模型:基于 Reactor 模型,实现 One Loop per Thread,支持多客户端并发连接。

动态缓冲区:实现自动增长缓冲区,动态调整大小以适配不同请求,优化内存分配。

连接管理:使用小根堆实现高效定时器,管理连接超时时间,防止长期空闲连接浪费资源。

异步日志:设计异步日志模块,基于单例模式和阻塞队列,实现高效日志写入,避免同步写入的性能开销。

关于项目

介绍一下

本项目是一个高性能的WEB服务器,使用C++实现,项目底层采用了多线程多Reactor的网络模型,并且在这基础上增加了内存池,高效的双缓冲异步日志系统,以及LFU的缓存。

服务器的网络模型是主从reactor加线程池的模式,IO处理使用了非阻塞IO和IO多路复用技术,具备处理多个客户端的http请求和ftp请求,以及对外提供轻量级储存的能力。

项目中的工作可以分为两部分,

一部分是服务器网络框架、日志系统、存储引擎等一些基本系统的搭建,

另一部分 是为了提高服务器性能所做的一些优化,比如缓存机制、内存池等一些额外系统的搭建。

最后还对系统中的部分功能进行了功能和压力测试。对于存储引擎的压力测试,

在本地测试下,存储引擎读操作的QPS可以达到36万,写操作的QPS可以达到30万。对于网络框架的测试,使用webbench创建1000个进程对服务器进行60s并发请求,测试结果表明,对于短连接的QPS为1.8万,对于长连接的QPS为5.2万。

项目难点

根据工作分为两部分

一部分是服务器网络框架,日志系统,存储引擎等一些基本系统的搭建,这部分的难点主要就是技术理解和选型,以及将一些开源的框架调整后应用到我的项目中去。

另一部分就是性能优化方面,比如缓存机制,内存池等一些额外系统的搭建。这部分的难点在于找出服务器的性能瓶颈所在,然后结合自己的想法突破瓶颈,提高服务器性能。

遇到的困难,怎么解决

一方面是对技术理解不够深刻,难以选出合适的技术框架,这部分主要是阅读作者的技术文档,找相关的解析文章看

另一部分是编程遇到的困难,由于工程能力不足出现bug,这部分主要是通过日志定位bug,推断bug出现的原因并尝试修复,如果以自己能力无法修复,先问问ai能提供什么思路,或者搜索相关的博客。

内存优化

设计了内存池LFU 缓存

缓存机制

为什么选择LFU

因为最近加入的数据因为起始的频率很低,容易被淘汰,而早期的热点数据会一直占据缓存。

高效事件处理:

epoll 多路复用机制

采用非阻塞I/O模型,执行系统调用就立即返回,不检查事件是否发生,没有立即发生返回-1,errno设置为在处理中。所以要采用I/O通知机制(I/O复用和SIGIO信号)来得知就绪事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
I/O多路复用技术
I/O 多路复用允许一个线程同时监视多个 I/O 文件描述符(如网络 socket),并在其中一个或多个文件描述符变为"可操作"时返回。应用程序可以据此进行相应的 I/O 操作(如读、写)。

1.1 select
简介
select 是一种最早的 I/O 多路复用接口,几乎所有主流平台都支持。

它允许程序监视多个文件描述符,查询它们是否可读、可写或出现错误。
select 的接口会使用三个位图(readset、writeset 和 exceptset)指定文件描述符的状态。
工作原理
调用 select 时,程序将文件描述符集(一个位图)传递给内核。
内核在超时时间内扫描这些文件描述符并返回那些状态发生变化的描述符(如变为可读或可写)。
用户态程序可根据返回结果进行相应的 I/O 操作。
缺点
支持的文件描述符数量有限(通常受 FD_SETSIZE 限制,默认 1024)。
每次调用时都需要将文件描述符的状态从用户态复制到内核态,这带来一定的性能开销。
内核需要线性遍历所有文件描述符(效率低),尤其在大并发连接时性能较差。
1.2 poll
简介
poll 是 select 的改进版本,克服了文件描述符数量限制的问题。

它使用一个数组结构而不是位图来描述文件描述符及其事件。
工作原理
用户定义一个 pollfd 数组,该数组中每一个元素保存一个文件描述符及其相关事件。
调用 poll 时,内核会遍历这个数组,检查哪些文件描述符有事件发生,并返回结果。
优点
支持任意数量的文件描述符,突破了 select 的 FD_SETSIZE 限制。
缺点
和 select 类似,每次调用都需要将监控的文件描述符数组从用户态复制到内核态,开销较大。
和 select 一样,内核需要线性遍历文件描述符,在高并发场景下效率仍然较低。
1.3 epoll
简介
epoll 是 Linux 平台下提供的高性能 I/O 多路复用接口,它是 select 和 poll 的替代品。

epoll 被设计用于解决 select 和 poll 的性能问题,是一种效率更高的方式处理大量并发连接的技术。
工作原理
epoll 的核心思想是使用事件驱动机制(Event-Driven)替代轮询机制。

创建一个 epoll 实例(epoll_create),用作事件管理器。
使用 epoll_ctl 向内核注册需要监听的具体文件描述符及其事件类型(关注可读、可写或异常事件)。
调用 epoll_wait,等待事件发生。
发生事件的文件描述符被加入到一个内核维护的就绪列表,并从中直接返回。
这避免了不必要的遍历额外文件描述符的开销。
优点
事件驱动模型:文件描述符有变化时通过回调机制加入就绪列表,只需处理活跃文件描述符。
无大小限制:最大受限于系统的内存资源,而非固定限制。
高性能:避免了线性遍历,即使监视十万连接,只需处理少量已就绪的描述符。
缺点
仅支持 Linux 系统,不跨平台。
epoll 的两种触发模式
LT(Level Trigger,水平触发): 默认模式,文件描述符只要处于就绪状态,就会不断返回。
ET(Edge Trigger,边缘触发): 更高效,只在文件描述符状态从未就绪到就绪时触发(适用于非阻塞 I/O)

IO多路复用

LT与ET

LT:水平触发模式,只要内核缓冲区有数据就一直通知,只要socket处于可读状态就一直返回sockfd;是默认的工作模式,支持阻塞IO和非阻塞IO

ET:边沿触发模式,只有状态发生变化才通知并且这个状态只会通知一次,只有当socket由不可写到可写或由不可读到可读,才会返回sockfd:只支持非阻塞IO

为什么用epoll,其他多路复用方式以及区别

高并发模型

基于 Reactor 模型,实现 One Loop per Thread

Reactor模式通常用同步I/O模型实现

Proactor模式通常用异步I/O模型实现

  1. 主线程往epoll内核事件表注册socket读就绪事件
  2. 主线程调用epoll_wait等待socket上有数据可读
  3. 当socket上有数据可读时,epoll_wait通知主线程,主线程将socket可读事件放入请求队列
  4. 工作线程被唤醒,读数据处理请求,然后往epoll内核事件表注测socket写就绪事件
  5. 主线程调用epoll_wait等待socket可写
  6. 当socket可写,epoll_wait通知主线程,主线程将socket可写事件放入请求队列
  7. 睡眠在请求队列的工作线程被唤醒,往socket上写入服务器处理客户请求的结果

动态缓冲区

实现自动增长缓冲区

1. 核心数据结构

1
2
3
4
5
6
class Buffer {
private:
std::vector<char> buffer_; // 主缓冲区(使用vector自动管理内存)
size_t readerIndex_; // 读指针(数据起始位置)
size_t writerIndex_; // 写指针(数据结束位置)
};

采用vector作为底层容器,自动处理内存分配/释放

读写指针分离设计,支持零拷贝操作

零拷贝是指计算机执行IO操作时,CPU不需要将数据从一个存储区域复制到另一个存储区域,从而可以减少上下文切换以及CPU的拷贝时间。它是一种I/O操作优化技术。

2. 自动增长机制

(1) 扩容触发条件

writableBytes() < 待写入数据量时自动扩容

通过vector的resize实现:

1
2
3
4
5
6
7
void append(const char* data, size_t len) {
if (writableBytes() < len) {
makeSpace(len); // 扩容操作
}
std::copy(data, data+len, beginWrite());
writerIndex_ += len;
}

(2) 智能扩容策略

1
2
3
4
5
6
7
8
9
10
11
12
void makeSpace(size_t len) {
if (writableBytes() + prependableBytes() < len) {
// 需要真正扩容:vector.resize(writerIndex_ + len)
buffer_.resize(writerIndex_ + len);
} else {
// 通过移动数据复用空间
size_t readable = readableBytes();
std::copy(begin()+readerIndex_, begin()+writerIndex_, begin());
readerIndex_ = 0;
writerIndex_ = readable;
}
}

3. 高性能IO优化

(1) 双缓冲区读操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ssize_t readFd(int fd, int* saveErrno) {
char extrabuf[65536]; // 64KB栈缓冲区
iovec vec[2];

vec[0].iov_base = begin() + writerIndex_;
vec[0].iov_len = writableBytes();
vec[1].iov_base = extrabuf;
vec[1].iov_len = sizeof(extrabuf);

// 根据剩余空间决定使用1个还是2个缓冲区
const int iovcnt = (writableBytes() < sizeof(extrabuf)) ? 2 : 1;
ssize_t n = readv(fd, vec, iovcnt);

// 处理读入的数据...
}

使用readv系统调用实现分散读

优先使用主缓冲区空间,不足时使用栈缓冲区过渡

避免频繁扩容带来的性能损耗

(2) 写操作优化

1
2
3
ssize_t writeFd(int fd, int* saveErrno) {
return ::write(fd, peek(), readableBytes());
}

直接使用write系统调用

peek()返回有效数据起始指针,避免内存拷贝

4. 关键特性总结

  1. 智能扩容:按需自动增长,兼顾内存使用效率

  2. 零拷贝设计:读写指针分离,减少内存拷贝

  3. 双缓冲策略:栈空间+主缓冲区组合优化IO性能

  4. 线程安全:单次IO操作原子性保证

  5. 内存高效:自动回收已读区域空间

典型工作流程:

  1. 读取数据时优先使用主缓冲区空间

  2. 空间不足时暂存到栈缓冲区

  3. 触发自动扩容后合并数据

  4. 写入数据时直接操作有效数据区域

连接管理

使用小根堆实现高效定时器,管理连接超时时间

1
2
using Entry = std::pair<Timestamp, Timer*>; // 以时间戳作为键值获取定时器
using TimerList = std::set<Entry>; // 底层使用红黑树管理,自动按照时间戳进行排序
1
2
// 定时器管理红黑树插入此新定时器
timers_.insert(Entry(when, timer));

异步日志

设计异步日志模块,基于单例模式和阻塞队列

日志系统是多生产者,单消费者的任务场景

多生产者负责把日志写入缓冲区,单消费者负责把缓冲区数据写入文件

img

前端往后端写,后端往硬盘写

双缓冲技术 ,写满就交换,相当于将多条日志拼接成一个大buffer传送到后端然后写入文件,减少了线程开销