善意提醒

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

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 的一些内部运行机制,也是有好处的。

2017-05-25

将 C++11 新特性用于代码优化

关于 C++11 的科普,在这里就不详细进行了,可以参考 维基百科 页面。即使是中文页面,我认为写得足够详细和系统了。

总之,C++11 对原始的 C/C++ 作出了在我看来是不算小的改动。有一些概念,放在以前的时代是绝对真理,在 C++11 推出之后,可能需要重新了解一下了。VS2013 对 C++11 的支持并不算「完美」,不过大部分「有用」的特性还是到位了。这里就以它为例,来谈谈如何把 C++11 的新特性应用到你的软件开发工作中来提升性能和开发效率。

本文提到的 C++11 的这些新特性,我大致把它们分为两类:一类是可以直接提升代码的性能表现的,我列在「性能优化」部分;另一类虽然不能直接提升代码的性能,但可以提升开发效率,便于更快地开发出可维护性更好的代码,我列在「非性能优化部分」。

另外,受作者水平所限,本文并不是对 C++11 在这些方面的完整的参考内容,仅仅作为一个引导来阅读吧。


性能优化部分

右值引用和 move 语义
C++11 引入了右值引用,支持了 move 语义。在我看来,这个变化的意义可能是 C++11 里面最大的一个。右值引用和 move 语义是什么,这里不展开。通俗一点地讲,这个特性使得程序员可以在必要的时候自行决定到底是深拷贝还是浅拷贝。对于大量的数据「搬运」操作,可以节省下不少时间。对于性能优化来说,意义重大。

其实就算没有右值引用,在 C++11 之前的时代也可以做类似的优化。C++ 程序员只要对于自己的资源管理类显式地提供深 / 浅拷贝版本的函数即可。不过这样一来代码工作量会比较大,程序会变得比较复杂,并且始终不是一个规范。现在这一切都不是问题了。

对于 STL 自己的类/容器,VS2013 已经做了足够的优化。例如,你可以通过:
string strA = std::move(strB);
来把 strB 的字符串动态内存部分直接给到 strA,速度比简单的赋值要快不少。当然,strB 就不再具有有意义的值了(这里例子中会变成空字符串)。当你 push_back 或 insert 一个 string 到容器里面的时候,如果 string 其实是一个临时变量,那么用 move 语义你也可以得到相当明显的性能提升。

如果例子里面 string 换成一个 map<string, string>,那么提升会更明显。总之,内存的分配和释放,以及 memcpy 操作统统被避免了。所以,理论上需要传递的东西越多,你得到的性能提升就会越显著。

就地部署(emplace)
C++11 对于常见的 STL 容器,都提供了一种能提升性能的数据置入方法,称之为「就地部署」。通过用就地部署取代原来的 push_back 或 insert 之类的操作,不再需要先构造再传递,而是由容器直接调用目标对象的构造函数来完成数据填充。

在某些情况下(T 提供了对应的构造函数时),这样可以避免一次拷贝构造的开销。而最差的情况(T 没有提供对应的构造函数),也最多不过就是与 push_back 和 insert 效果一模一样而已。所以我建议所有能用上就地部署的地方,都统统用上,无需太多考虑。

并且,就地部署与 move 语义相互并不冲突,而且是互有补充。move 语义解决深拷贝慢的问题,就地部署试图减少哪怕是浅拷贝的执行次数。两者配合起来效果更加完美。

散列表
在 C++11 里,不再需要通过第三方库来引入散列表(或者叫哈希表)了。STL 正式支持了四种散列表的实现,全部都冠以「unordered_」的前缀,以便与一些第三方实现相区别。

对于大多数用 map / set 实现的代码,只要简单替换容器就可以得到性能上的提升。map / set 基于红黑树(自平衡二叉树),时间复杂度至少是 log(N)。散列表版本的 map / set 提供常数级的时间复杂度,随着数据量的增大,无论是写入还是读取的性能都超过了红黑树版本的 map / set。

我个人的测试结论是:同是 set<string>,即使是小数据量,散列表版读取代价也只是红黑树版的约 60%;小数据量下,红黑树版写入略快,但在容器内数据量达到「万」级别的时候,散列表的写入速度也开始超越红黑树版(此为 Release 版测试结论,Debug 版在「百」级别即发生超越现象)。

所以我认为,只有在数据量很小,并且写入与读取的概率大致相当时,使用红黑树版 map / set 才在性能上可能有明显收益。其余情况,都建议采用散列表版本 map / set。当然,如果 T 是自定义类,并且你不愿意为它写散列函数,那就算了。


非性能优化部分

完美转发
C++11 中所谓「完美转发」的特性,其实是配合右值引用来使用的。如果为了支持右值引用,而不得不让自己的代码量变大一倍,那有些人可能就要望而却步了。完美转发其实是借用了模板技术,使得你可以只写一份代码,就可以兼顾(常量)左值引用与右值引用的情况。工作量更少,代码更简洁,出错的概率也就更低。

不过,采用模板技术的缺点就是:编译期展开。这一方面降低了编译器的效率,另一方面会导致头文件的包含关系变得不太容易整理。除此之外,还有一种我称之为「不完美转发」的替代解决方案,本质上是在性能上作出一定程度上还算可以接受牺牲,来换取代码简洁性,取得一个还算 OK 的平衡。我会另外写一篇 Blog 来介绍一下它。

类型推导
「类型推导」也就是所谓的 auto 类型。这个东西使用起来基本没有门槛。很多人可能最开始接触 C++11 就是通过它了。

这个的确是一个好东西,用来写 STL 的 iterator 类型再合适不过了。因为我们本来也不怎么关心 iterator 的具体类型。不过,仍然不建议滥用。如果到处都是 auto,阅读你代码的人会经常性地需要回顾才能知道一个变量的类型,特别是在你没有用匈牙利命名法的时候更是如此。

所以,我的建议是:当你觉得一个变量的类型写起来很麻烦,而你其实并不关心它的时候,放心地用 auto。并且,auto 变量的作用域不要太大,if / for / while 循环内的局部变量用它是最合适的。

基于范围的 for 循环
很多语言早就可以这样写了。而 C++11 现在也可以这样写了:
for (auto& stk : stocklist)
相比起:
for (auto pIter = stocklist.begin(); pIter != stocklist.end(); ++pIter)
孰优孰劣一目了然。何况后者通常还需要跟一句:
auto stk = (*pIter);
不过,如果是一个 map,你可能经常要取 pIter->first / second 之类。或者你打算在循环里面对 pIter 做 erase 操作,那还是用传统方式比较好。

空指针
用 nullptr 取代 NULL。我觉得最大的好处就是 nullptr 的颜色没有 NULL 扎眼。不过,由于 NULL 也表示 0,有的时候也表示无效句柄。我觉得对于所有指针类型的 NULL,置换成 nullptr 可能会对阅读代码有一定帮助。

角括号
C++11 的编译器现在可以识别 >> 到底是两个模板类的嵌套,还是 >> 运算符。因此写代码的时候就不特意空上一格,写多层模板类嵌套的时候就更美观一点。

不过,多层模板类嵌套,本来就不可能「美观」到哪里去。起码我是不建议太多此类的代码实践的。

初始化列表
vector 可用这样的方式来进行初始化:
vector<int> vecX = { 1, 2, 3, 4 };
的确是比以前省事了。也就是说,C-Style 数组的存在意义又少了一层。

统一初始化
struct 可以被这样初始化:
struct C
{
    int a;
    int b;
    int c;
};
C c{1, 2, 3};
class 的 public 成员也可以。
在某些喜欢使用各种结构体的代码中,这个特性可以让你少写一大堆构造函数。

通用智能指针
std::shared_ptr<T>,强在可以指向任意对象,缺点也由此而生:由于引用计数保存在 shared_ptr 中,因此对智能指针的赋值操作是线程不安全的。这个问题,有一篇Blog论述,我觉得写得不错,就直接引用不细讲了。从原理和测试数据来看,我认为这篇 Blog 是靠谱的。

所以,虽然 shared_ptr 很强大,但使用场合需要注意:单线程随便用。多线程下,赋值过程要注意。单对单没啥问题,最好不要出现左值右值交叉的情况(一个线程在 A = B,另一个线程在 B = C)。若因业务需求无法避免的话,要考虑当作临界资源加锁保护。实在不行,就写一个专用智能指针,把引用计数放在 T 里面,加锁保护,就不会有问题了。

正则表达式
与散列表类似,不再需要第三方实现,现在 C++11 也直接支持正则表达式了。我以前要找一个 Unicode 支持得好的 Regex 库真的是苦水一堆,现在有了官方支持真的是太好了。

2017-05-21

正确地获取 Windows 的版本号

以前,想要获取 Windows 的版本号很简单,有个 Win32 API 函数名字叫做 GetVersion,望文生义,接下来要做的事情就是去 MSDN 上查下用法就可以了。

现在,GetVersion 会被报告成「过期函数」了。也许还能用,但(据 MSDN 说)起码在 Win10 上是别指望得到预期的结果了。道理也很简单,Win10 都搞滚动升级了,版本号规则肯定也和之前不一样了,你还指望这么老的函数能兼容么?

别痴心妄想了,GetVersionEx 也一样过期。那么,眼下有什么好办法吗?

一般来说,拿 Windows 版本号可能有两种用途:

  • 我想看看你 Windows 版本达到我要求没。
  • 我就是想知道你 Windows 版本号是多少。

对于前者,微软现在在 MSDN 上是这样推荐的:它做了一组 Version Helper functions,你如果想知道当前 Windows 的版本是不是某个特定的发行版,调这组函数就可以。我们来看看这组函数中三个典型:

  • IsWindowsXPOrGreater
  • IsWindowsXPSP3OrGreater
  • IsWindowsServer

不需要更多说明,我们从名字中就可以看出,这组函数可以用于判断 Windows 的大版本,Service Pack 的版本(结合大版本),以及能知道是不是服务器版操作系统。通常情况下,这些函数大概是够了。

但是,有的时候我们并不关心版本号高低,我们只是想要一个版本号(例如记录日志时)而已。微软对此的建议是:用 GetFileVersionInfo 去获取一个系统 DLL(例如 Kernel32.dll)的文件版本号(原文看 这里)。

相关的代码虽然能找到,MSDN 上也有官方例子(有点小 Bug),但比起一行 GetVersion 来代码量实在是不能算很少。由此可见,处理「过期函数」真的没有想象中那么容易。最后我还是提供一下我从项目代码中挖出来的一个实现吧。别照抄,如果你不想引入 STL 的话:

#include <windows.h>
#include <Strsafe.h>

#pragma comment(lib, "Version.lib")

// 获取文件版本
std::wstring GetFileVersionString(const std::wstring& strFilePath, bool bStrVer = false) {
    DWORD dwVerInfoSize = GetFileVersionInfoSize(strFilePath.c_str(), nullptr);
    if (dwVerInfoSize) {
        std::vector<BYTE> vecVerData(dwVerInfoSize);
        if (GetFileVersionInfo(strFilePath.c_str(), NULL, dwVerInfoSize, &vecVerData[0])) {
            LPCVOID pBlock = &vecVerData[0];

            UINT cbTranslate;
            TCHAR SubBlock[MAX_PATH];
            struct LANGANDCODEPAGE {
                WORD wLanguage;
                WORD wCodePage;
            } *lpTranslate;

            // 阅读语言和代码页列表
            VerQueryValue(pBlock,
                L"\\VarFileInfo\\Translation",
                (LPVOID*)&lpTranslate,
                &cbTranslate);

            if (bStrVer && lpTranslate) {
                // 读取第一种语言和代码页的文件版本
                for (size_t i = 0; i < (cbTranslate / sizeof(struct LANGANDCODEPAGE)); ++i) {
                    StringCchPrintf(SubBlock, sizeof(SubBlock) / sizeof(TCHAR),
                        L"\\StringFileInfo\\%04x%04x\\ProductVersion",
                        lpTranslate[i].wLanguage,
                        lpTranslate[i].wCodePage);

                    LPVOID lpBuffer = nullptr;
                    UINT dwBytes;
                    if (VerQueryValue(pBlock, SubBlock, &lpBuffer, &dwBytes) && lpBuffer && dwBytes > 0) {
                        std::wstring strVersion(reinterpret_cast<TCHAR*>(lpBuffer));
                        return strVersion;
                    }
                }
            }

            // 未找到任何字符串版本
            VS_FIXEDFILEINFO* lpffi = nullptr;
            UINT uLen = 0;
            // 注意:这里的第二个参数 "\" 是固定写法,表示查询根块
            if (VerQueryValue(pBlock, L"\\", (LPVOID*)&lpffi, &uLen) && lpffi && uLen >= sizeof(VS_FIXEDFILEINFO)) {
                std::wstringstream wos;
                wos << HIWORD(lpffi->dwFileVersionMS) << L"." << LOWORD(lpffi->dwFileVersionMS) << L"."
                    << HIWORD(lpffi->dwFileVersionLS) << L"." << LOWORD(lpffi->dwFileVersionLS);
                return wos.str();
            }
        }
    }
    return L"";
}

// 获取 OS 版本信息
std::wstring GetOSVersion(const std::wstring& strWinSysDir, bool bStrVer) {
    std::wstring strWinSysFilePath = strWinSysDir;
    if (!strWinSysFilePath.empty() && strWinSysFilePath.back() != L'\\') {
        strWinSysFilePath += L'\\';
    }
    return GetFileVersionString(strWinSysFilePath + L"Kernel32.dll", bStrVer);
}

2017-02-03

折腾 boost::python 的一些收获

最近从事了一些通过 boost 在 C++ 中调用 Python 脚本的工作。折腾下来有一些收获,记录一下,也许也可以帮到有些人。

一、关于万能变量类型


对于习惯了 Python 的 C++ 程序员而言,boost::python::object 这个东西是一个巨大的诱惑。它让你几乎可以像在 Python 里面那样使用弱类型的变量,同时还支持数组和字典之类的复杂变量类型,并且还支持嵌套。这简直就是一个万能变量类型,有了它,常见的需求几乎都可以满足了。

而且它还快,还容易用。它其实是 PyObject* 的一个封装,也就是说 PyObject* 其实功能也一样,但是没它容易使用。JsonCPP 里面的 Json::Value 也可以「万能」,但性能与 boost::python::object 相差颇多。这真的是一个巨大的诱惑。

但是在这里,我要给大多数 C++ 程序员泼一盆冷水。在单线程下,这个梦想可能真的是事实:但是在多线程下,boost::python::object 就是一个

boost::python::object 为什么快?因为它基于 PyObject*,具有引用计数,所以赋值才飞快,浅拷贝嘛。但是引用计数的问题就是线程不安全。

当然,光是引用计数本身不会导致线程安全性问题,导致问题的是引用计数带来的临界区对象引用问题,而归根结底,是 Python C API 的「并发问题不归我管」的思路。boost::python 的封装机制使得对象引用不好控制,也不太可见。想法是好的:使用者不需要关心这些。但现实就很无奈了。

所以,尽管很不甘心,关于万能变量类型的实现,我还是老老实实地用回了 Json::Value。

二、类型转换与判断


要从 boost::python::object 转换成 C/C++ 原生变量类型,一般用 boost::python::extract<T>。转出来的对象调 check() 就可以确定是不是正确的类型。转 boost::python::list 或 boost::python::dict 也是一样。

有一点需要特别注意的是:用 boost::python::extract<bool> 的时候,很可能得不到你预想的结果。你会发现,int 也被转成 bool 了(check() 返回 true),反过来也一样。这个不是 Bug,是 boost::python 故意这样搞的。BOOST_PYTHON_BOOL_INT_STRICT 宏可以解决这个问题(必须改动 boost::python 源代码并重新编译,因为相关代码在 cpp 里面不在头文件里面),它使得 int 和 bool 将被严格区分。

但是 boost::python 之所以把 int 和 bool 混起来用也不是没有道理的。这样使得 MFC 里面的那些 BOOL(不是 bool)类型的函数形参(和返回值)可以直通 Python,封装起控件来尤其方便。如果你设了 BOOST_PYTHON_BOOL_INT_STRICT,就不能这样干了,必须显式转换。左是一刀,右也是一刀。你们自己掂量吧。

反正我是没有去加 BOOST_PYTHON_BOOL_INT_STRICT,而是自己通过 _stricmp(value.ptr()->ob_type->tp_name, "bool") 搞定的。

三、清空 boost::python::list


用惯 STL 刚开始用 boost::python 的人可能会头大——怎么 dict 有 clear() 但 list 没有?
答案很简单,因为 Python C API 没提供,所以 boost::python 就没有。

说到底,boost::python 就是对 Python C API 的一个封装而已。你如果去看 boost::python 的源代码,甚至会发现里面不少的「成员函数」其实是跑去执行了一句 Python 脚本。不了解细节的 C++ 程序员很容易在这种地方栽跟头,所以我觉得 boost::python 绝对不会是一个终极形态。

要想清空数组,并不是完全没有办法。我找到的一个办法是 boost::python::delitem(list, boost::python::slice())。并发调用时记得用本文最后一节的办法加锁。但是我是真的不建议在多线程环境下用 boost::python,太多坑了。如果实在要用,在完成了 Python 脚本调用,把数据转换好之后,就赶紧离开 boost::python 区域吧。

四、boost::python::object 深拷贝


boost::python::object 默认的赋值操作都是作浅拷贝,极快。然而,有的时候需要深拷贝怎么办呢?

简单数据类型直接 extract 出来就是深拷贝(传值)了。会有问题的仅仅是复杂数据结构,具体地说,list 和 dict。

dict 又是有提供 copy(),理由还是——Python 里面的 dict 有 copy(),而 list 就没有。我尝试过最简单的办法,就是把一个 list 放到一个 dict 里面去 copy() 完再拿出来用。这样肯定可以达到目的。至于有没有更好的办法呢?我反正是自那之后就弃坑了,你们去研究吧。

五、多线程并发加锁


C++ 调 Python 的时候的加锁是一个不小的话题。不过这方面中文资料也算不少,有兴趣可以去搜来研究。我这里就简单地说一下跟 boost::python 相关的部分。

加锁是用 PyGILState_STATE,像这样:
PyGILState_STATE gstate;
gstate = PyGILState_Ensure();
……    // 调用Python脚本
PyGILState_Release(gstate);
这种例子网上很多,就不细说了。
关键在于,我前面也提到了,boost::python 里面很多看起来是 object 的成员函数的东西,其实都是对 Python 脚本的调用,所以都得加锁。

事实上,只要你用到了 boost::python 的地方,都得加锁。不管是 module 和 call 这种看起来就跟「调用」二字相关的,还是在对 boost::python::object 作数据处理,看起来人畜无害的,都得加锁,无论读 / 写,否则你就等着 Crash 吧,特别是服务端程序。

这也就是我一再警告避免在多线程下使用 boost::python 的原因。当然,我这边是不得不用,只能小心翼翼,如履薄冰,然后尽量控制不要让菜鸟程序员去写这种代码。由此可见,隐藏了实现细节并非全是好事,也并非全是对新手有益。

2015-08-19

蹊跷的 LNK1104 错误

最近在给公司开发下一代产品,所以比较忙,也没有太多的事情写 Blog。不过昨天在技术上遇到一件不大不小的事情,也许值得拿出来说一下。

这个下一代产品是从头全新开发的,为此我提供了一些基础库的代码,给到项目组的其它 Project 使用。其中有一个是用的动态链接库的形式。为了方便其它 Project 使用,我把如下的预编译宏写在了头文件中:
#pragma comment(lib, "XEngine")
这样,所有需要包含这个头文件的代码,就不需要自己去搞清楚到底要链接哪个 lib 文件了。

但是,同事在拿到代码做全新编译的时候,出现了 LNK1104 错误。Cannot Open File XEngine.lib。同事当然一头雾水,Baidu 上搜了一下之后,一头埋进去检查硬盘坏道去了。

我这边一开始没有问题,当把发布目录中的 XEngine.lib 删除了之后也出现了同样的问题。源代码对编译结果产生了依赖,这当然是不对的。问题当然出在那句预编译宏上,只不过代码的编写时间过去有点久了,我一时没想起来。
正好这个 DLL 具有导出函数,因此 VC 自动生成了相关的代码,只需要把前面的预编译宏放在这里面就可以了:
#ifdef XENGINE_EXPORTS
#define XENGINE_API __declspec(dllexport)
#else
#define XENGINE_API __declspec(dllimport)
#pragma comment(lib, "XEngine")
#endif
这样就解决这个问题了。如果没有这些自动生成的代码可以利用,也可以参考着自己写一个。

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 不要不经大脑就乱用。

2014-03-20

为什么不用动态内存分配?

在写这篇 Blog 的时候,我考虑了几分钟,在想要不要把标题写成《为什么有的程序员喜欢用动态内存分配?》。最后我还是把那些修饰词和定语给删了。虽然那个标题更准确一点,但是本文基本上是一篇吐槽文,我还是比较喜欢这种反问句的感觉。

事情是这样开始的:
在工作中,遇到了别的同事以前写的一段代码。作用是显示从某些网上下载的文件的内容。文件下载完后,也在本地保存了一份副本,这样如果下次发现本地有副本,就直接显示不用下载了。
这基本上是一个类似浏览器缓存的功能,实现起来也不难。不过这次我碰到一个 Bug,有个文件的副本,在解析的时候报错了。
因为第一次下载的时候并没有报错,所以焦点就集中到这个缓存机制上。这里面有个值得关注的地方在于,大概是出于节省本地硬盘空间的考虑,本地的副本在保存时是压缩过的。于是问题可能出在两个地方:

  • 压缩算法有问题,压缩保存的时候,把文件给弄坏了。
  • 解压缩算法有问题,无法正确还原这个文件。

这套压缩 / 解压缩的算法,是开源的(zlib)。所以我认为问题不应该出在算法本身,更可能是用法没用对。调用代码大概是这个样子的:
#define chunk 16384
void compress_file(const char* source_file , const char* dest_file)
{
    unsigned char datein[chunk];
    unsigned char dateout[chunk];
    unsigned long datelong = chunk;
    unsigned long sourcelong;
    FILE* source;
    FILE* dest;
    source = fopen(source_file , "r");
    dest = fopen(dest_file, "w+b");
    while (!feof(source))
    {
        sourcelong = fread(datein, 1, chunk, source);
        compress(dateout, &datelong, datein, sourcelong, 1);
        fwrite(dateout, datelong, 1, dest);
    }
    fclose(source);
    fclose(dest);
}
void un_compress_file(const char* source_file , const char* dest_file)
{
    unsigned char datein[chunk];
    unsigned char dateout[chunk];
    unsigned long datelong = chunk;
    unsigned long sourcelong;
    FILE* source;
    FILE* dest;
    source = fopen(source_file , "r+b");
    dest = fopen(dest_file , "w");
    while (!feof(source))
    {
        sourcelong = fread(datein, 1, chun, source);
        datelong = chunk;
        if (uncompress(dateout, &datelong, datein, sourcelong))
        {
            fwrite(dateout, datelong, 1, dest );
        }
    }
    fclose(source);
    fclose(dest);
}
这段代码我也不打算在这里分析太多,问题很明显:代码编写的初衷,是想把文件分块处理。但每块数据压缩之后的大小并没有记录在压缩文件中,也没有采取一些诸如分隔符或区块补齐之类的定位措施,所以解压缩的时候实际上是无法忠实地按压缩时的分块来还原数据的。而出问题的那个文件,大小的确是超过了 16384,于是就被弄坏了。

这里就引出了一个问题:为什么要分块?
事实上,如果这段代码没有采用固定长度的 C-style 数组,而是用动态内存分配的解决方案,压根都不会需要分块,也就不会出现这个 Bug。当然,这只是解决这个 Bug 的方案之一。对分块压缩算法的理解有问题,也是造成这个 Bug 的原因之一。从这方面着手进行改进也是可以的,各有利弊而已。
但这不是我要表达的重点。在这个案例里,下载的文件并不会很大,几十 KB 就顶天了。我真正疑惑的地方在于:为什么不用动态内存分配?
可能的解释有:
  • 担心内存碎片问题
  • 担心忘记释放
  • 嫌动态分配内存麻烦
  • 习惯了这种固定长度缓冲区的写法
  • ……
也许还有别的原因,一时半会儿我是想不到了。

那么换个问题:什么时候该用动态内存分配?
这个答案会比较明确一点:
  • 空间大小不确定(运行期确定)
  • 栈上空间不够
  • 方便与线程外部传递 / 分享数据

在本文的这个例子中,文件的长度是不确定的,每块数据压缩后的长度也是不确定的。很明显,这就是属于应该用上动态内存分配的时候。
该用的时候不用,带来的恶果就是程序的可读性和可维护性就会变得差,出 Bug 的机会更高。毕竟固定长度的内存区域就一定要处理溢出问题。而且用固定长度去处理变长内容,要分块 / 分次,要做循环,要留意退出条件,测试时要覆盖 1 和 N……,这些都带来了不必要的开销。
还不如直接分配一块内存出来,只要到时候记得回收就 OK。性能方面值得担心的话,也可以自己优化内存管理,这是可以集中处理掉的事情。而那种用固定长度的栈缓冲区来解决此类问题的办法,好听一点叫做「质朴」,难听一点叫「土」。总不能每个需要动态内存分配的地方,都用这种土办法来应对吧。

我其实是觉得,有些程序员,会有意识(或下意识)地避免用动态内存分配。从写代码的时候就开始重视性能,是好事情,但写程序不能只看功能和性能。你写的程序,好不好懂,容不容易出问题,有没有定时炸弹,好不好改,方不方便扩展,这些也都是很重要的。性能不佳可以优化,这种代码级的性能问题(相比架构级而言)优化起来尤其容易。但其它的方面,要改善起来绝非一日之功。
往开了说,作为程序员,应该避免陷入「某个东西就是不好」的思维方式中。思维开始变得狭隘,是自身没法继续再提高(达到上限了)的标志之一。

2013-03-21

如此错误检查,还不如不要

今天在查一个 Bug。表面现象很奇怪,至少让测试组觉得很奇怪。故障现象是这样的:
有一个列表框,如果里面只有两条记录,那么没事。如果有三条以上的记录,那么在删除记录时会「随机」出现程序无响应的情况。

上面这段错误描述中,「随机」二字之所以打引号,是因为其实不是随机的。只是测试组的同事没有找到规律而已。只要按从下到上的顺序进行逐个删除,就很容易遇到故障。

这种现象,有经验的程序员一看就知道跟序号、数组之类的东西有关。
果不其然,在代码中我找到了这样的一段:
pItem = ListView->Selected;
for (; pItem != NULL;)
{
    int iWhich = (int*)pItem->data;
    if (iWhich >=0 && iWhich < ItemArray.GetSize())
    {
        ItemArray.RemoveAt(iWhich);
        ListView->Delete(pItem->Index);
    }
    pItem = ListView->Selected;
}

这里解释一下,ListView->Selected 能拿到列表内当前选中的第一行,而 ItemArray 是一个项目组自己实现的数组对象。这两个东西都没啥毛病。

坑爹的代码就是这一句:
if (iWhich >=0 && iWhich < ItemArray.GetSize())
我能理解,写的人是想把iWhich的取值限定在某个区间。因为这个 iWhich 接下来会被用作数组下标。如果越界,后果不堪设想。有这个意识,是好的。
但是,仅仅意识到这个问题,还不够。接下来还有问题了:iWhich 会越界吗?什么情况下会?

我相信,如果写这段代码的人当时有问过自己这个问题,那么就不会有这个 Bug 了。
这个 iWhich 来自一个 pItem->Data,这是一个 ListView 行对象附带的 DWORD 类型的数据,其值是由使用者(程序员)赋值的。
也就是说:如果你给它正确赋值,那么就不会有不正确的值出现。如果它有问题,那么是你前面的程序造成的。

也许还是有程序员会担心,这个 pItem->Data 会不会什么时候被改掉。也可能赋值的地方是另一个程序员写的。而写这段代码的程序员出于防御性编码的目的,写下了这样的判断。那么 OK,没有关系,判断就判断吧。可是判断为 FALSE 的时候怎么办呢?
他什么都没有做。

其实,判断为 FALSE 之后,从 ListView 中删掉这一行,应该是安全的。从上面的代码中可以看出,删除行的时候,只用到了 pItem->Index。这是由操作系统自行维护的值,不会存在 Data 那种「可能没有正确赋值」的情况。
又或者,实在不放心,那么直接把整个函数 return 掉,甚至报个错,也是可以的。
现在的处理方式就直接导致了死循环。这是很糟糕的情况,单核机器的用户甚至可能会几乎没有机会来做什么处理。

好了,上面这些就是我这篇博文想说的:有防御性编码的意识,很好!但是处理方式也要正确有效。你不应该在避免一个错误的同时,引入另一个错误。

那么,最后还有个遗留问题:iWhich 为什么会越界呢?
我想,对于合格的程序员,这个问题不应该成为问题。所以我就不说了。

2012-10-19

一个关于浮点运算的坑

这两天遇到一个很奇怪的问题,后来通过排除法终于把故障点缩小到一句代码,但还是很奇怪:

某个同事以前用 VC 写了一个 DLL 用于提供某类通用计算,相当于一个计算模块,由我这边写 BCB 程序来调用。不过在这次的问题中发现,一旦线程调用过 pow() 这个 C 库函数来计算过一个非整数指数的幂,那么这个计算模块接下来同样的参数再次用就会得出不一样的结果。调用过 pow() 之前是一种结果,调用之后是另一种结果,现象相当稳定。

诡异就诡异在 pow() 的返回值既没有错,也没有被采用过。比如仅仅一句:
pow(2.0, 3.1);
函数返回值根本没有用来干过任何事情,相当于直接丢掉了。那么按道理来说这行代码应该不会对之前或之后的代码造成任何影响。但它确确实实影响了。
然而指数如果是整数,就不会,比如:
pow(2.0, 3.0);

那个写 DLL 的同事对此也是一头雾水。怀疑点一度被放在 VC / BCB 身上,因为它们的 CRT 不一样。但因为正好有一个测试工具,稍加改造便可以针对计算过程输出详细的结果报表,因此用来比较了一下,发现了问题:出问题的地方,有两个本来应该用来比较的 double 型输入数据正好是相等的。在 pow() 调用之前,它们的确被判断为相等。但调用 pow() 之后,计算结果显示它们被判断为不相等了。

这种事情对于常写浮点运算相关代码的程序员而言是很容易引起警惕的。浮点数不能直接用 == 之类来比较,必须去判断两数相减的绝对值是否小于某个精度。所以将这个测试结果提交给写 DLL 的同事之后,很快就定位并解决了问题。
但这里我更关注的是以下几个问题:

  1. pow(2.0, 3.0) 和 pow(2.0, 3.1) 的不同,使我相信 CRT 肯定对前者作了优化。这种事情,想得通,但后果可能是个坑。
  2. 很明显,CRT 在 pow() 被调用之后,处理浮点数时的行为模式改变了。通过观察 _statusfp() 的返回值,我发现调用前为 0,调用后变成了 0x20。但这个 0x20 是一个 Undocumented 的东西,哪怕是最新的 MSDN 上也找不到。并且我通过 _fpreset() 将状态字变回了 0,但计算模块仍然会出错,说明应该还有别的东西也被改了。这个坑是不是 VC 和 BCB 联合挖的,目前还不知道。
  3. 如果不知道有这个坑,就可能导致一些大麻烦。在特定的代码逻辑中,这个问题很难通过黑盒测试发现。它可能在很长一段时间内都能工作得很好,直到某一次有个用户算了一个指数带小数点的幂,然后……一切就不一样了。这简直就是逻辑炸弹嘛!让我想起了《深渊上的火》里面可怜的蓝荚和绿茎。
  4. MSDN 真心不是完全靠得住的。

在本文最后,还要对提供过重要帮助的 Libin Yan 表示感谢!并感谢所有关注和评论过这个 PO 的 G+ 网友!

2011-10-08

都是负数惹的祸?

十一长假之后第一天上班,继续调试那位还在婚假当中的同事留下的代码。上午查到的一个 Bug 让我相当无语。不过鉴于自己如果思想在开小差的话也很可能会犯类似错误,故记录下来警醒自己。

看看这段代码:

#define ST_ERR -1
……
BYTE result = ST_ERR;
……
if (result == ST_ERR)
    return errmsg;

事实上,这个if语句根本就没得到执行。即使 result 在赋值了 ST_ERR 之后从未改变,代码在执行时仍然跳过了判断,继续执行下面的代码。也就是说,result 明明赋值了 ST_ERR,但却不等于 ST_ERR。

难道遇到鬼了,result 中间被篡改了?溢出过?

好吧,其实 result 的确不是 -1,而是 255。看看 BYTE 的定义就清楚了。BYTE 并不是 C/C++ 内建的数据类型。在 Win32 平台上,它通常都被定义为 unsigned char。

到这里就明白了,unsigned 当然不可能有负数。而 ST_ERR 因为用了宏定义,当做常量使用的时候并不会自动转成非负的 unsigned 形式,这样当然不可能相等了。

说穿了,不是负数惹的祸,而是「宏」这个东西惹的祸。如果用 const 常量,因为常量的定义上有类型信息存在,就不会有这个问题了。

当然,这里无意评价宏与 const 常量的好与坏,单纯指出问题而已。目前这个公司的代码,宏用得不少,难保会有菜鸟程序员出这种错,隐患啊!

2011-10-04

在 STL 容器中删对象一定要小心

先来看一段代码:

typedef map<string, string> Dict;
Dict eventmap;
for (Dict::iterator pIter = eventmap.begin(); pIter != eventmap.end(); ++pIter) {
    if (pIter->second == "no")
        eventmap.erase(pIter);
}

熟悉 STL 的人都知道,这段代码是错的。出问题的可能性不是 100%,但是相当大,而且可能会是那种不一定能稳定重现的问题,往往会搞得人很恍惚。

自己很早以前就吃过这个亏,那时候还不太会用 STL。当定位到这一段代码后,凭直觉也觉得如果这样删除对象,那删除之后的 pIter 是不是正确的?可能很难讲。毕竟学过「数据结构」,大概能猜到各种容器里面的信息是如何组织的。

昨天又看见同事在这样写代码。可能他对 STL 也不熟。至少我目前对于 erase 这个单词已经产生了足够的警戒,一旦要用的时候就会想起吃过的亏来。

正确的代码应该是:

typedef map<string, string> Dict;
Dict eventmap;
for (Dict::iterator pIter = eventmap.begin(); pIter != eventmap.end();) {
    if (pIter->second == "no")
        pIter = eventmap.erase(pIter);
    else
        ++pIter;
}

另外,昨天发现 Borland C++ Builder 提供的 STL 和 VC 有点区别,map::erase 没有返回值,因此不支持这种写法。那么还有另一种办法:

        eventmap.erase(pIter++);

这样就可以了。相比之下,这种写法可能适应性更强一些。

2011-03-29

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

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

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

2006-08-28

C 随机函数的正确用法

看到有同事在用 rand() % 100 + 1 这样的方法试图获取一个 1~100 的随机数。虽然 rand() 出来的确实是一个整数,而且确实会大于 100,但是这样得到的随机数并不是从 1~100 机率均等的。用在某些场合,不是很合适。

在 C 中正确的取随机数应该是这样:

#include <time.h>
#include <stdlib.h>

// 初始化随机数序列
void randomize(void);
{
    srand((unsigned)time(NULL));
    return;
}

// 获得一个0<=x<1之间的随机数(双精度)
double randf(void)
{
    return (double)(rand()) / (RAND_MAX + 1);
}

// 获得一个0<=x<number之间的随机数(整数)
int random(int number)
{
    return (int)(rand() * number / (RAND_MAX + 1) );
}

random 之前别忘了先用 randomize 进行初始化,否则每次出来的随机序列可是一样的。呵呵,用时间值进行初始化也不一定保险,特别是用在博彩系统中,曾有人根据系统启动时间推算出随机数种子的例子。有必要的话,建议各位加上别的因素(比如对鼠标进行采样等)进行随机数种子的初始化工作。


更新于 18 年以后:

后来有人跟我杠,说 rand() % 100 + 1 这样写才是对的。
我的确也在很多地方看到有人这样写,甚至包括 V2EX。我有点不是很明白,现在程序员的门槛这么低了吗?这些人是不是都是写 Web 前端的?这已经不是编程问题了,这是数学不及格啊!
往轻了说,你为了性能和开发效率,在业务需求允许的情况下走了「捷径」,起码在心中要有这个 X 数,而不是来跟我杠说这个写法才是对的。你那么爱杠,去跟 AI 杠一杠吧,它会让着你的。

2006-06-29

《C Traps & Pit Falls》读后偶感

内容有点旧,不过看过之后还是有一些收获。

其中有一段很有趣的回忆:

……我还记得自己在开发某个系统时,曾经与一个用户有过这样一场对话:

「这部分记录中可能出现的代码有哪些?」 「可能的代码是 X、Y 和 Z。」
「如果与 X、Y 和 Z 不同的代码在这里出现,该怎么办呢?」
「这不可能发生。」
「嗯,但如果这种情况确实发生时,程序需要做些适当的处理。你认为程序应该做些什么呢?」
「这个我可不关心。」 「你真的不关心?」 「对。」
「那么,如果程序在检测到不同于 X、Y 和 Z 的代码出现时删除整个数据库,你也不会介意吗?」
「太荒唐了。你绝对不能删除整个数据库!」
「那就是说,你还是介意程序在这种情况下的行为。那么,你希望程序做些什么呢?」

……

看到这里,忍不住直想发笑。昨天我还遇到类似的对话来着。美国人就是会搞笑,我当时怎么没有想到这样幽他们一默呢?