关于消息循环的一点思考
Jacksing

最近仔细阅读了一下 Windows 的消息循环机制,发现可以基于这个机制实现一些有趣的功能。这篇文章就是对消息循环的一点思考。

项目情况

最近的这个项目,是一个后台的普通进程,该进程在开机启动之后,没有什么特殊原因的话,是不会退出的。所以过去的操作就是,在 main 函数里直接使用 Windows 消息循环的套路代码,一个死循环放在那里,其他的模块自己开自己的线程去搞事情。

这样明显是一个非常不合理的操作,随便想想都能罗列出好些问题:

  1. 平白无故浪费了一个线程,该线程开着却什么事情都不干。
  2. 在项目里还有好些地方也需要使用窗口消息,之前就是需要的地方自己整个窗口整一套事件循环,这样就会有好多个消息循环,不好管理。
  3. 虽然笔者的项目是不会退出的,但是现在这个做法真的也就一点退路都不留,完全没有任何办法去进行退出操作。

前期改进措施

针对于第二点,笔者在项目的中期,额外迭代了一个通用的消息循环模块,这个模块内部就有着一个消息循环,其他模块只需要注册自己的消息处理函数就可以了。这样某种程度上就解决了第二点的问题。

并且在后续项目开发的时候,产生了这样的一个需求。笔者在 A 线程里无法调用某个接口,否则会产生死锁,这时候需要丢给其他的线程去执行。这时候笔者想到可以用上这个通用的消息循环模块,所以笔者给这个消息循环模块加上了一个发送消息的接口,这样就可以在 A 线程里发送消息给消息循环线程,然后消息循环线程再去执行这个接口。

重构思路

虽然有了一个通用消息循环模块,但项目主线程的操作始终还是一个心结,这看着就很丑。所以笔者决定重构一波。

整体的一个思路就是,以 Qt 的消息循环为模板,重构一个消息循环模块出来。主线程可以是一个消息循环,但是需要让其他的线程也能使用到它,将它的能力开放出来。针对于此,这样一个消息循环模块肯定是需要有这么几个部分的:

  1. 消息循环触发函数,通过这个函数可以让线程正式进入消息循环中
  2. 广播发送和单播发送消息的接口,可以让其他线程发送消息给消息循环线程
  3. 公共消息与私有消息的区分。公共消息可以用于广播,每个业务模块都可以根据广播消息进行相应的业务操作。私有消息是提供给不同的业务模块使用的,这样可以避免不同的业务模块之间的消息冲突
  4. 注册和注销消息处理函数的接口,可以让其他线程注册自己的消息处理函数
  5. 窗口句柄获取接口,通过这个接口可以获取到当前消息循环的窗口句柄
  6. 消息循环退出接口,这样可以让消息循环线程退出消息循环

开始设计

回调函数设计

Windows 的消息循环回调中,会通过一个 procedure 函数来处理消息。这个函数有四个参数,其中第一个是窗口句柄。

Windows 的消息循环中,事件的处理是通过一个 Procedures 来实现的,也就是回调函数。这个回调函数有四个参数,其中第一个参数是窗口句柄,其他的具体可见文档。在我们的项目里,对于窗口句柄的需求看起来是没有的,所以设计消息回调函数的时候,只保留了后面的三个参数,函数返回值也是没有存在必要的。具体签名如下:

1
using MessageCallback = std::function<void(UINT msgId, UINT_PTR first, LONG_PTR second)>;

消息定义

通过这样的划分有几个好处。首先公共消息的定义肯定是放在一个全局可见的区域的,而在业务里,大部分的消息其实都应该是私有的,这样就不需要每申请一个消息就需要在全局定义一次,这样会显得很乱。其次,通过这样的划分,可以避免不同的业务模块之间的消息冲突。

Windows 规定了用户自定义消息从 WM_USER 开始,到 0x7FFF 为止,所以我们可以在这个范围内定义我们的消息。这里我们的制定策略是,公共消息在头文件从 WM_USER 开始定义,私有消息则通过函数接口获取。

这种设计可以极大的避免消息冲突问题,多人协作开发的时候,也不需要沟通之后再进行消息添加。并且对于具体的消息值,是一个完全没有必要去关心的东西。

窗口句柄

Windows 下很多的接口都需要一个窗口的句柄来进行操作,所以我们的消息循环模块也需要提供一个接口来获取窗口句柄。这个接口的设计也是很简单的,只需要返回一个窗口句柄就可以了。

消息处理

在对外暴露的整个消息循环模块里,MsgLoop 是唯一的一个类,这个类提供了几个 static 方法用来进行一些消息循环相关的操作,这些方法都保证是线程安全的。如下枚举:

  1. exec:这个方法用于陷入消息循环,直到消息循环退出
  2. boardcast:该方法用于广播消息,所有注册的消息处理函数都会收到这个消息
  3. handle:该方法用于获取当前消息循环的窗口句柄
  4. quit:该方法用于退出消息循环

消息的处理则额外创建了一个类 MsgOperator,这个类放置于 MsgLoop 内。这个类用于注册和注销消息处理函数,发送事件,同时还需要提供一个接口用来生成私有消息。

实现细节

窗口创建

创建时机

在我们的模块设计中,进程调用了 exec 之后就陷入了消息循环当中了。照理来说,窗口以及相关资源的窗口应该在调用这个函数的时候才创建。但是这存在一个问题,如果在 exec 之前,就需要使用窗口句柄来初始化一些操作,又或者某个业务需要发生一些私有消息来进行初始化操作,那么这个时候窗口还没有创建出来,这就会导致一些问题。

一般情况下,可以额外提供一个初始化函数,这个函数用于初始化窗口,然后在 exec 之前调用这个函数。但是这样的话,就会导致使用者需要额外的操作,又不太友好了。

所以我们需要在 exec 之前就创建窗口,并且这个窗口的创建还应该足够的快。在 C++ 里,比较通用的解决方案就是全局 static 变量了。这种变量会在 main 函数之前初始化。所以窗口的创建我们可以选在这个变量中进行。

不过这种方式带来的弊端就是,出现错误的时候,无法记录相关的日志。除非我们在窗口创建时就进行一些日志的初始化工作。但是这个时候并不想去介入过多额外的东西,所以笔者选择了无视错误信息。

消息队列创立

创建时机所提及的弊端除了需要快速创建窗口之外,还需要尽可能快的创建消息事件循环。这里笔者采用了一个比较简单且通用的方式,就是在窗口成功创建之后,直接调用 PeekMessage 函数来让系统迅速创建一个消息事件循环。注意参数应该设置为 PM_NOREMOVE,避免移除掉了需要被处理的消息。

类名

Windows 要求创建窗口的时候,提供一个类名,为了避免跟其他的类名发生冲突。笔者项目里的类名额外增加了一个固定的 uuid 进去。使用固定的类名主要是考虑以后可能会涉及到一些类名的查询工作。

多线程安全

对于 MsgLoop 的几个静态方法,都保证是线程安全的。在这里方法中,唯一会导致线程不安全的变量就是窗口句柄了。为了提高性能,这里采用了读写锁的方式来进行保护。每次进入这些函数时,都会加一个读锁,判断窗口句柄是否存在。这里还有一个注意点,现阶段不存在的话,笔者就直接让函数返回了。后续会让进程直接崩溃。毕竟整个进程都依赖着这个窗口循环,窗口句柄不存在的话,这个进程就没有存在的必要了。

exec 函数中,从消息循环中退出后,需要将窗口句柄设为 nullptr,这样可以避免其他业务还在使用这个窗口句柄。

控制消息

对于这个消息循环组件,现在所暴露的公共属性只有两个,一个是消息循环创建消息,一个是退出消息。之所以提供这个,也是从 QCoreApplication::aboutToQuit 得到的启示。这两个消息可以让业务进行一些初始化和退出操作。并且保证这些操作都在消息循环内。

发送时机

从上文提到的,调用 exec 的时候,可能已经晚了,很多操作都需要在之前就搞定好。所以得利用好 Windows 消息循环的特性,在创建完窗口的时候,我们会调用 PeekMessage 函数来让系统迅速创建消息事件循环,紧接着我们就可以发送一个消息循环创建消息。这时候发送的消息是不会被处理的,因为还没有地方去获取和处理它。等调用者调用 exec 的时候,就会进入消息循环,这个时候这条创建消息就会被立马处理,并且因为是创建完立马发送进来的,所以基本可以确保会在消息队列的最前面。

退出的消息则是在 quit 函数中去调用,我们会在退出之前,发送一个退出消息,而后立马发送 WM_QUIT 消息,这样就基本上就保证了退出消息会是最后一个需要被处理的消息,并且还给了业务一个机会去处理这个退出消息。

非及时性

既然决定使用了消息循环机制来处理事件,就必须要接收事件的发送和处理是分离且不及时的这一情况。主线程会依次收到不同的消息,处理结束后再进行下一个消息的处理。这里必然存在着消息处理延迟。不过可以考虑在 exec 中,强行提高一下线程的优先级,确保消息能够尽可能地及时处理。

总结

自从整了这么一个组件出来之后,笔者整个项目都围绕着这么一个类展开,跨线程通信 / 消息处理 / winapi 参数设置等操作都顺手多了,整个代码的架构也优雅多了。

  • 本文标题:关于消息循环的一点思考
  • 本文作者:Jacksing
  • 创建时间:2024-04-24 13:17:16
  • 本文链接:https://wzzzx.github.io/Windows/about-message-loop/
  • 版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
 评论