C++这门语言是一个追求底层的语言, 老实说我为什么选择C++就是因为它够底层, 让我能知道底层大致在干什么。 但是在学习的过程很明显存在不具体的问题, 而且C++语言的语法非常多,
是要做减法的。 基于这个背景, 我积累了一下以自己为中心的C++最佳实践和理解。
关注于高性能或者底层领域, 而不是业务领域, 业务领域有点像用汽车穿过小巷, 浪费时间还效率低。
优点与通用行业领域知识绑定多,没35岁危机, 入门难, 壁垒高。 缺点就是生态太少了, 如果做业务效率太低, 各个C++领域生殖隔离。
C++的特点
C++重要的是不要迷失方向
编译 :代码写完之后, 我们开始进行程序的编译;(感悟, 所有语言转换成可执行文件之后, 都是指令和数据了, 所以可以跨语言调用。)
预处理(预处理如 #include、#define 等预编译指令的展开与解析, 生成 .i 或 .ii 文件)编译(编译器进行词法分析、语法分析、语义分析、中间代码生成、目标代码生成、优化,生成 .s 文件)
汇编(汇编器把汇编码翻译成机器码,生成 .o 文件)。
链接( 这个过程第一阶段符号表解析 是把各个.o 文件的elf中同样段进行合并, 所有的符号表中und的变量都要在这过程中查找在哪, 解析成功之后给所有的符号分配虚拟地址。 , 最后生成 .out 文件 (注意这些过程根据objdump 查看elf文件) elf文件会包括了所有的信息。 可执行文件.out和.o大部分都相同, 但是可执行文件还有一个program 告诉系统哪些内容加载到内存中。 一般只加载代码段和数据段。
加载: 操作系统在装载应用程序时,主要分为三步:
4.1. 为进程分配虚拟内存(逻辑内存),一个进程是4G, 分为txt , data ,bss(存储为0或者未初始化的全局变量) , heap, shared stack(从上往下增加的, 要删除一个必须把下面的都删除了, 因此没有内存碎片) kernel 。 动态库和mmap会加载到headp到stack之间。
2 建立虚拟内存与可执行文件的映射(在内核态的页表, 以4K大小为单位,将虚拟内存地址与可执行文件的偏移建立映射关系。形成一个虚拟地址到物理地址的页表,注意这里不是寻址的页表)
4.3. 将CPU的指令寄存器设置为应用程序的入口地址(入口地址在elf文件中),启动运行。
6: 运行过程中堆栈调用过程:
栈在内存中是连续的一块空间(向低地址扩展)最大容量是系统预定好的,堆在内存中的空间(向高地址扩展)是不连续的。堆在内存中呈现的方式类似于链表(记录空闲地址空间的链表)。栈里面不仅有压栈出栈的函数调用, 还能分配局部变量。 这里面能讲解很深的~, 一定要注意。堆栈调用非常关键,深刻理解很有用。 这里面我简单说一下石磊老师课程里面的代码, 局部变量不产生地址, 直接根据栈的偏移量计算的。 直接是一个move指令。 调用函数中$ { $ 会存储之前栈的地址,开辟sum函数的栈空间, 保存之前栈的下一步汇编地址。 然后 执行函数的指令, 最后将存储return结果的形参变量内容交给一个寄存器。 右括号负责将这个调用栈回退(pop 得到之前的栈底的地址, 赋值给当前的栈底esp, 并将call function 的下一行指令直接给pc寄存器执行,(一般在这就回退到真正的main函数栈顶), 然后将存返回值的寄存器内容给main栈中的ret , 并开始从main栈顶继续执行。 在这过程中可能会反复发生调度算法切换,但是没事,不影响正常的程序调用。
7 : 运行过程中虚拟内存如何工作
当我们代码中访问到具体的数据时候,而不是只是申请虚拟内存的时候, 就会发生页表的实际分配。 接下来就让我们看看这里是怎么切换的。
内存管理单元(MMU)管理着虚拟地址空间和物理内存的转换,操作系统为每一个进程维护了一个从虚拟地址到物理地址的映射关系的数据结构,叫页表,存储着程序地址空间到物理内存空间的映射表。页表寻址中可以通过查询页表中的状态位来确定所要访问的页面是否存在于内存中。每当所要访问的页面不在内存时(缓存不命中),会产生一次缺页异常加中断,此时操作系统会根据页表中的外存地址在外存中找到所缺的一页,将其调入内存。如果执行出错那么就直接退出了。在mmu上查找虚拟地址的物理地址时候,如果内存满了等会做缺页置换算法。例如LRU, 总之可以不用做实际的转换, 直接将物理地址返回给cpu。
如果需要实际访问的话第一次没有缓存而且标志位为空的话会产生缺页异常,如果映射整体是三级表 , 就是经历(1)逻辑地址转线性地址, 这样我们拿到了32位的地址, 然后拆分10 ,10 ,12 大小分别找页表,页, 物理地址。还要注意缺页异常是前提, 随后触发中断, 去真正的将外存放到内存中,说明已经做了一次实际映射,这种就不是malloc , 而是实际的访问了。
8 : cpu的调度算法
每个进程的PCB都是存在所有进程共享的内核空间中,操作系统管理进程,也就是在内核空间中管理的,在内核空间中通过链表管理所有进程的PCB,如果有一个进程要被创建,实际上多分配了这么一个4G的虚拟内存,并在共享的内核空间中的双向链表中加入了自己的PCB。PCB(Process Control Block)进程控制块,描述进程的基本信息和运行状态。
还要注意, 在多道程序而且多用户的情况下,组织多个作业或任务时,就要解决处理器的进程调度。 如果CPU调度算法生效了, 需要进行程序寄存器上下文的保存, 之后再去调度其他的进程。常见的方法有先来先服务法等。
9: 优化性能
最后在整个阶段我们需要考虑几个优化方面的问题 :(1)内存碎片的避免, (2)如何进行优化核态和用户态, 库函数在这个过程中做了什么?
关于内存碎片的避免就是采用每天定时启动, 此外还有malloc底层使用了内存池。采用不同的链表绑定不同需求, 如果请求的大小在链表中满足直接返回, 不满足再去重新开辟。
第二个内核态是发生在一些文件读写或者事件响应中的, 内核态拥有最高权限,可以访问所有系统指令;用户态则只能访问一部分指令。一些对硬件操作或者重要的指令只有内核态才能访问到。例如:当读取文件等(read,write),需要进入内核态。 进入的方式就是软中断和硬件中断。 中断是当前程序需要暂停当前的任务去做的事情, 为了区分不同的中断,每个设备有自己的中断号。系统有0-255一共256个中断。系统有一张中断向量表,用于存放256个中断的中断服务程序入口地址。每个入口地址对应一段代码,即中断服务程序。 一般需要保存现场(当前的执行位置和当前状态到两个寄存器上), 模式切换, 找中断表中的函数, 执行函数, 返回恢复状态。
那么内核态和用户态的交互太多是非常影响性能的, 一般我们使用read, mmap.sendfile去调用内核, 但是不同的方法效率不一样,1. 调用read函数读取文件, 需要进入内核态再返回用户态。这里面拷贝过程比较多。2上面那个步骤可以使用mmap去避免一次拷贝。 kafka使用了这个机制做消息持久化, 它开辟了一个磁盘的mmap , 数据直接从用户态映射到磁盘。
3. 0用户态拷贝 是通过sendfile实现的, 就是说内核直接打开这个底层磁盘, 将数据直接通过内核态发送给客户端。
第三个库函数问题: 其实一些库函数在应用层添加了缓存区, 使用库函数调用可以大大减少系统调用的次数, 这个和系统的内核态还不一样。
小飞 : 这个一般就是new和malloc分配的, 一般都是有底层内存池来管理的,避免内存碎片产生, 不是每次请求都要执行一次系统中断去调用的。 brk、sbrk、mmap都属于系统调用,若每次申请内存,都调用这三个,那么每次都会产生系统调用,影响性能;其次,这样申请的内存容易产生碎片,因为他不像栈,释放之前前面的必须释放,没有内存碎片。
所以malloc采用的是内存池(ptmalloc)来减少使用堆内存引起的内存碎片,也就是自己一次申请一块足够大的空间,然后自己来管理,用于大量频繁地new/delete操作。每次配置一大块内存,并维护对应的16个空闲链表,大小从8字节到128字节。如果有小额内存被释放,则回收到空闲链表中。
(1)如果有相同大小的内存需求,则直接从空闲链表中查找对应大小的子链表。
(2)如果在自由链表中查找不到或者空间不够,则向内存池进行申请。
老张: 不错, 内存碎片是发生在代码长时间运行的时候, 由于堆里面很多节点被小内存的数据结构占领了, 如果当你要分配比较大的数据结构,这时候很容易找不到足够大的连续空间, 从而一直服务失败, 这是非常难受的, 所有语言都有这个问题, 一般C++是用一些开源的内存池, 例如google的tmalloc去分配, 他的分配原理是非常复杂的, 这种组件基本不是个人实现的, 你自己造别人根本不敢用。
老张: 说道这里, 你晓得java的gc是什么意思么?为什么java不容易内存泄漏
小飞: 这个我知道, 这些语言堆上数据基本上都是自动释放的, 例如他会定期扫描你有没有不用的堆内存,然后给你释放掉, 但是C++如果用普通指针就不会, 因为它不知道其他的地方用到这个堆数据没, 因此需要手动释放。 但是gc运行的时间是不能固定的, 如果刚好你的程序在处理关键的业务, 但是gc要进行大量的不用堆数据释放, 就会占用cpu过多, 导致你的业务突然变卡, 这在游戏和搜索行业是绝对不容发生的, 因为这种业务场景, 用户不会容忍卡0.1s的 。
资源通过{}进行释放或者回收
引用 + 指针 + 右值引用这三条, 让资源返回的非常合适。
一般动态库编译运行速度慢, 静态库编译的话如果你依赖的各种库链接的静态库因为版本不一致, 函数定义不同, 那么链接起来有版本问题, 还要找源码进行修改。 其实还不如源码全部下载到本地编译进来, 速度还是非常可以的。
C++基本语法就是数据类型, 循环控制, 函数这些。 比如下面的这个代码。
// main.cpp
#include
#include "calc.h"
using namespace std;
int main(int args, char** argc) {
double a = 0.2;
int aa = 0;
double b = 2.2;
double c = sum(a, b);
double d = multi(a, b);
double e = multi(a);
double f = multi(aa);
cout << "c : " << c << endl;
cout << "d : " << d << endl;
cout << "e : " << e << endl;
cout << "f : " << f << endl;
for (int i = 0; i < 10; ++i) {
cout << "i:" << i << endl;
}
}
\end{
lstlisting}
// calc.h
#pragma once
#include
using namespace std;
double sum(double a, double b);
double multi(double a, double b = 2.2);
double multi(int a, double b = 2.2);
double multi(vector<int>& a, vector<int>& b);
// calc.cpp
double sum(double a, double b) {
return a + b; }
double multi(double a, double b) {
return a + b; }
double multi(int aa, double b) {
re