xlq22322头像
关注

服务器复习2

Reactor

1. 它是什么,原理是什么?

Reactor 是一种事件驱动的并发处理模式。把多个连接的读写事件注册到事件多路复用器,由事件循环等待事件就绪,再分发给对应的处理函数。这样,一个线程就能管理多个连接,不需要为每个连接创建一个线程。

2.我的项目怎么用它?为什么用它?

我的项目采用主从多 Reactor。Main Reactor 负责接收新连接和管理服务器生命周期,新连接按轮询分配给 Worker EventLoop。每条连接建立后固定归属于一个 Worker,连接的读写、HTTP 解析、路由回调和关闭都在这个线程中串行执行。

3.你的方案有什么不足?

当前主要代价是事件驱动代码需要维护状态机,并认真处理部分读写、对象生命周期和跨线程任务。另一方面,Worker 内的耗时回调会拖慢同一个 EventLoop 上的其他连接。

如果以后接入耗时计算、同步数据库访问,可以把这部分工作交给业务线程池,完成后再将结果投递回 owner loop,同时处理连接是否已关闭、HTTP pipeline 响应顺序等问题。

另外,非阻塞 socket 不意味着整条业务路径都不会阻塞。例如文件冷页读取,以及当前示例路由中的同步文件操作,仍可能占用 Worker。

4.遇到什么坑?怎么发现和解决?

将连接操作排进 EventLoop 后,任务可能稍后才执行。如果只捕获裸 this,连接提前销毁就可能发生释放后使用;如果其他线程直接修改连接状态,还可能与读写回调产生竞争。

解决:

  • 异步任务捕获 shared_ptr<Connection>,保证执行期间对象存活。
  • 状态修改回到 owner loop,保证线程归属。
  • 关闭统一进入 ReleaseInLoop,通过 DISCONNECTED 状态保证幂等。
  • 回收时移除 Channel,清理 socket、定时器和输出资源。

非阻塞读取暂时没有数据,与对端关闭是两回事。发送也可能只成功一部分;如果错误后仍移动输出偏移,会破坏缓冲区状态。

解决:发送队列清空后撤销 EPOLLOUT。否则在 LT 下,socket 通常持续可写,会反复触发无意义回调,形成忙循环。

慢客户端接收不及时,输出队列不断积压。即使暂停 socket 读事件,输入 Buffer 中可能已经有多个完整请求;HTTP parser 如果继续循环,就仍会产生更多响应。

另一个问题是:恢复时仅打开 EPOLLIN,如果内核没有新数据,而用户态 Buffer 中还有请求,这些请求可能迟迟不被处理。

解决:

  • 输出积压达到 4 MiB HIGH:暂停 EPOLLIN,并用 CanProcessInput() 停止 parser。
  • 积压降到 1 MiB LOW:恢复读事件,异步投递任务继续处理已缓冲的请求。
  • 续跑任务合并为至多一个,避免重复排队。
  • 不从写回调直接递归调用 parser,避免业务重入。

这里可以提炼成一句话:

背压必须同时控制内核读取和用户态请求处理,恢复时也要主动推进已经读入的数据。

 连接生命周期

1.它是什么/原理是什么?

我理解连接生命周期管理的核心,是用状态机明确连接当前允许做什么,用所有权保证异步访问期间对象有效,再通过统一清理路径保证资源及时且只释放一次。

2.我的项目里怎么用它?为什么用它?

你的实现可以按“建立—运行—关闭—销毁”讲。

① 建立:先明确所有权,再注册事件。

TcpServer::NewConnection() 给连接分配所属 EventLoop,设置回调,并先把连接放入服务器连接表,再调用 Established()。

真正的 EstablishedInLoop() 在所属线程执行,将状态改为 CONNECTED,开启读事件,再调用连接建立回调。HTTP 层在这个回调中创建 HttpContext。

这样做保证了:连接开始接收事件之前,服务器已经持有它,协议上下文也会在开始处理请求前准备好。

② 运行:一个连接由一个 EventLoop 负责修改。

连接的状态、Channel、定时器和输出队列,都交给所属的 owner loop 操作。

其他线程调用 Send()、Shutdown() 时,通过 DispatchToOwner() 投递任务;异步任务捕获 shared_ptr,保证执行期间对象还活着。

这里的分工很关键:

shared_ptr 解决“对象还在不在”;owner loop 解决“多个线程会不会同时修改连接”。引用计数安全,并不代表连接成员天然线程安全。

项目也不是完全没有锁:跨线程投递入口有短临界区,保护“检查是否允许投递+任务入队”,避免停机过程中向已销毁的 EventLoop 投递。

③ 关闭:区分排空输出和强制清理。

正常 Shutdown() 的流程是:

进入 DISCONNECTING → 停止读事件和请求解析 → 尽量发送完已提交的输出 → Release()

例如 HTTP 响应决定 Connection: close 时,先提交响应,再调用 Shutdown()。这样不会因为立刻关闭 socket,把仍在用户态输出队列里的响应丢掉。

遇到致命读写错误或服务器 StopNow(),则走强制清理,不保证排空输出。

这里的 Shutdown() 是项目自己的关闭流程,不能直接等同于系统调用 shutdown(fd, SHUT_WR);输出排空也不代表对端应用已经处理了响应。

④ 清理:统一进入 ReleaseInLoop()。

它在 owner loop 中完成:

  1. 检查是否已 DISCONNECTED,避免重复清理;
  2. 关闭新任务投递入口,设置终态;
  3. 从 Poller 移除 Channel,关闭 socket;
  4. 清除请求 deadline、取消空闲定时器;
  5. 清空输出队列,释放队列中的文件 FD,归还输出预算;
  6. 执行关闭回调,通知主循环从连接表移除连接。

最后一个强引用消失,才会触发对象析构。关键 I/O 资源和输出资源在关闭阶段释放,不等待对象最终析构。

顺序输出与 FIFO

1.它是什么,原理是什么?

FIFO 是 First In, First Out,先进先出:

入队:A -> B -> C
出队:A -> B -> C

网络输出中,FIFO 的关键不是“一次 send 完整发送”,而是:

  • 只发送队首数据;
  • 部分写入后记录 offset / remaining;
  • EAGAIN 时保留队首,等待下一次 EPOLLOUT;
  • 队首完全发送后,才能 pop_front() 处理下一个数据段。

2. 项目里怎么用,为什么用?

项目中核心实现位于 Connection 的 _output_queue,类型是:

std::list<OutputSegment> _output_queue;

OutputSegment 可以是:

  • MemorySegment:普通响应头、动态响应体;
  • FileSegment:静态文件,通过 sendfile 发送。

HTTP 层的 WriteResPonse() 会先构造一个 ResponseBatch:

Header + Body

或者:

Header + File

然后一次性提交到连接的 FIFO 队列。项目通过 list::splice() 把整批响应追加到队尾,保证一个响应要么完整进入队列,要么完全不进入队列。

写出时 HandleWrite() 永远只处理:

_output_queue.front()

具体行为是:

  • 普通内存数据使用非阻塞 send;
  • 文件数据使用 sendfile;
  • 部分写入只推进当前段的 offset;
  • EINTR 重试当前发送;
  • EAGAIN/EWOULDBLOCK 保留队首并开启 EPOLLOUT;
  • 当前段发送完成后才出队。

例如 Pipeline 请求产生:

H1, B1, H2, B2

即使 H1 只发出去一部分,也不会越过 H1 去发送 B1 或 H2。

使用 FIFO 的原因有三个:

  1. 保证 HTTP 响应和请求顺序一致;
  2. 兼容非阻塞 socket 的部分写入;
  3. 让内存响应和 sendfile 文件响应共用一套写状态机。

另外,项目还用 _flow_backlog_bytes 做背压控制。积压达到 4 MB 时暂停读,降到 1 MB 时恢复读,防止慢客户端导致输出队列无限增长。

慢客户端资源治理

1.它是什么,原理是什么?

慢客户端资源治理的核心是:

  1. 识别慢

    • 发送请求慢:长时间只发送几个字节。
    • 接收响应慢:服务端 send 频繁返回 EAGAIN。
  2. 限制资源

    • 限制一个连接的输入缓冲、输出内存、排队文件数和输出段数。
    • 限制全局连接数和全局输出内存。
  3. 施加背压或关闭

    • 输出积压高时暂停读取,避免继续产生响应。
    • 请求阶段超时或超过缓冲上限时关闭连接。
    • 连接关闭时一次性释放所有队列、fd、计数和 timer。

服务端写数据不能假设一次 send 就能完成。非阻塞 socket 可能出现:

  • 短写:只发送了一部分;
  • EINTR:需要重试;
  • EAGAIN/EWOULDBLOCK:对端暂时收不动,需要等待 EPOLLOUT。

因此项目使用一个 FIFO OutputQueue,只发送队首,保存每个 segment 的 offset 和 remaining。

慢读请求则使用绝对 deadline。收到请求首字节时启动 Header 或 Body 阶段的 deadline,后续字节不会刷新这个时间,从而避免“每隔几百毫秒发一个字节”永久续命。

2. 项目里怎么用,为什么用?

对慢写客户端:OutputQueue + 背压

项目把响应统一封装成:

  • MemorySegment:普通响应数据,通过 send 发送;
  • FileSegment:静态文件,通过 sendfile 发送;
  • 两者进入同一个 FIFO 队列,保证 Header → Body → 下一个响应 的顺序。

当队列积压达到:

  • FLOW_HIGH = 4 MiB:关闭 EPOLLIN,暂停读取请求;
  • FLOW_LOW = 1 MiB:重新打开 EPOLLIN,并异步处理已经进入用户态的 pipeline 请求。

代码中明确区分了 flow_backlog_bytes 和 accounted_output_payload_bytes:

  • flow_backlog_bytes 表示对端还没收走多少数据,文件数据也计入;
  • accounted_output_payload_bytes 表示当前占用多少用户态输出内存,文件段不计入。

这样设计的原因是:如果只关闭 EPOLLIN,用户态缓冲区里已经存在的 pipeline 请求仍然可能继续被解析,继续生成响应。因此项目还在 CanProcessInput() 和 ProcessInputInLoop() 中增加了 parser gate,真正停止请求处理;恢复时通过异步任务继续处理,避免从写回调递归进入 HTTP 业务代码。

对慢读客户端:阶段 deadline + 输入上限

HTTP 层默认配置是:

  • Keep-Alive 空闲超时:15 秒;
  • Header deadline:10 秒;
  • Body deadline:30 秒;
  • 单连接输入缓冲上限:1 MiB。

请求解析过程是:

收到请求首字节
    ↓
启动 Header 绝对 deadline
    ↓
Header 完成、Body 未完成
    ↓
切换到 Body 绝对 deadline
    ↓
请求完整后取消阶段 deadline,恢复 idle timer

输入缓冲采用“写入前预检”。如果本次 recv 会导致缓冲超过上限,就不再扩容、不写入,并由 HTTP 层返回 413 后关闭连接。

对资源本身:硬预算和 RAII

项目还限制:

  • 最大连接数:4096;
  • 全局输出 memory payload:256 MiB;
  • 单连接排队文件 fd:64;
  • 单连接输出 segment:1024。

响应不是逐段提交,而是先计算整批成本,再一次性预留内存、fd 和 segment 配额。成功后通过 list::splice 原子提交;失败或异常由 Reservation 自动回滚。

连接 teardown 时统一清理:

  • 从 epoll 移除 Channel;
  • 关闭 socket;
  • 清空 OutputQueue;
  • 归还全局和本地预算;
  • UniqueFd 自动关闭静态文件 fd;
  • 取消 timer;
  • 更新 metrics。

为什么要这样做?因为项目采用多连接共享的 EventLoop。一个慢客户端如果阻塞发送、无限积压或长期占用 fd,可能拖垮同一个 Worker 上的所有正常客户端。

 RAII 与失败回滚

1. RAII 与失败回滚是什么?

RAII 是 C++ 的资源管理思想:对象构造成功后就拥有资源,析构时自动释放资源。资源不仅包括内存,也包括文件描述符、锁、定时器、连接和配额。

项目中的 Socket、UniqueFd、OwnedBuffer 都是 RAII 封装:

  • Socket 析构时自动 close(fd),并且只能移动不能复制。
  • UniqueFd 独占文件描述符,析构时关闭。
  • OwnedBuffer 用 unique_ptr<char[]> 持有响应数据。

失败回滚是在 RAII 基础上增加“提交状态”:

  1. 先预留资源或配额;
  2. 尝试构造对象、队列节点;
  3. 所有步骤成功后 Commit();
  4. 中途异常、提前返回或状态变化时,由析构函数自动释放预留资源。

项目中的 Reservation 就是一个回滚卫士:

  • 构造后表示配额已经预留;
  • Commit() 后表示配额正式转移给输出队列;
  • 如果没有 Commit(),析构函数调用 Release() 退回配额;
  • 移动构造会把回滚责任转移给新对象,避免重复释放。

它提供的是强异常安全保证:要么整批响应提交成功,要么队列和配额恢复到提交前状态。

2. 项目里怎么用?为什么用?

最典型的场景是 HTTP 响应发送。

HTTP 响应由 Header 和 Body/File 组成。项目先构造 ResponseBatch,然后:

  1. 计算整批响应需要多少内存、文件 FD 和队列段;
  2. 预留本地配额和全局配额;
  3. 构造 MemorySegment 或 FileSegment;
  4. 用一次 list::splice 把完整批次放入 FIFO;
  5. 调用 Reservation::Commit()。

如果 Header 构造成功,但 Body 构造时抛异常:

  • 临时 list 自动析构;
  • 已构造的 MemorySegment 自动释放内存;
  • FileSegment 内部的 UniqueFd 自动关闭文件;
  • Reservation 析构,退回本地和全局预算;
  • 输出队列不会出现“只有 Header、没有 Body”的半个响应。

HTTP 层正是把 Header 和 Body/File 放进同一个 ResponseBatch:

静态文件也使用同样的所有权转移:

  • openat2 得到 fd;
  • 先放进局部 UniqueFd;
  • fstat 失败时自动关闭;
  • 成功时通过 std::move 转交给 FileSegment;
  • 最终使用 sendfile 发送。

连接关闭时,项目也有统一的显式 teardown:

  • 移除 Channel;
  • 关闭 socket;
  • 取消 timer;
  • 清空输出队列;
  • 归还全局输出预算;
  • 释放文件 FD;
  • 只允许执行一次。

之所以这样设计,是因为这个项目有大量非正常路径:EAGAIN、EINTR、客户端断开、超时、预算不足、构造异常、服务器停止。如果依赖每条路径手工清理,很容易出现 fd 泄漏、配额不归还或半个响应进入队列。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/qq_55640460/article/details/166688363

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--