跳到主要内容

21 篇博文 含有标签「C++」

查看所有标签

C++ 包管理方案

· 阅读需 4 分钟

为什么 C++ 没有一个统一的包管理器

很多主流语言都有自己的包管理方案,比如 Python 的 pip, JavaScript 的 npm, Java中的 Maven 等,但是 C++ 并没有一个统一的包管理方案,归根到底有一定的历史原因。C++ 标准在不断地演进,C++98,C++03,到现代的C++11/14/17/20等,每个版本都引入一些新特性,在这些标准当中引入一个官方的包管理器要考虑到前后兼容性和现有系统的影响。另外 C++ 的生态系统非常庞大和多样化,涵盖了不同的编译器,操作系统,硬件平台等,一个官方的 C++ 管理器平台如何实现需要各个社区达成一致,制定一个统一的标准,这是一个复杂且需要时间的过程。

没有包管理器的开发环境是怎样的

首先,项目规模不大的话,有没有包管理器是影响不大的,选定好平台,安装或者自己编译需要的库一直用发下去就可以了。

但如果项目规模变大可能就会出现问题,依赖项管理困难,团队成员可能需要手动,安装和管理各种依赖和第三方库。进而会导致另外一个问题,版本控制和一致性,每个团队成员手动管理依赖项的时候,可能会使用不同版本的库或者工具,增加代码的不稳定性,出现一些潜在的BUG。

同时手动维护依赖会导致很多流程无法自动化,CI/CD等无法实施 随着团队规模变大,需要投入更大的时间和精力去管理这些流程,慢慢就会变得不可持续。

包管理的各个阶段

  1. 原生拷贝。各个团队成员直接拷贝代码或者库文件给他人使用,且没有版本管理。
  2. 加入版本管理。每个团队维护好自己的代码库,通过CI自动构建,供别人下载使用。
  3. 加入依赖管理。提供一定的策略,获取递归的获取代码库所有依赖。
  4. 解决依赖冲突。当设计到依赖冲突的时候,提供策略解决冲突问题。
  5. 多平台支持。支持不同的架构,操作系统。

一种包管理平台的设计思路

Conan, 一个开源的C++包管理器工具,虽然没有被大规模普及,但是它的一些策略和方案,可以用来学习。

首先一个 C++ 包需要有哪些属性,一个软件包最终都是编译成二进制,所以就要考虑二进制库文件有哪些属性,首先是二进制包是怎么来的

  1. 构建包的体系架构(x86/arm/???),操作系统(Linux/Windows/MacOS/???), 编译器(gcc/clang/msvc/???), 编译器版本,等平台的属性。
  2. 编译选项,比如静态库还是动态库,某些编译选项等包自定义的一些属性。
  3. 依赖项。依赖哪些项目,对项目的依赖粒度,比如是否限制大版本或者小版本。

如果只管理二进制包,即编译之后的库文件,只需要上面这些属性就可以了。同一个版本比如 OpenCV/3.4.2 在不同架构,操作系统,编译器,编译选项下可能会产生数十个版本。其他用户下载固定包的时候也会带上自己的平台属性,然后找到对应的包传递给客户即可。有上面的匹配机制,就保证下载下来的一定是兼容的。

如果不想直接管理二进制库,想要通过管理源代码的方式管理各个平台的二进制库还需要更复杂的策略,比如获取源代码的方式,构建方式,打包方式等。

C++中的锁

· 阅读需 1 分钟

C++中,锁是用来管理并发访问共享资源的工具,下面是与锁相关的一些概念。

std::mutex

互斥锁,最基本的锁类型之一,提供了最基本的锁操作,lock()/unlock()/try_lock();

std::shared_mutex

共享锁,或者叫读写锁, C++17中引入的。允许多个线程同时读取共享锁。

std::lock_guard, std::unique_lock, std::shared_lock

对 mutex 进行了一层封装,更为抽象的封装,RAII风格,为 mutex 的管理类。

  • std::lock_guard 在构造函数时候加锁,在析构的时候释放锁。
  • std::unique_lock 支持手动释放锁,加锁。
  • std::shared_lock 与 std::shared_mutex 配合实现共享锁

std::atomic_flag

原子布尔类型,可用于实现自旋锁。提供有 test_and_set()/clear() 方法

std::condition_varable

条件变量不是锁,而是与锁结合使用来实现复杂的线程同步机制。它允许一个线程在条件变量上等待,被唤醒之后先判断表达式的值,如果为真再尝试获取锁。

C++虚继承下的内存布局

· 阅读需 6 分钟

讲虚继承之前,先讲讲多继承,下面是一个多继承的示例, C 继承了 A 和 B。

#include <cstdio>

class A {
public:
long varA;
virtual void funA1(){ std::puts("A::funA1()");};
virtual void funA2(){ std::puts("A::funA1()");};
};

class B {
public:
int varB;
virtual void funB1(){ std::puts("B::funB1()");};
virtual void funB2(){ std::puts("B::funB2()");};
};

class C: public A, public B {
public:
int varC;
virtual void funA1(){ std::puts("C::funA1()");};
virtual void funB2(){ std::puts("C::funB2()");};
virtual void funC(){ std::puts("C::funC()");};
};

先思考下面几个问题

  1. A, B, C的对象大小应该都是多大?
  2. A* a = new C();, 那么 typeid(*a) 返回的会 A 的信息,还是 C 的信息?
  3. 有函数 void process(A *a);, 该函数内部,能否访问到 B::funB1 / B::funB2 吗?
  4. 只给一个类C的指针,怎么不用他的函数接口来访问它的虚函数?
  5. 虚函数调用会增加访问调用开销吗?多重继承得到的虚函数和单层继承得到的虚函数,他们调用开销一样吗?

如果你对上面的问题了如指掌,建议跳过本文章。

内存布局

A,B,C 的内存布局与虚函数表如下

可以通过 g++ -fdump-lang-class -c base.cpp 来看到C++ 类的虚函数表和内存布局

我们先以class A 为例, 讲解下虚函数表的内容。

--------------
0 // Top Offset 原始对象的偏移量
--------------
typeinfo for A // RTTI信息,dynmaic_cast 转换的时候会根据这个判断是否能转换
--------------
A::funA1() // 虚函数表指针指向的位置,注意,虚函数表指针指向的是该位置,而不是虚函数表的开头
--------------
A::funA2()
--------------

在非虚继承当中,基类的内存布局要在派生类中保证完整性,比如示例中 C 的内存布局可以拆分成两块,一块用来表示子对象A,一块用来表示子对象B。上面的 Offset 原始对象指针的偏移。通常多继承的情况下,第一个子对象在内存布局的最顶部,所以 Offset 为 0,但是之后其它子对象的 Offset 就不为 0 了, 比如示例中的 子对象 B ,其 Offset 就为 -16,子对象B的指针向上偏移16就得到了原始对象的指针,该字段在基类向派生类转换的时候会用到。

基类派生类转换

派生类->基类

首先要知道,派生类到基类的转换,百分百会成功,因为所谓的转换就是对指针进行调整,使其指向子对象的位置,这个动作编译器在编译期间就已经确定了。

void fun(C *c){
B *b = c; // 编译器会进行隐式转换,使指针B指向C内部子对象b的位置。
printf("c: %p\n", c); // c: 0x000000000010
printf("b: %p\n", b); // b: 0x000000000020
}

基类->派生类

但是当基类到派生类转换的时候,如果通过基类对象的地址找到其原本派生类对象的地址呢? 这就用到了前面提到的 Top Offset, 基类对象的地址,加上该偏移就得到了原始对象的地址。

int main() {
C c1;
B *b = &c1;
C *c2 = dynamic_cast<C *>(b);
printf("c1: %p\n", &c1); // c1: 0x000000000010
printf("b : %p\n", b); // b : 0x000000000020
printf("c2: %p\n", c2); // c2: 0x000000000010
}

访问虚函数

下面是一份通过内存布局访问虚函数的代码,在 Compiler Explore 上查看

#include <cstdio>

class A {
public:
long varA;
virtual void funA1(){ std::puts("A::funA1()");};
virtual void funA2(){ std::puts("A::funA1()");};
};

class B {
public:
int varB;
virtual void funB1(){ std::puts("B::funB1()");};
virtual void funB2(){ std::puts("B::funB2()");};
};

class C: public A, public B {
public:
int varC;
virtual void funA1(){ std::puts("C::funA1()");};
virtual void funB2(){ std::puts("C::funB2()");};
virtual void funC(){ std::puts("C::funC()");};
};

int main(int argc, char **argv){
C c;
using Fun = void (*)();
Fun *virtual_table = ((Fun**)&c)[0];

// virtual_table[-2] 内部子对象 A 的偏移
// virtual_table[-1] typeinf
virtual_table[0]();
virtual_table[1]();
virtual_table[2]();
virtual_table[3]();
// virtual_table[3] 内部子对象 B 的偏移
// virtual_table[4] typeinfo
virtual_table[6]();
virtual_table[7](); // thunk 间接调用
}

思考

如果 类 A 和类 B 都继承了一个 Base 类,那么 A 和 B 内部都有了 Base 类的成员。 那么 C 内部岂不是有两份 Base 的数据成员?怎么解决这个问题?这个就讲的了虚继承

虚继承

虚继承和普通继承的区别,简单来说有两点

  1. 新增加了一个 vtt 表,也就是虚函数表的表,里面存放的是虚函数表的地址。
  2. 虚函数表内在Top Offset上新增加了字段,用来表示内部虚拟子对象的偏移。

内存布局

下面是一个虚继承下的内存布局与虚函数表示例

注意到 VTT 中有几处空白没有列出来,那几个是构造函数虚表,有兴趣可自行了解。

访问虚函数表

下面是一份通过内存布局访问虚函数的代码,在 Compiler Explorer上查看

#include <cstdio>

class Base {
public:
long varBase{1};
virtual void funBase() { std::puts("Base::funBase"); };
};

class A : virtual public Base {
public:
long varA{2};
virtual void funA1() { std::puts("A::funA1"); };
virtual void funA2() { std::puts("A::funA2"); };
};

class B : virtual public Base {
public:
long varB{3};
virtual void funB1() { std::puts("B::funB1"); };
virtual void funB2() { std::puts("B::funB2"); };
};

class C : public A, public B {
public:
long varC{4};
virtual void funA1() { std::puts("C::funA1"); };
virtual void funB2() { std::puts("C::funB2"); };
virtual void funC() { std::puts("C::funC"); };
};

void printMemoryLayout(void *ptr) {

printf("[0]:%p, vtable_ptr_for_A\n", *((void **)ptr));
printf("[1]:%ld, varA\n", *((long *)((char *)ptr + 8))); // varA = 2
printf("[2]:%p, vtable_ptr_for_B\n", *((void **)ptr + 2));
printf("[3]:%ld, varB\n", *((long *)((char *)ptr + 24))); // varB = 3
printf("[4]:%ld, varC\n", *((long *)((char *)ptr + 32))); // varC = 4
printf("[5]:%p, vtable_ptr_for_Base\n", *((void **)ptr + 5));
printf("[6]:%ld, varBase\n", *((long *)((char *)ptr + 48))); // varBase = 1

using Fun = void (*)();
Fun *vtable_for_a = *((Fun **)ptr);
Fun *vtable_for_b = *((Fun **)ptr + 2);
Fun *vtable_for_base = *((Fun **)ptr + 5);

(*vtable_for_a)();
(*vtable_for_b)();
(*vtable_for_base)();
}

int main() {
C c;
printMemoryLayout(&c);
}

同时调用多个版本的"相同"函数

· 阅读需 2 分钟

在软件开发中,版本管理至关重要。但是,某些极端情况,需要同时运行一个接口的不同版本的函数,那么有没有可能实现呢。

比如下面有两个版本的 fun 函数,他们函数签名,都完全一样,能在一个程序中同时调用这两个函数吗?

// v1/fun.cpp
const char* fun() {
return "fun version 1";
}

// v2/fun.cpp
const char* fun() {
return "fun version 2";
}

在上述代码中,我们定义了两个版本的函数 fun(),分别位于目录 v1/v2/ 下。接下来,我们将这两个版本编译成共享库(.so 文件):

g++ -shared -fPIC -o v1/fun.so v1/fun.cpp
g++ -shared -fPIC -o v2/fun.so v2/fun.cpp

然后,我们在 main.cpp 中调用这两个共享库中的 fun 函数:

#include <dlfcn.h>
#include <iostream>

typedef const char* (*fun_ptr)();

int main() {
// 打开 v1 版本的共享库
void* handle_v1 = dlopen("v1/fun.so", RTLD_LAZY);
fun_ptr fun_v1 = reinterpret_cast<fun_ptr>(dlsym(handle_v1, "_Z3funv"));

// 打开 v2 版本的共享库
void* handle_v2 = dlopen("v2/fun.so", RTLD_LAZY);
fun_ptr fun_v2 = reinterpret_cast<fun_ptr>(dlsym(handle_v2, "_Z3funv"));

// 调用并输出两个版本的函数
std::cout << "Calling v1/fun(): " << fun_v1() << std::endl;
std::cout << "Calling v2/fun(): " << fun_v2() << std::endl;

// 关闭共享库
dlclose(handle_v1);
dlclose(handle_v2);

return 0;
}

在上述代码中,我们使用了 dlsym 函数来获取函数指针,注意参数中的符号是 _Z3funv,而不是函数名fun。在编译并运行程序之前,你可以通过 nm v1/fun.so 查看符号表以获取正确的符号名。有关符号修饰请查看 符号修饰 最后,编译并运行程序:

g++ main.cpp -ldl
./a.out

通过以上步骤,你将能够看到以下输出结果:

Calling v1/fun(): fun version 1
Calling v2/fun(): fun version 2

通过 dl 库,我们发现在一个程序中同时调用这一个函数的不同版本是可行的。(实际项目中一定要避免这种情况,做好版本管理)

上述示例代码可在 https://github.com/wenyg/dynamic_link_example 查看

C++小实验,两个线程交替执行

· 阅读需 1 分钟

cpp小实验,两个线程,一个线程负责将变量减一,另一个线程负责打印线程。

#include <thread>
#include <condition_variable>
#include <mutex>
#include <iostream>

int global_value = 10;
bool ready_to_reduce{false};
std::mutex mtx;
std::condition_variable cv_reduce;
std::condition_variable cv_print;

void producer() {
while(true) {
std::unique_lock<std::mutex> lock(mtx);
cv_reduce.wait(lock,[](){
return ready_to_reduce;
});
global_value--;
ready_to_reduce = !ready_to_reduce;
cv_print.notify_one();
if (global_value == 0) {
break;
}
}
}

void consumer() {
while(true) {
std::unique_lock<std::mutex> lock(mtx);
cv_print.wait(lock,[] () {
return !ready_to_reduce;
});
std::cout << global_value << std::endl;
if (global_value == 0) {
break;
}
ready_to_reduce = true;
cv_reduce.notify_one();
}
}

int main() {
auto t1 = std::thread(producer);
auto t2 = std::thread(consumer);
t1.join();
t2.join();
return 0;
}

C++对象模型(5): 关于数据

· 阅读需 2 分钟
  • 空类也有一字节的大小,因为这才能对对象取地址
  • 一个对象的内存布局通常由三部分组成
    1. 非静态成员变量
    2. 内存对齐所填补的空间
    3. 为了支持 virtual 机制而引起的额外负担,比如 vptr
  • 传统上,vptr 被安置到所有明确声明的 member 后面,但要看具体编译器实现
  • 直观上,通过对象取值 object.xxx 比指针取值 object_point->xxx 更方便,但实际上是完全一样的,都会被编译器扩展
  • 编译器编译的时候会区分
    • 执行data member的指针,指向第一个member (object::member - 1)
    • 指向data member的指针,但是没有指向任何 member
  • 下面两种方式,有什么区别
    X x; 
    x.x == 0.0;

    X *px;
    px->x = 0.0;
    当 X 中含有一个虚基类, 而且 x 正好是虚基类中的成员时,有重大区别。前者可以编译期确定 x 的 offset,但是 px 只能再运行期确定
  • 支持多态带来的负担
    1. 导入virtual table 用来存放 virtual function 地址, 这个table的元素数量一般是virtual function的数目再加上一个或两个slots(用于支持RTTI)
    2. 在每一个object中安插一个 vptr 指向 virtual table
    3. 在 constructor 中安插代码用于设置 vptr
    4. 在 deconstructor 中安插代码用于抹除 vptr

C++对象模型(3): 关于数据

· 阅读需 2 分钟
  • 空类也有一字节的大小,因为这才能对对象取地址
  • 一个对象的内存布局通常由三部分组成
    1. 非静态成员变量
    2. 内存对齐所填补的空间
    3. 为了支持 virtual 机制而引起的额外负担,比如 vptr
  • 传统上,vptr 被安置到所有明确声明的 member 后面,但要看具体编译器实现
  • 直观上,通过对象取值 object.xxx 比指针取值 object_point->xxx 更方便,但实际上是完全一样的,都会被编译器扩展
  • 编译器编译的时候会区分
    • 执行data member的指针,指向第一个member (object::member - 1)
    • 指向data member的指针,但是没有指向任何 member
  • 下面两种方式,有什么区别
    X x; 
    x.x == 0.0;

    X *px;
    px->x = 0.0;
    当 X 中含有一个虚基类, 而且 x 正好是虚基类中的成员时,有重大区别。前者可以编译期确定 x 的 offset,但是 px 只能再运行期确定
  • 支持多态带来的负担
    1. 导入virtual table 用来存放 virtual function 地址, 这个table的元素数量一般是virtual function的数目再加上一个或两个slots(用于支持RTTI)
    2. 在每一个object中安插一个 vptr 指向 virtual table
    3. 在 constructor 中安插代码用于设置 vptr
    4. 在 deconstructor 中安插代码用于抹除 vptr

SFINAE应用场景: 检测成员

· 阅读需 3 分钟

在C++编程中,Substitution Failure Is Not An Error(SFINAE)是一种强大的技术,允许我们在编译时根据类型特征选择不同的实现。这个特性在许多现代C++库和框架中被广泛使用,其中之一是在处理不同结构的通用接口时。

让我们看一个简单而实用的例子,通过检查类型是否具有特定成员来优雅地处理不同结构的通用接口。在这个例子中,我们将使用SFINAE来检查类型是否包含名为 'header' 的成员,并相应地执行不同的处理。

#include <iostream>
#include <type_traits>

// 首先,让我们定义一个模板结构 HasHeader,该结构通过SFINAE技术检查是否存在 'header' 成员。
template <typename T, typename = void>
struct HasHeader : std::false_type {};

template <typename T>
struct HasHeader<T, std::void_t<decltype(std::declval<T>().header)>> : std::true_type {};

// 接下来,我们定义一个使用SFINAE的函数模板 process_header,该模板在类型具有 'header' 成员时执行特定处理。
template <typename T>
std::enable_if_t<HasHeader<T>::value> process_header(const T &data) {
std::cout << "处理 header: " << data.header << std::endl;
}

template <typename T>
std::enable_if_t<!HasHeader<T>::value> process_header(const T &data) {
std::cout << "没有要处理的 header。" << std::endl;
}

// 最后,我们定义两个示例类,一个具有 'header' 成员,另一个没有。
struct ExampleWithHeader {
int header = 42;
};

struct ExampleWithoutHeader {};

// 在 main 函数中,我们使用 process_header 函数处理这两个示例类的实例。
int main() {
ExampleWithHeader obj1;
ExampleWithoutHeader obj2;

process_header(obj1); // 输出: 处理 header: 42
process_header(obj2); // 输出: 没有要处理的 header。

return 0;
}

在实际项目中,这种技术可以用于实现泛型算法、库组件和其他需要在编译时进行类型分派的场景

C++ Template 小课堂

decltypestd::declvalstd::enable_if_t 简要解释:

  1. decltype:
    • 作用: decltype 用于获取表达式的类型,而不执行该表达式。
    • 用法: decltype(expr) 返回表达式 expr 的类型。通常在模板编程中用于推导类型。
  2. std::declval:
    • 作用: std::declval 用于生成对于任何类型 T 的右值引用(rvalue reference)。通常与 decltype 一起使用,以在不创建对象的情况下获取类型信息。
    • 用法: std::declval<T>() 返回类型 T 的右值引用。
  3. std::enable_if_t:
    • 作用: std::enable_if_t 用于在编译时根据条件启用或禁用模板特化。
    • 用法: std::enable_if_t<Condition, Type> 如果 Condition 为真,则定义 Type;否则,不定义。

C++: std::decay_t

· 阅读需 1 分钟

std::decay_t是一个类型转换工具,它接受一个类型,并将其转换为对应的"衰减类型"。所谓"衰减类型"指的是将某个类型以如下方式处理得到的类型:

  • 对于数组或函数类型,将其转换为指针类型。
  • 对于const/volatile限定符和引用类型,去除这些修饰符,得到被修饰类型本身。
  • 对于非上述类型,保持其原样。

以下是一个使用std::decay_t的例子:

#include <type_traits>

int main() {
int arr[3] = {1, 2, 3};
using type1 = std::decay_t<decltype(arr)>; // type1会被推导为int*

const volatile double& ref = 42.0;
using type2 = std::decay_t<decltype(ref)>; // type2会被推导为double

struct S {};
using type3 = std::decay_t<S>; // type3会被推导为S
}

C++: 进程间同步之共享内存

· 阅读需 3 分钟

为了实现多进程间的数据同步,我们可以使用进程间通信(IPC)机制。下面是一个使用共享内存实现多进程间数据同步的示例代码:

#include <iostream>
#include <cstdlib>
#include <cstring>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <fcntl.h>

using namespace std;

// 共享内存的结构体
struct SharedMemory {
int request_count; // 请求数量
};

int main() {
const char* kSharedMemoryName = "/my_shared_memory"; // 共享内存名称
const int kSharedMemorySize = sizeof(SharedMemory); // 共享内存大小

// 创建或打开共享内存
int shm_fd = shm_open(kSharedMemoryName, O_CREAT | O_RDWR, 0666);
if (shm_fd == -1) {
perror("shm_open");
exit(1);
}

// 调整共享内存大小
if (ftruncate(shm_fd, kSharedMemorySize) == -1) {
perror("ftruncate");
exit(1);
}

// 映射共享内存到当前进程的地址空间
void* shared_memory = mmap(NULL, kSharedMemorySize, PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, 0);
if (shared_memory == MAP_FAILED) {
perror("mmap");
exit(1);
}

// 关闭文件描述符,不再需要
close(shm_fd);

// 初始化共享内存的内容
SharedMemory* p_shared_memory = reinterpret_cast<SharedMemory*>(shared_memory);
p_shared_memory->request_count = 0;

// 模拟多进程并发请求的情况
const int kNumProcesses = 4;
for (int i = 0; i < kNumProcesses; i++) {
pid_t pid = fork();
if (pid == -1) {
perror("fork");
exit(1);
} else if (pid == 0) {
// 子进程逻辑
for (int j = 0; j < 10000; j++) {
// 模拟一次请求
p_shared_memory->request_count++;
}
exit(0);
}
}

// 父进程等待所有子进程结束
for (int i = 0; i < kNumProcesses; i++) {
wait(NULL);
}

// 输出最终请求数量
cout << "Total request count: " << p_shared_memory->request_count << endl;

// 解除共享内存映射
if (munmap(shared_memory, kSharedMemorySize) == -1) {
perror("munmap");
exit(1);
}

// 删除共享内存
if (shm_unlink(kSharedMemoryName) == -1) {
perror("shm_unlink");
exit(1);
}

return 0;
}

在这个示例代码中,我们使用了 shm_open、ftruncate、mmap、munmap 和 shm_unlink 函数来操作共享内存。其中,shm_open 函数用于创建或打开共享内存,ftruncate 函数用于调整共享内存的大小,mmap 函数用于映射共享内存到当前进程的地址空间,munmap 函数用于解除共享内存映射,shm_unlink 函数用于删除共享内存。

在初始化共享内存的内容后,我们创建了多个子进程,并在每个子进程中模拟了一定数量的请求,这些请求会增加共享内存中的请求数量。最后,父进程等待所有子进程结束后,输出最终的请求数量,并删除共享内存。

需要注意的是,使用共享内存进行进程间通信需要确保不同进程对共享内存中的数据进行访问时不会产生竞态条件。在本示例代码中,我们使用了共享内存的结构体成员来保存请求数量,这样可以避免多个进程同时修改同一个变量的问题。同时,由于共享内存是一块共享的内存区域,因此不同进程可以通过相同的指针来访问共享内存中的数据。

需要注意的是,共享内存虽然是一种高效的进程间通信方式,但是由于多个进程共享同一块内存,因此容易产生数据一致性问题,需要采取相应的同步措施来避免这些问题的出现。在本示例代码中,我们没有使用任何同步措施,因为对于只有一个变量的情况下,并发修改不会导致数据不一致的问题。但是,在实际情况下,如果需要对多个变量进行并发修改,就需要使用锁或者其他同步机制来保证数据的一致性。

C++: 右值引用和移动构造

· 阅读需 4 分钟

C++ 11 引入了移动语义和右值引用,使得代码的性能得到了大幅提升。其中,移动构造函数是一种特殊的构造函数,它可以接受一个右值引用参数,用来实现将一个对象的资源转移到另一个对象而不需要复制数据的操作。本文将详细介绍移动构造函数和右值引用。

右值引用

在 C++ 11 中,引入了新的类型:右值引用。右值是指一个临时对象,它只能存在于表达式的右边,并且其生命周期只能存在于这个表达式内部。右值引用则是指对右值进行引用的方式,其使用的语法是在类型名后面加上 &&,例如:

int&& a = 5;

在这个例子中,a 是一个右值引用,它引用了一个临时的 int 类型对象。由于这个对象是一个右值,所以它的生命周期只存在于这个语句中。右值引用的主要作用是实现移动语义。

移动构造函数

移动构造函数是一个特殊的构造函数,它接受一个右值引用参数,用来实现将一个对象的资源转移到另一个对象而不需要复制数据的操作。移动构造函数通常用于容器类、智能指针等需要管理资源的类中,以提高程序的性能。

移动构造函数的语法如下:

class MyClass {
public:
MyClass(MyClass&& other) noexcept {
// 资源转移代码
}
};

在这个例子中,MyClass 的移动构造函数接受一个右值引用参数 other,用来接收另一个 MyClass 对象的资源。在移动构造函数中,可以将 other 对象的资源移动到当前对象中,而不需要进行深拷贝或浅拷贝。由于移动构造函数的特殊性质,其通常会使用 noexcept 关键字来标记其不会抛出异常,从而使得代码更加高效。

使用移动构造函数

使用移动构造函数通常需要遵循一些规则:

  • 对象必须是右值:只有右值可以通过移动构造函数来进行资源的转移。如果需要移动一个左值,可以使用 std::move 函数来将其转换为右值。
  • 转移后原对象的状态是未定义的:移动构造函数会将原对象的资源转移到新对象中,原对象的状态是未定义的。因此,在使用移动构造函数后,原对象不能再被使用,否则可能会引发未定义行为。

下面是一个使用移动构造函数的例子:

class MyClass {
public:
MyClass() : data_(new int[100]) {}
MyClass(MyClass&& other) noexcept : data_(other.data_) {
other.data_ = nullptr;
}

private:
int* data_;
};
MyClass func() {
MyClass a;
// ...
return std::move(a);
}

int main() {
MyClass b = func();
return 0;
}

在这个例子中,func 函数返回一个 MyClass 对象,由于返回值是一个临时对象,因此可以通过移动构造函数来转移资源。在 main 函数中,通过使用 std::move 函数将返回值转换为右值,从而使得可以使用 MyClass 的移动构造函数来进行资源的转移。这样,就可以避免不必要的数据复制,从而提高程序的性能。

总结

移动构造函数可以用来实现将一个对象的资源转移到另一个对象而不需要复制数据的操作,从而提高程序的性能。使用移动构造函数需要遵循一些规则,例如对象必须是右值,转移后原对象的状态是未定义的等。通过掌握移动构造函数和右值引用,可以编写更加高效的 C++ 代码。

C++: std::condition_variable

· 阅读需 3 分钟

不要无条件的等待。否则可能会错过唤醒,或者唤醒了发现无事可做。

condition_variable.wait() 有两个重载函数

  • void wait (unique_lock& lck)。 无条件的等待
  • void wait (unique_lock& lck, Predicate pred)。 有条件的等待. 大致实现如下
    template<typename _Predicate>
    void wait(unique_lock<mutex>& __lock, _Predicate __p)
    {
    while (!__p())
    wait(__lock);
    }

下面来看个简单的例子

#include <condition_variable>
#include <iostream>
#include <thread>

std::mutex mutex_;
std::condition_variable condVar;

bool dataReady{false};

void waitingForWork(){
std::cout << "Waiting " << std::endl;
std::unique_lock<std::mutex> lck(mutex_);
condVar.wait(lck, []{ return dataReady; }); // (4)
std::cout << "Running " << std::endl;
}

void setDataReady(){
{
std::lock_guard<std::mutex> lck(mutex_);
dataReady = true;
}
std::cout << "Data prepared" << std::endl;
condVar.notify_one(); // (3)
}

int main(){

std::cout << std::endl;

std::thread t1(waitingForWork); // (1)
std::thread t2(setDataReady); // (2)

t1.join();
t2.join();

std::cout << std::endl;

}

为什么一个如此简单的程序会看起来都会这么麻烦呢。如果不使用 dataReady 会怎样呢?

先看看正常情况下 上面 (4)语句

condVar.wait(lck, []{ return dataReady; });

如果 []{ return dataReady; } 为真, 那么程序继续运行。 如果 []{ return dataReady; } 为假, condVar 会释放锁,然后阻塞自己,等待其他线程的 notify 信号。

如果接下来,condVar收到了其他线程的 notify 信号被唤醒。(也有可能被虚假唤醒)线程会被唤醒,并获取锁,并检查 []{ return dataReady; } 的结果。跟之前一样,根据结果来决定接下来的行为。

回到最开始的问题,如果不使用 dataReady 会怎样的呢, 即无条件的等待。

void waitingForWork(){
std::cout << "Waiting " << std::endl;
std::unique_lock<std::mutex> lck(mutex_);
condVar.wait(lck); // (1)
std::cout << "Running " << std::endl;
}

void setDataReady(){
std::cout << "Data prepared" << std::endl;
condVar.notify_one();
}

可能会发生如下现象。

  1. waitingForWork 在收到信号之前,setDataReady 线程中已经发出了 notify 信号。那么 waitingForWork 会永远出于阻塞状态,看起来像死锁一样。

用不加锁的变量控制也一样

std::atomic<bool> dataReady{false};

void waitingForWork(){
std::cout << "Waiting " << std::endl;
std::unique_lock<std::mutex> lck(mutex_);
condVar.wait(lck, []{ return dataReady.load(); }); // (1)
std::cout << "Running " << std::endl;
}

void setDataReady(){
dataReady = true;
std::cout << "Data prepared" << std::endl;
condVar.notify_one();
}

如果执行到 condVar.wait(lck, []{ return dataReady.load(); }); 的时候。 信号的通知的发生在执行 []{ return dataReady.load() 和 获取到锁之间。 那么可以想象的到。即执行线程顺序如下

  • setDataReady 线程 dataReady = true;
  • waitingForWork 线程 []{ return dataReady.load(); }
  • setDataReady 线程 condVar.notify_one();
  • waitingForWork condVar.wait(lck,

很明显,信号通知也会被错过,程序会永远阻塞。原因在于 dataReady 的修改并没有正确的同步到,如果dataReady 在加锁的情况下修改,就不会发生这种现象。

其他

unique_lock/lock_guard

lock_guard 是一个 scope 锁。创建时获取锁,离开作用域是自动解锁。由于只有在析构的时候才会解锁,如果这个定义域比较大的话,那么锁的粒度就比较大,可能会影响程序效率。所以尽可能的是定义域小。

unique_lock 与lock_guard一样,但它额外提供了一个 unlock() 来主动解锁。

notify_one/notify_all

notify_one 只唤醒等待队列中的第一个线程。其余线程只能等待下次 notify_xxx

notify_all 所有线程都会被唤醒。所有线程争用锁,并执行接下里的任务,然后释放锁。

参考链接

https://www.modernescpp.com/index.php/c-core-guidelines-be-aware-of-the-traps-of-condition-variables

C++: std::bind

· 阅读需 3 分钟

在C++11中,引入了一个新的库 functional ,其中包括了许多有用的函数对象和函数适配器。其中之一是 std::bind。

std::bind 它可以绑定一个函数或成员函数的参数,并返回一个可调用对象。这意味着可以将参数延迟到稍后再使用。

基本用法

下面是一个简单的例子,展示了如何使用 std::bind 实现参数绑定:

#include <iostream>
#include <functional>

void sum(int a, int b)
{
std::cout << "sum: " << a + b << std::endl;
}

int main()
{
auto add_five = std::bind(sum, std::placeholders::_1, 5); // 绑定第二个参数为5
add_five(10); // 输出:sum: 15
return 0;
}

在这个例子中,我们创建了一个名叫 add_five 的可调用对象,它实际上是 sum 函数的参数绑定版本。我们使用 std::bind 将第一个参数绑定为 _1(占位符),第二个参数绑定为 5,以此类推。最终,我们可以调用 add_five 并将一个整型值作为第一个参数传入,生成最终结果。

可绑定对象

除了普通函数外,std::bind 还可以接收能够被调用的任何对象,例如函数对象和成员函数。

#include <iostream>
#include <functional>

class A
{
public:
void print(int n, char c)
{
std::cout << "print called: " << n << ", " << c << std::endl;
}
};

int main()
{
auto a = A();
auto func = std::bind(&A::print, &a, 10, std::placeholders::_1); // 绑定第二个参数为1
func('A'); // 输出:print called: 10, A
return 0;
}

在这个例子中,我们创建了一个名为 a 的 A 类实例,并将其地址传递给 std::bind。我们还绑定了 print 成员函数的第一个参数为 10,第二个参数使用 _1 占位符。最后我们可以调用 func 函数,并为其传递一个 char 类型的参数,生成最终结果。

组合函数

还可以使用 std::bind 来组合多个函数。

#include <iostream>
#include <functional>

int sum(int a, int b)
{
return a + b;
}

int mul(int a, int b)
{
return a * b;
}

int main()
{
auto func = std::bind(sum, std::bind(mul, std::placeholders::_1, 5), 10);
std::cout << "Result: " << func(2) << std::endl; // 输出:30
return 0;
}

在这个例子中,我们首先将第一个参数与 5 相乘,然后将结果与 10 相加。我们使用 std::bind 将 mul 函数的第二个参数绑定为 5。最终,我们调用 func(2) 并得到 30 作为输出。

使用 std::placeholders 占位符

当使用 std::bind 时,需要使用 _1, _2, _3, ... _N 等占位符来表示绑定的函数的参数。

auto add_five = std::bind(sum, std::placeholders::_1, 5);

在这个例子中,_1 表示将要绑定的函数的第一个参数。

另外,如果想对多个参数进行绑定,则需要使用不同的占位符:

在这个例子中,_1 表示原函数的第一个参数,_2 表示原函数的第二个参数。

C++: std::function

· 阅读需 2 分钟

std::function 是一个函数包装器,它可以包装任何可调用函数实体。

  • 函数
  • 函数指针
  • 成员函数
  • 静态函数
  • lambda 表达式
  • 函数对象

函数对象赋值方法

#include <functional>
#include <iostream>
#include <type_traits>

int func(int a){
return a;
}

class FuncClass{
public:
int operator()(int a){
return a;
}
int member_func(int a){
return a;
}
static int static_memory_func(int a){
return a;
}
};

int main(){
std::function<int(int)> f;
if (!f){ // 函数对象重载了 bool 运算符
std::cout << "函数对象还没赋值" << std::endl;
}

// 函数
f = func;
std::cout << f(10) << std::endl;

// 函数指针
int (*func_ptr)(int) = func;
f = func_ptr;
std::cout << f(11) << std::endl;

// 可调用对象
FuncClass func_class_obj;
f = func_class_obj;
std::cout << f(12) << std::endl;

// 类静态成员函数
f = FuncClass::static_memory_func;
std::cout << f(13) << std::endl;

// 成员函数
f = std::bind(&FuncClass::member_func, &func_class_obj, std::placeholders::_1);
std::cout << f(14) << std::endl;

// lamda
f = [](int a){
return a;
};
std::cout << f(15) << std::endl;

// reset
f = nullptr;
std::cout << "无效函数对象,会抛异常" << std::endl;
std::cout << f(16) << std::endl;

return 0;
}

输出如下

函数对象还没赋值
10
11
12
13
14
15
无效函数对象,会抛异常
terminate called after throwing an instance of 'std::bad_function_call'
what(): bad_function_call

C++对象模型(1): 关于对象

· 阅读需 3 分钟

在 C 语言当中,“数据” 和 “处理数据的函数” 是分开声明的,语言本身并没有支持 “数据和函数” 之间的关联性。C++ 则有抽象数据类型,其中不仅有“数据”, 还有处理数据相关的操作。

将 C 中的 strcut+函数 转换成 C++的抽象数据类型,会增加多少成本呢? 经常有人说,C++ 会比 C 慢, 慢在哪里呢?

答案是并没没有增加成本,C++ 也不会比 C 慢。 通常 C++ 比 C 慢主要是 virtual 引起的

  • virtual function 机制,用于支持执行期绑定
  • virtual base class, 用于实现“多次出现在继承体系中的 base class,有一个单一而被共享的实例”

C++ 对象模型

C++对象模型包含哪些东西

  • 成员变量,静态成员变量
  • 函数,静态函数,虚函数

成员变量被配置与每一个对象中,静态变量,函数以及静态函数则是在对象之外。虚函数则稍微不同

  • 每一个 class 产生一堆指向虚函数的指针,放在表格这种,这个表格被称为虚函数表
  • 每一个 class object 被安插一个指针,指向相关的虚函数表。通常这个指针称为虚函数表指针。虚函数表指针的初始化,重置都由class 的构建函数,析构函数,copy运算符自动完成。
  • 每一个 class 所关联的 type_info object (用以只会RTTI) 也经由虚函数表被指出来,通常被放在表格的第一个 slot

C++中 struct / class

C 语言中,struct 的内存布局就如我们看到的一样,按照声明从上往下排列。你可能会在 C 语言中看到一些 “巧妙” 的设计(或许是陋习?)

struct mumble{
//...
char pc[1];
}

struct mumble *pmumb1 = (struct mumble*)malloc(sizeof(struct mumble) + strlen(string) + 1);
strcpy(&muble.pc, string);

但是在 C++中或许不行,C++中凡出于同一个 access section的数据,必定保证其声明顺序出现在内存布局当中。然而多个 access section的数据,其排列顺序就不一定了。前面的 C 技巧,或许能够有效运行,或许不能。

如果一个程序员迫切的需要一个相当复杂的 C++ class 的某部分数据,使它像 C 声明的那种模样,那么最好将该部分抽取出来成为一个单独的 struct 声明。然后使用组合的方式

struct C_point {...};
class Point{
public:
operator C_point(){return _c_point;}
private:
C_point _c_point;
};

C struct 在 C++ 中的一个合理用途,就是你需要传递一个 复杂的 class 数据到某个 C 函数中,struct 声明可以将数据封装起来,并保证拥有与 C 兼容的空间布局。

C++对象模型(6): 运行时

· 阅读需 4 分钟

想象一下下面这句话执行的时候会发生什么

if (yy == xx.getValue()){
//...
}

xx 和 yy 定义如下

class Y{
public:
Y();
~Y();
bool operator==(const Y&) const;
};
class X{
public:
X();
~X();
operator Y() const;
X getValue();
};

yy == 明显需要调用一个等号运算符yy.operator==()。 但是 xx.getValue() 返回的是一个 X 对象,所以需要调用 xx.openrator Y() 运算符将其转换为 Y 对象。上面的展开了其实就是

if (yy.operator==(xx.getValue().operator Y())){
//...
}

以上的发生的一切,都是编译器根据 class 的隐含语义生成的。当然我们也可以明确的写出这样的例子,但是不建议,它只会时编译速度稍微快一些。并没有其他好处。

runtime_semantincs_1.png

实际程序运行时会比这复杂

  1. 产生临时 x 对象,放置 getValue() 返回值
  2. 产生临时 y 对象,放置 operator Y() 返回值
  3. 产生临时 int 对象,放置 yy.operator==() 返回值

所以最开始的一行代码,会被转换为如下形式

X temp1 == xx.getValue();
Y temp2 == temp1.operator Y();
int temp3 = yy.operator==(temp2);

if (temp3){
//...
}
temp2.Y::~Y();
temp1.X::~X();

C++就是这样,不太容易从源码表达式上看出它的复杂度。

对象的构造和析构

一般来说,对应的构造和析构安插如我们所想的那样

{
Point point;
// point.Point::Point();
...
// point.Point::~Point();
}

如果区段中有多个离开点(多处 return),情况会复杂一些,析构必须被放置在每一个离开点之前。
同时我们声明变量的时候,最好在使用他的时候才开始定义,否则可能会造成不必要的构造,析构。

if (cache){
return 1;
}

Point point;

如果我们在检查 cache 之前就声明 point。那么就可能会造成 Point 对象不必要的构造与析构开销

全局对象

假如我们有以下代码

Matrix identity;

int main(){
Matrix m1 = identity;

return 0;
}

C++ 保证,一定会在第一次用到全局变量之前把它构造出来,并且在 main 函数结束之前,把它销毁掉。

通常不建议直接使用全局变量,建议将全局变量包装成一个函数。否则容易造成初始化灾难,比如两个全局变量,分别在不同的源文件中,并且其中一个变量用到了另一个变量,就可能会造成使用前未被初始化的问题。

Matrix& x(){
static Matrix* identity = new Matrix();
return *identity;
}

局部静态对象

假设有以下片段

const Matirx& identity(){
static Matrix mat_identity;
return mat_identity;
}

局部静态对象保证,mat_identity的构造和析构只调用一次,即使 identity() 可能被调用多次。

如果你是一个编译器开发者,你会怎么保证该特性

现在C++标准中要求,编译单位中的局部静态对象必须被摧毁--以构造相反的顺序摧毁,。但是由于这些 object 是在需要时才被构造,所以编译时期无法预知其集合以及顺序。为了支持这个规则,可能需要对被产生出来的局部对象保持一个执行器链表。

对象数组

假设有一下数组定义

Point knots[10];

编译会有对应的函数来生成该数组,通常函数形式可能如下

void *vec_new(
void *array,
size_t elem_size,
int elem_count,
void (*constructor)(void *),
void (*destructor)(void *, char)
)

对于支持异常处理的编译器,传入一个destructor 是有必要的

编译器实际运行的时候就可能对我们声明的数组做 vec_new 操作

Point knots[10];
vec_new(&knots, sizeof(Point), 10, &Point::Point, 0)

显然释放也一样

void *vec_delete(
void *array,
size_t elem_size,
int elem_count,
void (*destructor)(void *, char)
)

有些编译器会另外增加一些参数,便于有条件的导引 vec_delete 的逻辑

new 和 delete 运算符

new 运算符总是以 C 的 malloc() 完成,虽然没有规定一定这么做。 相同情况,delte 总是以 free() 完成

临时对象

临时对象的被摧毁,应该是对完整表达式求值过程中的最后一个步骤

((objA > 1024) && (objB > 1024)) ? objA + objB : foo(objA, objB)

以上包含 5 个表达式,任何一个表达式所产生的任何一个临时对象,都应该在完整表达式被求值完成后,才可以被毁去。

初探模板元编程

· 阅读需 3 分钟

模板特化

先讲讲模板特化,因为元编程中用到了大量的模板特化。什么是模板特化,比如

// 主函数
template <typename T> void process(T data);

// 针对int的特化
template <> void process<int>(int data);
// 针对float的特化
template <> void process<float>(float data);

C++的模板元编程中就用到了大量的模板特化, 你如 remove_const 用于去除类型的 const 修饰符

  template<typename _Tp>
struct remove_const
{ typedef _Tp type; };

template<typename _Tp>
struct remove_const<_Tp const>
{ typedef _Tp type; };

实际使用的时候

std::remove_const<int>::type a1; // 匹配第一个模板,a1 类型是 int 
std::remove_const<int const>::type a2; // 匹配第二个模板, a2 的类型也是 int

std::integral_constant

大部分模板都继承了 std::integral_constant, 它是一个具有指定值类型,指定值的编译期常量。

std::integral_constant<int, 42>::value // 等同于 42
std::integral_constant<int, 42>::type // 等同于 int

相关定义如下

  /// integral_constant
template<typename _Tp, _Tp __v>
struct integral_constant
{
static constexpr _Tp value = __v;
typedef _Tp value_type;
typedef integral_constant<_Tp, __v> type;
constexpr operator value_type() const noexcept { return value; }
};

typedef integral_constant<bool, true> true_type;

/// The type used as a compile-time boolean with false value.
typedef integral_constant<bool, false> false_type;

注意,它特化了两个类型, true_type 和 false_type, 大量的类型判断模板都继承了这两个类型

is_xxx

is_xxx 就是大量的模板特化, 拿 is_integral 举例

  template<typename>
struct is_integral
: public false_type { };

template<>
struct is_integral<bool>
: public true_type { };

template<>
struct is_integral<char>
: public true_type { };

template<>
struct is_integral<int>
: public true_type { };

// short, long, longlong, unsigned char, unsigned short 等等等

大部分 is_xxx 的模板都默认继承了 false_type, 然后特化 xxx 类型继承自 true_type

std::is_integral<int>::value // true
std::is_integral<float>::value // false

其他的 is_xxx 大同小异

  • is_array 特化 T[Size] 或者 T[]
  • is_pointer 特化 T*
  • is_lvalue_reference 特化 T&
  • is_rvalue_reference 特化 T&&
  • ......

但有一部分比如 is_enum, is_union, is_class 无法通过其接口形式来判断,只能通过编译器内置的函数来判断。

还有很多 is_xxx 是复合类型, 就是上面的几个基础类型组合

  • is_arithmetic 是否是算数类型 (is_float_point, is_integral)
  • is_fundamental 是否是基础类型 (is_arithmetic, is_void, is_nullptr)
  • is_reference 是否是左值或者右值引用
  • ......

其他

不仅可以判断类型,模板元编程还提供了一些其他的模板类,比如

  1. 判断属性类型
    • is_const
    • is_volatile
    • ...
  2. 查询支持的操作
    • 是否有默认构造函数
    • 是否有虚析构函数
    • ...
  3. 查询类型关系
    • is_same
    • is_base_of 是否是另一个类的基类
    • is_convertible 是否能转换到另一个类型
    • ...
  4. 变化类型
    • 去除增加 const/volatile
    • 添加或移除引用
    • 添加或移除 singed
    • 添加或移除指针
  5. 其他
    • enable_if 条件性的移除函数重载或者模板特化

对模板元编程有了大致的了解之后,你就可以对一些模板库初窥门径了,关于模板编程的最好学习资料就是开源库。

CPU 缓存一致性

· 阅读需 5 分钟

有两个独立的线程,一个线程读写 var1, 一个线程读写 var2。这两个线程的读写会相互影响吗?

struct SharedData {
char var1;
// double magic;
char var2;
};

下面我们来做个实验

实验

// test.cpp
#include <iostream>
#include <thread>

struct SharedData {
char var1;
// double magic;
char var2;
};

SharedData data;

void Thread1() {
for (int i = 0; i < 100000000; ++i) {
data.var1++;
}
}

void Thread2() {
for (int i = 0; i < 100000000; ++i) {
data.var2++;
}
}

int main() {
std::thread t1(Thread1);
std::thread t2(Thread2);

t1.join();
t2.join();

return 0;
}

实验一

将上面的代码保存为 test.cpp, 然后用 g++ test.cpp -lpthread 编译, 运行 10 次统计平均运行时间

$ for i in {1..10}; do (time ./a.out); done;
./a.out 1.93s user 0.01s system 191% cpu 1.015 total
./a.out 1.42s user 0.06s system 180% cpu 0.819 total
./a.out 0.85s user 0.16s system 142% cpu 0.706 total
./a.out 1.12s user 0.06s system 186% cpu 0.633 total
./a.out 1.56s user 0.00s system 182% cpu 0.861 total
./a.out 1.22s user 0.01s system 143% cpu 0.857 total
./a.out 1.67s user 0.02s system 185% cpu 0.911 total
./a.out 1.68s user 0.01s system 182% cpu 0.924 total
./a.out 1.63s user 0.02s system 175% cpu 0.941 total
./a.out 1.68s user 0.02s system 184% cpu 0.919 total

total 平均时间为 0.8366 秒

实验二

然后将 SharedData 里面的 // double magic; 注释打开

struct SharedData {
char var1;
double magic;
char var2;
};

重新编译运行

$ for i in {1..10}; do (time ./a.out); done;
./a.out 0.71s user 0.00s system 191% cpu 0.369 total
./a.out 0.67s user 0.01s system 175% cpu 0.390 total
./a.out 0.65s user 0.00s system 154% cpu 0.420 total
./a.out 0.68s user 0.00s system 175% cpu 0.389 total
./a.out 0.69s user 0.00s system 184% cpu 0.374 total
./a.out 0.70s user 0.00s system 183% cpu 0.381 total
./a.out 0.67s user 0.02s system 180% cpu 0.385 total
./a.out 0.65s user 0.04s system 161% cpu 0.425 total
./a.out 0.63s user 0.00s system 116% cpu 0.540 total
./a.out 0.70s user 0.00s system 182% cpu 0.386 total

total 平均时间为 0.4269 秒

结果

实验一耗时几乎是实验二的两倍,为什么?这里就涉及到 CPU 缓存行的概念

缓存行

缓存行是计算机体系结构中的基本缓存单元,通常是一组相邻的内存位置。当一个线程修改了共享的内存位置时,它会将整个缓存行加载到CPU缓存中。

在上述实验中, SharedData 结构中的两个 char 变量 var1 和 var2 可能处于相同的缓存行,因为它们是相邻的。当一个线程修改 var1 时,整个缓存行被加载到该线程的 CPU 缓存中。如果另一个线程正在修改var2,它会导致缓存行无效(缓存失效),从而迫使其它的线程重主存重新加载最新的数据。

// double magic; 被注释打开时,结构的大小变大,可能使 var1 和 var2 不再在同一个缓存行上。这样,两个线程可以独立地修改各自的变量,减少了缓存失效的可能性。

缓存一致性

CPU缓存一致性是指多个处理器或核心之间共享数据时,确保它们看到的数据是一致的。在多核处理器系统中,每个核心都有自己的缓存,当一个核心修改了共享数据时,其他核心可能仍然持有旧的缓存值。为了保证数据的一致性,需要采取一些机制来同步各个核心之间的缓存。

MESI协议是一种常见的缓存一致性协议,它定义了四种状态,分别是:

  1. (M)Modified:缓存行被修改,并且是唯一的拥有者,与主内存不一致。如果其他缓存需要该数据,必须先写回主内存。
  2. (E)Exclusive:缓存行是唯一的拥有者,与主内存一致,且未被修改。其他缓存可以直接读取这个缓存行,而不需要从主内存读取。
  3. (S)Shared:缓存行是共享的,与主内存一致,且未被修改。多个缓存可以同时拥有相同的缓存行。
  4. (I)Invalid:缓存行无效,不能被使用。可能是因为其他缓存修改了这个行,导致当前缓存的数据不再有效。

状态的变化可以通过以下例子来说明:

假设有两个核心,A 和 B,它们共享某个数据的缓存行:

  1. 初始状态:A 和 B 的缓存都标记为 Invalid(I),因为还没有任何核心读取或修改这个数据。
  2. 核心A读取数据:A 将缓存行标记为 Exclusive(E),表示A是唯一的拥有者,并且数据与主内存一致。
  3. 核心B读取数据:由于 A 是唯一的拥有者,B 可以直接从 A 的缓存行中读取数据,此时B 的缓存也标记为 Shared(S)。
  4. 核心A修改数据:A 将缓存行标记为 Modified(M),表示数据已被修改且A是唯一的拥有者。同时,A 会通知其他缓存失效,因为此时数据在 A 的缓存中已不一致。
  5. 核心B尝试读取数据:由于 A 将数据标记为 Modified,B 的缓存行变为 Invalid(I),B 需要从主内存重新读取最新的数据。

PyTorch 与 C++ Torch 混合编程问题排查

· 阅读需 2 分钟

问题

在公司的一个项目中,需要同时使用 C++ 和 Python 的一些接口。项目的主要语言是 Python,通过 pybind11 调用 C++ 的接口。然而,在实际运行过程中出现了 torch 符号未定义的问题,尽管 Python 和 C++ 使用的 torch 都是相同版本(PyTorch 调用的也是 C++ 的 so),理论上不应该出现这样的问题。

排查

尽管两个 torch 版本相同,但它们的安装方式不同。PyTorch 是通过 pip 安装的,而 C++ Libtorch 是通过源码编译的方式安装的。虽然两者版本相同,但通过使用 nm 查看符号表发现两个 torch.so 的符号表确实不一样。最终排查发现,由于 Dual ABI 的原因,libtorch 在编译时采用的是 cxx11 ABI,而 Python 中的 torch 使用的是 Pre-cxx11 ABI,两者版本的符号不兼容,导致冲突问题。

解决方案

  1. 重新编译 C++ 项目相关的代码和库,全部使用旧 ABI。然而,使用旧 ABI 编译的可行性尚待调研。考虑到目前几乎所有的代码都是以 cxx11 编译,包括很多系统库也选择了 cxx11 的 ABI,因此全部重新编译并不现实。该方案被否决。
  2. 重新编译 Python 中的 torch,使用新的 ABI。

C++ 符号修饰

· 阅读需 3 分钟

什么是符号修饰?

在 C++ 中,符号修饰是指编译器对函数和变量名进行的一种变换或修饰。这种修饰是为了解决 C++ 的函数重载、命名空间和类等特性引入的命名冲突问题。通过符号修饰,编译器可以在目标文件中唯一标识不同的实体,确保在链接阶段能够正确地找到并匹配相应的函数或变量。

符号修饰的原理

C++ 编译器通过一种名为“名字翻译”(Name Mangling)的技术来实现符号修饰。名字翻译将源代码中的函数和变量名转换成目标文件中的唯一标识符。这种转换过程包括以下几个方面:

1. 函数重载

C++ 允许函数重载,即在同一作用域内定义多个同名函数,但它们的参数类型或个数不同。为了在目标文件中区分这些重载函数,编译器会根据函数的参数类型和个数生成不同的符号。

2. 命名空间

命名空间是 C++ 中组织代码的一种方式,但它可能导致相同名字的函数或变量在目标文件中发生冲突。符号修饰通过在符号前添加命名空间信息,解决了这一问题。

3. 类成员函数

类的成员函数通常需要包含类的信息,以便正确访问对象的成员。符号修饰通过在符号中嵌入类的信息,确保了正确的成员函数匹配。

示例:符号修饰的实际应用

考虑以下 C++ 代码片段:

#include <iostream>

namespace Math {
class Calculator {
public:
int add(int a, int b);
};
}

int Math::Calculator::add(int a, int b) {
return a + b;
}

int main() {
Math::Calculator calc;
std::cout << calc.add(3, 4) << std::endl;
return 0;
}

通过编译并查看符号表,我们可以看到 Math::Calculator::add 函数的符号修饰:

$ nm a.out | grep add
000000000040157e T _ZN4Math9Calculator3addEii

在这个例子中,符号 _ZN4Math9Calculator3addEii 就是经过修饰的 Math::Calculator::add 函数名。

如何处理符号修饰

在正常情况下,大多数 C++ 程序员在日常编程中不需要过多关注符号修饰。编译器会自动处理符号修饰,而开发者通常只需使用函数和变量的原始名称即可。只有在涉及到库的开发、跨语言交互或者遇到符号冲突的特殊情况下,才需要更深入地了解符号修饰。