小记最近踩得两个C++坑

小记最近踩得两个C++坑

记一下最近踩得两个C++独有的暗坑,其中一个和ABI相关。第二个坑其实之前研究过,但是没有实例,这次算是碰到了个典型的实例。

坑一:常量引用失效

在项目中碰到的实例的大致流程是:

  1. 获取某个容易的迭代器,迭代器内包含智能指针(std::shared_ptr)
  2. 把智能指针通过常量引用方式传入函数
  3. 执行过程中智能指针被释放
  4. 于是这时候,我们有了一个空悬的智能指针引用了

用代码表示的话,流程如下:

std::map<int, std::shared_ptr<T> > outter_map;

void func1(int a) {
    std::map<int, std::shared_ptr<T> >::const_iterator iter = outter_map.find(a);
    if (iter != outter_map.end()) {
        func2(iter->second);
    }
}

void func2(const std::shared_ptr<T>& obj_ptr) {
    if (!obj_ptr) {
        return;
    }
    // 从逻辑隔离的角度,按照正常的语义,这里之后obj_ptr应该一直有效了吧 
    // ... ,执行了茫茫多操作以后,间接调用了outter_map.erase([上一层函数用到的a])
    
    obj_ptr->xxx; // 这里崩溃了,因为智能指针常量不再有效
}

如果这两个函数分散在两个模块里,并且是不同人写得话,很难发现这个问题。因为对双方各自的使用来说,似乎都没什么问题(上层不关心下层的内部实现,下层不应该认为常量是不会变化的)。但是放一起的话问题就来了。所以算是C++ 使用上的一个坑。解决办法也很简单,强制造成一次引用计数即可。

以下是对func1的改造

void func1(int a) {
    std::map<int, std::shared_ptr<T> >::const_iterator iter = outter_map.find(a);
    if (iter != outter_map.end()) {
        // 这意味着调用方要保证被调用方不会出现问题,而间接关心被调用方的实现
        std::shared_ptr<T> cache = iter->second; 
        func2(cache);
    }
}

并且这个问题在迭代器内容不是智能指针的时候也存在,不过会导致解决方法更加复杂(因为不应该有对象的拷贝复制)。这里不再列举。

坑二:Linux环境下共享静态库的问题

这个问题之前就提及过《C++又一坑:动态链接库中的全局变量》现在则是碰到了更有代表性的实例。

我们的程序框架和逻辑模块的关系是。逻辑服务器编译成一个动态链接库,由框架执行dlopen加载。框架之间通信是采用protobuf,逻辑服务器和哭护短通信也采用的是protobuf。那么问题就来了,两个模块都使用了protobuf并且都是静态链接,而protobuf里的协议描述信息又是全局的(我们这里体现在了google::protobuf::FileDescriptorTables这个类上,并且它在常量区),并且存在多种协议集合。

按照Linux的ABI的实现逻辑,这个全局的对象在框架层面会进行一次初始化构造,在动态链接库里又会执行一次初始化构造。并且次执行构造函数的this指针地址一样,成员(特别是STL)的构造数据地址不一样。

这些导致少量的内存泄露都还是其次,最重要的问题是,在析构的时候,dlclose会进行析构的内存回收,主框架也会。这就导致了回收了两遍,并且回收不完全。

我们这里检测到是在google::protobuf::FileDescriptorTables析构时hash table的析构的时候内存错误。而且由于现在的内存分配器都有容错,意味着这个崩溃不是必现的。使用debug版本的jemalloc可以100%复现这个问题,而使用release版的jemalloc或者ptmalloc或者tcmalloc的时候都不能及时发现。valgrind的检测信息大致如下:

==29910== Invalid read of size 8
==29910==    at 0x4F75F0: _M_deallocate_nodes (hashtable.h:467)
==29910==    by 0x4F75F0: clear (hashtable.h:1121)
==29910==    by 0x4F75F0: ~_Hashtable (hashtable.h:640)
==29910==    by 0x4F75F0: ~__unordered_map (unordered_map.h:43)
==29910==    by 0x4F75F0: ~unordered_map (unordered_map.h:180)
==29910==    by 0x4F75F0: ~hash_map (hash.h:172)
==29910==    by 0x4F75F0: google::protobuf::FileDescriptorTables::~FileDescriptorTables() (descriptor.cc:606)
==29910==    by 0x6212E48: __run_exit_handlers (in /usr/lib64/libc-2.17.so)
==29910==    by 0x6212E94: exit (in /usr/lib64/libc-2.17.so)
==29910==    by 0x61FBAFB: (below main) (in /usr/lib64/libc-2.17.so)
==29910==  Address 0x702f020 is 0 bytes inside a block of size 96 free'd
==29910==    at 0x4C2B131: operator delete(void*) (in /usr/lib64/valgrind/vgpreload_memcheck-amd64-linux.so)
==29910==    by 0x4F7650: deallocate (new_allocator.h:110)
==29910==    by 0x4F7650: _M_deallocate_buckets (hashtable.h:509)
==29910==    by 0x4F7650: ~_Hashtable (hashtable.h:641)
==29910==    by 0x4F7650: ~__unordered_map (unordered_map.h:43)
==29910==    by 0x4F7650: ~unordered_map (unordered_map.h:180)
==29910==    by 0x4F7650: ~hash_map (hash.h:172)
==29910==    by 0x4F7650: google::protobuf::FileDescriptorTables::~FileDescriptorTables() (descriptor.cc:606)
==29910==    by 0x62131B9: __cxa_finalize (in /usr/lib64/libc-2.17.so)
==29910==    by 0xF4C44E2: ???
==29910==    by 0x40146F0: _dl_close_worker (in /usr/lib64/ld-2.17.so)
==29910==    by 0x401525B: _dl_close (in /usr/lib64/ld-2.17.so)
==29910==    by 0x400F2F3: _dl_catch_error (in /usr/lib64/ld-2.17.so)
==29910==    by 0x52AA62C: _dlerror_run (in /usr/lib64/libdl-2.17.so)
==29910==    by 0x52AA10E: dlclose (in /usr/lib64/libdl-2.17.so)

结论

对于前一个问题,属于纯C++坑,对于第二个问题,虽然Windows环境下不会出现问题,但是要开发跨平台代码的话,势必要对开发过程做出规范。

如果要编写一个可以供其他多个模块使用的库(即不保证一个应用程序及其所依赖的动态链接库里链接这个库的次数总和<=1的情况下),应该符合下面的条件:

  1. 编译成库的时候尽量使用动态链接库(带-fPIC)
  2. 如果一定要使用静态库,则库里不能使用全局变量或静态局部变量
  3. 如果实在不能避免使用全局或静态变量,这些变量必须是POD类型且一定不能有构造初始化
  4. 因为条件2的原因,所以也基本和单例模式说ByeBye了

条件1的目的是,每个程序载入动态链接库之后再程序中只有一份地址空间,并且不会被重复载入。所以不会有问题。而是用静态库时,数据只有一份,代码却有多份。

条件3的原因在于,很有可能程序在执行一段时间之后再加载动态链接库,如果存在构造初始化,那么在加载这个动态链接库的时候还是会把之前初始化正常的数据给冲刷掉。

不过由于纯C没有构造初始化一说,所以语言层面就已经避免了条件2条件3带来的问题。但是对条件2纯C仍然需要小心,特别是对于那些声明为启动main前执行的函数和退出后执行的函数。

Written with StackEdit.

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏喔家ArchiSelf

全栈必备JavaScript基础

1995年,诞生了JavaScript语言,那一年,我刚刚从大学毕业。在今年RedMonk 推出的2017 年第一季度编程语言排行榜中,JavaScript 排...

1294
来自专栏海说

深入理解计算机系统(3.2)---数据格式、访问信息以及操作数指示符

  本文的内容其实可以成为汇编语言的基础,因为汇编语言大部分时候是在操作一些我们平时开发看不到的东西,因此本文的目的就是搞清楚,汇编语言都是在操作些什么东西。或...

922
来自专栏有趣的django

1.python简介

简介 1、python语言介绍 python的创始人:Guido Van Rossum 2、python是一门什么样的语言 编程语言主要从以下几个角度进行分类:...

3955
来自专栏程序员阿凯

JDK10 揭秘

1545
来自专栏JetpropelledSnake

Linux学习笔记之Redis中5种数据结构的使用场景介绍

原来看过 redisbook 这本书,对 redis 的基本功能都已经熟悉了,从上周开始看 redis 的源码。目前目标是吃透 redis 的数据结构。我们都知...

1211
来自专栏海说

深入理解计算机系统(3.2)---数据格式、访问信息以及操作数指示符

  本文的内容其实可以成为汇编语言的基础,因为汇编语言大部分时候是在操作一些我们平时开发看不到的东西,因此本文的目的就是搞清楚,汇编语言都是在操作些什么东西。或...

1304
来自专栏java一日一条

java提高篇之异常(上)

在这个世界不可能存在完美的东西,不管完美的思维有多么缜密,细心,我们都不可能考虑所有的因素,这就是所谓的智者千虑必有一失。同样的道理,计算机的世界也是不完美的,...

982
来自专栏彭湖湾的编程世界

【javascript】异步编年史,从“纯回调”到Promise

异步和分块——程序的分块执行 一开始学习javascript的时候, 我对异步的概念一脸懵逼, 因为当时百度了很多文章,但很多各种文章不负责任的把笼统的描述混杂...

2208
来自专栏机器之心

资源 | 简单快捷的数据处理,数据科学需要注意的命令行

1655
来自专栏AzMark

Python 学习之模块

1253

扫码关注云+社区

领取腾讯云代金券