恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
C语言银行系统实战:消息队列实现进程间通信与并发控制
首页
资讯中心
/
C语言银行系统实战:消息队列实现进程间通信与并发控制
C语言银行系统实战:消息队列实现进程间通信与并发控制
发布时间:2026/10/15 16:26:25
前段时间我动手写了一个C语言的银行系统练手项目功能清单看着很常规开户、销户、存钱、取钱、转账、查询、保存用户信息。但标题里真正藏关键的是最后那句——使用消息队列达到两个不同进程之间的相互通信。这句话直接把项目的难度从“文件读写练习”拉高到“进程间通信实战”。很多人拿到这类题目第一反应是“几个控制台菜单而已”真写起来才知道坑全在数据怎么持久化、两个进程怎么对话、并发时账目怎么不错乱。下面把我实现过程中的设计思路、关键函数和踩坑记录完整整理一遍供正在做C语言课程项目或想练IPC的同学参考。1. 从需求到架构一个银行系统该怎么拆1.1 功能清单其实就是模块划分图拿到题目不要急着敲代码先把“开户、存钱、取钱、转账、查询、销户、保存用户信息”这些词翻译成技术模块开户、销户账户生命周期管理涉及账号分配、状态维护。存钱、取钱单账户余额变更属于最基础的账务操作。转账涉及两个账户的复合操作核心是一致性。查询只读操作但要避免读到写一半的数据。保存用户信息持久化层决定程序重启后数据还在不在。消息队列通信把界面操作和底层数据服务拆到两个进程里。把这些模块串起来一个典型的C/S架构就出现了。客户端进程负责接收用户指令服务端进程维护账户文件两者通过消息队列交互。我当时把这个架子画出来之后后续写代码的路径清晰很多。如果你把所有功能塞进单个进程也能跑通但消息队列这道题会逼着你把“界面层”与“数据层”分开这恰恰是这个练习最值钱的部分。1.2 为什么选消息队列而不是管道或共享内存C语言里进程间通信的方式不少有管道、信号、共享内存、消息队列、套接字等。题目点名用消息队列我一开始也想过换成管道行不行对比下来消息队列有三个明显优势。管道是字节流读写双方必须保持节奏一致而且通常只支持单向流动双向通信得开两条管道。共享内存虽然快但同步互斥机制要自己用信号量维护稍不注意就是数据竞争。消息队列是内核维护的一个消息链表发送方把一条结构体消息丢进队列接收方按类型取走天然具有“数据块”语义和按类型路由的能力。更直白地类比管道是两个人拿一根管子对着喊共享内存是几个人在一块白板上写字消息队列则像一个带信箱编号的邮局。发信人把信丢进邮局收信人按编号取信中间不需要两边同时在线。这种模式非常适合“客户端发请求、服务端回响应”的命令问答场景。1.3 项目文件结构先约定好我建议一开始就规划目录别写到后面像一锅粥bank/ ├── common.h # 公共结构体、消息定义、命令字常量 ├── server.c # 服务端进程处理业务逻辑 ├── client.c # 客户端进程交互菜单与消息收发 ├── account.dat # 用户信息数据文件 └── Makefile # 快速编译common.h 是通信协议的核心两个进程都必须包含它。这样消息结构体只要改一处两边同步生效。我把业务结构体叫BankMessage把命令字叫CMD_OPEN、CMD_DEPOSIT这种名字后面在消息队列章节会详细展开。2. 用户信息持久化账号文件的设计直接决定后续代码量2.1 用定长结构体存账户别碰文本解析很多新手喜欢把用户信息写成逗号分隔的文本10001,张三,5000.00,1读取时用strtok拆字段听起来简单实际写起来很痛苦。字段长度一变解析逻辑就得跟着改数值中间出现空格或者中文名里夹带符号直接乱套。我第二次重写时换成了定长二进制结构体#pragma pack(1) // 禁止字节对齐保证文件记录长度固定 typedef struct { char account[16]; // 账号 char name[32]; // 用户名 long balance; // 余额以“分”为单位 int status; // 1 正常0 销户 } Account; #pragma pack()#pragma pack(1)在这里特别重要。如果不指定编译器会按对齐规则在结构体字段之间插入填充字节导致每条记录长度不确定。加了它sizeof(Account)固定为 56 字节文件里第 N 条记录的位置就是(N-1) * sizeof(Account)可以随时用fseek精准定位。定位能力强了查询账号就变成一次随机读取int find_account(FILE *fp, const char *account, Account *out) { // 先把文件头部的账户数量读出来再逐个比较 // 也可以用哈希或索引但简单项目里线性扫描足够 }这种定长方案虽然浪费一点磁盘空间但换来了极高的编写效率和定位能力非常适合练习项目。2.2 写文件必须考虑“写一半崩溃”的问题银行系统的数据文件承载的是账目绝不能出现“写了一半断电”导致记录损坏的情况。我第一版直接用fseek fwrite原地修改记录测试时人为杀掉服务端进程文件里的余额就会出现错乱。后来改成“临时文件 rename”策略每次开户、存钱、转账需要修改数据时不直接改原文件而是先写一个临时文件全部写完后用rename原子替换原文件。rename在同一个文件系统里是原子操作要么新文件完全生效要么原文件保持不变不存在中间状态。流程大致是1. 复制原文件到临时文件或者打开原文件顺次读取 2. 在临时文件里完成余额修改 3. 刷新并关闭临时文件 4. rename(临时文件名, 原文件名) 5. 删除临时文件如果 rename 失败。这种策略在很多真实系统里也常见属于一种低成本的事务手段。2.3 密码字段不能明文裸奔题目只提到“保存用户信息”但实际做出来免不了有密码校验。我第一版为了省事把密码明文塞进结构体后来自己测试时都觉得心虚——这个文件一旦被别人拿到所有账户直接用记事本就能看穿。简单项目不必上重量级加密但至少不能明文。我建议用简单的哈希或者异或混淆unsigned char hash_pwd(const char *pwd, unsigned char *out) { unsigned char h 0; while (*pwd) { h (h 5) h (unsigned char)(*pwd); // 类似简单哈希 pwd; } return h; }如果只是交作业加个哈希已经足够体现安全意识。真实生产环境自然要上更成熟的方案这里不展开。3. 消息队列搞定两个进程的“双向传话”3.1 消息队列的三个关键函数消息队列的核心概念不复杂内核维护一个队列进程往队列里放消息另一个进程从队列里取消息。使用前要理解三个函数msgget()创建或获取一个消息队列。msgsnd()向队列发送消息。msgrcv()从队列接收消息。它们都依赖一个key这个key一般用ftok()生成key_t key ftok(/tmp/bank_queue, 66); int qid msgget(key, IPC_CREAT | 0666);ftok两个参数是文件路径和一个整数项目ID。同一个路径和同一个ID生成的 key 是固定的两个进程都调用同样的参数就能拿到同一个消息队列标识。各函数原型如下int msgget(key_t key, int flag); int msgsnd(int msqid, const void *msgp, size_t msgsz, int flag); ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long type, int flag);注意msgsnd的msgsz长度不包含long mtype字段msgrcv的type参数是消息类型过滤条件这个是后面所有通信协议设计的核心。3.2 通信协议给消息定一个双方都懂的格式消息队列只负责传字节它不关心字节里是什么。所以通信双方必须约定一套统一的“协议”否则客户端发一堆字段服务端根本不知道哪段是账号、哪段是金额。我在common.h里定义#define MSG_TYPE_REQUEST 1 // 客户端 - 服务端 的请求 #define MSG_TYPE_RESPONSE 2 // 服务端 - 客户端 的响应 #define CMD_OPEN 1 #define CMD_CLOSE 2 #define CMD_DEPOSIT 3 #define CMD_WITHDRAW 4 #define CMD_TRANSFER 5 #define CMD_QUERY 6 typedef struct { long mtype; // 消息类型用来区分请求和响应 int cmd; // 命令字 char account[16]; // 操作账号 char targetAcc[16]; // 转账目标账号 char name[32]; // 开户姓名 double amount; // 金额单位为元存储时转成“分” int result; // 处理结果0 成功非0 错误码 int balanceAfter; // 查询余额时的返回 } BankMessage;这里最关键的一点进程间通信时绝对不能传指针。每个进程有独立地址空间你在客户端传一个指向栈上变量的指针服务端拿到后访问的是一块毫无意义的地址。消息队列只允许传“值”。所有数据都要在结构体里按值拷贝。我当时还顺手加了一个result字段服务端处理完业务后把结果写回同一个结构体再把mtype改成响应类型发回去。这就是最简单的“请求/响应”协议。3.3 服务端接收循环一条 while 打天下服务端的核心是一个死循环不停从队列里取请求然后分发处理。伪代码框架如下while (1) { BankMessage msg; ssize_t size msgrcv(qid, msg, sizeof(msg) - sizeof(long), MSG_TYPE_REQUEST, 0); if (size 0) { perror(msgrcv); continue; } switch (msg.cmd) { case CMD_OPEN: result open_account(msg); break; case CMD_DEPOSIT: result deposit(msg); break; // 其他命令类似 default: msg.result -1; break; } msg.mtype MSG_TYPE_RESPONSE; msgsnd(qid, msg, sizeof(msg) - sizeof(long), 0); }分段处理的好处是业务逻辑和通信逻辑分得很清楚。每一个业务函数都接收一个BankMessage*处理完直接修改结构体里的result返回给调用方发送。3.4 多客户端场景下光一个公共队列是不够的题目只说两个进程看起来像一个客户端一个服务端。但如果客户端能重复启动多个就会出现一个问题多个客户端都往同一个公共队列发请求也都往同一个公共队列读“响应”怎么保证张三收不到李四的响应最简单的方案是“每个客户端建一个自己的私有队列”。客户端把私有队列的qid随请求消息一起发给服务端服务端处理完后调用msgsnd把响应发到那个私有队列。这样每个客户端只从自己的队列里读消息不会串线。我当时第一版没考虑这个问题第二个终端一启动就出现“客户端A收到客户端B查询结果”的诡异现场。排查半天意识到消息队列没有“定向投递”能力它是靠消息类型和接收侧主动过滤来区分归属。用私有队列后多客户端并行一下就正常了。4. 核心业务功能的实现与并发控制4.1 开户先校验再落盘开户流程可以拆成几步检查姓名合法性避免空名字。生成唯一账号一般用递增序号。检查是否已有相同账号的存量数据。用fopen追加或者插入一条记录。返回成功信息。落盘时我用的是“读整个文件到内存新增一条记录后整体重写”的策略。开户频率不高性能不是主要矛盾。关键点在于生成账号那一步不能简单用当前时间戳因为同一秒开户两次可能撞号。用文件里已有的最大账号再 1 比较稳妥int next_id 0; while (fread(acc, sizeof(Account), 1, fp) 1) { if (acc.status ! 1) continue; int cur atoi(acc.account); if (cur next_id) next_id cur; } next_id; snprintf(new_acc.account, sizeof(new_acc.account), %d, next_id);4.2 存钱和取钱读改写操作必须加锁存钱取钱看起来就是读余额、加钱/扣钱、写回去但这里有个经典的并发陷阱。假设余额是 100 元进程A存50进程B取30。如果两个进程同时读到“100”A写入150B写入70最终结果是70凭空少了30。这不是理论问题测试时用两个终端同时操作同一个账号很容易复现。我的解决方案是引入“文件锁”在每次读取余额前对文件记录范围加锁写完后再解锁。C语言里可以用fcntl的F_SETLKWstruct flock fl; fl.l_type F_WRLCK; // 写锁 fl.l_whence SEEK_SET; fl.l_start account_offset; // 该账号记录在文件中的偏移 fl.l_len sizeof(Account); // 只锁这一条记录 fcntl(fd, F_SETLKW, fl); // 读取、修改、写入 fl.l_type F_UNLCK; fcntl(fd, F_SETLKW, fl);这里有个小细节lockf或fcntl锁是基于文件描述符的如果写操作之前做了fopen而不是open要注意两个库函数混用可能会导致加锁失效。我后来统一用openread/write配合lseek操作文件避免stdio缓冲区带来的额外复杂度。4.3 转账两个账户的一致性最麻烦转账比存钱取钱难在它要修改两条记录。如果先扣A账户再加B账户中间任何一个环节失败账就平不了。我的做法是给转账流程加一个简单的“事务编排”对A、B两条记录按账号大小排序先锁账号较小的记录。再锁账号较大的记录。检查A账户余额是否足够。执行“扣A、加B”。同时写回文件或者先写回A再写回B。全部成功解锁成功任何一步失败把已经写入的改动恢复再解锁。按账号排序加锁是为了避免两个客户端同时做反向转账时出现死锁。如果不排序进程1先锁A再锁B进程2先锁B再锁A两边互相等对方释放锁程序就卡死了。具体业务函数原型int transfer(BankMessage *msg) { Account from, to; if (find_account(from, msg-account) ! 0 || find_account(to, msg-targetAcc) ! 0) { return ERR_ACCOUNT_NOT_FOUND; } // 按账号顺序加锁 if (strcmp(msg-account, msg-targetAcc) 0) { lock_record(from); lock_record(to); } else { lock_record(to); lock_record(from); } if (from.balance (long)(msg-amount * 100)) { unlock_record(from); unlock_record(to); return ERR_BALANCE_NOT_ENOUGH; } from.balance - (long)(msg-amount * 100); to.balance (long)(msg-amount * 100); write_record(from); write_record(to); unlock_record(from); unlock_record(to); return 0; }这里已经很接近真实系统里“事务 锁”的概念了。当然真实银行系统不可能用文件锁做事务但这个思路迁移过去是相通的。4.4 查询余额与销户的细节查询看起来最简单但要注意读取模式。如果服务端还在写某条记录客户端同时发查询请求可能读到的余额是写了一半的脏数据。所以查询也应当加读锁fl.l_type F_RDLCK; fcntl(fd, F_SETLKW, fl); read_record(...); fl.l_type F_UNLCK; fcntl(fd, F_SETLKW, fl);销户则要决定一件事账户里有余额怎么办我实现的策略是余额必须为0才能销户否则返回错误。这样避免“销户后钱不知去向”的历史问题。同时我在结构体里用status 0标记销户而不是物理删除记录。保留原始数据的好处是后续如果要打印历史流水单数据都还找得回来。5. 这些坑我帮你踩过了问题定位与修复速查5.1 ftok 换了路径队列就找不到了ftok生成的 key 和传入的文件路径直接相关。如果路径叫/tmp/bank_q程序启动时正常后来把路径改成了/var/tmp/bank_q两边进程就拿不到同一个队列了。还有一个坑是路径必须真实存在ftok并不会帮你创建文件。所以建议把ftok的路径固定写成一个服务端启动时确保存在的文件路径比如程序自己创建的配置目录别用/tmp下随机临时文件名。5.2 msgsnd 卡死消息队列满了消息队列有大小上限。当客户端发消息特别快服务端处理不过来队列塞满后msgsnd默认会阻塞住。我测试时用循环连发100条请求程序直接卡在发送上。解决办法是给msgsnd加IPC_NOWAIT标志让它非阻塞返回错误或者先查询队列当前字节数struct msqid_ds buf; msgctl(qid, IPC_STAT, buf); // buf.msg_qbytes 是队列总字节上限 // buf.msg_cbytes 是当前字节数如果要用msgsnd的非阻塞模式if (msgsnd(qid, msg, size, IPC_NOWAIT) -1) { if (errno EAGAIN) { // 队列满了可以稍后重试或者提示用户 } }5.3 msgrcv 读走一条消息别人就再读不到了消息队列不是广播一条消息只能被一个进程取走。如果两个客户端都往同一个队列里收响应某一瞬间两个人可能抢到对方的响应。很多初学者误以为“队列里的消息可以共享读”实际上msgrcv是“取走”语义。要解决多客户端归属问题回到前面说的私有队列方案别让客户端共享响应队列。5.4 不加文件锁存进去的钱会“变少”这个坑在4.2已经详细演示过这里再强调一点文件锁是“建议性锁”不是“强制性锁”。也就是说如果某个操作流程忘了加锁别的进程同样能在没有锁的情况下读写文件破坏数据一致性。所以每个修改入口都必须手动调用加锁函数漏一个都不行。我后来把所有涉及余额修改的逻辑收敛到三个函数里deposit、withdraw、transfer。所有入口都走这三个函数才彻底杜绝了漏锁问题。5.5 double 算钱账目差一分钱这是老生常谈。计算机里浮点数不能精确表示所有小数比如0.1 0.2的结果是0.30000000000000004。如果余额用double存频繁加减后误差会累积对账的时候差几分钱是最难受的。我建议内部存储一律用“分”这个整数单位。用户输入100.50元程序立刻转成10050分来处理。只有显示给用户时再除以100。结构体里long balance存的就是分不要再存double。5.6 服务端重启后处理了一堆“旧消息”消息队列是内核对象服务端进程退出后队列还在。如果之前客户端发了一堆没处理完的请求服务端重启后又会把这些旧请求当新请求处理结果可能是把“昨天的存钱”又执行了一遍。解决方案是在服务端启动时主动清空队列msgctl(qid, IPC_RMID, NULL); // 然后重新 msgget 创建或者更优雅一点在每条消息里增加自增序号或时间戳服务端识别出过期请求直接丢弃。练习项目中直接清空队列最简单。5.7 用 ipcs 排查队列状态排查消息队列问题时别靠猜直接看内核状态。Linux 下用ipcs -q能显示队列标识、消息数、总字节数。某个消息积压在队列里不消失直接看msgsnd和msgrcv的调用参数是否匹配。不需要用的队列可以用ipcrm -q 队列编号手动删除。但如果程序还在跑删掉后需要重新创建程序内部的qid也要重新获取所以一般只在调试时手动清理。下面整理成速查表现象大概率原因处理办法客户端发消息服务端收不到ftok 路径不一致或 key 不匹配统一 ftok 参数msgsnd 卡住队列满使用 IPC_NOWAIT 或加快服务端消费多个客户端收到同一个响应共享响应队列客户端使用私有队列余额偶尔少钱未加锁并发写用 fcntl 加记录锁金额总和差几分用 double 存钱改成整数“分”存储服务端重启后处理旧请求队列残留旧消息启动时 IPC_RMID 清空6. 从作业到工程还能往哪些方向扩展6.1 把账号文件换成数据库文件存储虽然简单但在并发、检索、事务上都很吃力。真实系统至少会用 SQLite 这类嵌入式数据库或者直接上 MySQL/PG。如果学有余力可以保留消息队列通信框架把服务端的文件读写替换成 SQL 跟数据库交互。你会发现消息队列的壳完全不用动业务函数内部改改就能跑。6.2 用多线程替代多进程题目限定“两个进程”但生产环境里通常是一个服务端进程内开多条工作线程客户端通过消息队列把请求丢给线程池处理。这里面还要考虑线程安全文件锁、内存数据结构加锁、消息队列并发读写。多线程比多进程更能直观暴露共享变量的竞争问题值得单独做一个版本对比。6.3 增加日志、对账与权限控制真实银行系统要做三件事操作日志、日终对账、权限控制。操作日志就是给每一笔存取款、转账记录一条不可篡改的日志对账是定期把日志流水和余额文件重新计算一遍确保账实相符权限控制则是区分管理员、普通用户、只读查询者。即使在练习项目里加一个小型的 append-only 日志文件代码复杂度也不会增加太多但整个项目的完整度和说服力会高一个档次。6.4 通信层升级为网络通信消息队列只能在同一台主机上的进程之间通信。如果把客户端换成网络版服务端需要监听某个端口那就超出进程间通信范畴进入网络编程领域了。这时候可以用 Socket 自定义协议替换消息队列或者在消息队列前面加一层网络网关。想往服务器方向走的话这个扩展路径非常值得走一遍。这个项目我做完最大的一个体会是别小看这些基础功能。存钱取钱背后是读改写原子性转账背后是事务一致性消息队列背后是协议设计和路由边界。每一块单独拆出来都不难但组合到一起就是一个小型业务系统的缩影。如果你也在做类似练习我建议第一步先把存储格式和消息协议定下来再写业务函数顺序反了会改到怀疑人生。最后再分享一个小技巧调试这种两个进程协作的程序多开几个终端一边跑服务端一边跑客户端用ipcs -q盯着队列状态。看到消息数、字节数变化时的直观感受比你读十遍手册都管用。做完这个项目再回去看任何“C/S 架构”“消息中间件”的资料你会觉得很多东西似曾相识。