善意提醒

如果您打开本站很慢,布局排版混乱,并且看不到图片,那么可能是因为您还没有掌握用科学的方法上网的本领。
显示标签为“MFC”的博文。显示所有博文
显示标签为“MFC”的博文。显示所有博文

2026-04-08

当 AI 遇上忒修斯之船

在软件中需要加入一些为用户个性化定制的内容推送,因此需要一个用户标识。

正常情况下,如果用户有登录,就是一个 UserID 的事情。然而我们这次的场景是匿名使用,所以没有这个 UserID 可以用,需要自己来生成一个。

以及,产品设计上希望能做到还是能大致识别到一个「人」。同一台机器上如果有多个我们的软件,最好能得到相同的 ID。因此最后商量下来决定采用设备 ID(或设备指纹)的方式。

如果是 Web 浏览器,算是有相对成熟的方案。浏览器指纹也不是一两个人在做了,「广告」这个需求天然就需要这种东西。然而我们是 MFC 程序,需要自己想办法。

我以前做 Shareware 的时候也大致接触过一些 DeviceID 的东西,知道这种事情吃力不讨好。很难覆盖那么多种类的硬件,特别有一些可能还是服务器上才能见到的东西。万一搞不好,说不定在某些特殊硬件上还要出什么状况,所以并不是一件容易的事情。所幸我们这次的事情并不是性命攸关的「注册码」,只是一个可有可无的身份标识,即使拿不到,也可以接受,因此压力没那么大。

很自然地,我想到了让 AI 来做这个事情。说到「见多识广」,可能没有人比得上它。知识结构也是它比较新,不用担心去网上找到的开源代码只能支持老旧硬件的事情。把需求描述给了它,很快就生成了一个函数,专门用来在 Windows 上得到 DeviceID。

自测的时候,问题来了:DeviceID 有时候会变。
其实这个问题一早就埋下了,是我需求没向AI说清楚,算我的锅。

说起来,这类需求虽然都可以描述为 DeviceID,但实际上是不同的:

  1. 用于区分两台设备;
  2. 用于追踪使用者。

两者是有大区别的。前一种就最好是有点变化就换个 ID,后一种则应该只要怀疑可能还是原来那台,那 ID 就不要动。

但是,这种事情确实不太好把握。加根内存条算不算新设备?可能不应该算。那换块硬盘呢?如果 CPU 升了个级,心脏都变了,还说没变化,有点说不过去吧?以及,如果用户把操作系统从正版的 Win10 家庭版给重装成了盗版 Win11 专业版呢?

图片由 Google Gemini 3 Pro + Nano Banana Pro 生成

我们的需求肯定是希望尽可能不要因为一丁点儿变化就换一个 DeviceID。但这事是一个「忒修斯之船」悖论。变到什么程度才算一台新设备?如果太过追求某一个极端,就会落入两头的陷阱。要确保 DeviceID 的「排他性」,就需要冒「稳定性」不佳的风险。

AI一开始给出来的方案,「稳定性」是不够好的。即使硬件和 OS 都没有发生变化,它也可能会变。据说是多核 CPU 的线程绑定带来的问题。麻烦在于,如果我不从需求侧来反推,以及没有进行足够数量的测试,很可能发现不了。

解决了这个问题,还有另外一个问题:如何保证这段用于生成 DeviceID 的代码,在各种硬件上都能稳定运行?

我们的要求很低:如果不能保证生成 DeviceID,那就别生成,或者生成一个可能会重复的,都 OK。这算是允许牺牲 DeviceID 的「稳定性」,来换取更好的「鲁棒性」。这个需求我从一开始就给了 AI,它的确也在代码上作了处理,我有看到。然而,如何保证呢?作更多的测试?

我目前能想到的:在调用的时候套一个 try...catch,不知道有没有用。另一个我知道肯定有用的办法,就是把这个事情扔给一个独立的进程来做,这样应该可以保证万无一失。软件运行架构会需要一点调整。我知道这样去做,AI 呢?你如果只是把它限制在写函数这件事情上的话,它肯定没法告诉你答案。

这种事情,让 Claude Code 来做,能更好吗?如果让 OpenClaw 放开手脚不限费用地去干,它最终能搞定吗?GIGO,如果人想偷懒的话,估计就会很难。

2026-02-04

CEF 的异步 CreateBrowser 造成的问题及解决方案

图片由 Google Gemini 3 Pro + Nano Banana Pro 生成

公司有个软件,用到了 CEF 浏览器。CEFWebClient 是一个 CEFClient 的子类,作为一个子窗口放在另外一个由我们软件控制生命周期的父窗口中的。这个父窗口的创建,有时候是自动的,而且可能会短期内进行多次。因此,我们遇到了一个问题:如果父窗口太快被「干掉」,那么可能会 Crash。

父窗口的这个行为,当然可以被认为是「不正常」。但首先我的问题是「为什么」。为什么会出现这种事情?

一年前,查这个问题费了我不少时间和精力。本文主要是把相关经验给传承下去,因此接下来我就尽量长话短说了。

CEF 中创建浏览器的方法是先 new 一个 CEFWebClient,放在智能指针里面,然后交给 CefBrowserHost::CreateBrowser() 去创建窗口。这一步许多人都知道,教程和文档上都是这样写的。不太为人所知的细节是:CefBrowserHost::CreateBrowser() 可能是异步的。

这里就要介绍一些上下文了。
跟 Demo 不同,我们的软件,把 multi_threaded_message_loop 设置为 true。几乎是必须如此,因为这个程序并不单纯是个浏览器而已,没法把主线程消息循环让给 CEF。
在多线程模式下,CEF 会有自己的一些线程用来干活。包括创建浏览器窗口这种事情,也是在它的线程中,而不是调用线程或主线程。

因此,CefBrowserHost::CreateBrowser() 是在另一个线程中进行的。这一点我一开始确实没想到,建个窗口而已,谁会想到 CEF 要搞成异步的啊?官方文档也没强调这个事实。这导致调用线程在函数调用返回时,浏览器窗口还没真正创建起来,只是把这个任务给安排了下去而已。如果父窗口在浏览器窗口真正创建前被销毁了,实际创建浏览器窗口的时候就会出问题。

我们的调用线程其实就是主线程,一开始尝试了让主线程等着,但不行。不管是用信号同步,还是用个 while 循环暴力堵住,都会导致死锁。很明显,CefBrowserHost::CreateBrowser() 在子线程中先是作了一些准备工作,然后又用到了主线程,毕竟创建窗口只可能在主线程中进行。可能是一个 SendMessage 类似的操作。我们是直接用了 Spotify 预编译好的 CEF 二进制分发包,并没有从 CEF 源代码从头编译,所以没有往里面深究。总之如果不把主线程空出来,后面的事情没法继续。

简单休眠 20ms 再放行貌似就可以解决问题,似乎是因为工作线程那边的准备工作已经完成了,已经能够正确应对父窗口被销毁这种事情了。但 20ms 时间够不够?谁知道呢。肯定不能靠这种方式在生产环境上运作。最好是有个判断标志可以判断出准备工作做完了没有,但目前 CEF 对我们来说是个黑盒,也没留这个窗口,我们只能想办法去探上一探。

CefBrowserHost::CreateBrowser() 的第二个参数,是个 CefRefPtr 智能指针,具备引用计数。这个引用计数,对调试器是可见的。我盯着看了一阵子,发现只要引用计数上升到一定程度再放行,就没有问题。貌似工作线程在「准备阶段」把这个智能指针挂到了主线程够得着的地方了,以便创建窗口时访问。只要这个操作已经完成,此后即使父窗口被析构,引用计数也不会有被降为 0 的担忧,后面就能正常工作下去。

这个引用计数在cef_base.h里面,class CefRefCount的私有成员,

//
// Class that implements atomic reference counting.
///
class CefRefCount {
 public:
  CefRefCount() : ref_count_(0) {}

  ///
  // Increment the reference count.
  ///
  void AddRef() const { base::AtomicRefCountInc(&ref_count_); }

  ///
  // Decrement the reference count. Returns true if the reference count is 0.
  ///
  bool Release() const { return !base::AtomicRefCountDec(&ref_count_); }

  ///
  // Returns true if the reference count is 1.
  ///
  bool HasOneRef() const { return base::AtomicRefCountIsOne(&ref_count_); }

  ///
  // Returns true if the reference count is at least 1.
  ///
  bool HasAtLeastOneRef() const {
    return !base::AtomicRefCountIsZero(&ref_count_);
  }

 private:
  mutable base::AtomicRefCount ref_count_;
  DISALLOW_COPY_AND_ASSIGN(CefRefCount);
};

还好是在头文件里面,而且只是一个权限问题,因此连重新编译 libcef_dll_wrapper.lib 都不需要。魔改了一下,我们就这样用下去了。

至于引用计数要增加到多少才算是可以放行,我们取了一个经验数据。但这个方案我们一直用得有点担心。最后还是遇到了问题。重现出来一查,就是这个经验数据不再适用了。

正确的解决方案,还是要回到 CEF 的官方机制 OnAfterCreated() 上来。
官方是这样说的:一个 CefBrowser 有没有创建成功,一定要等到 OnAfterCreated() 被调用。不管是要干什么正经事情,还是要「去死」,都得等到它调用了以后。
实际试下来,虽然 OnAfterCreated() 的调用比引用计数「到位」要迟,但的确可以保证不出事情。

所以事情其实没有那么复杂:如果 OnAfterCreated() 还没被调用到,那么就不能关闭父窗口。不能关闭怎么办?先把窗口给 SW_HIDE 了,然后向 CEFWebClient 作个标记。等到 OnAfterCreated() 被调用的时候,再向父窗口发个消息让它自己销毁,调 DestroyWindow() 就行。

经验教训是:一切还得按规矩办。自己想土办法,或许能在某些场合解决问题,但终究不是长治久安之策。
但是,虽然没有看或调试源代码,不过这次也算是刺探了一下 CEF 的一些内部运行机制,也是有好处的。

2025-11-11

你可能从未注意过的 MFC 陷阱:模态对话框禁用机制的局限性

在最近的一个软件开发案例中,遇到了不容易处理的情况。

在我们的软件中,有那种一直浮在界面上的非模态对话框,例如那种浮动的工具面板。但是,这种窗口,貌似不会在主窗口弹出模态对话框的时候被 Disable。如果模态对话框是在这个非模态对话框中弹出的,那没有问题。

用 VS2013 和升级到最新的 VS2022 各写了一个测试程序,发现这就是 MFC 的默认逻辑。
在主窗口上放了两个点击后各自会弹出非模态对话框和模态对话框的按钮。先弹出非模态对话框,然后再去弹出模态对话框。此时主窗口被 Disable,无法响应鼠标、键盘消息。但非模态对话框不受影响。

本想靠调整产品设计「容忍」过去。但问题在于,如果在保持模态对话框弹出的情况下,去把非模态对话框先关闭了,那主窗口会被 Enable。此时它跟模态对话框之间都能响应鼠标、键盘消息,效果就好像之前弹出的模态对话框变成了非模态对话框一样。这个时候就会有「后果」了,产品设计再怎么调整,也没法让软件在这种乱了套的情况下还能工作正常。


在网上 Google 了半天,不要说解决方案,连问题都没人提到。讲解模态 / 非模态对话框的文章有一些,但都是很入门的介绍。只是从「使用者」的角度去讲用法谈区别,并没有涉及我遇到的问题。很少从原理角度进行说明,更没有去分析源代码。大概 MFC 现在用的人真的不多了吧?

图片由 Google Gemini 生成

还是得自己动手,在 DoModal() 处下了一个断点,单步跟踪进到 MFC 的代码里面看了一下,就明白了。以下代码来自 VS2013,VS2022 我貌似没有安装 MFC 源代码,但从表现上看二者在这个逻辑上应该相差无几。我们一起来看看 CDialog::DoModal() 到底干了些什么事情吧:

INT_PTR CDialog::DoModal()
{
	// can be constructed with a resource template or InitModalIndirect
	ASSERT(m_lpszTemplateName != NULL || m_hDialogTemplate != NULL ||
		m_lpDialogTemplate != NULL);

	// load resource as necessary
	LPCDLGTEMPLATE lpDialogTemplate = m_lpDialogTemplate;
	HGLOBAL hDialogTemplate = m_hDialogTemplate;
	HINSTANCE hInst = AfxGetResourceHandle();
	if (m_lpszTemplateName != NULL)
	{
		hInst = AfxFindResourceHandle(m_lpszTemplateName, RT_DIALOG);
		HRSRC hResource = ::FindResource(hInst, m_lpszTemplateName, RT_DIALOG);
		hDialogTemplate = LoadResource(hInst, hResource);
	}
	if (hDialogTemplate != NULL)
		lpDialogTemplate = (LPCDLGTEMPLATE)LockResource(hDialogTemplate);

	// return -1 in case of failure to load the dialog template resource
	if (lpDialogTemplate == NULL)
		return -1;

	// disable parent (before creating dialog)
	HWND hWndParent = PreModal();
	AfxUnhookWindowCreate();
	BOOL bEnableParent = FALSE;
	CWnd* pMainWnd = NULL;
	BOOL bEnableMainWnd = FALSE;
	if (hWndParent && hWndParent != ::GetDesktopWindow() && ::IsWindowEnabled(hWndParent))
	{
		::EnableWindow(hWndParent, FALSE);
		bEnableParent = TRUE;
		pMainWnd = AfxGetMainWnd();
		if (pMainWnd && pMainWnd->IsFrameWnd() && pMainWnd->IsWindowEnabled())
		{
			//
			// We are hosted by non-MFC container
			// 
			pMainWnd->EnableWindow(FALSE);
			bEnableMainWnd = TRUE;
		}
	}

	TRY
	{
		// create modeless dialog
		AfxHookWindowCreate(this);
		if (!CreateRunDlgIndirect(lpDialogTemplate, CWnd::FromHandle(hWndParent), hInst) && !m_bClosedByEndDialog)
		{
			// If the resource handle is a resource-only DLL, the dialog may fail to launch. Use the
			// module instance handle as the fallback dialog creator instance handle if necessary.
			CreateRunDlgIndirect(lpDialogTemplate, CWnd::FromHandle(hWndParent), AfxGetInstanceHandle());
		}

		m_bClosedByEndDialog = FALSE;
	}
	CATCH_ALL(e)
	{
		TRACE(traceAppMsg, 0, "Warning: dialog creation failed.\n");
		DELETE_EXCEPTION(e);
		m_nModalResult = -1;
	}
	END_CATCH_ALL

	if (bEnableMainWnd)
		pMainWnd->EnableWindow(TRUE);
	if (bEnableParent)
		::EnableWindow(hWndParent, TRUE);
	if (hWndParent != NULL && ::GetActiveWindow() == m_hWnd)
		::SetActiveWindow(hWndParent);

	// destroy modal window
	DestroyWindow();
	PostModal();

	// unlock/free resources as necessary
	if (m_lpszTemplateName != NULL || m_hDialogTemplate != NULL)
		UnlockResource(hDialogTemplate);
	if (m_lpszTemplateName != NULL)
		FreeResource(hDialogTemplate);

	return m_nModalResult;
}

看到高亮的代码应该就能明白了。MFC 在弹出模态对话框的时候,只是把 hWndParent 给 Disable 了。然后如果发现主窗口还没被 Disable,再补上一刀。后面这个逻辑应该就是为了应对在非模态对话框中弹出模态对话框的情况。

看到了这些逻辑,就可以明白本文最开始讲到的情况是情理之中的。模态对话框只管住了大 BOSS 和父亲,对于兄弟和叔伯之类,都没有去管。如果情况复杂一点,例如非模态对话框中又弹了第二层的对话框,那么不管这个对话框是模态还是非模态,接下来都有机会整出点问题。


知道了问题的原因,接下来就要寻找靠谱的解决方案了。肯定有人解决过这类问题,因为有些软件没有这种问题。但不知道是因为觉得问题太简单了不值得说,还是想藏着掖着?

希望是前者。因为真正的解决方案确实也很简单。

之前有同事尝试解决这个问题,办法是让模态对话框去通知所有的非模态对话框(或者说需要被 Disable 的窗口)自行 Disable,用了我们软件内部与 Windows 消息相互独立的另外一套通讯机制。

但这个解决方案有一些问题:模态对话框除了自己开发的那些,还有系统提供的 MessageBox、文件打开对话框等,另外还包括第三方库中的那些,都是我们无力触达的。所以虽然它花了很多力气让对话框们去多重继承一个基类,但还是没法彻底解决这类问题。

回头想想,主窗口不是总是会被 Disable 的么?那么监听主窗口的 WM_ENABLE 消息,在事件处理函数中把那些需要额外 Disable 掉的窗口集中处理掉,不是就可以了?

实际做起来也很简单,基本上就是在主窗口的 OnEnable() 中用 EnableWindow(bEnable) 把状态传递过去。唯一需要动点脑筋的,就是让那些需要额外 Disable 掉的窗口把自己的 HWND 注册到一个主窗口拿得到的地方,也就是说待处理的窗口列表需要管理起来。

具体的实现代码我就不贴了。重要的是解决问题的思路,具体如何实现,可以有很多种办法,选择适合自己的哪一种就好。相信读到这里的,都是合格的 Win32 / MFC 程序员。

2014-10-31

SingleThread 下遇到的并发问题

手下某个小弟,有一天报告我说他写的某个 Win32 Application 有一个奇怪的 Bug,搞了半天搞不定,向我寻求支援。Bug 现象是:下载文件,完毕弹框提示,点掉之后报错,Crash。

通常而言,这种问题,往往是因为在释放、删除什么东西的时候,该做的事情没做对,比如对着一个对象的指针进行了重复 delete 之类。但看了下代码,没觉得这方面有什么问题。因为这是个 SingleThread 的程序,于是尝试用单步跟踪跟了一下,发现有一段代码似乎在所属对象析构之后还在跑。这就有点奇怪了:SingleThread 的 Application,不应该有这种属于 MultiThread 的毛病才对。Socket 模型用的是 AsyncSelect,也就是说「异步」是用 Windows 消息做出来的,并不是真的「并发」。那么到底是哪里不对劲呢?

再接下来分析发现,虽然是 SingleThread,但最后出错前弹的那个提示框,是在 OnReceive 的时候通过 SendMessage 去弹的。这样就有眉目了:ModalDialog 并不阻塞 ParentWindow 的消息循环,所以在弹框等待用户确认的时候,消息循环收到了 OnClose,于是 Socket 对象在用户点击确认按钮之前,其实已经被 Destroy 了。之前还没跑完的 OnReceive,接着再跑的话,当然只能 Crash 了。

分析到这里,问题就已经很明白了:这就跟 MultiThread 下临界区没加锁一样嘛。你以为 SingleThread 下每个函数就都是原子操作,不会被乱入的东西打搅?呵呵,你一 DoModal 就会给你再嵌个消息循环进去的。可怜很多小弟连 DoModel 的原理都没搞懂就开始写程序了。我上次还听几个小弟在争论相关问题呢。不是说写程序必须啥都弄明白才能开始,但若是只拎半壶水就开跑,将来就难免会碰上这种「奇怪」的问题。

要修正这个问题,也很简单,改成用 PostMessage 让 MainWindow 自己去处理弹框的事情就可以了。不过有点奇怪的是,在 XP 下好像不会看到错误现象。Win7 下直接运行 EXE 也不报错。只有通过两层以上的 CreateProcess 去调用,才会看到现象。难怪没什么用户报告这个问题。是不是 OS 觉得这个 EXE 反正会很快地 Over 掉,有些错误就不报算了?看来微软在私底下还是有一些没告诉大家的小动作的哈哈。

总结一下,这个案例教育我们:
  1. 不要以为只要是 SingleThread 就一定不会遇到并发问题。
  2. 前/后台逻辑应该要区分清晰,是后台代码就别抢前台的活儿。
  3. 还有,SendMessage / PostMessage 不要不经大脑就乱用。

2011-03-29

CWebBrowser2 打开 PDF 后退出时崩溃问题的解决

用嵌入在对话框中的 CWebBrowser2 控件打开 PDF 文档后,主程序退出时抛了 Access Violation。扔异常的是 ACRORD32.DLL,ADOBE 自己的玩意儿,调用栈中也看不出什么,主程序看来都快退完了,显然和什么东西没关干净有关。

照例,国产没货。最后在这里找到了:
以上方法,在 VC6 sp6 / WinXP 的 Debug 和 Release 编译上都试过。除此之外就不知道了。
据说,问题和 Adobe Reader 9 有关。V8 没有这个问题,因为 V8 是单一实例,而 V9 不是。照此一来,内存泄漏可能也是难免的。