网络编程基础:从Socket到Reactor,再到epoll与HTTP服务器

网络编程,说白了就是让两台机器能互相说话。我刚开始接触这块时,觉得不就是发数据收数据嘛,有什么难的?结果第一次写服务端程序,客户端一多就直接卡死,CPU飙到100%。嗯,那时候我才意识到,网络编程的水,比想象中深得多。

今天这一章,我们就把网络编程的核心脉络捋一遍。从最底层的Socket API封装开始,到Reactor模式的设计思想,再到非阻塞I/O与epoll的高性能实现,最后落地成一个简单的HTTP服务器。你想想看,这一套下来,你对C++网络编程的理解,基本就成型了。

核心要点:网络编程的本质是I/O模型的选择。选对了模型,你的服务端程序就能轻松扛住上万连接;选错了,几千连接就能让你怀疑人生。

1. Socket API封装:别让底层细节污染业务代码

我见过太多人,业务逻辑里直接塞满了socket、bind、listen、accept这些系统调用。代码读起来就像在翻一本混乱的日记。我个人习惯,先把这些底层操作封装成一个类,比如叫Socket

class Socket {
public:
    explicit Socket(int fd) : fd_(fd) {}
    ~Socket() { if (fd_ >= 0) close(fd_); }

    int fd() const { return fd_; }
    void bind(const InetAddress& addr);
    void listen(int backlog = SOMAXCONN);
    int accept(InetAddress& peerAddr);
    void setReuseAddr(bool on);
    void setNonBlocking(bool on);

    // 禁止拷贝,允许移动
    Socket(const Socket&) = delete;
    Socket& operator=(const Socket&) = delete;
    Socket(Socket&& other) noexcept : fd_(other.fd_) { other.fd_ = -1; }

private:
    int fd_;
};

为什么要封装?原因很简单:

  • 资源管理自动化:RAII思想,析构时自动关闭fd,避免资源泄漏。我在项目中遇到过,有人忘记close,结果文件描述符耗尽,服务端拒绝新连接。
  • 接口语义化setNonBlocking(true)fcntl(fd, F_SETFL, O_NONBLOCK)好读一百倍。
  • 错误处理集中化:所有系统调用的错误检查,都在封装内部完成,业务层只需要捕获异常或检查返回值。

小技巧:封装时记得禁用拷贝构造,只允许移动。socket fd是系统资源,拷贝语义会带来double close的风险。移动语义则能安全转移所有权。

2. Reactor模式入门:事件驱动的核心思想

有了Socket封装,接下来要解决一个根本问题:如何高效地处理多个连接?

传统的做法是每个连接开一个线程。但线程不是免费的,上下文切换的开销会让你在连接数上千时直接崩溃。Reactor模式就是来解决这个问题的。

Reactor的核心思想,说白了就是「事件分发」。你注册好感兴趣的事件(比如可读、可写),然后一个事件循环在那里等着。当事件发生时,Reactor调用你注册的回调函数。

class Reactor {
public:
    using EventCallback = std::function<void()>;

    void registerReadEvent(int fd, EventCallback cb);
    void registerWriteEvent(int fd, EventCallback cb);
    void removeEvent(int fd);

    void loop() {
        while (!quit_) {
            // 等待事件发生
            auto events = poller_.poll(timeoutMs_);
            for (auto& event : events) {
                event.handleEvent();
            }
        }
    }

private:
    Poller poller_;
    bool quit_ = false;
};

你看,Reactor把I/O多路复用和业务逻辑解耦了。你只需要关心「当这个fd可读时,我要做什么」,而不需要关心「怎么知道它可读了」。

关键理解:Reactor模式不是某个具体的API,而是一种设计思想。epoll、kqueue、select都可以作为它的底层实现。我个人建议,初学者先用select实现一个简单的Reactor,理解事件循环的本质,再切换到epoll。

3. 非阻塞I/O与epoll:高性能的基石

为什么非阻塞I/O这么重要?你想想看,如果socket是阻塞的,当你调用read时,如果没有数据,线程就会挂在那里。在Reactor模式里,事件循环线程一旦挂起,所有连接都得不到处理。

非阻塞I/O配合epoll,才是现代高性能服务器的标配。epoll相比select/poll,有几个核心优势:

特性 select/poll epoll
监听fd数量 有限制(select默认1024) 无上限(受系统内存限制)
事件传递方式 每次调用都要传递完整fd集合 只传递活跃的fd,效率高
触发模式 仅水平触发(LT) 支持水平触发(LT)和边缘触发(ET)
性能表现 O(n),fd越多越慢 O(1),与活跃连接数相关

我曾经在一个项目中,把select换成了epoll,连接数从2000直接提升到了5万,CPU占用反而下降了30%。这就是epoll的威力。

class EpollPoller {
public:
    EpollPoller() : epollFd_(epoll_create1(EPOLL_CLOEXEC)) {}

    void addFd(int fd, uint32_t events) {
        epoll_event ev;
        ev.events = events;
        ev.data.fd = fd;
        epoll_ctl(epollFd_, EPOLL_CTL_ADD, fd, &ev);
    }

    std::vector<epoll_event> poll(int timeoutMs) {
        std::vector<epoll_event> events(1024);
        int nfds = epoll_wait(epollFd_, events.data(), events.size(), timeoutMs);
        events.resize(nfds);
        return events;
    }

private:
    int epollFd_;
};

注意:使用边缘触发(ET)模式时,必须将socket设置为非阻塞,并且要循环读取直到返回EAGAIN。否则,数据没读完,epoll不会再通知你。这个坑我踩过,线上服务间歇性丢数据,排查了一整天才发现是ET模式下read没读完。

4. 简单的HTTP服务器:把理论串起来

好了,理论讲完了,我们来写一个简单的HTTP服务器。它不需要处理复杂的路由,只需要能响应GET请求,返回一个简单的HTML页面。

class HttpServer {
public:
    HttpServer(int port) : reactor_(), acceptor_(port) {
        acceptor_.setNewConnectionCallback([this](int connFd) {
            // 新连接到来,注册到Reactor
            auto conn = std::make_shared<HttpConnection>(connFd);
            reactor_.registerReadEvent(connFd, [conn]() {
                conn->handleRead();
            });
        });
    }

    void start() {
        reactor_.registerReadEvent(acceptor_.fd(), [this]() {
            acceptor_.accept();
        });
        reactor_.loop();
    }

private:
    Reactor reactor_;
    Acceptor acceptor_;
};

class HttpConnection {
public:
    void handleRead() {
        char buf[4096];
        int n = read(fd_, buf, sizeof(buf));
        if (n > 0) {
            // 解析HTTP请求
            HttpRequest req(buf, n);
            // 生成响应
            std::string response = "HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\nHello, World!";
            write(fd_, response.data(), response.size());
        }
        close(fd_);
    }
};

这个例子虽然简单,但包含了网络编程的核心要素:

  • 事件驱动:acceptor负责监听新连接,connection负责处理已连接的数据。
  • 非阻塞I/O:所有socket都设置为非阻塞,配合epoll的边缘触发。
  • 回调机制:用std::function把事件处理逻辑注入到Reactor中。

扩展思路:真正的HTTP服务器远比这个复杂。你需要处理请求头解析、连接复用、超时管理、缓冲区管理等。但核心骨架就是这个样子。我建议你从这个骨架开始,逐步添加功能,比如支持POST请求、解析URL参数、返回静态文件等。

知识体系总览

下面这张图,把本章的核心知识点串在了一起。你可以看到,从底层的Socket封装,到上层的HTTP服务器,每一层都有明确的职责。

网络编程知识体系 Socket API封装 RAII管理fd | 非阻塞设置 | 地址绑定 | 错误处理集中化 Reactor模式 事件循环 | 回调注册 | 事件分发 | 业务与I/O解耦 epoll多路复用 LT vs ET | 非阻塞I/O | O(1)事件通知 | 大规模连接支持 HTTP服务器 请求解析 | 响应生成 | 连接管理 | 静态文件服务

从这张图你可以看到,每一层都依赖下一层提供的服务。Socket封装提供了基础的网络操作能力;Reactor模式定义了事件驱动的架构;epoll提供了高性能的事件通知机制;HTTP服务器则是这些技术的综合应用。

我个人建议,学习网络编程不要急于求成。先把Socket封装写好,跑通一个简单的echo服务器。然后引入Reactor模式,把代码重构一遍。接着把select换成epoll,感受性能的提升。最后再写HTTP服务器。每一步都走扎实了,你的网络编程功底就稳了。

总结:网络编程的核心不在于记住几个API,而在于理解I/O模型和事件驱动的设计思想。API会过时,但思想不会。掌握了Reactor模式和非阻塞I/O,你就能应对绝大多数网络编程场景。


公众号:蓝海资料掘金营,微信deep3321