第 7 课:对象怎么生、怎么死
# 第 7 课:对象怎么生、怎么死
嘿,我是小方。 上一课咱们认识了 OBS 的核心抽象 —— 一切皆 Source,也掀开盖子看了实例内部长什么样。今天接着往下挖一个绕不开的问题:这些对象,在程序跑起来之后,是怎么被创建、怎么被销毁的?一个摄像头源同时被三个场景用着,谁有权把它删掉?删早了会怎样? 这一课讲两样东西:管对象生死的 引用计数 + 弱引用,和那个藏在每个对象身体里、装着全部设置的
obs_data。这俩都是 C 语言写大型项目的硬功夫,也是你以后写插件一定会碰到的。
# 7.1 先看一个 C 程序员的「原罪」
📍我们在哪:上一课看到「一切皆 source」,这一课看这些 source 对象怎么生、怎么死——先从 C 没有垃圾回收这个「原罪」说起。
如果你平时写 Java、Python、JavaScript,可能从没操心过「对象什么时候删」这种事 —— 因为这些语言有个垃圾回收器(GC),像个自动管家,默默帮你把没人用的对象清理掉。
但 OBS 是用 C / C++ 写的,C 语言没有这个管家。

在 C 里,你 malloc 申请的内存,就得自己负责 free 还回去。这件事看着简单,实则是 C 程序员的「原罪」—— 两头都是坑:
- 忘了
free→ 内存泄漏,程序越跑越胖,最后吃光内存; free早了 → 你手里的指针指向一块已经被释放的内存(叫悬空指针),一旦再用它,轻则数据错乱,重则当场崩溃。
而真正要命的难题是这个:当一个对象被好几个地方「同时共享」时,到底谁负责删它?
想象一下:你的摄像头源,同时出现在「游戏场景」和「待机场景」里,预览窗口也正显示着它 —— 三个地方都在用同一个源。这时候你关掉「游戏场景」,该不该把摄像头源删掉?
当然不能! 因为待机场景和预览还在用它。可「游戏场景」自己又怎么知道别人还在不在用?
这道题,OBS 用一个经典办法解决 —— 引用计数。
# 7.2 引用计数:给对象记个「还有几个人在用」
📍接上一节:知道了「多处共享时谁来删」是难题,这一节给出 OBS 的答案——给每个对象记一个「还有几人在用」的计数。
办法朴素得很:给每个对象身上挂一个计数器,记录「现在有几个地方在引用我」。

规则就三条:
- 对象一创建,计数 = 1(创建它的人,自然算第一个用户);
- 每多一个地方要用它,计数 +1(调
obs_source_get_ref,"我也要拿一个引用"); - 每个地方用完了,计数 −1(调
obs_source_release,"我不用了"); - 计数归零的那一刻 —— 说明没人用了 —— 这才真正销毁对象(调它的
destroy回调,还内存)。
关键就在第 4 条:只有最后一个使用者撒手,对象才会被删。 任何一个地方调 release,都只是把计数减一,绝不会贸然删掉别人还在用的东西。这就把「谁负责删」这道难题,变成了一道自动的算术题。
小方提醒一个实现细节:OBS 源码里这个计数其实是从 0 起算的(
create后内部记 0,表示"一个所有者"),release减到 −1 才触发销毁 —— 这是个小优化。道理完全一样,你按「归零销毁」来理解就行,不必纠结那个偏移。
# 7.3 OBS 实战:一个源,三处共用
📍为什么现在讲这个:规则讲完,拿最开头那个「摄像头被三处引用」的例子跑一遍,看引用计数怎么让「共享」变安全。
把上面的规则套回我们的摄像头例子,一切就通了:

摄像头源同时被「场景 A」「场景 B」「预览窗口」引用,它的计数就是 3。现在:
- 你关掉场景 A → 场景 A 调
obs_source_release→ 计数变 2 → 源照样活着,因为场景 B 和预览还在用; - 再关掉场景 B、关掉预览 → 计数一路减到 0 → 这时才真正销毁。
谁都不会被别人提前删掉脚下的对象,「共享」这件事一下子变得安全了。 上一课结尾我让你混个脸熟的那两个函数,就是干这个的:
// libobs/obs.h
obs_source_t *obs_source_get_ref(obs_source_t *source); // 拿一个引用,计数 +1 :1062
void obs_source_release(obs_source_t *source); // 还掉引用,计数 −1 :1057
2
3
记住一条铁律:get_ref 和 release 必须成对出现。 你拿了引用却忘了还,对象就永远删不掉(泄漏);你没拿引用却乱还,计数提前归零,对象被误删(崩溃)。这是写 OBS 插件最容易翻车的地方,没有之一。
# 7.4 强引用的「甜蜜负担」:有时你并不想拖住它
📍转折:引用计数看着完美,但它有个副作用——只要你持有引用,对象就删不掉;有些场景这反而碍事。
到这儿引用计数看起来很完美。但它有个甜蜜的负担:只要你持有一个引用(我们叫它「强引用」),对象就一定不会被删。
大多数时候这正是我们要的。可有些场景,这反而碍事。举个例子:
假设有个「热键」绑定了某个摄像头源(按 F1 就切到它)。这个热键需要记住这个源。但 —— 如果用户在界面上手动删掉了这个摄像头源,你觉得该删掉吗?
应该删掉! 用户都明确删了,你一个热键凭什么因为"记着它"就把它强行续命、删不掉?可如果热键持有的是强引用,计数就永远落不到 0,这个源就成了删不掉的幽灵。
那热键改成存一个裸指针(obs_source *)行不行?不行,那就掉进 7.1 说的坑里了:

你存了裸指针,可对象被用户删了之后,你那个指针就悬空了 —— 指向一块已经释放的内存。等你按 F1 一用它,程序当场崩溃。
我们需要的是一种「记住但不拖住」的引用:它不阻止对象被删,但又能让你在用之前安全地问一句「它还活着吗?」。
这就是 弱引用(weak reference)。
# 7.5 幕后:一个「控制块」管两个计数
📍接上一节:上一节要的是「记住但不拖住」,这一节揭幕——弱引用靠一个控制块 + 强弱两个计数做到这件事。
弱引用怎么做到「既不拖住对象、又能安全查询」的?秘密在于:OBS 给每个对象配了一个独立的小账本 —— 控制块,里面记着两个计数。

翻开源码,这个设计干净得让人舒服:
// libobs/obs-internal.h:613
struct obs_weak_ref {
volatile long refs; // 强引用数:还有几个人「在用」
volatile long weak_refs; // 弱引用数:还有几个人「记着」
};
struct obs_weak_object { // 控制块 :618
struct obs_weak_ref ref;
struct obs_context_data *object; // 指向真正的对象
};
2
3
4
5
6
7
8
9
10
(还记得第 6 课说每个对象都有个共享底座 obs_context_data 吗?它里面就有个 control 字段指向这个控制块 —— obs-internal.h:634。)
两个计数,各管一摊:
- 强引用(
refs)管「对象」的命:refs归 0 → 销毁真正的对象(还像素、还设置、还缓冲那些大块内存)。 - 弱引用(
weak_refs)管「控制块」的命:只有refs和weak_refs都归 0,才连这个小账本一起删掉。
为什么对象删了、账本还得留着?因为还有人(弱引用持有者)指着这个账本呢。对象虽然没了,但账本还在,账本上写着「对象已死(refs=0)」。于是当热键按下、想用这个源时,它做一个**「弱 → 强」升级**:
// libobs/obs.h:1064
obs_source_t *obs_weak_source_get_source(obs_weak_source_t *weak);
2
这个函数会去看账本:对象还活着(refs > 0)→ 给你一个强引用(计数 +1),你放心用;对象已经死了 → 直接返回 NULL。 你只要判一下返回值是不是 NULL,就永远不会踩到悬空指针。安全。
配套的弱引用 API 就这几个:
// libobs/obs.h
obs_weak_source_t *obs_source_get_weak_source(obs_source_t *source); // 拿弱引用 :1063
obs_source_t *obs_weak_source_get_source(obs_weak_source_t *w); // 弱→强 升级 :1064
bool obs_weak_source_expired(obs_weak_source_t *weak); // 对象死了没? :1065
2
3
4
🔧 小方彩蛋(写过 C++ 的同学会心一笑): 这套「一个控制块 + 强弱两个计数」的设计,跟 C++ 标准库的
std::shared_ptr/std::weak_ptr是一模一样的!shared_ptr就是强引用(管对象),weak_ptr就是弱引用(管控制块、能.lock()升级)。OBS 用纯 C 手搓了一套shared_ptr。所以你看,经典的设计模式是跨语言相通的 —— 看懂了 OBS 这套,你就顺带看懂了shared_ptr的原理。
# 7.6 源码深读:翻开 .c,看 release 一路怎么走
📍为什么现在讲这个:道理讲清了,这一节翻开
obs-source.c,顺着obs_source_release一路走到底,看引用计数怎么落成真代码。
前面五节把"道理"讲清楚了。但小方知道,总有人(也包括我自己)不亲眼看一遍真代码就不踏实。这一节咱们就翻开 libobs/obs-source.c,顺着 obs_source_release 的函数体一路走到底,看引用计数到底怎么落到一行行 C 代码上。边看代码边对这张图:

# 第一步:release 本体 —— 减一,看够不够零
// libobs/obs-source.c:853
void obs_source_release(obs_source_t *source)
{
if (!obs && source) { ...; return; } // ① 核心都关了就别管了
if (!source) return; // ② 防空指针
obs_weak_source_t *control = get_weak(source); // ③ 取出控制块(7.5 那个账本)
if (obs_ref_release(&control->ref)) { // ④ 原子减 1,够「归零」才返回 true
obs_source_destroy(source); // ⑤ 真正销毁
obs_weak_source_release(control); // ⑥ 对象自带的那个弱引用也还掉
}
}
2
3
4
5
6
7
8
9
10
11
核心是第 ④ 行的 obs_ref_release。去看它的实现 —— 就一行:
// libobs/obs-internal.h:680
static inline bool obs_ref_release(struct obs_weak_ref *ref)
{
return os_atomic_dec_long(&ref->refs) == -1; // 原子 refs−−,减完正好 −1 就返回 true
}
2
3
4
5
os_atomic_dec_long 把 refs 原子地减一(原子 = 多个线程同时减也不会算错),再判断"减完是不是 −1"。到这儿,7.2 节我让你"先别纠结"的那个 −1 偏移,真相大白了:OBS 里 refs = 0 表示"还有 1 个所有者"。 创建后 refs=0(创建者这一个),每个 get_ref 加一,每个 release 减一;最后一个所有者 release 时,refs 从 0 减到 −1 —— == -1 成立,触发销毁。这不是 bug,是个精心设计的偏移,妙用马上就到。
# 第二步:destroy —— 这里调用了插件的 vtable!
release 确认归零后调 obs_source_destroy,它最终走到这段(obs-source.c:765):
obs_context_wait(&source->context); // 先等这一帧渲染做完,别半道拆房子
obs_source_dosignal(source, "source_destroy", "destroy"); // 喊一嗓子:我要死了
if (source->context.data) {
source->info.destroy(source->context.data); // ★ 调用插件填进登记表的 destroy 回调
source->context.data = NULL;
}
2
3
4
5
6
这三行,把好几课缝在了一起:
obs_source_dosignal(..., "destroy")—— 对外广播一个 "destroy" 信号。谁关心这个源的死活(比如界面要把它从列表移除),订阅了就能收到通知。这正是下一课"信号机制"的活例子。source->info.destroy(source->context.data)—— 还记得第 6 课那张登记表吗?source->info就是插件填的那张obs_source_info(vtable),.destroy是其中一个函数指针。这一行就是引擎回调插件自己写的销毁函数(图片源在这里释放它加载的纹理)。引擎自始至终不关心销毁细节 —— 它只负责在正确的时机,按一下插件留下的那个按钮。一行代码,把"引用计数(本课)→ vtable(第 6 课)"实打实接上了。
# 第三步:反过来,get_ref 怎么"加一"?—— 一段无锁好戏
减一看完,加一(get_ref)呢?你以为是 refs++ 这么简单?还真不是。先看入口:
// libobs/obs-source.c:888
obs_source_t *obs_source_get_ref(obs_source_t *source)
{
if (!source) return NULL;
return obs_weak_source_get_source(get_weak(source)); // 注意:它复用了「弱→强升级」!
}
2
3
4
5
6
有意思 —— 哪怕你手里已有一个活的强引用,想再加一个,OBS 也让你走"弱→强升级"这唯一一条路。因为这条路线程安全。看它:
// libobs/obs-source.c:906
obs_source_t *obs_weak_source_get_source(obs_weak_source_t *weak)
{
if (!weak) return NULL;
if (obs_weak_ref_get_ref(&weak->ref)) // 尝试原子地 +1
return weak->source; // 成功 → 对象还活着,给你
return NULL; // 失败 → 对象已死,返回 NULL(永不悬空)
}
2
3
4
5
6
7
8
核心又落到 obs_weak_ref_get_ref。这是全场最精彩的一段:
// libobs/obs-internal.h:695
static inline bool obs_weak_ref_get_ref(struct obs_weak_ref *ref)
{
long owners = os_atomic_load_long(&ref->refs); // 先读一眼当前 refs
while (owners > -1) { // 只要 > −1(没死),就尝试加一
if (os_atomic_compare_exchange_long(&ref->refs, &owners, owners + 1))
return true; // CAS 成功 → 抢到引用
// CAS 失败:别的线程刚改过 refs,owners 已被刷成最新值,循环重试
}
return false; // owners ≤ −1 → 对象死了,放弃
}
2
3
4
5
6
7
8
9
10
11
这是一个经典的 CAS(比较并交换)自旋循环。它要一口气、原子地完成两件事:① 检查对象还活着(refs > -1)② 把 refs 加一 —— 中途不容别的线程插队。os_atomic_compare_exchange_long(&refs, &owners, owners+1) 的意思是:"如果 refs 还等于我刚读到的 owners,就改成 owners+1"。万一这一瞬间被别的线程改了,CAS 失败,owners 自动更新为最新值,while 再转一圈重试。
现在,第一步埋的"妙用"兑现了:因为"死亡"被编码成 refs < 0,这里的存活判断就化简成一个最廉价的整数比较 owners > -1。 一个小小的偏移,让"检查存活 + 增加引用"在一个无锁循环里干净完成 —— 这就是为什么弱引用升级在多线程下,永远不会拿到一个正在被销毁的对象。
小方说句心里话:
obs_source_release加obs_weak_ref_get_ref这两段,统共不到 20 行,却把"引用计数、弱引用、线程安全、vtable 回调、信号"全串在一起了。概念是平的,代码是立体的。 这门课里,凡是关键的地方,我都会这样带你翻开.c走一遍(上一课"一切皆 source"结尾也做过一次)。
# 7.7 换个主角:obs_data,对象的「设置抽屉」
📍转折:生死讲完,换个主角——每个实例
context里那个装着全部设置的obs_data,你以后会天天打交道。
讲完生死,咱们换个主角 —— 一个你以后会天天打交道的东西:obs_data。
第 6 课说过,每个 source 实例的 context 里装着它的「设置」。那个设置,到底长什么样?答案就是 obs_data。源码里它的自我介绍很直白:
// libobs/obs-data.h:33
/* OBS data settings storage ... This is designed for JSON serialization. */
// OBS 的设置存储……专为 JSON 序列化而设计。
2
3
说白了,obs_data 就是一个「类 JSON 的键值表」 —— 给每个对象配一个「设置抽屉」,里面一格一格地放着「键 → 值」:

比如一个图片源的设置抽屉,大概是这样:
"file" : "logo.png" (string)
"width" : 1920 (int)
"opacity" : 0.8 (double)
"loop" : true (bool)
2
3
4
它支持的值类型,正好就是 JSON 的那几种:字符串、数字、布尔、对象、数组(enum obs_data_type,obs-data.h:46)—— 这不是巧合,它生来就是为了能和 JSON 互转(后面会讲)。
存取也简单,一个键一个值地拿:
// libobs/obs-data.h
const char *obs_data_get_string(obs_data_t *d, const char *name); // 读字符串 :120
long long obs_data_get_int(obs_data_t *d, const char *name); // 读整数
void obs_data_set_string(obs_data_t *d, ...); // 写字符串 :83
void obs_data_set_int(obs_data_t *d, ...); // 写整数
2
3
4
5
想拿到某个 source 的设置抽屉?一个函数:
// libobs/obs.h:1164
obs_data_t *obs_source_get_settings(const obs_source_t *source);
2
顺便说一句,
obs_data自己也是引用计数的(obs_data_addref/obs_data_release,obs-data.h:64/65)。看,本课前半场讲的那套生死管理,在 OBS 里是无处不在的统一机制 —— source、output、encoder、连设置数据obs_data都用同一套。学一遍,通吃。
# 7.8 别小看这个抽屉:它干三件大事
📍接上一节:知道了
obs_data是个类 JSON 键值表,这一节看它扛起的三件大事:存档、属性面板、设置更新。
obs_data 看着只是个键值表,但 OBS 里三件顶重要的事,全靠它:

① 存档 / 读档(工程的保存与加载)。
你的场景、来源、各种设置,关掉 OBS 还在,重开能恢复 —— 靠的就是把每个对象的 obs_data 序列化成 JSON 存进工程文件,下次再读回来。对应的函数一看就懂:obs_data_get_json()(对象 → JSON 字符串,obs-data.h:67)、obs_data_create_from_json()(JSON → 对象,:61)。obs_data 之所以长得像 JSON,就是为了这一步。
② 属性面板(你改设置的那个界面)。
你右键来源 →「属性」,弹出的那个面板里,每一个控件(输入框、滑块、下拉…)都绑着 obs_data 里的一个键。你拖一下滑块、填一个路径,本质就是往这个对象的 obs_data 抽屉里写值。(还记得第 6 课登记表里的 get_properties 回调吗?它就是用来描述这个面板长什么样的。)
③ 设置更新(把新设置喂回给 source)。
设置一变,引擎就把更新后的 obs_data 通过 obs_source_update()(obs.h:1112)传给 source 的 update 回调,source 据此刷新自己(比如换了 file,就重新加载那张图)。界面 → obs_data → source,一条线串起来。
所以你看,obs_data 表面是个不起眼的键值表,实则是界面、引擎、插件、磁盘文件之间传递配置的通用货币。理解它,你就理解了 OBS 的"设置"是怎么流动的。
# 7.9 本课小结
- C 没有 GC,对象得手动管;难点在「对象被多处共享时谁来删」。
- 引用计数解决它:创建 = 1,
obs_source_get_ref+1,obs_source_release−1,归零才真正销毁。get_ref/release必须成对(obs.h:1062/1057)。 - 强引用保对象活着;但有时你想「记住而不拖住」,又不能用会悬空的裸指针 —— 于是有了弱引用。
- 幕后是一个控制块(
obs_weak_object,obs-internal.h:618),里面两个计数:refs(强,管对象)、weak_refs(弱,管控制块)。弱引用用obs_weak_source_get_source(obs.h:1064)升级成强引用,对象已死则返回NULL—— 永不悬空。这套设计 = C 手搓的shared_ptr/weak_ptr。 obs_data是对象的「设置抽屉」:一个类 JSON 的键值表(obs-data.h:33),类型就是 JSON 的类型;obs_source_get_settings取它(obs.h:1164)。它本身也引用计数。- 它干三件大事:存档/读档(⇄ JSON)、属性面板(控件 ↔ 键)、设置更新(
obs_source_update→update回调)。 - 源码深读(7.6):
obs_source_release(obs-source.c:853)→obs_ref_release原子减一(obs-internal.h:680)→ 归零则obs_source_destroy→source->info.destroy(...)(调第 6 课的 vtable)+dosignal(发第 8 课的信号);而get_ref走obs_weak_ref_get_ref(:695)的无锁 CAS 自旋,refs<0即"死亡"正是为了让存活判断化简成> -1。
# 7.10 动手 / 观察(本课作业)
- 看看工程文件里的
obs_data:OBS 的场景配置存成 JSON 文件(Windows 一般在%APPDATA%\obs-studio\basic\scenes\下,后缀.json)。用文本编辑器打开,你会看到一棵 JSON 树 —— 那正是各个对象的obs_data序列化的结果。找找你某个来源的settings,里面是不是有file、width这类键? - 数引用计数的成对调用:在源码里搜
obs_source_get_ref,随便挑一处,看看附近是不是有配套的obs_source_release(可能在另一个函数里)。体会「成对」这条铁律。 - (思考题):为什么 OBS 要专门搞「弱引用」,而不是让所有引用都用强引用?如果热键、UI 这些"旁观者"全用强引用,会出什么问题?(提示:用户还删得掉源吗?)
# 下一课预告
我们已经知道对象怎么生、怎么死,设置怎么存。但还有个问题:这些对象之间、以及对象和界面之间,是怎么互相通知的?比如「某个源被静音了」「某个场景切换了」,界面是怎么第一时间知道、然后更新显示的?总不能让界面拿着所有源一直轮询吧?
第 8 课:模块之间怎么说话 —— 信号与回调 —— 我会带你看 OBS 那套贯穿全身的 signal(信号)/ proc handler(过程处理器) 机制:它让引擎、界面、插件之间松耦合地互相通知、互相调用,谁也不用直接抱住谁。这是 OBS 架构里另一根"隐形的大梁"。下节课见。
📁 本课配图:
imgs/07-01~imgs/07-08📌 源码锚点:libobs/obs-internal.h:613(obs_weak_ref)、:618(obs_weak_object 控制块)、:634(context.control)、:680(obs_ref_release)、:695(obs_weak_ref_get_ref / CAS)、libobs/obs-source.c:853(release 实现)、:765(destroy_defer → info.destroy)、:888(get_ref 实现)、:906(weak_get_source 实现)、libobs/obs.h:1057/1062/1063/1064/1065/1112/1164、libobs/obs-data.h:33/46/61/64/67/83/120
本留言区仅对应当前文章,欢迎补充观点、提出问题或帮助修正文中疏漏。 留言由 GitHub/Gitalk 提供,需要使用 GitHub 登录。
社区交流
讨论与留言