分类 C/C++ 下的文章

最近写CMU15-445的时候,project 1里面有个任务类似于写unique_ptr智能指针PageGuard,创建时增加引用计数,销毁时减少引用计数。然后这里有个让人疑惑的地方,就是他要求写移动构造函数。当时我想着,这跟用= default应该没啥区别,后来不放心,还是做了个实验验证下,发现和想象中的还是有些出入。

废话不多说,直接贴代码:

#include <bits/stdc++.h>

using namespace std;

struct A {
    int val = 0;
    string msg = "";
};

int main()
{
    A a1{114514, "1919810"};
    printf("a1{%d,%s}\n", a1.val, a1.msg.c_str());

    A a2(std::move(a1));
    printf("a2{%d,%s}\n", a2.val, a2.msg.c_str());
    printf("a1{%d,%s}\n", a1.val, a1.msg.c_str());

    return 0;
}

然后结果是,a2能成功继承a1的所有数据,但是在move后的a1中,只有std::string的值被清空,而int类型的数据仍然保持了原有值,而非初始化到0。这个结果就与成员变量的平凡性有关了:对于平凡类型,move和直接复制该类型没有任何区别。也就是说,仅仅对于非平凡类型而言,才会存在真实的move动作,并将被move对象重置为设定的某个中间状态——平凡类型要求使用编译器隐式生成的移动构造函数,因而并不会对原有数据进行修改,move之后还是保持原有的值。

由于规定以C语言生成的所有数据结构都是平凡的,我们可以推测指针本身也是一种平凡类型,move后不会被清空。那么这下问题就解决了:要求我们自己实现移动构造函数的原因是,在对象中使用了C指针而非智能指针,并不能通过move的方式来解除被move对象与内存区域的绑定,需要自己手动使用nullptr来重置这些指针,防止它们在预期的生命周期外修改数据,制造未定义行为。

最近试图在gem5里做一个数据导出的工作,即在运行过程中记录每次内存访问的时间、位置和数据。

本来是打算当一个单线程来写的,但是考虑到本来跑一个程序用时就长,把关键路径做得尽可能短对工作效率比较友好,就开始考虑把io相关的工作单独提取到一个新线程完成,结果不知不觉地把这个问题演变成一个多线程问题了。

由于涉及到并发编程,原子操作和锁是绕不开的两个概念,一轻一重两大门神守护着多线程环境下的数据安全性,而两者在C++下分别对应有最为常用的头文件,<atomic><mutex>。由于锁的基本使用较为简单,此文主要记录原子类型变量的基本使用方法。

原子类型所要完成的任务是,确保对该类型的指定操作以原子方式完成。至于为什么一开始不把类型设计成原子操作的,这里涉及硬件设计与软件语义之间的抽象层次差异,换句话说,以硬件成本来看,不值得。数据类型难以有个规范,要是为每种数据类型单独设计一条指令,那光是原子操作指令都能上天。VCISC,超复杂指令集的奠基者

于是乎,现有计算机系统采用了折中方案,即硬件提供一些最基本的指令,再使用一些软件手段,以时间/空间为代价,来拓宽原子操作特性的可用范围。以此为契机,出现了<atomic>这种向用户提供软(也可能是硬)原子操作的语言基础库。

原子操作最常见的用法是使用类模板定义对应类型的变量:

std::atomic<int> atomic_int{0};  // use (0) instead of {0} is okay

上面的代码定义了一个原子操作的int类型变量,并且赋初值为0。注意这里用的是括号初始化,而不是等号赋值。这是因为在定义变量时,operator =并不是将值赋予该变量,而是执行对象复制操作,而翻阅文档,有如下定义:

atomic& operator=( const atomic& ) = delete;

也就是说,语言禁止了atomic对象的复制,只能通过值初始化的方式在变量声明时设置初始值。

介绍完初始化操作之后,就是读写操作了。std::atomic提供了两种对原子变量的读写方式,一是像普通变量一样正常使用,二是load/store接口:

std::atomic<int> val{0};
int tmp = val.load();      // equivalent to `int tmp = val;`
val.store(1);              // equivalent to `val = 1;`

虽然说像普通变量一样写很方便,但是真到读代码的时候原子变量和普通变量混在一起还是挺麻烦的。个人还是觉得用显式的load/store比较好。

然后就是大名鼎鼎的复杂原子操作了。复杂原子操作具有共同的特征:RMW(Read-Modify-Write,读-改-写),而CAS(Compare-And-Swap,比较并交换)则是其中最为基础的RMW操作,所有的复杂RMW操作都可以基于CAS操作完成。对于CAS,<atomic>头文件提供了两种相似的实现方案,compare_exchange_weakcompare_exchange_strong。cplusplus对于这两种方法的区别介绍的非常隐晦,只是说strong方法用起来符合直觉,运行结果是标准的CAS;而weak方法用起来性能更好,但是可能出现假阴性的情况(即使compare结果是允许swap的,但还是判定为不允许,走失败逻辑)。

先是compare_exchange_strong的使用样例。

std::atomic<int> val{0};
int prev = val.load();
val.compare_exchange_strong(prev, newval);

这里取原始值为prev,以该值作为CAS的对比值,如果执行compare_exchange_strong的时候读取到的val仍然是该值的话,则执行CAS操作,将val值设置为newval,并给出返回值true;否则不执行CAS操作,反而将prev值设置为最新读取到的val值,并返回false

其次是compare_exchange_weak。由于这里可能出现假阴性的逻辑错误,需要额外对代码做一点修改,来保证代码仍然能完成正确的逻辑。

std::atomic<int> val{0};
int prev = val.load();
while(!val.compare_exchange_weak(prev, newval));

可以看到weak的用法与strong略有差别,这里多套了一层while循环。如前面所说,使用while的目的是对付compare_exchange_weak中即使满足CAS条件,但仍然执行失败流程的情况。这种方式并不适用于顺序敏感型的应用,比如乘加操作,不同线程的操作顺序会导致产生截然不同的输出结果;但是对只需要将数据加入指定结构而不要求顺序的类链表操作的话,这个方法还是具有更高的效率的(因为有神秘的性能优化)。

对于部分常见基础类型,std::atomic预先提供了常见运算方式的原子操作实现,比如原子加fetch_add、原子减fetch_sub,对于char/int及其各种衍生基础类,还提供有原子位或fetch_or/位与fetch_or/位异或fetch_xor等原子逻辑操作的实现。

离大谱

用VS+Boost写程序的时候,Intellisense没有给出任何报错,但是编译的时候给出如下信息:

LNK2038: mismatch detected for 'boost_log_abi': value 'v2s_mt_nt6' doesn't match value 'v2s_mt_nt62'

到网上转了一圈,看到别人遇到了类似问题,但又不完全一样,包括:

  • 编译Boost库的环境与编译应用程序的环境不一致(v2s_mt_nt5/v2s_mt_nt6
  • 用动态库链接静态对象文件(v2_mt_nt6/v2s_mt_nt6

但我这个情况,库版本没问题,编译环境也完全一致,报错也跟上述有点不一样。。。

地铁老人手机.jpg

找了好久原因,后来想起之前遇到过相同的问题,从哪里找到的解决方案忘记了,但方法奏效了:原因是我将其他库(某不愿透露姓名的PMDK)和Boost一起使用,然后可能是两个库使用了相同的宏变量,把Boost的头文件include放在最前面就好了。

如果有人遇到了相同的问题,可以考虑试一下交换include顺序,可能就是不同库碰巧使用了同一宏变量呢= =

大意了,没有闪

起因是在VS新建了一个C++项目,然后试图引用一个用C写的函数库。结果编译运行的时候报错了,提示无法解析的外部符号。根据常识,这个问题一般与C++的函数重载有关,但是在仔细检查了库函数和调用时函数的签名之后发现并没有问题。用grepdumpbin对着编译出来的.lib静态库文件捣腾了老半天,也愣是没有找出问题所在。

左思右想,唯一的端倪在于用dumpbin观察.lib静态库文件时发现,编译出来的库函数是没有带C++后缀的,而报错的地方所调用的库函数是带了C++后缀的,这也正是导致出现函数名不匹配的地方。无奈之下只能去网上冲浪,带着我也描述不清的问题试图找出解决方法。

在万能的StackOverflow上看到了这个回答之后才意识到,函数名是在编译期确定的。由于头文件本身作为源文件参与编译,编译器只能通过源文件信息来确定这是C还是C++代码。很显然,.c后缀的被MSVC作为C代码编译,而.cpp文件则是作为C++代码编译。

而正是这细微的编译规则差异导致了本次的问题:在库函数源代码中,由于是以.c源文件的形式参与编译,遵循了C语言不允许函数重载的规则,编译出来的函数名就是头文件中所定义的函数名;而在我写的调用程序中,因为使用了.cpp格式的文件,代码使用了C++规则编译,因而给头文件中定义的函数名加上了C++的小尾巴(如@UAEXXZ这种)。在之后的链接过程中再用C++风格的函数名去匹配C风格的函数体,由于两者名字不一致,就产生了无法解析的外部符号问题。

解决方法也非常简单,在写C/C++混合调用的程序时,被调用者(主要是头文件)最好加上用#ifdef __cplusplus包装的extern "C"标记,以确保最大的兼容性

还是神奇的gem5。

这次是在学习底层实现的时候,看到的船新POSIX API:

    if (sharedBackstore.empty()) {
        shm_fd = -1;
        map_flags =  MAP_ANON | MAP_PRIVATE;
    } else {
        DPRINTF(AddrRanges, "Sharing backing store as %s\n",
                sharedBackstore.c_str());
        shm_fd = shm_open(sharedBackstore.c_str(), O_CREAT | O_RDWR, 0666);
        if (shm_fd == -1)
               panic("Shared memory failed");
        if (ftruncate(shm_fd, range.size()))
               panic("Setting size of shared memory failed");
        map_flags = MAP_SHARED;
    }

    ...

    uint8_t* pmem = (uint8_t*) mmap(NULL, range.size(),
                                    PROT_READ | PROT_WRITE,
                                    map_flags, shm_fd, 0);

由于这块代码是从gem5中内存模块复制出来的,其功能是配置内存模块维护被模拟系统数据所需的储存空间(初始化),可以预见的是单词分配的内存空间大小至少是MB,甚至GB级别的。第一眼看去有点眼花,还在想Linux的shared memory什么时候能够配置GB级别的共享内存块了?仔细一看才发现,这里用的是shm_open而不是shmget,只是名字长得像,功能有很大差别= =,前者在fnctl.h,后者在shm.h中定义。(P.S. 在APUE第三版上翻了老半天都没找到,我觉得应该不是我的问题

比较一下shmgetshm_open的函数签名:

int shmget(key_t key, size_t size, int shmflg);
int shm_open(const char *name, int oflag, mode_t mode);

不同于shmget在创建共享内存块的时候需要声明块大小,且存在比较小气的最大限制(Linux每块默认不大于4KB,MacOS和Solaris还是比较大气,大了几个数量级),shm_open的语法更偏向于open,创建的是一个默认大小为0的空文件,关于文件大小、文件内容需要开发者后续调用其它syscall来修改。

那么shm_open实际上做了什么呢?实际上是创建了一个基于内存的文件描述符。根据man7上的文档介绍,Linux上的实现方法是建立了一个基于内存的tmpfs,并挂载到/dev/shm目录下。调用shm_open的时候,会对应地在目录下用文件的形式进行相应的操作。

如上文所述,shm_open以后,文件的大小为0,用常规方法往文件写入数据并不能如我们所愿,将文件“撑”大。这时需要使用ftruncate系统调用解决该问题,由操作系统完成对文件大小的调整。

由于shm_open系统调用与进程无关,在不同程序中可以使用相同的操作获取同一块共享内存,达到无关进程内存共享的目的。这与文件系统类似,第一次调用建立文件,后面的重复调用只需要修改文件引用值即可。由shm_open创建的文件描述符多与mmap结合运用,直接将fd用于mmap中,其余按照需要配置即可,本文开头的代码段给出了较好的示范。

当一个进程不再需要使用这段共享内存的时候,使用shm_unlink函数,把获得的fd作为参数传过去即可。当最后一个引用关闭之后,共享内存对象由系统销毁。

这里顺便说一下上面的代码段中,mmap使用的一个小细节。当满足条件sharedBackstore.empty() == false的时候,fd设置为-1之后,直接执行了mmap操作。-1显然是一个无效的文件描述符,但是为什么程序还能正常运行?

在StackOverflow上搜索后,万能网友的给出了答案:此时的fd-1是允许的,而这是与mmap的参数map_flagsMAP_ANON(或MAP_ANONYMOUS)这个flag共用时的一个特殊情况。在声明了MAP_ANON/MAP_ANONYMOUS的情况下,该共享内存块仅对当前进程及其子进程有效,其他进程无法访问。某些POSIX实现要求这种情况下,fd一定要传-1,而这也只是特殊情况,并不代表在fd=-1的情况下mmap具有特殊的逻辑。