夜雨聆风学习资料网

ARTICLE · 998486

Qt 信号槽源码解析:emit 之后,Qt 到底做了什么

Qt 信号槽源码解析:emit 之后,Qt 到底做了什么

Qt 信号槽源码解析:emit 之后,Qt 到底做了什么

写下这两行代码时,表面上只是“通知一下”:

emit valueChanged(value);
connect(model, &Model::valueChanged,        view, &View::updateValue);

但 C++ 本身没有内建的信号槽。编译器不认识 signals、slots 和 emit。Qt 能做到这一层,是因为它在正常编译前插入了一个工具:moc,Meta-Object Compiler。

源码解析最容易走偏的地方,是一上来钻进几十层私有类。先抓住一条主线就够了:

signals 声明    -> moc 生成信号函数实现    -> emit 调用信号函数    -> QMetaObject::activate()    -> 遍历已连接的槽    -> 直接调用,或投递 QMetaCallEvent

理解这条线后,线程、连接类型、参数复制和断开连接的行为都会有地方安放。

signals 不是 C++ 关键字

先看一个最普通的类:

class Counter : public QObject{    Q_OBJECTpublic:void setValue(int value);signals:void valueChanged(int value);};

在 Qt 头文件里,signals 本质上是给 moc 和 C++ 编译器看的宏。emit 也不是神秘指令,在通常配置下它会展开为空。

所以:

emit valueChanged(value);

对 C++ 编译器来说,最终仍是一次普通函数调用:

valueChanged(value);

区别在于 valueChanged(int) 的函数体不是你手写的,而是 moc 根据类的元对象信息生成的。生成文件通常叫 moc_类名.cpp,构建目录里可以找到它。别直接修改它,下一次构建就会被覆盖;它是看机制时的观察窗口,不是业务代码的编辑点。

moc 生成了什么

moc 会扫描带 Q_OBJECT 的类,把信号、槽、属性、枚举等信息整理成元对象数据,并生成几类关键代码:

  • • 类的 staticMetaObject,里面描述方法名、参数类型、属性等元信息。
  • • qt_metacall(),让 Qt 能按方法索引调用槽函数。
  • • 每个信号的函数实现。

不同 Qt 版本生成代码的排版和辅助结构会变,但信号函数的核心形态大致相同:

void Counter::valueChanged(int value){    void *arguments[] = { nullptr, &value };    QMetaObject::activate(this, &staticMetaObject, signalIndex, arguments);}

这里的 signalIndex 是信号在元对象方法表中的索引,arguments 是参数地址组成的数组。第一个位置预留给返回值,信号通常没有返回值,所以放 nullptr。

这就是为什么 emit 不会“广播一段字符串名字”。moc 早已经把信号和方法索引整理好了;运行时 activate() 拿到的是发送者对象、元对象、信号索引和参数地址。

connect 并不立刻调用任何槽

执行 connect() 时,Qt 做的是登记工作:把“发送者的某个信号”与“接收者的某个槽或函数对象”建立关联。

connect(counter, &Counter::valueChanged,        label, &QLabel::setNum);

现代函数指针写法会在编译期检查很多事情,例如信号是否存在、槽参数能否接收信号参数。检查通过后,Qt 内部仍要创建一条连接记录。实现细节会用到私有的连接数据结构,例如 QObjectPrivate::ConnectionData 和 QObjectPrivate::Connection;这些名字可能随 Qt 版本调整,不该作为应用代码依赖。

可以把这条记录想成一张表项:

字段
大意
发送者与信号索引
哪个对象的哪个信号
接收者
要通知谁
调用目标
成员槽、普通函数或 Lambda
连接类型
Auto、Direct、Queued 等
连接状态
是否仍有效、是否是单次连接

connect() 返回的 QMetaObject::Connection,就是对这条内部记录的一个外部凭据。保存它以后,才能精准断开 Lambda 连接:

QMetaObject::Connection connection = connect(    counter, &Counter::valueChanged,    this, [this](int value) {        ui->label->setNum(value);    });disconnect(connection);

activate 才是真正的分发入口

当 Counter::valueChanged() 被调用,moc 生成的函数会进入 QMetaObject::activate()。Qt 的核心实现位于 Qt Core 模块的 qobject.cpp 一带;不同发行版、不同 Qt 版本的函数拆分不一样,不要拿网上某一版的行号当永久坐标。

activate() 的任务很像一个调度员:

  1. 1. 根据发送者和信号索引,找到这条信号对应的连接列表。
  2. 2. 逐条检查连接是否有效、是否被断开。
  3. 3. 判断这一条连接此刻应该直接调用还是排队。
  4. 4. 按正确方式执行接收端槽函数或 Lambda。

一个信号能连多个槽,正是因为连接列表里可以有多条记录:

connect(counter, &Counter::valueChanged,        label, &QLabel::setNum);connect(counter, &Counter::valueChanged,        this, &MainWindow::saveRecentValue);

通常按连接建立顺序调用,但业务代码不应该把关键正确性建立在多个槽的执行顺序上。若 A 必须在 B 之前完成,把这两个步骤放到一个明确的协调槽函数或流程里,比依赖连接注册顺序更稳。

直接连接:在当前栈里调用

如果连接是 Qt::DirectConnection,或者 AutoConnection 判断双方在同一个线程,Qt 会在当前调用栈内调用目标。

setValue()  -> emit valueChanged()    -> QMetaObject::activate()      -> View::updateValue()  -> 回到 setValue() 的下一行

这解释了两个常见现象。

第一,槽函数慢,emit 就慢。直接连接没有把工作“后台化”,槽函数跑多久,发射信号的线程就等多久。

第二,槽函数可以重入。槽里再改状态、再发信号,控制流可能回到原来的对象。Qt 在分发时需要处理连接在调用期间被断开的情况,应用代码也需要避免把对象状态写成“绝不可能嵌套进入”。

对于成员槽,Qt 可以通过元对象调用机制定位目标;对于函数指针和 Lambda,Qt 在连接时保存对应的调用包装,分发时再执行它。你不需要为了理解原理而去手写 qt_metacall(),它是 Qt 元对象系统的基础设施。

队列连接:先做一份待办事项

当连接是 Qt::QueuedConnection,或者 AutoConnection 判断发送线程与接收者线程不同,Qt 不会直接调用接收槽。

它会创建一个元调用事件,内部常见名称是 QMetaCallEvent,把“调用哪个槽、带哪些参数”打包后投递到接收者对象所属线程的事件队列:

工作线程 emit progressChanged(60)    -> activate()    -> 创建元调用事件    -> 投递给主线程中的窗口对象主线程事件循环    -> 取出元调用事件    -> 执行 MainWindow::updateProgress(60)

这一步必须复制能跨越当前调用栈的参数。int、QString 这类值很直接;自定义类型需要让 Qt 的元类型系统认识它。否则运行时会见到那句熟悉的警告:

Cannot queue arguments of type 'TaskResult'

解决方法不是改连接类型,而是用 Q_DECLARE_METATYPE 声明,并在需要时用 qRegisterMetaType() 注册类型。

队列事件最终依赖接收者线程的事件循环。事件循环不跑,事件就只是在队列里等待,槽函数自然不会进。这正是某些重写 QThread::run() 却没有 exec() 的代码,跨线程槽看起来失灵的原因。

QObject 销毁时,连接为什么会自动失效

连接记录并不只是一个永远增长的数组。QObject 析构时,Qt 会清理它相关的连接:它作为发送者建立的连接,以及它作为接收者建立的连接。

connect(worker, &Worker::progressChanged,        window, &MainWindow::updateProgress);

window 销毁后,Qt 不会继续调用 updateProgress()。这也是为什么 Lambda 连接最好传 context:

connect(worker, &Worker::progressChanged,        this, [this](int value) {            ui->progressBar->setValue(value);        });

这里的 this 让 Qt 知道 Lambda 的有效期应当绑定到窗口。没有 context 时,Qt 无法从 Lambda 捕获列表里推断 this 已经失效,后续调用就可能踩到释放后的内存。

已经投递、但还没处理的队列调用也要被谨慎处理。Qt 会结合接收对象与连接的存活状态避免调用已销毁的对象;但你自己跨线程传递的裸指针和外部资源,Qt 没法替你证明生命周期,仍需要应用层设计所有权。

源码该怎么读才不迷路

真想跟一遍 Qt 源码,建议按这个顺序走:

  1. 1. 从构建目录里找到某个简单类生成的 moc_*.cpp,看信号函数怎样调用 QMetaObject::activate()。
  2. 2. 在 Qt Core 源码的 qobject.cpp 搜索 QMetaObject::activate,看它如何进入内部激活逻辑。
  3. 3. 再搜索 QObject::connect 和 QObject::disconnect,了解连接怎样进入和离开连接数据。
  4. 4. 最后搜索 QMetaCallEvent 与 postEvent,把队列连接的投递和事件循环串起来。

不要一开始就试图记住每个私有类名。Qt 私有实现为性能、兼容性和线程安全做了不少分层,版本之间也会改。真正值得带走的是稳定的因果关系:moc 生成信号入口,connect 保存关联,activate 决定分发,队列连接通过事件循环换线程执行。源码里的类名会变,这条路径不会变。

相关学习资料