11、模块的初始化与销毁:init/deinit 函数的设计模式、资源管理的黄金法则、实战:设计一个数据库连接池模块

好,咱们今天聊一个特别实在的话题——模块的初始化和销毁。

你可能觉得,不就是写个 init 函数、再写个 deinit 函数吗?有什么好讲的?

嗯,我当年也是这么想的。直到有一次,我在一个嵌入式网关项目里,因为 deinit 里少释放了一个互斥锁,导致系统在热重启时直接死锁。那一次排查花了我整整两天,最后发现原因就一行代码。从那以后,我对 init/deinit 的设计就再也不敢马虎了。

说白了,模块的初始化和销毁,是资源管理的「入口」和「出口」。入口没把好关,后面全是坑;出口没打扫干净,系统迟早要崩。

11.1 init/deinit 函数的设计模式

我个人的习惯是,每个模块只暴露两个函数:xxx_init()xxx_deinit()。所有资源的申请和释放,都封装在这两个函数里。外部调用者不需要知道模块内部用了什么资源,只需要知道「初始化」和「销毁」两个动作。

这里我总结了几种常见的设计模式,你可以根据场景选用。

11.1.1 简单模式:一次性初始化

适用于那些只需要初始化一次、全局唯一的模块。比如系统时钟、日志模块。

// 简单模式:全局状态,只初始化一次
static bool g_initialized = false;

int log_init(void) {
    if (g_initialized) {
        return 0;  // 已经初始化过了,直接返回成功
    }
    // 实际初始化逻辑
    g_initialized = true;
    return 0;
}

void log_deinit(void) {
    if (!g_initialized) {
        return;
    }
    // 实际销毁逻辑
    g_initialized = false;
}

你想想看,这种模式的好处是什么?调用者可以放心地多次调用 init,不用担心重复初始化导致资源泄漏。我在项目中经常用这个模式来保护那些「只能初始化一次」的硬件外设。

11.1.2 实例模式:支持多个实例

如果一个模块需要创建多个实例,比如多个串口、多个数据库连接池,那就不能用全局状态了。这时候需要传入一个「句柄」或「上下文结构体」。

// 实例模式:每个实例独立管理
typedef struct {
    int fd;
    void *buffer;
    bool active;
} db_pool_t;

int db_pool_init(db_pool_t *pool, int pool_size) {
    if (pool == NULL) return -1;
    pool->fd = open("/dev/db", O_RDWR);
    if (pool->fd < 0) return -1;
    pool->buffer = malloc(pool_size);
    if (pool->buffer == NULL) {
        close(pool->fd);
        return -1;
    }
    pool->active = true;
    return 0;
}

void db_pool_deinit(db_pool_t *pool) {
    if (pool == NULL || !pool->active) return;
    free(pool->buffer);
    close(pool->fd);
    pool->active = false;
}

这里有个细节:deinit 里一定要把 active 标志置为 false。为什么?因为如果调用者不小心在 deinit 之后又调用了其他函数,我们可以通过检查这个标志来阻止非法操作。我曾经在一个项目中漏掉了这一步,结果 deinit 之后又有人调用了发送函数,直接写到了已经关闭的文件描述符上——嗯,后果你懂的。

11.1.3 引用计数模式:共享资源的管理

有些资源是多个模块共享的,比如一个全局的定时器服务。这时候不能简单地「谁初始化谁销毁」,因为你不知道最后一个使用者是谁。

// 引用计数模式
static int g_ref_count = 0;
static pthread_mutex_t g_mutex = PTHREAD_MUTEX_INITIALIZER;

int timer_service_init(void) {
    pthread_mutex_lock(&g_mutex);
    if (g_ref_count == 0) {
        // 真正初始化硬件定时器
        hw_timer_start();
    }
    g_ref_count++;
    pthread_mutex_unlock(&g_mutex);
    return 0;
}

void timer_service_deinit(void) {
    pthread_mutex_lock(&g_mutex);
    if (g_ref_count > 0) {
        g_ref_count--;
        if (g_ref_count == 0) {
            // 真正销毁硬件定时器
            hw_timer_stop();
        }
    }
    pthread_mutex_unlock(&g_mutex);
}

这种模式在大型系统中特别有用。每个模块只管调用 init/deinit,底层自动管理引用计数。最后一个离开的人负责关灯。

11.2 资源管理的黄金法则

说到资源管理,我脑子里蹦出来的第一条法则就是:谁申请,谁释放。听起来简单吧?但实际项目中,这条法则经常被打破。

我见过最典型的场景是:模块 A 申请了一块内存,传给模块 B 使用,然后模块 B 用完之后释放了。结果模块 A 不知道,后来又去访问这块内存——野指针,崩溃。

所以,我给自己定了几条铁律,分享给你:

资源管理黄金法则(我的版本)

  1. 谁申请,谁释放——申请资源的函数负责释放,不要转手给别人。
  2. init 失败必须回滚——如果 init 中途失败,已经申请的资源要全部释放,不能留下半残状态。
  3. deinit 必须幂等——多次调用 deinit 不能导致崩溃或资源泄漏。
  4. deinit 之后,所有指针置 NULL——防止野指针访问。
  5. 资源申请顺序和释放顺序相反——后申请的先释放,避免依赖关系导致的问题。

第三条「幂等」我特别想强调一下。你想想看,如果某个模块的 deinit 第一次执行正常,第二次执行就崩溃了,那这个模块在复杂的生命周期管理中就很难用。我习惯在 deinit 里加一个标志位,第一次执行后置位,第二次执行直接返回。

小技巧:在 deinit 函数末尾,把释放掉的指针显式赋值为 NULL。这样即使后续有人误用了这个指针,也能通过 assert(p == NULL) 快速定位问题。

11.3 实战:设计一个数据库连接池模块

好,理论讲完了,咱们来点实战。设计一个数据库连接池模块,把上面说的 init/deinit 模式、资源管理法则都用上。

11.3.1 模块设计思路

数据库连接池的核心功能是:预先创建一批数据库连接,放到池子里。业务模块需要时从池子里取一个,用完了再还回去。这样可以避免频繁创建和销毁连接的开销。

这个模块需要管理哪些资源?

  • 连接数组(每个连接是一个结构体,包含 socket 句柄、状态等)
  • 互斥锁(保护连接数组的并发访问)
  • 条件变量(用于等待可用连接)

11.3.2 数据结构定义

#define MAX_CONN 16

typedef struct {
    int sock_fd;
    bool in_use;
    // 其他连接属性...
} db_conn_t;

typedef struct {
    db_conn_t conns[MAX_CONN];
    int pool_size;
    pthread_mutex_t mutex;
    pthread_cond_t cond;
    bool initialized;
} db_pool_t;

11.3.3 init 函数实现

int db_pool_init(db_pool_t *pool, int size) {
    if (pool == NULL || size <= 0 || size > MAX_CONN) {
        return -1;
    }

    // 先清零,避免残留数据
    memset(pool, 0, sizeof(db_pool_t));

    pool->pool_size = size;

    // 初始化同步原语
    if (pthread_mutex_init(&pool->mutex, NULL) != 0) {
        return -1;
    }
    if (pthread_cond_init(&pool->cond, NULL) != 0) {
        pthread_mutex_destroy(&pool->mutex);
        return -1;
    }

    // 逐个创建数据库连接
    for (int i = 0; i < size; i++) {
        pool->conns[i].sock_fd = db_connect_real();  // 实际连接数据库
        if (pool->conns[i].sock_fd < 0) {
            // 创建失败,回滚已经创建的连接
            for (int j = 0; j < i; j++) {
                db_disconnect_real(pool->conns[j].sock_fd);
            }
            pthread_cond_destroy(&pool->cond);
            pthread_mutex_destroy(&pool->mutex);
            return -1;
        }
        pool->conns[i].in_use = false;
    }

    pool->initialized = true;
    return 0;
}

注意看回滚逻辑。如果第 5 个连接创建失败了,前面 4 个已经创建成功的连接必须全部释放。这就是「init 失败必须回滚」法则的体现。我见过太多代码只记得报错,忘了清理,结果留下一堆孤儿资源。

11.3.4 deinit 函数实现

void db_pool_deinit(db_pool_t *pool) {
    if (pool == NULL || !pool->initialized) {
        return;  // 幂等:未初始化或已销毁,直接返回
    }

    // 先获取锁,确保没有其他线程正在使用连接
    pthread_mutex_lock(&pool->mutex);

    // 关闭所有连接
    for (int i = 0; i < pool->pool_size; i++) {
        if (pool->conns[i].sock_fd >= 0) {
            db_disconnect_real(pool->conns[i].sock_fd);
            pool->conns[i].sock_fd = -1;
        }
    }

    pthread_mutex_unlock(&pool->mutex);

    // 销毁同步原语
    pthread_cond_destroy(&pool->cond);
    pthread_mutex_destroy(&pool->mutex);

    // 标记为未初始化
    pool->initialized = false;
}

这里有个细节:deinit 里先获取锁,确保没有线程正在使用连接。但获取锁之后,如果其他线程正在等待条件变量,可能会出现问题。更严谨的做法是加一个「关闭中」的标志,让等待的线程主动退出。不过对于入门级的连接池,上面的实现已经够用了。

11.3.5 获取和释放连接的函数

int db_pool_acquire(db_pool_t *pool) {
    if (pool == NULL || !pool->initialized) return -1;

    pthread_mutex_lock(&pool->mutex);

    // 找一个空闲连接
    while (1) {
        for (int i = 0; i < pool->pool_size; i++) {
            if (!pool->conns[i].in_use) {
                pool->conns[i].in_use = true;
                int fd = pool->conns[i].sock_fd;
                pthread_mutex_unlock(&pool->mutex);
                return fd;
            }
        }
        // 没有空闲连接,等待
        pthread_cond_wait(&pool->cond, &pool->mutex);
    }
}

void db_pool_release(db_pool_t *pool, int fd) {
    if (pool == NULL || !pool->initialized) return;

    pthread_mutex_lock(&pool->mutex);

    for (int i = 0; i < pool->pool_size; i++) {
        if (pool->conns[i].sock_fd == fd) {
            pool->conns[i].in_use = false;
            break;
        }
    }

    // 通知等待的线程
    pthread_cond_signal(&pool->cond);
    pthread_mutex_unlock(&pool->mutex);
}

注意:上面的 acquire 函数在 while 循环里等待条件变量,这是正确的做法。因为 pthread_cond_wait 可能被虚假唤醒,所以必须用 while 重新检查条件。我曾经在这个坑里摔过一次,排查了好久才发现是虚假唤醒导致的。

11.4 模块生命周期流程图

下面我用一张 SVG 图来展示这个连接池模块的完整生命周期。从 init 开始,到 acquire/release 的循环,最后到 deinit 结束。

数据库连接池模块生命周期 未初始化 db_pool_init() 初始化中 成功 失败 运行中(已初始化) 回滚(释放已创建资源) 返回 -1 db_pool_acquire() db_pool_release() db_pool_deinit() 已销毁(回到未初始化) 可再次 init

这张图把整个生命周期串起来了。你注意看,从「运行中」到「已销毁」再到「未初始化」,是一个闭环。这意味着你可以反复 init/deinit,模块不会出问题。这就是我前面说的「幂等」和「可重入」的体现。

11.5 总结一下

今天咱们聊了 init/deinit 的三种设计模式:简单模式、实例模式、引用计数模式。每种模式都有它的适用场景,没有银弹。

资源管理的黄金法则,说白了就是五句话:谁申请谁释放、失败要回滚、deinit 要幂等、释放后置 NULL、申请释放顺序相反。这五条我用了十几年,每次出问题都是因为违反了其中一条。

最后的连接池实战,把前面讲的理论都用上了。你如果自己写模块,也可以照着这个模板来:先定义数据结构,再写 init(带回滚),再写 deinit(带幂等),最后写业务函数。

嗯,今天就到这里。记住,好的 init/deinit 设计,能让你的模块活得很「体面」——进来的时候干干净净,走的时候也干干净净。


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