仿 muduo 高并发服务器:实现 Acceptor 新连接接收器

前言

上一篇完成了 Connection,解决了一个连接建立之后如何进行非阻塞收发、管理缓冲区以及处理关闭的问题。

不过服务器想要管理连接,首先要能够接收连接。今天实现的 Acceptor 就负责监听服务端端口,并在新客户端到来时调用 accept() 获取通信 fd,再把它交给上层的 TcpServer

Acceptor 本身不负责收发数据,也不负责保存连接。它只做一件事:接收新连接并向上层转交。

一、Acceptor 的职责

Acceptor 内部组合了三个已经实现的模块:

  • Socket:创建监听套接字,并调用 accept() 获取新连接
  • Channel:管理监听 fd 关心的读事件及其回调
  • EventLoop:通过事件循环监控监听 fd

它们之间的关系可以概括为:

text 复制代码
客户端完成连接
      ↓
监听 fd 触发可读事件
      ↓
EventLoop 通知 Channel
      ↓
Channel 调用 Acceptor::HandleRead()
      ↓
Socket::Accept() 返回通信 fd
      ↓
_accept_callback(newfd) 交给 TcpServer

这里要区分两个 fd:监听 fd 只负责等待新连接,accept() 返回的新 fd 才用于和某个客户端通信。后续每个新 fd 都会被封装为一个 Connection 对象。

二、完整实现

cpp 复制代码
class Acceptor {
private:
    Socket _socket;//用于创建监听套接字
    EventLoop *_loop; //用于对监听套接字进行事件监控
    Channel _channel; //用于对监听套接字进行事件管理

    using AcceptCallback = std::function<void(int)>;
    AcceptCallback _accept_callback;
private:
    /*监听套接字的读事件回调处理函数---获取新连接,调用_accept_callback函数进行新连接处理*/
    void HandleRead() {
        int newfd = _socket.Accept();
        if (newfd < 0) {
            return ;
        }
        if (_accept_callback) _accept_callback(newfd);
    }
    int CreateServer(int port) {
        bool ret = _socket.CreateServer(port);
        assert(ret == true);
        return _socket.Fd();
    }
public:
    /*不能将启动读事件监控,放到构造函数中,必须在设置回调函数后,再去启动*/
    /*否则有可能造成启动监控后,立即有事件,处理的时候,回调函数还没设置:新连接得不到处理,且资源泄漏*/
    Acceptor(EventLoop *loop, int port): _socket(CreateServer(port)), _loop(loop),
        _channel(loop, _socket.Fd()) {
        _channel.SetReadCallback(std::bind(&Acceptor::HandleRead, this));
    }
    void SetAcceptCallback(const AcceptCallback &cb) { _accept_callback = cb; }
    void Listen() { _channel.EnableRead(); }
};

这段代码主要分为创建监听套接字、绑定内部事件回调、开启事件监控和转交新连接四步。

三、创建监听套接字

cpp 复制代码
int CreateServer(int port) {
    bool ret = _socket.CreateServer(port);
    assert(ret == true);
    return _socket.Fd();
}

Socket::CreateServer() 内部完成 socket()bind() 和系统调用 listen(),最终得到监听 fd。这里的系统调用 listen() 是把 socket 变成监听套接字,其 backlog 参数表示内核连接队列长度,并不是服务器最大并发连接数。

Acceptor::Listen() 的含义不同:

cpp 复制代码
void Listen() { _channel.EnableRead(); }

它没有再次调用系统的 listen(),只是让 Channel 开始监控监听 fd 的读事件。这两个名字虽然相同,但所处层次完全不同,很容易混淆。

四、监听 fd 为什么会触发读事件

普通通信 fd 可读,通常表示接收缓冲区中有数据;监听 fd 可读,则表示内核的已完成连接队列中存在可以被应用层取出的连接。

因此 Channel 收到监听 fd 的读事件后,会调用:

cpp 复制代码
void HandleRead() {
    int newfd = _socket.Accept();
    if (newfd < 0) {
        return ;
    }
    if (_accept_callback) _accept_callback(newfd);
}

accept() 不会把监听 fd 变成通信 fd,而是创建并返回一个新的 fd。监听 fd 会继续留在 EventLoop 中,等待后续客户端连接。

Acceptor 不知道应该怎样管理这个新 fd,所以通过 _accept_callback 将其交给上层。这种回调设计避免了 Acceptor 依赖 TcpServerConnection 的具体实现。

五、最容易出错的地方:启动顺序

构造函数中只设置了内部读事件回调:

cpp 复制代码
_channel.SetReadCallback(std::bind(&Acceptor::HandleRead, this));

但没有立即调用 _channel.EnableRead()。正确的外部使用顺序应该是:

text 复制代码
构造 Acceptor
    ↓
设置 SetAcceptCallback()
    ↓
调用 Listen() 开启读事件监控

如果在构造函数中直接开启监控,监听 fd 可能马上触发事件。此时上层的 _accept_callback 还没有设置,HandleRead() 虽然成功取出了 newfd,却没有对象接管它,也没有关闭它,最终会造成连接丢失和 fd 泄漏。

这也是网络库中常见的"两阶段初始化":先完成对象和回调关系的配置,再真正启动事件处理。

六、另外两个容易忽略的细节

1. 成员初始化顺序

C++ 成员的初始化顺序由它们在类中的声明顺序决定,而不是由初始化列表的书写顺序决定。

当前 _socket 声明在 _channel 前面,因此会先创建监听 fd,之后 _channel(loop, _socket.Fd()) 才能安全使用它。如果把 _channel 声明到 _socket 前面,即使初始化列表仍然先写 _socket,实际初始化顺序也不会改变。

2. 回调中的 this 生命周期

cpp 复制代码
std::bind(&Acceptor::HandleRead, this)

这个回调保存的是裸 this 指针。如果 Acceptor 已经销毁,但对应 Channel 仍然可能被事件循环调用,就会访问悬空指针。

因此必须保证停止事件监控和移除 Channel 之后,才能销毁 Acceptor。在后续设计中,通常由生命周期更长的 TcpServer 持有它。

七、LT 和 ET 模式下 accept 的区别

当前 HandleRead() 每次只调用一次 accept(),适合当前学习版本的 LT(水平触发)思路。如果已完成连接队列里还有连接,监听 fd 仍然处于可读状态,epoll 后续还会继续通知。

如果改成 ET(边缘触发)模式,只接收一个连接可能导致其余连接长时间留在队列中。此时监听 fd 应设置为非阻塞,并循环调用 accept(),直到返回 EAGAIN,表示本轮连接已经全部取完。

另外,工业实现还需要更细致地区分 EINTREAGAIN 和真正错误,并保证回调为空或抛出异常时,新 fd 仍能得到正确释放。这些可以作为后续优化,不影响当前版本对整体流程的理解。

八、面试常见问题

1. Acceptor 在 Reactor 模型中负责什么?

它负责处理监听 fd 的可读事件,调用 accept() 获取新连接,再把新连接分发给上层。它是连接建立阶段的事件处理器,不负责连接建立后的数据收发。

2. 为什么 accept 返回的新 fd 要交给上层?

因为 Acceptor 只负责接收连接。连接的读写、缓冲区、超时、关闭和业务回调应由 ConnectionTcpServer 管理,这样各模块职责更清晰。

3. 为什么不能在构造函数中直接开启读事件?

因为上层接收回调可能尚未设置。若事件立即到来,新 fd 无人接管,会造成连接丢失和资源泄漏。

4. LT 与 ET 模式下 accept 有什么区别?

LT 模式下未取完连接时还会继续通知;ET 模式通常只在状态变化时通知一次,因此必须使用非阻塞监听 fd,并循环 accept() 直到 EAGAIN

总结

今天实现的 AcceptorSocketChannelEventLoop 组合起来,把监听套接字正式接入 Reactor 事件循环。

它的核心流程并不复杂:监听 fd 可读时调用 accept(),然后通过回调把新 fd 交给上层。真正需要注意的是模块边界和启动时序:监听 fd 与通信 fd 不能混淆,接收回调必须在开启事件监控之前设置,Acceptor 的生命周期也必须覆盖事件回调的执行过程。

下一步可以实现 TcpServer,由它持有 Acceptor、设置 _accept_callback,并把每个新 fd 封装成 Connection 放入连接表管理。这样服务器从监听端口、建立连接到非阻塞收发的完整链路就能串联起来。

相关推荐
MacroZheng几秒前
Redis官方发布高颜值可视化工具,功能更是强的离谱!
java·redis·后端
骇客野人3 分钟前
Java 前后端可部署落地架构方案
服务器·前端·微服务
IT小白杨6 分钟前
跨境电商账号关联归因链路拆解与可落地环境配置
服务器·网络·经验分享·安全·指纹浏览器
回家路上绕了弯18 分钟前
为什么 AI 写代码时,总喜欢“防御性编程”?
后端
网硕互联的小客服20 分钟前
可以把服务器当成DNS服务器使用吗?如何操作?
运维·服务器·网络
灯澜忆梦21 分钟前
【Redis中间件】#4 | 进阶数据类型语法
redis·后端
广州山泉婚姻24 分钟前
前后端分离的核心优势
前端·后端
梦想平凡37 分钟前
情怀棋牌源代码焕新记录(一):全新国风UI与原版工程梳理
java·服务器·数据库·websocket·网络协议·cocos2d
IT_陈寒43 分钟前
Vue的响应式让我加班到凌晨,问题竟出在这个不起眼的地方
前端·人工智能·后端
程序员-Benothing1 小时前
Linux用户与组管理命令大全:useradd、usermod、groupadd、passwd
linux·运维·服务器