第 8 课:模块之间怎么说话 —— 信号与回调
# 第 8 课:模块之间怎么说话 —— 信号与回调
嘿,我是小方。 第 6、7 课里我们俩反复撞见一行神秘代码:
obs_source_dosignal(source, "source_create", ...)、obs_source_dosignal(source, "destroy", ...)。当时我说"这是发信号,第 8 课讲"。好,这一课还债。 信号(signal)是 OBS 架构里另一根隐形大梁 —— 引擎、界面、插件之间所有的"互相通知"几乎都靠它。这一课我们代码优先,而且这次我把上一版省略的硬骨头(延迟删除、可重入、全局信号、calldata内部、proc实战)全啃一遍。翻开libobs/callback/,走起。
# 8.1 难题:怎么"通知",又不把模块焊死
📍我们在哪:前两课那行
obs_source_dosignal一直没解释,这一课还债——先摆出难题:模块之间怎么互相通知,又不把彼此焊死?
具体场景:用户在界面把某个源静音了,界面那个小喇叭图标得变样。问题是 —— 界面怎么知道"源被静音了"这件事发生了?
几个笨办法,都不行:

- 引擎直接调界面函数? 那等于把"核心引擎"和"某个界面"焊死了。可
libobs是独立引擎 —— 它根本不知道有没有界面、是哪个、甚至可能同时有好几个东西(界面、插件、脚本)都想知道。核心去依赖界面,方向反了。 - 界面一直轮询? 每秒问几十次"静音了吗",绝大多数答案都是"没变",纯浪费。
正解是经典的发布 / 订阅(publish / subscribe),在 OBS 里叫 signal(信号)。
比方说它像订报纸:报社(发布者)只管印,不认识每个订户;你(订阅者)提前订了,报纸就自动送上门。发布者和订阅者互不认识,中间靠"信号"解耦 —— 这就是松耦合。
# 8.2 源码:一个信号处理器,内部长什么样
📍接上一节:知道了要用发布/订阅,这一节直接翻源码,看一个
signal_handler内部的三层数据结构长什么样。
直接看代码。libobs/callback/signal.c 开头三个结构体,把骨架交代得明明白白:

// libobs/callback/signal.c:23
struct signal_callback { // 一个「订阅者」
signal_callback_t callback; // 回调函数
void *data; // 用户数据(回调时原样带回)
bool remove; // 「待删除」标记(8.4 的关键)
bool keep_ref;
};
// signal.c:30
struct signal_info { // 一个「具名信号」,比如 "mute"
struct decl_info func; // 声明(名字 + 参数类型)
DARRAY(struct signal_callback) callbacks; // 订阅这个信号的所有回调
pthread_mutex_t mutex;
bool signalling; // 「我正在广播」标志(8.4 的关键)
struct signal_info *next; // 链表:下一个信号
};
// signal.c:87
struct signal_handler { // 「信号处理器」,挂在每个对象身上
struct signal_info *first; // 信号链表头
volatile long refs;
DARRAY(struct global_callback_info) global_callbacks; // 订了「所有信号」的人
...
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
三层串起来就是:
一个
signal_handler挂着一条信号链表(每个具名信号signal_info一个节点);每个信号挂着一个订阅者数组callbacks。另外 handler 还单独存了一份global_callbacks—— 那是订了"全部信号"的人。
先记住 signal_callback.remove 和 signal_info.signalling 这两个字段,8.4 你会看到它们撑起一处很精巧的设计。
# 8.3 两个简单动词:声明、订阅
📍承上启下:结构看懂了,先拿两个简单动作热身——发布者怎么「声明」信号、订阅者怎么「订阅」。
广播是重头戏,留到下一节;先看两个简单的。
① 声明信号 signal_handler_add(signal.c:160) —— 发布者"挂牌"说明自己会发哪些信号,造个 signal_info 接进链表:
sig = signal_info_create(&func); // 造新信号节点
if (!last) handler->first = sig; // 接进链表
else last->next = sig;
2
3
② 订阅信号 signal_handler_connect(signal.c:191) —— 订阅者把回调挂上去,核心两步:找到信号,push 进它的数组:
struct signal_callback cb_data = {callback, data, ...}; // 打包成一个订阅者
sig = getsignal(handler, signal, &last); // 按名字找到信号
...
da_push_back(sig->callbacks, &cb_data); // 塞进订阅者数组(signal.c:222)
2
3
4
getsignal 顺着链表 strcmp 比对名字,朴素。

# 8.4 源码深读:signal() 的完整真相
📍为什么现在讲这个:声明和订阅都是铺垫,广播才是灵魂——这一节把
signal_handler_signal的三段循环一段不漏地拆开。
广播 signal_handler_signal 是全场灵魂。上一版我只贴了主循环,但它其实有三段,每段都有讲究。这次咱们一段不漏(signal.c:290):

void signal_handler_signal(signal_handler_t *handler, const char *signal, calldata_t *params)
{
struct signal_info *sig = getsignal_locked(handler, signal); // 按名字找到信号
if (!sig) return;
pthread_mutex_lock(&sig->mutex);
sig->signalling = true; // ① 竖起「我正在广播」的旗子
for (size_t i = 0; i < sig->callbacks.num; i++) { // ── 循环①:广播 ──
struct signal_callback *cb = sig->callbacks.array + i;
if (!cb->remove) // 标了「待删」的跳过
cb->callback(cb->data, params); // ★ 挨个调回调
}
for (size_t i = sig->callbacks.num; i > 0; i--) { // ── 循环②:延迟清理 ──
struct signal_callback *cb = sig->callbacks.array + i - 1;
if (cb->remove)
da_erase(sig->callbacks, i - 1); // 现在才真正删掉
}
sig->signalling = false; // 放下旗子
pthread_mutex_unlock(&sig->mutex);
/* ── 循环③:全局回调 ── */
for (size_t i = 0; i < handler->global_callbacks.num; i++) {
struct global_callback_info *cb = handler->global_callbacks.array + i;
if (!cb->remove)
cb->callback(cb->data, signal, params); // 注意:多了个 signal 名参数
}
...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 为什么是"两个循环"?这是本课最精巧的一处
你可能纳闷:循环①里既然有 if (!cb->remove) 跳过,那"删"为什么不当场删,非要留个循环②?
因为有个要命的场景:广播到一半,某个回调可能在自己内部把自己 disconnect 掉(很常见 —— "一次性"回调收到通知后就注销自己)。如果 disconnect 当场从 callbacks 数组里 da_erase,那正在跑的循环①下标就乱套了:后面的元素整体前移,轻则漏调、重则越界崩溃。
OBS 的解法,就藏在 disconnect 里(signal.c:251):
idx = signal_get_callback_idx(sig, callback, data);
if (idx != DARRAY_INVALID) {
if (sig->signalling) { // 正在广播中?
sig->callbacks.array[idx].remove = true; // 不删!只「标记」
} else {
da_erase(sig->callbacks, idx); // 没在广播,才真删
}
}
2
3
4
5
6
7
8
看到那个 if (sig->signalling) 没?正在广播时,disconnect 不动数组,只把 remove 标成 true;等广播的循环①跑完,循环②再统一把标了 remove 的删掉。这就是延迟删除(deferred removal) —— 一处教科书级的"不能边遍历边修改集合"的解法。
🔧 小方彩蛋: 还有个更隐蔽的细节。
signal_info的互斥锁是用pthread_mutex_init_recursive(signal.c:47)创建的递归锁。为什么?因为一个回调在执行时,可能又触发同一个 handler 发另一个信号(回调里改了别的属性 → 又发update信号……)。普通互斥锁这时会自己锁死自己;递归锁允许同一线程重复加锁,才不会死。这种"回调里再发信号"的可重入,正是信号系统最容易埋雷的地方,OBS 用递归锁稳稳接住了。
# 第三段:全局回调
循环③遍历的是 global_callbacks —— 通过 signal_handler_connect_global 订阅的人。它们订的不是某一个信号,而是这个 handler 上的所有信号。所以它们的回调签名多一个参数:cb->callback(cb->data, signal, params) —— 那个 signal 字符串告诉它"刚才响的是哪个信号"。日志、调试、转发,常用这个。
顺带一提生命周期:
signal_handler自己也有个refs计数。connect_ref连接时会refs++(signal.c:218),让"只要还有人连着,handler 就不会被销毁";断开或被删时refs--,归零才真正signal_handler_actually_destroy(signal.c:274)。又是第 7 课那套引用计数的思想 —— 在 OBS 里真是无处不在。
# 8.5 calldata:万能参数袋,以及它内部怎么打包
📍接上一节:上一节回调全长一个样、都收一个
params,这一节讲清那个params是什么——一个能装任意参数的万能袋子calldata。
上面回调全长一个样:cb->callback(cb->data, params)。这个 params 是什么?为什么所有信号回调签名都一样?
C 语言的硬限制:回调类型是固定的(signal.h:37):
typedef void (*signal_callback_t)(void *data, calldata_t *params);
可 "mute" 要传 bool、"rename" 要传两个字符串、"volume" 要传 float…… 个数类型都不同,怎么塞进同一个签名?答案就是 calldata_t *params —— 一个万能参数袋:

// libobs/callback/calldata.h:30 ——「存放 传给/传出 信号、过程、回调的参数」
struct calldata {
uint8_t *stack; // 其实就是一块字节缓冲
size_t size, capacity;
bool fixed; // true = 用栈上的固定缓冲(不 malloc)
};
2
3
4
5
6
发的时候 calldata_set_ptr(d, "source", src) 塞进去,收的时候 calldata_get_bool(d, "muted", &m) 按名字取。袋子里能放的类型(calldata.h:34)正好是 JSON 那几样:INT / FLOAT / BOOL / PTR / STRING。
# 内部:它怎么把"名字→类型→值"塞进一块字节缓冲?
翻 calldata.c,核心是 calldata_set_data(:179):它在那块 stack 字节缓冲里,顺序排放一条条记录,每条大致是 [这条多大][名字][类型][值]。cd_getparam 找参数时,就是从头扫这块缓冲、按名字比对。calldata_get_* 取值同理。换句话说,calldata 是个手写的、极轻量的"序列化键值表",专为"塞进固定回调签名"而生。
注意 fixed 那个字段:obs_source_dosignal 用的是 calldata_init_fixed(&data, stack, 128) —— 直接拿栈上 128 字节当缓冲,连 malloc 都省了,发信号是高频操作,这点开销抠得很细。
# IN / OUT:信号还能"协商"
参数有 IN / OUT 之分(calldata.h:43)。OUT 意味着回调能往袋子里写值传回去。这就解释了那个奇怪声明 "void volume(ptr source, in out float volume)" —— 监听音量信号的回调,可以把音量改了再传回来。信号因此不只是"通知",还能"协商"。
还有个你可能好奇的:
"void mute(ptr source, bool muted)"这串字符串,引擎怎么知道它叫mute、带一个ptr一个bool?靠parse_decl_string(decl.c:179)—— 它内部用一个 C 语言风格的词法分析器(cf_parser)把这串"伪函数原型"真的解析了一遍,拆出返回类型、信号名、每个参数的类型和in/out方向,存进decl_info。是的,OBS 为了这套信号声明,内置了一个迷你 C 声明解析器。
# 8.6 源码:一个源到底发哪些信号?
📍接上一节:机制拆透了,回到最初的问题——一个源实际会发哪些信号?顺便看
obs_source_dosignal那一嗓子怎么喊出去。
回到最初的问题。一个源会发哪些信号?obs-source.c 里有张明明白白的清单:

// libobs/obs-source.c:75
static const char *source_signals[] = {
"void destroy(ptr source)", // 被销毁(第 7 课!)
"void update(ptr source)", // 设置更新了
"void mute(ptr source, bool muted)", // 静音/取消
"void rename(ptr source, string new_name, string prev_name)", // 改名
"void volume(ptr source, in out float volume)", // 音量(注意 in out)
...
};
2
3
4
5
6
7
8
9
源初始化时,一次性全声明上去(obs-source.c:124):
return signal_handler_add_array(source->context.signals, source_signals);
注意 source->context.signals —— 每个源的信号处理器就挂在它的 context 里! 第 6/7 课说 context 装着设置(obs_data)、引用计数控制块,现在又添一样:信号处理器(还有它兄弟 context.procs,8.9 讲)。
# obs_source_dosignal:那一嗓子怎么喊出去的
揭开第 6/7 课那个 obs_source_dosignal 的真面目(obs-internal.h:1030):
static inline void obs_source_dosignal(struct obs_source *source,
const char *signal_obs, const char *signal_source)
{
struct calldata data;
uint8_t stack[128]; // 栈缓冲,省掉 malloc
calldata_init_fixed(&data, stack, 128);
calldata_set_ptr(&data, "source", source); // 往袋子里放:"source" = 这个源
if (signal_obs && !source->context.private)
signal_handler_signal(obs->signals, signal_obs, &data); // ① 发给「全局」
if (signal_source)
signal_handler_signal(source->context.signals, signal_source, &data); // ② 发给「源自己」
}
2
3
4
5
6
7
8
9
10
11
12
13
14
它发两份!这就引出下一节最关键的一点。
# 8.7 两个收信箱:全局信号 vs 源自己的信号
📍承上启下:上一节发现
dosignal一次「发两份」,这一节解释为什么——OBS 有全局和源自己两层收信箱。
obs_source_dosignal 为什么要发两份、还传两个不同的名字?因为 OBS 有两层收信箱:

全局信箱
obs->signals—— OBS 核心自己有一个全局信号处理器,声明了一串"以源为中心"的全局信号(obs.c:1078):static const char *obs_signals[] = { "void source_create(ptr source)", "void source_destroy(ptr source)", "void source_remove(ptr source)", ... };1
2
3
4
5
6谁会订阅它?界面! 界面想知道"任何源被创建/删除了"好更新列表,它当然不会去一个个源上订阅(源还没创建呢),而是订全局的那一个。
本地信箱
source->context.signals—— 每个源自己的处理器(就是 8.6 的source_signals[])。谁订它?只关心"这一个特定源"的人 —— 比如一个属性对话框,只想知道它正盯着的那个源改没改名。
看出那个命名玄机了吗:全局的叫 "source_destroy",本地的叫 "destroy"。所以 obs_source_dosignal(source, "source_destroy", "destroy") 一次调用,给两个信箱各发各的,名字还不冲突。设计得很周到。
# 翻到界面这一侧:真实的订阅代码
光说不练假把式。界面到底怎么订的?翻 frontend/widgets/OBSBasic.cpp:917:
signalHandlers.emplace_back(obs_get_signal_handler(), "source_create", OBSBasic::SourceCreated, this);
signalHandlers.emplace_back(obs_get_signal_handler(), "source_remove", OBSBasic::SourceRemoved, this);
2
obs_get_signal_handler() 拿到的正是全局 handler obs->signals;界面把自己的 SourceCreated / SourceRemoved 两个函数,connect 到全局的 "source_create" / "source_remove" 上。(signalHandlers.emplace_back(...) 是个 RAII 小封装 OBSSignal,底层就是调 signal_handler_connect,析构时自动 disconnect —— 又见第 7 课的"成对"思想。)
于是:第 6 课 obs_source_create_internal 末尾那句 dosignal(.., "source_create", ..),发到全局信箱 → 界面早就连在这里的 SourceCreated 被循环①调到 → 界面把新源加进来源列表。 第 6 课到第 8 课,这条线现在严丝合缝地闭合了。
# 8.8 全链路:点一下"静音",图标怎么亮的
📍收束:零件都装齐了,这一节把整条链从头走一遍——你点「静音」,图标是怎么一路亮起来的。
把整条路连起来走一遍,一个优雅的闭环:

- (界面) 你点"静音"按钮;
- (界面) 调
obs_source_set_muted(src, true); - (引擎) 内部触发
obs_source_dosignal(.., "mute"); - (引擎) 打包
calldata { source, muted=true }(8.5 的栈缓冲袋); - (引擎)
signal_handler_signal("mute")的循环①遍历订阅者; - (界面) 界面早先
connect("mute", ...)注册过的回调被调到; - (界面) 回调里把静音图标点亮。
最妙的是:发起的是界面,收通知的也是界面 —— 可中间的引擎,自始至终不知道"界面"存不存在。 它只忠实地"喊了一嗓子",剩下交给那个 for 循环。界面、脚本、别的插件,谁想知道都行,引擎一行不改。
# 8.9 一对兄弟:proc(点名调用)
📍转折:信号是「广播」;它还有个长得很像的兄弟
proc,干的是「点名调用」——这一节认识它。
libobs/callback/ 里,signal 有个长得很像的兄弟:proc_handler(过程处理器)。

同源同构 —— 都在 libobs/callback/、都用 calldata 传参、都"按字符串名字"操作。区别在关系:
- signal 是广播:一个事件,0 ~ N 个订阅者都收到,语义"发生了什么"(通知),发完不等回应。
- proc 是点名:一个具名函数,你按名字调它、可通过
out参数拿返回值,语义"帮我做件事"(请求 / 应答)。
每个源的 context 里这俩都有(obs-source.c:4505 / 4510):
signal_handler_t *obs_source_get_signal_handler(...) { return source->context.signals; }
proc_handler_t *obs_source_get_proc_handler(...) { return source->context.procs; }
2
# 8.10 proc 实战:按名字"遥控"一个源
📍接上一节:知道了 proc 是「点名办事」,这一节看真实源码里怎么用它按名字遥控一个源,连那个插件的头文件都不用
#include。
proc 不是摆设,源码里到处在用。看几个真实的:

// ffmpeg 媒体源:注册一个「重头播」过程 obs-ffmpeg-source.c:626
proc_handler_add(ph, "void restart()", restart_proc, s);
// dshow 摄像头:开/关 win-dshow.cpp:1181
proc_handler_add(ph, "void activate(bool active)", proc_activate, dshow);
// 幻灯片:查询当前第几张(out 返回值!) obs-slideshow.c:682
proc_handler_add(ph, "void current_index(out int current_index)", current_slide_proc, ss);
2
3
4
5
6
7
8
它的实现 proc_handler 跟 signal 几乎是孪生 —— 一个 DARRAY(struct proc_info),按名字线性找(proc.c:34,源码里还留着一句 TODO: 换成哈希表 的注释,很真实)。proc_handler_call(proc.c:111)长这样:
bool proc_handler_call(proc_handler_t *handler, const char *name, calldata_t *params)
{
pthread_mutex_lock(&handler->mutex);
struct proc_info *info = getproc(handler, name); // 按名字找
struct proc_info info_copy;
if (info) info_copy = *info; // 先复制一份……
pthread_mutex_unlock(&handler->mutex); // ……再解锁(防止回调里又调 proc 死锁)
if (!info) return false; // 没这个过程 → false
info_copy.callback(info_copy.data, params); // ★ 调用!
return true;
}
2
3
4
5
6
7
8
9
10
11
12
注意那个"先复制 info 再解锁"的小动作 —— 又是为了可重入:万一被调的过程内部又去调同一个 handler 的另一个过程,锁早放下了,不会自锁死。和 8.4 信号那边一个思路。
那外面怎么用?比如界面想让一个媒体源重头播,根本不用 #include 那个插件,按名字喊一声就行:
proc_handler_t *ph = obs_source_get_proc_handler(media_source);
proc_handler_call(ph, "restart", &cd); // 媒体源就重头播了
2
一句话记区别:signal 是"我喊一嗓子,谁爱听听";proc 是"我点名让你办件事"。 两者一起,构成了 OBS 里万物互通的"神经系统"。
# 8.11 信号在真实世界:跨线程那一跳
📍为什么现在讲这个:8.8 说「回调把图标点亮」时,其实藏了一跳没讲——这一节补上工程里最容易踩坑的跨线程一跳。
8.8 我说"界面 connect 的回调被调到、把图标点亮",其实还藏着一跳没讲 —— 而这一跳,是 OBS 工程上最容易踩坑、也最见"真实代码"的地方。
把界面那个回调的真身翻出来(OBSBasic_SceneItems.cpp:191):
void OBSBasic::SourceRenamed(void *data, calldata_t *params)
{
obs_source_t *source = (obs_source_t *)calldata_ptr(params, "source"); // 从袋子取 source
const char *newName = calldata_string(params, "new_name"); // 取 new_name
const char *prevName = calldata_string(params, "prev_name"); // 取 prev_name
QMetaObject::invokeMethod(static_cast<OBSBasic *>(data), "RenameSources",
Q_ARG(OBSSource, source),
Q_ARG(QString, QT_UTF8(newName)),
Q_ARG(QString, QT_UTF8(prevName))); // ← 注意:没直接改界面!
...
}
2
3
4
5
6
7
8
9
10
11
12
短短几行,把本课所有伏笔一次性兑现。
# ① calldata 的"拆包"端,终于现身
8.5 看了怎么往袋子里塞(calldata_set_ptr),这里就是怎么取:calldata_ptr(params, "source")、calldata_string(params, "new_name")。而且取的这三个名字 —— source / new_name / prev_name —— 正好对上 8.6 那条声明:
"void rename(ptr source, string new_name, string prev_name)"
声明 → 打包 → 广播 → 拆包,整条链的两端在这里合龙了:声明里写仨参数,发时塞仨,收时按名字取仨,严丝合缝。
# ② 那个 invokeMethod 才是重点:跨线程
你可能没意识到一件可怕的事:SourceRenamed 这个回调,根本不在界面线程上跑。
libobs 是多线程的 —— 视频线程、音频线程在后台连轴转。信号往往是在这些工作线程里被 8.4 那个 for 循环调起来的。可 Qt 有条铁律:界面控件只能在 GUI 线程里碰,别的线程直接动控件,轻则花屏、重则崩溃。
所以 SourceRenamed 绝不直接改界面,而是用 QMetaObject::invokeMethod(..., "RenameSources", ...) 把真正的活儿(RenameSources 槽函数)排队甩到 GUI 线程执行;Q_ARG(...) 把参数也安全搬过去。

这一跳,是 libobs(纯 C、多线程、不知有没有界面)和 Qt 界面(GUI 单线程)之间的"减震器"。几乎每个 libobs 信号到达界面时,都要做这一跳。 你以后写 OBS 前端插件,只要在信号回调里碰了 Qt 控件却没走
invokeMethod,十有八九就是偶发崩溃的根源 —— 记死它。
# ③ 那个 emplace_back 是什么:OBSSignal RAII
还掉 8.7 留的小尾巴。界面订阅写的是 signalHandlers.emplace_back(obs_get_signal_handler(), "source_rename", SourceRenamed, this)。这个 signalHandlers 装的是 OBSSignal 小对象(libobs/obs.hpp:272),一个 RAII 封装:
class OBSSignal {
...
~OBSSignal() { Disconnect(); } // 析构时自动断开
void Connect(handler, signal, callback, param) {
Disconnect();
signal_handler_connect_ref(handler, signal, callback, param); // 注意是 connect_ref
}
};
2
3
4
5
6
7
8
两个细节,正好收束前两课:
- 构造即
Connect、析构即Disconnect—— 第 7 课"成对"思想的极致:对象一销毁,连接自动撤销,绝不泄漏。 - 它用
signal_handler_connect_ref(不是普通connect)—— 就是 8.4 提过的"带引用"连接:refs++,让信号处理器只要还有界面连着就不会被销毁。第 7 课的引用计数,在这里又救了一次场。
至此,信号系统从"一个 for 循环",一路讲到了"跨线程 + RAII + 引用计数"的真实工程全貌。这套机制看着朴素,每处细节却都在替你扛住多线程世界的复杂。
# 8.12 本课小结
- 难题:模块间要互相通知又不能焊死 → 发布/订阅(signal),发布者只管喊、订阅者提前订、互不认识(松耦合)。
- 数据结构(
signal.c):signal_handler→ 信号链表signal_info(每个具名信号一个)→ 订阅者数组signal_callback;另有global_callbacks(订全部信号的人)。 signal()的三段(signal.c:290):循环①广播(cb->callback)、循环②延迟清理、循环③全局回调。- 延迟删除 + 可重入:广播时
disconnect只标remove、不动数组(否则遍历下标乱),循环②再删;信号互斥锁是递归锁,接住"回调里再发信号"的重入。 calldata:万能参数袋,把"名字→类型→值"打包进一块字节缓冲;dosignal用栈缓冲省malloc;IN/OUT让信号能"协商";声明字符串由parse_decl_string(内置迷你 C 解析器)解析。- 两个信箱:全局
obs->signals(obs_signals[],如source_create/source_destroy,界面订它,OBSBasic.cpp:917)vs 源自己的context.signals(source_signals[],如destroy/mute);obs_source_dosignal一次双发,名字还不一样。 - 兄弟
proc:signal 广播(一对多·通知)vs proc 点名(请求/应答·可返回值);实现孪生,proc_handler_call(proc.c:111)按名查找、复制后解锁再调(同样为可重入);真实用例如媒体源"restart()"、幻灯片"current_index(out int)"。 - 落地的真实一跳(8.11):界面回调(
OBSBasic_SceneItems.cpp:191)先calldata_string拆包(与声明的参数名对上,整条链合龙),再QMetaObject::invokeMethod(..., QueuedConnection)跨线程甩给 GUI 线程(libobs 多线程,Qt 控件只能 GUI 线程碰);订阅用OBSSignalRAII(obs.hpp:272,构造connect_ref、析构disconnect)绝不泄漏。
# 8.13 动手 / 观察(本课作业)
- 数信号:
obs-source.c:75的source_signals[]数一数一个源会发多少种信号;再看obs.c:1078的obs_signals[],对比"本地名"和"全局名"(如destroyvssource_destroy)。 - 找订阅点:在
frontend/搜signal_handler_connect(或OBSSignal),看界面订了哪些全局信号、回调里干啥 —— 这就是 8.8 那条链的"界面侧"。 - 读懂两个循环:回到
signal.c:290,用自己的话讲清楚"为什么广播时disconnect只能标记不能真删"。 - (思考题):如果把
signal_info的互斥锁从递归锁换成普通锁,什么场景下会死锁?(提示:回调里又对同一个 handler 发信号。)
# 下一课预告
信号让"已经加载进来"的模块互相通信。可还有个更前置的问题:plugins/ 下那些插件(obs-x264、win-capture……),一开始是怎么被发现、加载、注册进引擎的?第 6 课说"插件填表 obs_register_source 注册",但这个调用是什么时候、被谁触发的?
第 9 课:插件是怎么被加载的 —— 顺着 obs_module_load、动态库加载(.dll/.so)、OBS_DECLARE_MODULE 宏一路走,看引擎启动时怎么扫描目录、把一个个插件 .dll 装进来、再调它们的 obs_module_load() 完成自我注册。又是一条能翻开 .c 走到底的链。下节课见。
📁 本课配图:
imgs/08-01~imgs/08-11📌 源码锚点:signal.h:37(回调签名)、signal.c:23(signal_callback)、:30(signal_info)、:87(signal_handler)、:160(add)、:222(connect)、:251(disconnect/延迟删除)、:290(signal 三段)、:323(全局回调)、calldata.h:30/34/43、calldata.c:179(set_data)、decl.c:179(parse_decl_string)、proc.c:34(proc_handler)、:111(call)、obs.c:1078(obs_signals)、obs-source.c:75(source_signals)、:124、:4505/:4510、obs-internal.h:1030(dosignal)、frontend/widgets/OBSBasic.cpp:917(界面订阅)、OBSBasic_SceneItems.cpp:191(SourceRenamed 回调/拆包/跨线程)、libobs/obs.hpp:272(OBSSignal RAII)
本留言区仅对应当前文章,欢迎补充观点、提出问题或帮助修正文中疏漏。 留言由 GitHub/Gitalk 提供,需要使用 GitHub 登录。
社区交流
讨论与留言