编译单元

编译单元是指一个源文件(.c/.cpp)文件和包含展开的头文件,之后编译器会将该文件编译成一个 object 文件(在 linux 中是 .o 文件)。

1
gcc xxx.cc -c

之后用可以看见当前目录下生成一个 xxx.o 的文件,这个文件就是 xxx.cc 编译单元生成的对应二进制。这个二进制里面有该文件所对应的符号。

普通成员函数

现在有如下例子,尝试编译对应的二进制文件,不链接:

main.cc:

1
2
3
4
5
6
#include "A.h"

int main() {
A a;
a.print();
}

A.h:

1
2
3
4
5
class A {
public:
int m;
void print();
};

A.cc:

1
2
3
4
5
6
7
#include <iostream>
#include "A.h"
using namespace std;

void A::print() {
cout << m << endl;
}

之后可以用 nm 命令查看二进制中的符号,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@└────> # g++ main.cc -c
@└────> # g++ A.cc -c
@└────> # nm main.o
0000000000000000 T main
U _ZN1A5printEv
@└────> # nm A.o
U __cxa_atexit
U __dso_handle
000000000000006c t _GLOBAL__sub_I__ZN1A5printEv
000000000000002e t _Z41__static_initialization_and_destruction_0ii
0000000000000000 T _ZN1A5printEv
U _ZNSolsEi
U _ZNSolsEPFRSoS_E
U _ZNSt8ios_base4InitC1Ev
U _ZNSt8ios_base4InitD1Ev
U _ZSt4cout
U _ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_
0000000000000000 r _ZStL19piecewise_construct
0000000000000000 b _ZStL8__ioinit

可以看到,在 main.o 中,有一个 _ZN1A5printEv,是对 undefined,也就是说编译器在这个编译单元中找不到这个符号。在 main.o 中,看下反汇编,调用 print 函数的时候是类似 call print 这种形式跳转函数的。因为找不到这个符号,自然也就不知道这个函数的地址,所以需要链接器链接有这个符号的 A.o,就可以运行了。常规的成员函数这样运行的,那么如果换成模板成员函数会怎么样呢?

模板成员函数

修改代码如下:

main.cc:

1
2
3
4
5
6
#include "A.h"

int main() {
A a;
a.print<int>();
}

A.h:

1
2
3
4
5
6
class A {
public:
int m;
template<class T>
void print();
};

A.cc:

1
2
3
4
5
6
7
#include <iostream>
#include "A.h"
using namespace std;
template<class T>
void A::print() {
cout << m << endl;
}

编译:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@└────> # g++ main.cc -c
@└────> # g++ A.cc -c
@└────> # nm main.o
0000000000000000 T main
U _ZN1A5printIiEEvv
@└────> # nm A.o
U __cxa_atexit
U __dso_handle
000000000000003e t _GLOBAL__sub_I_A.cc
0000000000000000 t _Z41__static_initialization_and_destruction_0ii
U _ZNSt8ios_base4InitC1Ev
U _ZNSt8ios_base4InitD1Ev
0000000000000000 r _ZStL19piecewise_construct
0000000000000000 b _ZStL8__ioinit

可以看到,在换成模板函数后,A.o 中根本没有 print 的符号!尽管 A.cc 中有定义。这是因为在 C++ 中,当一个模板不被用到的时侯它就不该被实例化出来,而在 A.cc 中显然这个模板没有被用到。所以编译器在编译 main.o 的时候,没找到符号,就把希望寄托于链接器。而链接器在链接的时候也懵逼了,因为这个符号在 A.o 中也没有。所以这种情况下链接器会报找不到符号的错误。

解决方案

所以回到主题,这就是为什么对于 C++ 的模板函数来说,要把模板函数的实现放到头文件中。

main.cc:

1
2
3
4
5
6
#include "A.h"

int main() {
A a;
a.print<int>();
}

A.h:

1
2
3
4
5
6
7
8
9
#include <iostream>
class A {
public:
int m;
template<class T>
void print() {
std::cout << m << std::endl;
}
};

编译:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@└────> # g++ main.cc -c
@└────> # nm main.o
U __cxa_atexit
U __dso_handle
0000000000000059 t _GLOBAL__sub_I_main
0000000000000000 T main
000000000000001b t _Z41__static_initialization_and_destruction_0ii
0000000000000000 W _ZN1A5printIiEEvv
U _ZNSolsEi
U _ZNSolsEPFRSoS_E
U _ZNSt8ios_base4InitC1Ev
U _ZNSt8ios_base4InitD1Ev
U _ZSt4cout
U _ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_
0000000000000000 r _ZStL19piecewise_construct
0000000000000000 b _ZStL8__ioinit

可以看到 main.o 中包含 print 的符号。

auto

C++11 中,使用 auto 关键字来进行自动类型推导。语法如下:

1
auto name = value;

其中,name 是变量的名字。value 是变量的初始值。其中 auto 是一个占位符,会在编译期间由编译器推导出来。而且由 auto 推导的变量必须初始化,因为是占位符的原因,不能用作声明。

限制:

  1. auto 不能在函数的参数中使用。

我们在定义函数的时候只是对参数进行了声明,指明了参数的类型,但并没有给它赋值,只有在实际调用函数的时候才会给参数赋值。而 auto 要求必须对变量进行初始化。

  1. auto 不能作用于类的非静态成员变量(即没有 static 关键字修饰的成员变量)中。
  2. auto 关键字不能定义数组,比如下面的例子就是错误的:
1
2
char a[] = "abcdefg";  
auto b[] = a; // a 为数组,所以不能使用 auto
1
error: ‘b’ declared as array of ‘auto’
  1. auto 不能作用于模板参数
1
2
3
4
5
6
7
8
9
template <typename T>
class A {
//TODO:
};

int main(){
A<int> C1;
A<auto> C2 = C1; // 错误
}
1
error: invalid use of ‘auto’

decltype

decltype 是 C++11 新增的一个关键字,它和 auto 的功能一样,都用来在编译时期进行自动类型推导。
auto 和 decltype 关键字都可以自动推导出变量的类型,但它们的用法是有区别的:

1
2
3
auto varname = value;
decltype(exp) varname = value;
decltype(exp) varname;

其中,varname 表示变量名,value 表示赋给变量的值,exp 表示一个表达式。

auto 根据等号右边的初始值 value 推导出变量的类型,而 decltype 根据 exp 表达式推导出变量的类型,跟等号右边的 value 没有关系。

另外,auto 要求变量必须初始化,而 decltype 不要求。auto 是根据变量的初始值来推导出变量类型的,如果不初始化,变量的类型也就无法推导了。此外,我们必须要保证 exp 的结果是有类型的,不能是 void。例如,当 exp 调用一个返回值类型为 void 的函数时,exp 的结果也是 void 类型,此时就会导致编译错误。

1
error: variable or field ‘b’ declared void

注意:

  1. 如果 exp 是一个不被括号包围的表达式,或者是类成员访问表达式,或者是单独的变量,那么 decltype(exp) 的类型就和 exp 一致。
  2. 如果 exp 是函数调用,那么 decltype(exp) 的类型就和函数返回值的类型一致。
  3. 如果 exp 是一个左值,或者被括号包围,那么 decltype(exp) 的类型就是 exp 的引用。假设 exp 的类型为 T,那么 decltype(exp) 的类型就是 T&。
  4. decltype 如果是函数调用,因为是在编译时确定,和 sizeof 一样,不会调用一次函数。

多态

  1. 静态多态:相同对象接收到不同的消息,产生不同的结果 => 函数重载。
  2. 动态多态:不同对象接收到相同的消息,产生不同的结果 => 虚函数。

多态的作用就是为了提供高度统一的接口,实现代码复用,提高代码的可扩展性和可维护性。

虚函数

虚函数表

虚函数的实现原理可见另一文章:关于cpp虚函数表的实现原理。

使用限制

0. virtual 具有继承性: 父类中定义为 virtual 的函数在子类中重写的函数也自动成为虚函数。需要注意的是,只有子类的虚函数和父类的虚函数定义完全一样才被认为是虚函数。

1. virtual 不能修饰类外的普通函数,只能修饰类中的成员函数(普通函数和析构函数)。

这句话很好理解,就是 virtual 关键字不能修饰全局函数。

1
2
3
virtual int test () { // 不正确,不能修饰全局函数
// do some thing
}

编译报错:

1
error: ‘virtual’ outside class declaration

2. virtual 不能修饰构造函数。

  • 从存储空间角度,虚函数的每个对象内部都有一个指向虚函数表的指针。那么虚函数表是什么时候初始化的呢?就是在调用构造函数的时候。cpp 调用 new 的步骤是,先申请所需的内存(malloc/operator new),再调用构造函数。如果构造函数是虚的,那必须通过虚函数表指针来找到虚构造函数的入口地址。然而此时申请的内存还没有初始化,不可能由虚函数表和虚函数表指针的。综上所述,构造函数不能为虚函数。
  • 从使用角度,构造函数是创建对象时调用的,不可能通过父类的指针或引用去调用。创建一个对象时,总是要明确指定对象的类型。但是析构函数不一样,可以通过基类指针进行析构。
1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>
using namespace std;

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

int main() {
A* a = new A();
}

编译报错:

1
error: constructors cannot be declared ‘virtual’

3. virtual 不能修饰静态成员函数。

虚函数的调用时经过对象内部的虚函数表指针,找到虚函数表,进而去调用对应的函数。但是静态成员函数和实例没有关系,只和类有关。所以调用时不会隐式传入 this 指针。题外话,因为没有 this 指针,静态函数也不能声明为 const 和 volatile。因为 void func() const {} 会被转换为 void func(const A *this) {};

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
using namespace std;

class A {
public:
A() {
cout << "construct" << endl;
}
virtual static void print() {
cout << "print" << endl;
}
};

int main() {
A* a = new A();
a->print();
}

编译报错:

1
error: member ‘print’ cannot be declared both ‘virtual’ and ‘static’

4. virtual 不能修饰内联函数。

内联函数关键字 inline 是对编译器的一个建议:如果可能,请把此函数变为内联函数。虚函数是由虚函数表实现的,因为表中需要存放函数的地址。被内联展开的函数没有具体的地址,所以无法被 virtual 修饰。但是以下写法也不会报编译错误。

1
2
3
4
5
6
7
8
9
class A {
public:
A() {
cout << "construct" << endl;
}
virtual inline void print() {
cout << "print" << endl;
}
};

因为内联函数的建议编译器没有采纳,就和没有加 inline 关键字效果一样。

5. virtual 不能修饰友元函数。

友元函数是可以访问类内私有成员的非成员函数,是定义在类外的普通函数。它不属于某个类,所以自然无法被 virtual 修饰。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
using namespace std;

class A {
public:
A() {
cout << "construct" << endl;
}
virtual friend void print();
};

void print() {
cout << "print" << endl;
}

int main() {
A* a = new A();
print();
}

编译报错:

1
error: virtual functions cannot be friends

vector 是 cpp 中常用的一种容器,是一种可以动态扩容的数组。他在每次插入的时候,当它的 capacity 不满足要求的时候,会重新申请一块两倍的内存,把之前的内容复制过来。在这种情况下,复制的时间复杂度为 O(n)O(n)。如果不涉及扩容,那么它只修改一个元素的内存,时间复杂度为 O(1)O(1)。

也就是说,它扩容的时机为,容器大小达到以下时:

1 2 4 8 16 32 64 128 256 512 1024 2048 4096 8192 16384 32768 65536 …

则每次插入代价设为 cic_i,则

ci={ii为2的幂1othersc_i = \begin{cases} i & i 为 2 的幂\\ 1 & others \\ \end{cases}

那么则有:

∑i=0nci≤n+∑j=0⌊log2n⌋2j≤n+2n≤3n\begin{aligned} \sum_{i = 0}^nc_i &\le n + \sum_{j = 0}^{\lfloor log_2n \rfloor}2^j \\ &\le n + 2n\\ &\le 3n \end{aligned}

因为共有 nn 次插入,所以总时间复杂度为 O(3n)O(3n),均摊下来平均时间复杂度为 O(3)O(3)。

单例模式概念

定义

单例模式 Singleton:该类只能实例化一个对象的设计模式。

分类

  1. 懒汉模式:延时加载,在这个唯一的对象使用时才加载。

    优点

    1. 资源利用率高,使用时才创建,不存在浪费。
    2. 加载类速度快,不需要创捷对象。

    缺点

    1. 存在线程安全问题,需要引入同步机制(锁、信号量)。创建唯一对象不是原子操作,判空也不是原子操作。
    2. 运行获取速度慢,多线程同步带来额外开销。
  2. 饿汉模式:贪婪加载,在类加载时就生成唯一的对象。

    优点

    1. 线程安全的单例模式。因为在加载的时候就创建了,所以不会有竞争。
    2. 获取对象速度快,因为已经创建好了,只需要获取就可以。

    缺点

    1. 存在资源浪费的可能。因为可能只是加载这个类却不使用他。
    2. 类加载速度慢,因为需要创建对象。

设计思想

如何控制,达到只生成一个对象的目的。

  1. 对生成对象进行控制,将构造函数设计为私有。这样类外就不能调用构造函数,达到控制对象生成的目的。
  2. 对拷贝构造函数进行控制,将其设为私有并不实现,或者进行删除(C++11)。

提供对外接口,生成唯一对象。

这个接口需要判断是否只生成了一个对象。

  1. 这个函数的返回值需要返回一个对象,分三种情况。
    1. 返回对象类名,存在临时对象,会发生拷贝,不符合单例模式要求。
    2. 返回对象引用,没有拷贝发生。
    3. 返回对象的指针,没有拷贝发生。
  2. 修饰关键字设置,从调用角度考虑。
    1. 依赖对象来调用,obj.func() 来生成。但是此时对象未生成,所以不符合要求。
    2. 设计为静态函数,不依赖对象调用。通过类作用域来调用 ClassName::func() 来生成。
  3. 有一个标识,来标识唯一的对象。
    1. 为了防止这个接口被调用很多次,定义一个唯一标识对象的指针,这个指针不依赖于对象。所以还是定义为静态变量,类外初始化为 nullptr。
    2. 指针为空,那就说明对象还没有生成。如果不为空,就说明已经生成了。

懒汉模式代码

单线程版本

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;

class LazySingleton {
public:
static LazySingleton *GetInstance() {
if (instance_ == nullptr) {
instance_ = new LazySingleton();
}
return instance_;
}
private:
LazySingleton() {
cout << "create singleton object:" << this << endl;
}
LazySingleton(const LazySingleton& s) = delete;
static LazySingleton *instance_;
};
LazySingleton* LazySingleton::instance_ = nullptr;
int main() {
LazySingleton *obj1 = LazySingleton::GetInstance();
LazySingleton *obj2 = LazySingleton::GetInstance();
LazySingleton *obj3 = LazySingleton::GetInstance();
cout << "obj1:" << obj1 << endl;
cout << "obj2:" << obj2 << endl;
cout << "obj3:" << obj3 << endl;
return 0;
}

输出:

1
2
3
4
5
@└────> # ./a.out 
create singleton object:0x1861eb0
obj1:0x1861eb0
obj2:0x1861eb0
obj3:0x1861eb0

这种懒汉模式的单例,在单线程下是没问题的,但是多线程下有竞争就会出现问题。比如两个线程都进行完了 instance_ 的判断,并且都为空,那么他们都会调用那个构造函数。

多线程版本

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

class LazySingleton {
public:
static LazySingleton *GetInstance() {
m_.lock();
if (instance_ == nullptr) {
instance_ = new LazySingleton();
}
m_.unlock();
return instance_;
}
private:
LazySingleton() {
cout << "create singleton object:" << this << endl;
}
LazySingleton(const LazySingleton& s) = delete;
static LazySingleton *instance_;
static mutex m_;
};
LazySingleton* LazySingleton::instance_ = nullptr;
mutex LazySingleton::m_;
int main() {
thread t1([]() {
cout << "thread id:" << this_thread::get_id() << " instance pointer:" << LazySingleton::GetInstance() << endl;
});
thread t2([]() {
cout << "thread id:" << this_thread::get_id() << " instance pointer:" << LazySingleton::GetInstance() << endl;
});
thread t3([]() {
cout << "thread id:" << this_thread::get_id() << " instance pointer:" << LazySingleton::GetInstance() << endl;
});
t1.join();
t2.join();
t3.join();
return 0;
}

结果:

1
2
3
4
5
@└────> # ./a.out 
thread id:139862718146304 instance pointer:create singleton object:0x7f344c000f70
0x7f344c000f70
thread id:139862709753600 instance pointer:0x7f344c000f70
thread id:139862701360896 instance pointer:0x7f344c000f70

加锁,保证一个线程在对临界资源进行操作时不能被其他线程打断。用互斥锁即可,但是如果每次调用都需要进行加锁解锁,在第一个对象已经生成之后,在读的层面不需要进行加锁了。加锁解锁会导致效率低下。

可以把上面的加锁部分套在一个 if 判断里,这样下个线程进来的时候就不用试图加锁了。

改进版代码:

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

class LazySingleton {
public:
static LazySingleton *GetInstance() {
if (instance_ == nullptr) { // 套起来
m_.lock();
if (instance_ == nullptr) {
instance_ = new LazySingleton();
}
m_.unlock();
}
return instance_;
}
private:
LazySingleton() {
cout << "create singleton object:" << this << endl;
}
LazySingleton(const LazySingleton& s) = delete;
static LazySingleton *instance_;
static mutex m_;
};
LazySingleton* LazySingleton::instance_ = nullptr;
mutex LazySingleton::m_;
int main() {
thread t1([]() {
cout << "thread id:" << this_thread::get_id() << " instance pointer:" << LazySingleton::GetInstance() << endl;
});
thread t2([]() {
cout << "thread id:" << this_thread::get_id() << " instance pointer:" << LazySingleton::GetInstance() << endl;
});
thread t3([]() {
cout << "thread id:" << this_thread::get_id() << " instance pointer:" << LazySingleton::GetInstance() << endl;
});
t1.join();
t2.join();
t3.join();
return 0;
}

结果和上面是一样的,这里就不展示了。

C++11 的多线程版本

在 C++11 中,引入了 std::call_once,可以更便捷的实现上述功能。如需使用,只需要 #include 即可,简单来说 std:call_once 的作用,确保函数或代码片段在多线程环境下,只需要执行一次,常用的场景如 Init() 操作或一些系统参数的获取等。

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

class LazySingleton {
public:
static LazySingleton *GetInstance() {
std::call_once(instnace_flag_, []() {
instance_ = new LazySingleton();
});
return instance_;
}
private:
LazySingleton() {
cout << "create singleton object:" << this << endl;
}
LazySingleton(const LazySingleton& s) = delete;
static LazySingleton *instance_;
static once_flag instnace_flag_;
};
LazySingleton* LazySingleton::instance_ = nullptr;
once_flag LazySingleton::instnace_flag_;
int main() {
thread t1([]() {
cout << "thread id:" << this_thread::get_id() << " instance pointer:" << LazySingleton::GetInstance() << endl;
});
thread t2([]() {
cout << "thread id:" << this_thread::get_id() << " instance pointer:" << LazySingleton::GetInstance() << endl;
});
thread t3([]() {
cout << "thread id:" << this_thread::get_id() << " instance pointer:" << LazySingleton::GetInstance() << endl;
});
t1.join();
t2.join();
t3.join();
return 0;
}

结果:

1
2
3
4
5
@└────> # ./a.out 
thread id:140655525984000 instance pointer:create singleton object:0x7fece4000f70
0x7fece4000f70
thread id:140655517591296 instance pointer:0x7fece4000f70
thread id:140655509198592 instance pointer:0x7fece4000f70

饿汉模式代码

如果我们在线程开启前,已经将唯一的对象生成,就不会出现线程不安全的问题。即唯一的对象在main函数调用前已经生成,就不会产生线程安全问题。所以:

  1. 在未进入 main 函数时,就完成唯一对象的生成,那么就是在程序加载时已经完成,存放在 .data段 的数据有这样的特点,所以我们可以直接给静态成员变量生成对象。
  2. 接口只用返回生成好的对象指针即可。
    代码:
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
#include <iostream>
#include <thread>
using namespace std;

class HungerSingleton {
public:
static HungerSingleton *GetInstance() {
return instance_;
}
private:
HungerSingleton() {
cout << "create singleton object:" << this << endl;
}
HungerSingleton(const HungerSingleton& s) = delete;
static HungerSingleton *instance_;
};
HungerSingleton* HungerSingleton::instance_ = new HungerSingleton();

int main() {
thread t1([]() {
cout << "thread id:" << this_thread::get_id() << " instance pointer:" << HungerSingleton::GetInstance() << endl;
});
thread t2([]() {
cout << "thread id:" << this_thread::get_id() << " instance pointer:" << HungerSingleton::GetInstance() << endl;
});
thread t3([]() {
cout << "thread id:" << this_thread::get_id() << " instance pointer:" << HungerSingleton::GetInstance() << endl;
});
t1.join();
t2.join();
t3.join();
return 0;
}
1
2
3
4
5
@└────> # ./a.out 
create singleton object:0x2343eb0
thread id:140504409663232 instance pointer:0x2343eb0
thread id:140504401270528 instance pointer:0x2343eb0
thread id:140504392877824 instance pointer:0x2343eb0

什么是多路复用

操作系统在处理 I/O 的时候,分为两个阶段:

  1. 等待数据到达内核空间。
  2. 等待数据从内核空间拷贝到用户空间。

在 Linux 操作系统中,多路复用的方式有三种。分别是 select,poll,和 epoll。I/O 多路复用为,只通过一种机制,可以监视多个描述符,一旦某个描述符就绪(读就绪/写就绪),能够通知程序进行相应的读写操作。

但 select,poll,epoll 本质上都是同步 I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步 I/O 则无需自己负责进行读写,异步 I/O 的实现会负责把数据从内核拷贝到用户空间。

select

单个进程就可以同时处理多个网络连接的 I/O 请求(同时阻塞多个 I/O 操作)。

基本原理就是:

  1. 程序调用 select,然后整个程序就阻塞状态。
  2. 这时候,内核就会轮询检查所有 select 负责的文件描述符集合 fd_set(一般上限为 1024 个),当找到其中那个的数据准备好了的文件描述符返回给 select。
  3. select 进行系统调用,将数据从内核复制到进程缓冲区(用户空间)。

1

select的缺点:

  1. 每次调用 select,都需要把 fd_set 从用户态拷贝到内核态,这个开销在 fd 很多时会很大。
  2. 同时每次调用 select 都需要在内核遍历传递进来的所有 fd,这个开销在 fd 很多时也很大
  3. select 支持的文件描述符数量太小了,默认是 1024

poll

poll的原理与select非常相似,差别如下:

  1. 描述fd集合的方式不同,poll 使用 pollfd 结构而不是 fd_set 结构,是链式的,所以没有最大连接数的限制。
  2. poll 有一个特点是水平触发,也就是通知程序 fd 就绪后,这次没有被处理,那么下次 poll 的时候会再次通知同样的 fd 已经就绪。

poll 解决了 fd_set 有上限的情况,并且每次读就绪描述符集合时不用保存之前的状态。

epoll

epoll 提供了三个函数:

1
2
3
4
5
6
7
8
int epoll_create(int size);
// 建立一個 epoll 对象,并传回它的id

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 事件注册函数,将需要监听的事件和需要监听的fd交给epoll对象

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
// 等待注册的事件被触发或者timeout发生

epoll解决的问题:

  1. epoll没有fd数量限制

epoll没有这个限制,我们知道每个 epoll 监听一个 fd,所以最大数量与能打开的 fd 数量有关,一个 G 的内存的机器上,能打开10万个左右

  1. epoll 不需要每次都从用户空间将 fd_set 复制到内核

epoll 在用 epoll_ctl 函数进行事件注册的时候,已经将 fd 复制到内核中,所以不需要每次都重新复制一次

  1. select 和 poll 都是主动轮询机制,需要遍历每一个人 fd;而 epoll 是被动触发方式。

epoll_ctl 给 fd 注册了相应事件的时候,我们为每一个 fd 指定了一个回调函数,当数据准备好之后,就会把就绪的 fd 加入一个就绪的队列中,epoll_wait 的工作方式实际上就是在这个就绪队列中查看有没有就绪的 fd,如果有,就唤醒就绪队列上的等待者,然后调用回调函数。

虽然 epoll, poll, select 都需要查看是否有 fd 就绪,但是 epoll 之所以是被动触发,就在于它只要去查找就绪队列中有没有 fd,就绪的 fd 是主动加到队列中,epoll 不需要一个个轮询确认。

换一句话讲,就是 select 和 poll 只能通知有 fd 已经就绪了,但不能知道究竟是哪个 fd 就绪,所以 select 和 poll 就要去主动轮询一遍所有监听的 fd 才能找到就绪的 fd。而 epoll 则是不但可以知道有 fd 可以就绪,而且还具体可以知道就绪 fd 的编号,所以直接找到就可以,不用轮询。

注意,epoll 只有 Linux 上面有。

callgrind

callgrind 是 valgrind 套件中的一个工具,使用方式为

1
valgrind --tool=callgrind ./your_program your_program_args1 your_program_args2

运行之后,可以生成一份 callgrind.out.pid 的文件,在当前目录下。这份文件记录了运行时的一些数据,以及函数调用关系。

kcachegrind

kcachegrind 可以分析上述产生的 callgrind.out.pid 的文件,并且点击下方 call graph 可以看到调用关系图,帮助我们分析代码结构。

点击下载windows版kcachegrind

示例

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
using namespace std;

int accumulate(int begin, int end) {
int result = 0;
for (int i = begin; i < end; i++) {
result += i;
}
}

int accumulate0_100000() {
return accumulate(0, 100000);
}

int accumulate0_200000() {
return accumulate(0, 200000);
}

int main() {
accumulate0_100000();
accumulate0_200000();
return 0;
}
1
valgrind --tool=callgrind --dump-instr=yes --trace-jump=yes ./a.out

kcachegrind分析

生成文件后,使用 kcachegrind 工具打开这个分析文件。

1

可以从图中看到函数执行时间,百分比等功能。

gprof2dot分析

使用 gprof2dot 工具,需要安装 python 和 graphviz。

1
2
yum install graphviz
pip3 install gprof2dot

之后使用上述工具对其进行分析,生成调用图。

1
2
gprof2dot -f callgrind -n0 -e0 ./callgrind.out.2328 > callgrind.2328.dot
dot callgrind.2328.dot -Tsvg -o callgrind.2328.dot.svg

其中 -f 指定输出格式为 callgrin d格式,-nX 指定生成的文件忽略小于 X 个 node 的函数,例如 -n10,代表函数节点小于 10 个的,在最终文件中不生成其相关信息,-eX 代表边缘阈值,与 -n 类似。

四种转换介绍

C风格转换:转换的含义是通过改变一个变量的类型为别的类型从而改变该变量的表示方式。为了类型转换一个简单对象为另一个对象,会使用传统的类型转换操作符。比如,为了转换一个类型为 double 的浮点数的指针到整型:

1
2
3
4
int i;
double d;
i = (int)d;
i = int(d);

对于具有标准定义转换的简单类型而言很好。但是这样的转换符不能随意用于类(class)和类的指针。标准定义了四个新的转换符:reinterpret_cast, static_cast, dynamic_cast 和 const_cast,目的在于控制类之间的类型转换。

reinterpret_cast

reinterpret_cast 转换一个指针为其它类型的指针。它也允许从一个指针转换为整数类型。反之亦然。这个操作符能够在非相关的类型之间转换。操作结果只是简单的从一个指针到别的指针的值的二进制拷贝。在类型之间指向的内容不做任何类型的检查和转换。

代码:

1
2
3
4
class A {};
class B {};
A *a = new A;
B *b = reinterpret_cast<B *>(a);

reinterpret_cast 就像传统的类型转换一样对待所有指针的类型转换。

static_cast

static_cast 允许执行任意的隐式转换和相反转换动作。(相关类之间转换)

应用到类的指针上,意思是说它允许子类类型的指针转换为父类类型的指针(这是一个有效的隐式转换),同时,也能够执行相反动作:转换父类为它的子类。

在这最后例子里,被转换的父类没有被检查是否与目的类型相一致。

代码:

1
2
3
4
class Base {};
class Derived : public Base {};
Base *a = new Base;
Derived *b = static_cast<Derived *>(a);

static_cast 除了操作类型指针,也能用于执行类型定义的显式的转换,以及基础类型之间的标准转换:

1
2
double d = 3.14159265;
int i = static_cast<int>(d);

如果转换的类型之间没有关系的话,会报编译错误:

1
2
3
4
5
@└────> # g++ test.cc 
test.cc: In function ‘int main()’:
test.cc:12:41: error: invalid static_cast from type ‘Base*’ to type ‘Derived*’
Derived *b = static_cast<Derived *>(a);
^

如果子类中有基类没有的对象,那么转换后使用则不安全。

dynamic_cast

dynamic_cast 只用于对象的指针和引用。当用于多态类型时,它允许任意的隐式类型转换以及相反过程。不过,与 static_cast 不同,在自上而下的转换过程中, dynamic_cast 会检查操作是否有效。也就是说,它会检查转换是否会返回一个被请求的有效的完整对象。
检测在运行时进行。如果被转换的指针不是一个被请求的有效完整的对象指针,返回值为NULL.
代码:

1
2
3
4
5
6
7
8
class Base { virtual dummy() {} };
class Derived : public Base {};

Base* b1 = new Derived;
Base* b2 = new Base;

Derived* d1 = dynamic_cast<Derived *>(b1); // 成功
Derived* d2 = dynamic_cast<Derived *>(b2); // 失败,返回NULL

用于安全向下转型,成本非常高昂。

const_cast

这个转换类型操纵传递对象的 const 属性,或者是设置或者是移除:
代码:

1
2
3
class C {};
const C *a = new C;
C *b = const_cast<C *>(a);

其它三种操作符是不能修改一个对象的常量性的。
const_cast 在调用第三方函数中的使用:
在使用第三方库或API时,它们只提供了非 const 类型的参数的函数,但我们只有 const 类型的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
using namespace std;
int third_lib_fun(int *ptr) {
*ptr = *ptr + 10;
return (*ptr);
}
int main(void) {
int val = 10;
const int *ptr = &val;
int *ptr1 = const_cast<int *>(ptr);
third_lib_fun(ptr1);
cout << val;
return 0;
}

输出结果:

1
20

我们在使用第三方库和 API 的时候,我们只能调用,看不到其具体的实现,为了能够调用成功,需要使用 const_cast 来去除 *ptr 的 const 属性。

锁

锁是用来防止不同线程访问同一个共享资源时发生数据竞争风险,保证访问的先后顺序而达到数据的一致性访问。本文介绍的 lock_guard 和 unique_lock,都是对 std::mutex 进行封装,实现 RAII 的效果。这两种锁本质差不多,而 unique_lock 的功能比 lock_guard 多一些。

lock_guard

lock_guard 用来管理一个 std::mutext 类型的对象,定义一个 lock_guard 对象同时调用构造函数,对 std::mutex 进行上锁,退出作用域时析构,从而进行解锁。

1
std::lock_guard<std::mutex> lock(mutex_name);

特点如下:

  1. 创建即加锁,退出作用域自动解锁,避免忘记解锁。
  2. 无需手工解锁。不能中途解锁。
  3. 无法复制。
  4. 并不管理 std::mutex 的生命周期,如果在作用域内的时候 std::mutex 被释放了,那就会出现空指针错误。

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <thread>
#include <mutex>
#include <iostream>

int g_i = 0;
std::mutex g_i_mutex;

void safe_increment() {
const std::lock_guard<std::mutex> lock(g_i_mutex);
++g_i;
std::cout << std::this_thread::get_id() << ": " << g_i << std::endl;
}

int main() {
std::cout << "start: " << g_i << '\n';
std::thread t1(safe_increment);
std::thread t2(safe_increment);
t1.join();
t2.join();
std::cout << "end: " << g_i << '\n';
}

输出:

1
2
3
4
5
@└────> # ./a.out 
start: 0
140365627524864: 1
140365619132160: 2
end: 2

unique_lock

虽然 lock_guard 挺好用的,但是有个很大的缺陷,在定义 lock_guard 的地方会调用构造函数加锁,在离开定义域的话 lock_guard 就会被销毁,调用析构函数解锁。这就产生了一个问题,如果这个定义域范围很大的话,那么锁的粒度就很大,很大程序上会影响效率。
所以为了解决 lock_guard 锁的粒度过大的问题,unique_lock 就出现了。

方法 说明 详细说明
explicit unique_lock(mutex_type& m); 加锁 新创建的 unique_lock 对象,管理 Mutex 对象 m,并尝试调用 m.lock() 对 Mutex 对象进行上锁,如果此时另外某个 unique_lock 对象已经管理了该 Mutex 对象 m,则当前线程将会被阻塞。
unique_lock(mutex_type& m, try_to_lock_t tag); 尝试加锁 try-locking 初始化新创建的 unique_lock 对象,管理 Mutex 对象 m,并尝试调用 m.try_lock() 对 Mutex 对象进行上锁,但如果上锁不成功,并不会阻塞当前线程。
unique_lock(mutex_type& m, defer_lock_t tag) noexcept; 延迟加锁 初始化新创建的 unique_lock 对象,管理 Mutex 对象 m,但是在初始化的时候并不锁住 Mutex 对象。 m 应该是一个没有当前线程锁住的 Mutex 对象。
unique_lock(mutex_type& m, adopt_lock_t tag); 递归加锁 初始化新创建的 unique_lock 对象管理 Mutex 对象 m, m 应该是一个已经被当前线程锁住的 Mutex 对象。(并且当前新创建的 unique_lock 对象拥有对锁(Lock)的所有权)。

特点如下:

  1. 创建时可以不锁定(通过指定第二个参数为 std::defer_lock),而在需要时再锁定。
  2. 可以随时加锁解锁(调用 lock 或者 unlock 成员函数)。
  3. 作用域规则同 lock_grard,析构的时候会判断当前锁的状态来决定是否解锁,如果当前状态已经是解锁状态了,那么就不会再次解锁,而如果当前状态是加锁状态,就会自动调用unique.unlock()来解锁。
  4. 不可复制,可移动
  5. 条件变量需要该类型的锁作为参数(此时必须使用 unique_lock)
1
2
condition_variable cv;
cv.wait(unique_lock); // 会做两件事1:使线程进入等待状态 2:unique_lock.unlock 把mtx给释放掉

unique_lock 示例:

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
#include <mutex>
#include <thread>
#include <chrono>
#include <iostream>

struct Box {
explicit Box(int num) : num_things{num} {}
int num_things;
std::mutex m;
};

void transfer(Box &from, Box &to, int num) {
std::unique_lock<std::mutex> lock1(from.m, std::defer_lock);
std::unique_lock<std::mutex> lock2(to.m, std::defer_lock);
std::lock(lock1, lock2);
from.num_things -= num;
to.num_things += num;
}

int main() {
Box box1(100);
Box box2(50);
std::thread t1(transfer, std::ref(box1), std::ref(box2), 10);
std::thread t2(transfer, std::ref(box2), std::ref(box1), 5);
t1.join();
t2.join();
std::cout << "box1:" << box1.num_things << std::endl;
std::cout << "box2:" << box2.num_things << std::endl;
}
1
2
3
@└────> # ./a.out 
box1:95
box2:55

总结

unique_lock 内部会维护一个锁的状态,所以在效率上肯定会比 lock_guard 慢。所以在能使用 lock_guard 的情况下尽量优先使用 lock_guard。

区别

先来看一段代码:

1
2
3
struct A;
std::shared_ptr<A> p1 = std::make_shared<A>();
std::shared_ptr<A> p2(new A);

他俩是有区别的,使用 make_shared 的方式只会申请一次内存,而使用 new 的方式要申请两次。
shared_ptr 内部有一个计数器,会维护指向当前对象的指针个数。

  1. 在使用 new 的方式申请的时候,先用 new 申请指向对象的内存,再申请 shared_ptr 中维护计数部分的内存,所以是两次申请。
  2. 用make_shared 的方式,是把指向对象部分和维护计数部分合在一起,一起申请,所以是一次申请。

那这两种有什么影响呢?

影响

1
2
3
4
void func(std::shared_ptr<Lhs> &lhs, std::shared_ptr<Rhs> &rhs) {
// do something
}
func(std::shared_ptr<Lhs>(new Lhs()), std::shared_ptr<Rhs>(new Rhs()));

C++ 允许在做参数运算的时候 打乱顺序,所以执行顺序可能为这样:

  1. new Lhs();
  2. new Rhs();
  3. shared_ptr 构造
  4. shared_ptr 构造

假设在步骤 2 中,因为内存不足抛出了异常。之后我们就丢失了 1 中申请的内存,因为 shared_ptr 还没有指向它,也就没有接受管理。有一种解决方案:

1
2
3
auto lhs = std::shared_ptr<Lhs>(new Lhs());
auto rhs = std::shared_ptr<Rhs>(new Rhs());
func(lhs, rhs);

这样不够优雅,所以诞生了 make_shared:

1
func(std::make_shared<Lhs>(), std::make_shared<Rhs>());

make_shared 的缺点

因为 make_shared 是申请(控制块 + 数据块)一整块内存,所以也是一起释放。weak_ptr 可以无限期地保持控制块的存活。
使用 new 申请的 shared_ptr 内存分布
使用 new 申请的 shared_ptr 内存分布
使用 make_shared 申请的 shared_ptr 内存分布
使用 make_shared 申请的 shared_ptr 内存分布
为什么 weak_ptr 的实例会使控制块保持活动状态?
必须有一种方法让 weak_ptr 来确定托管对象是否仍然有效(例如for lock)。他们通过检查 shared_ptr 拥有托管对象的数量来实现这一点,该托管对象存储在控制块中。结果是控制块处于活动状态,直到 shared_ptr 计数和 weak_ptr 计数都达到 00,才会释放整个控制块和数据块。