24、回调与可重入设计:设计线程安全的回调注册与注销
回调机制在嵌入式系统里太常见了。中断处理、定时器超时、数据接收完成……几乎每个模块都会用到。但有个问题很多人容易忽略——线程安全。
我早期做的一个项目,跑着跑着就死机。查了三天,最后发现是回调函数在注册和注销时,被两个任务同时操作了。嗯,从那以后,我对回调的线程安全设计就特别敏感。
为什么回调注册/注销会出问题?
你想想看,一个回调链表,任务A正在遍历执行回调,任务B突然把某个节点删了。或者任务A正在注册新回调,任务B也在注册。这种并发操作,轻则回调漏执行,重则直接野指针崩溃。
说白了,回调的注册和注销,本质上是共享资源的读写操作。多个执行流同时访问,不加保护就是找死。
可重入设计的基本要求
一个线程安全的回调管理器,必须满足以下几点:
- 注册操作是原子的:要么完整插入,要么不插入
- 注销操作是安全的:不会在遍历过程中删除正在执行的节点
- 遍历执行是可重入的:允许回调函数内部再次注册或注销
- 没有死锁风险:锁的粒度要合适,不能嵌套锁死
核心原则:回调的注册/注销与回调的执行,必须互斥访问链表。但互斥的粒度要精细,不能一把大锁锁死整个系统。
常见的设计方案
我见过几种做法,各有优劣:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 全局互斥锁 | 注册/注销/遍历都用同一把锁 | 实现简单 | 遍历时阻塞注册,实时性差 |
| 读写锁 | 注册/注销用写锁,遍历用读锁 | 允许多个遍历同时进行 | 写锁可能饿死,实现复杂 |
| 延迟注销 | 标记删除,遍历结束后再真正移除 | 遍历时无需加锁 | 需要额外内存和清理机制 |
| RCU(读-拷贝-更新) | 拷贝链表,替换指针 | 读操作无锁 | 实现复杂,不适合所有场景 |
我个人习惯用延迟注销 + 轻量级互斥锁的组合。既保证了遍历的实时性,又避免了复杂的锁机制。
代码实现:线程安全的回调管理器
下面是我在一个物联网网关项目里用过的方案。核心思路是:
- 回调节点用
volatile修饰,防止编译器优化 - 注册时加锁,直接插入链表
- 注销时只标记
removed = 1,不真正删除 - 遍历时跳过已标记的节点,遍历结束后统一清理
// callback_mgr.h
#ifndef CALLBACK_MGR_H
#define CALLBACK_MGR_H
#include <stdint.h>
typedef void (*callback_t)(void *arg);
typedef struct callback_node {
struct callback_node *next;
callback_t func;
void *arg;
volatile uint8_t removed; // 延迟删除标记
} callback_node_t;
typedef struct {
callback_node_t *head;
/* 锁资源,具体实现取决于平台 */
void *lock;
} callback_mgr_t;
/* 初始化回调管理器 */
void callback_mgr_init(callback_mgr_t *mgr);
/* 注册回调,返回节点句柄用于注销 */
callback_node_t *callback_register(callback_mgr_t *mgr,
callback_t func,
void *arg);
/* 注销回调,标记删除 */
void callback_unregister(callback_mgr_t *mgr,
callback_node_t *node);
/* 遍历执行所有未删除的回调 */
void callback_execute(callback_mgr_t *mgr);
/* 清理已标记删除的节点(遍历结束后调用) */
void callback_cleanup(callback_mgr_t *mgr);
#endif
// callback_mgr.c
#include "callback_mgr.h"
#include <stdlib.h>
/* 假设平台提供了简单的互斥锁 */
extern void lock_acquire(void *lock);
extern void lock_release(void *lock);
void callback_mgr_init(callback_mgr_t *mgr) {
mgr->head = NULL;
mgr->lock = NULL; /* 实际项目中初始化锁 */
}
callback_node_t *callback_register(callback_mgr_t *mgr,
callback_t func,
void *arg) {
callback_node_t *node = malloc(sizeof(callback_node_t));
if (!node) return NULL;
node->func = func;
node->arg = arg;
node->removed = 0;
lock_acquire(mgr->lock);
node->next = mgr->head;
mgr->head = node;
lock_release(mgr->lock);
return node;
}
void callback_unregister(callback_mgr_t *mgr,
callback_node_t *node) {
if (!node) return;
/* 只标记,不删除 */
lock_acquire(mgr->lock);
node->removed = 1;
lock_release(mgr->lock);
}
void callback_execute(callback_mgr_t *mgr) {
callback_node_t *curr;
/* 遍历时不需要加锁,因为不会修改链表结构 */
curr = mgr->head;
while (curr) {
if (!curr->removed && curr->func) {
curr->func(curr->arg);
}
curr = curr->next;
}
/* 遍历结束后清理已标记的节点 */
callback_cleanup(mgr);
}
void callback_cleanup(callback_mgr_t *mgr) {
callback_node_t *prev = NULL;
callback_node_t *curr;
lock_acquire(mgr->lock);
curr = mgr->head;
while (curr) {
if (curr->removed) {
/* 从链表中移除 */
if (prev) {
prev->next = curr->next;
} else {
mgr->head = curr->next;
}
callback_node_t *to_free = curr;
curr = curr->next;
lock_release(mgr->lock); /* 释放锁再free,避免死锁 */
free(to_free);
lock_acquire(mgr->lock);
} else {
prev = curr;
curr = curr->next;
}
}
lock_release(mgr->lock);
}
注意:callback_cleanup里我特意先释放锁再free。为什么?因为free内部可能涉及内存管理器的锁,如果持有回调链表的锁去free,万一内存管理器也用了同一把锁,就死锁了。我曾经在这个坑里爬了整整一个下午。
可重入性验证
这个设计允许回调函数内部再次调用callback_register或callback_unregister。因为:
- 注册只加锁插入,不会影响正在遍历的链表
- 注销只标记,不会修改链表结构
- 清理操作在遍历结束后才执行
但有一个边界情况要注意:如果回调函数内部调用了callback_execute,就会形成递归遍历。这时候需要加一个递归深度计数器,防止栈溢出。
警告:不要在回调函数里调用callback_cleanup!因为清理时会修改链表,而外层可能正在遍历。我建议把清理操作放在任务级的空闲时刻,或者由专门的定时器触发。
SVG:回调注册/注销/执行流程
避坑指南
我总结几个实际项目中容易踩的坑:
- 不要在中断里注册/注销回调:中断上下文可能无法获取锁,或者导致优先级反转。我习惯用中断只做标记,由任务层处理注册注销。
- 注意内存泄漏:延迟删除的节点如果忘记清理,内存会慢慢涨。建议加一个定时器定期调用
callback_cleanup。 - 回调函数本身要可重入:如果回调里访问了全局变量或硬件寄存器,记得自己加保护。回调管理器只保证链表操作的安全,不保证回调函数内部的安全。
- 小心递归调用:回调里又调
callback_execute,形成递归。我一般加一个executing标志位,检测到递归时直接返回。
总结一下:线程安全的回调设计,核心就是「注册/注销加锁,遍历无锁,延迟删除」。这个模式我在三个项目里验证过,稳定运行了好几年。你如果遇到类似的场景,可以直接拿来用,但记得根据你的平台调整锁的实现。