EWSS: 面向嵌入式 Linux 的轻量级 WebSocket 服务器

3 阅读11分钟

背景

嵌入式 Linux 设备(激光雷达、机器人控制器、边缘网关)经常需要一个 WebSocket 接口,用于调试面板、远程配置、实时数据推送。现有方案要么太重(Boost.ASIO 体系,二进制 2MB+),要么太简陋(裸 socket 手写帧解析,缺乏状态管理)。

Simple-WebSocket-Server 是一个广泛使用的 C++ WebSocket 库,功能完整、接口简洁。但它依赖 ASIO(或 Boost.ASIO),使用 std::shared_ptrstd::ostream、动态 std::string 做帧编码,每条消息都有堆分配。对桌面/服务器场景这不是问题,但在内存受限、要求确定性延迟的嵌入式平台上,这些开销不可接受。

EWSS(Embedded WebSocket Server)是对 Simple-WebSocket-Server 的嵌入式重构:去掉 ASIO 依赖,用 poll() 单线程 Reactor 替代多线程模型,用固定大小 RingBuffer 替代动态缓冲区,用状态机替代隐式的 ASIO handler 链。目标是在 67KB 二进制、12KB/连接的资源预算内,提供完整的 RFC 6455 WebSocket 协议支持。

项目地址: github.com/DeguiLiu/ew…

架构概览

Server (poll Reactor)
  |
  +-- Connection #1 ─┐
  +-- Connection #2  ├─ 每个连接:
  +-- Connection #N ─┘
        RxBuffer (RingBuffer<4096>)
            | readv 零拷贝接收
        StateOps (函数指针表)
            | on_message 回调
        Application
            | send()
        TxBuffer (RingBuffer<8192>)
            | writev 零拷贝发送
        TCP Socket (sockpp)

核心设计决策:

  • 单线程 Reactor: poll() 事件循环,无锁、无上下文切换、Cache 友好
  • 固定内存: 编译期确定的 RingBuffer 大小,运行时零堆分配
  • 状态机驱动: 4 状态 StateOps 函数指针表(Handshaking/Open/Closing/Closed),编译期常量零分配
  • 零拷贝 I/O: readv 直接读入 RingBuffer,writev 直接从 RingBuffer 发送

为什么去掉 ASIO

不是 ASIO 不好,而是嵌入式场景的约束不同。

维度ASIO 方案EWSS 方案
二进制体积~2 MB (含 ASIO 模板实例化)67 KB (stripped)
每连接内存动态,取决于消息大小固定 12 KB (4KB RX + 8KB TX)
热路径堆分配每消息 make_shared<SendStream>
线程模型多线程 + strand 序列化单线程,无锁
依赖Boost.ASIO 或 standalone ASIOsockpp (仅 TCP 封装)
异常处理必须开启可选 (-fno-exceptions)

在 ARM Cortex-A 平台上,2MB 二进制意味着更多的 I-Cache miss;动态内存分配意味着不确定的延迟毛刺;多线程意味着锁竞争和上下文切换开销。对于 64 连接以内的嵌入式场景,单线程 poll() Reactor 是更合适的选择。

核心模块详解

RingBuffer: 固定内存的循环缓冲

RingBuffer 是整个系统的数据通道。每个连接有两个:RxBuffer (4KB) 接收数据,TxBuffer (8KB) 发送数据。

template <typename T, size_t Size>
class alignas(64) RingBuffer {
 public:
  static constexpr size_t kCapacity = Size;

  bool push(const T* data, size_t len);     // 写入数据
  size_t peek(T* data, size_t max_len) const; // 读取不移除
  void advance(size_t len);                  // 消费数据

  // 零拷贝 I/O 接口
  size_t fill_iovec(struct iovec* iov, size_t max_iov) const;       // writev 发送
  size_t fill_iovec_write(struct iovec* iov, size_t max_iov) const; // readv 接收
  void commit_write(size_t len);                                     // readv 后提交

 private:
  alignas(64) std::array<T, kCapacity> buffer_{};
  size_t read_idx_ = 0;
  size_t write_idx_ = 0;
  size_t count_ = 0;
};

关键设计点:

  • alignas(64) 缓存行对齐,避免 false sharing
  • fill_iovec_write + commit_write 配合 readv,内核直接写入 RingBuffer 的可写区域,省去一次 memcpy
  • fill_iovec 配合 writev,从 RingBuffer 的读侧直接发送,同样零拷贝
  • 环形缓冲区可能跨越数组边界,fill_iovec 返回 1 或 2 个 iovec 段处理 wrap-around

为什么不用 std::vectorstd::string?因为它们会在数据增长时 realloc,产生不确定延迟和内存碎片。RingBuffer 的所有操作都是 O(1),内存占用在编译期确定。

零拷贝接收路径

传统做法是先 recv 到临时缓冲区,再 memcpy 到应用缓冲区。EWSS 用 readv 直接读入 RingBuffer:

expected<void, ErrorCode> Connection::handle_read() {
  struct iovec iov[2];
  size_t iov_count = rx_buffer_.fill_iovec_write(iov, 2);
  if (iov_count == 0) {
    return expected<void, ErrorCode>::error(ErrorCode::kBufferFull);
  }

  ssize_t n = ::readv(socket_.handle(), iov, static_cast<int>(iov_count));
  if (n > 0) {
    rx_buffer_.commit_write(static_cast<size_t>(n));
    ops_->on_data(*this);
    return expected<void, ErrorCode>::success();
  }
  // ... 错误处理
}

fill_iovec_write 返回 RingBuffer 写侧的 1-2 个连续内存段(处理 wrap-around),readv 一次系统调用直接填充,commit_write 更新写指针。整个路径零 memcpy

发送路径同理,fill_iovec 返回读侧的连续段,writev 一次系统调用发送:

expected<void, ErrorCode> Connection::handle_write_vectored() {
  struct iovec iov[2];
  size_t iov_count = tx_buffer_.fill_iovec(iov, 2);
  if (iov_count == 0) return expected<void, ErrorCode>::success();

  ssize_t n = ::writev(socket_.handle(), iov, static_cast<int>(iov_count));
  if (n > 0) {
    tx_buffer_.advance(static_cast<size_t>(n));
  }
  // ...
}

协议状态机

WebSocket 连接有 4 个状态,每个状态是一个 StateOps 函数指针表:

Handshaking ──(握手成功)──> Open ──(Close 帧)──> Closing ──> Closed
     |                       |                                  ^
     +──(超时/错误)──────────+──────(错误)──────────────────────+
// Function pointer types for state operations
using StateDataHandler = expected<void, ErrorCode> (*)(Connection& conn);
using StateSendHandler = expected<void, ErrorCode> (*)(Connection& conn, std::string_view payload);
using StateCloseHandler = expected<void, ErrorCode> (*)(Connection& conn, uint16_t code);

struct StateOps {
  ConnectionState state;
  StateDataHandler on_data;
  StateSendHandler on_send;
  StateCloseHandler on_close;
};

// Compile-time constant state tables (zero allocation, zero virtual)
inline const StateOps kHandshakeOps = { ConnectionState::kHandshaking, ... };
inline const StateOps kOpenOps      = { ConnectionState::kOpen, ... };
inline const StateOps kClosingOps   = { ConnectionState::kClosing, ... };
inline const StateOps kClosedOps    = { ConnectionState::kClosed, ... };

状态转换通过指针切换实现,不需要 new/delete,也没有 virtual 开销:

void Connection::transition_to_state(ConnectionState state) {
  switch (state) {
    case ConnectionState::kOpen:
      ops_ = &kOpenOps;
      if (on_open) on_open(shared_from_this());
      break;
    case ConnectionState::kClosed:
      ops_ = &kClosedOps;
      if (on_close) on_close(shared_from_this(), true);
      break;
    // ...
  }
}

每个状态只处理自己关心的事件。kHandshakeOps.on_data 解析 HTTP Upgrade 请求,kOpenOps.on_data 解析 WebSocket 帧,kClosingOps.on_data 等待对端 Close 帧。职责清晰,不会出现 if-else 嵌套的状态混乱。

帧编码: 栈上完成

WebSocket 帧头最大 14 字节(2 字节基础 + 8 字节扩展长度 + 4 字节掩码)。EWSS 在栈上编码,直接写入 TxBuffer:

void Connection::write_frame(std::string_view payload, ws::OpCode opcode) {
  uint8_t header_buf[14];  // 栈上分配
  size_t header_len = ws::encode_frame_header(
      header_buf, opcode, payload.size(), false);

  tx_buffer_.push(header_buf, header_len);
  if (!payload.empty()) {
    tx_buffer_.push(
        reinterpret_cast<const uint8_t*>(payload.data()), payload.size());
  }
}

对比 Simple-WebSocket-Server 的做法:

// Simple-WebSocket-Server: 每次发送都堆分配
auto send_stream = make_shared<SendStream>();
*send_stream << message_str;  // std::ostream 格式化
connection->send(send_stream, callback);

一个是 14 字节栈缓冲 + RingBuffer push,一个是 shared_ptr + ostream + 堆分配。在嵌入式热路径上,差距是数量级的。

Server: poll Reactor

Server 的主循环是经典的 Reactor 模式:

void Server::run() {
  while (is_running_) {
    // 1. 构建 pollfd 数组(预分配,零堆分配)
    poll_fds_[0] = {server_sock_, POLLIN, 0};
    for (uint32_t i = 0; i < connections_.size(); ++i) {
      short events = POLLIN;
      if (connections_[i]->has_data_to_send()) events |= POLLOUT;
      poll_fds_[i + 1] = {connections_[i]->get_fd(), events, 0};
    }

    // 2. poll 等待事件
    int ret = ::poll(poll_fds_.data(), nfds, poll_timeout_ms_);

    // 3. 处理新连接(含过载保护)
    if (poll_fds_[0].revents & POLLIN) {
      if (stats_.is_overloaded(max_connections_)) {
        // Accept and immediately close to drain kernel backlog
        int reject_sock = accept(server_sock_, ...);
        if (reject_sock >= 0) ::close(reject_sock);
      } else {
        accept_connection();
      }
    }

    // 4. 处理客户端 I/O
    for (size_t i = 1; i < nfds; ++i) {
      handle_connection_io(connections_[i - 1], poll_fds_[i]);
    }

    // 5. 清理已关闭连接(swap-and-pop)
    remove_closed_connections();
  }
}

几个细节:

  • poll_fds_std::array<pollfd, 65>,编译期固定,不需要每轮 new
  • connections_FixedVector<ConnPtr, 64>,栈上分配,swap-and-pop 移除
  • 过载保护:活跃连接超过 90% 容量时,accept 后立即 close,避免资源耗尽
  • 性能监控:原子计数器跟踪 poll 延迟、连接数、错误数

词汇类型: 从 newosp 移植

EWSS 的基础类型(expectedoptionalFixedVectorFixedStringFixedFunctionScopeGuard)来自 newosp 库,全部栈分配、零堆开销:

类型替代用途
expected<V, E>异常 / errno类型安全错误处理
FixedVector<T, N>std::vector连接列表 (N=64)
FixedFunction<Sig, Cap>std::functionSBO 回调
ScopeGuard手动 cleanupRAII 资源释放

这些类型兼容 -fno-exceptions -fno-rtti,适合嵌入式编译配置。

性能实测

测试环境:x86-64 Linux (虚拟化),GCC 13.3.0 -O2 Release,loopback TCP。EWSS 目标平台是 ARM-Linux 嵌入式,x86-64 结果作为基线参考。

单客户端吞吐量 (10,000 消息)

载荷大小吞吐量 (msg/s)P50 (us)P99 (us)
8 B27,34435.555.9
64 B27,44635.554.6
128 B26,83036.158.9
512 B25,46237.761.0
1024 B22,08442.573.8

小载荷(8-128B)吞吐量稳定在 ~27K msg/s,说明瓶颈在系统调用开销而非数据拷贝。1KB 载荷下降到 22K msg/s,符合预期。

多客户端吞吐量 (64B 载荷)

客户端数总吞吐量 (msg/s)P50 (us)P99 (us)
127,44635.554.6
466,73157.884.9
867,856102.6167.2

4 客户端时总吞吐量达到 ~67K msg/s,接近单线程 poll Reactor 的上限。8 客户端时吞吐量不再增长,P99 延迟上升到 167us,这是单线程模型的固有限制——所有连接共享一个事件循环。

资源占用

指标
二进制大小 (stripped)67 KB
库类型Header-only (单文件 ~1720 行)
每连接内存~12 KB (4KB RX + 8KB TX RingBuffer)
热路径堆分配0
最大连接数 (编译期)64

67KB 二进制 vs Simple-WebSocket-Server 的 ~2MB,差 30 倍。EWSS 是 header-only 单文件库(~1720 行),无需编译静态库。体积差距主要来自 ASIO 的模板实例化和异常处理代码。

架构维度对比

维度EWSSSimple-WebSocket-Server
I/O 模型poll() 单线程 ReactorASIO 多线程
内存模型固定 RingBuffer (12KB/conn)动态 std::string + shared_ptr
热路径分配每消息堆分配
帧编码栈缓冲 (14B max)std::ostream + shared_ptr<SendStream>
状态机StateOps 函数指针表 (零分配, 零 virtual)隐式 ASIO handler 链
Socket I/Oreadv/writev 零拷贝ASIO async_read/async_write
依赖sockpp (仅 TCP)Boost.ASIO 或 standalone ASIO
二进制大小 (stripped)67 KB~2 MB
TLS 支持可选 mbedTLSOpenSSL
目标平台ARM-Linux 嵌入式桌面/服务器
C++ 标准C++17C++11/14
异常处理可选 (-fno-exceptions)必须

EWSS 在单线程场景下的吞吐量(~27K msg/s 单客户端,~67K msg/s 多客户端)与 Simple-WebSocket-Server 处于同一量级,但资源占用差距显著:67KB vs 2MB 二进制,12KB vs 动态内存每连接,P50 35us / P99 55us 的确定性延迟 vs 受 GC 和堆分配影响的不确定延迟。

Simple-WebSocket-Server 的优势在多核扩展:4 线程池可以线性提升吞吐量,而 EWSS 的单线程模型在 4-8 客户端后就触及上限。这正是两者的设计定位差异——EWSS 优化的是资源受限场景下的确定性,而非吞吐量天花板。

设计权衡

EWSS 为嵌入式约束做了明确的取舍:

取舍EWSS 选择代价
最大连接数64 (编译期固定)不能动态扩展
线程模型单线程CPU 密集型任务会阻塞所有连接
缓冲区大小固定 4KB RX / 8KB TX大消息需要分片
poll vs epollpoll()POSIX 可移植,但 O(n) 扫描
内存模型全部预分配固定容量,不能按需增长

这些取舍在嵌入式场景下是合理的:64 连接足够覆盖调试面板、配置接口、数据推送等典型用途;单线程避免了锁竞争;固定内存消除了碎片化风险。

对于需要数千并发连接和多核扩展的桌面/服务器场景,Simple-WebSocket-Server(或类似 ASIO 方案)仍然是更好的选择。

Simple-WebSocket-Server 的优势

公平起见,列出 EWSS 做不到而 Simple-WebSocket-Server 能做的:

  • 多线程扩展:ASIO 线程池可利用多核
  • 动态缓冲区:处理任意大小的消息
  • 成熟生态:ASIO 集成、OpenSSL TLS
  • URL 路由:正则表达式端点路由
  • 客户端库:内置 WebSocket 客户端

测试覆盖

EWSS 目前有 7 个测试套件,119 个测试用例,307 个断言:

  • 单元测试:Base64、SHA1、帧解析、RingBuffer、连接状态机、对象池
  • 集成测试:13 个端到端测试(握手、echo、批量消息、二进制、Ping/Pong、关闭、统计、回调)
  • Sanitizer:ASan + UBSan 全部通过

集成测试使用原始 POSIX socket 实现的 WebSocket 客户端,覆盖了从 TCP 连接到 WebSocket 帧收发的完整链路,对标 Simple-WebSocket-Server 的 io_test.cpp

快速上手

git clone https://github.com/DeguiLiu/ewss.git
cd ewss
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j

最小 echo 服务器:

#include "ewss.hpp"

int main() {
  ewss::Server server(8080);

  ewss::TcpTuning tuning;
  tuning.tcp_nodelay = true;
  server.set_tcp_tuning(tuning);

  server.on_message = [](const auto& conn, std::string_view msg) {
    conn->send(msg);  // Echo back
  };

  server.run();
}

设计文档

完整的架构设计、数据流、状态机、回压控制、超时管理等详细设计,参见 EWSS 设计文档

适用场景

EWSS 适合这些场景:

  • 嵌入式 Linux 设备的 WebSocket 调试/配置接口
  • 资源受限环境(ARM Cortex-A,内存 < 64MB)
  • 对延迟确定性有要求,不能容忍堆分配毛刺
  • 连接数少(< 64),不需要多核扩展
  • 需要最小二进制体积(67KB vs 2MB)

如果你的场景是高并发服务器、需要 TLS、需要 URL 路由,Simple-WebSocket-Server 或其他 ASIO 方案更合适。

项目地址: github.com/DeguiLiu/ew…


本文介绍的 EWSS 库基于 MIT 协议开源。