
1. 项目概述为什么我们需要一个C轻量级RPC框架如果你正在用C开发分布式系统无论是游戏服务器、高频交易引擎还是物联网后台迟早会碰到一个核心问题如何让运行在不同机器、甚至不同进程里的服务像调用本地函数一样方便地相互通信这就是RPC远程过程调用要解决的事。市面上的RPC框架很多gRPC、Thrift功能强大但对于一些追求极致性能、低延迟或者资源受限比如嵌入式环境的场景它们可能显得“太重”了。依赖复杂、二进制包大、序列化/反序列化开销不可控这些都可能成为瓶颈。所以自己动手用C实现一个轻量级的RPC框架就从一个“痒点”变成了一个很有价值的“练手项目”和“生产级解决方案”。它不只是一个网络编程练习更是对多线程并发、网络I/O模型、协议设计、序列化、服务治理等后端核心技术的深度整合。通过这个项目你能透彻理解从“函数调用”到“网络报文”再到“函数调用”的完整闭环这是单纯使用框架很难获得的体验。这个框架的目标很明确核心功能完备、性能足够高、代码足够清晰、依赖足够少让它能轻松嵌入到你的现有项目中解决实际的分布式通信问题。2. 核心设计思路与架构拆解一个RPC框架无论轻量与否其核心工作流程都可以抽象为以下几个步骤1. 客户端调用一个本地存根Stub方法2. 存根将调用信息函数名、参数序列化成字节流3. 通过网络将字节流发送到服务端4. 服务端接收字节流并反序列化5. 服务端找到对应的实现函数并执行6. 将执行结果序列化后通过网络返回7. 客户端接收并反序列化结果返回给调用者。我们的轻量级设计就是要在保证这个流程可靠的前提下在每个环节做“减法”和“优化”。2.1 核心组件设计框架主要分为四大模块网络通信模块负责底层的连接建立、数据收发。这是性能的基石。协议编解码模块定义客户端与服务端交互的报文格式并负责序列化与反序列化。服务管理模块服务端用于注册服务方法客户端用于发现和调用服务。线程模型模块管理网络I/O和业务逻辑处理的线程决定并发模式。2.2 架构选型背后的考量为什么选择Reactor模式而非Proactor在Linux环境下Epoll是高性能网络编程的事实标准。我们采用Reactor模式主线程一个或少量只负责通过Epoll监听所有socket上的事件可读、可写当事件发生时它将对应的连接派发给工作线程池进行处理。这样设计的好处是逻辑清晰充分利用多核且避免为每个连接创建线程的资源消耗。虽然Windows的IOCPProactor模式在理论上更高效但为了跨平台简易性和在Linux上的广泛适用性Reactor是更稳妥的选择。我们的“轻量”也体现在这里——不试图封装所有平台的最高性能模型而是优先保证在主流服务器环境Linux下的简洁与高效。为什么自定义二进制协议而非直接使用HTTP/JSONHTTP/JSON的方案开发速度快但性能开销大。HTTP头部的解析、JSON的文本解析都是CPU密集型操作且报文体积庞大。对于追求低延迟和高吞吐的C服务自定义紧凑的二进制协议是必然选择。这能减少网络带宽占用加速序列化/反序列化过程。轻量不代表功能弱而是“没有冗余”。序列化方案选型为什么不用Protobuf或MessagePackProtobuf固然强大但引入它就意味着引入一个外部依赖增加了二进制体积和编译复杂度。对于“轻量级”框架我们更倾向于设计一个极简的、自包含的序列化方案。例如可以基于简单的“类型标识长度数据”的TLVType-Length-Value格式来打包基本类型和字符串。对于结构体可以要求用户提供简单的序列化/反序列化方法。这样做的核心思想是将序列化的复杂度转移给框架使用者以换取框架自身的极致简洁和零外部依赖。框架提供基础能力复杂结构由用户按需实现。3. 关键实现细节与核心技术点3.1 网络通信模块基于Epoll非阻塞IO这是框架的性能核心。我们实现一个TcpServer类和一个TcpClient类。TcpServer的工作流程创建监听socket设置为非阻塞模式绑定并监听端口。创建Epoll实例将监听socket的读事件注册进去。进入事件循环Event Loop。主线程调用epoll_wait等待事件。当监听socket可读时表示有新连接。调用accept接收连接并将新的客户端socket也设置为非阻塞模式然后将其读事件注册到Epoll中。当已连接的客户端socket可读时表示有数据到达。此时我们不直接在该线程中处理业务逻辑而是将读取到的完整数据包如何判断完整见协议部分封装成一个任务Task投递到一个全局的任务队列中。工作线程池中的线程从任务队列中取出任务进行反序列化和业务处理生成响应数据。工作线程处理完毕后需要将响应数据发回给客户端。这里有一个设计关键点由哪个线程执行写操作一个常见的做法是工作线程将响应数据放入对应连接的发送缓冲区然后通过某种方式如Eventfd或管道通知主循环线程该socket可写再由主循环线程执行实际的send操作。这样可以避免多线程同时写同一个socket的复杂性。注意这里有一个经典的“惊群”问题但仅在多线程同时调用epoll_wait监听同一个Epoll实例时才会发生。我们的设计是单一线程或固定少数几个线程运行Event Loop工作线程不接触Epoll所以自然避免了此问题。TcpClient的实现相对简单它是一个连接池的管理者。当需要发起RPC调用时从连接池中获取一个到服务端的空闲连接或新建通过该连接发送序列化后的请求数据然后同步或异步地等待响应。3.2 协议设计自定义二进制协议格式一个健壮的协议需要解决数据包边界问题。我们采用常见的“长度头”法。每个RPC消息的协议格式设计如下[ 4字节魔数 (0xCAFEBABE) | 4字节总长度 (N) | 2字节消息类型 | 2字节序列号 | 4字节服务名长度 (M) | M字节服务名 | 4字节方法名长度 (K) | K字节方法名 | N-头部长度 字节的序列化参数数据 ]魔数用于快速校验这是一个合法的协议包也可以在通信开始时做简单的协议校验。总长度指从“魔数”开始到整个消息结束的字节数。接收方先读取4字节得到N然后就知道还需要读取多少字节才能得到一个完整的数据包。这解决了TCP流式传输的粘包/拆包问题。消息类型区分是请求Request、响应Response、心跳Ping/Pong等。序列号用于匹配请求和响应尤其在异步调用时至关重要。服务名和方法名用于在服务端定位要调用的具体函数。序列化参数数据即通过我们自研的序列化方法打包的函数参数。序列化实现示例我们提供一个Serializer类它内部维护一个std::vectorchar缓冲区。class Serializer { public: void writeInt32(int32_t value) { size_t oldSize buffer_.size(); buffer_.resize(oldSize sizeof(value)); std::memcpy(buffer_.data() oldSize, value, sizeof(value)); // 可考虑转换为网络字节序htonl } void writeString(const std::string str) { writeInt32(static_castint32_t(str.size())); size_t oldSize buffer_.size(); buffer_.resize(oldSize str.size()); std::memcpy(buffer_.data() oldSize, str.data(), str.size()); } // ... 其他基本类型的write方法 const char* data() const { return buffer_.data(); } size_t size() const { return buffer_.size(); } private: std::vectorchar buffer_; };对应的需要一个Deserializer类从一个字节数组里按相同顺序读出数据。3.3 服务注册与调用核心的Stub与Skeleton这是RPC的“魔法”发生地。服务端Skeleton我们需要一个全局的ServiceRegistry服务注册表。服务提供者将自己的服务类和方法注册进去。class CalculatorService { public: int add(int a, int b) { return a b; } }; // 注册过程通常通过宏来简化这里展示原理 ServiceRegistry::instance().registerMethod( CalculatorService, // 服务名 add, // 方法名 [](Deserializer in, Serializer out) - void { // 1. 从in中反序列化参数 a, b int a in.readInt32(); int b in.readInt32(); // 2. 创建服务实例并调用真实方法 CalculatorService svc; int result svc.add(a, b); // 3. 将结果序列化到out out.writeInt32(result); } );当网络层收到一个完整请求包解析出服务名、方法名和参数数据后就调用ServiceRegistry找到对应的lambda函数执行。这个lambda就是方法的“骨架”Skeleton它完成了反序列化参数、调用真实函数、序列化结果的全过程。客户端Stub客户端需要生成一个代理类这个代理类的方法实现就是进行网络调用。// 通过工具或手动生成的Stub类 class CalculatorServiceStub { public: int add(int a, int b) { // 1. 创建连接从连接池获取 TcpConnection* conn connectionPool_-getConnection(); // 2. 构造请求并序列化 Serializer req; req.writeString(CalculatorService); req.writeString(add); req.writeInt32(a); req.writeInt32(b); // 3. 发送请求并同步等待响应这里以同步调用为例 Serializer resp conn-sendRequest(req); // 4. 反序列化响应结果 Deserializer de(resp.data(), resp.size()); return de.readInt32(); } private: ConnectionPool* connectionPool_; };对于用户来说他只需要CalculatorServiceStub stub; int sum stub.add(1, 2);就像调用本地函数一样。3.4 线程模型与并发安全我们采用的是“One Loop Thread Pool”模型。主循环线程1个或N个负责所有连接的I/O事件读/写。它不做任何耗时的业务处理只负责数据的接收和派发以及响应数据的发送。这保证了网络I/O的高响应性。业务线程池M个负责处理具体的RPC请求逻辑。线程池的大小可以根据CPU核心数和任务类型I/O密集型或CPU密集型进行配置。并发安全要点连接对象生命周期一个连接可能同时被主循环线程读事件触发和工作线程处理完任务后写回响应操作。必须对每个连接的发送缓冲区进行加锁如使用std::mutex或者使用无锁队列进行通信。更优雅的做法是每个连接绑定一个专属的“发送队列”工作线程将响应push进队列主循环线程从这个队列中取数据发送。服务注册表ServiceRegistry在服务启动时注册之后基本是只读的因此不需要加锁。如果支持动态注册/注销则需要读写锁如std::shared_mutex。任务队列连接主循环和线程池的任务队列必须是线程安全的。可以使用std::queue配合std::mutex和std::condition_variable或者直接使用moodycamel::ConcurrentQueue这样的高性能无锁队列。4. 完整实现流程与核心代码剖析让我们以一个简单的“计算器服务”为例串联起整个实现流程。4.1 第一步定义协议头与基础类首先定义协议常量与基础数据结构。// protocol.h #pragma once #include cstdint namespace lightrpc { const uint32_t MAGIC_NUMBER 0xCAFEBABE; enum MessageType : uint16_t { REQUEST 0x01, RESPONSE 0x02, HEARTBEAT 0x03 }; struct ProtocolHeader { uint32_t magic; uint32_t total_len; uint16_t msg_type; uint16_t seq_id; // 紧随其后的是变长的服务名和方法名 }; }接着实现序列化器。// serializer.h #include vector #include string #include cstring class Serializer { public: Serializer() default; void writeInt32(int32_t v) { uint32_t net_v htonl(static_castuint32_t(v)); append(net_v, sizeof(net_v)); } void writeString(const std::string s) { writeInt32(static_castint32_t(s.size())); append(s.data(), s.size()); } void append(const void* data, size_t len) { const char* src static_castconst char*(data); buffer_.insert(buffer_.end(), src, src len); } const char* data() const { return buffer_.data(); } size_t size() const { return buffer_.size(); } void clear() { buffer_.clear(); } private: std::vectorchar buffer_; }; // Deserializer 类似需要维护一个读取指针4.2 第二步实现网络层以服务端为例TcpServer的核心是Event Loop。// tcp_server.h class TcpServer { public: TcpServer(EventLoop* loop, const InetAddress listenAddr); void start(); void onConnection(const TcpConnectionPtr conn); void onMessage(const TcpConnectionPtr conn, Buffer* buf); private: EventLoop* loop_; // 主循环 std::unique_ptrAcceptor acceptor_; // 用于接受新连接 std::mapint, TcpConnectionPtr connections_; // 所有连接 };EventLoop的核心是epoll_wait循环。// event_loop.cpp (简化) void EventLoop::loop() { while (!quit_) { int numEvents ::epoll_wait(epollfd_, events_, MAX_EVENTS, -1); for (int i 0; i numEvents; i) { int fd events_[i].data.fd; if (fd listenFd_) { handleAccept(); // 处理新连接 } else { uint32_t events events_[i].events; auto conn connections_[fd]; if (events EPOLLIN) { conn-handleRead(); // 读事件读取数据 } if (events EPOLLOUT) { conn-handleWrite(); // 写事件发送数据 } } } // 处理其他任务如执行回调函数 doPendingTasks(); } }在TcpConnection::handleRead()中我们需要解析协议。void TcpConnection::handleRead() { // 从socket读到inputBuffer_中 ssize_t n ::read(fd_, inputBuffer_.peek(), inputBuffer_.writableBytes()); // ... 错误处理 inputBuffer_.hasWritten(n); // 解析协议 while (inputBuffer_.readableBytes() sizeof(ProtocolHeader)) { const ProtocolHeader* header reinterpret_castconst ProtocolHeader*(inputBuffer_.peek()); if (header-magic ! MAGIC_NUMBER) { // 非法包关闭连接 close(); return; } if (inputBuffer_.readableBytes() header-total_len) { // 数据还不够一个完整包等待下次读取 break; } // 收到一个完整包 std::string packet(inputBuffer_.peek(), header-total_len); inputBuffer_.retrieve(header-total_len); // 从缓冲区移除已处理数据 // 将packet封装成任务投递到线程池的任务队列 threadPool_-enqueue([this, packet]() { processPacket(packet); }); } }4.3 第三步实现服务注册与调用服务注册表是一个单例。// service_registry.h class ServiceRegistry { public: using ServiceMethod std::functionvoid(Deserializer, Serializer); static ServiceRegistry instance() { static ServiceRegistry reg; return reg; } void registerMethod(const std::string service, const std::string method, ServiceMethod func) { std::string key service . method; methods_[key] std::move(func); } ServiceMethod* findMethod(const std::string service, const std::string method) { std::string key service . method; auto it methods_.find(key); return it ! methods_.end() ? (it-second) : nullptr; } private: std::unordered_mapstd::string, ServiceMethod methods_; };工作线程中的processPacket函数void TcpConnection::processPacket(const std::string packet) { Deserializer in(packet.data(), packet.size()); ProtocolHeader header; in.readBytes(header, sizeof(header)); // 假设Deserializer有readBytes方法 // 读取服务名和方法名 std::string service_name in.readString(); std::string method_name in.readString(); // 查找方法 auto* method ServiceRegistry::instance().findMethod(service_name, method_name); if (!method) { // 构造错误响应 sendErrorResponse(header.seq_id, Method not found); return; } // 执行方法 Serializer out; (*method)(in, out); // 这里会调用注册的lambda执行真正的业务逻辑并写入结果到out // 构造响应协议头并发送 sendResponse(header.seq_id, out); }4.4 第四步客户端Stub生成与调用客户端Stub可以通过宏来简化生成这里展示手动编写。// calculator_service_stub.h class CalculatorServiceStub { public: CalculatorServiceStub(const InetAddress serverAddr) : client_(serverAddr) {} int add(int a, int b) { // 1. 构造请求 Serializer req; ProtocolHeader header; header.magic MAGIC_NUMBER; header.msg_type REQUEST; header.seq_id generateSeqId(); // 生成序列号 // 先序列化body部分以便计算总长度 Serializer body; body.writeString(CalculatorService); body.writeString(add); body.writeInt32(a); body.writeInt32(b); header.total_len sizeof(header) body.size(); // 组装完整请求 req.writeBytes(header, sizeof(header)); req.append(body.data(), body.size()); // 2. 同步发送并接收响应 Serializer resp client_.sendSync(req); // 3. 解析响应 Deserializer de(resp.data(), resp.size()); ProtocolHeader resp_header; de.readBytes(resp_header, sizeof(resp_header)); if (resp_header.msg_type ! RESPONSE) { throw std::runtime_error(Invalid response type); } // 可以检查seq_id是否匹配 int result de.readInt32(); return result; } private: TcpClient client_; };5. 常见问题、性能调优与避坑指南在实际开发和测试中你会遇到各种各样的问题。以下是一些典型场景和解决方案。5.1 粘包与拆包处理不当这是网络编程新手最容易出错的地方。我们的协议通过“总长度”字段完美解决了这个问题。关键在于在handleRead中必须等到缓冲区中的数据大于等于header.total_len时才取出一个完整的包进行处理。绝对不能假设一次read就能读到一个完整包或者一个read只包含一个包。5.2 序列化/反序列化性能瓶颈自定义的简单序列化在复杂结构如嵌套vector的map上会变得繁琐且易错。一个重要的取舍是是否引入代码生成工具你可以设计一个类似于Protobuf的.proto文件格式然后编写一个代码生成器自动生成C的序列化/反序列化代码以及客户端Stub和服务端Skeleton。这虽然增加了框架的复杂度但极大地提升了开发体验和类型安全。对于“轻量级”框架可以将其作为可选插件。5.3 线程池任务堆积导致延迟如果工作线程处理速度跟不上请求到达的速度任务队列会越来越长导致请求延迟增高。监控任务队列长度是关键。可以在ThreadPool中暴露一个getPendingTaskCount()方法。当队列长度超过阈值时可以触发告警或者动态调整线程池大小如果实现的话。另一种思路是给不同的服务方法设置不同的优先级队列。5.4 连接管理与心跳机制对于长时间空闲的连接需要有心跳机制来检测对方是否存活。可以在协议中定义HEARTBEAT消息类型。客户端定时如每30秒发送一个心跳包服务端收到后立即回复。如果服务端在多个心跳周期内未收到任何数据包括心跳可以主动断开连接释放资源。同样客户端如果长时间未收到服务端对心跳的回复也应认为连接已失效进行重连。5.5 异步调用与超时控制上面的示例是同步调用会阻塞客户端线程直到收到响应。在生产环境中异步调用更为常见。我们需要实现一个Future/Promise模式。客户端发送请求后立即返回一个Futureint对象。框架内部维护一个std::unordered_mapuint16_t, std::promiseSerializer键是序列号seq_id。当收到响应时根据响应中的seq_id找到对应的promise并设置值set_value这样等待在该future上的线程就会被唤醒并获得结果。同时必须为每个请求设置超时防止因为服务端挂掉或网络问题导致客户端的future永远等待。可以使用一个定时器轮询检查未完成的请求超时则设置异常set_exception。5.6 内存管理优化频繁的序列化/反序列化会创建大量临时Serializer和Deserializer对象以及字符串拷贝。可以考虑使用对象池Object Pool来复用这些对象。例如维护一个Serializer对象的池子每次需要时从池中取用完后归还并清空内容而不是反复new/delete。5.7 服务端压测与性能指标使用wrk或ab等工具对服务端进行压测。关注以下指标QPS每秒查询率在可接受的延迟下系统能处理的最大请求数。平均/尾部延迟P99 P999特别是对于金融、游戏场景尾部延迟非常重要。CPU和内存使用率检查是否有不必要的拷贝或锁竞争导致CPU空转。性能调优常见方向锁竞争检查任务队列、连接发送缓冲区的锁。尝试用无锁数据结构替代。系统调用减少read/write的次数。使用writev进行聚合写或设置TCP_NODELAY选项禁用Nagle算法在小包即时性要求高的场景。内存分配使用tcmalloc或jemalloc替代默认的malloc它们对多线程场景下的内存分配有优化。序列化这是最可能的热点。使用perf工具采样看CPU时间是否大量消耗在序列化代码上。如果是考虑更高效的序列化方案如flatbuffer或优化现有代码。实现一个轻量级RPC框架是一次对分布式系统底层通信原理的深刻之旅。从协议设计到网络I/O从线程模型到序列化每一个环节的决策都直接影响着框架的性能和可用性。这个项目没有银弹你需要根据自己团队的技术栈和业务场景是更在意开发效率还是运行时性能是内部微服务调用还是对公网开放来做出权衡。但无论如何亲手实现一遍之后你再去看那些成熟的RPC框架会有一种“一览众山小”的通透感。