从零手写 C++ 高并发 Web Server:v0.1 Tcp Echo Server

前言

本文是「从零手写 C++ 高并发 Web Server」系列第一章。我从最基础的阻塞 I/O 出发,一步步把它演进成支持 HTTP 的并发服务器。

项目版本快照:webserver_cpp_v0.1 | 对应 tag:v0.1

阅读本文需要:C++ 基础、了解 TCP/IP 基本概念(知道什么是 socket 即可)

为什么从 Echo Server 开始

学习网络编程,绕不开 Socket API。但是很多教程直接上 epoll、reactor,反而让人摸不清楚底层发生了什么。 我选择从最简单的 Tcp Echo Server 开始------客户端发送什么,服务端就回复什么。功能简单,但是完整走完 socket -> bind -> listen -> recv/send 这条链路,后续引入 epoll 时就知道哪里发生了变化、为什么要变。


开发环境

  • 系统:Ubuntu 24.04.1 LTS(x86_64)
  • 编译器:GCC 13.3.0 / C++17
  • 构建工具:CMake 3.28.3
  • 调试工具:GNU GDB 15.1

注:理论上只要支持 C++17 的编译器和 CMake 版本均可编译通过,以上为本人开发与测试时的具体环境。


构建运行

bash 复制代码
# 下载项目文件
git clone https://github.com/ziyan-cs/webserver-cpp
cd webserver-cpp

# 切换到本文对应版本
git checkout v0.1

#构建项目
cmake -B build && cmake --build build

# 运行服务
./bin/webserver

正确运行图示:

服务启动后,支持默认初始化配置以及手动配置。之后终端会先打印配置信息,随后进入监听状态。当客户端连接并发送数据时,服务端会打印连接信息和收到的数据,并原样回显给客户端。


项目结构

v0.1 采用 CMake 三层架构,各模块管理自己的源码:

text 复制代码
webserver-cpp/
├── .gitignore
├── CMakeLists.txt
└── src/
    ├── CMakeLists.txt
    ├── log/        # 日志宏
    ├── config/     # 服务器配置
    ├── net/        # Socket 封装
    └── server/     # TcpServer + main

模块拆分从一开始就做,是为了后续 v0.2 引入 epoll 时不用大改结构。


设计思路

1. 为什么这样拆分出模块

项目从一开始就按职责分模块:Config 管配置、Logger 管日志、Socket 管连接、TcpServer 管业务逻辑。对 v0.1 做分层设计,核心目的是实现低耦合、易扩展,后续引入 Epoll、HTTP 时改动最小------只需要扩展对应模块,不用重构整体结构。

2. 服务器数据流模型

流程:main 初始化配置 → TcpServer 启动服务 → Socket 监听端口 → accept 建立客户端连接 → 循环 recv 接收数据 → send 原样回显 → 客户端断开后关闭连接 → 循环处理下一个请求


核心实现

Config:集中管理配置

portipbacklog 等配置信息封装进一个类,支持默认值和自定义覆盖。

cpp 复制代码
class Config {
public:
    Config() = default;
    Config(int port, int backlog, int buffer_size, std::string ip);

    // Getters
    int port() const;
    int backlog() const;
    int bufferSize() const;
    std::string ip() const;

    // 其他 Setters、调试方法在完整实现中提供
private:
    int port_ = 8080;
    int backlog_ = 5;
    int buffer_size_ = 1024; 
    std::string ip_ = "0.0.0.0";
};

Logger:四级日志宏

cpp 复制代码
#define LOG_INFO(msg)  std::cout << "[INFO]  " << msg << '\n'
#define LOG_DEBUG(msg) std::cout << "[DEBUG] " << msg << '\n'
#define LOG_WARN(msg)  std::cout << "[WARN]  " << msg << '\n'
#define LOG_ERROR(msg) std::cout << "[ERROR] " << msg << '\n'

v0.1 用宏实现日志输出够用了,后续可以升级成异步日志。

Socket:RAII 封装

socket() 创建和 close() 释放封装在构造/析构里,避免资源泄漏:

cpp 复制代码
class Socket {
public:
    Socket();
    Socket(int fd);
    ~Socket();

    // 封装 socket API
    void bindSock(int port, std::string ip);
    void listenSock(int backlg);
    int acceptSock();

    // Getters
    int fd() const { return fd_; }
private:
    int fd_;
};

TcpServer:主循环

服务器核心循环由 g_running 控制,持续监听并接收客户端连接,同时处理信号断开,保证服务稳定与优雅退出。

完整的错误处理、缓冲区初始化等逻辑已省略,详见 GitHub 源码。

cpp 复制代码
class TcpServer {
public:
    TcpServer(const Config& config);
    ~TcpServer();

    void start();
private:
    void init();  // socket、bind、listen 初始化
    void run();   // 主循环逻辑

    Config config_;
    Socket listen_socket_;
};


void TcpServer::init() {
    listen_socket_.bindSock(config_.port(), config_.ip());
    listen_socket_.listenSock(config_.backlog());
    LOG_INFO("Server init succeeded");
}

void TcpServer::run() {
    signal(SIGINT, signalHandler);
    g_running = true;

    while (g_running) {    // 全局标志位,由信号处理函数控制
    
        // 1. 接收新连接,处理信号中断
        int connect_fd = listen_socket_.acceptSock();
        if (connect_fd == -1) {
            if (errno == EINTR && !g_running) {  //踩坑②
                break;
            }
            continue;
        }
        LOG_INFO("new client connected, fd=" << connect_fd);
        
        // 2. 处理客户端读写
        while (g_running) {
            recv(connect_fd, buffer.data(), buffer_size, 0);
            // 处理 EINTR、客户端断开、数据读取逻辑 
            send(connect_fd, buffer.data(), n, 0);
            // 判断处理逻辑在完整实现中
        }
        ::close(connect_fd);
    }
    ::close(listen_socket_.fd());
    LOG_INFO("Server stopped");
}

踩坑记录

① recv 收到的数据会乱码

现象LOG_INFO << buffer.data() 输出乱码,数组越界。

根因recv() 返回的是二进制流,不会自动补 \0。用 data() 当 C 字符串输出,读到随机内存了。

解决recv() 收到数据后手动补 buffer[n] = '\0',再输出。

② Ctrl+C 无法退出服务器

现象 :按下 Ctrl+C 发送 SIGINT,服务器不退出,仍卡在 accept() 里。

根因 :阻塞系统调用(accept/recv)被信号打断时会返回 -1errno 置为 EINTR。如果不处理这个返回值,循环直接 continue,永远出不去。

解决

cpp 复制代码
// 注册信号处理
volatile sig_atomic_t g_running = 1;
signal(SIGINT, [](int){ g_running = 0; });

// accept 处添加判断
int client_fd = ::accept(fd_, ...);
if (client_fd < 0) {
    if (errno == EINTR && !g_running) break;
}

③ CMake 链接报错 cannot find -lcore

现象main.cpp 编译正常,链接时找不到自定义库。

根因build/ 目录下有旧的 CMake 缓存,新增模块后缓存没有更新。

解决 :删掉 build/ 目录重新构建。

bash 复制代码
rm -rf build && cmake -B build && cmake --build build

④ telnet 测试时首字节叠加

  • 现象 :Windows Telnet 连接服务器输入 hello,终端显示 heelllloo。可是 Linux telent 和 nc 逐行发送回车后显示正常

Windows Telent:
Linux telnet:
Linux nc:

  • 根因:三种客户端行为不同:
客户端 发送方式 本地回显 结果
Windows Telnet 逐字符发送 除首字符外每个字符显示两次
Linux telnet 整行发送 正常
Linux nc 整行发送 正常

Windows Telnet 逐字符发送时,每个字符会显示两次(本地回显 + 服务端 echo),这是预期行为。异常的反而是第一个字符 h------服务端发回数据包的时序刚好与本地回显重合,两个 h 盖在同一位置,只看到一个,丢失了一次显示

  • 结论:服务端代码完全正确,是 Windows Telnet 逐字符发送 + 本地回显的客户端特性导致的视觉问题。

  • 验证:通过 Windows 客户端重定向输出查看,可见服务端收发的数据是完全正常的。

bash 复制代码
(echo "hello"; sleep 0.5) | nc 127.0.0.1 8080 > output.txt
cat output.txt

运行效果

text 复制代码
运行效果见上方踩坑截图,服务端收发均正常。

v0.1 的局限

阻塞 I/O 最大的问题:每次只能服务一个客户端accept() 拿到连接后,整个进程就会卡住,第二个客户端只能干等。

下一篇 v0.2 引入 epoll,让服务器同时处理多个连接。


系列文章:

  • v0.1 Tcp Echo Server(本篇)
  • v0.2 引入 Epoll(下一篇)
  • v0.3 添加 HTTP 解析(进行中)

项目持续迭代中,当前进度见:github.com/ziyan-cs/we... 下一篇:v0.2 引入 Epoll,敬请期待。

第一次写技术文章,有表述不清或理解有误的地方,欢迎评论指出。

相关推荐
晓杰'1 天前
从0到1实现Balatro游戏后端(4):玩家手牌操作(出牌 / 弃牌 / 补牌)与状态流转设计
后端·websocket·typescript·node.js·状态模式·项目实战·nestjs
Unbelievabletobe1 天前
外汇实时api的WebSocket心跳间隔设多少秒最稳定?
开发语言·网络·python·websocket·网络协议
Upsy-Daisy1 天前
OpenClaw 源码解析(七):Gateway 控制平面与 WebSocket RPC 机制
websocket·平面·gateway
wWYy.2 天前
WebSocket
网络·websocket·网络协议
守护安静星空3 天前
websocket
网络·websocket·网络协议
alwaysrun4 天前
C++之轻量极速Web框架Crow
c++·websocket·http
晓杰'5 天前
Balatro后端进阶(2):基于GitHub Actions的CI自动化验证实现
websocket·ci/cd·typescript·node.js·自动化·github·nestjs
Irissgwe6 天前
一、网络基础概念
linux·网络·websocket·网络协议·socket·linux网络编程
AIFQuant6 天前
Java 对接全球股票实时报价:高可用架构与异常处理
java·开发语言·websocket·金融·架构·股票api