☰
手写网络计算器:自定义套接字、序列化与守护进程实战
2026/9/28 22:32:47 网站建设 项目流程

有一段时间,我在内网批量跑同一组数值计算,每台机器装完整运行环境太不值当,就想着把计算逻辑收敛成一个常驻服务,客户端只负责发请求和收结果。这个小需求后来演变成了一个网络计算器项目,把自定义套接字、序列化、守护进程三个点完整串了一遍,也让我把很多课本上似是而非的概念落到了实处。这篇内容适合三类人:刚学完 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. 序列化协议:把"1+2"变成字节流

2.1 内存里的结构体不能直接扔进网络

很多人第一步会想:直接把 C 结构体发出去不就行了?还真不行。结构体在内存里有字节对齐,比如一个结构体里同时有 char 和 double,编译器会在中间插入 padding 字节,不同平台、不同编译选项下的布局都可能不同。加上还有大小端字节序的问题,同样一个 0x12345678,x86 小端机器和 ARM 大端机器在内存里的排列完全相反。直接把结构体指针交给 send(),等于让通讯双方赌运气。

序列化要做的事,就是把内存中的结构化数据,按一种双方都认可的规则,变成一长串可靠的字节流;接收端再按同一套规则还原。打个比方:序列化是打包快递,你按固定尺寸装箱并写好物品清单,反序列化是收快递的人按清单拆箱验收,中间任何一步少填或多填都会出问题。

2.2 协议格式:头部和负载分开设计

我给这个网络计算器定了一个很小的二进制协议,头部固定 12 字节:

字段长度(字节)说明
magic4固定魔数,用于校验传输对象,防止错收垃圾数据
version1协议版本,方便未来演进
cmd10x01 请求,0x02 响应
error1错误码,0 成功,非 0 表示失败原因
reserved1保留字段,置 0
payload_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_full:

int 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_len:

uint8_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 进程没有终端,想停掉它不能靠 Ctrl+C,只能靠 kill 和 pid。我在启动参数里增加了一个-P /var/run/calc.pid,写入 pid 的位置。启动时先读取旧 pid,再用kill(pid, 0)探测进程是否存在,存在就直接拒绝启动,防止两个实例抢同一端口。

日志方面,很多初学者把 printf 留在代码里,结果 daemon 跑起来后什么都没看到。我建议程序统一走日志函数,比如log_msg(level, fmt, ...),写到日志文件里。日志格式至少带时间戳和连接来源,排错的时候会舒服很多。

4.4 守护进程的常见坑位记录

坑一:进程被 kill 之后 pid 文件还留在那里。正确的做法是在信号处理函数里做清理,我在 main 里注册了 SIGTERM 和 SIGINT:

void 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, pid=1234

客户端测试:

./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 返回 0,handle_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 概念,全都能对号入座,知道自己正在解决什么问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询