欢迎光临
我们一直在努力

Acceptor模块的设计与实现

目录

  • 前言
  • Acceptor 类的设计思想
  • Acceptor 类的实现
  • 结语

前言

在上篇文章中,我们详细探讨了 Connection 模块的设计与实现。从套接字管理到缓冲区设计,从事件回调到协议切换,我们构建了一个功能完备的连接管理单元。它承载着数据收发的重任,管理着连接的生命周期,是整个网络库中承上启下的关键模块。

然而,一个 Connection 不会凭空产生。每一个新的客户端连接,都必须经过一个“入口”才能被系统接纳,继而创建出对应的 Connection 对象并注册到 EventLoop 的事件监控中。这个“入口”,就是本节的主角——Acceptor。

Acceptor 的职责非常纯粹:监听指定的端口,接收新连接,将新连接转交给上层处理。它不关心数据内容,不参与业务逻辑,只负责一件事——打开大门,迎接访客。

在实现思路上,Acceptor 本身也是一个 Channel 的使用者。它将自己持有的监听 socket 封装成一个 Channel,注册到 EventLoop 中,并设置读事件回调。当新连接到达时,EventLoop 检测到监听 socket 可读,便会调用 Acceptor 的回调函数,后者通过 accept() 获取新连接的文件描述符,再通过用户设置的回调函数将新连接交给上层处理。

为了更清晰地阐述这一模块的设计,我们可以提出这样一个问题:为什么 Acceptor 不直接在构造函数中启动读事件监控?

答案是——如果构造函数中启动了读事件监控,一旦有新连接在构造函数执行完毕后、回调函数设置之前到达,Channel 就会触发读事件并调用尚未设置的回调函数,导致新连接无法被正确处理,甚至造成资源泄漏。因此,我们将 Listen() 与 SetAcceptCallback() 分离,强制用户在设置好回调函数后,再显式地启动监听。这是 API 设计中一个微小但至关重要的细节。

本文将从 Acceptor 的设计目标出发,逐步拆解其接口设计、核心实现以及与 EventLoop 和 LoopThread 的协作关系。同时,我们还会讨论 Acceptor 如何与上一篇文章中提到的 Connection 模块衔接,共同构成服务器连接管理的完整链路。

让我们从最基础的接口设计开始,一步步构建这个网络库的"大门"。

Acceptor类的设计思想

在深入代码之前,我们先明确 Acceptor 的设计目标。它的职责可以概括为以下三点:

  • 创建监听套接字:绑定指定端口,开始监听客户端的连接请求
  • 事件监控与管理:将监听套接字注册到 EventLoop 的事件监控体系中
  • 新连接的接收与分发:当有新连接到达时,接受连接并将新连接的文件描述符交给上层处理
  • 简单来说,Acceptor 就是一个“连接工厂”,它不关心连接建立之后的事情,只负责把连接“生产”出来,它的功能就是对监听套接字进行管理。

    当我们获取了一个新建连接的描述符后,需要为这个连接封装一个 Connection 对象,并设置不同回调。但是因为 Acceptor 模块本身并不知道一个连接产生了事件该如何处理,因此获取一个通信连接后,Connection 的封装以及事件回调函数的设置都应该由服务器模块来设置。

    当新连接到达时,它通过 accept() 获取新连接的文件描述符,然后需要将这个 fd 交给上层处理。这也代表着我们需要的回调函数(因为回调就是我们传给上层的途径)的函数类型,参数必须传入一个 int 类型,代表一个 newfd。

    class Acceptor
    {
    private:
    Socket _socket; // 监听套接字对象,负责底层的 socket 操作
    EventLoop *_loop; // 事件循环指针,用于注册和移除事件监控,对监听套接字进行事件监控
    Channel _channel; // 监听套接字的事件管理,将 socket 和事件回调绑定

    using AcceptCallback = std::function<void(Socket)>;//我们的socket的Accept是返回一个Socket类型对象
    AcceptCallback _accept_callback; // 新连接到达时的回调函数
    public:
    };

    逐个分析这些成员的作用:

    Socket _socket:封装了底层的 socket 系统调用,提供了 CreateServer、Accept 等接口。它是 Acceptor 与操作系统内核交互的桥梁。 EventLoop *_loop:指向当前 Acceptor 所属的事件循环。Acceptor 需要将自己的 Channel 注册到 _loop 中,才能开始接收新连接的事件通知。 Channel _channel:将监听 socket 的文件描述符与事件回调绑定在一起。当 epoll_wait 返回监听 socket 可读时,EventLoop 会调用 _channel 的 HandleEvent 方法,进而触发我们设置的读事件回调。

    AcceptCallback _accept_callback:这是一个由上层(通常是 TcpServer)设置的回调函数。当有新连接到达时,Acceptor 会调用此回调,将新连接的 socket 传递给上层,由上层创建对应的 Connection 对象。

    这里有一个重要的设计决策:为什么 Acceptor 不直接创建 Connection 对象?

    答案是——职责分离。Acceptor 只负责"接受连接"这一件事,而 Connection 的创建涉及协议选择、回调设置、上下文初始化等复杂逻辑,这些应当由更上层的 TcpServer 或业务代码来决定。通过回调的方式,我们将连接的"接受"与"创建"解耦,使 Acceptor 成为一个可复用的通用组件。


    另外,_accept_callback 需要被外层设置,所以我们要有一个接口来设置它。而它如何被调用呢?那就是通过 Acceptor 的读事件回调 HandleRead 了。 当 Acceptor 的读回调被触发,就代表有了新连接,我们应该接收并调用 _accept_callback。 而公开接口的设计也比较简洁,除了构造函数外,就只有一个接口来设置 _accept_callback,以及启动监听事件。 对于构造函数来说,需要外界至少传入一个正确的EventLoop指针,和一个端口号,才能完成socket的服务端连接创建的工作。

    class Acceptor
    {
    private:
    Socket _socket; // 监听套接字对象,负责底层的 socket 操作
    EventLoop *_loop; // 事件循环指针,用于注册和移除事件监控,对监听套接字进行事件监控
    Channel _channel; // 监听套接字的事件管理,将 socket 和事件回调绑定

    using AcceptCallback = std::function<void(int)>;
    AcceptCallback _accept_callback; // 新连接到达时的回调函数
    private:
    void HandleRead();

    public:
    Acceptor(EventLoop *loop,int port);
    // 设置新连接到达时的回调函数
    void SetAcceptCallBack();

    // 启动监听:将 Channel 注册到 EventLoop,开启读事件监控
    void Listen();
    };


    Acceptor类的实现

    接下来就是我们 Acceptor 类的构造函数的实现,这里就有一个细节:构造函数中,初始化列表的初始化顺序问题。

    在 C++ 中,成员变量的初始化顺序按照声明顺序,而非初始化列表的顺序。但是这就有个问题:

    在这里插入图片描述

    我们的 socket 该如何初始化呢?

    没有默认构造函数的类型变量,必须在构造函数的初始化列表中完成初始化,我们的_channel正好就是一个没有默认构造的变量。

    它的构造条件是有 EventLoop 和一个文件描述符,但是这个描述符按理来说需要由 socket 来调用 CreateServer 来创建一个服务端的连接才能获得一个有效的文件描述符。

    这里有一个办法,那就是你可以调用一个辅助接口,在这个接口函数中,会先调用 _socket 的 CreateServer,让 socket 创建有效的服务端连接,这个时候再返回 socket 的 fd 充当构造函数的 socket 的传入参数,这样 socket 就初始化完毕了,可以为后面的 channel 提供参数,即:

    class Acceptor
    {
    private:

    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));
    }
    };

    但是,这样写是不规范的。的确可以达成咱们想要的这个效果,但这里其实是未定义行为,因为这个socket对象还没构造完成就调用,但为什么没有崩溃呢,是因为 _socket是Acceptor的成员,这个对象空间已经开辟好了,只不过还没有通过构造函数去初始化它的成员,所以通过 _socket去调用成员函数,或访问成员变量没有报错。

    这里要重温一下,如果一个a类中包含了一个b类对象,那么一开始创建一个a类对象只会为其分配足够的空间,这个时候是内存已分配,但未初始化(原始垃圾数据)。

    随后进行构造函数逻辑,先是构造函数的初始化列表,如果b没有默认构造,就要求b必须写在a的初始化列表里进行有参构造。如果有默认构造,哪怕没显式把b写在初始化列表,也会默认调用b的默认构造。在b的构造完成前,都是处于一种未初始化的状态。

    只有在其调用了自己构造完成之后,才是初始化完毕。

    我们这里就有点卡bug了,在调用socket的有参构造的时候,顺便通过我们的CreateServer调用了socket的CreateServer,要知道这个时候socket可是没有被初始化的。

    在对语法掌握不牢固的时候,就不要这样写。

    所以有另外一种写法,那就是先给channel一个无效的fd,比如-1。

    随后在构造函数正文中先调用socket的CreateServer,再让channel的fd修改成socket的fd。

    我们原本的channel对象里是没有这个接口的,所以我们可以给channel新增一个SetFd接口:

    class Channel
    {
    private:
    int _fd; // 当前Channel对应的连接的文件描述符

    public:

    //新增一个改变Fd的接口
    void SetFd(int fd)
    {

    // 如果当前 fd 已经在监控中,需要先移除
    if (_fd != -1)
    {
    Remove(); // 从 EventLoop 的 epoll 中移除
    }
    _fd = fd;
    // 如果 fd 有效,重置事件状态
    if (_fd != -1)
    {
    _events = 0; // 重置关注的事件
    // 注意:这里不应该自动注册到 epoll
    // 应该由调用者通过 EnableRead/EnableWrite 显式启动
    }
    }

    };

    随后构造函数就能改成:

    Acceptor(EventLoop *loop, int port) :_loop(loop),_channel(loop,-1)
    {
    // 在构造函数体中完成 socket 创建
    bool ret = _socket.CreateServer(port);
    assert(ret == true);

    // 设置 channel 的 fd 和回调
    _channel.SetFd(_socket.Fd());
    _channel.SetReadCallback(std::bind(&Acceptor::HandleRead, this));
    }


    你可能注意到,Acceptor 的 Channel 只启用了读事件监控(EnableRead),而没有启用写事件监控(EnableWrite)。

    这是因为监听 socket 永远只关心"有新连接到达"这一件事。新连接的到达在内核中表现为监听 socket 的"可读"状态,因此只需要监控 EPOLLIN 事件。写事件对于监听 socket 来说没有意义——它不需要发送数据。

    Acceptor 本身并不主动做任何事情,它是被动的——它的所有工作都由 EventLoop 驱动。完整的协作流程如下:

    1. 用户创建 Acceptor 对象

    2. 用户设置 _accept_callback

    3. 用户调用 Listen(),_channel.EnableRead()

    4. EventLoop::Start() 启动事件循环

    5. Poller::Poll() 通过 epoll_wait 监控所有 Channel

    6. 新连接到达,epoll_wait 返回监听 socket 的 EPOLLIN 事件

    7. EventLoop 遍历活跃 Channel,调用 channel->HandleEvent()

    8. Channel::HandleEvent() 检测到 _revents & EPOLLIN

    9. 调用 _read_callback(),即 Acceptor::HandleRead()

    10. Acceptor::HandleRead() 调用 _socket.Accept() 获取新连接

    11. 调用 _accept_callback(newfd),将新连接交给上层

    12. 上层创建 Connection 对象,注册到 EventLoop


    说了这么多,所以handleRead与Listen的功能其实也说的很明白了,handleread会获取新连接,并把它交给上层——通过上层设置的事件回调:

    class Acceptor
    {
    private:
    Socket _socket; // 监听套接字对象,负责底层的 socket 操作
    EventLoop *_loop; // 事件循环指针,用于注册和移除事件监控,对监听套接字进行事件监控
    Channel _channel; // 监听套接字的事件管理,将 socket 和事件回调绑定

    using AcceptCallback = std::function<void(Socket)>;
    AcceptCallback _accept_callback; // 新连接到达时的回调函数
    private:
    void HandleRead()
    {
    Socket newfd = _socket.Accept();

    if (newfd.Fd() < 0)
    {
    return;
    }
    if (_accept_callback)
    _accept_callback(std::move(newfd));
    }

    public:

    Acceptor(EventLoop *loop, int port) : _loop(loop), _channel(loop, -1)
    {
    // 在构造函数体中完成 socket 创建
    bool ret = _socket.CreateServer(port);
    assert(ret == true);

    // 设置 channel 的 fd 和回调
    _channel.SetFd(_socket.Fd());
    _channel.SetReadCallback(std::bind(&Acceptor::HandleRead, this));
    }
    // 设置新连接到达时的回调函数
    void SetAcceptCallBack(const AcceptCallback &cb)
    {
    _accept_callback = cb;
    }

    // 启动监听:将 Channel 注册到 EventLoop,开启读事件监控
    void Listen()
    {
    _channel.EnableRead();
    }
    };

    注意,这里HandleRead中,给_accept_callback的传参是移动传参,我们这里构造的新socket对象会在函数结束时被销毁,我们一定要转移其的所有权,防止fd被关闭,后续就不能正常使用。

    这个函数的逻辑非常清晰:

  • 调用 _socket.Accept() 从内核的全连接队列中取出一个已完成的套接字
  • 如果 accept() 失败(例如没有新连接),直接返回
  • 如果 _accept_callback 已被设置,将新连接的套接字对象传递给上层
  • 这里需要注意的是,accept() 返回的是一个新的 socket ,这个 socket 与监听 socket 无关,它代表的是与客户端的已建立连接。上层拿到这个 socket 后,通常会创建 Connection 对象,并将该 socket 封装进 Connection 中进行管理。


    结语

    至此,我们完成了 Acceptor 模块的完整设计与实现。让我们回顾一下这个模块在整个网络库中的定位与价值。

    Acceptor 虽然代码量不大,职责也非常单一,但它在整个网络框架中扮演着不可或缺的"守门人"角色。它静静地监听在指定端口上,等待着每一个新连接的到来,一旦有客户端发起连接请求,它便迅速响应,将新连接的 socket 交付给上层处理,然后继续回到监听状态,周而复始。

    回顾 Acceptor 的设计,有几个关键点值得我们再次品味:

    第一,关于构造函数中 fd 的初始化顺序问题。 我们通过先给 _channel 传入无效 fd -1,然后在构造函数体中创建 socket 并调用 SetFd 的方式,优雅地绕过了成员初始化顺序的陷阱。这提醒我们:在 C++ 中,成员变量的初始化顺序遵循声明顺序,而非初始化列表顺序,理解这一点对于写出健壮的代码至关重要。

    第二,关于“先配置,后启动”的设计原则。 我们将 SetAcceptCallback 与 Listen 分离,强制用户先设置好回调函数,再显式启动监听。这看似是一个微小的 API 设计细节,实则避免了事件触发时回调函数尚未设置的竞态条件。一个好的接口设计,不仅能让用户用起来顺手,更能引导用户正确地使用。

    第三,关于移动语义的使用。 在 HandleRead 中,我们使用 std::move(newfd) 将 socket 对象的所有权转移给上层,避免了不必要的拷贝,也明确了"所有权转移"这一语义。在现代 C++ 中,合理使用移动语义可以让代码更加高效和清晰。

    第四,关于 Acceptor 与 LoopThread 的协作关系。 Acceptor 本身并不创建线程,也不管理线程,它只依赖于一个 EventLoop 指针来注册和移除事件。这种设计使得 Acceptor 可以灵活地工作在单线程或多线程环境中——在单线程场景下,它直接使用主线程的 EventLoop;在多线程场景下,它可以通过 LoopThread 获取一个专属的 EventLoop,实现连接的负载均衡。

    第五,关于职责边界的划分。 Acceptor 只负责"接受连接"这一件事,至于连接建立之后如何处理数据、如何管理生命周期,统统不是它关心的范畴。这种“做一件事,并做好”的设计哲学,让 Acceptor 成为了一个可复用的通用组件,无论上层是 HTTP 服务器、WebSocket 服务器还是自定义协议服务器,Acceptor 都能完美适配。

    至此,我们已经完成了从 EventLoop 事件驱动核心,到 Connection 连接管理,再到 Acceptor 连接接收的完整链路。在下一篇文章中,我们将把这些模块整合起来,设计并实现 TcpServer 类——它将作为整个网络库对外的统一入口,负责协调 Acceptor、LoopThread 和 Connection 的协作,最终呈现出一个完整、可用的高性能网络服务器框架。

    赞(0)
    未经允许不得转载:171主机测评 » Acceptor模块的设计与实现
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址