你真的理解 shared_ptr 吗

C++ 的 shared_ptr 是什么呢?你真的理解吗?让我们试着回答以下问题。

  1. 下面的几行代码是否正确,是否存在差异?
    1
    2
    3
    std::shared_ptr<int> p1;
    std::shared_ptr<int> p2(new int(0));
    std::shared_ptr<int> p3 = std::make_shared<int>(0);
  2. void func(Class *A); 的参数可以是 std::shared_ptr<A> 吗?
  3. shared_ptr<T> 是线程安全的吗?
  4. 类成员函数如何返回指向自身的 shared_ptr
  5. 如何自定义 shared_ptr 的释放行为?

问题解答

1. shared_ptr(new T) 与 make_shared()

在 C++ 中,shared_ptr 内部有一个指向动态分配对象的指针和一个指向控制块的指针。指向同一对象的共享指针共享这两个块。

1
2
3
std::shared_ptr<int> p1;  // 空对象,数据指针指向空,分配控制块内存,并计数 0
std::shared_ptr<int> p2(new int(0)); // 数据指针指向 `new int(0)` 的内存地址,分配控制块内存,计数 1
std::shared_ptr<int> p3 = std::make_shared<int>(0); // 分配一次内存(数据块+计数块), 数据指针指向数据块,控制块计数为1

make_shared 只分配一次内存,效率更高,且能够有效避免内存碎片的产生。

2. 获取裸指针

std::shared_ptr<T> 构造函数中传入了一个对象指针。它同时提供了一个 get() 方法用来返回裸指针,但是在使用裸指针时要注意其生命周期。

3. shared_ptr 是线程安全的吗?

shared_ptr 的引用计数是原子操作,因此是线程安全的。但是 shared_ptr 指向的对象以及 shared_ptr 自身并不是线程安全的。在多线程中修改 shared_ptr 指向的数据或修改 shared_ptr 自身的指向可能对其他线程的 shared_ptr 造成破坏。

4. enable_shared_from_this

考虑下面的代码会有什么问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <memory>

class A {
public:
std::shared_ptr<A> getPtr(){
return std::shared_ptr<A>(this);
}
};

int main(int argc, char **argv){
auto p1 = std::shared_ptr<A>(new A());
auto p2 = p1->getPtr();
}

运行这段代码会发现 A 会被析构两次。因为 std::shared_ptr<A>(this) 会增加一次引用计数,而返回的 shared_ptr 和之前的 shared_ptr 是没有关联的,所以会各自析构一次。下面的代码有同样的问题。

1
2
3
4
5
6
void test(){
A *p = new A();
auto p1 = std::shared_ptr<A>(p);
auto p2 = std::shared_ptr<A>(p);
// p1 和 p2 各自独立
}

enable_shared_from_this 就是为了解决通过对象获取自身的智能指针的问题。

1
2
3
4
5
6
7
8
#include <memory>

class A std::enable_shared_from_this<A> {};

int main(int argc, char **argv){
auto p1 = std::shared_ptr<A>(new A());
auto p2 = p1->shared_from_this(); // 同 auto p2 = std::weak_ptr<A>(p1);
}

enable_shared_from_this 内部也是通过 weak_ptr 的原理来实现的。

5. 自定义 shared_ptr 删除器

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <memory>

int main() {
auto customDeleter = [](int* p) {
std::cout << "Custom deleting: " << *p << std::endl;
delete p;
};

std::shared_ptr<int> sharedPtr(new int(42), customDeleter);

// 使用 sharedPtr,最后会调用 customDeleter 进行释放
return 0;
}

浅析 lambda 表达式

lambda 中的函数调用和普通函数调用有什么区别?

这是一个普通的函数调用

1
2
3
4
5
6
7
8
int add(int x, int y){
return x + y;
}

void invoke(){
// 普通函数调用
add(11,17);
}

这是一个 lambda 函数调用

1
2
3
4
5
6
7
void invoke(){
auto add = [](int x, int y){
return x + y;
};
//lambda 函数的调用
add(11,17);
}

下面一起来解开他们的头盖骨,看看下面到底是什么东西。

汇编角度

他们的汇编代码如下, 可以 点击此处查看

lambda 函数与普通函数汇编比较

查看他们的汇编代码,可以发现 lambda 表达式最后也会被编译成一个函数,调用方式也是常见的函数栈调用,从这个角度来看,lambda 就是一个匿名函数。

但仔细观察,他们还是稍微还是有一点差异,如下

lambda 函数与普通函数汇编差异

至于为什么会有这样的差异,接下来会讲。

C++ 角度

从 C++ 的角度,解释 lambda 到底是什么,先看下面的示例,下面的代码与上面的 lambda 函数调用代码在汇编代码中等价,点击查看汇编代码

1
2
3
4
5
6
7
8
9
10
11
12
void invoke()
{
class __invoke_add{
public:
inline int operator()(int x, int y) const {
return x + y;
}
};

__invoke_add add;
add.operator()(11, 17);
}

注意仅仅是在该示例中等价,实际上lambda函数在编译时还会有一个类型转换操作符,用于将lambda对象转换为一个函数指针,本例中没有涉及,故省略。

简单一句话就是编译器会将 lambda 函数编译成一个匿名类, 重载它的 operator () 方法。

回头来解释上面汇编的几处差异,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
invoke()::{lambda(int, int)#1}::operator()(int, int) const:
pushq %rbp
movq %rsp, %rbp
movq %rdi, -8(%rbp) ; 这里是匿名类对象的地址,也就是 C++ 中的 this 指针。
movl %esi, -12(%rbp)
movl %edx, -16(%rbp)
movl -12(%rbp), %edx
movl -16(%rbp), %eax
addl %edx, %eax
popq %rbp
ret
invoke():
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp ; 因为声明了一个匿名类对象,所以要开辟一个新的栈帧
leaq -1(%rbp), %rax ; 没有任何数据成员的类对象,其大小为 1, rax里放到是类对象的地址
movl $17, %edx
movl $11, %esi
movq %rax, %rdi ; 将匿名类对象的地址放入 rdi 寄存器
call invoke()::{lambda(int, int)#1}::operator()(int, int) const
nop
leave
ret

lambda参数捕获

拿捕获的变量为 int base=5; 举例。

= 捕获相当于匿名类内部有个 int base; 私有变量。构造函数是 __invoke_add(int &_base): base(_base) {};

& 捕获相当于匿名类内部有个 int &base; 私有变量。构造函数同上。

计算机底层技术名词概览

CPU

晶体管

CPU 中的晶体管有 3 个连接端,其中一个输入端的电平高低能决定另外两端是否能导通。有两种型号的晶体管,一个高电平连通,一个低电平连通。

逻辑门

利用多个晶体管经过各种搭配就可以实现各种逻辑运算 (与,或,非,与非,或非,异或),这些门电路称为逻辑门。

加法器

门电路组合起来可以做加法器

算数逻辑单元 ALU

加法器可以经过扩展和修改,就有了乘法器,减法器,除法器。把它们打包在一起,加一些控制电路,就既可以做逻辑运算,也可以做算术运算。就是算术逻辑单元,是 CPU 中非常核心的部件。

指令集

CPU 中,不同的指令用不同的机器码表示,比如 0000 0001 表示加法,0000 0002 表示减法,所有的指令构成了指令集。对各种指令集进行配列组合已完成特定的功能的过程就是编程。

指令集又分为两个流派,一是精简指令集,指令长度固定,一个指令只完成一个基本操作。 一个是复杂指令集,一个指令可以完成一些复杂操作。x86 属于复杂指令集。

寄存器

CPU 工作过程中需要存储一些数据,比如要执行的指令地址,存储要计算的数据等,如果都在内存中读取,速度会很慢,所以 CPU 内部有一些电路用来存储数据叫做寄存器。

汇编语言

用一些助记符号代替机器码,比如 ADD 代表机器码 0000 0001,用助记符号来编程的语言就是汇编语言。

高级语言

助记符与 CPU 指令集相关联,人们有发明了高级语言,可以用接近人类语言的方式描述程序的功能,比如 int sum = a + b; 然后又发明了编译器,可以将高级语言变成机器指令,这个过程叫做编译。

指令执行过程

读取指令 -> 指令译码 -> 指令执行 -> 数据回写

流水线

指令执行过程分为了几个步骤,这几个步骤就可以用不同的电路来做,第一条指令执行到指令译码的时候,读取指令的电路已经开始读取第二条指令了,类似于工厂流水线。

流水线冒险

结构冒险,出现硬件资源竞争;
数据冒险,后面的指令等待前面的指令完成数据读写;
控制冒险,后面的指令需要根据前面的执行结果来决定下一步去哪执行;

缓存

读取某个数据的时候,其周边的数据也大概率会被访问,来回读取太费劲,CPU 内部就增加了一些电路用来保存一些内存的数据,这个技术叫做缓存

缓存行

CPU 通常以 64 字节大小为单元管理缓存,这个单元叫做缓存行

指令缓存和数据缓存

为了缓解流水线结构冒险问题,将缓存分为两块,分别存储数据和指令。

L1/L2/L3 缓存

之前的数据缓存和指令缓存那一层叫 L1 缓存,然后又增加了 L2 缓存。L1 和 L2 都是在一个 CPU 核心里。之后又加了一块较大的缓存,供所有 CPU 核心公用,称为 L3 缓存。

缓存失效

L2 的缓存是在各自的 CPU 核心里的,如果多个核心读取的是同一个缓存行的数据,会出现不一致的问题,需要一定的策略来保证缓存的一致性。

乱序执行

指令执行的时候,一些指令没有前后依赖关系,可以一起执行。之后再将执行结果进行重新排列。指令执行的过程变成了 指令分发到不同的执行单元 -> 多个执行单位乱序执行 -> 乱序执行结果重新排序

静态预测

为了缓解流水线控制冒险问题,先预测分支结果并执行某一分支,如果预测正确就将预测的执行结果拿来用,如果不正确就丢弃,执行另一分支。

动态预测

一些分支两边的概率并不是 50% 对 50%,统计最近多次的跳转结果,根据其最近跳转次数来进行预测,这个技术也叫分支预测。

SIMD

单指令多数据流 (Single Instruction Multiple Data)。 扩大一些寄存器的长度,比如 128 位,这样可以存储 4 个 32 位的整数。新增一些指令,可以并行计算这些数据。

超线程

每个 CPU 里都有一些不同的电路,比如整数运算电路,浮点型运算电路。不同指令用到的电路可能不一样,为了充分利用闲置的电路。新增加一套寄存器,内部协调好两套寄存器使用的缓存和 ALU。比如可以同时执行整数运算和浮点运算,对外看起来像是两个线程。 这个新增寄存器,复用缓存和计算资源的技术叫做超线程。

虚拟内存

程序访问的内存地址都不是真实的内存地址,访问真实地址内存的时候需要由一个 MMU (内存管理单元) 将地址映射到真实的内存。内存是按照页来管理的,通常一页为 4kb。

分页交换

系统内存资源有限,操作系统会将长时间不用的内存换到硬盘上,并做个标记,如果之后谁访问该内存,就会触发一个页错误中断,操作系统再去把那个页换回来。

虚拟地址翻译

虚拟内存一般是多级页表,虚拟地址到真实地址需要计算,这个过程叫虚拟地址翻译。

地址翻译缓存

和缓存行类似,翻译过的地址,接下来一段时间大概率还会被用到,缓存翻译地址能够减少翻译所做计算。

GPU 和 CPU

GPU 运算单元比较多,可以进行数据的批量计算,类似 CPU 的 SIMD。

操作系统

多程序并行

CPU 只有一个,由 CPU 控制程序控制许多程序在排队运行,如果某个程序在等待其他设备输入输出,CPU 就闲置了,可以让其他程序来运行

时钟中断

如果有程序在执行死循环,那么 CPU 控制程序也拿不到 CPU 控制权。为了解决这个问题,发明了一个 “中断” 技术,CPU 收到中断信号就停下来执行 CPU 控制程序。
为了能让 CPU 控制程序及时获取控制权,人们搞了一个中断源,周期性的发送中断信息,叫做时钟中断。

时间分片

时钟中断间隔就是时间分片,每个程序只能执行一小段时间。

状态

有些程序在 sleep 状态,有些程序在等待状态,这样也会分配时间片,但是时间片到了,它们什么也不做白白浪费时间。人们又把程序划分为不同的状态,只有准备就绪的程序才会分到时间片。

状态有创建,就绪,执行,阻塞,终止…

优先级

有些程序对实时性要求较高,人们有搞了优先级队列,如果有高优先级的程序出现,即使低优先级的程序时间片没用完,也会被剥夺执行机会。

进程地址空间

每个进程看到的地址空间都是虚拟的。访问虚拟地址的时候,MMU 会把他映射到真实物理地址。

进程和线程

一开始进程只有一个执行流,想要并发,就只能创建多个进程。但是进程间通信不是很方便。于是工程师们就琢磨一个线程里搞多个执行流,也就是多个线程。

每个线程都有自己的执行上下文和堆栈,互不影响。最重要的是,这些线程看到的地址空间都是同一个,线程之间通信就方便多了。

现在操作系统的最小调度单位,由进程变成了线程。

系统调用

操作系统吧管理文件,内存,网络,进程,线程,还有管理硬件设备等资源的操纵封装成一个个函数,以供应用程序调用。这些较低层的接口就是系统调用。

系统调用表

系统调用表就是所有的系统调用接口的集合,每个接口有一个系统调用号。

系统调用号 用户空间接口 内核空间接口
0 read sys_read
1 write sys_write
2 open sys_open
3 close sys_close
…… …… ……

在早起的 x86 架构中,系统调用是通过 int 0x80 软中断来进行的。但软中断需要再内存中查找中断表,为提升性能,在 x86-64 中优化了系统调用,并且提供了专门的系统调用指令 syscall

中断描述符表

中断描述符表(IDT,Interrupt Descriptor Table)存储了所有的中断和异常,以及其发生时候的处理程序信息,

信号

每个进程都有一个信号处理表。用户可以注册自己的信号处理函数,当某个信号发生时候,按照编号取出表里的函数地址,调用就可以了。

多线程中的信号处理

每个 task_struct 中的信号等待队列只存放线程自己的信号,另外单独设置队列存放进程的信号,所有的线程共享 。

发送信号的时候有个 group 参数用来决定是投给进程还是投递给线程。比如 kill 是发送给进程的,tkill 是发送给线程的。

进程中的信号有哪个线程进行处理呢,只要线程没有屏蔽信号,它都有机会去处理信号,先到先得。

处理信号的函数表格只有一份,是整个进程共享的。有线程修改的话,所有线程都会有影响。

原子操作

比如 i++, 有 3 个步骤 读数据,加 1,写数据,这三个步骤不能被拆分,中途不能被打断。这样的操作就原子操作。

自旋锁

锁有个状态标记当前有没有被占用,获取锁的函数内部不断循环的尝试获取锁。因为获取锁的时候线程会一直循环的检查状态,所以叫自旋锁。

自旋锁一直阻塞自旋,没有让出 CPU,只适合快速处理的场合。

互斥锁

获取锁的时候,把自己放进锁的等待队列中去,然后就让出 CPU 权限,进入睡眠,等到锁被其他线程释放的时候,再去唤醒等待队列里的线程,进行运行。

条件变量

等待条件变量的线程平时阻塞着,只有满足条件的时候,条件变量才被激活,等待的线程才会被唤醒。

信号量

升级版的互斥锁,可以指定最多允许多少个线程获取锁

chroot 和 pivot_root

通过这两个可以设置一个进程的根目录为指定目录,容器就是通过这两个限制容器进程的活动范围。

命名空间

命名空间相互独立,空间内的进程,用户,网络等,对空间外不可见。命名空间有好几个分别管理不同的资源,比如 PID 管理进程 id;网络命名空间管理网络接口,IP 地址,路由表等;UTS 管理主机名和域名; IPC 管理消息队列,共享内存等;User 管理用户和用户组;

Cgroup

Cgroup 和命名空间类似,可以通过划组限制每个分组的使用资源

进程 fork

fork 进程的时候,会将进程的结构体 task_stuct 拷贝一份,创建一个全新的进程地址空间和堆栈。

写时拷贝

进程地址空间都是虚拟的,新 fork 的进程内存页面和父进程的内存页面映射到了同一个物理内存页面上。只有进程尝试修改内存的时候,内核再重新复制一份。

线程会被 fork 吗

fork 创建子进程的时候只会拷贝当前线程,其他线程不会被拷贝。

进程间通信

信号,信号只能作为通知使用,不能携带数据

套接字,127.0.0.1 只走协议栈,不走网卡。

匿名管道,匿名管道需要有血缘关系的进程才能通信

消息队列

共享内存,将同一块物理内存分别映射到不同进程的地址空间中

I/O 多路复用

select,监听批量描述符,有监听上限,通常是 1024,内核把监听的文件描述符拷贝到内核空间,然后遍历,没有数据的话就进入睡眠。然后在数据可读或者超时的时候被唤醒。

poll,和 select 差不多,只不过是解决了监听描述符的数量限制。

epoll, 有个就绪队列,可读的文件都会进入这个队列中去,只需要处理这个队列就行。epoll 有两个模式,默认的水平触发模式,有数据就就一直触发。边缘模式,只触发一回,需要用户及时取走所有数据。

mmap

像 cpu 访问内存的时候有缓存一样,硬盘里的数据在内存中也有一份缓存。这样读写文件的时候会拷贝两次,从硬盘到内核缓存页,从内核缓存页到用户缓存。

mmap 把内核地址空间的缓存页和用户地址空间的缓存页映射到同样的物理内存中去,这个减少了一次内存拷贝。

协程

在用户空间实现的类似于操作系统的进程调度。

HTTPS: CA证书验证

在当今数字化的世界中,信息的安全性至关重要。无论是在线购物、银行交易还是简单的网页浏览,我们都依赖于安全的通信渠道来保护我们的敏感信息。而在这个保护信息的背后,CA证书扮演着至关重要的角色。

什么是CA证书?

CA(证书颁发机构)是一个可信任的第三方实体,负责验证和签发数字证书。数字证书本质上是一种由CA签名的文件,用于确认在网络通信中各方的身份。

CA证书包含两个主要部分:一是网站信息,二是数字签名。

  • 网站信息通常包括域名、有效期、公钥、证书颁发者等。
  • 数字签名是由网站信息的哈希值通过证书颁发者的私钥加密而来。

这两部分合起来构成了一个数字证书。

证书签发流程大致如下:

CA证书签发流程

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

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

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

1
2
3
4
5
6
7
8
9
// v1/fun.cpp
const char* fun() {
return "fun version 1";
}

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