1. 单继承虚函数表

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
35
#include<iostream>
using namespace std;
class A {
private:
int a;
public:
virtual void f() {
cout<<"A::f()"<<endl;
}
virtual void g() {
cout<<"A::g()"<<endl;
}
};
class B:public A {
private:
int b;
public:
virtual void f() {
cout<<"B::f()"<<endl;
}
virtual void g1() {
cout<<"B::g1()"<<endl;
}
void h() {
cout<<"B::h()"<<endl;
}
};
int main()
{
typedef void(*fun)(void);
fun pFun;
A a;
B b;
return 0;
}

定义了两个对象,B继承自A。 B重写了A的f()函数,并新增了一个虚成员函数g1()和一个普通的成员函数h()。那么对象a,b的内存布局应该如下图所示:
1

口说无凭,我们用gdb打印一下看看。

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
35
36
37
38
39
40
41
42
43
44
$ gdb a.exe
GNU gdb (GDB) 7.6.1
Copyright (C) 2013 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law. Type "show copying"
and "show warranty" for details.
This GDB was configured as "mingw32".
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>...
Reading symbols from F:\zkangHUST\C++\a.exe...done.
(gdb) start
Temporary breakpoint 1 at 0x40146e: file test3.cpp, line 32.
Starting program: F:\zkangHUST\C++/a.exe
[New Thread 10860.0x2e0c]
[New Thread 10860.0x3e64]
[New Thread 10860.0x3e94]
[New Thread 10860.0x8]

Temporary breakpoint 1, main () at test3.cpp:32
32 A a;
(gdb) n
33 B b;
(gdb)
51 return 0;
(gdb) p a
$1 = {_vptr.A = 0x405178 <vtable for A+8>, a = 4194432}
(gdb) p (int*)*((int*)0x405178)
$2 = (int *) 0x403c08 <A::f()>
(gdb) p (int*)*((int*)0x405178 + 1)
$3 = (int *) 0x403c3c <A::g()>
(gdb) p (int*)*((int*)0x405178 + 2)
$4 = (int *) 0x0
(gdb) p b
$5 = {<A> = {_vptr.A = 0x405188 <vtable for B+8>, a = 4200896}, b = 0}
(gdb) p (int*)*((int*)0x405188)
$6 = (int *) 0x403ca0 <B::f()>
(gdb) p (int*)*((int*)0x405188+1)
$7 = (int *) 0x403c3c <A::g()>
(gdb) p (int*)*((int*)0x405188+2)
$8 = (int *) 0x403cd4 <B::g1()>
(gdb) p (int*)*((int*)0x405188+3)
$9 = (int *) 0x3a434347
(gdb)

a的虚函数表地址是0x405178,把这个地址强制转换成int指针,对改指针取值即是虚函数表第一个函数的地址,可以转换成int指针,打印出来。可以看到,虚函数表跟我们分析的是一样的。这里有一个问题,可以看到A的虚函数表是以空地址结束的,B的虚函数结束的位置是一个随机值,可见虚函数表并不一定是以空地址结束。另外,B类新增的h()函数没有加入到虚函数表中,因为它不是一个虚函数,这个函数怎么调用已经在程序编译的过程中确定了(即所谓静态联编,也叫早期联编)。
同理,如果有第三个类C像下面这样继承类B。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class C:public B {
private:
int c;
public:
virtual void f() {
cout<<"C::f()"<<endl;
}
virtual void g1() {
cout<<"C::g1()"<<endl;
}
virtual void k() {
cout<<"C::k()"<<endl;
}
};

那么C对象的内存应该如下图:
2
gdb打印结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
(gdb) p c
$1 = {<B> = {<A> = {_vptr.A = 0x4051c0 <vtable for C+8>, a = 1948871853}, b = 4200912}, c = 6422368}
(gdb) p (int*)*((int*)0x4051c0)
$2 = (int *) 0x403d58 <C::f()>
(gdb) p (int*)*((int*)0x4051c0 + 1)
$3 = (int *) 0x403c4c <A::g()>
(gdb) p (int*)*((int*)0x4051c0 + 2)
$4 = (int *) 0x403dc0 <C::g1()>
(gdb) p (int*)*((int*)0x4051c0 + 3)
$5 = (int *) 0x403d8c <C::k()>
(gdb) p (int*)*((int*)0x4051c0 + 4)
$6 = (int *) 0x3a434347

2. 多继承(无虚函数覆盖)

单继承的虚函数表比较简单,现在来看下多继承的虚函数表是什么样的。首先看多继承无虚函数覆盖的情况。假设有四个类A,B,C,D。继承关系如下图。
3

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
35
36
37
38
39
40
41
class A {
private:
int a;
public:
virtual void f() {
cout<<"A::f()"<<endl;
}
virtual void g() {
cout<<"A::g()"<<endl;
}
};
class B {
private:
int a;
public:
virtual void f() {
cout<<"B::f()"<<endl;
}
virtual void g() {
cout<<"B::g()"<<endl;
}
};
class C {
private:
int a;
public:
virtual void f() {
cout<<"C::f()"<<endl;
}
virtual void g() {
cout<<"C::g1()"<<endl;
}
};
class D:public A,public B, public C {
private:
int a;
public:
virtual void h() {
cout<<"D::h()"<<endl;
}
};

子类继承了多个父类,在内存中会维持多张虚函数表,有几个父类就有几张虚函数表。同时,自己新加的虚函数会附加到第一个父类的虚函数表后面。类D的内存布局如图:
4

1
2
3
4
5
6
7
8
9
10
11
int main()
{
D d;
A* a = (A*)&d;
B* b = (B*)&d;
C* c = (C*)&d;
a->f();
b->f();
c->f();
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
(gdb) p d
$1 = {<A> = {_vptr.A = 0x4051f0 <vtable for D+8>, a = 6422368}, <B> = {_vptr.B = 0x405204 <vtable for D+28>, a = 4200896}, <C> = {
_vptr.C = 0x405214 <vtable for D+44>, a = 3981312}, a = 4194432}
(gdb) p (int*)*((int*)0x4051f0)
$2 = (int *) 0x403c08 <A::f()>
(gdb) p (int*)*((int*)0x4051f0 + 1)
$3 = (int *) 0x403c3c <A::g()>
(gdb) p (int*)*((int*)0x4051f0 + 2)
$4 = (int *) 0x403d88 <D::h()>
(gdb) p (int*)*((int*)0x4051f0 + 3)
$5 = (int *) 0xfffffff8
(gdb) p (int*)*((int*)0x405204)
$6 = (int *) 0x403c88 <B::f()>
(gdb) p (int*)*((int*)0x405204 + 1)
$7 = (int *) 0x403cbc <B::g()>
(gdb) p (int*)*((int*)0x405204 + 2)
$8 = (int *) 0xfffffff0
(gdb) p (int*)*((int*)0x405214)
$9 = (int *) 0x403d08 <C::f()>
(gdb) p (int*)*((int*)0x405214 + 1)
$10 = (int *) 0x403d3c <C::g()>
(gdb) p (int*)*((int*)0x405214 + 2)
$11 = (int *) 0x3a434347
(gdb)

可见结构跟我们能分析得到的虚函数表图是一致的。做类型强制转换之后,a指针指向D类中第一个虚函数表,b指针指向第二张虚函数表,c指针指向第三张虚函数表。
不过,从打印结果来看,a指向的地址与b指向的地址相差8,b指向的地址和c指向的地址也相差8。但是在32位系统中,一个指针所占用的字节数应该是4,为什么会是a,b,c之间会相差8呢?多出来的4字节其实是成员变量a所占用的字节。
我们的内存布局图应该是这样:
5
A类占用8字节,B类占用8字节,C类占用8字节,D类占用28字节((4+4)*3+4)以上就是多继承无虚函数覆盖的虚函数表和对象内存布局情况。下面看一下有虚函数覆盖的情况。

3. 多继承(有虚函数覆盖)

上例的继承关系保存不变,在D类中重写f()方法,修改类D的定义为:

1
2
3
4
5
6
7
8
9
10
11
class D:public A,public B, public C {
private:
int a;
public:
void f() {
cout<<"D::f()"<<endl;
}
virtual void h() {
cout<<"D::h()"<<endl;
}
};

6
也就是把A,B,C类虚函数表中各自的f()函数地址替换为D类重写的f()函数地址。

内存寻址定义

所谓内存寻址,就是 cpu 接受到指令后,需要从内存中取得相应数据。但是内存中的数据都是有对应地址的,如何通过地址拿到的地址,来获取相应地址段上的数据。

所谓地址在操作系统中分为逻辑地址,虚拟地址,线性地址和物理地址。

1. 逻辑地址

逻辑地址就是机器码指令用到的地址。机器指令码中用到的地址都是逻辑地址。目前这个地址是由 16 位段选择符和 32 位偏移量来表示的(CS:EIP 段选择符:段内偏移量)。

2. 虚拟地址

虚拟地址就是逻辑地址的段内偏移量。所以逻辑地址 = 段选择符:虚拟地址。我们正常代码中拿到的地址就是虚拟地址,比如:

1
int *p = (int*)malloc(sizeof(int));

3. 线性地址

是一个 32 位无符号整数,是由逻辑地址经过段页式转换而来的。我们常说的进程的地址空间,所谓的地址指的就是线性地址。

4. 物理地址

是内存芯片中的物理地址,是存放数据的实际地址。是由逻辑地址转换而来的。最终是由这个地址来定位到内存空间。在页表转换时,这里存的不是真正物理地址,是物理内存块编号。例如一个内存块大小为 4K,那第 0 块地址若为 0x50000000,第一块就为 0x50001000。

最后给一张地址变换的图:

0

x86 段页式内存管理机制

逻辑地址转换为物理地址需要经历两个过程:

1. 段式内存管理:逻辑地址 => 线性地址

逻辑地址是(selector:offset)的形式,selector 可以为代码段或者数据段。

1

如用 selector 去 GDT 全局描述符表(假定 TI = 0)拿到段基址 segment base address,之后再加上 offset,就得到了线性地址。这个过程,被称为段式内存管理。对于表指示器 Table Indicator 来讲,决定了去哪种表寻找描述符。全局描述符放在 GDT(每个 CPU 有一个)里面,进程自己的放在 LDT 里面。

分段的目的主要有两个:

  • 使操作系统可以访问大于地址总线的内存,如 32 位地址总线可以访问 大于 4G 的内存。
  • 权限控制,将每个段设置权限位,让不同程序可以访问不同段。

2. 页式内存管理:线性地址 => 物理地址

线性地址结构如下图:

2

线性地址切成三段,用前两段分别作为索引去 Page Directory 和 Page Table 里查表,会先得到一个页目录表项,再得到一个页表项(Page Table Entry),那里面的值就是一个物理内存块的起始地址(其实就是是物理内存编号),把它加上 线性地址 切分之后第三段的页内偏移就得到了最终的物理地址。我们把这个过程称作页式内存管理

Linux 段页式管理做法

Linux 认为靠页式管理就能完成内核所需的功能了,段式太麻烦了。关又关不掉,因为是硬件那里做的,所以只能略施小计:所有段的 segment base address 都设置为 0!这样就不分段了,所有段内空间和线性空间重合。

逻辑地址 == 虚拟地址 == 线性地址

虚继承和虚基类

为了解决继承中的二义性问题,提出的虚基类和虚继承。虚继承只能解决具备公共祖先的多继承所带来的二义性问题,不能解决没有公共祖先的多继承的问题。

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <iostream>
using namespace std;

class A {
public:
A() {
cout << "A()" << endl;
}
~A() {
cout << "~A()" << endl;
}
};

class B : public A {
public:
B() {
cout << "B()" << endl;
}
~B() {
cout << "~B()" << endl;
}
};

class C : public A {
public:
C() {
cout << "C()" << endl;
}
~C() {
cout << "~C()" << endl;
}
};

class D : public B, public C {
public:
D() {
cout << "D()" << endl;
}
~D() {
cout << "~D()" << endl;
}
};
int main() {
D d;
// A* a = &d;
// test.cc: In function ‘int main()’:
// test.cc:46:13: error: ‘A’ is an ambiguous base of ‘D’
// A* a = &d;
// ^
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
@└────> # ./a.out 
A()
B()
A()
C()
D()
~D()
~C()
~A()
~B()
~A()

可以看到,在以上存在多继承且为菱形继承的情况下,构造函数的调用顺序是 ABACD,这表明继承 B 和 C 的时候夹杂了两份 A 的内容。这样也有一个问题,就是在试图用基类指针指向 D 对象时,编译器不知道应该让它指向哪个 A 的内存,因为在 d 中有两个 A 的实例。虚继承就是为了解决这个问题的。

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
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <iostream>
using namespace std;

class A {
public:
A() {
cout << "A()" << endl;
}
~A() {
cout << "~A()" << endl;
}
};

class B : virtual public A { // 虚继承,A 是 B 的虚基类
public:
B() {
cout << "B()" << endl;
}
~B() {
cout << "~B()" << endl;
}
};

class C : virtual public A { // 虚继承,A 是 C 的虚基类
public:
C() {
cout << "C()" << endl;
}
~C() {
cout << "~C()" << endl;
}
};

class D : public B, public C {
public:
D() {
cout << "D()" << endl;
}
~D() {
cout << "~D()" << endl;
}
};
int main() {
D d;
A* a = &d;
return 0;
}
1
2
3
4
5
6
7
8
9
@└────> # ./a.out 
A()
B()
C()
D()
~D()
~C()
~B()
~A()

这样 D 的内存中就只有一份 A 了,并且用基类指针指向该地址也能正确进行。虚基类并不是在声明基类时声明的,而是在声明派生类时,指定继承方式时声明的。因为一个基类可以在生成一个派生类时作为虚基类,而在生成另一个派生类时不作为虚基类。

纯虚函数

cpp 支持编译时多态和运行时多态,函数重载就是编译时多态,而派生类和虚函数则为运行时多态。

编译时多态和运行时多态的区别就是函数地址是早绑定还是晚绑定。如果函数的调用,在编译阶段就可以确定函数的调用地址,并产生代码,就是编译时多态,就是说地址是早绑定的。而如果函数的调用地址不能在编译期间确定,而需要在运行时才能决定,这这就属于运行时多态。 向上类型转换问题,对象可以作为自己的类或者作为它的基类的对象来使用。还能通过基类的地址来操作它。取一个对象的地址(指针或引用),并将其作为基类的地址来处理,这种称为向上类型转换。也就是说:父类引用或指针可以指向子类对象,通过父类指针或引用来操作子类对象。

Cpp 动态多态性是通过虚函数来实现的,虚函数允许派生类重新定义基类成员函数,而派生类重新定义基类虚函数的做法称为覆盖(override),或者称为重写。这部分原理可见另一篇文章。 cpp虚函数表

在设计时,常常希望基类仅仅作为其派生类的一个接口。这就是说,仅想对基类进行向上类型转换,使用它的接口,而不希望用户实际的创建一个基类的对象。同时创建一个纯虚函数允许接口中放置成员原函数,而不一定要提供一段可能对这个函数毫无意义的代码。做到这点,可以在基类中加入至少一个纯虚函数(pure virtual function),使得基类称为抽象类(abstract class)。

纯虚函数使用关键字 virtual,并在其后面加上 = 0。如果试图去实例化一个抽象类,编译器则会阻止这种操作。当继承一个抽象类的时候,必须实现所有的纯虚函数,否则由抽象类派生的类也是一个抽象类。virtual void fun() = 0,告诉编译器在vtable中为函数保留一个位置,但在这个特定位置不放地址。

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
35
36
37
#include <iostream>
using namespace std;

class A {
public:
A() {
cout << "A()" << endl;
}
virtual void func() = 0; // 纯虚函数,表明 A 已经是抽象类了,不能实例化
virtual ~A() {
cout << "~A()" << endl;
}
};

class B : public A {
public:
B() {
cout << "B()" << endl;
}
void func() { // 实现该函数,否则 B 因为抽象类而不能实例化
cout << "B::func()" << endl;
}
~B() {
cout << "~B()" << endl;
}
};

int main() {
// A a;
//test.cc:26:7: error: cannot declare variable ‘a’ to be of abstract type ‘A’
// A a;
// ^
B b;
A* a = &b;
a->func();
return 0;
}
1
2
3
4
5
6
@└────> # ./a.out 
A()
B()
B::func()
~B()
~A()

std::atomic

原子类型是对数据的一种封装,可以防止数据竞争,达到同步多线程的内存访问的目的。对该变量的读写是原子的。

1
#include <atomic>

使用前需要包含头文件。该头文件中主要包含两个类:atomic 和 atomic_flag。本文主要讲解 std::atomic。

构造函数和赋值

1
2
3
4
5
6
7
8
atomic() noexcept = default;
constexpr atomic (T val) noexcept;
atomic (const atomic&) = delete;

T operator= (T val) noexcept;
T operator= (T val) volatile noexcept;
atomic& operator= (const atomic&) = delete;
atomic& operator= (const atomic&) volatile = delete;
  1. 构造一个未初始化的原子对象。
  2. 构造一个用 val 初始化的原子对象。
  3. 禁用拷贝构造函数,原子对象不可复制、移动
  4. 可以赋值(val)。

memory_order

1
2
3
4
5
6
7
8
typedef enum memory_order {
memory_order_relaxed,
memory_order_consume,
memory_order_acquire,
memory_order_release,
memory_order_acq_rel,
memory_order_seq_cst
} memory_order;

它们的目的是为了做线程间的同步,原理是在线程内限制变量操作的顺序:

  1. memory_order_relaxed:用于读写,不做任何限制。
  2. memory_order_acquire:用于读,如果一个原子变量的 load 用了该选项,那么可以保证,在本线程内,该 load 语句之后的所有变量(不论是否原子变量)的读写语句,都实际在该 load 操作执行后执行。
  3. memory_order_release:用于写,如果一个原子变量的 store 用了该选项,那么可以保证,在本线程内,该 store 语句之前的所有变量(不论是否原子变量)的读写语句,都实际在该 store 操作执行前执行。
  4. memory_order_consume:用于读,如果一个原子变量的 load 用了该选项,那么可以保证,在本线程内,该 load 语句之后的所有依赖该变量的变量的读写语句,都实际在该 load 操作执行后执行。但是,它只保证与当前操作相关的数据依赖关系。从 2016 年后,所有编译器实现中,memory_order_consume 和 memory_order_acquire 完全一致。
  5. memory_order_acq_rel:memory_order_acquire + memory_order_release
  6. memory_order_seq_cst:sequence consistent,顺序一致,要求所有变量的读写执行顺序都和代码中的顺序一致。

memory_order_acquire 和 memory_order_release 直观上就像一个栅栏:在调用处设置一个栅栏,acquire 是拦住代码中在它后边的变量读写操作,不让其跑到它前边;release则相反,不让前边的跑到后边。(cpu 提供 memory_barrier 或者 memory_fence 指令)关于为什么 cpu 会进行内存重排,请见内存模型与内存序

atomic::store()

1
2
void store (T val, memory_order sync = memory_order_seq_cst) volatile noexcept;
void store (T val, memory_order sync = memory_order_seq_cst) noexcept;

用 val 替换原子对象中的值。该操作是原子性的,通过 sync 指定内存顺序。sync 可选项见上文。

atomic::load()

1
2
T load (memory_order sync = memory_order_seq_cst) const volatile noexcept;
T load (memory_order sync = memory_order_seq_cst) const noexcept;

返回原子对象中的值。该操作为原子性。

atomic::exchange

1
2
T exchange (T val, memory_order sync = memory_order_seq_cst) volatile noexcept;
T exchange (T val, memory_order sync = memory_order_seq_cst) noexcept;

用 val 替换原子对象中的值,并返回替换前的值。操作为原子性。整个过程完成之前,其他线程无法访问。

atomic::compare_exchange_weak

1
2
3
4
5
bool compare_exchange_weak (T &expected, T val, memory_order sync = memory_order_seq_cst) volatile noexcept;
bool compare_exchange_weak (T &expected, T val, memory_order sync = memory_order_seq_cst) noexcept;

bool compare_exchange_weak (T &expected, T val, memory_order success, memory_order failure) volatile noexcept;
bool compare_exchange_weak (T &expected, T val, memory_order success, memory_order failure) noexcept;

将原子对象的存储值和预期值比较:

  • 若为 true,用 val 替换原子对象值。
  • 若为 false, 用包含的值替换预期值。

整个过程是原子性的。

下面版本使用的内存顺序取决于比较结果:true 则使用 success; false 则使用 failure。该函数比较的是原子对象和预期值中的物理内容,这可能导致使用操作符 == 比较相等的值的在这里比较失败。

与 compare_exchange_strong 不同,该 weak 版本允许错误的返回 false,即使原子对象存储值与预期值相等。对于某些循环算法,这可能是可接受的行为,并且可能在某些平台上显著提高性能。对于这些虚假的失败,函数返回 false,但不修改预期的值。对于非循环算法,通常首选 compare_exchange_strong。

atomic::compare_exchange_strong

1
2
3
4
5
bool compare_exchange_strong (T &expected, T val, memory_order sync = memory_order_seq_cst) volatile noexcept;
bool compare_exchange_strong (T &expected, T val, memory_order sync = memory_order_seq_cst) noexcept;

bool compare_exchange_strong (T &expected, T val, memory_order success, memory_order failure) volatile noexcept;
bool compare_exchange_strong (T &expected, T val, memory_order success, memory_order failure) noexcept;

原子操作。与 compare_exchange_week 不同,当期望值与对象存储的值相等时,这个强版本必须始终返回 true,不允许虚假的失败。但是,在某些机器上,对于某些在循环中检查这个的算法,compare_exchange_weak 可能有更高的性能表现。

专门化计算操作

atomic::fetch_add(T val, memory_order sync = memory_order_seq_cst)

存储的值 + val 并返回之前的值,整个操作是原子性的。如果第二个参数使用默认值,那么这个函数相当于 atomic::operator+=。

atomic::fetch_sub(T val, memory_order sync = memory_order_seq_cst)

存储的值 - val 并返回之前的值,整个操作是原子性的。如果第二个参数使用默认值,那么这个函数相当于 atomic::operator-=。

atomic::fetch_and(T val, memory_order sync = memory_order_seq_cst)

(存储的值 & val)并返回之前的值,整个操作是原子性的。如果第二个参数使用默认值,那么这个函数相当于 atomic::operator&=。

atomic::fetch_or(T val, memory_order sync = memory_order_seq_cst)

(存储的值 | val)并返回之前的值,整个操作是原子性的。如果第二个参数使用默认值,那么这个函数相当于 atomic::operator|=。

atomic::fetch_xor(T val, memory_order sync = memory_order_seq_cst)

(存储的值 ^ val)并返回之前的值,整个操作是原子性的。如果第二个参数使用默认值,那么这个函数相当于 atomic::operator^=。

atomic::operator++ ()

递增保存的值,返回递增后的值。

atomic::operator++ (int)

递增保存的值,返回递增前的值。

atomic::operator-- ()

递减保存的值,返回递减后的值。

atomic::operator-- (int)

递减保存的值,返回递减前的值。

reference:https://blog.csdn.net/u014673282/article/details/89789139

什么是内存泄漏?

内存泄漏(Memory Leak)是指程序中已动态分配的堆内存由于某种原因程序未释放或
无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。

valgrid工具

(1) 简介:
Valgrind 是一套 Linux 下,开放源代码(GPL V2)的仿真调试工具的集合。Valgrind 由
内核(core)以及基于内核的其他调试工具组成。内核类似于一个框架(framework),它模
拟了一个 CPU 环境,并提供服务给其他工具;而其他工具则类似于插件 (plug-in),利用内
核提供的服务完成各种特定的内存调试任务。最常用的工具为 Memcheck,用来检测程序中
出现的内存问题,所有对内存的读写都会被检测到,一切对 malloc()/free()/new/delete 的调用
都会被捕获。所以,它能检测以下问题:
• 对未初始化内存的使用;
• 读/写释放后的内存块;
• 读/写超出 malloc 分配的内存块;
• 读/写不适当的栈中内存块;
• 内存泄漏,指向一块内存的指针永远丢失;
• 不正确的 malloc/free 或 new/delete 匹配;
• memcpy()相关函数中的 dst 和 src 指针重叠。
(2) 使用示例:
valgrind --log-file=report.log --tool=memcheck --leak-check=full --show-leak-kinds=all ./benc
hmark arg1 arg2 …
(3) 输出日志解读:
• 报告中会指示出发生泄漏的函数和文件位置;
• 最后会给出一个总结,标明了泄漏的内存总量;
• definitely lost: 铁定丢失,一般来说存在明显的泄漏;
• indirectly lost: 间接丢失,当使用了含有指针成员的类或结构时可能会报这个错误;
• possibly lost: 可能丢失;
• 只能定位到内存最初被申请的位置,不能定位到是哪里泄漏

AddressSantizer

(1) 简介:
AddressSanitizer(ASan)是一个快速的内存错误检测工具。它包括一个编译器 instrumentation 模块和一个提供 malloc()/free()替代项的运行时库。
(2) 使用:
编译和链接选项中加入-fsanitize=address -fno-omit-frame-pointer 即可。可以通过 readelf 工具查看.so 的依赖库中是否包含 ASan 来确认编译是否成功。

推理性能优化

推理优化工作可以归成四类

  1. 算子优化
  2. 图优化
  3. 模型压缩
  4. 部署优化

算子优化

算子优化就是优化单算子的性能,方法无非是算法优化和微架构优化。

对同一个算子可能有不同的算法去实现它。举个例子,对卷积算法我们常见的就有:矩阵乘法,直接卷积法,Winograd 变换法,FFT 变换法。需要我们针对目标问题,分析并实现合适的算法,来达到最佳性能。
微架构优化。微架构优化主要焦点是如何充分利用好微架构的内置加速器的能力去最大化算子的性能。

图优化

图优化主要通过子图变换算子融合的方式来达到减少计算量或者其他系统开销(如访存开销),从而达到性能优化的目的。图优化主要是希望在不影响模型的数值特性的基础上,通过图变换达到简化计算、资源开销,提升性能,所以是性能优化时的首选方法之一。

子图变换

子图变换主要是通过数学变换的手段对图进行精简,从而减少计算和调度开销。常见的有常数折叠,公共子表达式折叠以及算术变换。

常数折叠 (Constant Folding)

1

2

算数变换

3

算子融合

在深度学习中,一般来说,计算密集型和访存密集型算子是相伴出现的。这时候我们可以通过 fusion 来实现寄存器计算,从而减少访存密集型算子的访存,减少内存访问延时和带宽压力,提高推理效率。

4

5
通过这种方式,减少两次 tensor 的内存读写操作。

模型压缩

上面的方案都是精度无损的,当这三点都做完了后,如果还需要额外的性能增益,这时候需要考虑模型压缩方案。

模型量化

模型量化主要是通过降低模型中 tensor 和 weights 精度的手段,从而减少计算需求和数据存储与传输需求,来达到加速的目的。例如把权重从 FP32 降低为 FP16。虽然可以加速,但是有一定的精度损失。

模型蒸馏

模型蒸馏采用的是迁移学习的方法,通过采用预先训练好的复杂模型(Teacher Model)的输出作为监督数据,去训练另外一个简单的网络(Student Model),最后把 Student Model 用于推理。有精度损失。

部署优化

部署优化主要通过调整模型在部署时的资源分配和调度的参数来进一步优化性能。如,调整了 NUMA 的参数。

cpp 的构造函数种类

  1. 构造函数:生成一个新的对象。
  2. 拷贝构造函数:参数是 const T& x,用于拷贝。
  3. 赋值构造函数:这个严格意义来说不是构造函数,是赋值运算符 = 的重载。初始化的时候不会调用这个函数!
  4. 移动构造函数:参数是 T &&x,用于移动构造,提升效率。

代码示例

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <iostream>
#include <string>

using namespace std;

class Integer {
private:
int* m_ptr;
public:
// 普通构造函数
Integer(int value) : m_ptr(new int(value)) {
cout << "constructor" << endl;
}
// 拷贝构造函数
Integer(const Integer& source) : m_ptr(new int(*source.m_ptr)) {
cout << "copy constructor" << endl;
}
// 移动构造函数
Integer(Integer&& source) : m_ptr(source.m_ptr) {
source.m_ptr = nullptr;
cout << "move constructor" << endl;
}
// 赋值运算符重载
Integer& operator=(const Integer& source) {
m_ptr = new int(*source.m_ptr);
cout << "copy assignment constructor" << endl;
return *this;
}
~Integer() {
cout << "destructor:" << GetValue() << endl;
delete m_ptr;
}
int GetValue(void) {
if (m_ptr) {
return *m_ptr;
} else {
return -1;
}
}
};

Integer getNum() {
Integer a(100);
return a;
}
int main(int argc, char const* argv[]) {
Integer a(getNum());
cout << "a=" << a.GetValue() << endl;
cout << "-----------------" << endl;
Integer temp(10000);
Integer b(temp);
cout << "b=" << b.GetValue() << endl;
cout << "-----------------" << endl;
Integer c = b;
c = a;
cout << "c=" << c.GetValue() << endl;
cout << "-----------------" << endl;
return 0;
}

在编译的时候一定要指定编译选项 --no-elide-constructors,否则会被编译器优化!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@└────> # ./a.out 
constructor
move constructor
destructor:-1
move constructor
destructor:-1
a=100
-----------------
constructor
copy constructor
b=10000
-----------------
copy constructor
copy assignment constructor
c=100
-----------------
destructor:100
destructor:10000
destructor:10000
destructor:100

分析

a

  1. 第一个 constructor,是在 getNum() 中首句 Integer a(100) 所打印的,调用默认的构造函数。
  2. 第二个 move constructor 是因为 return a 的时候,这个 a 是个右值,作为 main 函数 getNum() 的返回值,而调用的移动构造函数,将 getNum() 函数内的返回值移动构造给 main 函数接收 getNum() 的对象。
  3. 第三个 destructor 是析构 getNum() 中的对象 a。
  4. 第四个 move constructor 是因为 Integer a(右值),所以调用移动构造函数。
  5. 第五个 destructor 是析构 Integer a(右值) 中的右值而显示的。

b

  1. 第一个 constructor 是 Integer temp(10000); 调用的默认构造函数。
  2. 第二个 copy constructor 是 Integer b(temp); 调用的拷贝构造函数,将 temp 拷贝给 b;

c

  1. 第一个 copy constructor 是 Integer c = b; 调用的拷贝构造函数,可以看到此处并没有调用拷贝赋值运算符,因为这是在初始化!
  2. 第二个 copy assignment constructor 是 c = a 调用的才是拷贝赋值运算符。

可以看到,初始化的时候不会调用 拷贝赋值运算符!!!

析构

最后的四个 destructor 是析构。根据规则,先构造的后析构,后调用的先析构。析构顺序:c = 100,b = 10000,temp = 10000,a = 100。

何为右值

任何值不是左值,就是右值。左值和右值是针对表达式定义的。

  1. 右值的生存周期只到表达式结束,即语句分号后右值的生存周期就结束了。
  2. 左值可以取地址,右值不行。
  3. 左值可以放在 = 的左右,但是右值只能放在右边。

为什么要引入这么复杂的概念呢?为了性能。在赋值的时候,难免会产生一些临时变量的构造和销毁,这些构造和销毁可能会引起内存的申请与释放。频繁的系统调用会有很大的性能开销。这种时候没必要重复申请内存,只要把那个将要销毁对象内部的指针指向的空间被新的对象所利用就可以了!

1

1
2
3
4
int t = 10;  // t为左值
++t; // t为左值,没有拷贝,这就是为什么 ++t 比 t++ 高效
t++; // t为右值,因为加之前的值是个将亡值,是个 t_copy,是个临时变量
int &&r = t; // 错误,右值不能绑定左值。

std::move()

实现代码

1
2
3
4
5
6
7
8
9
/**
* @brief Convert a value to an rvalue.
* @param __t A thing of arbitrary type.
* @return The parameter cast to an rvalue-reference to allow moving it.
*/
template<typename _Tp>
constexpr typename std::remove_reference<_Tp>::type&&
move(_Tp&& __t) noexcept
{ return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }

这个函数就一个作用:将左值明确的转换为右值。由于引用折叠的存在,导致返回值一定是右值。引用折叠概念见下一小节。

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
using namespace std;
void func(string &&str) {
cout << "右值函数" << endl;
}
void func(string &str) {
cout << "左值函数" << endl;
}

int main() {
string s;
func(s + s);
func(s);
func(std::move(s));
}

结果:

1
2
3
4
@└────> # ./a.out 
右值函数
左值函数
右值函数

可以看到,s + s 是一个右值,编译器会自动匹配调用右值对应的函数。s 是一个左值,会调用左值对应的函数。使用 std::move() 强行把左值转换为右值,就匹配右值对应的函数。此处注意,因为 s 已经变为右值了,所以在调用之后,s 会失效。

std::forward()

在介绍 std::forward() 之前,先介绍下引用折叠。

引用折叠

引用折叠是指,在左值类型和右值类型叠加时,确认叠加规则。如下代码:

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
#include <iostream>
using namespace std;

template<typename T>
void WithoutPerfectForward(T &&t) {
cout << "lvalue:" << std::is_lvalue_reference<decltype(t)>::value << endl;
cout << "rvalue:" << std::is_rvalue_reference<decltype(t)>::value << endl;
cout << endl;
}

int main() {
int a = 1;
int b = 2;
const int c = 1;
const int d = 0;
int &e = a;
int &f = b;
const int &g = c;
const int &h = d;
WithoutPerfectForward(a); // l + r = l
WithoutPerfectForward(move(b)); // r + r = r
WithoutPerfectForward(c); // const l + r = l
WithoutPerfectForward(move(d)); // const r + r = r
WithoutPerfectForward(e); // l ref + r = l
WithoutPerfectForward(move(f)); // r ref + r = r
WithoutPerfectForward(g); // const l ref + r = l
WithoutPerfectForward(move(h)); // const r ref + r = r
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@└────> # ./a.out 
lvalue:1
rvalue:0

lvalue:0
rvalue:1

lvalue:1
rvalue:0

lvalue:0
rvalue:1

lvalue:1
rvalue:0

lvalue:0
rvalue:1

lvalue:1
rvalue:0

lvalue:0
rvalue:1

可以看到,因为 T 的不同会产生不同的模版函数特化版本,如参数会变为 T& && 和 T&& && 这两种形式,c++11 支持的编译器会对这种情况用 4 条规则做折叠处理,生成只有 & 和 && 的引用方式:

  1. & + & = &
  2. & + && = &
  3. && + & = &
  4. && + && = &&

即引用折叠时只要有左值引用,就折叠为左值引用,全部是右值引用,才折叠为右值引用

那么考虑如下场景:

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
35
36
37
38
39
#include <iostream>
using namespace std;

void RunCode(int && m) {
cout << "int && called!" << endl;
}
void RunCode(int &m) {
cout << "int & called!" << endl;
}
void RunCode(const int && m) {
cout << "const int && called!" << endl;
}
void RunCode(const int & m) {
cout << "const int & called!" << endl;
}

template<typename T>
void WithoutPerfectForward(T &&t) {
RunCode(t);
}

int main() {
int a = 1;
int b = 2;
const int c = 1;
const int d = 0;
int &e = a;
int &f = b;
const int &g = c;
const int &h = d;
WithoutPerfectForward(a); // l + r
WithoutPerfectForward(move(b)); // r + r
WithoutPerfectForward(c); // const l + r
WithoutPerfectForward(move(d)); // const r + r
WithoutPerfectForward(e); // l ref + r
WithoutPerfectForward(move(f)); // r ref + r
WithoutPerfectForward(g); // const l ref + r
WithoutPerfectForward(move(h)); // const r ref + r
}

结果:

1
2
3
4
5
6
7
8
9
@└────> # ./a.out 
int & called!
int & called!
const int & called!
const int & called!
int & called!
int & called!
const int & called!
const int & called!

可以看到虽然在函数 WithoutPerfectForward 中是右值,但是传给下一个函数的时候又默认都是左值了。虽然参数 t 是右值类型的,但此时 t 在内存中已经有了位置,所以 t 其实是个左值。

完美转发

为了实现不丢失传进来值的左右性,使用 std::forward() 就可以实现完美的转发了!

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
35
36
37
38
39
#include <iostream>
using namespace std;

void RunCode(int && m) {
cout << "int && called!" << endl;
}
void RunCode(int &m) {
cout << "int & called!" << endl;
}
void RunCode(const int && m) {
cout << "const int && called!" << endl;
}
void RunCode(const int & m) {
cout << "const int & called!" << endl;
}

template<typename T>
void WithoutPerfectForward(T &&t) {
RunCode(std::forward<T>(t));
}

int main() {
int a = 1;
int b = 2;
const int c = 1;
const int d = 0;
int &e = a;
int &f = b;
const int &g = c;
const int &h = d;
WithoutPerfectForward(a); // l + r
WithoutPerfectForward(move(b)); // r + r
WithoutPerfectForward(c); // const l + r
WithoutPerfectForward(move(d)); // const r + r
WithoutPerfectForward(e); // l ref + r
WithoutPerfectForward(move(f)); // r ref + r
WithoutPerfectForward(g); // const l ref + r
WithoutPerfectForward(move(h)); // const r ref + r
}

结果:

1
2
3
4
5
6
7
8
9
@└────> # ./a.out 
int & called!
int && called!
const int & called!
const int && called!
int & called!
int && called!
const int & called!
const int && called!

实现代码

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
/**
* @brief Forward an lvalue.
* @return The parameter cast to the specified type.
*
* This function is used to implement "perfect forwarding".
*/
template<typename _Tp>
constexpr _Tp&&
forward(typename std::remove_reference<_Tp>::type& __t) noexcept
{ return static_cast<_Tp&&>(__t); }

/**
* @brief Forward an rvalue.
* @return The parameter cast to the specified type.
*
* This function is used to implement "perfect forwarding".
*/
template<typename _Tp>
constexpr _Tp&&
forward(typename std::remove_reference<_Tp>::type&& __t) noexcept
{
static_assert(!std::is_lvalue_reference<_Tp>::value, "template argument"
" substituting _Tp is an lvalue reference type");
return static_cast<_Tp&&>(__t);
}

可以看到,std::forward() 有两个函数,会根据是左值还是右值调用不同的函数,返回不同种类出参。

KMP算法是什么

是用来解决字符串匹配的问题。在一个 strstr 字符串中找到一个子字符串 subsub,返回索引的位置。
常规能想到的最简单的方式就是一个一个比对,匹配失败的话就向下挪一个位置继续匹配。这样做时间复杂度是 O(n2)O(n^2)
KMP算法可以在 O(n)O(n) 的时间复杂度内解决这个问题。

next数组是什么

nextnext 数组的本质是寻找子串中“相同前后缀的长度,并且最长”。并且不能是字符串本身。举个例子而言,如下所示:

A B A B C
0 0 1 2 0

11AA 只有一个字符,不能是字符串本身,为 00
22BBAA 比较,不相等,为 00
33AAAA 比较,相等,所以相同前缀为 11
44BB 和前缀为 11 后的 BB 比较,相等,和前缀组成了 ABAB 的相同模式的串,所以相同前缀为 22
55CC 和前缀为 22 后的 AA 比较,不相等,和前缀长度为 00 的比,相同前缀为00
关于最长前缀的理解是,如下图所示。
next
最后一位的 AA 很显然可以和 ABAABA 组成长度为 33 的相同前缀,也可以自己 AA 组成长度为 11 的相同前缀。这种情况下选择较大的那个值。

求解next数组

nextnext 数组求解的本质是动态规划。假设我们已经知道当前数字的共同前后缀了,如下图所示。
1
接下来求第 ii 位的 nextnext 数组,分为两种情况。

  1. 下一位也和前缀相等
    那就很明显等于 next[i1]next[i - 1] 加上 11
  2. 下一位和前缀不相等
    这里不是直接等于 00 哦。如下图所示
    2
    虽然 ABABABABABACABAC 并不相等,但是也不能直接设为 00。因为这个新来的 BB 可以和前面的 AA 组成 ABAB,达到 22 的长度。那么这个数难道要暴力求解吗?其实不然。此时第 i1i - 1 位的 nextnext 的可利用的值在前缀的 nextnext 数组中可以得到答案,在本图中为 next[2]next[2],即是 11。此时修改前缀长度为 next[2]next[2],则又回到了最开始的状况,匹配长度为 11 并且匹配下一位,下一位 BB 相等,所以 next[i]next[i]1+1=21 + 1 = 2
    代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
vector<int> buildNext(string sub) {
vector<int> next;
next.push_back(0);
int prefix_len = 0;
int i = 1; // 处理的索引,不会后退,以保证时间复杂度为O(n)
while (i < sub.size()) {
if (sub[i] == sub[prefix_len]) { // 下一位相等,next数组数值+1
prefix_len++;
next.push_back(prefix_len);
i++;
} else { // 下一位不相等
if (prefix_len == 0) {
next.push_back(prefix_len); // 目前没有相等的,就是0了
i++;
} else {
prefix_len = next[prefix_len - 1]; // 有不为0的前缀选择,继续回到开始状况
}
}
}
return next;
}

next数组在KMP中的应用

KMP算法在匹配失败的时候,回去看最后失败的字符前一个字符所对应的 nextnext 值。nextnext 数组中的值是什么作用呢?它代表我们可以“跳过匹配”的字符数量。
3
4
这样就可以保证匹配的 ii 不向后退,达到时间复杂度 O(n)O(n) 的目的。
代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int kmp(string s, string sub) {
vector<int> next = buildNext(sub);
int i = 0; // 主串指针
int j = 0; // 子串指针
while (i < s.size()) {
if (s[i] == sub[j]) { // 匹配,指针后移
i++;
j++;
} else if (j > 0) { // 匹配失败,但是next跳过了一些字符继续匹配
j = next[j - 1];
} else { // 子串第一个字符就匹配失败
i++;
}
if (j == sub.size()) {
return i - j; // 成功,返回初始下标
}
}
return -1; // 失败
}

代码:

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
35
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <netdb.h>

#define MAXLINE 64
int main(int argc, char **argv)
{
struct addrinfo *p, *listp, hints;
char buf[MAXLINE];
int rc, flags;

if (argc != 2) {
fprintf(stderr, "usage: %s <domain name>\n", argv[0]);
exit(0);
}
memset(&hints, 0, sizeof(struct addrinfo));
hints.ai_family = AF_INET; // 只用IPV4
hints.ai_socktype = SOCK_STREAM; // 只用TCP
if ((rc = getaddrinfo(argv[1], NULL, &hints, &listp)) != 0) {
fprintf(stderr, "getaddrinfo error: %s\n", gai_strerror(rc));
exit(1);
}

flags = NI_NUMERICHOST; // 展示数字而不是域名
for (p = listp; p; p = p->ai_next) { // 遍历结果链表
getnameinfo(p->ai_addr, p->ai_addrlen, buf, MAXLINE, NULL, 0, flags);
printf("%s\n", buf);
}

freeaddrinfo(listp);

exit(0);
}

编译:

gcc nslookup.c -o nslook

运行:

在这里插入图片描述


Reference:

CSAPP: P659P_{659}