前言
本文是「从零手写 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:集中管理配置
把 port、ip、backlog 等配置信息封装进一个类,支持默认值和自定义覆盖。
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)被信号打断时会返回 -1,errno 置为 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,敬请期待。
第一次写技术文章,有表述不清或理解有误的地方,欢迎评论指出。



