手写网络计算器:自定义套接字、序列化与守护进程实战
发布时间:2026/9/28 22:32:48来源:尧图网络
有一段时间我在内网批量跑同一组数值计算每台机器装完整运行环境太不值当就想着把计算逻辑收敛成一个常驻服务客户端只负责发请求和收结果。这个小需求后来演变成了一个网络计算器项目把自定义套接字、序列化、守护进程三个点完整串了一遍也让我把很多课本上似是而非的概念落到了实处。这篇内容适合三类人刚学完 Socket API 想找一个完整项目练手的、工作中需要自己定义二进制协议做服务间通信的、以及一直没搞懂守护进程到底是怎么脱离终端的人。我会从需求拆解讲起给出协议设计、序列化代码、socket 链路、daemon 化实现最后放上完整的验证过程和踩坑记录。所有关键选择我都会把理由说透而不是只贴一段能跑的代码。1. 为什么要做一个网络计算器三个技术点缺一不可1.1 从本地函数调用到远程请求难点换了位置本地计算器在代码里就是个函数调用参数通过栈寄存器传进去返回值放寄存器里程序内部天然共享内存不存在数据怎么表达的问题。一旦变成网络计算器客户端和服务端是两个独立进程中间只有一条字节流通道难点立刻从算法转移到三件事上数据表示double、int 这些值在内存里怎么排列换一台机器还能不能解析错误传达除零、非法操作符这类错误怎么从服务端回到客户端而不是只能靠打印日志连接生命周期客户端请求到一半断开、服务端同时接多个连接时各自怎么收场。我当时把这三件事分别对应成了协议设计、错误码约定、并发模型一旦拆开想项目结构就很清楚了。这也是我坚持不用现成框架的最主要原因框架会把这些问题藏起来而藏起来的问题最终会在性能和可维护性上还债。1.2 项目拆解协议、服务端、客户端三件套我按照关注点把代码拆成了三块目录结构大致如下calc/ ├── protocol.h # 协议头结构体、序列化接口声明 ├── protocol.c # 序列化 / 反序列化实现 ├── server.c # 套接字监听、连接处理、计算逻辑 ├── client.c # 客户端套接字、请求发送与结果解析 ├── calc_daemon.c # 守护进程化入口 └── Makefile协议单独放一个文件就是为了以后传输层换掉比如换 UNIX Domain Socket或换 HTTP 包装序列化和业务代码都不用动。服务端只关心如何接收协议、如何解析、如何回包客户端只关心如何组装协议、如何解释响应。这套解耦思路就算不用 C 实现换 Python、Go 也一样成立。1.3 为什么不用现成 RPC 框架非要自己写套接字当时完全可以搭一个 Nginx 后端或者用 gRPC但需求本身就是一个计算接口引入整套 RPC 框架的收益远小于成本。自己写自定义套接字的价值在于协议里每个字节都是自己定通信过程中的粘包、半包、字节序、连接中断这些问题都会真实地暴露出来而不是被框架藏起来。尤其在一些资源受限或者内网极简环境里一个裸 socket 服务能省掉 HTTP 头部解析的开销知识和收益都实在。还有一层原因今天看 gRPC、Thrift 这些成熟方案底层核心其实都是四件事——传输通道、序列化格式、消息调度、连接管理。把自定义套接字这一层亲手打通再去看那些框架源码就不会一头雾水所以我强烈建议这个项目要自己写不要一上来就套库。2. 序列化协议把12变成字节流2.1 内存里的结构体不能直接扔进网络很多人第一步会想直接把 C 结构体发出去不就行了还真不行。结构体在内存里有字节对齐比如一个结构体里同时有 char 和 double编译器会在中间插入 padding 字节不同平台、不同编译选项下的布局都可能不同。加上还有大小端字节序的问题同样一个 0x12345678x86 小端机器和 ARM 大端机器在内存里的排列完全相反。直接把结构体指针交给 send()等于让通讯双方赌运气。序列化要做的事就是把内存中的结构化数据按一种双方都认可的规则变成一长串可靠的字节流接收端再按同一套规则还原。打个比方序列化是打包快递你按固定尺寸装箱并写好物品清单反序列化是收快递的人按清单拆箱验收中间任何一步少填或多填都会出问题。2.2 协议格式头部和负载分开设计我给这个网络计算器定了一个很小的二进制协议头部固定 12 字节字段长度字节说明magic4固定魔数用于校验传输对象防止错收垃圾数据version1协议版本方便未来演进cmd10x01 请求0x02 响应error1错误码0 成功非 0 表示失败原因reserved1保留字段置 0payload_len4负载长度网络字节序负载部分根据 cmd 分两种。请求负载字段长度字节说明x8double第一个操作数y8double第二个操作数op10加1减2乘3除reserved3对齐用保留位于是请求包总长 12 头 20 负载 32 字节。响应负载是 8 字节的 double 结果总长 20 字节。这样设计的好处是按长度读协议头再用头里的 payload_len 决定还要读多少负载天然解决粘包问题后面会展开说。2.3 手写序列化的核心代码我用的 C协议结构体如下typedef struct { uint32_t magic; uint8_t version; uint8_t cmd; uint8_t error; uint8_t reserved; uint32_t payload_len; } calc_header; typedef struct { double x; double y; uint8_t op; uint8_t reserved[3]; } calc_request; typedef struct { double result; } calc_response;序列化函数不再直接 memcpy 整个结构体而是逐个字段写入。整数用 htonl 转网络字节序浮点数我先把内存复制成 64 位整数再转成大端写进缓冲。这些 endian 转换函数在 Linux glibc 的endian.h里都有macOS 上要换成NSSwapBigLongLongToHost那一套static void write_double(uint8_t **p, double value) { uint64_t v; memcpy(v, value, sizeof(v)); v htobe64(v); memcpy(*p, v, sizeof(v)); *p sizeof(v); } static double read_double(const uint8_t **p) { uint64_t v; memcpy(v, *p, sizeof(v)); v be64toh(v); double d; memcpy(d, v, sizeof(d)); *p sizeof(v); return d; }序列化请求时先写头部再写负载int serialize_request(uint8_t *buf, size_t buf_size, const calc_request *req) { size_t need sizeof(calc_header) 20; if (buf_size need) return -1; uint8_t *p buf; calc_header hdr { .magic htonl(0xCAFE1234), .version CALC_VERSION, .cmd CMD_REQUEST, .error 0, .reserved 0, .payload_len htonl(20), }; memcpy(p, hdr, sizeof(hdr)); p sizeof(hdr); write_double(p, req-x); write_double(p, req-y); *p req-op; *p 0; *p 0; *p 0; return (int)(p - buf); }反序列化时最要紧的是先校验int deserialize_request(const uint8_t *buf, size_t len, calc_request *out) { if (len sizeof(calc_header)) return -1; const uint8_t *p buf; calc_header hdr; memcpy(hdr, p, sizeof(hdr)); uint32_t magic ntohl(hdr.magic); if (magic ! 0xCAFE1234) return -1; if (hdr.version ! CALC_VERSION) return -1; if (hdr.cmd ! CMD_REQUEST) return -1; uint32_t plen ntohl(hdr.payload_len); if (plen ! 20 || len sizeof(calc_header) plen) return -1; p sizeof(calc_header); out-x read_double(p); out-y read_double(p); out-op *p; return 0; }代码里两次 memcpy 绕开了浮点数类型双关的未定义行为看起来多复制了几次但十六字节的数据量对 CPU 来说完全可以忽略换来的是跨平台安全。2.4 为什么不直接选 JSON 或现成序列化库我也想过直接用 JSON请求体写成{x:1,y:2,op:0}服务端用 json-c 解析。后来发现三个问题第一JSON 是文本协议double 转字符串再转回 double中间有精度损耗风险有些浮点值打印出来就是不定长小数第二每次都要完整解析 JSON 树在计算服务这种高频小请求场景解析开销占比太大第三JSON 没有天然的二进制长度限制防御边界要自己加反而更麻烦。至于 Redis 序列化方案或 Java/PHP 那套自带序列化机制它们在其他场景当然顺手但基本都是语言相关 自带元信息对固定结构的计算请求来说太重了。像 Protocol Buffers 这种优秀方案各方面都成熟但引入编译器工具链和额外运行时对一个想要吃透底层原理的教学项目来说反而成了负担。手写 32 字节的小协议每个字段都是自己定的未来想加校验和、加密、压缩都能在一行代码里看出来改在哪。3. 套接字链路从 socket() 到 recv() 的完整实现3.1 服务端主循环bind、listen、accept服务端流程是教科书式的但每个环节都有细节。第一个是SO_REUSEADDR如果服务刚被 kill 掉端口还在 TIME_WAIT 状态不设置这个选项 bind 会直接报 Address already in use第二个是 listen 的 backlog 参数内核会在 accept 之前帮你暂存建立好的连接这个值太小突发连接数上来会丢连接。我在主进程里只做 accept每接到一个连接就用 fork 开一个子进程去处理主进程继续等待新连接int srv_fd socket(AF_INET, SOCK_STREAM, 0); int opt 1; setsockopt(srv_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); struct sockaddr_in addr {0}; addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(port); bind(srv_fd, (struct sockaddr*)addr, sizeof(addr)); listen(srv_fd, 16); while (1) { struct sockaddr_in cli_addr; socklen_t cli_len sizeof(cli_addr); int conn_fd accept(srv_fd, (struct sockaddr*)cli_addr, cli_len); if (conn_fd 0) continue; pid_t pid fork(); if (pid 0) { close(srv_fd); handle_connection(conn_fd); close(conn_fd); exit(0); } close(conn_fd); signal(SIGCHLD, SIG_IGN); }signal(SIGCHLD, SIG_IGN)是让系统直接回收子进程资源否则发起几十次请求后进程表里会堆积一堆僵尸进程到时候ps看起来会非常诡异。3.2 客户端请求路径connect、send、recv客户端更简单但同样有细节。connect 要等三次握手完成才能继续如果目标服务没起来connect 会返回ECONNREFUSED。我遇到的坑是服务器启动慢客户端立刻连接会失败重试最好带个退避间隔别死循环打日志。客户端发送序列化好的请求时不能指望一次 send 就把全部字节发出去。send 返回值表示实际写入内核发送缓冲的字节数大包或 TCP 窗口繁忙时可能只发一半。所以我封装了一个循环发送int send_all(int fd, const void *buf, size_t len) { const uint8_t *p buf; size_t sent 0; while (sent len) { ssize_t n send(fd, p sent, len - sent, 0); if (n 0) { if (errno EINTR) continue; return -1; } sent n; } return 0; }同样读响应时也要处理半包不能假设一次 recv 就把整个响应拿回来。3.3 粘包、半包靠协议头里的 payload_len 解决这是整个项目最有教学价值的部分。TCP 是字节流协议不保留应用层的消息边界。客户端连续发两个 32 字节请求服务端 recv 一次可能收到 64 字节反过来一个 32 字节请求也可能被拆成 16 16 两次到达。所以代码里永远不能假设一次 recv 等于一条消息。正确做法是先把 12 字节协议头读完整解析出 payload_len再按这个长度循环读取剩余负载。我把读取封装成了 recv_fullint recv_full(int fd, uint8_t *buf, size_t len) { size_t got 0; while (got len) { ssize_t n recv(fd, buf got, len - got, 0); if (n 0) return -1; // 对端关闭 if (n 0) { if (errno EINTR) continue; return -1; } got n; } return 0; }处理连接时先把头部整个收下来再从头部里拿 payload_lenuint8_t hdr_buf[12]; if (recv_full(conn_fd, hdr_buf, sizeof(hdr_buf)) 0) return; calc_header hdr; memcpy(hdr, hdr_buf, sizeof(hdr)); uint32_t plen ntohl(hdr.payload_len); if (plen MAX_PAYLOAD_LEN) { /* 回错误包并关闭 */ } uint8_t payload[MAX_PAYLOAD_LEN]; if (recv_full(conn_fd, payload, plen) 0) return;如果说 send_all 是照顾发送端recv_full 就是照顾接收端两头都对齐之后粘包和半包问题就不复存在了。3.4 并发模型的选择进程、线程还是 epoll这个项目我选了 fork 进程模型一个重要原因是逻辑最简单子进程里就算把请求处理崩了主进程和其他连接都不受影响。每个连接的处理流程是固定的接收请求、解析、计算、回包、关闭。但进程模型不是万能的。每次 fork 都要复制进程表项连接数量上百后开销明显。如果进一步扩展我会改成单线程 epoll 事件循环把每个连接做成一个状态机EPOLLIN 就继续收包收完整条消息再发响应。也可以用线程池但对计算量几乎为零的加法和减法来说线程池的意义不在于加速而在于复用一个线程处理多个连接。这个选择取决于你能容忍的复杂度如果只是学习自定义套接字fork 模型和 epoll 模型各跑一遍是最好的对比实验。4. 守护进程化让计算服务不再依赖终端4.1 为什么 nohup 也不行必须真正 daemon 化写完 server直接./calc_server能跑但 SSH 一断开会话结束shell 向进程组发 SIGHUP服务就没了。这时候有人会想到nohup ./calc_server nohup 只是让进程忽略 SIGHUP可它仍然属于当前会话如果有其他信号或需要系统服务管理还是会出现各种奇怪关联。要么写 systemd unit要么在程序内部做标准 daemon 化。既然标题是守护进程我自然选择后者自己把整条链敲一遍。4.2 daemon 化五步每一步都有防御目的我封了一个 daemonize 函数核心步骤如下void daemonize(void) { pid_t pid fork(); if (pid 0) exit(1); if (pid 0) _exit(0); // 第一步父进程退出 if (setsid() 0) exit(1); // 第二步创建新会话 pid fork(); if (pid 0) exit(1); if (pid 0) _exit(0); // 第三步二次 fork chdir(/); // 第四步工作目录变成根目录 umask(0); // 放开文件创建掩码 int fd open(/dev/null, O_RDWR); // 第五步重定向标准三流 if (fd 0) { dup2(fd, 0); dup2(fd, 1); int logfd open(/var/log/calcd.log, O_WRONLY | O_CREAT | O_APPEND, 0644); if (logfd 0) dup2(logfd, 2); } }我来解释每一步的意义第一次 fork 后让父进程退出是让终端认为命令已经结束服务进程被 1 号进程收养不再受当前 shell 的作业控制。setsid 会创建一个新会话调用进程变成会话首进程从而脱离原来进程组的控制终端。第二次 fork 不是必须的但非常重要会话首进程一旦重新打开终端设备是有可能重新获得控制终端的而二次 fork 后的子进程不是会话首进程彻底杜绝了这种可能。chdir(/) 是为了进程不占用原工作目录避免文件系统的加载/卸载被这个进程挡住。umask(0) 是为了让后续创建日志、pid 文件时不被默认掩码裁剪权限避免明明设置了 0644 却变成 0600这种怪事。重定向标准三流是为了让程序内的 printf/日志函数不会往已经失效的终端写。4.3 pid 文件和日志运维不能靠猜daemon 进程没有终端想停掉它不能靠 CtrlC只能靠 kill 和 pid。我在启动参数里增加了一个-P /var/run/calc.pid写入 pid 的位置。启动时先读取旧 pid再用kill(pid, 0)探测进程是否存在存在就直接拒绝启动防止两个实例抢同一端口。日志方面很多初学者把 printf 留在代码里结果 daemon 跑起来后什么都没看到。我建议程序统一走日志函数比如log_msg(level, fmt, ...)写到日志文件里。日志格式至少带时间戳和连接来源排错的时候会舒服很多。4.4 守护进程的常见坑位记录坑一进程被 kill 之后 pid 文件还留在那里。正确的做法是在信号处理函数里做清理我在 main 里注册了 SIGTERM 和 SIGINTvoid signal_handler(int sig) { unlink(pid_file); _exit(0); }坑二daemon 服务和多进程写同一份日志时可能出现内容交错。O_APPEND 能保证单次 write 原子追加但如果一条日志要两次 write 才能写完中间就可能被别的进程插入。这时候要么每次日志组装成单次 write要么加个简单的互斥锁。坑三父进程_exit(0)而不是exit(0)。因为 exit 会刷新 stdio 缓冲区而 fork 时缓冲区可能被复制了一份父进程刷新会导致子进程的缓冲数据被清掉或重复输出。用_exit直接退出内核层面不做用户态缓冲区清理更干净。坑四排查时想在前台跑一遍观察输出最好给程序加一个-f参数表示 foreground默认才是 daemon 化。我在主函数里就是通过-f控制是否调用 daemonize否则每次测试都要翻日志效率很低。5. 端到端验证与调试复盘5.1 完整的测试链路编译没什么好说的Makefile 写好make一把过。启动服务./calc_server -d -p 12345 -P /var/run/calc.pid参数含义是-d 表示 daemon 模式-p 指定监听端口-P 指定 pid 路径。然后看日志确认 bind 成功tail -f /var/log/calcd.log # 输出listening on port 12345, pid1234客户端测试./calc_client 127.0.0.1 12345 3.5 2.5 0 # result 6.000000 ./calc_client 127.0.0.1 12345 10 0 3 # error: division by zero ./calc_client 127.0.0.1 12345 1 2 9 # error: unsupported operator注意这里是本机测的客户端连的是回环地址 127.0.0.1数据没有真正出网卡。要测网络链路最好再找一台同内网的机器把 IP 换成服务端局域网地址同时看防火墙是否把 12345 挡了。5.2 调试工具与复盘三个真实案例案例一客户端连上后立刻关闭。服务端 recv 返回 0handle_connection 里做了判断并正常退出日志记了一条 connection closed by peer。这说明对端关闭这个分支处理对了。案例二服务端收到请求后回包但客户端说包长度不够。我拿抓包工具看负载只有 19 字节排查发现协议定义里请求负载是 20 字节但序列化函数写 payload_len 固定写 20实际写入负载时因为字段长度计算错了一位少写 1 字节。问题出在序列化函数和协议定义没有完全对齐后来把序列化函数改为按字段逐个写才彻底解决。案例三压测时连发 1000 个同步请求出现请求处理错乱。日志显示同一连接处理了多条请求这正是前面讲的 TCP 粘包一次 recv 就拿回好几个完整请求。后来加上按 payload_len 循环接收并在处理完一条后检查缓冲区是否还有剩余字节。这个排查过程让我真正明白了序列化只解决怎么编码配合协议长度边界才能解决怎么断句。5.3 协议防御网络计算器也要有输入边界自定义协议最怕的是信任一切输入。我建议至少做四层防线第一层magic 校验。垃圾数据或串数据进入协议解析前就直接丢弃。第二层长度校验。payload_len 超过预设上限直接回错误码并关闭连接防止恶意超长包把缓冲区撑爆。第三层类型和操作符枚举校验。op 如果不是 0 到 3 的合法值就不能进入 switch。第四层浮点数运算结果检查。x、y 里如果出现 NaN 或无穷大运算结果同样要拦截。之所以反复强调这几点是因为序列化本质上是把外部输入映射回内存数据结构这个入口一旦放松后面执行什么逻辑都是不可控的。历史上很多反序列化相关的安全事件归根到底都是反序列化入口对输入内容、长度、类型缺乏严格校验这一件事。不是只有大型分布式系统才需要认真做协议校验一个小型网络计算服务同样要守住输入边界这不算过度设计。6. 重写时我肯定会换掉的几处设计这个项目做完之后我复盘时列了几个想改的方向也当是给后面做同类项目的人一个参考。协议层面我会把现在的固定头部改成更通用的 TLV 结构再加上一个 checksum 字段。现在的校验只有 magic 和长度遇到数据在传输过程中被破坏的情况概率低但不是零毫无感知。加 checksum 之后接收端可以先算校验再解析坏包直接丢弃比到时候结果算出来不对再怀疑人生要舒服得多。传输层面epoll 非阻塞 IO 是必改的方向。fork 模型在连接数上来之后的成本和复杂度都有上限改成事件循环之后才能撑住更大的并发量。我会保持协议的接口不变只替换传输层的 accept/recv/send 包装正好能验证当时把协议独立成文件的决定。加密方面如果服务要跨公网跑必须在 socket 之上加 TLS 层。TLS 对协议本身是透明的把 read/write 包装一层就行但要注意握手阶段的延迟和证书管理这已经不是单纯网络编程的范畴了。最后如果真要把这套东西搬进生产环境我会直接换成 Protocol Buffers 或 FlatBuffers。手写协议的优点是底层透明缺点是自己维护校验、字段扩展、版本兼容的工作量不小。但从零手写一遍的价值恰好在这里以后看到框架文档里那些 schema、IDL、TAG 概念全都能对号入座知道自己正在解决什么问题。
网站建设高端定制企业官网