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_registercallback_unregister。因为:

  • 注册只加锁插入,不会影响正在遍历的链表
  • 注销只标记,不会修改链表结构
  • 清理操作在遍历结束后才执行

但有一个边界情况要注意:如果回调函数内部调用了callback_execute,就会形成递归遍历。这时候需要加一个递归深度计数器,防止栈溢出。

警告:不要在回调函数里调用callback_cleanup!因为清理时会修改链表,而外层可能正在遍历。我建议把清理操作放在任务级的空闲时刻,或者由专门的定时器触发。

SVG:回调注册/注销/执行流程

线程安全回调管理器:注册/注销/执行流程 任务A:注册回调 任务B:注销回调 任务C:遍历执行 互斥锁保护 注册/注销时加锁 遍历时不加锁 回调链表 节点1 (正常) 节点2 (已标记删除) 节点3 (正常) 清理已标记节点

避坑指南

我总结几个实际项目中容易踩的坑:

  • 不要在中断里注册/注销回调:中断上下文可能无法获取锁,或者导致优先级反转。我习惯用中断只做标记,由任务层处理注册注销。
  • 注意内存泄漏:延迟删除的节点如果忘记清理,内存会慢慢涨。建议加一个定时器定期调用callback_cleanup
  • 回调函数本身要可重入:如果回调里访问了全局变量或硬件寄存器,记得自己加保护。回调管理器只保证链表操作的安全,不保证回调函数内部的安全。
  • 小心递归调用:回调里又调callback_execute,形成递归。我一般加一个executing标志位,检测到递归时直接返回。

总结一下:线程安全的回调设计,核心就是「注册/注销加锁,遍历无锁,延迟删除」。这个模式我在三个项目里验证过,稳定运行了好几年。你如果遇到类似的场景,可以直接拿来用,但记得根据你的平台调整锁的实现。

公众号:蓝海资料掘金营,微信deep3321