仿 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 放入连接表管理。这样服务器从监听端口、建立连接到非阻塞收发的完整链路就能串联起来。

相关推荐
凌虚2 小时前
AI 时代,程序员会消失吗?
人工智能·后端·程序员
ACP广源盛139246256732 小时前
GSV6155@ACP# 搭配 AI 服务器、AI PC 完整适配方案
大数据·服务器·人工智能·分布式·单片机·嵌入式硬件
用户8356290780512 小时前
使用 Python 在 Excel 中添加和自定义文本框
后端·python
干到60岁退休的码农3 小时前
SpringMVC实现Restful api请求方法
后端·restful
从零开始的代码生活_4 小时前
C++ 多态详解:虚函数、动态绑定、抽象类与虚表原理
开发语言·c++·后端·学习·算法
神奇小汤圆4 小时前
吃透MySQL核心原理:日志、事务、MVCC、锁机制深度详解
后端
老孙讲技术4 小时前
零售后端笔记:AI 能力接不上?先分清 numberstat / alarm / faceAnalysis 三条管
后端·物联网
维基框架4 小时前
维基框架(Wiki-Framework) 1.2.0:Mybatis 升级为主从读写分离
java·后端·架构