Posted in

引用计数原理:C 语言实现与 C++ 智能指针

周末,你和两位朋友借了一间活动室。你先到,管理员开门、开灯,把房间交给你使用;过了一会儿,两位朋友也来了,大家共用这间房间。

你最先忙完,准备离开。这时能顺手关灯锁门吗?当然还不行,因为两位朋友还在里面。可如果每个人都觉得「别人会收尾」,最后又可能谁也没关灯。

问题在于:一个人用完了,不代表所有人都用完了。怎样才能知道,什么时候可以关闭这间活动室?

可以约定一条简单的规则:管理员记录当前还有多少人需要使用房间。每来一位使用者,记录加一;每有一位确认不再使用,记录减一。人数从 1 变成 2、3,再随着大家离开变成 2、1。只有最后一个人离开、记录归零时,才关灯锁门。

这样,先离开的人只需说明「我用完了」,不必猜测别人什么时候结束;最后一次离开,则有了明确的收尾动作。这里假设每个人都按约定登记和离开,现实中若有人忘记登记或注销,这套办法也会出错。

把活动室换成程序里的一段共享数据,问题就很接近了:模块 A 创建数据,模块 B、C 也要继续使用。A 完成工作时不能直接释放内存,必须等 B、C 也不再需要,才能安全清理。

这就引出了引用计数(Reference Counting):为共享资源记录还有多少个需要保持它存活的引用,最后一个引用释放时,再销毁资源。 活动室的「关灯锁门」对应资源清理,并不表示要拆掉房间;程序中的具体清理动作,可以是释放内存、关闭文件或断开连接。

不过,程序不能直接照搬「数人数」:同一个模块可能持有多个引用,也可能只是临时借用别人的引用。要把这套办法写成可靠的代码,我们还需要先弄清楚:哪些引用应该计数,谁负责归还它们?

先理解所有权:数的是引用,不是使用人数

引用计数为资源维护一个计数器:每增加一个拥有所有权的引用,计数加一;每释放一个这样的引用,计数减一;最后一个引用释放时,销毁资源。

这里的关键词是拥有所有权的引用。它承诺在自己仍需使用资源时,维持资源的生命周期。一个模块可以持有多个引用,多个临时访问也可以只借用同一个已有引用,因此计数不能简单理解为用户数、访问次数或指针变量数。

操作 是否改变引用计数 原因
创建资源并交给首个拥有者 通常初始化为 1 已有一个引用负责保持资源存活
新增一个共享拥有者 加一 新拥有者需要独立维持生命周期
在有效期内临时借用资源 不变 由已有拥有者保证借用期间不被释放
转移一个已有引用的所有权 不变 负责持有它的人变了,引用总数没变
释放一个拥有型引用 减一 该拥有者不再维持生命周期
释放最后一个拥有型引用 减到零并销毁 已经没有拥有者

例如,C 语言里仅执行 Resource *b = a; 只复制地址,不会自动增加计数。若 B 需要独立持有,就必须调用约定的获取引用接口;否则 B 只能在 A 保证资源存活的时间内借用。

回到活动室的例子,拥有型引用更像一张有效的使用凭证:只要还持有凭证,房间就需要保持开放;如果只是跟随朋友临时进去,就必须在朋友交还凭证之前离开。程序里的借用也有这样的有效期约束。

创建、共享、释放:一次完整流程

先看三个拥有者共享同一个对象的过程:

步骤 操作 计数变化 对象状态
1 A 创建对象并取得首个引用 无对象 → 1 分配并初始化
2 B 获取一个引用 1 → 2 继续存活
3 C 获取一个引用 2 → 3 继续存活
4 A 释放自己的引用 3 → 2 B、C 仍可使用
5 B 释放自己的引用 2 → 1 C 仍可使用
6 C 释放自己的引用 1 → 0 销毁对象

引用计数规定的是共享和销毁规则,并不要求在第一次 get() 时创建资源。 可以把创建与获取引用合并,也可以像下面的示例一样,分别提供 create()、get()、put()。接口名称没有统一标准,调用者必须理解每个接口是否交付一个需要归还的引用。

对象销毁后,原来的地址不能再拿来增加计数。下一次需要同类资源时,应创建新对象,或通过有同步保护的注册表等机制取得有效引用。

C语言示例:手动配对get与put

先只看单线程里的三个动作:创建时把计数设为 1,共享时加一,释放时减一。为了突出这条主线,示例直接在 main() 中创建对象,用 get() 和 put() 管理引用。

#include <stdio.h>
#include <stdlib.h>

typedef struct {
    int count;
    int data;
} Resource;

Resource *get(Resource *r)
{
    r->count++;             /* 多一个拥有者 */
    return r;
}

void put(Resource *r)
{
    r->count--;             /* 少一个拥有者 */
    if (r->count == 0) {
        printf("destroyn");
        free(r);            /* 最后一个拥有者负责释放 */
    }
}

int main(void)
{
    Resource *a = malloc(sizeof(Resource));
    if (a == NULL) return 1;
    a->count = 1;
    a->data = 42;

    Resource *b = get(a);   /* 1 → 2,a、b 共享同一个对象 */
    put(a);                /* 2 → 1,b 还在使用,不能释放 */
    a = NULL;
    printf("%dn", b->data);
    put(b);                /* 1 → 0,现在才释放 */
    b = NULL;
    return 0;
}

保存为 reference_counting.c,使用 GCC 编译并运行:

gcc -std=c99 reference_counting.c -o reference_counting
./reference_counting

预期输出:

42
destroy

关键在于:put(a) 之后,B 还能读到 42;直到 put(b),才输出 destroy 并释放内存。两个指针指向同一个对象,但各自持有一份需要归还的引用。

这里用两个拥有者演示前面三个拥有者的同一套规则,计数路径缩短为 1 → 2 → 1 → 0。如果增加 C,只需再调用一次 get(),并在 C 用完后配对调用一次 put()。

这个教学示例要求:传给 get()、put() 的都是有效引用,每份拥有型引用只归还一次,计数不会溢出。put() 不会自动清空调用者变量,所以用完后手动置空;对空指针或已释放地址再次调用仍是错误。并发同步等问题放到后文讨论。

C++ shared_ptr:把引用配对交给对象生命周期

C++11 的 std::shared_ptr 用控制块记录共享所有权。拷贝一个拥有资源的 shared_ptr 会增加强引用计数;析构或 reset() 会释放它持有的引用;最后一个强引用释放时销毁被管理对象。

#include <iostream>
#include <memory>

struct Resource {
    ~Resource() { std::cout << "destroyn"; }
};

int main()
{
    auto a = std::make_shared<Resource>(); // 计数 1
    {
        auto b = a;                       // 计数 2
    }                                     // b 离开作用域,计数 1
    std::cout << "a still owns itn";
}                                         // a 离开作用域,计数 0,销毁资源

保存为 shared_ptr_demo.cpp,编译并运行:

g++ -std=c++11 shared_ptr_demo.cpp -o shared_ptr_demo
./shared_ptr_demo

预期输出:

a still owns it
destroy

这里不再手动调用 get()、put():拷贝 shared_ptr 相当于获取引用,离开作用域则自动归还。B 先结束时资源保留,A 最后结束时才销毁,与前面的 C 示例一致。

这是 RAII 的一种应用:用 C++ 对象的生命周期管理资源,正常离开作用域或异常展开时都自动归还引用。RAII 不等于引用计数,std::unique_ptr 同样采用 RAII,却使用独占所有权。

实际使用时还要区分几种动作:

写法 所有权效果
auto b = a; 拷贝共享所有权,强引用加一
auto b = std::move(a); 转移所有权,引用总数不增加,a 变空
auto p = a.get(); 返回借用的裸指针,不增加计数
a.reset(); 释放 a 的引用,可能触发销毁

use_count() 可以观察强引用数量,但不应作为并发环境中是否可以安全修改对象的判断条件。也不要用同一个裸指针分别构造两个独立的 shared_ptr:两个控制块会各自认为自己负责释放,导致重复释放。应拷贝已有的 shared_ptr。

关于控制块与接口行为,可参照微软的 shared_ptr 文档。强引用归零会销毁被管理对象;若还有弱引用,控制块会继续保留,以便判断对象是否已经失效。

为什么不直接手写一个智能指针?

仅有构造、拷贝构造和析构还不够。若遗漏拷贝赋值运算符,默认赋值会直接覆盖指针和计数器地址,既不释放左侧原有引用,也不增加右侧引用,造成泄漏或重复释放。

完整实现还需要处理自赋值、空指针、移动操作、异常安全和控制块分配失败等情况。因此,理解原理可以从上面的 C 示例入手;生产代码优先使用标准库,避免把教学用简化实现当成 shared_ptr 的替代品。

循环引用:为什么计数可能永远不归零?

假设 A 用 shared_ptr 持有 B,B 又用 shared_ptr 持有 A。外部引用全部释放后,A、B 仍各有一个来自对方的强引用,计数都不会归零,对象便无法自动销毁。

解决方法是根据所有权关系,把其中一条边改成 std::weak_ptr。弱引用不会增加强引用计数,访问前通过 lock() 尝试取得一个临时强引用:成功后,在该强引用存在期间对象保持存活;失败则说明对象已销毁。

例如,父节点拥有子节点,子节点只需要找到父节点,可以把两种关系写成:

struct Node {
    std::shared_ptr<Node> child; // 拥有子节点,增加强引用计数
    std::weak_ptr<Node> parent;  // 观察父节点,不增加强引用计数
};

这是一段关系定义,需包含 <memory>,并放进自己的程序中。重点只看两条边:父 → 子是拥有,子 → 父是观察,这样就不会形成互相维持存活的强引用环。

需要通过弱引用访问父节点时,用 lock() 取得临时强引用,成功后再使用;不要先检查是否过期,再假定对象一定还在。具体用法可参照微软的 weak_ptr 指南。

线程安全:原子加减计数就够了吗?

多个线程同时修改普通整数计数会产生数据竞争。但把计数改成原子变量,只解决了部分问题:如果一个线程已经释放最后一个引用,另一个线程再通过失效的裸指针增加计数,访问的仍然是已经释放的内存。

因此,并发设计还必须保证获取引用时,对象仍然存活。例如,从共享注册表取对象时,用同一把锁保护查找和获取引用,并与移除、销毁流程协调。对象内部业务数据的读写,也需要另外的同步机制。

shared_ptr 支持多个线程操作各自独立、但共享同一控制块的实例;这不代表多个线程可以无同步地修改同一个 shared_ptr 变量,也不代表它指向的对象自动线程安全。可参照微软的 C++ 标准库线程安全说明。

真实应用:Linux内核对象与文件硬链接

Linux 内核提供 struct kref 来帮助管理对象生命周期:初始化得到首个引用,新增拥有者时获取引用,释放时减少计数,最后一个引用释放时调用清理回调。内核还有其他计数类型,并非每个内核对象都直接使用 kref。具体约定见 Linux 内核 kref 文档。

文件硬链接也能帮助理解计数,不过需要区分两个概念:硬链接计数记录有多少目录项指向文件;进程打开文件后还持有打开文件引用。

ln file_a file_b
rm file_a

对于普通文件,删除 file_a 后,file_b 仍能访问相同内容。即使最后一个文件名也被删除,只要进程仍打开该文件,数据仍会保留;最后一个相关文件描述符关闭后,文件占用空间才可回收。因而「链接计数归零就立即释放数据」并不完整,打开文件并不会增加硬链接计数。这个行为可参照 Linux unlink 手册。

如何检查引用计数的生命周期流程?

检查代码时,可以沿着一个对象从创建到销毁的路径逐项确认:

  1. 创建成功后,首个引用交给谁? 分配失败是否直接返回,避免读写空指针?
  2. 哪些操作新增所有权? 裸指针赋值是借用还是持有,接口和注释是否一致?
  3. 每个引用在哪里释放? 正常结束、提前返回和错误分支都要覆盖;转移所有权时不要额外减计数。
  4. 最后一次释放后还有谁访问? 回调、异步任务和注册表可能仍保存旧地址,需要明确生命周期保护。
  5. 对象之间是否形成强引用环? 根据实际所有权选择弱引用,或显式拆除关系。
  6. 并发时如何取得有效引用? 分别检查计数更新、对象查找与业务数据同步。

验证时可记录创建、获取和释放事件:C 示例应走完 1 → 2 → 1 → 0,且只销毁一次;C++ 示例应验证离开内层作用域后对象仍存活。扩展弱引用示例时,可以验证父节点销毁后 lock() 返回空。还应覆盖分配失败、空引用释放和错误分支清理,条件允许时使用 AddressSanitizer 或 Valgrind 检查非法访问与泄漏。

引用计数、独占所有权与连接池怎么选?

场景 可以采用的方式 需要明确的边界
生命周期由单个对象负责 栈对象、RAII、unique_ptr 谁拥有,何时转移
多个模块需要独立保持资源存活 引用计数、shared_ptr 每次获取与释放是否配对
父子结构带反向观察关系 强引用配合 weak_ptr 哪个方向承担所有权
大量任意对象关系和引用环 根据运行环境评估追踪式 GC 等方案 回收时机与运行时成本
数据库连接池 池管理连接,租约管理借用期限 归还池与销毁连接是不同动作

连接池通常希望最后一个借用者结束时把连接归还池中,以便复用,而非每次都断开连接。引用计数可以参与租约设计,却不能替代容量限制、连接健康检查和空闲回收策略。

设计共享资源时,先把「谁必须保证它继续存在」写清楚,再选择管理方式。引用计数让每个拥有者独立表达这个承诺;当最后一个承诺解除时,资源就有了明确的收尾时机。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注