C的内存分配
32bitCPU可寻址4G线性空间, 每个进程都有各自独立的4G逻辑地址, 其中0~3G是用户态空间, 3~4G是内核空间, 不同进程相同的逻辑地址会映射到不同的物理地址中. 其逻辑地址其划分如下:
正文段(code segment/text segment, .text段): 或称代码段, 通常是用来存放程序执行代码的一块内存区域. 这部分区域的大小在程序运行前就已经确定, 并且内存区域通常属于只读, 某些架构也允许代码段为可写, 即允许修改程序. 在代码段中, 也有可能包含一些只读的常数变量, 例如字符串常量等. CPU执行的机器指令部分. ( 存放函数体的二进制代码 . )
- 只读数据段(RO data, .rodata):只读数据段是程序使用的一些不会被改变的数据, 使用这些数据的方式类似查表式的操作, 由于这些变量不需要修改, 因此只需放在只读存储器中.
已初始化读写数据段(data segment, .data段):通常是用来存放程序中已初始化的全局变量的一块内存区域. 数据段属于静态内存分配. 常量字符串就是放在这里的, 程序结束后由系统释放(rodata—read only data). 已初始化读写数据段(RW data, .data):已初始化数据是在程序中声明, 并且具有初值的变量, 这些变量需要占用存储器空间, 在程序执行时它们需要位于可读写的内存区域, 并具有初值, 以供程序读写.
只读数据段和数据段统称为数据段BSS段(bss segment, .bss段):未初始化数据段(BSS, .bss)通常是指用来存放程序中未初始化的全局变量的一块内存区域. BSS段属于静态内存分配. 全局变量和静态变量的存储是放在一块的. 初始化的全局变量和静态变量在一块区域(.rwdata or .data), 未初始化的全局变量和未初始化的静态变量在相邻的另一块区域(.bss), 程序结束后由系统释放. 未初始化数据是在程序中声明, 但是不具有初值的变量, 这些变量在程序运行之前不需要占用存储空间.
在 C++中, 已经不再严格区分bss和 data了, 它们共享一块内存区域静态存储区包括bbs段和data段
data段包含经过初始化的全局变量以及它们的值. bss段的大小从可执行文件中得到, 然后链接器得到这个大小的内存块, 紧跟在data段的后面. 当这个内存进入程序的地址空间后全部清零. 包含data段和bss段的整个区段此时通常称为数据区data中的局部静态变量在第一次进入函数中进行初始化
对于data段, 保存的是初始化的全局变量和stataic的局部变量, 直接载入内存即可. text段保存的是代码直接载入. BSS段从目标文件中读取BSS段大小, 然后在内存中紧跟data段之后分配空间, 并且清零(这也是为什么全局变量和static局部变量不初始化会有0值得原因)
可执行程序在运行时又多出两个区域: 栈区和堆区.
- 栈区: 由编译器自动释放, 存放函数的参数值、局部变量等. 每当一个函数被调用时, 该函数的返回类型和一些调用的信息被存放到栈中. 然后这个被调用的函数再为他的自动变量和临时变量在栈上分配空间. 每调用一个函数一个新的栈就会被使用. 栈区是从高地址位向低地址位增长, 是一块连续的内存区域, 最大容量是由系统预先定义好的, 申请的栈空间超过这个界限时会提示溢出, 用户能从栈中获取的空间较小.
- 堆区: 用于动态分配内存, 位于bss和栈中间的地址区域. 由程序员申请分配和释放. 堆是从低地址位向高地址位增长, 采用链式存储结构. 频繁的malloc/free造成内存空间的不连续, 产生碎片. 当申请堆空间时库函数是按照一定的算法搜索可用的足够大的空间. 因此堆的效率比栈要低的多
int printf(const char* format, ...); int global_init_var = 84; // 已初始化的全局变量 int global_uninit_var; // 未初始化的全局变量 char *str1 = "hello world!"; // 字符串常量 void func1(int i) { printf("%d\n", i); } int main(void) { static int static_var = 85; // 已初始化的静态局部变量 static int static_var2; // 未初始化的静态局部变量 char *str2 = "22222"; // 字符串常量 int a = 1; int b; func1(static_var+static_var2+a+b); return a; } 用readelf -s 或 objdump -t 查看符号表 用readelf -S 或 objdump -h 查看段表 objdump -s -d main.o
C++内存模型
C++中, 内存分成5个区, 他们分别是堆、栈、自由存储区、全局/静态存储区和常量存储区
- 栈: 就是那些由编译器在需要的时候分配, 在不需要的时候自动清楚的变量的存储区. 里面的变量通常是局部变量、函数参数等.
- 堆: 就是那些由new分配的内存块, 他们的释放编译器不去管, 由我们的应用程序去控制, 一般一个new就要对应一个delete. 如果程序员没有释放掉, 那么在程序结束后, 操作系统会自动回收.
- 自由存储区: 就是那些由malloc等分配的内存块, 他和堆是十分相似的, 不过它是用free来结束自己的生命的.
- 全局/静态存储区: 全局变量和静态变量被分配到同一块内存中, 在以前的C语言中, 全局变量又分为初始化的和未初始化的, 在C++里面没有这个区分了, 他们共同占用同一块内存区.
- 常量存储区: 这是一块比较特殊的存储区, 他们里面存放的是常量, 不允许修改(当然, 你要通过非正当手段也可以修改)
堆和栈究竟有什么区别
申请方式不同.
栈由系统自动分配.
堆由程序员手动分配.
申请大小限制不同.
栈顶和栈底是之前预设好的, 大小固定, 可以通过ulimit -a查看, 由ulimit -s修改.
堆向高地址扩展, 是不连续的内存区域, 大小可以灵活调整.
栈空间大小默认8K, 堆得大小受限于计算机系统的有效虚拟内存空间
申请效率不同.
栈由系统分配, 速度快, 不会有碎片.
堆由程序员分配, 速度慢, 且会有碎片.
请你来说一下什么时候会发生段错误
段错误是指程序访问(读写)了系统未给予读写权限的内存空间.
包括:访问了不存在的内存空间,访问了系统保护的空间, 对只读内存空间写覆盖等, 常见的形式有数组越界访问, 野指针操作等
请你回答一下如何判断内存泄漏
内存泄漏通常是由于调用了malloc/new等内存申请的操作, 但是缺少了对应的free/delete. 为了判断内存是否泄露, 我们一方面可以使用linux环境下的内存泄漏检查工具Valgrind,另一方面我们在写代码时可以添加内存申请和释放的统计功能, 统计当前申请和释放的内存是否一致, 以此来判断内存是否泄露.
请你来说一下一个C++源文件从文本到可执行文件经历的过程?
对于C++源文件, 从文本到可执行文件一般需要四个过程:
预处理阶段: 对源代码文件中文件包含关系(头文件) 、预编译语句(宏定义)进行分析和替换, 生成预编译文件.
编译阶段: 将经过预处理后的预编译文件转换成特定汇编代码, 生成汇编文件
汇编阶段: 将编译阶段生成的汇编文件转化成机器码, 生成可重定位目标文件
链接阶段: 将多个目标文件及所需要的库连接成最终的可执行目标文件
请你说一下源码到可执行文件的过程
编译分三个阶段: 预编译, 编译, 汇编.
1)预编译main.c---->main.i
主要处理源代码文件中的以“#”开头的预编译指令. 处理规则见下
- 删除所有的#define, 展开所有的宏定义.
- 处理所有的条件预编译指令, 如“#if”、“#endif”、“#ifdef”、“#elif”和“#else”.
- 处理“#include”预编译指令, 将文件内容替换到它的位置, 这个过程是递归进行的, 文件中包含其他文件.
- 删除所有的注释, “//”和“/**/”.
- 保留所有的#pragma 编译器指令, 编译器需要用到他们, 如: #pragma once 是为了防止有文件被重复引用.
- 添加行号和文件标识, 便于编译时编译器产生调试用的行号信息, 和编译时产生编译错误或警告是能够显示行号.
gcc -E hello.c -o hello.i
2)编译main.i---->main.s
把预编译之后生成的xxx.i或xxx.ii文件, 进行一系列词法分析、语法分析、语义分析及优化后, 生成相应的汇编代码文件.
- 词法分析: 利用类似于“有限状态机”的算法, 将源代码程序输入到扫描机中, 将其中的字符序列分割成一系列的记号.
- 语法分析: 语法分析器对由扫描器产生的记号, 进行语法分析, 产生语法树. 由语法分析器输出的语法树是一种以表达式为节点的树.
- 语义分析: 语法分析器只是完成了对表达式语法层面的分析, 语义分析器则对表达式是否有意义进行判断, 其分析的语义是静态语义——在编译期能分期的语义, 相对应的动态语义是在运行期才能确定的语义.
- 优化: 源代码级别的一个优化过程.
- 目标代码生成: 由代码生成器将中间代码转换成目标机器代码, 生成一系列的代码序列——汇编语言表示.
- 目标代码优化: 目标代码优化器对上述的目标机器代码进行优化: 寻找合适的寻址方式、使用位移来替代乘法运算、删除多余的指令等.
gcc -S hello.i -o hello.s # 或者 gcc -S hello.c -o hello.s
3)汇编main.s---->main.ojb
将汇编代码转变成机器可以执行的指令(机器码文件). 汇编器的汇编过程相对于编译器来说更简单, 没有复杂的语法, 也没有语义, 更不需要做指令优化, 只是根据汇编指令和机器指令的对照表一一翻译过来, 汇编过程有汇编器as完成. 经汇编之后, 产生目标文件(与可执行文件格式几乎一样)xxx.o(Windows下)、xxx.obj(Linux下).
as hello.s -o hello.o # 或者 gcc -c hello.c -o hello.o gcc -c hello.s -o hello.o
链接
各个源代码模块独立的被编译, 然后将他们组装起来成为一个整体, 组装的过程就是链接. 被链接的各个部分本本身就是二进制文件, 所以在被链接时需要将所有目标文件的代码段拼接在一起, 然后将所有对符号地址的引用加以修正.
- 静态链接:
静态链接最简单的情况就是在编译时和静态库链接在一起成为完整的可执行程序. 这里所说的静态库就是对多个目标文件(.o)文件的打包, 通常静态链接的包名为lib****.a, 静态链接所有被用到的目标文件都会复制到最终生成的可执行目标文件中. 这种方式的好处是在运行时, 可执行目标文件已经完全装载完毕, 只要按指令序执行即可, 速度比较快, 但缺点也有很多, 在讲动态链接时会比较一下.
空间浪费: 因为每个可执行程序中对所有需要的目标文件都要有一份副本, 所以如果多个程序对同一个目标文件都有依赖, 会出现同一个目标文件都在内存存在多个副本;
更新困难: 每当库函数的代码修改了, 这个时候就需要重新进行编译链接形成可执行程序.
运行速度快: 但是静态链接的优点就是, 在可执行程序中已经具备了所有执行程序所需要的任何东西, 在执行的时候运行速度快.
- 动态链接:
静态链接发生于编译阶段, 加载至内存前已经完整, 但缺点是如果多个程序都需要使用某个静态库, 则该静态库会在每个程序中都拷贝一份, 非常浪费内存资源, 所以出现了动态链接的方式来解决这个问题.
共享库: 就是即使需要每个程序都依赖同一个库, 但是该库不会像静态链接那样在内存中存在多分, 副本, 而是这多个程序在执行时共享同一份副本;
更新方便: 更新时只需要替换原来的目标文件, 而无需将所有的程序再重新链接一遍. 当程序下一次运行时, 新版本的目标文件会被自动加载到内存并且链接起来, 程序就完成了升级的目标.
性能损耗: 因为把链接推迟到了程序运行时, 所以每次执行程序都需要进行链接, 所以性能会有一定损失.
比较静态库和动态库我们可以得到二者的优缺点.
- 动态库运行时会先检查内存中是否已经有该库的拷贝, 若有则共享拷贝, 否则重新加载动态库(C语言的标准库就是动态库). 静态库则是每次在编译阶段都将静态库文件打包进去, 当某个库被多次引用到时, 内存中会有多份副本, 浪费资源.
- 动态库另一个有点就是更新很容易, 当库发生变化时, 如果接口没变只需要用新的动态库替换掉就可以了. 但是如果是静态库的话就需要重新被编译.
- 不过静态库也有优点, 主要就是静态库一次性完成了所有内容的绑定, 运行时就不必再去考虑链接的问题了, 执行效率会稍微高一些.
链接是干嘛, 能讲下原理吗
符号解析
- 可重定位目标文件
对于独立编译的可重定位目标文件, 其ELF文件格式包括ELF头(指定文件大小及字节序)、.text(代码段)、.rodata(只读数据区)、.data(已初始化数据区)、.bss(未初始化全局变量)、.symtab(符号表)等, 其中链接时最需要关注的就是符号表. 每个可重定位目标文件都有一张符号表, 它包含该模块定义和引用的符号的信息, 简而言之就是我们在每个模块中定义和引用的全局变量(包括定义在本模块的全局变量、静态全局变量和引用自定义在其他模块的全局变量)需要通过一张表来记录, 在链接时通过查表将各个独立的目标文件合并成一个完整的可执行文件.
静态链接过程分为两步:
- 扫描所有的目标文件, 获取它们的每个段的长度、位置和属性, 并将每个目标文件中的符号表的符号定义和符号引用收集起来放在一个全局符号表中, 建立起可执行文件到目标文件的段映射关系
- 读取目标文件中的段数据, 并且解析符号表信息, 根据符号表信息进行重定位、调整代码中的地址等操作
重定位表
可以简单的认为是编译器把所有需要被重定位的数据存放在重定位表中, 这样链接器就能够知道该目标文件中哪些数据是需要被重定位的. .rela.text就存放了需要被重定位的指令的信息, 同样的如果是需要被重定位的数据则段名应该叫做.rela.data.
符号表(.symtab)
目标文件中的某些部分是需要在链接的时候被使用到的“粘合剂”, 这些部分我们可以把其称之为“符号”, 符号就保存在符号表中. 符号表中保存的符号很多, 其中最重要的就是定义在本目标文件中的可以被其它目标文件引用的符号和在本目标文件中引用的全局符号, 这两个符号呈现互补的关系.
问: 重定位表和符号表之间是什么关系?
答: 它们之间是相互合作的关系, 链接器首先要根据重定位表找到该目标文件中需要被重定位的符号, 之后再根据符号表去其它的目标文件中找到可以相匹配的符号, 最后对本目标文件中的符号进行重定位.
从上面的过程中我们可以看到链接器最终需要完成的工作有三个
- 合并不同目标文件中的同类型的段
- 对于目标文件中的符号引用, 在其它的目标文件中找到可以引用的符号
- 对目标文件中的变量地址进行重定位
一个程序从开始运行到结束的完整过程(四个过程)
A* a = new A; a->i = 10;
在内核中的内存分配上发生了什么
1)A *a: a是一个局部变量, 类型为指针, 故而操作系统在程序栈区开辟4/8字节的空间(0x000m), 分配给指针a. 2)new A: 通过new动态的在堆区申请类A大小的空间(0x000n). 3)a = new A: 将指针a的内存区域填入栈中类A申请到的地址的地址. 即*(0x000m)=0x000n. 4)a->i: 先找到指针a的地址0x000m, 通过a的值0x000n和i在类a中偏移offset, 得到a->i的地址0x000n + offset, 进行`*(0x000n + offset) = 10`的赋值操作, 即内存0x000n + offset的值是10.
内存溢出和内存泄漏
内存溢出: 指程序申请内存时, 没有足够的内存供申请者使用. 内存溢出就是你要的内存空间超过了系统实际分配给你的空间, 此时系统相当于没法满足你的需求, 就会报内存溢出的错误
内存溢出原因:
- 内存中加载的数据量过于庞大, 如一次从数据库取出过多数据
- 集合类中有对对象的引用, 使用完后未清空, 使得不能回收
- 代码中存在死循环或循环产生过多重复的对象实体
- 使用的第三方软件中的BUG
- 启动参数内存值设定的过小
内存泄漏: 内存泄漏是指由于疏忽或错误造成了程序未能释放掉不再使用的内存的情况. 内存泄漏并非指内存在物理上的消失, 而是应用程序分配某段内存后, 由于设计错误, 失去了对该段内存的控制, 因而造成了内存的浪费
内存泄漏的分类:
- 堆内存泄漏(Heap leak). 对内存指的是程序运行中根据需要分配通过malloc,realloc new等从堆中分配的一块内存, 再是完成后必须通过调用对应的free或者delete删掉. 如果程序的设计的错误导致这部分内存没有被释放, 那么此后这块内存将不会被使用, 就会产生Heap Leak
- 系统资源泄露(Resource Leak). 主要指程序使用系统分配的资源比如Bitmap, handle, SOCKET等没有使用相应的函数释放掉, 导致系统资源的浪费, 严重可导致系统效能降低, 系统运行不稳定
- 没有将基类的析构函数定义为虚函数. 当基类指针指向子类对象时, 如果基类的析构函数不是virtual, 那么子类的析构函数将不会被调用, 子类的资源没有正确是释放, 因此造成内存泄露
给你一个类, 里面有static, virtual, 分析这个类的内存分布
- static修饰符
(1) static修饰成员变量
对于非静态数据成员, 每个类对象都有自己的拷贝.
静态数据成员被当做是类的成员, 无论这个类被定义了多少个, 静态数据成员都只有一份拷贝, 为该类型的所有对象所共享(包括其派生类). 所以, 静态数据成员的值对每个对象都是一样的, 它的值可以更新
因为静态数据成员在全局数据区分配内存, 属于本类的所有对象共享, 所以它不属于特定的类对象, 在没有产生类对象前就可以使用
(2) static修饰成员函数
与普通的成员函数相比, 静态成员函数由于不是与任何的对象相联系, 因此它不具有this指针. 从这个意义上来说, 它无法访问属于类对象的非静态数据成员, 也无法访问非静态成员函数, 只能调用其他的静态成员函数.
static修饰的成员函数, 在代码区分配内存.
- C++继承和虚函数
C++多态分为静态多态和动态多态. 静态多态是通过重载和模板技术实现, 在编译的时候确定. 动态多态通过虚函数和继承关系来实现, 执行动态绑定, 在运行的时候确定
动态多态实现有几个条件:
(1)虚函数;
(2)一个基类的指针或引用指向派生类的对象;
基类指针在调用成员函数(虚函数)时, 就会去查找该对象的虚函数表. 虚函数表的地址在每个对象的首地址. 查找该虚函数表中该函数的指针进行调用.
每个对象中保存的只是一个虚函数表的指针, C++内部为每一个类维持一个虚函数表, 该类的对象的都指向这同一个虚函数表.
虚函数表中为什么就能准确查找相应的函数指针呢?因为在类设计的时候, 虚函数表直接从基类也继承过来, 如果覆盖了其中的某个虚函数, 那么虚函数表的指针就会被替换, 因此可以根据指针准确找到该调用哪个函数.
- virtual修饰符
如果一个类是局部变量则该类数据存储在栈区, 如果一个类是通过new/malloc动态申请的, 则该类数据存储在堆区.
如果该类是virutal继承而来的子类, 则该类的虚函数表指针和该类其他成员一起存储.
虚函数表指针指向只读数据段中的类虚函数表, 虚函数表中存放着一个个函数指针, 函数指针指向代码段中的具体函数. 如果类中成员是virtual属性, 会隐藏父类对应的属性.
请你来说一下微内核与宏内核
宏内核: 除了最基本的进程、线程管理、内存管理外, 将文件系统, 驱动, 网络协议等等都集成在内核里面, 例如linux内核.
优点: 效率高.
缺点: 稳定性差, 开发过程中的bug经常会导致整个系统挂掉.
微内核: 内核中只有最基本的调度、内存管理. 驱动、文件系统等都是用户态的守护进程去实现的.
优点: 稳定, 驱动等的错误只会导致相应进程死掉, 不会导致整个系统都崩溃
缺点: 效率低. 典型代表QNX, QNX的文件系统是跑在用户态的进程, 称为resmgr的东西, 是订阅发布机制, 文件系统的错误只会导致这个守护进程挂掉. 不过数据吞吐量就比较不乐观了.
用户态和内核态区别
用户态和内核态是操作系统的两种运行级别, 两者最大的区别就是特权级不同. 用户态拥有最低的特权级, 内核态拥有较高的特权级
运行在用户态的程序不能直接访问操作系统内核数据结构和程序. 内核态和用户态之间的转换方式主要包括: 系统调用, 异常和中断.
操作系统为什么要分内核态和用户态
为了安全性. 在cpu的一些指令中, 有的指令如果用错, 将会导致整个系统崩溃.
分了内核态和用户态后, 当用户需要操作这些指令时候, 内核为其提供了API, 可以通过系统调用陷入内核, 让内核去执行这些操作
请你来说一说用户态到内核态的转化原理
1)用户态切换到内核态的3种方式
- 系统调用
系统调用是操作系统为用户提供的一系列API; 系统调用将用户的请求发给内核, 内核执行完以后, 将结果返回给用户; - 异常
当CPU在执行运行在用户态的程序时, 发现了某些事件不可知的异常, 这是会触发由当前运行进程切换到处理此. 异常的内核相关程序中, 也就到了内核态, 比如缺页异常. - 外围设备的中断
当外围设备完成用户请求的操作之后, 会向CPU发出相应的中断信号, 这时CPU会暂停执行下一条将要执行的指令, 转而去执行中断信号的处理程序, 如果先执行的指令是用户态下的程序, 那么这个转换的过程自然也就发生了有用户态到内核态的切换. 比如硬盘读写操作完成, 系统会切换到硬盘读写的中断处理程序中执行后续操作等.
2)切换操作
从出发方式看, 可以在认为存在前述3种不同的类型, 但是从最终实际完成由用户态到内核态的切换操作上来说, 涉及的关键步骤是完全一样的, 没有任何区别, 都相当于执行了一个中断响应的过程, 因为系统调用实际上最终是中断机制实现的, 而异常和中断处理机制基本上是一样的, 用户态切换到内核态的步骤主要包括:
- 从当前进程的描述符中提取其内核栈的ss0及esp0信息.
- 使用ss0和esp0指向的内核栈将当前进程的cs,eip, eflags, ss,esp信息保存起来, 这个过程也完成了由用户栈找到内核栈的切换过程, 同时保存了被暂停执行的程序的下一条指令.
- 将先前由中断向量检索得到的中断处理程序的cs, eip信息装入相应的寄存器, 开始执行中断处理程序, 这时就转到了内核态的程序执行了.
linux中异常和中断的区别
1.中断
中断是由硬件设备产生的, 而它们从物理上说就是电信号, 之后, 它们通过中断控制器发送给CPU, 接着CPU判断收到的中断来自于哪个硬件设备(这定义在内核中), 最后, 由CPU发送给内核, 有内核处理中断.
2.异常
CPU处理程序的时候一旦程序不在内存中, 会产生缺页异常; 当运行除法程序时, 当除数为0时, 又会产生除0异常. 所以, 异常是由CPU产生的, 同时, 它会发送给内核, 要求内核处理这些异常,
相同点:
- 最后都是由CPU发送给内核, 由内核去处理
- 处理程序的流程设计上是相似的
不同点:
- 产生源不相同, 异常是由CPU产生的, 而中断是由硬件设备产生的
- 内核需要根据是异常还是中断调用不同的处理程序
- 中断不是时钟同步的, 这意味着中断可能随时到来; 异常由于是CPU产生的, 所以, 它是时钟同步的
- 当处理中断时, 处于中断上下文中; 处理异常时, 处于进程上下文中
系统调用进入内核态的过程
应用程序(application program)与库函数(libc)之间, 系统调用处理函数(system call handler)与系统调用服务例程(system call service routine)之间, 均是普通函数调用, 应该不难理解. 而库函数与系统调用处理函数之间, 由于涉及用户态与内核态的切换, 要复杂一些.
Linux通过软中断实现从用户态到内核态的切换. 用户态与内核态是独立的执行流, 因此在切换时, 需要准备执行栈并保存寄存器
内核实现了很多不同的系统调用(提供不同功能), 而系统调用处理函数只有一个. 因此, 用户进程必须传递一个参数用于区分, 这便是系统调用号(system call number). 在Linux 中, 系统调用号一般通过eax寄存器来传递
总结起来, 执行态切换过程如下:
- 应用程序在用户态准备好调用参数, 执行int指令触发软中断, 中断号为 0x80
- CPU 被软中断打断后, 执行对应的中断处理函数, 这时便已进入内核态
- 系统调用处理函数准备内核执行栈, 并保存所有寄存器 (一般用汇编语言实现)
- 系统调用处理函数根据系统调用号调用对应的C函数--系统调用服务例程
- 系统调用处理函数准备返回值并从内核栈中恢复寄存器
- 系统调用处理函数执行ret指令切换回用户态
系统调用是什么, 你用过哪些系统调用
1)概念:
运行在使用者空间的程序向操作系统内核请求需要更高权限运行的服务. 系统调用提供了用户程序与操作系统之间的接口(即系统调用是用户程序和内核交互的接口).
应用程序有时会需要一些危险的、权限很高的指令, 如果把这些权限放心地交给用户程序是很危险的(比如一个进程可能修改另一个进程的内存区, 导致其不能运行), 但是又不能完全不给这些权限. 于是有了系统调用, 危险的指令被包装成系统调用, 用户程序只能调用而无权自己运行那些危险的指令. 另外, 计算机硬件的资源是有限的, 为了更好的管理这些资源, 所有的资源都由操作系统控制, 进程只能向操作系统请求这些资源. 操作系统是这些资源的唯一入口, 这个入口就是系统调用.
2)系统调用举例:
对文件进行写操作, 程序向打开的文件写入字符串“hello world”, open和write都是系统调用.
物理内存和虚拟内存
为什么会有虚拟内存和物理内存. 正在运行的一个进程, 他所需的内存是有可能大于内存条容量之和的, 比如你的内存条是256M, 你的程序却要创建一个2G的数据区, 那么不是所有数据都能一起加载到内存(物理内存)中, 势必有一部分数据要放到其他介质中(比如硬盘), 待进程需要访问那部分数据时, 在通过调度进入物理内存. 所以, 虚拟内存是进程运行时所有内存空间的总和, 并且可能有一部分不在物理内存中
虚拟内存地址和物理内存地址区别. 假设你的计算机是32位, 那么它的地址总线是32位的, 也就是它可以寻址0~0xFFFFFFFF(4G)的地址空间, 但如果你的计算机只有256M的物理内存0x~0x0FFFFFFF(256M), 同时你的进程产生了一个不在这256M地址空间中的地址, 那么计算机该如何处理呢?回答这个问题前, 先说明计算机的内存分页机制.
计算机会对虚拟内存地址空间(32位为4G)分页产生页(page), 对物理内存地址空间(假设256M)分页产生页帧(page frame), 这个页和页帧的大小是一样大的, 所以呢, 在这里, 虚拟内存页的个数势必要大于物理内存页帧的个数. 在计算机上有一个页表(page table), 就是映射虚拟内存页到物理内存页的, 更确切的说是页号到页帧号的映射, 而且是一对一的映射. 但是问题来了, 虚拟内存页的个数 > 物理内存页帧的个数, 岂不是有些虚拟内存页的地址永远没有对应的物理内存地址空间?不是的, 操作系统是这样处理的. 操作系统有个页面失效(page fault)功能. 操作系统找到一个最少使用的页帧, 让他失效, 并把它写入磁盘, 随后把需要访问的页放到页帧中, 并修改页表中的映射, 这样就保证所有的页都有被调度的可能了. 这就是处理虚拟内存地址到物理内存的步骤.
什么是虚拟内存地址和物理内存地址. 虚拟内存地址由页号(与页表中的页号关联)和偏移量组成. 页号就不必解释了, 上面已经说了, 页号对应的映射到一个页帧. 那么, 说说偏移量. 偏移量就是我上面说的页(或者页帧)的大小, 即这个页(或者页帧)到底能存多少数据. 举个例子, 有一个虚拟地址它的页号是4, 偏移量是20, 那么他的寻址过程是这样的: 首先到页表中找到页号4对应的页帧号(比如为8), 如果页不在内存中, 则用失效机制调入页, 否则把页帧号和偏移量传给MMU(CPU的内存管理单元)组成一个物理上真正存在的地址, 接着就是访问物理内存中的数据了. 总结起来说, 虚拟内存地址的大小是与地址总线位数相关, 物理内存地址的大小跟物理内存条的容量相关.
二级页表的图, 它的映射只有两层: 页目录→页表→页 而PAE则是: 页目录指针页→页目录→页表→页
利用虚拟内存机制的优点
- 既然每个进程的内存空间都是一致而且固定的(32位平台下都是4G), 所以链接器在链接可执行文件时, 可以设定内存地址, 而不用去管这些数据最终实际内存地址, 这交给内核来完成映射关系
- 当不同的进程使用同一段代码时, 比如库文件的代码, 在物理内存中可以只存储一份这样的代码, 不同进程只要将自己的虚拟内存映射过去就好了, 这样可以节省物理内存
- 在程序需要分配连续空间的时候, 只需要在虚拟内存分配连续空间, 而不需要物理内存时连续的, 实际上, 往往物理内存都是断断续续的内存碎片. 这样就可以有效地利用我们的物理内存
交换空间与虚拟内存的关系
Swap分区(即交换区)在系统的物理内存不够用的时候, 把硬盘空间中的一部分空间释放出来, 以供当前运行的程序使用. 那些被释放的空间可能来自一些很长时间没有什么操作的程序, 这些被释放的空间被临时保存到Swap分区中, 等到那些程序要运行时, 再从Swap分区中恢复保存的数据到内存中.
请你说一说Linux虚拟地址空间
为了防止不同进程同一时刻在物理内存中运行而对物理内存的争夺和践踏, 采用了虚拟内存.
虚拟内存技术使得不同进程在运行过程中, 它所看到的是自己独自占有了当前系统的4G内存. 所有进程共享同一物理内存, 每个进程只把自己目前需要的虚拟内存空间映射并存储到物理内存上. 事实上, 在每个进程创建加载时, 内核只是为进程“创建”了虚拟内存的布局, 具体就是初始化进程控制表中内存相关的链表, 实际上并不立即就把虚拟内存对应位置的程序数据和代码(比如.text .data段)拷贝到物理内存中, 只是建立好虚拟内存和磁盘文件之间的映射就好(叫做存储器映射), 等到运行到对应的程序时, 才会通过缺页异常, 来拷贝数据. 还有进程运行过程中, 要动态分配内存, 比如malloc时, 也只是分配了虚拟内存, 即为这块虚拟内存对应的页表项做相应设置, 当进程真正访问到此数据时, 才引发缺页异常.
请求分页系统、请求分段系统和请求段页式系统都是针对虚拟内存的, 通过请求实现内存与外存的信息置换.
虚拟内存的好处:
- 扩大地址空间;
- 内存保护: 每个进程运行在各自的虚拟内存地址空间, 互相不能干扰对方. 虚存还对特定的内存地址提供写保护, 可以防止代码或数据被恶意篡改.
- 公平内存分配. 采用了虚存之后, 每个进程都相当于有同样大小的虚存空间.
- 当进程通信时, 可采用虚存共享的方式实现.
- 当不同的进程使用同样的代码时, 比如库文件中的代码, 物理内存中可以只存储一份这样的代码, 不同的进程只需要把自己的虚拟内存映射过去就可以了, 节省内存
- 虚拟内存很适合在多道程序设计系统中使用, 许多程序的片段同时保存在内存中. 当一个程序等待它的一部分读入内存时, 可以把CPU交给另一个进程使用. 在内存中可以保留多个进程, 系统并发度提高
- 在程序需要分配连续的内存空间的时候, 只需要在虚拟内存空间分配连续空间, 而不需要实际物理内存的连续空间, 可以利用碎片
虚拟内存的代价:
- 虚存的管理需要建立很多数据结构, 这些数据结构要占用额外的内存
- 虚拟地址到物理地址的转换, 增加了指令的执行时间.
- 页面的换入换出需要磁盘I/O, 这是很耗时的
- 如果一页中只有一部分数据, 会浪费内存.
程序从堆中动态分配内存时, 虚拟内存上怎么操作的
Linux下的 brk() 和 mmap() 系统调用都可以用于申请堆内存, 它们获取堆内存的方式分别如下:
- brk 是将 program brk 向高地址推, 以此来获取新的传统堆内存空间
- mmap 是在 Memory Map Segment 中找一块空闲的内存空间
但是我们一般不会直接使用系统调用, 而是使用库函数来申请堆内存, 我们一般使用glibc中的malloc函数来申请内存, 它会根据申请内存的大小的不同而使用不同的实现. 这两种方式分配的都是虚拟内存, 没有分配物理内存. 在第一次访问已分配的虚拟地址空间的时候, 发生缺页中断, 操作系统负责分配物理内存, 然后建立虚拟内存和物理内存之间的映射关系.
如果申请的内存小于128k
- 首先查看传统堆内存中是否有足够的空闲内存, 如果有足够的空闲内存则直接分配给用户程序而不需要经过系统调用
- 如果传统堆中空闲内存不足则调用 brk 系统调用增大传统堆以获取新的内存分配给用户程序
- 如果被 free 的内存位于 program brk 处, 则调用 brk 系统调用减小堆大小
- 如果被 free 的内存位于传统堆内部, 则库函数记录下被释放空间的位置和大小, 之后再有新的内存申请时可以优先的从传统堆的空闲内存中分配空间, 而不需要再次调用 brk 系统调用
如果申请的内存大于128k
- 使用 mmap 系统调用申请内存
- mmap 所申请内存的释放使用 munmap 系统调用来实现
问题: 既然堆内内存brk和sbrk不能直接释放, 为什么不全部使用 mmap 来分配, munmap直接释放呢?
既然堆内碎片不能直接释放, 导致疑似“内存泄露”问题, 为什么 malloc 不全部使用 mmap 来实现呢(mmap分配的内存可以会通过munmap进行free, 实现真正释放)? 而是仅仅对于大于128k的大块内存才使用mmap?
进程向OS申请和释放地址空间的接口sbrk/mmap/munmap都是系统调用, 频繁调用系统调用都比较消耗系统资源的. 并且mmap申请的内存被munmap后, 重新申请会产生更多的缺页中断. 例如使用mmap分配1M空间, 第一次调用产生了大量缺页中断(1M/4K次), 当munmap后再次分配1M空间, 会再次产生大量缺页中断. 缺页中断是内核行为, 会导致内核态CPU消耗较大. 另外, 如果使用mmap分配小内存, 会导致地址空间的分片更多, 内核的管理负担更大. 同时堆是一个连续空间, 并且堆内碎片由于没有归还OS, 如果可重用碎片, 再次访问该内存很可能不需产生任何系统调用和缺页中断, 这将大大降低CPU的消耗. 因此, glibc的malloc实现中, 充分考虑了sbrk和mmap行为上的差异及优缺点, 默认分配大块内存(128k)才使用mmap获得地址空间, 也可通过 mallopt(M_MMAP_THRESHOLD,
请你回答一下malloc的原理, 另外brk系统调用和mmap系统调用的作用分别是什么?
Malloc函数用于动态分配内存. 为了减少内存碎片和系统调用的开销, malloc其采用内存池的方式, 先申请大块内存作为堆区, 然后将堆区分为多个内存块, 以块作为内存管理的基本单位. 当用户申请内存时, 直接从堆区分配一块合适的空闲块. Malloc采用隐式链表结构将堆区分成连续的、大小不一的块, 包含已分配块和未分配块; 同时malloc采用显示链表结构来管理所有的空闲块, 即使用一个双向链表将空闲块连接起来, 每一个空闲块记录了一个连续的、未分配的地址.
当进行内存分配时, Malloc会通过隐式链表遍历所有的空闲块, 选择满足要求的块进行分配; 当进行内存合并时, malloc采用边界标记法, 根据每个块的前后块是否已经分配来决定是否进行块合并.
Malloc在申请内存时, 一般会通过brk或者mmap系统调用进行申请. 其中当申请内存小于128K时, 会使用系统函数brk在堆区中分配; 而当申请内存大于128K时, 会使用系统函数mmap在映射区分配.
malloc分配失败原因及解决办法
malloc()函数分配内存失败的原因
- 内存不足.
- 在前面的程序中出现了内存的越界访问, 导致malloc()分配函数所涉及的一些信息被破坏. 下次再使用malloc()函数申请内存就会失败, 返回空指针NULL(0)
应该是写越界, 有可能覆盖到了下一个空闲块的头节点, 从而破坏了 malloc管理的环形链表, malloc就无法从一个空闲块的指针字段找到下一个空闲块了
new失败解决办法
C返回空指针, C++抛出异常
try { int* pStr = new string[SIZE]; // processing codes } catch ( const bad_alloc& e ) { return -1; }
常见内存分配方式和错误
1、内存分配方式
内存分配方式有三种:
(1) 从静态存储区域分配. 内存在程序编译的时候就已经分配好, 这块内存在程序的整个运行期间都存在. 例如全局变量, static变量.
(2) 在栈上创建. 在执行函数时, 函数内局部变量的存储单元都可以在栈上创建, 函数执行结束时这些存储单元自动被释放. 栈内存分配运算内置于处理器的指令集中, 效率很高, 但是分配的内存容量有限.
(3) 从堆上分配, 亦称动态内存分配. 程序在运行的时候用malloc或new申请任意多少的内存, 程序员自己负责在何时用free或delete释放内存. 动态内存的生存期由我们决定, 使用非常灵活, 但问题也最多.
2、常见的内存错误及其对策
结构体相等是否能够使用内存比较, 分是否有内存对齐两种情况
不可以, memcmp是逐个字节对比的, 但当字节对齐时, 中间的填充部分是随机的, 不确定的地址, 所以比较的结果是不正确的
请你说一说操作系统中的结构体对齐, 字节对齐
原因:
- 不同硬件平台不一定支持访问任意内存地址数据, 使用内存对齐可以保证每次访问都从块内存地址头部开始存取
- 提高cpu内存访问速度, 内存是分块的, 如两字节一块, 四字节一块, 考虑这种情况: 一个四字节变量存在一个四字节地址的后三位和下一个四字节地址的前一位, 这样cpu从内存中取数据便需要访问两个内存并将他们组合起来, 降低cpu性能
三个准则:
- 结构体变量的首地址能够被其最宽基本类型成员的大小所整除;
- 结构体每个成员相对结构体首地址的偏移量(offset)都是成员大小的整数倍, 如有需要编译器会在成员之间加上填充字节(internal adding);
- 结构体的总大小为结构体最宽基本类型成员大小的整数倍, 如有需要编译器会在最末一个成员之后加上填充字节{trailing padding}.
关闭内存对齐
内部碎片和外部碎片
内部碎片就是已经被分配出去(能明确指出属于哪个进程)却不能被利用的内存空间;
内部碎片是处于(操作系统分配的用于装载某一进程的内存)区域内部或页面内部的存储块. 占有这些区域或页面的进程并不使用这个存储块. 而在进程占有这块存储块时, 系统无法利用它. 直到进程释放它, 或进程结束时, 系统才有可能利用这个存储块.
单道连续分配只有内部碎片. 多道固定连续分配既有内部碎片, 又有外部碎片.
外部碎片指的是还没有被分配出去(不属于任何进程), 但由于太小了无法分配给申请内存空间的新进程的内存空闲区域.
外部碎片是处于任何两个已分配区域或页面之间的空闲存储块. 这些存储块的总和可以满足当前申请的长度要求, 但是由于它们的地址不连续或其他原因, 使得系统无法满足当前申请.
页面置换算法
最佳
最近最久未使用(LRU)
最近未使用(NRU)
先进先出(FIFO)
第二次机会算法
时钟算法
缺页中断后, 执行了那些操作
当一个进程发生缺页中断的时候, 进程会陷入内核态, 执行以下操作:
- 检查要访问的虚拟地址是否合法
- 查找/分配一个物理页
- 填充物理页内容(读取磁盘, 或者直接置0, 或者啥也不干)
- 建立映射关系(虚拟地址到物理地址)
重新执行发生缺页中断的那条指令
如果第3步, 需要读取磁盘, 那么这次缺页中断就是majflt, 否则就是minflt.
请你说一说操作系统中的缺页中断
malloc和mmap等内存分配函数, 在分配时只是建立了进程虚拟地址空间, 并没有分配虚拟内存对应的物理内存. 当进程访问这些没有建立映射关系的虚拟内存时, 处理器自动触发一个缺页异常.
缺页中断: 在请求分页系统中, 可以通过查询页表中的状态位来确定所要访问的页面是否存在于内存中. 每当所要访问的页面不在内存是, 会产生一次缺页中断, 此时操作系统会根据页表中的外存地址在外存中找到所缺的一页, 将其调入内存.
缺页本身是一种中断, 与一般的中断一样, 需要经过4个处理步骤:
- 保护CPU现场
- 分析中断原因
- 转入缺页中断处理程序进行处理
- 恢复CPU现场, 继续执行
但是缺页中断是由于所要访问的页面不存在于内存时, 由硬件所产生的一种特殊的中断, 因此, 与一般的中断存在区别:
- 在指令执行期间产生和处理缺页中断信号
- 一条指令在执行期间, 可能产生多次缺页中断
- 缺页中断返回是, 执行产生中断的一条指令, 而一般的中断返回是, 执行下一条指令.
进程如何创建(底层实现原理)
进程被存放在一个叫做任务队列的双向循环链表当中.链表当中的每一项都是类型为task_struct成为进程描述符的结构, 也就是进程PCB
fork执行流程:
- 用户空间调用fork()方法;
- 经过syscall陷入内核空间, 内核根据系统调用号找到相应的sys_fork系统调用;
- sys_fork()过程会在调用do_fork(), 该方法参数有一个flags很重要, 代表的是父子进程之间需要共享的资源; 对于进程创建flags=SIGCHLD, 即当子进程退出时向父进程发送SIGCHLD信号;
- do_fork(),会进行一些check过程,之后便是进入核心方法copy_process.
fork采用Copy on Write机制, 父子进程共用同一块内存, 只有当父进程或者子进程执行写操作时会拷贝一份新内存. 另外, 创建进程也是有可能失败, 比如进程个数达到系统上限(32768)或者系统可用内存不足
第一个问题, 为什么frok成功调用后返回两个值?
由于在复制时复制了父进程的堆栈段, 所以两个进程都停留在fork函数中, 等待返回.因为fork函数会返回两次,一次是在父进程中返回,
另一次是在子进程中返回, 这两次的返回值不同.
从fork函数开始以后的代码父子共享, 既父进程要执行这段代码, 子进程也要执行这段代码.(子进程获得父进程数据空间, 堆和栈的副本. 但是父子进程并不共享这些存储空间部分. 父, 子进程共享代码段.)现在很多现实并不执行一个父进程数据段, 堆和栈的完全复制. 而是采用写时拷贝技术不懂可以戳进去看一看).这些区域有父子进程共享, 而且内核地他们的访问权限改为只读的.如果父子进程中任一个试图修改这些区域,则内核值为修改区域的那块内存制作一个副本, 也就是如果你不修改我们一起用, 你修改了之后对于修改的那部分
fork()函数在底层中做了什么?
- 调用系统函数clone. 其中fork, vfork函数根据各自需要的参数标志去调用clone
- clone继续调用do_fork
- do_fork调用copy_process, 其中copy_process执行下列功能
1. 调用dup_task_struct函数创建一个内核栈(task_struct结构体). thread_info与task_struct这些结构体与当前父进程相同, 此时子进程与父进程的描述符完全相同
2. 检查新创建的子进程. 当前用户所拥有的进程数目是否超过给它分配的资源限制
3. 区别父子进程描述符, 部分进程描述符成员清零或设置
4. 子进程状态设置TASK_UNINTERRUPTIBLE(不可中断)以保证不会投入运行
5. 调用copy_flags以更新task_struct的flags成员. 表明进程是否拥有超级用户权限的PF_SUPERPRIV标志清零, 表明进程还没有调用exec函数
6. 调用get_pid为新进程获取一个有效的pid
7. 根据clone参数, copy_process拷贝或共享打开的文件, 进程地址空间等
8. copy_process扫尾返回指向进程的指针
线程如何创建(底层实现原理)
在GNU / Linux上, 线程是作为进程实现的. 每当您调用pthread_create创建新线程时, Linux都会创建一个运行该线程的新进程. 但是, 此过程与使用fork创建的过程不同; 特别是, 它与原始进程共享相同的地址空间和资源.
clone系统调用
虽然在同一程序中创建的GNU / Linux线程是作为单独的进程实现的, 但它们共享其虚拟内存空间和其他资源
- 但是, 使用fork创建的子进程可以获取这些项的副本.
- Linux clone系统调用是fork和pthread_create的通用形式, 它允许调用者指定在调用进程和新创建的进程之间共享哪些资源.
- clone()的主要用途是实现线程: 在共享内存空间中并发运行的程序中的多个控制线程.
- 与fork()不同, 这些调用允许子进程与调用进程共享其执行上下文的一部分, 例如内存空间, 文件描述符表和信号处理程序表.
传送门
什么是上下文切换
上下文切换就是从当前执行任务切换到另一个任务执行的过程。但是,为了确保下次能从正确的位置继续执行,在切换之前,会保存上一个任务的状态。
线程上下文切换和进程上下文切换的区别
进程切换分两步
- 切换页目录以使用新的地址空间
- 切换内核栈和硬件上下文
对于linux来说,线程和进程的最大区别就在于地址空间。
对于线程切换,第1步是不需要做的,第2是进程和线程切换都要做的。所以明显是进程切换代价大
线程上下文切换和进程上下问切换一个最主要的区别是线程的切换虚拟内存空间依然是相同的,但是进程切换是不同的。这两种上下文切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。
另外一个隐藏的损耗是上下文的切换会扰乱处理器的缓存机制。简单的说,一旦去切换上下文,处理器中所有已经缓存的内存地址一瞬间都作废了。还有一个显著的区别是当你改变虚拟内存空间的时候,处理的页表缓冲(processor’s Translation Lookaside Buffer (TLB))或者相当的神马东西会被全部刷新,这将导致内存的访问在一段时间内相当的低效。但是在线程的切换中,不会出现这个问题。
用户级线程和内核级线程
线程的实现可以分为两类:用户级线程(User-Level Thread)和内核线线程(Kernel-Level Thread),后者又称为内核支持的线程或轻量级进程。在多线程操作系统中,各个系统的实现方式并不相同,在有的系统中实现了用户级线程,有的系统中实现了内核级线程。
- 内核级线程:切换由内核控制,当线程进行切换的时候,由用户态转化为内核态。切换完毕要从内核态返回用户态;可以很好的利用smp,即利用多核cpu。windows线程就是这样的。
- 用户级线程内核的切换由用户态程序自己控制内核切换,不需要内核干涉,少了进出内核态的消耗,但不能很好的利用多核Cpu,目前Linux pthread大体是这么做的。
多线程模型
多对一模型: 多个用户及线程映射到一个内核级线程
优点: 用户级线程的切换在用户空间即可完成, 不需要切换到核心态, 线程管理的系统开销小, 效率高
缺点: 当一个用户级线程被阻塞后, 整个进程都会被阻塞, 并发度不高, 多个线程不可在多核处理机上并行运行
一对一模型: 一个用户及线程映射到一个内核级线程. 每个用户进程与用户级线程同数量的内核级线程
优点: 当一个线程被阻塞后, 别的线程还可以继续执行, 并发能力强. 多线程可以在多核处理机上并行执行
缺点: 一个用户进程会占用多个内核级线程. 线程切换由操作系统内核完成, 需要切换到核心太, 因此线程管理的成本高, 开销大
多对多模型: n用户及线程映射到m个内核级线程(n>=m). 每个用户进程对应m个内核级线程
克服了多对一模型并发度不高的缺点, 又克服了一对一模型中一个用户进程占太多内核级线程, 开销太大的缺点
请你说一说操作系统中的页表寻址
页式内存管理, 内存分成固定长度的一个个页片。操作系统为每一个进程维护了一个从虚拟地址到物理地址的映射关系的数据结构, 叫页表, 页表的内容就是该进程的虚拟地址到物理地址的一个映射。页表中的每一项都记录了这个页的基地址。通过页表, 由逻辑地址的高位部分先找到逻辑地址对应的页基地址, 再由页基地址偏移一定长度就得到最后的物理地址, 偏移的长度由逻辑地址的低位部分决定。一般情况下, 这个过程都可以由硬件完成, 所以效率还是比较高的。页式内存管理的优点就是比较灵活, 内存管理以较小的页为单位, 方便内存换入换出和扩充地址空间。
Linux最初的两级页表机制:
两级分页机制将32位的虚拟空间分成三段, 低十二位表示页内偏移, 高20分成两段分别表示两级页表的偏移。
- PGD(Page Global Directory): 最高10位, 全局页目录表索引
- PTE(Page Table Entry): 中间10位, 页表入口索引
当在进行地址转换时, 结合在CR3寄存器中存放的页目录(page directory, PGD)的这一页的物理地址, 再加上从虚拟地址中抽出高10位叫做页目录表项(内核也称这为pgd)的部分作为偏移, 即定位到可以描述该地址的pgd;从该pgd中可以获取可以描述该地址的页表的物理地址, 再加上从虚拟地址中抽取中间10位作为偏移, 即定位到可以描述该地址的pte;在这个pte中即可获取该地址对应的页的物理地址, 加上从虚拟地址中抽取的最后12位, 即形成该页的页内偏移, 即可最终完成从虚拟地址到物理地址的转换。从上述过程中, 可以看出, 对虚拟地址的分级解析过程, 实际上就是不断深入页表层次, 逐渐定位到最终地址的过程, 所以这一过程被叫做page talbe walk。
Linux的三级页表机制:
当X86引入物理地址扩展(Pisycal Addrress Extension, PAE)后, 可以支持大于4G的物理内存(32位), 但虚拟地址依然是32位, 原先的页表项不适用, 它实际多4 bytes被扩充到8 bytes, 这意味着, 每一页现在能存放的pte数目从1024变成512了(4k/8)。相应地, 页表层级发生了变化, Linus新增加了一个层级, 叫做页中间目录(page middle directory, PMD), 变成:
字段 描述 位数
cr3 指向一个PDPT crs寄存器存储
PGD 指向PDPT中4个项中的一个 位31~30
PMD 指向页目录中512项中的一个 位29~21
PTE 指向页表中512项中的一个 位20~12
page offset 4KB页中的偏移 位11~0
现在就同时存在2级页表和3级页表, 在代码管理上肯定不方便。巧妙的是, Linux采取了一种抽象方法: 所有架构全部使用3级页表: 即PGD -> PMD -> PTE。那只使用2级页表(如非PAE的X86)怎么办?
办法是针对使用2级页表的架构, 把PMD抽象掉, 即虚设一个PMD表项。这样在page table walk过程中, PGD本直接指向PTE的, 现在不了, 指向一个虚拟的PMD, 然后再由PMD指向PTE。这种抽象保持了代码结构的统一。
Linux的四级页表机制:
硬件在发展, 3级页表很快又捉襟见肘了, 原因是64位CPU出现了, 比如X86_64, 它的硬件是实实在在支持4级页表的。它支持48位的虚拟地址空间1。如下:
字段 描述 位数
PML4 指向一个PDPT 位47~39
PGD 指向PDPT中4个项中的一个 位38~30
PMD 指向页目录中512项中的一个 位29~21
PTE 指向页表中512项中的一个 位20~12
page offset 4KB页中的偏移 位11~0
Linux内核针为使用原来的3级列表(PGD->PMD->PTE), 做了折衷。即采用一个唯一的, 共享的顶级层次, 叫PML4。这个PML4没有编码在地址中, 这样就能套用原来的3级列表方案了。不过代价就是, 由于只有唯一的PML4, 寻址空间被局限在(239=)512G, 而本来PML4段有9位, 可以支持512个PML4表项的。现在为了使用3级列表方案, 只能限制使用一个, 512G的空间很快就又不够用了, 解决方案呼之欲出。
在2004年10月, 当时的X86_64架构代码的维护者Andi Kleen提交了一个叫做4level page tables for Linux的PATCH系列, 为Linux内核带来了4级页表的支持。在他的解决方案中, 不出意料地, 按照X86_64规范, 新增了一个PML4的层级, 在这种解决方案中, X86_64拥一个有512条目的PML4, 512条目的PGD, 512条目的PMD, 512条目的PTE。对于仍使用3级目录的架构来说, 它们依然拥有一个虚拟的PML4,相关的代码会在编译时被优化掉。 这样, 就把Linux内核的3级列表扩充为4级列表。这系列PATCH工作得不错, 不久被纳入Andrew Morton的-mm树接受测试。不出意外的话, 它将在v2.6.11版本中释出。但是, 另一个知名开发者Nick Piggin提出了一些看法, 他认为Andi的Patch很不错, 不过他认为最好还是把PGD作为第一级目录, 把新增加的层次放在中间, 并给出了他自己的Patch:alternate 4-level page tables patches。Andi更想保持自己的PATCH, 他认为Nick不过是玩了改名的游戏, 而且他的PATCH经过测试很稳定, 快被合并到主线了, 不宜再折腾。不过Linus却表达了对Nick Piggin的支持, 理由是Nick的做法conceptually least intrusive。毕竟作为Linux的扛把子, 稳定对于Linus来说意义重大。最终, 不意外地, 最后Nick Piggin的PATCH在v2.6.11版本中被合并入主线。在这种方案中, 4级页表分别是: PGD -> PUD -> PMD -> PTE。
请你回答一下为什么要有page cache, 操作系统怎么设计的page cache
加快从磁盘读取文件的速率。page cache中有一部分磁盘文件的缓存, 因为从磁盘中读取文件比较慢, 所以读取文件先去page cache中去查找, 如果命中, 则不需要去磁盘中读取, 大大加快读取速度。在 Linux 内核中, 文件的每个数据块最多只能对应一个 Page Cache 项, 它通过两个数据结构来管理这些 Cache项, 一个是radix tree, 另一个是双向链表。Radix tree 是一种搜索树, Linux内核利用这个数据结构来通过文件内偏移快速定位Cache 项
Linux内核线程实现原理
类Unix系统中, 早期是没有“线程”概念的, 80年代才引入, 借助进程机制实现出了线程的概念。因此在这类系统中, 进程和线程关系密切。
- 轻量级进程(light-weight process), 也有PCB, 创建线程使用的底层函数和进程一样, 都是clone
- 从内核里看进程和线程是一样的, 都有各自不同的PCB, 但是PCB中指向内存资源的三级页表是相同的
- 进程可以蜕变成线程
- 线程可看做寄存器和栈的集合
- 在linux下, 线程最是小的执行单位;进程是最小的分配资源单位
二级页表的图,它的映射只有两层:页目录→页表→页 而PAE则是:页目录指针页→页目录→页表→页
三级映射: 进程PCB --> 页目录(可看成数组, 首地址位于PCB中) --> 页表 --> 物理页面 --> 内存单元
对于进程来说, 相同的地址(同一个虚拟地址)在不同的进程中, 反复使用而不冲突。原因是他们虽虚拟址一样, 但, 页目录、页表、物理页面各不相同. 相同的虚拟址, 映射到不同的物理页面内存单元, 最终访问不同的物理页面
但!线程不同!两个线程具有各自独立的PCB, 但共享同一个页目录, 也就共享同一个页表和物理页面. 所以两个PCB共享一个地址空间.
实际上, 无论是创建进程的fork, 还是创建线程的pthread_create, 底层实现都是调用同一个内核函数clone. 如果复制对方的地址空间, 那么就产出一个“进程”; 如果共享对方的地址空间, 就产生一个“线程”. 因此: Linux内核是不区分进程和线程的. 只在用户层面上进行区分. 所以, 线程所有操作函数pthread_* 是库函数, 而非系统调用。
进程上下文怎么切换Linux进程上下文切换过程
挂起一个进程,将这个进程在 CPU 中的状态(上下文)存储于内存中的PCB
在内存中检索下一个进程的上下文并将其在 CPU 的寄存器中恢复
跳转到程序计数器所指向的位置(即跳转到进程被中断时的代码行),以恢复该进程
请你说一说并发(concurrency)和并行(parallelism)
并发(concurrency): 指宏观上看起来两个程序在同时运行, 比如说在单核cpu上的多任务。但是从微观上看两个程序的指令是交织着运行的, 你的指令之间穿插着我的指令, 我的指令之间穿插着你的, 在单个周期内只运行了一个指令。这种并发并不能提高计算机的性能, 只能提高效率。
并行(parallelism): 指严格物理意义上的同时运行, 比如多核cpu, 两个程序分别运行在两个核上, 两者之间互不影响, 单个周期内每个程序都运行了自己的指令, 也就是运行了两条指令。这样说来并行的确提高了计算机的效率。所以现在的cpu都是往多核方面发展。
进程与线程的概念
程序: 完成特定功能的一系列有序指令集合. 可执行文件称为程序, 可执行文件安装一定格式来组织: (1)指令组织保存在代码段中; (2)指令操作的数据保存在数据段中. 代码段+数据段
进程: 程序的一次动态执行过程. 比如我们运行一个游戏,打开一个软件,就是开启了一个进程。进程拥有代码和打开的文件资源、数据资源、独立的内存空间。
是系统进行资源调度和分配的的基本单位, 实现了操作系统的并发
线程: 在一个程序里的一个执行路线就叫做线程(thread), 线程从属于进程,是程序的实际执行者。一个进程至少包含一个主线程,也可以有更多的子线程。线程拥有自己的栈空间。 更准确的定义是: 线程是“一个进程内部的控制序列”. 一切进程至少都有一个执行线程, 实现进程内部的并发
对操作系统来说,线程是最小的执行单元,进程是最小的资源管理单元。无论进程还是线程,都是由操作系统所管理的。
协程
进程和线程的缺陷的如线程之间是如何进行协作的呢?
最经典的例子就是生产者/消费者模式:若干个生产者线程向队列中写入数据,若干个消费者线程从队列中消费数据。
生产者/消费者模式,但是却并不是一个高性能的实现。为什么性能不高呢?原因如下:
- 涉及到同步锁。
- 涉及到线程阻塞状态和可运行状态之间的切换。
- 涉及到线程上下文的切换。
以上涉及到的任何一点,都是非常耗费性能的操作。
协程,英文Coroutines,是一种比线程更加轻量级的存在。正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。
2)协程和线程区别
- 更高的执行效率. 协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行), 因此子程序切换不是线程切换, 而是由程序自身控制. 没有线程切换的开销, 和多线程比, 线程数量越多, 协程的性能优势就越明显。
- 不需要多线程的锁机制. 协程暂停和线程的阻塞是有本质区别的。协程的暂停完全由程序控制,线程的阻塞状态是由操作系统内核来进行切换。因为只有一个线程, 也不存在同时写变量冲突, 在协程中控制共享资源不加锁, 只需要判断状态就好了, 所以执行效率比多线程高很多。
在协程上利用多核CPU呢——多进程+协程, 既充分利用多核, 又充分发挥协程的高效率, 可获得极高的性能。
Python对协程的支持还非常有限, 用在generator中的yield可以一定程度上实现协程。虽然支持不完全, 但已经可以发挥相当大的威力了。
进程与线程的区别
资源 调度 通信 健壮
一个线程只能属于一个进程, 而一个进程可以有多个线程, 但至少有一个线程, 线程依赖于进程而存在. 进程是资源分配的最小单位, 线程是CPU调度的最小单位; 进程在执行过程中拥有独立的内存单元, 而多个线程共享进程的内存, 每个线程只拥有一些在运行中必不可少的私有属性, 比如pcb, 线程Id, 栈, 寄存器.
(资源分配给进程, 同一进程的所有线程共享该进程的所有资源. 同一进程中的多个线程共享代码段(代码和常量), 数据段(全局变量和静态变量), 扩展段(堆存储). 但是每个线程拥有自己的栈段, 栈段又叫运行时段, 用来存放所有局部变量和临时变量.)系统开销: 由于在创建或撤消进程时, 系统都要为之分配或回收资源, 如内存空间、IO设备等. 因此, 操作系统所付出的开销将显著地大于在创建或撤消线程时的开销。类似地, 在进行进程切换时, 涉及到整个当前进程CPU环境的保存以及新被调度运行的进程的CPU环境的设置。而线程切换只须保存和设置少量寄存器的内容, 并不涉及存储器管理方面的操作。可见, 进程切换的开销也远大于线程切换的开销。
进程间通信比较复杂, 需要进程同步和互斥手段的辅助, 以保证数据的一致性, 而同一进程的线程由于共享代码段和数据段, 所以通信比较容易. 进程编程调试简单可靠性高, 但是创建销毁开销大;线程正相反, 开销小, 切换速度快, 但是编程调试相对复杂。
- 一个进程崩溃, 不会对其他进程产生影响;而一个线程崩溃, 会让同一进程内的其他线程也死掉。
进程适应于多核、多机分布; 线程适用于多核
有了进程, 为什么还要有线程
线程产生的原因:
进程可以使多个程序能并发执行, 以提高资源的利用率和系统的吞吐量. 但是其具有一些缺点
- 进程在同一时间只能干一件事
- 进程在执行的过程中如果阻塞, 整个进程就会挂起, 即使进程中有些工作不依赖于等待的资源, 仍然不会执行
因此, 操作系统引入了比进程粒度更小的线程, 作为并发执行的基本单位, 从而减少程序在并发执行时所付出的时空开销, 提高并发性
与进程相比, 线程的优势如下:
- 从资源上来讲, 线程是一种非常"节俭"的多任务操作方式. 在linux系统下, 启动一个新的进程必须分配给它独立的地址空间, 建立众多的数据表来维护它的代码段、堆栈段和数据段, 这是一种"昂贵"的多任务工作方式
- 从切换效率上来讲, 运行于一个进程中的多个线程, 它们之间使用相同的地址空间, 而且线程间彼此切换所需时间也远远小于进程间切换所需要的时间. 据统计, 一个进程的开销大约是一个线程开销的30倍左右
- 从通信机制上来讲, 线程间方便的通信机制. 对不同进程来说, 它们具有独立的数据空间, 要进行数据的传递只能通过进程间通信的方式进行, 这种方式不仅费时, 而且很不方便. 线程则不然, 由于同一进城下的线程之间贡献数据空间, 所以一个线程的数据可以直接为其他线程所用, 这不仅快捷, 而且方便
除以上优点外, 多线程程序作为一种多任务、并发的工作方式, 还有如下优点:
- 使多CPU系统更加有效. 操作系统会保证当线程数不大于CPU数目时, 不同的线程运行于不同的CPU上
- 改善程序结构. 一个既长又复杂的进程可以考虑分为多个线程, 成为几个独立或半独立的运行部分, 这样的程序才会利于理解和修改
请你说一下多进程和多线程的使用场景
多进程模型的优势是CPU, 适用于CPU密集型。同时, 多进程模型也适用于多机分布式场景中, 易于多机扩展
多线程模型的优势是线程间切换代价较小, 因此适用于I/O密集型的工作场景, 因此I/O密集型的工作场景经常会由于I/O阻塞导致频繁的切换线程。同时, 多线程模型也适用于单机多核分布式场景
线程优点与缺点
优点: (1)提高程序并发性; (2)开销小; (3)数据通信、共享数据方便
缺点: (1)库函数, 不稳定; (2)调试、编写困难、gdb不支持; (3)对信号支持不好
优点相对突出, 缺点均不是硬伤。Linux下由于实现方法导致进程、线程差别不是很大
线程使用注意事项
- 主线程退出其他线程不退出, 主线程应调用pthread_exit
- 避免僵尸线程pthread_join, pthread_detach, pthread_create指定分离属性, 被join线程可能在join函数返回前就释放完自己的所有内存资源, 所以不应当返回被回收线程栈中的值;
- malloc和mmap申请的内存可以被其他线程释放
- 应避免在多线程模型中调用fork除非, 马上exec, 子进程中只有调用fork的线程存在, 其他线程在子进程中均pthread_exit
- 信号的复杂语义很难和多线程共存, 应避免在多线程引入信号机制
游戏服务器应该为每个用户开辟一个线程还是一个进程, 为什么
游戏服务器应该为每个用户开辟一个进程. 因为同一进程间的线程会相互影响, 一个线程死掉会影响其他线程, 从而导致进程崩溃. 因此为了保证不同用户之间不会相互影响, 应该为每个用户开辟一个进程
线程的共享资源与非共享资源
共享资源: (1)文件描述符表; (2)每种信号的处理方式; (3)当前工作目录; (4)用户ID和组ID; (5)内存地址空间 (.text/.data/.bss/heap/共享库); (6)环境变量, 命令行参数
非共享资源: (1)线程id; (2)处理器现场和栈指针(内核栈); (3)独立的栈空间(用户空间栈); (4)errno变量; (5)信号屏蔽字; (6)调度优先级; (7)栈, 若一共有5个线程, 则栈区被平均分成5份
进程的回收
一个进程在终止时会关闭所有文件描述符, 释放用户空间分配的内存, 但它的PCB还保留着, 内核在其中保存了一些信息: 如果是正常终止则保存着退出状态, 如果是异常终止则保存着退出状态, 如果是异常终止则保存着导致该进程终止的信号是哪个. 这个进程的父进程可以调用wait或waitpid获取这些信息, 然后彻底清除掉这个进程. 一个进程的退出状态可以在shell中用特殊变量$?
查看, 因为shell是它的父进程, 当它终止时shell调用wait或waitpid得到它的退出状态同时彻底清除掉这个进程
父进程调用wait函数可以回收子进程终止信息, 该函数有三个功能:
- 阻塞等待子进程退出
- 回收子进程残留资源
- 获取子进程结束状态(退出原因)
当进程终止时, 操作系统的隐式回收机制: (1) 关闭所有文件描述符; (2) 释放用户空间分配的内存. 内核的PCB仍存在. 其中保存该进程的退出状态(正常终止-->退出值; 异常终止-->终止信号)
孤儿进程&僵尸进程
1)正常进程
正常情况下, 子进程是通过父进程创建的, 子进程再创建新的进程. 子进程的结束和父进程的运行是一个异步过程, 即父进程永远无法预测子进程到底什么时候结束. 当一个进程完成它的工作终止之后, 它的父进程需要调用wait或者waitpid系统调用取得子进程的终止状态。
unix提供了一种机制可以保证只要父进程想知道子进程结束时的状态信息, 就可以得到: 在每个进程退出的时候, 内核释放该进程所有的资源, 包括打开的文件, 占用的内存等. 但是仍然为其保留一定的信息, 直到父进程通过wait/waitpid来取时才释放。保存信息包括:
- 进程号the process ID
- 退出状态the termination status of the process
- 运行时间the amount of CPU time taken by the process等
2)孤儿进程
一个父进程退出, 而它的一个或多个子进程还在运行, 那么那些子进程将成为孤儿进程. 孤儿进程将被init进程(进程号为1)所收养, 并由init进程对它们完成状态收集工作.这是为了释放进程占用的系统资源. 进程结束之后, 能够释放用户空间, 但释放不了pcb, 即内核资源, 必须由父进程释放, 当init进程领养孤儿进程之后就可以释放了.
3)僵尸进程
一个进程使用fork创建子进程, 如果子进程退出, 而父进程并没有调用wait或waitpid获取子进程的状态信息释放子进程的pcb, 那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵尸进程
僵尸进程的产生主要由于进程死后, 大部分资源被释放, 一点残留资源仍存于系统中, 导致内核认为该进程仍存在。
僵尸进程是一个进程必然会经过的过程: 这是每个子进程在结束时都要经过的阶段
如果子进程在pthread_exit之后, 父进程没有来得及处理, 这时用ps命令就能看到子进程的状态是“Z”. 如果父进程能及时处理, 可能用ps命令就来不及看到子进程的僵尸状态, 但这并不等于子进程不经过僵尸状态。也就是说父进程没有结束, 但是子进程结束了, 父进程没死且没有回收子进程, 就没办法给子进程收尸, 只有父进程死了子进程会交给init进程才能收尸
危害: 如果进程不调用wait/waitpid的话, 那么保留的那段信息就不会释放, 其进程号就会一直被占用, 但是系统所能使用的进程号是有限的, 如果大量的产生僵死进程, 将因为没有可用的进程号而导致系统不能产生新的进程。
外部消灭:
通过kill发送SIGTERM或者SIGKILL信号消灭产生僵尸进程的进程, 它产生的僵死进程就变成了孤儿进程, 这些孤儿进程会被init进程接管, init进程会wait()这些孤儿进程, 释放它们占用的系统进程表中的资源
内部解决:
- 子进程退出时向父进程发送SIGCHILD信号, 父进程处理SIGCHILD信号。在信号处理函数中调用wait进行处理僵尸进程。
- 父进程调用wait/waitpid等函数等待子进程结束,如果尚无子进程退出wait会导致父进程阻塞。waitpid可以通过传递WNOHANG使父进程不阻塞立即返回。
- 通过两次调用fork. 父进程首先调用fork创建一个子进程然后waitpid等待子进程退出, 子进程再fork一个孙进程后退出. 这样子进程退出后会被父进程等待回收, 而对于孙子进程其父进程已经退出所以孙进程成为一个孤儿进程, 孤儿进程由init进程接管, 孙进程结束后, init会等待回收
请你回答一下fork和vfork的区别
fork:创建一个和当前进程映像一样的进程
成功调用fork会创建一个新的进程,它几乎与调用fork的进程一模一样,这两个进程都会继续运行。在子进程中,成功的fork调用会返回0。在父进程中fork返回子进程的pid。如果出现错误,fork返回一个负值。
最常见的fork用法是创建一个新的进程,然后使用exec载入二进制映像,替换当前进程的映像。这种情况下,派生(fork)了新的进程,而这个子进程会执行一个新的二进制可执行文件的映像。这种“派生加执行”的方式是很常见的。
在早期的Unix系统中,创建进程比较原始。当调用fork时,内核会把所有的内部数据结构复制一份,复制进程的页表项,然后把父进程的地址空间中的内容逐页的复制到子进程的地址空间中。但从内核角度来说,逐页的复制方式是十分耗时的。现代的Unix系统采取了更多的优化,例如Linux,采用了写时复制的方法,而不是对父进程空间进程整体复制。
vfork的基础知识: 在实现写时复制之前, Unix的设计者们就一直很关注在fork后立刻执行exec所造成的地址空间的浪费.
vfork跟fork类似,都是创建一个子进程,这两个函数的的返回值也具有相同的含义。但是vfork创建的子进程基本上只能做一件事,那就是立即调用_exit函数或者exec函数族成员,调用任何其它函数(包括exit)、修改任何数据(除了保存vfork()返回值的那个变量)、执行任何其它语句(包括return)都是不应该的。此外,调用vfork()之后,父进程会一直阻塞,直到子进程调用_exit()终止,或者调用exec函数族成员。通过这样的方式, vfork避免了地址空间的按页复制. 在这个过程中, 父进程和子进程共享相同的地址空间和页表项. 实际上vfork只完成了一件事: 复制内部的内核数据结构. 因此, 子进程也就不能修改地址空间中的任何内存
fork和vfork的区别:
- fork会复制父进程的页表,而vfork不会复制,直接让子进程共用父进程的页表;
- fork使用了写时复制技术,而vfork没有,它任何时候都不会复制父进程地址空间。
- fork的父子进程的执行次序不确定;vfork保证子进程先运行, 在调用exec或exit之前与父进程数据是共享的, 在它调用exec或exit之后父进程才可能被调度运行, 如果在调用这两个函数之前子进程依赖于父进程的进一步动作, 则会导致死锁。
- 就算是fork使用了写时拷贝,也没有vfork性能高.
- 每个系统上的vfork都有问题,推荐不要使用.
补充知识点: 写时复制
Linux采用了写时复制的方法, 以减少fork时对父进程空间进程整体复制带来的开销
写时复制是一种采取了惰性优化方法来避免复制时的系统开销。它的前提很简单: 如果有多个进程要读取它们自己的那部门资源的副本, 那么复制是不必要的. 每个进程只要保存一个指向这个资源的指针就可以了. 只要没有进程要去修改自己的“副本”, 就存在着这样的幻觉: 每个进程好像独占那个资源. 从而就避免了复制带来的负担. 如果一个进程要修改自己的那份资源“副本”, 那么就会复制那份资源, 并把复制的那份提供给进程. 不过其中的复制对进程来说是透明的. 这个进程就可以修改复制后的资源了, 同时其他的进程仍然共享那份没有修改过的资源. 所以这就是名称的由来: 在写入时进行复制
写时复制的主要好处在于: 如果进程从来就不需要修改资源, 则不需要进行复制。惰性算法的好处就在于它们尽量推迟代价高昂的操作, 直到必要的时刻才会去执行。
在使用虚拟内存的情况下, 写时复制(Copy-On-Write)是以页为基础进行的。所以, 只要进程不修改它全部的地址空间, 那么就不必复制整个地址空间。在fork调用结束后, 父进程和子进程都相信它们有一个自己的地址空间, 但实际上它们共享父进程的原始页, 接下来这些页又可以被其他的父进程或子进程共享。
写时复制在内核中的实现非常简单。与内核页相关的数据结构可以被标记为只读和写时复制。如果有进程试图修改一个页, 就会产生一个缺页中断。内核处理缺页中断的方式就是对该页进行一次透明复制。这时会清除页面的COW属性, 表示着它不再被共享。
现代的计算机系统结构中都在内存管理单元(MMU)提供了硬件级别的写时复制支持, 所以实现是很容易的。
在调用fork时, 写时复制是有很大优势的。因为大量的fork之后都会跟着执行exec, 那么复制整个父进程地址空间中的内容到子进程的地址空间完全是在浪费时间: 如果子进程立刻执行一个新的二进制可执行文件的映像, 它先前的地址空间就会被交换出去。写时复制可以对这种情况进行优化。
进程状态转换图, 活动阻塞, 静止阻塞, 活动就绪, 静止就绪
1、进程的五种基本状态:
1)创建状态: 进程正在被创建
2)就绪状态: 进程被加入到就绪队列中等待CPU调度运行
3)执行状态: 进程正在被运行
4)等待阻塞状态: 进程因为某种原因, 比如等待I/O, 等待设备, 而暂时不能运行。
5)终止状态: 进程运行完毕
2、交换技术
当多个进程竞争内存资源时, 会造成内存资源紧张, 并且, 如果此时没有就绪进程, 处理机会空闲, I/0速度比处理机速度慢得多, 可能出现全部进程阻塞等待I/O
针对以上问题, 提出了两种解决方法:
- 交换技术: 换出一部分进程到外存, 腾出内存空间。
- 虚拟存储技术: 每个进程只能装入一部分程序和数据。
在交换技术上, 将内存暂时不能运行的进程, 或者暂时不用的数据和程序, 换出到外存, 来腾出足够的内存空间, 把已经具备运行条件的进程, 或进程所需的数据和程序换入到内存. 从而出现了进程的挂起状态: 进程被交换到外存, 进程状态就成为了挂起状态。
3、活动阻塞, 静止阻塞, 活动就绪, 静止就绪
- 活动阻塞: 进程在内存, 但是由于某种原因被阻塞了。
- 静止阻塞: 进程在外存, 同时被某种原因阻塞了。
- 活动就绪: 进程在内存, 处于就绪状态, 只要给CPU和调度就可以直接运行。
- 静止就绪: 进程在外存, 处于就绪状态, 只要调度到内存, 给CPU和调度就可以运行。
从而出现了:
活动就绪 -> 静止就绪(内存不够, 调到外存)
活动阻塞 -> 静止阻塞(内存不够, 调到外存)
执行 -> 静止就绪(时间片用完)
就绪状态的进程在等待什么?
被调度使用cpu的运行权
进程间通信
linux环境下, 进程地址空间相互独立, 每个进程各自有一个不同的用户地址空间. 任何一个进程的全局变量在另一个进程中都看不到, 所以进程之间不能相互访问, 要交换数据必须通过内核. 在内核中开辟一块缓冲区, 进程1把数据从用户空间拷贝到内核缓冲区, 进程2再从内核缓冲区把数据读走, 内核提供的这种机制称为进程通信(IPC, InterProcess Communication)
在进程间完成数据传递需要借助操作系统提供的特殊方法, 如: 文件, 管道, 信号, 共享内存, 消息队列, 套接字, 命名管道. 随着计算机的蓬勃发展, 一些方法由于自身设计缺陷被淘汰或者弃用. 现今常用的进程间通信方式有:
- 管道(使用最简单, 有血缘关系, 无血缘关系时使用命名管道)
- 信号(开销最小)
- 共享内存映射区(无血缘关系)
本地套接字(最稳定)
- 管道: 管道主要包括无名管道和命名管道:管道可用于具有亲缘关系的父子进程间的通信, 有名管道除了具有管道所具有的功能外, 它还允许无亲缘关系进程间的通信
1.1 普通管道PIPE:
1)它是半双工的(即数据只能在一个方向上流动), 具有固定的读端和写端
2)它只能用于具有亲缘关系的进程之间的通信(也是父子进程或者兄弟进程之间)
3)它可以看成是一种特殊的文件, 对于它的读写也可以使用普通的read、write等函数。但是它不是普通的文件, 并不属于其他任何文件系统, 并且只存在于内存中。
1.2 命名管道FIFO:
1)FIFO可以在无血缘关系的进程之间交换数据
2)FIFO有路径名与之相关联, 它以一种特殊设备文件形式存在于文件系统中。 - 系统IPC:
2.1 消息队列: 是消息的链接表, 存放在内核中。一个消息队列由一个标识符(即队列ID)来标记。 (消息队列克服了信号传递信息少, 管道只能承载无格式字节流以及缓冲区大小受限等特点)具有写权限得进程可以按照一定得规则向消息队列中添加新信息;对消息队列有读权限得进程则可以从消息队列中读取信息;
特点:
1)消息队列是面向记录的, 其中的消息具有特定的格式以及特定的优先级。
2)消息队列独立于发送与接收进程。进程终止时, 消息队列及其内容并不会被删除。
3)消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取。
2.2 信号量semaphore: 与已经介绍过的 IPC 结构不同, 它是一个计数器, 可以用来控制多个进程对共享资源的访问。信号量用于实现进程间的互斥与同步, 而不是用于存储进程间通信数据。
特点:
1)信号量用于进程间同步, 若要在进程间传递数据需要结合共享内存。
2)信号量基于操作系统的 PV 操作, 程序对信号量的操作都是原子操作。
3)每次对信号量的 PV 操作不仅限于对信号量值加 1 或减 1, 而且可以加减任意正整数。
4)支持信号量组。
2.3 信号signal: 信号是一种比较复杂的通信方式, 用于通知接收进程某个事件已经发生。
2.4 共享内存(Shared Memory): 它使得多个进程可以访问同一块内存空间, 不同进程可以及时看到对方进程中对共享内存中数据得更新。这种方式需要依靠某种同步操作, 如互斥锁和信号量等
特点:
1)共享内存是最快的一种IPC, 因为进程是直接对内存进行存取
2)因为多个进程可以同时操作, 所以需要进行同步
3)信号量+共享内存通常结合在一起使用, 信号量用来同步对共享内存的访问 套接字SOCKET: socket也是一种进程间通信机制, 与其他通信机制不同的是, 它可用于不同主机之间的进程通信。
线程间通信的方式
线程间通信的方式:
- 临界区: 通过多线程的串行化来访问公共资源或一段代码, 速度快, 适合控制数据访问;
- 互斥量: 采用互斥对象机制, 只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个, 所以可以保证公共资源不会被多个线程同时访问
- 信号量: 为控制具有有限数量的用户资源而设计的, 它允许多个线程在同一时刻去访问同一个资源, 但一般需要限制同一时刻访问此资源的最大线程数目。
- 事件(信号), Wait/Notify: 通过通知操作的方式来保持多线程同步, 还可以方便的实现多线程优先级的比较操作
请你说一说线程间的同步方式, 最好说出具体的系统调用
信号量
信号量是一种特殊的变量, 可用于线程同步。它只取自然数值, 并且只支持两种操作:
P(SV):如果信号量SV大于0, 将它减一;如果SV值为0, 则挂起该线程。
V(SV): 如果有其他进程因为等待SV而挂起, 则唤醒, 然后将SV+1;否则直接将SV+1。
其系统调用为:
sem_init
sem_destroy
sem_trywait
sem_timedwait
sem_wait: 以原子操作的方式将信号量减1, 如果信号量值为0, 则sem_wait将被阻塞, 直到这个信号量具有非0值。
sem_post: 以原子操作将信号量值+1。当信号量大于0时, 其他正在调用sem_wait等待信号量的线程将被唤醒。
互斥量
互斥量又称互斥锁, 主要用于线程互斥, 不能保证按序访问, 可以和条件锁一起实现同步。当进入临界区 时, 需要获得互斥锁并且加锁;当离开临界区时, 需要对互斥锁解锁, 以唤醒其他等待该互斥锁的线程。其主要的系统调用如下:
pthread_mutex_init:初始化互斥锁
pthread_mutex_destroy: 销毁互斥锁
pthread_mutex_lock: 以原子操作的方式给一个互斥锁加锁, 如果目标互斥锁已经被上锁, pthread_mutex_lock调用将阻塞, 直到该互斥锁的占有者将其解锁。
pthread_mutex_unlock:以一个原子操作的方式给一个互斥锁解锁。
pthread_mutex_trylock
条件变量
条件变量, 又称条件锁, 用于在线程之间同步共享数据的值。条件变量提供一种线程间通信机制: 当某个共享数据达到某个值时, 唤醒等待这个共享数据的一个/多个线程。即, 当某个共享变量等于某个值时, 调用 signal/broadcast。此时操作共享变量时需要加锁。其主要的系统调用如下:
pthread_cond_init:初始化条件变量
pthread_cond_destroy: 销毁条件变量
pthread_cond_signal: 唤醒一个等待目标条件变量的线程。哪个线程被唤醒取决于调度策略和优先级。
pthread_cond_broadcast
pthread_cond_wait: 等待目标条件变量。需要一个加锁的互斥锁确保操作的原子性。该函数中在进入wait状态前首先进行解锁, 然后接收到信号后会再加锁, 保证该线程对共享资源正确访问。
pthread_cond_timedwait
C++的锁你知道几种
锁包括互斥锁, 条件变量, 自旋锁和读写锁
自旋锁和互斥量的区别
自旋锁和互斥量是忙等与阻塞的区别
两个进程访问临界区资源, 会不会出现都获得自旋锁的情况?
单核cpu, 并且开了抢占可以造成这种情况。
自旋锁
自旋锁存在的问题
如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU。使用不当会造成CPU使用率极高。
上面Java实现的自旋锁不是公平的,即无法满足等待时间最长的线程优先获取锁。不公平的锁就会存在“线程饥饿”问题。
自旋锁的优点
自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快
非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。 (线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能)
说一说你用到的锁
生产者消费者问题利用互斥锁和条件变量可以很容易解决, 条件变量这里起到了替代信号量的作用
请你说一说多线程的同步, 锁的机制
同步的时候用一个互斥量, 在访问共享资源前对互斥量进行加锁, 在访问完成后释放互斥量上的锁。对互斥量进行加锁以后, 任何其他试图再次对互斥量加锁的线程将会被阻塞直到当前线程释放该互斥锁。如果释放互斥锁时有多个线程阻塞, 所有在该互斥锁上的阻塞线程都会变成可运行状态, 第一个变为运行状态的线程可以对互斥量加锁, 其他线程将会看到互斥锁依然被锁住, 只能回去再次等待它重新变为可用。在这种方式下, 每次只有一个线程可以向前执行
请问单核机器上写多线程程序, 是否需要考虑加锁, 为什么?
在单核机器上写多线程程序, 仍然需要线程锁。因为线程锁通常用来实现线程的同步和通信。在单核机器上的多线程程序, 仍然存在线程同步的问题。因为在抢占式操作系统中, 通常为每个线程分配一个时间片, 当某个线程时间片耗尽时, 操作系统会将其挂起, 然后运行另一个线程。如果这两个线程共享某些数据, 不使用线程锁的前提下, 可能会导致共享数据修改引起冲突。
请你讲述一下互斥锁(mutex)机制, 以及互斥锁和读写锁的区别
1、互斥锁和读写锁区别:
互斥锁: mutex, 用于保证在任何时刻, 都只能有一个线程访问该对象。当获取锁操作失败时, 线程会进入睡眠, 等待锁释放时被唤醒。
读写锁: rwlock, 分为读锁和写锁。处于读操作时, 可以允许多个线程同时获得读操作。但是同一时刻只能有一个线程可以获得写锁。其它获取写锁失败的线程都会进入睡眠状态, 直到写锁释放时被唤醒。 注意: 写锁会阻塞其它读写锁。当有一个线程获得写锁在写时, 读锁也不能被其它线程获取;写者优先于读者(一旦有写者, 则后续读者必须等待, 唤醒时优先考虑写者)。适用于读取数据的频率远远大于写数据的频率的场合。
互斥锁和读写锁的区别:
1)读写锁区分读者和写者, 而互斥锁不区分
2)互斥锁同一时间只允许一个线程访问该对象, 无论读写;读写锁同一时间内只允许一个写者, 但是允许多个读者同时读对象。
2、Linux的4种锁机制:
互斥锁: mutex, 用于保证在任何时刻, 都只能有一个线程访问该对象。当获取锁操作失败时, 线程会进入睡眠, 等待锁释放时被唤醒
读写锁: rwlock, 分为读锁和写锁。处于读操作时, 可以允许多个线程同时获得读操作。但是同一时刻只能有一个线程可以获得写锁。其它获取写锁失败的线程都会进入睡眠状态, 直到写锁释放时被唤醒。 注意: 写锁会阻塞其它读写锁。当有一个线程获得写锁在写时, 读锁也不能被其它线程获取;写者优先于读者(一旦有写者, 则后续读者必须等待, 唤醒时优先考虑写者)。适用于读取数据的频率远远大于写数据的频率的场合。
自旋锁: spinlock, 在任何时刻同样只能有一个线程访问对象。但是当获取锁操作失败时, 不会进入睡眠, 而是会在原地自旋, 直到锁被释放。这样节省了线程从睡眠状态到被唤醒期间的消耗, 在加锁时间短暂的环境下会极大的提高效率。但如果加锁时间过长, 则会非常浪费CPU资源。
RCU: 即read-copy-update, 在修改数据时, 首先需要读取数据, 然后生成一个副本, 对副本进行修改。修改完成后, 再将老数据update成新的数据。使用RCU时, 读者几乎不需要同步开销, 既不需要获得锁, 也不使用原子指令, 不会导致锁竞争, 因此就不用考虑死锁问题了。而对于写者的同步开销较大, 它需要复制被修改的数据, 还必须使用锁机制同步并行其它写者的修改操作。在有大量读操作, 少量写操作的情况下效率非常高。
死锁发生的条件以及如何解决死锁
死锁是指两个或两个以上进程在执行过程中, 因争夺资源而造成的相互等待的现象。
死锁发生的四个必要条件如下:
- 互斥条件: 进程对所分配到的资源不允许其他进程访问, 若其他进程访问该资源, 只能等待, 直至占有该资源的进程使用完成后释放该资源;
- 请求和保持条件: 进程获得一定的资源后, 又对其他资源发出请求, 但是该资源可能被其他进程占有, 此时请求阻塞, 但该进程不会释放自己已经占有的资源
- 不可剥夺条件: 进程已获得的资源, 在未完成使用之前, 不可被剥夺, 只能在使用后自己释放
- 环路等待条件: 进程发生死锁后, 必然存在一个进程-资源之间的环形链
解决死锁的方法即破坏上述四个条件之一, 主要方法如下:
- 资源一次性分配, 从而剥夺请求和保持条件
- 可剥夺资源: 即当进程新的资源未得到满足时, 释放已占有的资源, 从而破坏不可剥夺的条件
- 资源有序分配法: 系统给每类资源赋予一个序号, 每个进程按编号递增的请求资源, 释放则相反, 从而破坏环路等待的条件
介绍一下5种IO模型
- 阻塞IO: 调用者调用了某个函数, 等待这个函数返回, 期间什么也不做, 不停的去检查这个函数有没有返回, 必须等这个函数返回才能进行下一步动作
- 非阻塞IO: 非阻塞等待, 每隔一段时间就去检测IO事件是否就绪, 没有就绪就可以做其他事。
- 信号驱动IO: linux用套接字进行信号驱动IO, 安装一个信号处理函数, 进程继续运行并不阻塞, 当IO时间就绪, 进程收到SIGIO信号。然后处理IO事件。
- IO复用/多路转接IO: linux用select/poll函数实现IO复用模型, 这两个函数也会使进程阻塞, 但是和阻塞IO所不同的是这两个函数可以同时阻塞多个IO操作. 而且可以同时对多个读操作、写操作的IO函数进行检测, 直到有数据可读或可写时, 才真正调用IO操作函数
- 异步IO: linux中, 可以调用aio_read函数告诉内核描述字缓冲区指针和缓冲区的大小、文件偏移及通知的方式, 然后立即返回, 当内核将数据拷贝到缓冲区后, 再通知应用程序。
简述同步IO、异步IO、阻塞IO、非阻塞IO之间的联系与区别
异步编程的事件循环
事件循环就是不停循环等待时间的发生, 然后将这个事件的所有处理器, 以及他们订阅这个事件的时间顺序依次依次执行。当这个事件的所有处理器都被执行完毕之后, 事件循环就会开始继续等待下一个事件的触发, 不断往复。当同时并发地处理多个请求时, 以上的概念也是正确的, 可以这样理解: 在单个的线程中, 事件处理器是一个一个按顺序执行的。即如果某个事件绑定了两个处理器, 那么第二个处理器会在第一个处理器执行完毕后, 才开始执行。在这个事件的所有处理器都执行完毕之前, 事件循环不会去检查是否有新的事件触发。在单个线程中, 一切都是有顺序地一个一个地执行的!
怎么实现线程池
- 设置一个生产者消费者队列, 作为临界资源
- n个线程, 并让其运行起来, 加锁去队列取任务运行
- 当任务队列为空的时候, 所有线程阻塞
- 当生产者队列来了一个任务后, 先对队列加锁, 把任务挂在到队列上, 然后使用条件变量去通知阻塞中的一个线程
死循环+来连接时新建线程的方法效率有点低, 怎么改进?
提前创建好一个线程池, 用生产者消费者模型, 创建一个任务队列, 队列作为临界资源, 有了新连接, 就挂在到任务队列上, 队列为空所有线程睡眠。
改进死循环: 使用select epoll这样的技术
请你说说select,epoll的区别,原理,性能,限制都说一说
- IO多路复用
IO复用模型在阻塞IO模型上多了一个select函数,select函数有一个参数是文件描述符集合,意思就是对这些的文件描述符进行循环监听,当某个文件描述符就绪的时候,就对这个文件描述符进行处理。
这种IO模型是属于阻塞的IO。但是由于它可以对多个文件描述符进行阻塞监听,所以它的效率比阻塞IO模型高效。
IO多路复用就是我们说的select,poll,epoll。select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select,poll,epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。
当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。
所以,I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回。
I/O多路复用和阻塞I/O其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个system call (select 和 recvfrom),而blocking IO只调用了一个system call (recvfrom)。但是,用select的优势在于它可以同时处理多个connection。
所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)
在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。
- select
select:是最初解决IO阻塞问题的方法。用结构体fd_set来告诉内核监听多个文件描述符,该结构体被称为描述符集。由数组来维持哪些描述符被置位了。对结构体的操作封装在三个宏定义中。通过轮寻来查找是否有描述符要被处理。
存在的问题:
(1)内置数组的形式使得select的最大文件数受限与FD_SIZE;
(2)每次调用select前都要重新初始化描述符集,将fd从用户态拷贝到内核态,每次调用select后,都需要将fd从内核态拷贝到用户态;
(3)轮寻排查当文件描述符个数很多时,效率很低; - poll
poll:通过一个可变长度的数组解决了select文件描述符受限的问题。数组中元素是结构体,该结构体保存描述符的信息,每增加一个文件描述符就向数组中加入一个结构体,结构体只需要拷贝一次到内核态。poll解决了select重复初始化的问题。轮寻排查的问题未解决。
(4)epoll
epoll:轮寻排查所有文件描述符的效率不高,使服务器并发能力受限。因此,epoll采用只返回状态发生变化的文件描述符,便解决了轮寻的瓶颈。
epoll对文件描述符的操作有两种模式:LT(level trigger)和ET(edge trigger)。LT模式是默认模式
(1)LT模式
LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket.在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的。
(2)ET模式
ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)
ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
(3)LT模式与ET模式的区别如下:
LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。
ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。
处理僵尸进程的两种经典方法
方法一:父进程回收法, wait函数将使其调用者阻塞,直到其某个子进程终止。故父进程可调用wait函数回收其僵尸子进程。
方法二:init进程回收法
进程终止方式
linux的进程终止方式有8种,其中5种是正常终止,分别是:
从main函数返回。
- 调用exit函数。
- 调用_exit或_Exit。
- 最后一个线程从其启动例程返回。
- 最后一个线程调用pthread_exit。
异常终止有3种,分别是:
- 调用abort函数。
- 接收到信号并终止。
- 最后一个线程对取消请求做出响应。
释放进程剩余的资源
当一个进程终止之后,内核会向其父进程发送SIGCHLD信号(何时发?)。父进程在SIGCHLD的信号处理函数中调用wait()函数,获得已终结的子进程信息后,调用release_task()函数,释放其占用的剩余资源。
如果父进程创建了子进程,但是又不想负责回收子进程占用的资源,可以使用两次fork的方法:
父进程创建一个子进程,子进程再创建孙进程执行需要执行的操作,然后子进程退出。
由于子进程退出了,那么孙进程将会被init进程托管,所以其资源的回收也将由系统来负责。
从main函数return返回
调用exit(C库函数)
调用_exit(系统调用)
调用abort(产生SIGABRT信号,异常终止)
由信号终止(如ctrl+c 产生的 SIGINT信号)