9 / 32 Source、对象、插件与渲染

第 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 开头三个结构体,把骨架交代得明明白白:

signal_handler 数据结构

// 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;  // 订了「所有信号」的人
	...
};
1
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.removesignal_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;
1
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)
1
2
3
4

getsignal 顺着链表 strcmp 比对名字,朴素。

三个动词


# 8.4 源码深读:signal() 的完整真相

📍为什么现在讲这个:声明和订阅都是铺垫,广播才是灵魂——这一节把 signal_handler_signal 的三段循环一段不漏地拆开。

广播 signal_handler_signal全场灵魂。上一版我只贴了主循环,但它其实有三段,每段都有讲究。这次咱们一段不漏(signal.c:290):

signal() 的完整真相

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 名参数
	}
	...
}
1
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);           // 没在广播,才真删
	}
}
1
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);
1

"mute" 要传 bool、"rename" 要传两个字符串、"volume" 要传 float…… 个数类型都不同,怎么塞进同一个签名?答案就是 calldata_t *params —— 一个万能参数袋:

calldata 参数袋

// libobs/callback/calldata.h:30  ——「存放 传给/传出 信号、过程、回调的参数」
struct calldata {
	uint8_t *stack;   // 其实就是一块字节缓冲
	size_t size, capacity;
	bool fixed;       // true = 用栈上的固定缓冲(不 malloc)
};
1
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 里有张明明白白的清单:

source 的信号清单与 dosignal

// 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)
	...
};
1
2
3
4
5
6
7
8
9

源初始化时,一次性全声明上去(obs-source.c:124):

return signal_handler_add_array(source->context.signals, source_signals);
1

注意 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);  // ② 发给「源自己」
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

发两份!这就引出下一节最关键的一点。


# 8.7 两个收信箱:全局信号 vs 源自己的信号

📍承上启下:上一节发现 dosignal 一次「发两份」,这一节解释为什么——OBS 有全局和源自己两层收信箱。

obs_source_dosignal 为什么要发两份、还传两个不同的名字?因为 OBS 有两层收信箱:

全局信号 vs 本地信号

  • 全局信箱 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);
1
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 全链路:点一下"静音",图标怎么亮的

📍收束:零件都装齐了,这一节把整条链从头走一遍——你点「静音」,图标是怎么一路亮起来的。

把整条路连起来走一遍,一个优雅的闭环:

全链路

  1. (界面) 你点"静音"按钮;
  2. (界面)obs_source_set_muted(src, true);
  3. (引擎) 内部触发 obs_source_dosignal(.., "mute");
  4. (引擎) 打包 calldata { source, muted=true }(8.5 的栈缓冲袋);
  5. (引擎) signal_handler_signal("mute") 的循环①遍历订阅者;
  6. (界面) 界面早先 connect("mute", ...) 注册过的回调被调到;
  7. (界面) 回调里把静音图标点亮。

最妙的是:发起的是界面,收通知的也是界面 —— 可中间的引擎,自始至终不知道"界面"存不存在。 它只忠实地"喊了一嗓子",剩下交给那个 for 循环。界面、脚本、别的插件,谁想知道都行,引擎一行不改。


# 8.9 一对兄弟:proc(点名调用)

📍转折:信号是「广播」;它还有个长得很像的兄弟 proc,干的是「点名调用」——这一节认识它。

libobs/callback/ 里,signal 有个长得很像的兄弟:proc_handler(过程处理器)

signal vs proc

同源同构 —— 都在 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; }
1
2

# 8.10 proc 实战:按名字"遥控"一个源

📍接上一节:知道了 proc 是「点名办事」,这一节看真实源码里怎么用它按名字遥控一个源,连那个插件的头文件都不用 #include

proc 不是摆设,源码里到处在用。看几个真实的:

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);
1
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;
}
1
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);   // 媒体源就重头播了
1
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)));   // ← 注意:没直接改界面!
	...
}
1
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)"
1

声明 → 打包 → 广播 → 拆包,整条链的两端在这里合龙了:声明里写仨参数,发时塞仨,收时按名字取仨,严丝合缝。

# ② 那个 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
	}
};
1
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 线程碰);订阅用 OBSSignal RAII(obs.hpp:272,构造 connect_ref、析构 disconnect)绝不泄漏。

# 8.13 动手 / 观察(本课作业)

  1. 数信号:obs-source.c:75source_signals[] 数一数一个源会发多少种信号;再看 obs.c:1078obs_signals[],对比"本地名"和"全局名"(如 destroy vs source_destroy)。
  2. 找订阅点:在 frontend/signal_handler_connect(或 OBSSignal),看界面订了哪些全局信号、回调里干啥 —— 这就是 8.8 那条链的"界面侧"。
  3. 读懂两个循环:回到 signal.c:290,用自己的话讲清楚"为什么广播时 disconnect 只能标记不能真删"。
  4. (思考题):如果把 signal_info 的互斥锁从递归锁换成普通锁,什么场景下会死锁?(提示:回调里又对同一个 handler 发信号。)

# 下一课预告

信号让"已经加载进来"的模块互相通信。可还有个更前置的问题:plugins/ 下那些插件(obs-x264win-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/43calldata.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/:4510obs-internal.h:1030(dosignal)、frontend/widgets/OBSBasic.cpp:917(界面订阅)、OBSBasic_SceneItems.cpp:191(SourceRenamed 回调/拆包/跨线程)、libobs/obs.hpp:272(OBSSignal RAII)

上次更新: 2026/07/14, 19:37:40

社区交流

讨论与留言

前往 GitHub Issues →

本留言区仅对应当前文章,欢迎补充观点、提出问题或帮助修正文中疏漏。 留言由 GitHub/Gitalk 提供,需要使用 GitHub 登录。

最近更新
第 1 课:专栏导论与安全边界
07-01
第 3 课:安装编译与调试环境
07-01
第 4 课:第三方库下载、编译与依赖管理
07-01
更多文章>