程序员的自我修养(7): 动态链接

静态库暴露有以下缺点

  1. 内存和磁盘空间占用大。如果一个lib.o被多个程序使用,那么每个程序内都会有 lib.o的一个副本,如果多个程序同时运行,在内存中还会也会有多个副本。比如 C 语言静态库,一个普通程序使用到 C 语言静态库至少 1M 以上,如果有 100 个这样的程序,那么就至少要 100M 空间。很多 linux 机器上,/bin/下甚至有上千个程序,那么就要浪费几个 G 的空间。
  2. 程序更新与发布。每次更新一个静态库,整个程序都要重新编译更新。

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

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

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

初探模板元编程

模板特化

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

1
2
3
4
5
6
7
// 主函数
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 修饰符

1
2
3
4
5
6
7
template<typename _Tp>
struct remove_const
{ typedef _Tp type; };

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

实际使用的时候

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

std::integral_constant

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

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

相关定义如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/// 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 举例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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

1
2
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 缓存一致性

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

1
2
3
4
5
struct SharedData {
char var1;
// double magic;
char var2;
};

下面我们来做个实验

实验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 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 次统计平均运行时间

1
2
3
4
5
6
7
8
9
10
11
$ 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; 注释打开

1
2
3
4
5
struct SharedData {
char var1;
double magic;
char var2;
};

重新编译运行

1
2
3
4
5
6
7
8
9
10
11
$ 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 需要从主内存重新读取最新的数据。