From e00b0f8a8b51f1ee807440d6557af5ff78a8dd03 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Sun, 28 Jun 2026 22:39:34 +0800 Subject: [PATCH] feat: networking fundementals --- README.md | 2 +- .../lab0-mini-reactor/CMakeLists.txt | 40 +++ .../vol8-labs/lab0-mini-reactor/README.md | 47 +++ .../lab0-mini-reactor/include/net/reactor.hpp | 49 +++ .../include/net/unique_fd.hpp | 45 +++ .../lab0-mini-reactor/src/reactor.cpp | 84 ++++++ .../lab0-mini-reactor/tests/lab0_tests.cpp | 284 ++++++++++++++++++ .../00-traditional-socket/CMakeLists.txt | 15 + .../00-traditional-socket/README.md | 46 +++ .../00-traditional-socket/classic_client.c | 42 +++ .../00-traditional-socket/classic_server.c | 72 +++++ .../01-modern-socket/CMakeLists.txt | 20 ++ .../networking/01-modern-socket/README.md | 44 +++ .../01-modern-socket/echo_client.cpp | 50 +++ .../01-modern-socket/echo_server.cpp | 109 +++++++ .../01-modern-socket/hold_clients.cpp | 43 +++ .../networking/01-modern-socket/unique_fd.hpp | 44 +++ documents/vol8-domains/index.md | 1 + documents/vol8-domains/networking/.pages | 1 + .../00-traditional-socket-basics.md | 243 +++++++++++++++ .../networking/01-modern-socket-wrapping.md | 216 +++++++++++++ .../networking/02-epoll-io-multiplexing.md | 178 +++++++++++ .../networking/03-reactor-pattern.md | 163 ++++++++++ documents/vol8-domains/networking/index.md | 29 ++ .../networking/lab0-mini-reactor.md | 151 ++++++++++ 25 files changed, 2017 insertions(+), 1 deletion(-) create mode 100644 code/volumn_codes/vol8-labs/lab0-mini-reactor/CMakeLists.txt create mode 100644 code/volumn_codes/vol8-labs/lab0-mini-reactor/README.md create mode 100644 code/volumn_codes/vol8-labs/lab0-mini-reactor/include/net/reactor.hpp create mode 100644 code/volumn_codes/vol8-labs/lab0-mini-reactor/include/net/unique_fd.hpp create mode 100644 code/volumn_codes/vol8-labs/lab0-mini-reactor/src/reactor.cpp create mode 100644 code/volumn_codes/vol8-labs/lab0-mini-reactor/tests/lab0_tests.cpp create mode 100644 code/volumn_codes/vol8/networking/00-traditional-socket/CMakeLists.txt create mode 100644 code/volumn_codes/vol8/networking/00-traditional-socket/README.md create mode 100644 code/volumn_codes/vol8/networking/00-traditional-socket/classic_client.c create mode 100644 code/volumn_codes/vol8/networking/00-traditional-socket/classic_server.c create mode 100644 code/volumn_codes/vol8/networking/01-modern-socket/CMakeLists.txt create mode 100644 code/volumn_codes/vol8/networking/01-modern-socket/README.md create mode 100644 code/volumn_codes/vol8/networking/01-modern-socket/echo_client.cpp create mode 100644 code/volumn_codes/vol8/networking/01-modern-socket/echo_server.cpp create mode 100644 code/volumn_codes/vol8/networking/01-modern-socket/hold_clients.cpp create mode 100644 code/volumn_codes/vol8/networking/01-modern-socket/unique_fd.hpp create mode 100644 documents/vol8-domains/networking/.pages create mode 100644 documents/vol8-domains/networking/00-traditional-socket-basics.md create mode 100644 documents/vol8-domains/networking/01-modern-socket-wrapping.md create mode 100644 documents/vol8-domains/networking/02-epoll-io-multiplexing.md create mode 100644 documents/vol8-domains/networking/03-reactor-pattern.md create mode 100644 documents/vol8-domains/networking/index.md create mode 100644 documents/vol8-domains/networking/lab0-mini-reactor.md diff --git a/README.md b/README.md index 288a3c9ea..cd880aa4f 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ --- -![English Coverage](https://img.shields.io/badge/en_coverage-100%25-green.svg) 494/494 docs translated +![English Coverage](https://img.shields.io/badge/en_coverage-99%25-green.svg) 494/500 docs translated ## 这是什么项目 diff --git a/code/volumn_codes/vol8-labs/lab0-mini-reactor/CMakeLists.txt b/code/volumn_codes/vol8-labs/lab0-mini-reactor/CMakeLists.txt new file mode 100644 index 000000000..2ad195b3b --- /dev/null +++ b/code/volumn_codes/vol8-labs/lab0-mini-reactor/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(lab0-mini-reactor LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +# Catch2 v3(FetchContent 拉取;首次配置需联网) +include(FetchContent) +FetchContent_Declare( + Catch2 + GIT_REPOSITORY https://github.com/catchorg/Catch2.git + GIT_TAG v3.5.0) +FetchContent_MakeAvailable(Catch2) +list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras) + +# ---------- 普通版:reactor 库(参考实现)+ 测试 ---------- +add_library(reactor STATIC src/reactor.cpp) +target_include_directories(reactor PUBLIC include) +target_compile_options(reactor PUBLIC -Wall -Wextra -Wpedantic -g) + +add_executable(lab0_tests tests/lab0_tests.cpp) +target_link_libraries(lab0_tests PRIVATE reactor Catch2::Catch2WithMain) +target_compile_options(lab0_tests PRIVATE -Wall -Wextra -Wpedantic -g -pthread) +target_link_options(lab0_tests PRIVATE -pthread) + +# ---------- TSan 版:同样的源码,编译期开 -fsanitize=thread ---------- +# reactor 库也单独编一份 tsan 版,否则它内部的并发(如 stop_ 原子)在 TSan 下不可见 +add_library(reactor_tsan STATIC src/reactor.cpp) +target_include_directories(reactor_tsan PUBLIC include) +target_compile_options(reactor_tsan PUBLIC -Wall -Wextra -Wpedantic -g -fsanitize=thread -pthread) +target_link_options(reactor_tsan PUBLIC -fsanitize=thread) + +add_executable(lab0_tests_tsan tests/lab0_tests.cpp) +target_link_libraries(lab0_tests_tsan PRIVATE reactor_tsan Catch2::Catch2WithMain) +target_compile_options(lab0_tests_tsan PRIVATE -Wall -Wextra -Wpedantic -g -fsanitize=thread -pthread) +target_link_options(lab0_tests_tsan PRIVATE -fsanitize=thread) + +# 便捷:make check = 跑普通测试 +add_custom_target(check COMMAND $) diff --git a/code/volumn_codes/vol8-labs/lab0-mini-reactor/README.md b/code/volumn_codes/vol8-labs/lab0-mini-reactor/README.md new file mode 100644 index 000000000..d19ce901b --- /dev/null +++ b/code/volumn_codes/vol8-labs/lab0-mini-reactor/README.md @@ -0,0 +1,47 @@ +# Lab 0 · mini Reactor echo server + +配套文章:`documents/vol8-domains/networking/lab0-mini-reactor.md` + +在 epoll 上实现一个最小的 Reactor(事件循环 + handler 注册表),做成能扛并发的 echo server。MS1-4 四个 milestone,每个的验收都是**对抗性的**(并发不崩、ET 大 burst 不丢数据、stop 不挂、TSan 无 race)。 + +## 怎么做这个 Lab + +1. 读 `include/net/reactor.hpp`(接口)。 +2. 把 `src/reactor.cpp` 清空(现在是**参考答案**,留着对照思路),对照接口自己写一遍 `Reactor`。 +3. 构建 + 跑测试,让 `tests/lab0_tests.cpp` 的 4 个用例(MS1-4)逐个变绿。 + +## 构建 + +```bash +cd code/volumn_codes/vol8-labs/lab0-mini-reactor +cmake -S . -B build +cmake --build build -j +``` + +首次配置会从 github 拉 Catch2(FetchContent)。**若网络受限拉不下来**,手动浅克隆一份、用 `-DFETCHCONTENT_SOURCE_DIR_CATCH2` 指过去: + +```bash +git clone --depth 1 -b v3.5.0 https://github.com/catchorg/Catch2.git /tmp/catch2 +cmake -S . -B build -DFETCHCONTENT_SOURCE_DIR_CATCH2=/tmp/catch2 +cmake --build build -j +``` + +## 跑测试 + +```bash +./build/lab0_tests # 普通测试:MS1-4 +./build/lab0_tests_tsan # TSan 版:同样的用例,编译期开 -fsanitize=thread +``` + +预期:`All tests passed (6 assertions in 4 test cases)`,且 TSan 版**无任何 warning**。 + +## MS1-4 验收速查 + +| MS | 验收(对抗性) | 抓的坑 | +|----|------|------| +| MS1 | 单连接 + 顺序多连接 echo 全对 | 事件循环转起来 | +| MS2 | 16 并发 echo 全对,**TSan 无 race** | handler 注册表 / 隐藏 data race | +| MS3 | ET + 100KB burst,echo **正好 100000** 字节 | ET 不循环读到 EAGAIN → 丢数据([02 篇](../../../../documents/vol8-domains/networking/02-epoll-io-multiplexing.md)那个坑) | +| MS4 | `stop()` 后 `run()` 2s 内返回 | epoll_wait 阻塞、关闭挂死 | + +参考实现(`src/reactor.cpp`)一个值得注意的点:`run()` 调 handler 前先 `Handler h = it->second;` 拷一份——因为 handler 在 EOF 时会 `remove` 自己,直接调 map 里那个正在执行的 `std::function` 是 use-after-free(TSan 会抓)。这是 reactor 的经典自删除坑。 diff --git a/code/volumn_codes/vol8-labs/lab0-mini-reactor/include/net/reactor.hpp b/code/volumn_codes/vol8-labs/lab0-mini-reactor/include/net/reactor.hpp new file mode 100644 index 000000000..6baabe40c --- /dev/null +++ b/code/volumn_codes/vol8-labs/lab0-mini-reactor/include/net/reactor.hpp @@ -0,0 +1,49 @@ +#pragma once +#include +#include +#include +#include + +namespace net { + +/// 事件处理器:某个 fd 就绪时被调用,传入就绪的事件位(EPOLLIN/EPOLLOUT/...)。 +using Handler = std::function; + +/// 一个最小的 Reactor:epoll 事件循环 + fd→handler 注册表。 +/// +/// 所有 handler 都在 run() 的线程上同步执行(单线程 Reactor)——这是它"免锁" +/// 的关键:同一时刻只有一个 handler 在跑,共享状态不会被并发改。多线程安全靠 +/// stop()(用 eventfd 唤醒阻塞中的 epoll_wait)。 +/// +/// 配套 documents/vol8-domains/networking/lab0-mini-reactor.md。 +class Reactor { + public: + Reactor(); + ~Reactor(); + + Reactor(const Reactor&) = delete; + Reactor& operator=(const Reactor&) = delete; + + /// 注册 fd:关心 events,就绪时调 h。fd 必须是有效的、且尚未注册。 + void add(int fd, std::uint32_t events, Handler h); + + /// 修改已注册 fd 关心的事件(如 LT→ET、加 EPOLLOUT)。 + void modify(int fd, std::uint32_t events); + + /// 注销 fd(从兴趣表移除 + 删 handler)。 + void remove(int fd); + + /// 运行事件循环,阻塞直到 stop() 被调用。 + void run(); + + /// 请求停止(可从别的线程或信号 handler 调用;eventfd 写唤醒 epoll_wait)。 + void stop(); + + private: + int ep_fd_{-1}; + int stop_fd_{-1}; // eventfd,用于唤醒 + std::atomic stop_{false}; + std::unordered_map handlers_; +}; + +} // namespace net diff --git a/code/volumn_codes/vol8-labs/lab0-mini-reactor/include/net/unique_fd.hpp b/code/volumn_codes/vol8-labs/lab0-mini-reactor/include/net/unique_fd.hpp new file mode 100644 index 000000000..a4553425a --- /dev/null +++ b/code/volumn_codes/vol8-labs/lab0-mini-reactor/include/net/unique_fd.hpp @@ -0,0 +1,45 @@ +#pragma once +#include + +namespace net { + +/// RAII 包装一个 POSIX fd:不可拷贝、仅可移动,析构时 close。 +/// (与 01-modern-socket 同一个,Lab 里复用。) +class UniqueFd { + public: + UniqueFd() = default; + explicit UniqueFd(int fd) : fd_{fd} {} + ~UniqueFd() { reset(); } + + UniqueFd(const UniqueFd&) = delete; + UniqueFd& operator=(const UniqueFd&) = delete; + + UniqueFd(UniqueFd&& other) noexcept : fd_{other.fd_} { other.fd_ = -1; } + UniqueFd& operator=(UniqueFd&& other) noexcept { + if (this != &other) { + reset(); + fd_ = other.fd_; + other.fd_ = -1; + } + return *this; + } + + void reset() { + if (fd_ >= 0) { + ::close(fd_); + fd_ = -1; + } + } + int get() const { return fd_; } + int release() { + int f = fd_; + fd_ = -1; + return f; + } + explicit operator bool() const { return fd_ >= 0; } + + private: + int fd_{-1}; +}; + +} // namespace net diff --git a/code/volumn_codes/vol8-labs/lab0-mini-reactor/src/reactor.cpp b/code/volumn_codes/vol8-labs/lab0-mini-reactor/src/reactor.cpp new file mode 100644 index 000000000..645e06252 --- /dev/null +++ b/code/volumn_codes/vol8-labs/lab0-mini-reactor/src/reactor.cpp @@ -0,0 +1,84 @@ +// Reactor 参考实现(答案版)。Lab 的任务是:读完 reactor.hpp 这个接口, +// 自己在 src/ 里重写一遍,让 tests/ 全绿。这个文件是给你对照的参考答案。 +#include "net/reactor.hpp" + +#include +#include +#include +#include +#include + +namespace net { + +Reactor::Reactor() { + ep_fd_ = ::epoll_create1(0); + stop_fd_ = ::eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK); + // 把 stop_fd_ 也挂进 epoll,这样 stop() 往它写一字节就能唤醒阻塞中的 epoll_wait + epoll_event ev{}; + ev.events = EPOLLIN; + ev.data.fd = stop_fd_; + ::epoll_ctl(ep_fd_, EPOLL_CTL_ADD, stop_fd_, &ev); +} + +Reactor::~Reactor() { + if (ep_fd_ >= 0) + ::close(ep_fd_); + if (stop_fd_ >= 0) + ::close(stop_fd_); +} + +void Reactor::add(int fd, std::uint32_t events, Handler h) { + handlers_[fd] = std::move(h); + epoll_event ev{}; + ev.events = events; + ev.data.fd = fd; + ::epoll_ctl(ep_fd_, EPOLL_CTL_ADD, fd, &ev); +} + +void Reactor::modify(int fd, std::uint32_t events) { + epoll_event ev{}; + ev.events = events; + ev.data.fd = fd; + ::epoll_ctl(ep_fd_, EPOLL_CTL_MOD, fd, &ev); +} + +void Reactor::remove(int fd) { + handlers_.erase(fd); + ::epoll_ctl(ep_fd_, EPOLL_CTL_DEL, fd, nullptr); +} + +void Reactor::stop() { + stop_.store(true, std::memory_order_relaxed); + std::uint64_t one = 1; + ::write(stop_fd_, &one, sizeof(one)); // 唤醒 epoll_wait +} + +void Reactor::run() { + std::array events; + while (!stop_.load(std::memory_order_relaxed)) { + int n = ::epoll_wait(ep_fd_, events.data(), static_cast(events.size()), -1); + if (n < 0) { + if (errno == EINTR) + continue; // 被信号打断,重试 + break; // 真出错,退出 + } + for (int i = 0; i < n; ++i) { + int fd = events[i].data.fd; + if (fd == stop_fd_) { + std::uint64_t v; // 清掉 eventfd 的计数 + ::read(stop_fd_, &v, sizeof(v)); + continue; // stop_ 会在 while 条件里被检查 + } + auto it = handlers_.find(fd); + if (it != handlers_.end()) { + // 拷一份 handler 再调:handler 里若 remove 自己(EOF 时常见), + // 会从 handlers_ 擦除正在执行的这个 std::function,直接调 it->second + // 就是 use-after-free。先拷一份,本次调用就安全了。 + Handler h = it->second; + h(events[i].events); + } + } + } +} + +} // namespace net diff --git a/code/volumn_codes/vol8-labs/lab0-mini-reactor/tests/lab0_tests.cpp b/code/volumn_codes/vol8-labs/lab0-mini-reactor/tests/lab0_tests.cpp new file mode 100644 index 000000000..02e604308 --- /dev/null +++ b/code/volumn_codes/vol8-labs/lab0-mini-reactor/tests/lab0_tests.cpp @@ -0,0 +1,284 @@ +// Lab 0 测试:mini Reactor echo server 的 MS1-4 对抗性验收。 +// 配套 documents/vol8-domains/networking/lab0-mini-reactor.md。 +// 用法:先在 src/reactor.cpp 里(对照 reactor.hpp)实现 Reactor,再跑这些测试。 +#include + +#include "net/reactor.hpp" +#include "net/unique_fd.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr std::uint16_t kPortBase = 14000; // 每个用例用不同端口,避免 TIME_WAIT 互撞 + +bool set_nonblock(int fd) { + int fl = ::fcntl(fd, F_GETFL, 0); + return fl >= 0 && ::fcntl(fd, F_SETFL, fl | O_NONBLOCK) >= 0; +} + +int make_listener(std::uint16_t port) { + int fd = ::socket(AF_INET, SOCK_STREAM, 0); + int yes = 1; + ::setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)); + sockaddr_in a{}; + a.sin_family = AF_INET; + a.sin_addr.s_addr = htonl(INADDR_ANY); + a.sin_port = htons(port); + ::bind(fd, reinterpret_cast(&a), sizeof(a)); + ::listen(fd, 64); + set_nonblock(fd); + return fd; +} + +int connect_client(std::uint16_t port) { + int fd = ::socket(AF_INET, SOCK_STREAM, 0); + sockaddr_in a{}; + a.sin_family = AF_INET; + a.sin_port = htons(port); + ::inet_pton(AF_INET, "127.0.0.1", &a.sin_addr); + ::connect(fd, reinterpret_cast(&a), sizeof(a)); + return fd; +} + +// 带超时的读:在 timeout_ms 内尽量读,返回累计读到的字节数。 +std::size_t read_with_timeout(int fd, std::size_t want, int timeout_ms) { + std::size_t got = 0; + std::vector buf(4096); + auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); + while (got < want) { + int remain = static_cast(std::chrono::duration_cast( + deadline - std::chrono::steady_clock::now()) + .count()); + if (remain <= 0) + break; + ::pollfd p{fd, POLLIN, 0}; + int r = ::poll(&p, 1, remain); + if (r <= 0) + break; + ssize_t n = ::read(fd, buf.data(), buf.size()); + if (n <= 0) + break; + got += static_cast(n); + } + return got; +} + +// 一个正确的 echo 连接 handler:循环读到 EAGAIN,每段循环写到发完。 +// captures reactor 以便 EOF 时 remove 自己。 +net::Handler make_echo_handler(net::Reactor& r, int conn_fd) { + return [&r, conn_fd](std::uint32_t) { + std::array buf; // 栈上缓冲,每次调用一份(不用 thread_local,避免生命周期坑) + for (;;) { + ssize_t n = ::read(conn_fd, buf.data(), buf.size()); + if (n > 0) { + std::size_t off = 0; + while (off < static_cast(n)) { + ssize_t w = + ::write(conn_fd, buf.data() + off, static_cast(n) - off); + if (w < 0) { + if (errno == EINTR) + continue; + break; // EAGAIN 等:简化处理,丢这段 + } + off += static_cast(w); + } + } else if (n == 0) { + r.remove(conn_fd); + ::close(conn_fd); + return; + } else { + if (errno == EAGAIN || errno == EWOULDBLOCK) + return; // 读空,等下次 + if (errno == EINTR) + continue; + r.remove(conn_fd); + ::close(conn_fd); + return; + } + } + }; +} +} // namespace + +// ============ MS1:事件循环 + 单连接 echo(以及多个顺序连接)============ +TEST_CASE("MS1: reactor runs and echoes a connection") { + constexpr std::uint16_t port = kPortBase + 1; + net::Reactor r; + int lfd = make_listener(port); + r.add(lfd, EPOLLIN, [&]() { + net::Handler accept_h; + accept_h = [&](std::uint32_t) { + for (;;) { + int c = ::accept(lfd, nullptr, nullptr); + if (c < 0) { + if (errno == EINTR) + continue; + break; + } + set_nonblock(c); + r.add(c, EPOLLIN, make_echo_handler(r, c)); + } + }; + return accept_h; + }()); + + std::thread loop([&] { r.run(); }); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + // 单连接 + { + net::UniqueFd c{connect_client(port)}; + const char* msg = "hello-ms1"; + ::write(c.get(), msg, std::strlen(msg)); + std::size_t got = read_with_timeout(c.get(), std::strlen(msg), 1000); + REQUIRE(got == std::strlen(msg)); + } + // 再来一个顺序连接 + { + net::UniqueFd c{connect_client(port)}; + const char* msg = "second-ms1"; + ::write(c.get(), msg, std::strlen(msg)); + std::size_t got = read_with_timeout(c.get(), std::strlen(msg), 1000); + REQUIRE(got == std::strlen(msg)); + } + + r.stop(); + loop.join(); + ::close(lfd); +} + +// ============ MS2:N 个并发客户端全部正确 echo(TSan 下应无 race)============ +TEST_CASE("MS2: concurrent clients all echo correctly") { + constexpr std::uint16_t port = kPortBase + 2; + constexpr int kN = 16; + net::Reactor r; + int lfd = make_listener(port); + r.add(lfd, EPOLLIN, [&](std::uint32_t) { + for (;;) { + int c = ::accept(lfd, nullptr, nullptr); + if (c < 0) { + if (errno == EINTR) + continue; + break; + } + set_nonblock(c); + r.add(c, EPOLLIN, make_echo_handler(r, c)); + } + }); + + std::thread loop([&] { r.run(); }); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + std::atomic ok{0}; + std::vector clients; + for (int i = 0; i < kN; ++i) { + clients.emplace_back([&, i] { + net::UniqueFd c{connect_client(port)}; + std::string msg = "client-" + std::to_string(i); + ::write(c.get(), msg.data(), msg.size()); + if (read_with_timeout(c.get(), msg.size(), 1500) == msg.size()) + ok.fetch_add(1); + }); + } + for (auto& t : clients) + t.join(); + REQUIRE(ok.load() == kN); + + r.stop(); + loop.join(); + ::close(lfd); +} + +// ============ MS3(对抗性):ET 模式 + 大 burst 不丢数据 ============ +// 连接注册成 EPOLLET | EPOLLIN,handler 必须循环读到 EAGAIN,否则一次只读 4KB、 +// 剩下的卡在缓冲区里 ET 不再通知——就是 02 篇那个"丢 87KB"的坑。 +TEST_CASE("MS3: ET mode echoes a large burst without data loss") { + constexpr std::uint16_t port = kPortBase + 3; + constexpr std::size_t kBurst = 100000; + net::Reactor r; + int lfd = make_listener(port); + r.add(lfd, EPOLLIN, [&](std::uint32_t) { + for (;;) { + int c = ::accept(lfd, nullptr, nullptr); + if (c < 0) { + if (errno == EINTR) + continue; + break; + } + set_nonblock(c); + // ★关键:连接也用 EPOLLET,handler 必须循环读到 EAGAIN + r.add(c, EPOLLIN | EPOLLET, make_echo_handler(r, c)); + } + }); + + std::thread loop([&] { r.run(); }); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + net::UniqueFd c{connect_client(port)}; + std::string payload(kBurst, 'A'); + std::size_t sent = 0; + while (sent < kBurst) { // 客户端把 100KB 全发出去 + ssize_t w = ::write(c.get(), payload.data() + sent, kBurst - sent); + if (w <= 0) { + if (errno == EINTR) + continue; + break; + } + sent += static_cast(w); + } + std::size_t got = read_with_timeout(c.get(), kBurst, 3000); + REQUIRE(sent == kBurst); + REQUIRE(got == kBurst); // ← 对抗性验收:一字节都不能少 + + r.stop(); + loop.join(); + ::close(lfd); +} + +// ============ MS4:优雅关闭——stop() 后 run() 必须返回,不挂死 ============ +TEST_CASE("MS4: stop() unblocks run() without hanging") { + constexpr std::uint16_t port = kPortBase + 4; + net::Reactor r; + int lfd = make_listener(port); + r.add(lfd, EPOLLIN, [&](std::uint32_t) { + for (;;) { + int c = ::accept(lfd, nullptr, nullptr); + if (c < 0) { + if (errno == EINTR) + continue; + break; + } + set_nonblock(c); + r.add(c, EPOLLIN, make_echo_handler(r, c)); + } + }); + + // 在独立线程里跑 run(),用 future 做"带超时的 join" + std::future fut = std::async(std::launch::async, [&] { r.run(); }); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + // 先连一个客户端制造"有活动"的状态,再 stop,模拟生产中"带载关闭" + net::UniqueFd c{connect_client(port)}; + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + + r.stop(); // 从主线程请求停止 + auto status = fut.wait_for(std::chrono::seconds(2)); + REQUIRE(status == std::future_status::ready); // ← 验收:2s 内 run() 必须返回 + + ::close(lfd); +} diff --git a/code/volumn_codes/vol8/networking/00-traditional-socket/CMakeLists.txt b/code/volumn_codes/vol8/networking/00-traditional-socket/CMakeLists.txt new file mode 100644 index 000000000..20fcb167e --- /dev/null +++ b/code/volumn_codes/vol8/networking/00-traditional-socket/CMakeLists.txt @@ -0,0 +1,15 @@ +cmake_minimum_required(VERSION 3.14) +project(traditional-socket-echo LANGUAGES C) + +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +set(COMMON_FLAGS -Wall -Wextra -Wpedantic -g) + +# 文章 00:传统 C 风格 echo server/client(BSD socket 五步) +add_executable(echo_server classic_server.c) +target_compile_options(echo_server PRIVATE ${COMMON_FLAGS}) + +add_executable(echo_client classic_client.c) +target_compile_options(echo_client PRIVATE ${COMMON_FLAGS}) diff --git a/code/volumn_codes/vol8/networking/00-traditional-socket/README.md b/code/volumn_codes/vol8/networking/00-traditional-socket/README.md new file mode 100644 index 000000000..ad49f079f --- /dev/null +++ b/code/volumn_codes/vol8/networking/00-traditional-socket/README.md @@ -0,0 +1,46 @@ +# 00 · 传统 socket echo(server + client) + +配套文章:`documents/vol8-domains/networking/00-traditional-socket-basics.md` + +单线程、C 风格 BSD socket echo,演示服务器五步:`socket → bind → listen → accept → read/write`。不掺并发(并发与 C10K 在 01)。 + +## 编译 + +**方式 A:一行 gcc(最直接,文章里就是这套)** + +```bash +gcc -O2 -Wall -Wextra classic_server.c -o echo_server +gcc -O2 -Wall -Wextra classic_client.c -o echo_client +``` + +**方式 B:CMake(项目统一构建)** + +```bash +cmake -S . -B build && cmake --build build +# 产物:build/echo_server build/echo_client +``` + +## 运行与核查 + +终端 1 起服务(端口 13013 > 1024,无需 sudo): + +```bash +./echo_server +# classic echo server on 0.0.0.0:13013 (pid ...) +``` + +终端 2 跑客户端: + +```bash +./echo_client "hello" +# echo <- 'hello' +``` + +**核查监听 socket**——用 `ss` 把内核里的 LISTEN socket 拍出来。第二列 `64` 就是 `listen(lfd, 64)` 设的 Accept 队列上限(backlog),`fd=3` 是 stdin/stdout/stderr 之后的第一个 fd: + +```bash +ss -tlnp | grep 13013 +# LISTEN 0 64 0.0.0.0:13013 0.0.0.0:* users:(("echo_server",pid=...,fd=3)) +``` + +第一列 `0` 是当前 Accept 队列里积压的连接数(刚被取走,所以是 0)。开第二个客户端连上但不发数据,这列会涨——可以亲手验证 backlog 在管"已完成握手但还没 accept 的连接"。 diff --git a/code/volumn_codes/vol8/networking/00-traditional-socket/classic_client.c b/code/volumn_codes/vol8/networking/00-traditional-socket/classic_client.c new file mode 100644 index 000000000..c7d067d1c --- /dev/null +++ b/code/volumn_codes/vol8/networking/00-traditional-socket/classic_client.c @@ -0,0 +1,42 @@ +/* 传统 echo client:connect → write 一行 → read 回显。 + * 配套 documents/vol8-domains/networking/00-traditional-socket-basics.md。 + */ +#include +#include +#include +#include +#include +#include + +#define PORT 13013 + +int main(int argc, char** argv) { + const char* msg = (argc > 1) ? argv[1] : "hello"; + + int fd = socket(AF_INET, SOCK_STREAM, 0); + + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(PORT); + inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr); + + if (connect(fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) { + perror("connect"); + return 1; + } + + write(fd, msg, strlen(msg)); + + char buf[4096]; + ssize_t n = read(fd, buf, sizeof(buf) - 1); + if (n > 0) { + buf[n] = '\0'; + printf("echo <- '%s'\n", buf); + } else { + printf("no reply (read=%zd)\n", n); + } + + close(fd); + return 0; +} diff --git a/code/volumn_codes/vol8/networking/00-traditional-socket/classic_server.c b/code/volumn_codes/vol8/networking/00-traditional-socket/classic_server.c new file mode 100644 index 000000000..2f8f0c25d --- /dev/null +++ b/code/volumn_codes/vol8/networking/00-traditional-socket/classic_server.c @@ -0,0 +1,72 @@ +/* 传统 C 风格 echo server —— 裸 fd、手动 close、errno + perror。 + * 配套 documents/vol8-domains/networking/00-traditional-socket-basics.md。 + * 这就是从 Stevens《UNIX 网络编程》/ Beej's Guide 学的那套经典写法。 + * 单线程:专注演示"服务器五步",不掺并发(并发是 01 的事)。 + */ +#include +#include +#include +#include +#include +#include +#include +#include + +#define PORT 13013 +#define BACKLOG 64 +#define BUFSZ 4096 + +int main(void) { + signal(SIGPIPE, SIG_IGN); /* 否则 write 到已关连接会触发 SIGPIPE 杀进程 */ + + /* 第一步:socket() 向内核要一个通信端点,返回 fd */ + int lfd = socket(AF_INET, SOCK_STREAM, 0); + if (lfd < 0) { + perror("socket"); + return 1; + } + + int yes = 1; + setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)); + + /* 第二步:bind() 把 fd 钉到本地地址 */ + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_ANY); /* 监听所有网卡 */ + addr.sin_port = htons(PORT); /* 网络字节序 */ + if (bind(lfd, (struct sockaddr*)&addr, sizeof(addr)) < 0) { + perror("bind"); + return 1; + } + + /* 第三步:listen() 标记为被动监听,内核开始接受连接 */ + if (listen(lfd, BACKLOG) < 0) { + perror("listen"); + return 1; + } + + printf("classic echo server on 0.0.0.0:%d (pid %d)\n", PORT, getpid()); + + for (;;) { + /* 第四步:accept() 取出一个已完成握手的连接,返回新 fd */ + int cfd = accept(lfd, NULL, NULL); + if (cfd < 0) { + if (errno == EINTR) + continue; + perror("accept"); + continue; + } + + /* 第五步:在连接 fd 上 read/write */ + char buf[BUFSZ]; + for (;;) { + ssize_t n = read(cfd, buf, BUFSZ); + if (n <= 0) + break; /* 0 = 对端关闭;<0 = 出错 */ + write(cfd, buf, n); /* 教学版:假设一次写完 */ + } + close(cfd); /* 手动 close——漏了就泄漏 fd */ + } + return 0; +} diff --git a/code/volumn_codes/vol8/networking/01-modern-socket/CMakeLists.txt b/code/volumn_codes/vol8/networking/01-modern-socket/CMakeLists.txt new file mode 100644 index 000000000..ce5ae2b05 --- /dev/null +++ b/code/volumn_codes/vol8/networking/01-modern-socket/CMakeLists.txt @@ -0,0 +1,20 @@ +cmake_minimum_required(VERSION 3.14) +project(modern-socket-echo LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 23) # std::expected / std::print 需要 C++23 +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +set(COMMON_FLAGS -Wall -Wextra -Wpedantic -g) + +# 文章 01:Modern C++(RAII + std::expected)echo server + client + C10K 探测 +# server 用了 std::thread,需要 -pthread(编译期 + 链接期都加) +add_executable(echo_server echo_server.cpp) +target_compile_options(echo_server PRIVATE ${COMMON_FLAGS} -pthread) +target_link_options(echo_server PRIVATE -pthread) + +add_executable(echo_client echo_client.cpp) +target_compile_options(echo_client PRIVATE ${COMMON_FLAGS}) + +add_executable(hold_clients hold_clients.cpp) +target_compile_options(hold_clients PRIVATE ${COMMON_FLAGS}) diff --git a/code/volumn_codes/vol8/networking/01-modern-socket/README.md b/code/volumn_codes/vol8/networking/01-modern-socket/README.md new file mode 100644 index 000000000..90dc93832 --- /dev/null +++ b/code/volumn_codes/vol8/networking/01-modern-socket/README.md @@ -0,0 +1,44 @@ +# 01 · 现代 socket 封装(RAII + std::expected + 每连接一线程) + +配套文章:`documents/vol8-domains/networking/01-modern-socket-wrapping.md` + +把 00 的传统 C 风格 echo 现代化:`UniqueFd`(RAII,不可能漏 `close`)+ `std::expected`(错误带上下文)+ 每连接一线程(能扛并发客户端)。结尾实测"每连接一线程"在 2000 并发下把虚拟内存吃到 ~24GB,引出 epoll。 + +## 编译 + +```bash +cd code/volumn_codes/vol8/networking/01-modern-socket +cmake -S . -B build && cmake --build build +# 产物:build/echo_server build/echo_client build/hold_clients +``` + +(需要 C++23:GCC 14+ / Clang 17+ / MSVC 19.34+。本机 GCC 16.1.1。) + +## 运行 + +```bash +./build/echo_server # 终端 1 +./build/echo_client "hi" # 终端 2 → echo <- 'hi' +``` + +## C10K 实测 + +趁 server 跑着,开 2000 条空闲连接,看 server 的虚拟内存/线程数怎么涨: + +```bash +# 终端 1 +./build/echo_server & +SRV=$! +grep -E '^(VmSize|VmRSS|Threads):' /proc/$SRV/status # idle 基线 + +# 终端 2:开 2000 空闲连接并 hold +./build/hold_clients 2000 & +sleep 3 +grep -E '^(VmSize|VmRSS|Threads):' /proc/$SRV/status # 2000 连接下 + +# 释放后 +wait +grep -E '^(VmSize|VmRSS|Threads):' /proc/$SRV/status +``` + +你会看到 `VmSize` 从 ~83MB 飙到 ~24GB、`Threads` 涨到 ~2001——每连接一个线程,每个线程默认 8MB 栈。这就是"每连接一线程"扛不住 C10K 的实证,也是下一篇 epoll 的动机。 diff --git a/code/volumn_codes/vol8/networking/01-modern-socket/echo_client.cpp b/code/volumn_codes/vol8/networking/01-modern-socket/echo_client.cpp new file mode 100644 index 000000000..a61bf9a83 --- /dev/null +++ b/code/volumn_codes/vol8/networking/01-modern-socket/echo_client.cpp @@ -0,0 +1,50 @@ +// Modern echo client:connect → write 一行 → read 回显。配合 01 的 server。 +#include "unique_fd.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +int main(int argc, char** argv) { + constexpr std::uint16_t kPort = 13013; + std::string msg = (argc > 1) ? argv[1] : "hello from modern client"; + + int raw = ::socket(AF_INET, SOCK_STREAM, 0); + if (raw < 0) { + std::print(stderr, "socket errno {}\n", errno); + return 1; + } + UniqueFd fd{raw}; + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(kPort); + if (::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) <= 0) { + std::print(stderr, "inet_pton failed\n"); + return 1; + } + if (::connect(fd.get(), reinterpret_cast(&addr), sizeof(addr)) < 0) { + std::print(stderr, "connect errno {}\n", errno); + return 1; + } + + std::string_view s{msg}; + if (::write(fd.get(), s.data(), s.size()) < 0) { + std::print(stderr, "write errno {}\n", errno); + return 1; + } + + std::array buf; + ssize_t n = ::read(fd.get(), buf.data(), buf.size()); + if (n > 0) + std::print("echo <- '{}'\n", std::string_view{buf.data(), static_cast(n)}); + else + std::print("no reply (read returned {})\n", n); +} diff --git a/code/volumn_codes/vol8/networking/01-modern-socket/echo_server.cpp b/code/volumn_codes/vol8/networking/01-modern-socket/echo_server.cpp new file mode 100644 index 000000000..048009f46 --- /dev/null +++ b/code/volumn_codes/vol8/networking/01-modern-socket/echo_server.cpp @@ -0,0 +1,109 @@ +// Modern C++ echo server —— RAII UniqueFd + std::expected + 每连接一线程。 +// 配套 documents/vol8-domains/networking/01-modern-socket-wrapping.md。 +// 这是 00 传统版的"现代化 + 加并发"版本:close 不可能漏、错误带上下文、能扛并发客户端。 +// 但"每连接一线程"扛不住 C10K——文章结尾实测给你看。 +#include "unique_fd.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr std::uint16_t kPort = 13013; +constexpr int kBacklog = 64; +constexpr std::size_t kBufSize = 4096; + +/// 带 errno + 上下文的错误,供 std::expected 携带(替代 00 版散落的 errno + perror)。 +struct SysError { + int errno_value; + std::string context; +}; + +/// socket + bind + listen 三步。任一失败把 errno 和失败步骤塞进 SysError 返回。 +std::expected make_listener(std::uint16_t port) { + int raw = ::socket(AF_INET, SOCK_STREAM, 0); + if (raw < 0) + return std::unexpected(SysError{errno, "socket"}); + UniqueFd fd{raw}; + + int yes = 1; + if (::setsockopt(fd.get(), SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)) < 0) + return std::unexpected(SysError{errno, "setsockopt(SO_REUSEADDR)"}); + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_ANY); + addr.sin_port = htons(port); + if (::bind(fd.get(), reinterpret_cast(&addr), sizeof(addr)) < 0) + return std::unexpected(SysError{errno, "bind"}); + + if (::listen(fd.get(), kBacklog) < 0) + return std::unexpected(SysError{errno, "listen"}); + + return fd; +} + +/// 一条连接的 echo:循环读到对端关闭(返回 0)或出错。 +/// conn 按值传入——线程独占这条 fd,函数返回时 UniqueFd 析构自动 close(不可能漏)。 +void handle_session(UniqueFd conn) { + std::array buf; + for (;;) { + ssize_t n = ::read(conn.get(), buf.data(), buf.size()); + if (n == 0) + break; // 对端正常关闭 + if (n < 0) { + if (errno == EINTR) + continue; + break; + } + std::size_t off = 0; + while (off < static_cast(n)) { + ssize_t w = ::write(conn.get(), buf.data() + off, static_cast(n) - off); + if (w < 0) { + if (errno == EINTR) + continue; + return; + } + off += static_cast(w); + } + } +} + +} // namespace + +int main() { + std::signal(SIGPIPE, SIG_IGN); + + auto listener = make_listener(kPort); + if (!listener) { + std::print(stderr, "listen failed: {} (errno {})\n", listener.error().context, + listener.error().errno_value); + return 1; + } + std::print("modern echo server on 0.0.0.0:{} (pid {})\n", kPort, ::getpid()); + + for (;;) { + int raw = ::accept(listener->get(), nullptr, nullptr); + if (raw < 0) { + if (errno == EINTR) + continue; + std::print(stderr, "accept: errno {}\n", errno); + continue; + } + UniqueFd conn{raw}; + // 每个连接 spawn 一个线程;conn 用 move 交给线程,主循环立刻回去 accept。 + std::thread{handle_session, std::move(conn)}.detach(); + } +} diff --git a/code/volumn_codes/vol8/networking/01-modern-socket/hold_clients.cpp b/code/volumn_codes/vol8/networking/01-modern-socket/hold_clients.cpp new file mode 100644 index 000000000..24fd8207d --- /dev/null +++ b/code/volumn_codes/vol8/networking/01-modern-socket/hold_clients.cpp @@ -0,0 +1,43 @@ +// C10K 探测:打开 N 条空闲连接并 hold 一段时间。 +// 配合读取 server 的 /proc//status,实测"每连接一线程"模型在并发上去之后的 +// 虚拟内存 / 线程数代价。配套 01 文章的 C10K 实测段。 +#include "unique_fd.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +int main(int argc, char** argv) { + const int n = (argc > 1) ? std::atoi(argv[1]) : 500; + constexpr std::uint16_t kPort = 13013; + + std::vector conns; + conns.reserve(static_cast(n)); + int ok = 0; + for (int i = 0; i < n; ++i) { + int raw = ::socket(AF_INET, SOCK_STREAM, 0); + if (raw < 0) { + std::print(stderr, "socket failed at i={} (errno {})\n", i, errno); + break; + } + UniqueFd fd{raw}; + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(kPort); + ::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr); + if (::connect(fd.get(), reinterpret_cast(&addr), sizeof(addr)) == 0) { + conns.push_back(std::move(fd)); + ++ok; + } + } + std::print("opened {} idle connections ({} requested); holding 10s...\n", ok, n); + std::this_thread::sleep_for(std::chrono::seconds(10)); + std::print("releasing\n"); +} diff --git a/code/volumn_codes/vol8/networking/01-modern-socket/unique_fd.hpp b/code/volumn_codes/vol8/networking/01-modern-socket/unique_fd.hpp new file mode 100644 index 000000000..208dce070 --- /dev/null +++ b/code/volumn_codes/vol8/networking/01-modern-socket/unique_fd.hpp @@ -0,0 +1,44 @@ +#pragma once +#include + +/// RAII 包装一个 POSIX fd:不可拷贝、仅可移动,析构时 close。 +/// 替代裸 int fd + 手动 close——后者在提前 return / 异常时极易漏 close(fd)。 +/// 配套 documents/vol8-domains/networking/01-modern-socket-wrapping.md。 +class UniqueFd { + public: + UniqueFd() = default; + explicit UniqueFd(int fd) : fd_{fd} {} + ~UniqueFd() { reset(); } + + UniqueFd(const UniqueFd&) = delete; // 独占所有权,禁拷贝 + UniqueFd& operator=(const UniqueFd&) = delete; + + UniqueFd(UniqueFd&& other) noexcept : fd_{other.fd_} { other.fd_ = -1; } + UniqueFd& operator=(UniqueFd&& other) noexcept { + if (this != &other) { + reset(); + fd_ = other.fd_; + other.fd_ = -1; + } + return *this; + } + + /// 显式关闭并置 -1。已 close 再调安全(fd_ < 0 时不动)。 + void reset() { + if (fd_ >= 0) { + ::close(fd_); + fd_ = -1; + } + } + + int get() const { return fd_; } + int release() { + int f = fd_; + fd_ = -1; + return f; + } + explicit operator bool() const { return fd_ >= 0; } + + private: + int fd_{-1}; +}; diff --git a/documents/vol8-domains/index.md b/documents/vol8-domains/index.md index 11ed95006..ff9d5065d 100644 --- a/documents/vol8-domains/index.md +++ b/documents/vol8-domains/index.md @@ -29,6 +29,7 @@ tags: 嵌入式开发 + 网络编程 GUI 与图形 — 规划中 数据存储 — 规划中 算法与数据结构 — 规划中 diff --git a/documents/vol8-domains/networking/.pages b/documents/vol8-domains/networking/.pages new file mode 100644 index 000000000..4623ca546 --- /dev/null +++ b/documents/vol8-domains/networking/.pages @@ -0,0 +1 @@ +title: 网络编程 diff --git a/documents/vol8-domains/networking/00-traditional-socket-basics.md b/documents/vol8-domains/networking/00-traditional-socket-basics.md new file mode 100644 index 000000000..3b28a4b49 --- /dev/null +++ b/documents/vol8-domains/networking/00-traditional-socket-basics.md @@ -0,0 +1,243 @@ +--- +title: "传统 socket 编程:服务器五步与 TCP 建链,从 Stevens 学的那套经典写法" +description: 用最朴素的 C 风格 BSD socket API 走通一个 echo server,把 socket/bind/listen/accept/read-write 五步和 TCP 三次握手的内核细节讲透——字节序、listen 的两个队列与 backlog、监听 fd 与连接 fd 的区别,以及为什么"accept 完成握手"是个常见误解。这是几十年没变的网络地基,后面的 Asio/协程再花哨,底层都是这五步 +chapter: 8 +order: 0 +platform: host +difficulty: intermediate +cpp_standard: [20, 23] +reading_time_minutes: 16 +related: + - "现代 socket 封装:RAII 与 std::expected" + - "epoll:Linux I/O 多路复用" +tags: + - host + - cpp-modern + - intermediate + - 网络编程 +--- + +# 传统 socket 编程:服务器五步与 TCP 建链,从 Stevens 学的那套经典写法 + +说实话,写网络编程这一卷的时候,笔者是有点想先把"最土的那套"讲透的冲动。原因很简单:不管后面的 Boost.Asio 多优雅、C++20 协程多顺手、std::execution 多前卫,你在 Linux 上写网络程序,底层兜兜转转都绕不开同一套东西——1983 年定下来的 **BSD socket API**。`epoll` 是对它的加速,`Asio` 是对它的包装,但"一个服务器是怎么起来的"这件事,从 Stevens 的《UNIX 网络编程》到今天的内核,基本没变过。所以这一篇我们不碰任何现代设施,就用最朴素的 C 风格 socket,把这套**几十年没变的地基**一步一步踩实。 + +这一篇和下一篇是配对的:本篇(00)讲**传统的、C 风格的**写法——裸 fd、手动 `close`、`errno` 加 `perror`,就是 Beej's Guide 和 Stevens 教你的那套;下一篇(01)再用 Modern C++(RAII、`std::expected`)把它的脏活收掉。拆成两篇是有意的——有些读者已经会传统写法、只想看现代封装,可以直接跳到 01;有些读者要复习 socket 基础,本篇就是稳定的参考。BSD socket API 几十年没变,这篇写完基本不用再改。 + +本篇的代码就是纯 C socket,用 `gcc` 就能编,本机跑出来的终端输出都是真的。我们不掺并发(那是 01 的事),就一个单线程的 echo server,专注把"五步"走干净。 + +## 一个最小的目标:echo server + +我们先定一个最小的目标,把后面所有概念挂上去:写一个 **echo server**——客户端连上来发什么,服务端就把什么原样发回去,然后等下一条。它是网络编程的"Hello World",小到没有业务逻辑,却完整覆盖了一条 TCP 连接从建立到收发的全过程。 + +这条全生命周期,从服务端的视角看,就是经典的**五步**:`socket → bind → listen → accept → read/write`。接下来我们一步一步走,每一步不光讲"调哪个函数",更要讲**内核那边到底发生了什么**——因为 socket 编程的大部分坑,都来自"你以为这个函数干了 X,其实它干的是 Y"。 + +## 第一步:socket()——向内核要一个通信端点 + +```c +int lfd = socket(AF_INET, SOCK_STREAM, 0); +``` + +`socket()` 干的事其实很简单:**向内核申请一个"通信端点",返回一个文件描述符(fd)来代表它**。三个参数分别回答"用什么网络协议族、什么类型、具体哪个协议":`AF_INET` 是 IPv4,`SOCK_STREAM` 是面向连接的可靠字节流(也就是 TCP),第三个参数填 `0` 表示"协议自动选"(前面定了 TCP,它就选 TCP)。 + +当然,这里有个绕不开的概念:**fd 到底是什么**。Unix 的名言是"一切皆文件",内核给每个进程维护一张"已打开文件表",fd 就是这张表的一个下标(一个小整数)。`socket()` 返回的 fd,本质上是这张表里的一个新槽位,槽位背后挂着一个内核的 socket 对象。后面你对这个 fd 调 `bind`/`listen`/`accept`/`read`/`write`,内核都是拿 fd 去查这张表、找到背后的 socket 对象再操作。所以 fd 只是个"句柄",真正的状态在内核里。 + +::: warning fd 失败要判 < 0 +`socket` 和后面所有返回 fd 的调用,失败时返回 **-1** 并设置 `errno`,不是返回 0。判 `if (lfd < 0)` 才对。fd 0、1、2 进程启动时就被 stdin/stdout/stderr 占了,正常分配从 3 开始——这也是为什么我们等会儿用 `ss` 能看到监听 socket 的 fd 就是 3。 +::: + +## 第二步:bind()——把 fd 钉到一个本地地址 + +光有个 fd 还不能收连接,你得告诉内核"这个 socket 监听哪个地址、哪个端口"。这就是 `bind`: + +```c +struct sockaddr_in addr; +memset(&addr, 0, sizeof(addr)); +addr.sin_family = AF_INET; +addr.sin_addr.s_addr = htonl(INADDR_ANY); /* 监听所有网卡 */ +addr.sin_port = htons(PORT); /* 13013 */ +bind(lfd, (struct sockaddr *)&addr, sizeof(addr)); +``` + +`sockaddr_in` 是"IPv4 地址"的结构体,三个字段要填:协议族(`sin_family = AF_INET`)、IP 地址(`sin_addr`)、端口(`sin_port`)。`INADDR_ANY` 是个特殊值,意思是"本机所有网卡的 IP 都监听"——你的机器可能有 lo(127.0.0.1)、eth0(192.168.x.x)、wlan0 等好几个 IP,`INADDR_ANY` 一次全包;如果只填某一个具体 IP,就只在那张网卡上监听。 + +这里有个**新人必踩的字节序**问题,值得停下来讲。注意 `htonl(INADDR_ANY)` 和 `htons(PORT)` 这两个调用——它们不是装饰。TCP/IP 规定线上传输多字节数用**大端序(big-endian)**,叫 network byte order;而你的 x86/ARM CPU 是小端序(little-endian)。端口 13013 在小端机器内存里字节排布是 `05 33 00 00`(低字节在前),大端是 `00 00 33 05`——你直接把主机序的端口塞进 `sin_port`,内核和对端都按网络序解析,端口就全错了。`htonl`(host to network long,32 位,给 IP)和 `htons`(host to network short,16 位,给端口)就是干这个字节翻转的,在大端机器上是空操作、小端机器上翻转,所以写上它**无论什么 CPU 都对**。 + +因此,一个重要的经验油然而生:**凡是塞进 `sockaddr_in` 的多字节整数(IP、端口),一律过一遍 `htonl`/`htons`**,别拿主机序的裸值硬塞。反过来从内核读出来要给人类看,就用 `ntohl`/`ntohs`(network to host)翻回来。 + +## 第三步:listen()——标记为被动监听,两个队列登场 + +`bind` 之后这个 socket 还不能收连接,它只是"绑了个地址"。`listen` 才把它变成一个**被动监听**的 socket,告诉内核"开始接受别人往这个地址发起的连接": + +```c +listen(lfd, 64); /* 第二个参数是 backlog */ +``` + +`listen` 真正值得讲的是它第二个参数 **backlog**,以及它背后内核维护的**两个队列**。这是 socket 编程里被误读最多的一个点。 + +当一个客户端向你的服务器发起 TCP 连接时,先走**三次握手**:客户端发 SYN,你的内核回 SYN-ACK,客户端再回 ACK——握手完成,连接建立。这个过程中,内核为这个监听 socket 维护两个队列: + +- **SYN 队列(半连接队列)**:收到客户端 SYN、回了 SYN-ACK、还在等客户端最后那个 ACK 的连接。握手**没完成**。 +- **Accept 队列(全连接队列)**:三次握手已经完成、就等你 `accept` 把它取走的连接。握手**完成了**。 + +而 `listen` 的 backlog 参数,**管的是 Accept 队列(全连接队列)的容量上限**——不是很多人以为的 SYN 队列。`listen(lfd, 64)` 的意思是:最多允许 64 个已完成握手、但还没被 `accept` 取走的连接排队;超过这个数,新完成的连接会被内核丢弃或直接发 RST。SYN 队列的长度是另一回事,由 `/proc/sys/net/ipv4/tcp_max_syn_backlog` 管。 + +这个区别为什么重要?因为你一旦以为 backlog 管 SYN 队列,排查"连接卡在 SYN_RECV""SYN flood 攻击"时就会找错药——那得调 `tcp_max_syn_backlog` 和 SYN cookies,跟 backlog 没关系。`man 2 listen` 的原文也只说"a limit on the number of sockets in the accept queue",压根没提 SYN 队列。 + +我们等会儿真跑起来后,会用 `ss` 命令把这个监听 socket 的状态拍出来,你会亲眼看见 backlog 这个值落在内核里的什么位置。 + +## 第四步:accept()——取出一个已完成握手的连接 + +```c +int cfd = accept(lfd, NULL, NULL); +``` + +`accept` 干的事,是从那个 Accept 队列(全连接队列)里**取出一个已经完成握手的连接**,返回一个**全新的 fd** 来代表这条连接。这里有一个新手最容易糊涂的关键点,必须讲透: + +**监听 fd(`lfd`)和连接 fd(`cfd`)是两个完全不同的 fd,干两件完全不同的事。** `lfd` 是"门口的迎宾",它唯一的作用是 `accept` 出新的连接;它自己**不收发数据**。`cfd` 才是"某一位已经进来的客人",你在这个 fd 上 `read`/`write` 跟这位客人通话。一个服务器终身只有一个 `lfd`(可能 `accept` 出成千上万个 `cfd`),每来一个连接 `accept` 一次、得到一个新 `cfd`。把这两个 fd 搞混(比如往 `lfd` 上 `write` 数据),是初学者常见的低级错误。 + +`accept` 的后两个参数填 `NULL`,表示"我不关心这个客户端的地址";如果你想记录客户端的 IP 和端口,可以传一个 `sockaddr_in` 进去让内核填。 + +还有个**极其常见的误解**要在这里打破:**很多人以为"是 `accept` 完成了三次握手"——这是错的**。三次握手是内核在 `listen` 之后自动做的,客户端发 SYN、内核回 SYN-ACK、客户端回 ACK,这整个过程**完全不需要你的程序参与**,发生在 `accept` 被调用之前。握手完成的连接先进 Accept 队列排着,你的 `accept` 只是把队列里已经就绪的连接"领走"。如果你的程序卡在别的地方很久不调 `accept`,握手照样在内核里完成、连接照样在队列里堆——堆满 backlog 上限才会拒绝新连接。这个时序关系我们下一节展开讲。 + +## 第五步:read()/write()——在连接 fd 上收发 + +拿到 `cfd` 之后,它就是个"可以读写的 fd"——和读写文件用的是同一套系统调用: + +```c +char buf[4096]; +ssize_t n = read(cfd, buf, sizeof(buf)); /* 读对方发来的 */ +write(cfd, buf, n); /* 原样回写(echo) */ +``` + +`read` 返回**实际读到的字节数**;返回 `0` 表示**对端正常关闭了连接**(TCP 的 FIN 走完了,这是"流结束"的信号,不是错误);返回 `-1` 表示出错。`write` 把数据发回去,它也可能只写一部分(尤其非阻塞或大数据量时),教学版里我们简化处理,下一节你会看到完整循环。 + +到这里五步就走完了。我们把它们串成一个能跑的 server,真跑一次。 + +## 建链到底发生了什么:把握手和 accept 的时序理清 + +在贴完整代码之前,有一个贯穿"建链"的问题值得单独理清——因为它把 `connect`(客户端)、握手(内核)、`accept`(服务端)三者的关系讲明白后,后面所有东西都好理解。 + +假设客户端要连我们的 server。客户端那边调 `connect(fd, 服务器地址, ...)`——这一调用的本质是**让内核替你发出 SYN**,发起三次握手。然后: + +1. 客户端内核发 **SYN** → 到达服务端内核。 +2. 服务端内核(因为 `listen` 过)自动回 **SYN-ACK**,同时在**服务端的 SYN 队列**里记下这条"半连接"。 +3. 客户端内核收到 SYN-ACK,回最后的 **ACK** → 到达服务端内核。 +4. 服务端内核收到 ACK,**三次握手完成**——把这条连接从 SYN 队列挪到 **Accept 队列**。 +5. 服务端程序调用 `accept()` → 从 Accept 队列把这条连接取出来,返回新 fd。 + +注意第 2~4 步**全在内核里,你的服务端程序一个字都没参与**。你的 `accept` 是在第 5 步才登场的,它只是"领人"。这就是为什么"accept 完成握手"是错的——握手在第 4 步就完成了,`accept` 在第 5 步。理解这个时序,你才能理解 backlog 为什么管的是 Accept 队列(第 4 步产物)而不是 SYN 队列(第 2 步产物)。 + +## 经典 echo server:完整代码与真实运行 + +把上面五步拼起来,就是一个完整的经典 echo server。我们用最朴素的 C 风格写——裸 fd、手动 `close`、`errno` + `perror`: + +```c +/* 传统 C 风格 echo server:裸 fd、手动 close、errno + perror */ +#include +#include +#include +#include +#include +#include +#include +#include + +#define PORT 13013 +#define BACKLOG 64 +#define BUFSZ 4096 + +int main(void) { + signal(SIGPIPE, SIG_IGN); /* 见下方"两个咬人的细节" */ + + int lfd = socket(AF_INET, SOCK_STREAM, 0); /* 第一步 */ + if (lfd < 0) { perror("socket"); return 1; } + + int yes = 1; + setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)); + + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_ANY); + addr.sin_port = htons(PORT); + if (bind(lfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { /* 第二步 */ + perror("bind"); return 1; + } + if (listen(lfd, BACKLOG) < 0) { perror("listen"); return 1; } /* 第三步 */ + + printf("classic echo server on 0.0.0.0:%d (pid %d)\n", PORT, getpid()); + + for (;;) { + int cfd = accept(lfd, NULL, NULL); /* 第四步 */ + if (cfd < 0) { if (errno == EINTR) continue; perror("accept"); continue; } + + char buf[BUFSZ]; + for (;;) { /* 第五步 */ + ssize_t n = read(cfd, buf, BUFSZ); + if (n <= 0) break; /* 0=对端关, <0=出错 */ + write(cfd, buf, n); + } + close(cfd); /* ★手动 close,漏了就泄漏 fd */ + } +} +``` + +`gcc -O2 classic_server.c -o server` 编出来跑,再写个客户端连上去发两条消息: + +```text +$ ./client "hello from classic client" +echo <- 'hello from classic client' +$ ./client "the quick brown fox" +echo <- 'the quick brown fox' +``` + +echo 通了。现在做一件更有意思的事——趁 server 跑着,用 `ss` 命令把这个监听 socket 从内核里"拍"出来看看: + +```text +$ ss -tlnp | grep 13013 +LISTEN 0 64 0.0.0.0:13013 0.0.0.0:* users:(("server",pid=283539,fd=3)) +``` + +这几列信息量很大,我们把第三步讲的那些概念和它对上:`LISTEN` 是 socket 状态;`0.0.0.0:13013` 是我们 `bind` 的地址(`INADDR_ANY` + 端口 13013);`fd=3` 正是前面说的"stdin/stdout/stderr 占了 0/1/2,第一个新 fd 是 3"。最关键的是中间那两个数字 `0` 和 `64`——对 LISTEN 状态的 socket,**第一列是 Accept 队列的当前长度(0,因为刚被 accept 取走、没积压),第二列就是 backlog 上限(64,正是我们 `listen(lfd, 64)` 设的)**。你看,第三步讲的 backlog = Accept 队列上限,在这里是能直接看到的实物,不是抽象概念。 + +::: tip 自己编译核查 +本篇的完整代码在 `code/volumn_codes/vol8/networking/00-traditional-socket/`(`classic_server.c` + `classic_client.c` + `CMakeLists.txt`)。建议你亲手编译跑一遍,别只看: + +```bash +cd code/volumn_codes/vol8/networking/00-traditional-socket +# 方式 A:一行 gcc(文章里就是这套) +gcc -O2 -Wall -Wextra classic_server.c -o echo_server +gcc -O2 -Wall -Wextra classic_client.c -o echo_client +# 方式 B:CMake +cmake -S . -B build && cmake --build build +``` + +然后一个终端 `./echo_server`、另一个 `./echo_client "hello"`,看到 `echo <- 'hello'` 就通了;再开第三个终端 `ss -tlnp | grep 13013`,复核上面那个 `LISTEN 0 64 ... fd=3` 的行。端口 13013 大于 1024,不用 sudo。 +::: + +## 两个会咬人的细节:SIGPIPE 与 SO_REUSEADDR + +这套经典写法能跑,但有两个细节,都是**测试时不出问题、上线或重启才咬人**的那种——它们属于"man page 有但不会主动提醒你"的现场知识,笔者在这里必须强调一下。 + +**第一个是 SIGPIPE**。TCP 有个经典场景:客户端异常退出了,你的 server 还傻乎乎地往这条连接 `write`。对一个"对端已经关掉"的 socket 写数据,内核会给你发一个 `SIGPIPE` 信号,而 `SIGPIPE` 的默认处理动作是**直接终止进程**——你的 server 没有任何错误日志,就这么悄无声息地死了。这是个能让你排查一整天的坑。解法是 server 启动第一行就 `signal(SIGPIPE, SIG_IGN)` 忽略它,之后 `write` 到已关闭的 fd 会改返回 `-1`、`errno = EPIPE`,你能像处理普通错误一样处理它,而不是被信号谋杀。上面代码 `main` 第一行的就是这个。 + +**第二个是 SO_REUSEADDR**。你 kill 了 server 想立刻重启,经常撞 `bind: Address already in use`——明明刚才那个进程已经没了。原因是上一条 server 的某些连接还处在 **TIME_WAIT** 状态(主动关闭方会保持约 60 秒,确保对端收到了自己最后那个 FIN 的 ACK),这段时间端口还"占着"。解法是 `bind` 之前 `setsockopt(SO_REUSEADDR)`,允许复用处于 TIME_WAIT 的地址——上面代码 `socket` 之后那行就是这个。开发期频繁重启必备。注意它只解决 TIME_WAIT,不能让两个进程同时 `listen` 同一个端口(那要 `SO_REUSEPORT`,是另一个机制)。 + +说白了,这两个细节的共同点是:**它们都不会让你的程序"立刻崩给你看"**——SIGPIPE 是静默杀进程,TIME_WAIT 是重启才撞。所以它们特别容易被漏在测试之外,又被特别多地踩在生产里。 + +## 小结:这套能跑,但脏 + +这一篇我们用最朴素的 C 风格 socket,把服务器的五步和 TCP 建链从头走了一遍。几条关键的东西收一下:`socket` 要 fd、`bind` 钉地址、`listen` 开监听并定 backlog(管 Accept 队列)、`accept` 取出已完成握手的连接返回新 fd、`read`/`write` 收发;监听 fd 和连接 fd 是两回事;三次握手是内核在 `accept` 之前就做好的,`accept` 只是领人;字节序要 `htonl`/`htons`;SIGPIPE 和 SO_REUSEADDR 是两个 server 标配。 + +但你也应该嗅到了这套写法的味道——**它能跑,但脏**。裸 `int fd` 到处传,作用域里任何一条提前 `return` 都会跳过末尾的 `close(cfd)`,fd 就泄漏了;错误处理是散落的 `errno` + 返回码,每一步都"返回值 + errno"两件套,到底哪步炸的得你自己记;`perror` 打完日志,错误恢复全靠程序员自觉。这些脏活,BSD socket API 设计于 1983 年,它不管——那是后面语言和库的事。 + +下一篇(01)我们就用 Modern C++ 把它收掉:用 RAII 的 `unique_fd` 让"漏 close"变成不可能,用 `std::expected` 把错误和值装进类型系统。写完你再看这套传统代码,会发现它每一处"脏", Modern C++ 都有对应的、类型安全的收口。再往后我们还会回到这个 server,问它一个问题:如果客户端多了,"每来一个连接处理一个"扛得住吗?——那是引出 `epoll` 的钥匙。 + +## 参考资源 + +- [man 2 socket](https://man7.org/linux/man-pages/man2/socket.2.html) / [bind](https://man7.org/linux/man-pages/man2/bind.2.html) / [listen](https://man7.org/linux/man-pages/man2/listen.2.html) / [accept](https://man7.org/linux/man-pages/man2/accept.2.html) —— 五步 API 的权威定义,`listen` 的 backlog 原文说的就是 accept queue +- [man 7 socket](https://man7.org/linux/man-pages/man7/socket.7.html) / [man 7 tcp](https://man7.org/linux/man-pages/man7/tcp.7.html) —— socket 选项与 TCP 状态机(含 TIME_WAIT) +- [Beej's Guide to Network Programming](https://beej.us/guide/bgnet/) —— 传统 C socket 编程的经典入门,本篇的风格与内容取向深受它影响 +- [W. Richard Stevens《UNIX Network Programming, Volume 1》](https://www.pearson.com/en-us/subject-catalog/p/unix-network-programming-volume-1-the-sockets-networking-api/P20000000C9XXX) —— 五步模型与两个队列的原始出处 +- [ss(8)](https://man7.org/linux/man-pages/man8/ss.8.html) —— 查看内核 socket 表的工具,本篇用它拍出 LISTEN socket 的 backlog +- [现代 socket 封装:RAII 与 std::expected(下一篇 01)](./01-modern-socket-wrapping.md) —— 用 Modern C++ 收掉本篇的裸 fd 与散落 errno diff --git a/documents/vol8-domains/networking/01-modern-socket-wrapping.md b/documents/vol8-domains/networking/01-modern-socket-wrapping.md new file mode 100644 index 000000000..58df5bb5d --- /dev/null +++ b/documents/vol8-domains/networking/01-modern-socket-wrapping.md @@ -0,0 +1,216 @@ +--- +title: "现代 socket 封装:RAII、std::expected,以及每连接一线程扛不住的 C10K" +description: 把 00 的传统 C 风格 echo 现代化——RAII 的 unique_fd 让"漏 close"变成不可能、std::expected 把错误和值装进类型,再加上"每连接一线程"让它能扛并发;结尾实测 2000 个空闲连接把虚拟内存从 83MB 吃到 24GB,亲手摸到 C10K 的痛点,引出 epoll +chapter: 8 +order: 1 +platform: host +difficulty: intermediate +cpp_standard: [20, 23] +reading_time_minutes: 14 +prerequisites: + - "传统 socket 编程:服务器五步与 TCP 建链" +related: + - "传统 socket 编程:服务器五步与 TCP 建链" + - "epoll:Linux I/O 多路复用" +tags: + - host + - cpp-modern + - intermediate + - 网络编程 + - RAII守卫 +--- + +# 现代 socket 封装:RAII、std::expected,以及每连接一线程扛不住的 C10K + +上一篇(00)我们用最朴素的 C 风格 socket 走通了一个 echo server,把五步和 TCP 建链讲透了。但说实话,那套代码写完,笔者心里是有点别扭的——它能跑,可是**脏**。裸 `int fd` 在函数里到处传,中间任何一条 `if (...) return 1;` 都可能跳过末尾的 `close(cfd)`,fd 就悄悄漏掉了;错误处理是散落的 `errno` 加 `perror`,每一步都"返回值看一眼、errno 再看一眼",到底哪步炸的得你自己记。这套写法是 1983 年的 C 风格,它不管这些脏活——那是后面语言的事。 + +这一篇我们就用 Modern C++ 把它收掉。具体做两件事:第一,用 RAII 把 fd 的生命周期焊死,让"漏 close"在类型层面就不可能发生;第二,用 `std::expected` 把"成功值"和"带上下文的错误"装进同一个返回类型,替代散落的 errno。收完之后,我们再给这个 server 加上"每连接一线程",让它真正能同时伺候多个客户端——然后实测一个会让笔者血压拉满的数字,你会亲眼看到为什么"每连接一线程"这个看似天经地义的做法,在并发上去之后就崩。那个数字,正是下一篇 `epoll` 的入场券。 + +本篇的代码是 C++23(`std::expected`、`std::print` 都是 C++23),本机 GCC 16.1.1 编译运行,贴的终端输出都是真的。 + +## 先看传统版的"脏"到底脏在哪 + +在动手收之前,我们先把 00 那段代码的脏点指出来,这样后面每一处改造才有靶子。回想 00 的核心循环: + +```c +for (;;) { + int cfd = accept(lfd, NULL, NULL); + if (cfd < 0) { if (errno == EINTR) continue; perror("accept"); continue; } + + char buf[4096]; + for (;;) { + ssize_t n = read(cfd, buf, sizeof(buf)); + if (n <= 0) break; + write(cfd, buf, n); + } + close(cfd); /* ← 手动 close */ +} +``` + +这里的 `cfd` 是个裸 `int`。看起来没问题——但你想,如果在 `read`/`write` 那段循环里,你将来要加一个错误处理分支、提前 `return` 或者抛个异常,`close(cfd)` 那行就被跳过了。fd 是进程级有限资源(默认上限 1048576 个,听起来很多,但一个长跑的服务器漏一点积少成多就耗尽),漏一个少一个,还不报错——这种 bug 叫**资源泄漏**,它在测试里毫发无损,跑上几天才因为"fd 耗尽、`socket()` 返回 -1"而炸,排查起来极其痛苦。 + +错误处理那边也好不到哪去:每个系统调用失败都返回 `-1` + 设 `errno`,你得在每一步之后判返回值、读 `errno`、再 `perror` 打印。错误信息和出错位置是割裂的——`perror("bind")` 里的 "bind" 是你手写字符串硬塞的,跟实际代码没有绑定关系,改名了一忘改就误导自己。 + +这两个脏点——**资源靠人记着释放、错误靠人拼字符串**——就是 C 风格的宿命。Modern C++ 给了对应的、类型层面的收口。 + +## RAII:让"漏 close"变成不可能 + +Modern C++ 管资源的核心思想叫 **RAII**(Resource Acquisition Is Initialization):把资源的所有权绑到一个栈对象上,**对象构造时获取资源、析构时释放资源**。栈对象的作用域一结束,析构函数必然被调用——不管是正常走到末尾、提前 `return`、还是抛了异常,都跑不掉。换句话说,把"记得释放"这件事从程序员的脑子里挪到类型系统里。 + +对应到 fd,我们写一个 `UniqueFd`:构造时接管一个裸 fd,析构时 `close`,而且**禁拷贝、只移动**——因为 fd 是独占资源,不能两个对象都以为自己拥有同一个 fd(那会 double close)。 + +```cpp +class UniqueFd { +public: + UniqueFd() = default; + explicit UniqueFd(int fd) : fd_{fd} {} + ~UniqueFd() { reset(); } // 析构即 close + + UniqueFd(const UniqueFd&) = delete; // 独占,禁拷贝 + UniqueFd& operator=(const UniqueFd&) = delete; + + UniqueFd(UniqueFd&& other) noexcept : fd_{other.fd_} { other.fd_ = -1; } + UniqueFd& operator=(UniqueFd&& other) noexcept { + if (this != &other) { reset(); fd_ = other.fd_; other.fd_ = -1; } + return *this; + } + + void reset() { if (fd_ >= 0) { ::close(fd_); fd_ = -1; } } + int get() const { return fd_; } + explicit operator bool() const { return fd_ >= 0; } +private: + int fd_{-1}; // -1 = 空,析构不动 +}; +``` + +几个设计点值得讲一下。`fd_{-1}` 是"空状态"(合法 fd 不会是 -1),这样"一个空的 UniqueFd 析构"是安全的——`reset()` 看到 `fd_ < 0` 就什么都不做,不会去 `close(-1)`。移动构造把对方的 fd 偷过来、把对方置成空(-1),保证任意时刻只有一个 `UniqueFd` 持有这个 fd。有了它,00 里那段循环就变成: + +```cpp +for (;;) { + int raw = ::accept(listener->get(), nullptr, nullptr); + if (raw < 0) { + if (errno == EINTR) + continue; + /* ... */ + continue; + } + UniqueFd conn{raw}; // ← 接管,从此 conn 活着 fd 就有效 + // ... 在 conn 上 read/write ... +} // ← 循环体结束,conn 析构,自动 close——不可能漏 +``` + +`conn` 是栈对象,无论这个循环体里将来加多少个 `return`、抛多少异常,它析构时一定 `close`。**"漏 close"从一个需要人记的事,变成了一个不可能发生的事**。这就是 RAII 的力量,也是 Modern C++ 区别于 C 最根本的一条。 + +## std::expected:把错误和值装进一个类型 + +资源搞定了,再看错误处理。C 风格的错误是"返回值 + errno"两件套,Modern C++ 的答案是 `std::expected`——它要么装一个**成功值 T**,要么装一个**错误 E**,两者在同一个返回类型里,用类型系统强制你处理错误,不能假装没看见。 + +我们给错误定义一个带上下文的小结构,把"哪一步炸的"和 errno 一起带上: + +```cpp +struct SysError { + int errno_value; + std::string context; // "socket" / "bind" / "listen",失败发生在哪步 +}; +``` + +然后 `socket + bind + listen` 三步封装成一个返回 `std::expected` 的函数: + +```cpp +std::expected make_listener(std::uint16_t port) { + int raw = ::socket(AF_INET, SOCK_STREAM, 0); + if (raw < 0) return std::unexpected(SysError{errno, "socket"}); + UniqueFd fd{raw}; + + int yes = 1; + if (::setsockopt(fd.get(), SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)) < 0) + return std::unexpected(SysError{errno, "setsockopt(SO_REUSEADDR)"}); + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_ANY); + addr.sin_port = htons(port); + if (::bind(fd.get(), reinterpret_cast(&addr), sizeof(addr)) < 0) + return std::unexpected(SysError{errno, "bind"}); + + if (::listen(fd.get(), 64) < 0) + return std::unexpected(SysError{errno, "listen"}); + + return fd; // 成功:直接返回 UniqueFd +} +``` + +调用方拿到的是一个"可能成功也可能失败"的盒子,用 `if (!listener)` 一眼判断,失败时 `listener.error().context` 直接告诉你"是 bind 阶段、errno 98"——上下文和错误绑在一起,不用你再手写 `perror("bind")` 那种割裂的字符串。对比 00 的 `perror`,这里最大的区别是:**错误信息不再是散落的、靠人维护的字符串,而是类型里的一等公民,跟着错误对象走**。 + +这是 Modern C++ 给 I/O 代码的范式升级——和 RAII 一脉相承,都是"把程序员脑子里的约定,挪到类型系统里强制"。 + +## 加并发:每连接一线程 + +00 的传统 server 是**单线程**的——`accept` 一个连接、echo 完、`close`、再 `accept` 下一个。这有个要命的缺点:如果一个客户端连上来不发包(就挂着),整个 server 就卡在 `read` 上等它,后面的连接全进不来。能跑,但同一时刻只能伺候一个客人。 + +最直觉的升级是**每来一个连接,spawn 一个线程专门伺候它**。`accept` 拿到新 fd 之后,把它交给一个独立线程,主循环立刻回去 `accept` 下一个——这样多个客户端就被多个线程并行处理,互不卡。配合刚写的 RAII,fd 用 `std::move` 交给线程,所有权转移得干干净净: + +```cpp +void handle_session(UniqueFd conn) { // 按值接收:线程独占这条 fd + std::array buf; + for (;;) { + ssize_t n = ::read(conn.get(), buf.data(), buf.size()); + if (n == 0) break; // 对端关闭 + if (n < 0) { if (errno == EINTR) continue; break; } + /* ... 回写 ... */ + } +} // conn 析构,自动 close + +int main() { + /* ... make_listener ... */ + for (;;) { + int raw = ::accept(listener->get(), nullptr, nullptr); + if (raw < 0) { if (errno == EINTR) continue; /* ... */ continue; } + UniqueFd conn{raw}; + std::thread{handle_session, std::move(conn)}.detach(); // 每连接一线程 + } +} +``` + +`std::thread{handle_session, std::move(conn)}.detach()`——`detach` 让线程在后台独立跑,主循环不等它。`conn` 通过 `std::move` 转交所有权,转完主循环里的 `conn` 就是空的了,fd 彻底归线程所有,线程函数返回时 `conn` 析构自动 `close`。没有 double close,也没有漏 close。 + +跑起来,开三个客户端同时连,三个都立刻得到回显——并发问题了,看起来皆大欢喜。但事情到这里还没完,真正的坑在后面。 + +## 但是,它扛得住多少并发? + +"每连接一线程"看起来天经地义——一个连接一个线程,多直观。可我们换个问题问它:**如果并发量上去,比如几千个、一万个连接,它还扛得住吗?** 我们真跑一次,别凭感觉。 + +趁 server 跑着,我们开一个客户端连 2000 条**空闲连接**(连上但不发包,就挂着),期间读 server 的 `/proc//status`,看它的虚拟内存、常驻内存、线程数怎么变: + +```text +[idle] VmSize: 85352 kB VmRSS: 4212 kB Threads: 1 +[2000 连接] VmSize: 25081508 kB VmRSS: 28888 kB Threads: 2001 +``` + +你看那个 `VmSize`:**从 83MB 飙到了将近 24GB**。2000 个连接,虚拟内存吃了 24GB。算到每条连接上,`(25081508 - 85352) / 2000 ≈ 12.5 MB/连接`。这 12.5MB 是哪来的?——**每个线程默认 8MB 栈**(glibc 默认值),再加上 glibc 的 per-thread 内部映射、TLS、guard page,堆起来每个线程约 12MB 的虚拟地址空间。`Threads` 那列更是直接:`1 → 2001`,每来一个连接就多一个线程。 + +有意思的是 `VmRSS`(常驻物理内存)只涨了 `(28888 - 4212) / 2000 ≈ 12 KB/连接`——因为内核只给"真正摸到的栈页"分配物理内存(惰性分配),空闲阻塞的线程几乎不碰栈。所以**看着 RSS 不多,但虚拟地址空间已经被栈预留吃光了**。这就是 C10K 问题(Dan Kegel 1999 年提的经典命题:一台机器怎么扛一万个并发连接)的命门:每连接一线程 → 1 万连接 = 8MB × 10000 = **80GB 虚拟地址空间**;而且每个线程内核还要维护一份 `task_struct` + 内核栈,1 万个 mostly-idle 的线程(它们大部分时间都阻塞在 `read` 上干等数据)给调度器和内存子系统徒增负担——**用"线程"这个重实体去对应一个可能长期空闲的连接,资源利用率极低**。 + +说白了,"每连接一线程"不是错在"跑不动",而是错在**它用最重的资源(一个线程)去伺候最轻的工作(一个大部分时间在等 I/O 的连接)**。连接少的时候你感觉不到,一上量就炸。 + +那怎么办?方向很清楚:**用少量线程,服务大量连接**——让一两个线程同时盯着成千上万个 fd,谁的 fd 有数据了再去处理谁,而不是给每个连接配一个专职线程蹲守。这就是下一篇 **epoll / I/O 多路复用** 要解决的问题,也是我们从"同步阻塞、每连接一线程"跨进"事件驱动"的门槛。 + +## 小结 + +这一篇我们用 Modern C++ 把 00 的传统 server 改造了一遍,几个关键的东西收一下: + +- **RAII `UniqueFd`**:fd 的生命周期焊到栈对象上,析构即 `close`,禁拷贝只移动。"漏 close"从靠人记变成不可能。这是 Modern C++ 区别于 C 最根本的一条。 +- **`std::expected`**:成功值和带上下文的错误装进同一个返回类型,`if (!x)` 一眼判断,错误信息跟着错误对象走,替代散落的 errno + 手写 `perror` 字符串。 +- **每连接一线程**:让 server 能扛并发客户端,fd 用 `std::move` 转交线程,所有权干净。 +- **C10K 实测**:2000 空闲连接把虚拟内存从 83MB 吃到 24GB(每连接 ~12MB,主要是 8MB 线程栈),Threads 涨到 2001。根本问题是"用重实体(线程)伺候轻工作(等 I/O 的连接)",连接一上量就炸。 +- **出路**:少量线程服务大量连接——下一篇 epoll。 + +到这一篇,Linux socket 这块我们从"传统 C 风格"走到了"现代 C++ + 并发",也亲手摸到了同步模型的天花板。下一篇就该翻过这道墙了:epoll 怎么让一个线程盯住成千上万个 fd。 + +## 参考资源 + +- [cppreference: std::expected](https://en.cppreference.com/w/cpp/utility/expected) —— C++23 的错误处理(`std::unexpected` 构造错误值) +- [cppreference: std::unique_ptr / RAII](https://en.cppreference.com/w/cpp/memory/unique_ptr) —— RAII 范式,`UniqueFd` 是同一思想在 fd 上的应用 +- [The C10K problem (Dan Kegel)](https://kea.dev/notes/the-c10k-problem) —— "一台机器如何扛一万并发连接",本篇实测正是它的动机 +- [传统 socket 编程:服务器五步与 TCP 建链(本系列 00)](./00-traditional-socket-basics.md) —— 本篇的现代化对象 +- [epoll:Linux I/O 多路复用(本系列下一篇)](./02-epoll-io-multiplexing.md) —— 用少量线程服务大量 fd,解决本篇结尾的 C10K 痛点 diff --git a/documents/vol8-domains/networking/02-epoll-io-multiplexing.md b/documents/vol8-domains/networking/02-epoll-io-multiplexing.md new file mode 100644 index 000000000..696c9199e --- /dev/null +++ b/documents/vol8-domains/networking/02-epoll-io-multiplexing.md @@ -0,0 +1,178 @@ +--- +title: "epoll:Linux I/O 多路复用,从 poll 的瓶颈到兴趣表与就绪队列" +description: 从 poll/select 为什么撑不住 C10K 起步,拆透 epoll 的内核模型(兴趣表+就绪队列+等待队列)、ET 与 LT 在内核层的真正差异、为什么 ET 必须非阻塞 + 循环读到 EAGAIN,并用三个真实运行复现:LT 全量回显、ET-read-once 丢 87712 字节、一次事件内多次 read 收尾 EAGAIN +chapter: 8 +order: 2 +platform: host +difficulty: intermediate +cpp_standard: [20, 23] +reading_time_minutes: 14 +prerequisites: + - "Linux socket 地基:服务器五步、RAII 与每连接一线程的 C10K 代价" +related: + - "Reactor 模式" + - "Boost.Asio 入门:从同步到异步" +tags: + - host + - cpp-modern + - intermediate + - 网络编程 + - 异步编程 +--- + +# epoll:Linux I/O 多路复用,从 poll 的瓶颈到兴趣表与就绪队列 + +上一篇我们用"每来一个连接 spawn 一个线程"的模型跑了个 echo server,实测 2000 个空闲连接就把虚拟内存吃到 24GB——因为每线程默认 8MB 栈,1 万连接就是 80GB 虚拟地址空间,而且这些线程大部分时间都**阻塞在 `read` 上干等数据**,纯属浪费。结论很清楚:不能让"线程"这个重实体去一对一对应"一个可能长期空闲的连接"。 + +正确方向是 **I/O 多路复用**:让**一个线程同时盯着很多个 fd**,谁的 fd 有数据可读了,再去处理谁。Linux 上干这件事的就是 `epoll`。这一篇我们拆透它:它和前辈 `poll`/`select` 到底差在哪(为什么 poll 撑不住 C10K)、它在内核里长什么样(兴趣表 + 就绪队列)、以及那个让无数人踩坑的 **ET vs LT** —— 我们会真跑一次,让你看见 ET 模式下"只读一次"是怎么丢掉 87712 字节的。 + +本机 GCC 16.1.1,所有代码 `-std=c++23` 可编译可运行,下面贴的终端输出都是真跑出来的。 + +## 先看 poll 为什么撑不住:O(n) 的致命伤 + +`select`(1983)和 `poll`(1997)是 epoll 的前辈,思路一样:你把一堆 fd 交给内核,问"这堆里哪些可读了?",内核扫一遍告诉你。它们的命门是**每次调用都要把全部 fd 重新传一遍,内核也得 O(n) 全扫一遍,返回后用户态还要再 O(n) 遍历找出到底哪几个就绪了**。到这里我打赌你开始笑了——真多余啊。 + +以 poll 为例,伪代码长这样: + +```cpp +std::vector fds; // 你关心的所有 fd,1 万个连接就是 1 万个 +for (;;) { + int n = ::poll(fds.data(), fds.size(), -1); // 把 1 万个 fd 全传进内核 + for (int i = 0; i < fds.size(); ++i) { // ★O(n) 遍历找就绪的 + if (fds[i].revents & POLLIN) handle(fds[i].fd); + } +} +``` + +问题在两处:**① 每次都得把全部 fd 拷进内核**(1 万个 `pollfd`,每个 8 字节,8 万元素的拷贝);**② 返回的就绪信息是"揉在数组里的",你得自己 O(n) 遍历**。连接数一上万,光这两步每次循环就耗掉可观 CPU——而且**连接数越多越慢**(O(n) 的 n 在涨)。 + +`select` 更糟:它用一个 `fd_set` 位图,且有 `FD_SETSIZE`(默认 1024)的硬上限。`poll` 去掉了位图改用数组,没了 1024 上限,但 O(n) 的本质没变。 + +epoll 的革命就在于:它**不在每次调用时重新传 fd**,而是把"我关心哪些 fd"这件事**提前注册**进内核,内核帮你维护着;有 fd 就绪时,内核**直接把就绪的 fd 单独给你**(一个就绪链表),你拿到的就是"哪几个就绪了",不用全量扫描。连接数再多,只要就绪的少,开销就小——这就是它扛得住 C10K 的根本。 + +## epoll 的内核模型:兴趣表 + 就绪队列 + +epoll 在内核里维护**两个数据结构**(这是理解它全部行为的钥匙): + +- **兴趣表(interest list)**:你通过 `epoll_ctl(ADD)` 注册进来、"声明关心"的所有 fd。内核用**红黑树**存它,所以增删一个 fd 是 O(log n)——注册一次,永久在册,不用每次重新传。 +- **就绪队列(ready list)**:当前"有事件"的 fd 链表。`epoll_wait` 干的事就是**从这个链表里取**就绪的 fd 给你。 + +那 fd 是怎么从"兴趣表"进到"就绪队列"的?靠内核的 **wait queue(等待队列)** 机制:每个注册的 fd,内核在它的底层文件对象上挂一个回调;当网卡把数据收到该 fd 的接收缓冲区(状态变化)时,这个回调被触发,内核就把这个 fd **塞进就绪队列**。`epoll_wait` 醒来,发现就绪队列非空,把里面的 fd 拷给用户态。 + +```mermaid +flowchart LR + subgraph 注册["epoll_ctl(ADD/MOD/DEL) —— 一次性"] + A["fd 5: 关心 EPOLLIN"] --> RBT[("兴趣表
(红黑树, O(log n) 增删)")] + B["fd 7: 关心 EPOLLIN"] --> RBT + end + NET["网卡收到数据
fd 的缓冲区变可读"] -->|"状态变化触发
wait queue 回调"| RDY[("就绪队列
(ready list)")] + RBT -.->|"fd 在册,内核帮你盯"| NET + RDY --> W["epoll_wait()
从就绪队列取 fd 给你
(只返回就绪的, 不扫全表)"] +``` + +三个 API 对应这三件事: + +```cpp +int ep = ::epoll_create1(0); // 建一个 epoll 实例(内核分配兴趣表+就绪队列) + +epoll_event ev{}; ev.events = EPOLLIN; ev.data.fd = fd; +::epoll_ctl(ep, EPOLL_CTL_ADD, fd, &ev); // 把 fd 加进兴趣表,声明关心 EPOLLIN + +std::array evs; +int n = ::epoll_wait(ep, evs.data(), evs.size(), -1); // 取就绪的(阻塞等) +``` + +对照 poll:poll 每轮把全部 fd 传进内核扫;epoll **注册一次、永久在册**,`epoll_wait` 只取就绪的——**开销和"就绪的 fd 数"成正比,和"总 fd 数"无关**。这就是 epoll 扛 C10K 的数学原因。 + +## LT vs ET:那个让无数人翻车的开关 + +`epoll_ctl` 注册 fd 时,`events` 里除了 `EPOLLIN`(可读)、`EPOLLOUT`(可写)这些事件类型,还有一个开关决定**通知方式**:`EPOLLET`。带上它就是 **ET(edge-triggered,边缘触发)**,不带就是默认的 **LT(level-triggered,水平触发)**。这两个模式的区别,是 epoll 最容易踩坑、也最值得讲透的地方。 + +### 行为差异(先讲现象) + +- **LT(默认)**:只要 fd **还处于"可读"状态**(接收缓冲区里还有数据没读完),每次 `epoll_wait` 都会把这个 fd 通知给你。你没读完?下次还通知你。**好用、不易出错,但可能频繁通知。** +- **ET(`EPOLLET`)**:只在 fd **从"不可读"变成"可读"那个边沿**通知你**一次**。之后哪怕缓冲区里还有一堆数据没读完,只要没出现"新的数据到达"这个边沿,**它再也不通知你了**。**通知少、效率高,但你必须一次把数据读空,否则剩下的数据会被"遗忘"。** 属于是高效,但是bug高发区。 + +### 内核层的真正差异(为什么 ET 只通知一次) + +为什么 LT 会反复通知、ET 只通知一次?关键在内核把 fd "放进就绪队列"的时机: + +- **LT**:fd 的 wait queue 回调触发时把 fd 入就绪队列;**只要 `epoll_wait` 取出它时,发现它还能读到数据(状态仍满足),就把它重新挂回就绪队列**——所以下一轮 `epoll_wait` 还会拿到它。本质是"状态满足就一直就绪"。 +- **ET**:fd 入就绪队列时,内核会**标记它"已就绪过"**;只有当**新的数据到达**(fd 从不可读→可读的新边沿)时才**再次**入队。所以一次边沿只换来一次通知,读完没读完内核不管——**你没读空,它也不会再叫你**。 + +这就引出 ET 的铁律—— + +## ET 的命门:非阻塞 + 循环读到 EAGAIN + +既然 ET 一次通知后不再叫你,你**必须在这一次通知里把 fd 的数据彻底读空**,否则剩下的数据就"卡在缓冲区里永远等不到下一次处理"。怎么知道读空了?**循环 `read`,直到 `read` 返回 `-1` 且 `errno == EAGAIN`**(意思是"缓冲区暂时没数据了")。 + +而这里有个硬约束:**ET 模式下 fd 必须是非阻塞的**。为什么?因为你循环 `read`,最后一次"读空"时,如果是**阻塞 fd**,`read` 不会返回 EAGAIN——它会**阻塞**在那里等下一段数据,直接把你的事件循环卡死(这个线程还盯着别的 fd 呢)。非阻塞 fd 在"暂时没数据"时立刻返回 `-1 / EAGAIN`,你据此退出循环、回去处理别的 fd。所以:**ET + 非阻塞 + 循环到 EAGAIN,三件套缺一不可。** + +我们用 instrumented 的 LT server(它也用"循环读到 EAGAIN"的正确姿势)真跑一次,让你看见"一次事件里到底 read 了几次、最后怎么收尾": + +```text +[event#1] fd=5 : 4 reads, 14480 bytes, then EAGAIN -> stop loop +[event#2] fd=5 : 8 reads, 28960 bytes, then EAGAIN -> stop loop +[event#3] fd=5 : 14 reads, 56560 bytes, then EAGAIN -> stop loop +``` + +看清楚了一次 `epoll_wait` 事件里发生了什么:第一轮 read **4 次共 14480 字节**,直到 `read` 返回 EAGAIN 才停;下一轮 8 次共 28960;再下一轮 14 次共 56560。**一次事件可能需要很多次 read 才能读空**,这就是为什么"只 read 一次"在 ET 下是错的——剩下的数据就这么被晾在那儿了。 + +## 实战:ET-read-once 是怎么丢掉 87712 字节的 + +我们把 ET 的坑真复现一遍。写一个 ET server,但**故意每次事件只 `read` 一次**(很多网传笔记就是这么抄错的): + +```cpp +// 连接注册成 ET +epoll_event e{}; e.events = EPOLLIN | EPOLLET; e.data.fd = c; +::epoll_ctl(ep, EPOLL_CTL_ADD, c, &e); +// ...收到事件时: +ssize_t r = ::read(fd, buf.data(), buf.size()); // ★BUG:只读一次,没循环到 EAGAIN +``` + +再写一个 burst client,一次性发 100KB,然后读回 echo 统计字节数。先跑**正确的 LT server**(循环读到 EAGAIN),再跑**ET-read-once server**: + +```text +=== run A: LT server (correct, loop-read to EAGAIN) === +sent 100000 bytes to :13014 +got back 100000 bytes (expected 100000) ← 全量回显 + +=== run B: ET read-once server (the trap) === +sent 100000 bytes to :13015 +got back 12288 bytes (expected 100000) +>>> LOST 87712 bytes — this is the ET read-once trap ← 丢了 87KB! +``` + +LT 版全量回显 10 万字节;ET-read-once 版只回显了 12288 字节,**剩下的 87712 字节永远卡在 server 的 socket 接收缓冲区里**——因为 ET 只在"数据到达"那个边沿通知过一次,server 读了一次(连那次边沿内几个 read,共 12288)就结束了,缓冲区里还有 87KB,但**再没有新的"数据到达"边沿来触发下一次通知**,server 浑然不觉。client 这边等了 2 秒(超时)也没等到剩下的,只能报告 LOST。 + +### 这就是"别被测试骗了"的典型 + +注意一个要命的点:**如果 client 只发一个 4KB 的小消息,ET-read-once 版照样能正确回显**——因为 4KB 一次就读完了,没有"残留"。所以你拿小消息做单元测试,全绿;一上线,遇到真实的大请求/文件上传,数据就偷偷丢了,还不会崩——**最阴险的那种 bug**。 + +这正是本系列 Lab 0 的 MS3 要把"大 burst 不丢数据"设成**对抗性验收**的原因:测试必须主动制造"单次事件数据量远大于 read buffer"的场景,才能抓住这个坑。通不过这个验收,你的 ET server 就没写对。 + +::: warning ET 必须循环读到 EAGAIN,且 fd 必须非阻塞 +ET 模式下,收到 `EPOLLIN` 后**必须 `for(;;)` 循环 `read` 直到返回 `-1/EAGAIN`**,把数据读空。fd 必须先设 `O_NONBLOCK`,否则最后一次"读空"的 `read` 会阻塞、卡死事件循环。LT 模式下虽然不循环也能工作(没读完下次还通知),但循环读到 EAGAIN 是两个模式通用的正确姿势,养成习惯不会错。 +::: + +## 小结 + +- **poll/select 撑不住 C10K**:每次调用要把全部 fd 传进内核 O(n) 扫描,返回后用户态还要 O(n) 找就绪的;连接数越多越慢。select 还有 `FD_SETSIZE`(1024)硬上限。 +- **epoll 用"注册一次、永久在册"破局**:兴趣表(红黑树,O(log n) 增删)+ 就绪队列(wait queue 回调触发入队)。`epoll_wait` 只取就绪的,**开销与就绪 fd 数成正比,与总 fd 数无关**。 +- **三个 API**:`epoll_create1`(建实例)→ `epoll_ctl`(ADD/MOD/DEL 管兴趣表)→ `epoll_wait`(从就绪队列取)。 +- **LT vs ET**:LT 只要状态满足就反复通知(好用);ET 只在"不可读→可读"边沿通知一次(高效但要求读空)。内核层差异:LT 取出时仍可读就重挂就绪队列,ET 只在新数据边沿入队。 +- **ET 铁律**:`fd 非阻塞` + `循环 read 到 EAGAIN`,三件套缺一不可。实测一次事件 read 了 4~14 次才到 EAGAIN——"只 read 一次"在 ET 下会丢数据(复现:100KB 丢 87KB)。 +- **"别被测试骗了"**:ET-read-once 用小消息测试能过,大 burst 才暴露。大 burst 不丢数据是 Lab 0 MS3 的对抗验收。 + +到这一篇,我们已经能让**一个线程盯住成千上万个 fd**了。但"一堆 `if (fd == listener) ... else ...`"的散装事件处理,写到第三个连接类型就开始乱。下一篇我们把这套 epoll 包成 **Reactor 模式**——一个"事件循环 + 回调"的骨架,让事件驱动的代码有结构、可扩展。 + +## 参考资源 + +- [man 2 epoll_create1](https://man7.org/linux/man-pages/man2/epoll_create1.2.html) / [man 2 epoll_ctl](https://man7.org/linux/man-pages/man2/epoll_ctl.2.html) / [man 2 epoll_wait](https://man7.org/linux/man-pages/man2/epoll_wait.2.html) —— 三个 API 的权威定义 +- [man 7 epoll](https://man7.org/linux/man-pages/man7/epoll.7.html) —— "epoll semantics",含 LT/ET 的 `O(O)` 就绪通知与"avoid starvation"等官方表述 +- [man 2 poll](https://man7.org/linux/man-pages/man2/poll.2.html) —— poll 的 O(n) 模型,对照 epoll +- [The C10K problem (Dan Kegel)](https://kea.dev/notes/the-c10k-problem) —— epoll 诞生的直接动机 +- [epoll 内核实现:fs/eventpoll.c](https://github.com/torvalds/linux/blob/master/fs/eventpoll.c) —— 兴趣表(红黑树 `ep_insert`)+ 就绪队列(`ep_poll_callback` 入队)的源头 +- [现代 socket 封装:RAII 与 C10K 实测(本系列上一篇 01)](./01-modern-socket-wrapping.md) —— 每连接一线程扛不住并发的实测,本篇 epoll 的动机起点 +- [传统 socket 编程:服务器五步与 TCP 建链(本系列 00)](./00-traditional-socket-basics.md) —— socket 五步地基 +- [Reactor 模式(本系列下一篇)](./03-reactor-pattern.md) —— 把 epoll 包成事件循环 + 回调的结构化骨架 diff --git a/documents/vol8-domains/networking/03-reactor-pattern.md b/documents/vol8-domains/networking/03-reactor-pattern.md new file mode 100644 index 000000000..00f7aad99 --- /dev/null +++ b/documents/vol8-domains/networking/03-reactor-pattern.md @@ -0,0 +1,163 @@ +--- +title: "Reactor 模式:把 epoll 包成事件循环 + 回调,以及它为什么是'同步非阻塞'" +description: 从篇 2 散装的 epoll if/else 出发,讲透 Reactor 模式(POSA2 的 Handle / Demultiplexer / Event Handler / Initiation Dispatcher 四角色),epoll_wait 如何充当 Synchronous Event Demultiplexer,'同步非阻塞'的反直觉点,以及 Reactor(就绪通知)与 Proactor(完成通知)的本质对照——为后续 Boost.Asio 用 Reactor 模拟 Proactor、以及后插 Windows IOCP 埋下承重梁 +chapter: 8 +order: 3 +platform: host +difficulty: intermediate +cpp_standard: [20, 23] +reading_time_minutes: 10 +prerequisites: + - "Linux socket 地基:服务器五步、RAII 与每连接一线程的 C10K 代价" + - "epoll:Linux I/O 多路复用,从 poll 的瓶颈到兴趣表与就绪队列" +related: + - "Boost.Asio 入门:从同步到异步" + - "Windows IOCP:完成端口与 Proactor 模型(待补)" +tags: + - host + - cpp-modern + - intermediate + - 网络编程 + - 异步编程 +--- + +# Reactor 模式:把 epoll 包成事件循环 + 回调,以及它为什么是"同步非阻塞" + +上一篇我们用 epoll 写了个 echo server,能一个线程盯住一堆 fd 了。但回头看看那个事件处理代码,长这样: + +```cpp +for (int i = 0; i < n; ++i) { + int fd = evs[i].data.fd; + if (fd == lfd) { + // accept 新连接的逻辑 ... + } else { + // echo 已有连接的逻辑 ... + } +} +``` + +现在只有两种 fd(监听 fd + 连接 fd),`if/else` 还扛得住。可一旦你的 server 要同时处理"监听连接""定时器""信号""Unix domain socket"乃至"管道通知",这套散装的 `if/else` 就会膨胀成一团乱麻——每加一种 fd 就得改事件循环的核心代码。我们需要一个**结构**:把"事件循环"和"针对每种 fd 的处理逻辑"解耦,让加新 fd 类型不用动核心。这个结构就是 **Reactor 模式**。 + +## Reactor 是什么:POSA2 的四角色 + +Reactor 是《Pattern-Oriented Software Architecture, Volume 2》(POSA2)里给事件驱动 I/O 起的经典名字。它有四个角色: + +| 角色 | 是什么 | 本篇对应 | +|---|---|---| +| **Handle**(句柄) | 一个 I/O 资源的内核标识 | fd(socket、timerfd、eventfd……) | +| **Synchronous Event Demultiplexer**(同步事件多路分离器) | 阻塞等待一组 Handle,直到有某个就绪 | `epoll_wait`(篇 2 讲透的那个) | +| **Event Handler**(事件处理器) | 一个"某 fd 就绪时该干什么"的回调接口 | `handle_event(fd, events)` | +| **Initiation Dispatcher**(发起分发器)= **Reactor 本体** | 维护 fd→handler 的注册表、跑事件循环、把就绪事件分发给对应 handler | 我们的 `EventLoop` 类 | + +整张图: + +```mermaid +flowchart TD + APP["应用:Concrete Event Handlers
(监听 handler / echo handler / 定时器 handler ...)"] -->|"注册:fd + 关心的事件"| R["Reactor / Initiation Dispatcher
(fd→handler 注册表 + 事件循环)"] + R -->|"epoll_wait 阻塞等就绪"| DM["Synchronous Event Demultiplexer
(epoll_wait)"] + DM -->|"返回就绪的 fd + 事件"| R + R -->|"按 fd 查表,分发给对应 handler"| APP +``` + +关键在于**解耦**:Reactor 本体只管"注册表 + 循环 + 分发",它**不知道也不关心**某个 fd 具体怎么处理——那是 handler 的事。加一种新 fd 类型?写个新 handler、注册进 Reactor,事件循环一个字不用改。这就是模式的价值。 + +## 把篇 2 的 epoll echo 重构成 Reactor + +篇 2 的 `epoll_lt.cpp` 其实**已经是一个最小 Reactor**——只是角色没显式分开。我们把它重构成模式该有的样子,让结构清晰: + +```cpp +// 事件处理器接口:某个 fd 就绪时调 handle_event +class EventHandler { +public: + virtual ~EventHandler() = default; + virtual void handle_event(uint32_t events) = 0; + virtual int fd() const = 0; +}; + +// Reactor 本体:注册表 + 事件循环 +class Reactor { +public: + void add(int fd, uint32_t events, std::unique_ptr h) { + epoll_event ev{}; ev.events = events; ev.data.fd = fd; + ::epoll_ctl(ep_, EPOLL_CTL_ADD, fd, &ev); + handlers_[fd] = std::move(h); // fd → handler 注册表 + } + void run() { + for (;;) { + int n = ::epoll_wait(ep_, evs_.data(), evs_.size(), -1); // Demultiplexer + for (int i = 0; i < n; ++i) { + int fd = evs[i].data.fd; + handlers_[fd]->handle_event(evs[i].events); // 分发 + } + } + } +private: + int ep_{::epoll_create1(0)}; + std::array evs_; + std::unordered_map> handlers_; // 注册表 +}; +``` + +现在"监听"和"echo"是两个独立的 handler,各自实现 `handle_event`,注册进 Reactor。要加定时器?写个 `TimerHandler` 注册进去,`Reactor::run` 不用动。**核心循环和业务逻辑彻底分开**——这就是 Reactor 比散装 `if/else` 强的地方。 + +篇 2 的 epoll_lt(以及那个 ET 丢数据的反面教材)是这个模式的血肉:Reactor 模式只是骨架,ET/LT、非阻塞、循环读到 EAGAIN 这些**工程正确性细节全在 handler 里**。模式不替你保证正确性,它只保证结构。 + +## "同步非阻塞":一个反直觉的名字 + +Reactor 常被叫"**同步非阻塞**(synchronous non-blocking)"事件驱动,这名字乍看矛盾——"又同步又非阻塞"。拆开就懂了: + +- **非阻塞**:所有 fd 都是 `O_NONBLOCK` 的(篇 2 讲过,ET 必须,LT 也建议)。`read`/`write` 不会卡住线程。 +- **同步**:事件循环**在同一个线程里、同步地**调用 handler。`epoll_wait` 同步返回就绪事件,handler 同步执行完才回到循环——**没有跨线程、没有"操作完成后另起回调"**。整个 server 在一个(或几个)线程里跑事件循环,顺序处理事件。 + +所以"同步"指的是**处理模型**(单线程顺序分发),不是"I/O 阻塞"。它和真正的"异步(Proactor)"的区别,正是下一节的核心。 + +## Reactor vs Proactor:就绪通知 vs 完成通知 + +这是网络编程里最该一次讲清的一对概念,也是本系列后续的**承重梁**: + +- **Reactor(就绪通知,ready notification)**:内核告诉你"这个 fd **可以读了**"(状态就绪),**你自己去 `read`** 搬数据。搬多快、搬多少是你的事。Linux 的 epoll 就是 Reactor。本篇整个就是它。 +- **Proactor(完成通知,completion notification)**:你告诉内核"帮我**读这个 fd 的这段缓冲**",内核**帮你把数据读好**,读完后**通知你"读完了,数据在这儿"**。Windows 的 **IOCP** 是原生 Proactor。 + +```mermaid +flowchart LR + subgraph Reactor["Reactor(epoll / Linux)——就绪通知"] + R1["内核:fd 可读了"] --> R2["你:read() 自己搬"] + end + subgraph Proactor["Proactor(IOCP / Windows)——完成通知"] + P1["你:发起 read 请求"] --> P2["内核:帮你读完"] --> P3["通知你:读完了"] + end +``` + +差异的本质:**谁来执行那次真正的 `read`/`write` 系统调用**。Reactor 里是你(handler 里 `read`);Proactor 里是内核(你只发起请求)。Linux 内核**没有原生的通用 Proactor 接口**(io_uring 算半个,后面专门讲),所以 Linux 上的网络库要提供 Proactor 风格的 API,只能**用 Reactor 模拟**——这就是下一篇 **Boost.Asio** 要做的事:它对上层暴露的是 Proactor 风格(`async_read` 注册完成回调),但底层在 Linux 上是用 epoll(Reactor)实现的。Windows 上才落到原生 IOCP。 + +这条"Proactor 用 Reactor 实现"的承重梁,把本系列三篇纯 Linux 地基(socket→epoll→Reactor)和后面的 Asio、以及后插的 Windows IOCP,全部焊在了一起:你已经手写过 Reactor 了,Asio 告诉你"我把你手写的这个 Reactor 封成了一个 Proactor 接口",IOCP 则补上"Proactor 的原生半边"。 + +## 优雅关闭:Reactor 的工程收尾 + +一个能用的 Reactor server 还差最后一块:**优雅关闭**。你不能 `Ctrl+C` 直接杀进程——那样正在处理的连接会被粗暴 `close`,对端收到 RST。正确的姿势: + +1. **信号 handler** 把 `epoll_wait` 的 timeout 从 `-1`(永久阻塞)改成短超时,或用一个 eventfd 唤醒它。 +2. **停止 accept 新连接**(从兴趣表移除监听 fd)。 +3. **drain**:给已有连接发完剩余数据、等它们自然关闭或超时。 +4. 最后退出循环。 + +这块涉及信号与事件循环的协作(信号随时打断 `epoll_wait` 返回 `EINTR`)、以及"正在处理中的 handler 怎么收尾"的生命周期问题——坑不少(我们旧笔记里就有一个"accept 阻塞导致 `join` 挂死"的真 bug)。这些**工程细节正是本系列 Lab 0 的 MS4(优雅关闭)要练的对抗验收**:SIGTERM 后不能挂 `join`、不能漏 fd、不能让对端收到 RST。 + +## 小结 + +- **Reactor 模式**把"事件循环"和"fd 处理逻辑"解耦:Reactor 本体管注册表 + 循环 + 分发,handler 管具体处理。加新 fd 类型只写 handler,不动核心。 +- **POSA2 四角色**:Handle(fd)/ Synchronous Event Demultiplexer(`epoll_wait`)/ Event Handler(`handle_event`)/ Initiation Dispatcher(Reactor 本体)。篇 2 的 `epoll_lt.cpp` 就是最小 Reactor。 +- **"同步非阻塞"**:非阻塞指 fd 全 `O_NONBLOCK`,同步指单线程顺序分发事件——不是"I/O 阻塞"。 +- **Reactor(就绪通知)vs Proactor(完成通知)**:前者内核通知"可读了"你自己 read(epoll);后者内核帮你读完再通知(IOCP)。差异本质是**谁执行那次 read/write 系统调用**。 +- **承重梁**:Linux 无原生 Proactor,网络库(Boost.Asio)用 Reactor(epoll)模拟 Proactor;Windows IOCP 是原生 Proactor。这三篇纯 Linux 地基和后续 Asio/IOCP 由此焊在一起。 +- **优雅关闭**是 Reactor 的工程收尾(信号→停 accept→drain→退),是 Lab 0 MS4 的对抗验收。 + +到这里,Linux 网络编程的地基三篇(socket → epoll → Reactor)就齐了。下一篇我们先补 **io_uring**——Linux 的完成驱动新原语,把"后端巡礼"(epoll 就绪驱动 + io_uring 完成驱动)补全;再往后才进 **Boost.Asio**,那时你会看到它怎么把这套手写的 epoll/Reactor 封成一个跨平台的 Proactor 风格 API,把本篇的"事件循环 + 回调"升级成"发起异步操作 + 注册完成回调"。 + +## 参考资源 + +- [POSA2 — Reactor pattern (Doug Schmidt)](https://www.dre.vanderbilt.edu/~schmidt/PDF/Reactors.pdf) —— Reactor 模式的原始论文,四角色定义的出处 +- [Reactor - An Object Behavioral Pattern for Demultiplexing...](https://www.dre.vanderbilt.edu/~schmidt/PDF/POSA2.pdf) —— POSA2 第相关章节 +- [Boost.Asio — The Proactor Design Pattern: Concurrency Without Threads](https://www.boost.org/doc/libs/1_91_0/doc/html/boost_asio/overview/core/async.html) —— Asio 自述"Proactor 用 Reactor 实现",承重梁的官方表述 +- [epoll:I/O 多路复用地基(本系列上一篇)](./02-epoll-io-multiplexing.md) —— Reactor 的 Demultiplexer 就是 epoll +- io_uring:Linux 完成驱动新原语(下一篇,待写) —— 补全后端巡礼;之后进 Boost.Asio,把本篇 Reactor 封成 Proactor 风格 API diff --git a/documents/vol8-domains/networking/index.md b/documents/vol8-domains/networking/index.md new file mode 100644 index 000000000..e6548df4c --- /dev/null +++ b/documents/vol8-domains/networking/index.md @@ -0,0 +1,29 @@ +--- +title: "网络编程" +description: "从 Linux socket 地基到 epoll/Reactor,再到 Boost.Asio、协程与 std::execution 的现代 C++ 网络编程" +platform: host +tags: + - cpp-modern + - host + - intermediate +--- + +# 网络编程 + +C++作为长期在高性能优先的领域活跃的编程语言,一个重要的主战场就是网络编程(Networking Programming),由于大部分服务器都是Linux(笔者必须强调是大部分,因为仍然有些服务运行在Windows Server上),所以,基于**迁移自BSD Socket和随后派生的,著名的epoll机制**的学习,并且在这个基础上逐步学习和体验现代的C++网络编程,是我们这个子卷——网络编程种计划的一个核心要点。 + +目前计划是 **Linux First**,但是Windows的IOCP仍然重要,我们也会在这一卷的框架彻底完成之后,插入 Windows 提供的异步编程模型。随后,慢慢演进到理解Boost.ASIO的异步抽象(协程正式被支持前一个非常常见的解决方案),和重新看于 C++20 诞生的协程抽象方案,到Boost.Beast, 我们不会遗忘std::execution这一方案。截止到2026年6月底仍然让人兴奋的一个特性。 + + +## Linux 地基 + +- [00 · 传统 socket 编程:服务器五步与 TCP 建链](./00-traditional-socket-basics.md) —— 经典 C 风格 BSD socket 五步、TCP 三次握手时序、字节序、`listen` 的两个队列与 backlog、SIGPIPE/SO_REUSEADDR +- [01 · 现代 socket 封装:RAII 与 `std::expected`](./01-modern-socket-wrapping.md) —— 用 Modern C++ 收掉 00 的裸 fd 与散落 errno,实测"每连接一线程"2000 并发吃 24GB 的 C10K 代价 +- [02 · epoll:Linux I/O 多路复用](./02-epoll-io-multiplexing.md) —— 兴趣表 + 就绪队列 + 等待队列、ET vs LT 内核层差异、为什么 ET 必须非阻塞 + 循环读到 EAGAIN,复现 ET-read-once 丢 87KB 数据 +- [03 · Reactor 模式:把 epoll 包成事件循环 + 回调](./03-reactor-pattern.md) —— POSA2 四角色、"同步非阻塞"、Reactor(就绪通知)↔ Proactor(完成通知)承重梁 + +## 后续(进行中) + +io_uring(Linux 完成驱动,补全后端巡礼)→ Boost.Asio(同步到异步回调链)→ Asio 执行器 + completion token → C++20 协程 on Asio → Boost.Beast(HTTP/WebSocket)→ std::execution(P2300)展望。配套 **Lab:mini Reactor echo server**(对抗性验收 + TSan)进行中。 + +> 源码阅读层(Boost.Asio / Beast 源码深入)另见 [vol9 开源项目阅读](../../vol9-open-source-project-learn/)。 diff --git a/documents/vol8-domains/networking/lab0-mini-reactor.md b/documents/vol8-domains/networking/lab0-mini-reactor.md new file mode 100644 index 000000000..45ae59e4f --- /dev/null +++ b/documents/vol8-domains/networking/lab0-mini-reactor.md @@ -0,0 +1,151 @@ +--- +title: "Lab 0:mini Reactor echo server——从 epoll 到一个能扛并发的事件循环" +description: 在前四篇的 socket/epoll/Reactor 地基上,动手实现一个最小的 Reactor(epoll 事件循环 + handler 注册表),做成能扛并发的 echo server。分 4 个 milestone,每个只引入一个工程问题,验收是对抗性的:多并发不崩、ET 大 burst 不丢数据、stop 不挂死——通不过这些就不是"能跑",是"看着能跑" +chapter: 8 +order: 4 +platform: host +difficulty: advanced +cpp_standard: [20] +reading_time_minutes: 8 +prerequisites: + - "传统 socket 编程:服务器五步与 TCP 建链" + - "epoll:Linux I/O 多路复用" + - "Reactor 模式" +tags: + - host + - cpp-modern + - advanced + - 网络编程 + - 异步编程 +--- + +# Lab 0:mini Reactor echo server——从 epoll 到一个能扛并发的事件循环 + +> 这是一个动手 Lab,不是讲概念的教程。前四篇(00→03)我们把 socket、epoll、Reactor 模式都讲过了,这一篇轮到你自己把它们拼成一个能跑的东西。配套工程脚手架在 `code/volumn_codes/vol8-labs/lab0-mini-reactor/`。 + +## 目标 + +实现一个最小的 **Reactor**——在 epoll 之上的"事件循环 + handler 注册表",并用它搭一个能同时伺候大量连接的 echo server。整个 Lab 拆成 4 个 milestone,每个 milestone 只引入**一个新的工程问题**: + +- **MS1** 事件循环本身怎么转起来、单连接怎么 echo; +- **MS2** 多个并发连接同时来,怎么不出错; +- **MS3** ET 模式下大 burst,怎么不丢数据; +- **MS4** 怎么优雅关闭、不挂死。 + +重点是:**每个 milestone 的验收都是对抗性的**——不是"echo 能跑",而是"echo 在并发/大 burst/带载关闭下也不崩、不丢、不挂"。网络代码最大的谎就是"看着能跑",这个 Lab 就是要把那些"看着能跑"的实现,在验收里打成不合格。 + +## 前置知识 + +- [00 传统 socket 编程](./00-traditional-socket-basics.md)——服务器五步、RAII `UniqueFd`。 +- [01 现代 socket 封装](./01-modern-socket-wrapping.md)——`std::expected`、每连接一线程的 C10K 代价(这个 Lab 正是它的解药)。 +- [02 epoll](./02-epoll-io-multiplexing.md)——兴趣表/就绪队列、ET vs LT、循环读到 EAGAIN。 +- [03 Reactor 模式](./03-reactor-pattern.md)——POSA2 四角色,这个 Lab 实现的就是 Initiation Dispatcher。 + +## 工程脚手架 + +`code/volumn_codes/vol8-labs/lab0-mini-reactor/` 给你一套可构建的工程: + +```text +include/net/reactor.hpp # Reactor 的接口(你要实现的) +include/net/unique_fd.hpp # 复用 01 的 RAII fd +src/reactor.cpp # ★参考实现(答案)——你的任务是在这里对照接口重写一遍 +tests/lab0_tests.cpp # MS1-4 的 Catch2 对抗性验收 +CMakeLists.txt # Catch2(FetchContent)+ 普通测试 + TSan 测试两套目标 +``` + +**怎么干**:读 `reactor.hpp` 那个接口,在 `src/reactor.cpp` 里自己写实现,然后 `cmake --build`、跑测试,4 个 milestone 的测试逐个变绿。`src/reactor.cpp` 里现在那份是**参考答案**——给你对照思路用的,真要做 Lab 就先把它清空、只留接口,自己从头写。这叫 dogfooding:接口和测试是我给你的"题目",实现是你交的"作业"。 + +## 最终接口 + +你要实现的 `Reactor` 类(完整声明在 `reactor.hpp`): + +| 成员 | 语义 | 哪个 MS 用到 | +|---|---|---| +| `add(fd, events, handler)` | 注册一个 fd,声明关心的事件(`EPOLLIN`/`EPOLLOUT`/`EPOLLET`...),就绪时调 `handler` | MS1 | +| `modify(fd, events)` | 改已注册 fd 的事件(比如切 LT→ET、加 `EPOLLOUT`) | MS3 | +| `remove(fd)` | 注销 fd(从兴趣表移除 + 删 handler) | MS1(EOF 时) | +| `run()` | 跑事件循环,阻塞直到 `stop()` | MS1 | +| `stop()` | 请求停止(从别的线程/信号 handler 调;要能唤醒阻塞中的 `epoll_wait`) | MS4 | + +设计要点:**所有 handler 都在 `run()` 的线程上同步执行**(单线程 Reactor)。这是它"免锁"的根基——同一时刻只有一个 handler 在跑,共享状态不会被并发改。多线程安全只靠 `stop()`(它得能唤醒阻塞中的 `epoll_wait`,这就需要个 eventfd 或 self-pipe 来"戳"一下)。 + +## Milestone 1:事件循环转起来,echo 一条连接 + +**目标**:实现 `Reactor` 的核心——`epoll` 实例 + `add`/`run`,注册一个监听 fd,accept 出一条连接、echo 它。 + +**为什么**:这是整个 Lab 的地基。`run()` 必须是个能阻塞等待、有事件就分发的循环;`add` 必须能把 fd 和 handler 绑在一起存好。这一步通了,后面三个 milestone 都是在这之上加东西。 + +**实现指引**: + +- `epoll_create1(0)` 建 epoll 实例;`add` 里 `epoll_ctl(EPOLL_CTL_ADD)` + 把 handler 存进一个 `unordered_map`。 +- `run()` 是个 `while` 循环,里面 `epoll_wait` 阻塞等事件,拿到事件后按 `fd` 查 map、调对应 handler。 +- handler 里 `accept` 出连接后,再 `add` 一个连接 handler(负责 echo)。连接 handler 读到 `0`(EOF)时要 `remove` 自己 + `close`。 +- ⚠️ **`run()` 调 handler 前先拷一份**(`Handler h = it->second; h(events);`):连接 handler 在 EOF 时会 `remove` 自己,这会从 map 里擦除**正在执行**的这个 `std::function`,直接调 `it->second(...)` 就是 use-after-free——TSan 一抓一个准。这是 reactor 的经典自删除坑,参考实现 `src/reactor.cpp` 里就是这么处理的(这个 Lab 自己就踩过,所以 MS2 的 TSan 验收不是摆设)。 + +**验证**(`tests/lab0_tests.cpp` 的 MS1 用例):启动 reactor(放独立线程),客户端连上来发 `"hello-ms1"`,断言读回的 echo 字节数等于发送的。再连第二个,断言也通——**顺序多连接都要对**。 + +## Milestone 2:并发客户端,全对(且 TSan 干净) + +**目标**:16 个客户端**同时**连、同时发,16 条 echo 全对。 + +**为什么**:MS1 只测了顺序连接。并发一起来,如果你的 handler 注册表、或 per-连接状态有问题(比如 handler 捕获的 fd 错了、或 map 并发改),就会露馅。单线程 Reactor 设计上不该有 data race——所以这一步的验收外加一道 **TSan**:有 race 就红。 + +**实现指引**:MS1 的实现如果"所有 handler 都在 loop 线程跑、map 只在 loop 线程改",MS2 自然就过了。**别在 handler 里 `std::thread`**——那就退化回 01 的每连接一线程了,而且会和 loop 线程抢 map,TSan 立刻报 race。 + +**验证**:MS2 用例开 16 个客户端线程并发 echo,断言全部成功;**TSan 版测试**(`lab0_tests_tsan`)跑同一份用例,断言无 race 报告。这一步真正抓的是"看着并发能跑、其实有隐藏 race"的实现——TSan 就是来揭穿它的。 + +## Milestone 3(对抗性):ET 模式 + 大 burst,一字节都不能少 + +**目标**:连接注册成 `EPOLLET | EPOLLIN`,客户端一次性发 100KB,断言 echo 回来**正好 100KB**。 + +**为什么**:这是整个 Lab 的"别被测试骗了"名场面,直接对应 [02 篇那个"ET-read-once 丢 87KB"的坑](./02-epoll-io-multiplexing.md)。ET 只在"有新数据到达"这个边沿通知一次;你的 handler 如果只 `read` 一次,剩下的数据就卡在 socket 缓冲区里,ET 再也不通知——测试用小消息(4KB)根本测不出来,非得上 100KB 的大 burst 才能把这个 bug 揪出来。 + +**实现指引**: + +- `add` 时给连接 `EPOLLIN | EPOLLET`;连接 fd 必须 `O_NONBLOCK`。 +- handler 收到事件后,**必须 `for(;;)` 循环 `read`,直到返回 `-1` 且 `errno == EAGAIN`** 才结束这次处理——把缓冲区彻底读空。 +- 读到的每段循环 `write` 回去(write 也可能短写/`EAGAIN`)。 + +**验证**:MS3 用例发 100KB、读 echo(带 3s 超时),`REQUIRE(got == 100000)`。**一字节都不能少**——这就是对抗性验收。漏了循环读、或忘了非阻塞,这个数字就到不了 10 万。 + +## Milestone 4:优雅关闭,`stop()` 不挂死 + +**目标**:从别的线程调 `stop()`,断言 `run()` 在 2 秒内返回(不挂)。 + +**为什么**:`run()` 阻塞在 `epoll_wait(-1)` 上(永久等待)。如果 `stop()` 只是设个 `stop_` 标志,`epoll_wait` 根本不知道——它会继续阻塞,`run()` 永远不返回,你的 join 就挂死了。这是 reactor 类最容易踩的关闭坑(旧笔记里就有"accept 阻塞导致 join 挂起"的真 bug)。 + +**实现指引**: + +- 建一个 `eventfd`(或 self-pipe),`add` 进 epoll。 +- `stop()` 里:设 `stop_` 标志 + 往 eventfd 写一字节——这一写会立刻唤醒阻塞中的 `epoll_wait`。 +- `run()` 醒来发现 eventfd 可读(或检查 `stop_`),退出循环。 + +**验证**:MS4 用例把 `run()` 放进 `std::async`,先连一个客户端(模拟"带载"),再 `stop()`,用 `future::wait_for(2s)` 断言状态是 `ready`——**2 秒内必须返回**。挂死就红。 + +## 性能测试(选做) + +Lab 跑通后,可以和 `code/volumn_codes/vol8/networking/01-modern-socket/`(01 的每连接一线程 server)做个对照:同样开 2000 个空闲连接,看你的 reactor server 的 `VmSize`/`Threads` 涨多少。预期:Threads 应该基本不涨(就 loop 那一个线程),`VmSize` 远不到 01 的 24GB——这就是"少量线程服务大量连接"的实证。数据自己跑、自己贴,别照抄。 + +## 扩展练习(bonus,非主线) + +- **定时器**:用 `timerfd` 注册进 reactor,实现一个 `call_after(duration, fn)`。提示:timerfd 也是个 fd,read 它就清掉定时。 +- **EPOLLONESHOT**:连接注册 `EPOLLONESHOT`,处理后要 `modify` 重新挂上——理解它和普通 ET 的区别(为什么多线程 reactor 需要 oneshot)。 +- **多线程 reactor**:开 N 个 worker 线程跑同一个 `io_context`,用 `strand` 保证同一连接的 handler 不并发——这就摸到 Boost.Asio 的门槛了。 + +## 自查清单 + +- [ ] MS1:单连接 + 顺序多连接 echo 全对? +- [ ] MS2:16 并发 echo 全对,且 TSan 版**无 race**? +- [ ] MS3:ET + 100KB burst,echo **正好 100000** 字节?(循环读到 EAGAIN、fd 非阻塞) +- [ ] MS4:`stop()` 后 `run()` 2 秒内返回?(eventfd 唤醒) +- [ ] handler 全在 loop 线程跑,没在 handler 里 spawn 线程? +- [ ] 连接 EOF 时 `remove` + `close` 了?没漏 fd? + +## 参考资源 + +- [man 2 epoll_create1](https://man7.org/linux/man-pages/man2/epoll_create1.2.html) / [epoll_ctl](https://man7.org/linux/man-pages/man2/epoll_ctl.2.html) / [epoll_wait](https://man7.org/linux/man-pages/man2/epoll_wait.2.html) +- [man 2 eventfd](https://man7.org/linux/man-pages/man2/eventfd.2.html) —— MS4 用来唤醒阻塞中的 `epoll_wait` +- [Catch2](https://github.com/catchorg/Catch2) —— 本 Lab 的测试框架 +- [ThreadSanitizer](https://clang.llvm.org/docs/ThreadSanitizer.html) —— MS2 抓隐藏 data race 的工具 +- [Reactor 模式(本系列 03)](./03-reactor-pattern.md) —— 本 Lab 实现的设计模式 +- [epoll(本系列 02)](./02-epoll-io-multiplexing.md) —— MS3 的 ET 坑在那里有完整复现