操作系统

时间:2019-08-21
本文章向大家介绍操作系统,主要包括操作系统使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

进程和线程切换时会发生什么

什么是上下文切换

上下文切换就是从当前执行任务切换到另一个任务执行的过程。但是,为了确保下次能从正确的位置继续执行,在切换之前,会保存上一个任务的状态。

线程上下文切换和进程上下文切换的区别

进程切换分两步

  1. 切换页目录以使用新的地址空间
  2. 切换内核栈和硬件上下文。

对于linux来说,线程和进程的最大区别就在于地址空间。
对于线程切换,第1步是不需要做的,第2是进程和线程切换都要做的。所以明显是进程切换代价大

线程上下文切换和进程上下问切换一个最主要的区别是线程的切换虚拟内存空间依然是相同的,但是进程切换是不同的。这两种上下文切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。

另外一个隐藏的损耗是上下文的切换会扰乱处理器的缓存机制。简单的说,一旦去切换上下文,处理器中所有已经缓存的内存地址一瞬间都作废了。还有一个显著的区别是当你改变虚拟内存空间的时候,处理的页表缓冲(processor’s Translation Lookaside Buffer (TLB))或者相当的神马东西会被全部刷新,这将导致内存的访问在一段时间内相当的低效。但是在线程的切换中,不会出现这个问题。

用户级线程和内核级线程

线程的实现可以分为两类:用户级线程(User-Level Thread)和内核线线程(Kernel-Level Thread),后者又称为内核支持的线程或轻量级进程。在多线程操作系统中,各个系统的实现方式并不相同,在有的系统中实现了用户级线程,有的系统中实现了内核级线程。

  1. 内核级线程:切换由内核控制,当线程进行切换的时候,由用户态转化为内核态。切换完毕要从内核态返回用户态;可以很好的利用smp,即利用多核cpu。windows线程就是这样的。
  2. 用户级线程内核的切换由用户态程序自己控制内核切换,不需要内核干涉,少了进出内核态的消耗,但不能很好的利用多核Cpu,目前Linux pthread大体是这么做的。

进程间切换的步骤

  1. 保存程序计数其以及其他寄存器。
  2. 更新当前处于“运行态”的进程的进程控制块,把进程状态改为相应状态,更新其他相关域
  3. 把被切换进程的进程控制块移到相关状态的队列
  4. 选择另外一个进程开始执行,把该进程进程控制块的状态改为“运行态”
  5. 恢复被选择进程的处理器在最近一次被切换出运行态时的上下文,比如载入程序计数器以及其他处理器的值
    进程间切换伴随着两次模式切换(用户--内核,内核--用户)。

(同一进程内)线程间切换的步骤:线程分两种,用户级线程和内核级线程

在用户级线程中,有关线程管理的所有工作都由应用程序完成,内核没有意识到线程的存在。

(同一进程内)用户级线程间切换时,只需要保存用户寄存器的内容,程序计数器,栈指针,不需要模式切换。

缺点:

  1. 在进程的某个线程执行系统调用时,不仅该线程被阻塞,该线程所在进程的所有线程都被阻塞
  2. 无法利用多处理器

在内核级线程中,有关线程的管理工作都是由内核完成的,应用程序部分没有线程管理的权限,只有一个接口(API), 内核级线程间切换时,除了保存上下文,还要进行模式切换。

优点:

  1. 可以利用多处理器
  2. 线程阻塞不会导致进程阻塞

C++内存模型

C++中,内存分成5个区,他们分别是堆、栈、自由存储区、全局/静态存储区和常量存储区

  1. 栈,就是那些由编译器在需要的时候分配,在不需要的时候自动清楚的变量的存储区。里面的变量通常是局部变量、函数参数等。
  2. 堆,就是那些由new分配的内存块,他们的释放编译器不去管,由我们的应用程序去控制,一般一个new就要对应一个delete。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收。
  3. 自由存储区,就是那些由malloc等分配的内存块,他和堆是十分相似的,不过它是用free来结束自己的生命的。
  4. 全局/静态存储区,全局变量和静态变量被分配到同一块内存中,在以前的C语言中,全局变量又分为初始化的和未初始化的,在C++里面没有这个区分了,他们共同占用同一块内存区。
  5. 常量存储区,这是一块比较特殊的存储区,他们里面存放的是常量,不允许修改(当然,你要通过非正当手段也可以修改)

请你说一说C++的内存管理是怎样的?

在C++中, 虚拟内存分为代码段、数据段、BSS段、堆区、文件映射区以及栈区六部分.

堆, 栈, 全局(静态)存储区对应C的数据区(数据段+bss段), 自由存储区, 常量存储区
C中数据区: 数据段+bss段

堆和栈的区别?

申请方式不同。
  栈由系统自动分配。
  堆由程序员手动分配。
申请大小限制不同。
  栈顶和栈底是之前预设好的,大小固定,可以通过ulimit -a查看,由ulimit -s修改。
  堆向高地址扩展,是不连续的内存区域,大小可以灵活调整。
申请效率不同。
  栈由系统分配,速度快,不会有碎片。
  堆由程序员分配,速度慢,且会有碎片。

请你来说一下C++/C的内存分配

32bitCPU可寻址4G线性空间, 每个进程都有各自独立的4G逻辑地址, 其中0~3G是用户态空间, 3~4G是内核空间, 不同进程相同的逻辑地址会映射到不同的物理地址中. 其逻辑地址其划分如下:

各个段说明如下: 3G用户空间和1G内核空间

  1. 静态区域:
      text segment(代码段):包括只读存储区和文本区, 其中只读存储区存储字符串常量, 文本区存储程序的机器代码.
      data segment(数据段): 存储程序中已初始化的全局变量和静态变量
      bss segment: 存储未初始化的全局变量和静态变量(局部+全局) , 以及所有被初始化为0的全局变量和静态变量, 对于未初始化的全局变量和静态变量, 程序运行main之前时会统一清零. 即未初始化的全局变量编译器会初始化为0
  2. 动态区域:
      heap(堆): 当进程未调用malloc时是没有堆段的, 只有调用malloc时采用分配一个堆, 并且在程序运行过程中可以动态增加堆大小(移动break指针), 从低地址向高地址增长. 分配小内存时使用该区域. 堆的起始地址由mm_struct 结构体中的start_brk标识, 结束地址由brk标识.
      memory mapping segment(映射区):存储动态链接库等文件映射、申请大内存(malloc时调用mmap函数)
      stack(栈): 使用栈空间存储函数的返回地址、参数、局部变量、返回值, 从高地址向低地址增长. 在创建进程时会有一个最大栈大小, Linux可以通过ulimit命令指定.

操作系统中的程序的内存结构

.txt, .bss, 堆, 栈, 动态库加载区, 环境变量, 命令行参数

代码编译后的机器指令经常被放在代码段里, 代码段名为.text; 已初始化的全局变量和已初始化的局部静态变量经常放在数据段里, 数据段名为.data; 未初始化的全局变量和未初始化局部静态变量一般放在.bss段里, .bss在文件中不占据空间. 字符串常量一般放在.rodata段里

.bss: 通常用来存放程序中未初始化的全局变量和静态变量的一块内存区域. 属于静态内存分配, 程序结束后静态变量资源由系统自动释放
.data: 存放程序中已初始化的全局变量的一块内存区域. 数据段也属于静态内存分配
.text: 存放程序执行代码的一块内存区域. 这部分区域的大小在程序运行前就已经确定, 并且内存区域属于只读。在代码段中, 也有可能包含一些只读的常数变量

text段和data段在编译时已经分配了空间(数据保存到目标文件中), 而bss段并不占用可执行文件的大小, 并不给该段的数据分配空间, 只是记录数据所需空间的大小(记录在段表里, 记录的是所有未初始化变量总共的大小). 它是由链接器来获取内存的

data段包含经过初始化的全局变量以及它们的值. bss段的大小从可执行文件中得到, 然后链接器得到这个大小的内存块, 紧跟在data段的后面. 当这个内存进入程序的地址空间后全部清零. 包含data段和bss段的整个区段此时通常称为数据区

data中的局部静态变量在第一次进入函数中进行初始化

可执行程序在运行时又多出两个区域: 栈区和堆区。

  1. 栈区: 由编译器自动释放, 存放函数的参数值、局部变量等. 每当一个函数被调用时, 该函数的返回类型和一些调用的信息被存放到栈中。然后这个被调用的函数再为他的自动变量和临时变量在栈上分配空间. 每调用一个函数一个新的栈就会被使用. 栈区是从高地址位向低地址位增长, 是一块连续的内存区域, 最大容量是由系统预先定义好的, 申请的栈空间超过这个界限时会提示溢出, 用户能从栈中获取的空间较小。
  2. 堆区: 用于动态分配内存, 位于bss和栈中间的地址区域. 由程序员申请分配和释放. 堆是从低地址位向高地址位增长, 采用链式存储结构. 频繁的malloc/free造成内存空间的不连续, 产生碎片. 当申请堆空间时库函数是按照一定的算法搜索可用的足够大的空间. 因此堆的效率比栈要低的多

栈空间大小默认8K, 堆得大小受限于计算机系统的有效虚拟内存空间

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

请你回答一下静态变量什么时候初始化

static initialization: 静态初始化, dynamic initialization:动态初始化
C/C++ --- 全局变量初始化总结

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。

内存溢出和内存泄漏

内存溢出: 指程序申请内存时, 没有足够的内存供申请者使用. 内存溢出就是你要的内存空间超过了系统实际分配给你的空间, 此时系统相当于没法满足你的需求, 就会报内存溢出的错误
内存溢出原因:

  1. 内存中加载的数据量过于庞大, 如一次从数据库取出过多数据
  2. 集合类中有对对象的引用, 使用完后未清空, 使得不能回收
  3. 代码中存在死循环或循环产生过多重复的对象实体
  4. 使用的第三方软件中的BUG
  5. 启动参数内存值设定的过小

内存泄漏: 内存泄漏是指由于疏忽或错误造成了程序未能释放掉不再使用的内存的情况。内存泄漏并非指内存在物理上的消失, 而是应用程序分配某段内存后, 由于设计错误, 失去了对该段内存的控制, 因而造成了内存的浪费
内存泄漏的分类:

  1. 堆内存泄漏(Heap leak). 对内存指的是程序运行中根据需要分配通过malloc,realloc new等从堆中分配的一块内存, 再是完成后必须通过调用对应的free或者delete删掉. 如果程序的设计的错误导致这部分内存没有被释放, 那么此后这块内存将不会被使用, 就会产生Heap Leak
  2. 系统资源泄露(Resource Leak). 主要指程序使用系统分配的资源比如Bitmap, handle, SOCKET等没有使用相应的函数释放掉, 导致系统资源的浪费, 严重可导致系统效能降低, 系统运行不稳定
  3. 没有将基类的析构函数定义为虚函数. 当基类指针指向子类对象时, 如果基类的析构函数不是virtual, 那么子类的析构函数将不会被调用, 子类的资源没有正确是释放, 因此造成内存泄露

请你回答一下如何判断内存泄漏

内存泄漏通常是由于调用了malloc/new等内存申请的操作, 但是缺少了对应的free/delete. 为了判断内存是否泄露, 我们一方面可以使用linux环境下的内存泄漏检查工具Valgrind,另一方面我们在写代码时可以添加内存申请和释放的统计功能, 统计当前申请和释放的内存是否一致, 以此来判断内存是否泄露.

请你来说一下什么时候会发生段错误

段错误是指程序访问(读写)了系统未给予读写权限的内存空间。
包括:访问了不存在的内存空间,访问了系统保护的空间,对只读内存空间写覆盖等,常见的形式有数组越界访问,野指针操作等

给你一个类, 里面有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种方式

  1. 系统调用
    这是用户进程主动要求切换到内核态的一种方式, 用户进程通过系统调用申请操作系统提供的服务程序完成工作。而系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现, 例如Linux的ine80h中断。
  2. 异常
    当CPU在执行运行在用户态的程序时, 发现了某些事件不可知的异常, 这是会触发由当前运行进程切换到处理此。异常的内核相关程序中, 也就到了内核态, 比如缺页异常。
  3. 外围设备的中断
    当外围设备完成用户请求的操作之后, 会向CPU发出相应的中断信号, 这时CPU会暂停执行下一条将要执行的指令, 转而去执行中断信号的处理程序, 如果先执行的指令是用户态下的程序, 那么这个转换的过程自然也就发生了有用户态到内核态的切换。比如硬盘读写操作完成, 系统会切换到硬盘读写的中断处理程序中执行后续操作等。

2)切换操作
从出发方式看, 可以在认为存在前述3种不同的类型, 但是从最终实际完成由用户态到内核态的切换操作上来说, 涉及的关键步骤是完全一样的, 没有任何区别, 都相当于执行了一个中断响应的过程, 因为系统调用实际上最终是中断机制实现的, 而异常和中断处理机制基本上是一样的, 用户态切换到内核态的步骤主要包括:

  1. 从当前进程的描述符中提取其内核栈的ss0及esp0信息。
  2. 使用ss0和esp0指向的内核栈将当前进程的cs,eip, eflags, ss,esp信息保存起来, 这个过程也完成了由用户栈找到内核栈的切换过程, 同时保存了被暂停执行的程序的下一条指令。
  3. 将先前由中断向量检索得到的中断处理程序的cs, eip信息装入相应的寄存器, 开始执行中断处理程序, 这时就转到了内核态的程序执行了。

系统调用是什么, 你用过哪些系统调用

1)概念:
在计算机中, 系统调用(英语: system call), 又称为系统呼叫, 指运行在使用者空间的程序向操作系统内核请求需要更高权限运行的服务。系统调用提供了用户程序与操作系统之间的接口(即系统调用是用户程序和内核交互的接口)。

操作系统中的状态分为管态(核心态)和目态(用户态)。大多数系统交互式操作需求在内核态执行。如设备IO操作或者进程间通信。特权指令: 一类只能在核心态下运行而不能在用户态下运行的特殊指令。不同的操作系统特权指令会有所差异, 但是一般来说主要是和硬件相关的一些指令。用户程序只在用户态下运行, 有时需要访问系统核心功能, 这时通过系统调用接口使用系统调用。

应用程序有时会需要一些危险的、权限很高的指令, 如果把这些权限放心地交给用户程序是很危险的(比如一个进程可能修改另一个进程的内存区, 导致其不能运行), 但是又不能完全不给这些权限。于是有了系统调用, 危险的指令被包装成系统调用, 用户程序只能调用而无权自己运行那些危险的指令。另外, 计算机硬件的资源是有限的, 为了更好的管理这些资源, 所有的资源都由操作系统控制, 进程只能向操作系统请求这些资源。操作系统是这些资源的唯一入口, 这个入口就是系统调用。

2)系统调用举例:
对文件进行写操作, 程序向打开的文件写入字符串“hello world”, open和write都是系统调用。如下:

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<errno.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main(int argc, char *argv[])
{
    if (argc<2)
        return 0;
    //用读写追加方式打开一个已经存在的文件
    int fd = open(argv[1], O_RDWR | O_APPEND);
    if (fd == -1)
    {
        printf("error is %s\n", strerror(errno));
    }
    else
    {
        //打印文件描述符号
        printf("success fd = %d\n", fd);
        char buf[100];
        memset(buf, 0, sizeof(buf));
        strcpy(buf, "hello world\n");
        write(fd, buf, strlen(buf));
        close(fd);
    }
    return 0;
}

还有写数据write, 创建进程fork, vfork等都是系统调用。

请你说一说操作系统中的页表寻址

页式内存管理, 内存分成固定长度的一个个页片。操作系统为每一个进程维护了一个从虚拟地址到物理地址的映射关系的数据结构, 叫页表, 页表的内容就是该进程的虚拟地址到物理地址的一个映射。页表中的每一项都记录了这个页的基地址。通过页表, 由逻辑地址的高位部分先找到逻辑地址对应的页基地址, 再由页基地址偏移一定长度就得到最后的物理地址, 偏移的长度由逻辑地址的低位部分决定。一般情况下, 这个过程都可以由硬件完成, 所以效率还是比较高的。页式内存管理的优点就是比较灵活, 内存管理以较小的页为单位, 方便内存换入换出和扩充地址空间。

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 项

物理内存和虚拟内存

为什么会有虚拟内存和物理内存。正在运行的一个进程,他所需的内存是有可能大于内存条容量之和的,比如你的内存条是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则是:页目录指针页→页目录→页表→页

虚拟内存、物理内存

虚拟内存与物理内存的联系与区别

利用虚拟内存机制的优点 

  1. 既然每个进程的内存空间都是一致而且固定的(32位平台下都是4G),所以链接器在链接可执行文件时,可以设定内存地址,而不用去管这些数据最终实际内存地址,这交给内核来完成映射关系
  2. 当不同的进程使用同一段代码时,比如库文件的代码,在物理内存中可以只存储一份这样的代码,不同进程只要将自己的虚拟内存映射过去就好了,这样可以节省物理内存
  3. 在程序需要分配连续空间的时候,只需要在虚拟内存分配连续空间,而不需要物理内存时连续的,实际上,往往物理内存都是断断续续的内存碎片。这样就可以有效地利用我们的物理内存

缺页中断后,执行了那些操作

当一个进程发生缺页中断的时候,进程会陷入内核态,执行以下操作:

  1. 检查要访问的虚拟地址是否合法
  2. 查找/分配一个物理页
  3. 填充物理页内容(读取磁盘,或者直接置0,或者啥也不干)
  4. 建立映射关系(虚拟地址到物理地址)
    重新执行发生缺页中断的那条指令
    如果第3步,需要读取磁盘,那么这次缺页中断就是majflt,否则就是minflt。

请你说一说操作系统中的缺页中断

malloc和mmap等内存分配函数, 在分配时只是建立了进程虚拟地址空间, 并没有分配虚拟内存对应的物理内存。当进程访问这些没有建立映射关系的虚拟内存时, 处理器自动触发一个缺页异常。
缺页中断: 在请求分页系统中, 可以通过查询页表中的状态位来确定所要访问的页面是否存在于内存中。每当所要访问的页面不在内存是, 会产生一次缺页中断, 此时操作系统会根据页表中的外存地址在外存中找到所缺的一页, 将其调入内存。

缺页本身是一种中断, 与一般的中断一样, 需要经过4个处理步骤:

  1. 保护CPU现场
  2. 分析中断原因
  3. 转入缺页中断处理程序进行处理
  4. 恢复CPU现场, 继续执行

但是缺页中断是由于所要访问的页面不存在于内存时, 由硬件所产生的一种特殊的中断, 因此, 与一般的中断存在区别:

  1. 在指令执行期间产生和处理缺页中断信号
  2. 一条指令在执行期间, 可能产生多次缺页中断
  3. 缺页中断返回是, 执行产生中断的一条指令, 而一般的中断返回是, 执行下一条指令。

malloc底层分配内存原理

从操作系统角度来看,进程分配内存有两种方式,分别由两个系统调用完成:brk和mmap(不考虑共享内存)。brk是将数据段(.data)的最高地址指针_edata往高地址推,mmap是在进程的虚拟地址空间中(一般是堆和栈中间)找一块空闲的。这两种方式分配的都是虚拟内存,没有分配物理内存。在第一次访问已分配的虚拟地址空间的时候,发生缺页中断,操作系统负责分配物理内存,然后建立虚拟内存和物理内存之间的映射关系。 

传送门

问题:既然堆内内存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()函数分配内存失败的原因

  1. 内存不足。
  2. 在前面的程序中出现了内存的越界访问,导致malloc()分配函数所涉及的一些信息被破坏。下次再使用malloc()函数申请内存就会失败,返回空指针NULL(0)
    应该是写越界,有可能覆盖到了下一个空闲块的头节点,从而破坏了 malloc管理的环形链表, malloc就无法从一个空闲块的指针字段找到下一个空闲块了

new失败解决办法

C返回空指针, C++抛出异常

try {  
    int* pStr = new string[SIZE];  
    // processing codes  
}  
catch ( const bad_alloc& e )  {  
    return -1;  
} 

传送门

请你说一说Linux虚拟地址空间

为了防止不同进程同一时刻在物理内存中运行而对物理内存的争夺和践踏, 采用了虚拟内存。
虚拟内存技术使得不同进程在运行过程中, 它所看到的是自己独自占有了当前系统的4G内存。所有进程共享同一物理内存, 每个进程只把自己目前需要的虚拟内存空间映射并存储到物理内存上。 事实上, 在每个进程创建加载时, 内核只是为进程“创建”了虚拟内存的布局, 具体就是初始化进程控制表中内存相关的链表, 实际上并不立即就把虚拟内存对应位置的程序数据和代码(比如.text .data段)拷贝到物理内存中, 只是建立好虚拟内存和磁盘文件之间的映射就好(叫做存储器映射), 等到运行到对应的程序时, 才会通过缺页异常, 来拷贝数据。还有进程运行过程中, 要动态分配内存, 比如malloc时, 也只是分配了虚拟内存, 即为这块虚拟内存对应的页表项做相应设置, 当进程真正访问到此数据时, 才引发缺页异常。
请求分页系统、请求分段系统和请求段页式系统都是针对虚拟内存的, 通过请求实现内存与外存的信息置换。

虚拟内存的好处:

  1. 扩大地址空间;
  2. 内存保护: 每个进程运行在各自的虚拟内存地址空间, 互相不能干扰对方。虚存还对特定的内存地址提供写保护, 可以防止代码或数据被恶意篡改。
  3. 公平内存分配。采用了虚存之后, 每个进程都相当于有同样大小的虚存空间。
  4. 当进程通信时, 可采用虚存共享的方式实现。
  5. 当不同的进程使用同样的代码时, 比如库文件中的代码, 物理内存中可以只存储一份这样的代码, 不同的进程只需要把自己的虚拟内存映射过去就可以了, 节省内存
  6. 虚拟内存很适合在多道程序设计系统中使用, 许多程序的片段同时保存在内存中。当一个程序等待它的一部分读入内存时, 可以把CPU交给另一个进程使用。在内存中可以保留多个进程, 系统并发度提高
  7. 在程序需要分配连续的内存空间的时候, 只需要在虚拟内存空间分配连续空间, 而不需要实际物理内存的连续空间, 可以利用碎片

虚拟内存的代价:

  1. 虚存的管理需要建立很多数据结构, 这些数据结构要占用额外的内存
  2. 虚拟地址到物理地址的转换, 增加了指令的执行时间。
  3. 页面的换入换出需要磁盘I/O, 这是很耗时的
  4. 如果一页中只有一部分数据, 会浪费内存。

缺页置换

最佳置换算法(OPT)
先进先出(FIFO)页面置换算法
最近最久未使用(LRU)置换算法
时钟(CLOCK)置换算法

时钟(CLOCK)置换算法

虚拟内存置换的方式

比较常见的内存替换算法有:FIFO,LRU,LFU,LRU-K,2Q。

  1. FIFO(先进先出淘汰算法)
    思想:最近刚访问的,将来访问的可能性比较大。
    实现:使用一个队列,新加入的页面放入队尾,每次淘汰队首的页面,即最先进入的数据,最先被淘汰。
    弊端:无法体现页面冷热信息
  2. LFU(最不经常访问淘汰算法)
    思想:如果数据过去被访问多次,那么将来被访问的频率也更高。
    实现:每个数据块一个引用计数,所有数据块按照引用计数排序,具有相同引用计数的数据块则按照时间排序。每次淘汰队尾数据块。
    开销:排序开销。
    弊端:缓存颠簸。
  3. LRU(最近最少使用替换算法)
    思想:如果数据最近被访问过,那么将来被访问的几率也更高。
    实现:使用一个栈,新页面或者命中的页面则将该页面移动到栈底,每次替换栈顶的缓存页面。
    优点:LRU算法对热点数据命中率是很高的。
    缺陷:
    1)缓存颠簸,当缓存(1,2,3)满了,之后数据访问(0,3,2,1,0,3,2,1。。。)。
    2)缓存污染,突然大量偶发性的数据访问,会让内存中存放大量冷数据。
  4. LRU-K(LRU-2、LRU-3)
    思想:最久未使用K次淘汰算法。
    LRU-K中的K代表最近使用的次数,因此LRU可以认为是LRU-1。LRU-K的主要目的是为了解决LRU算法“缓存污染”的问题,其核心思想是将“最近使用过1次”的判断标准扩展为“最近使用过K次”。
    相比LRU,LRU-K需要多维护一个队列,用于记录所有缓存数据被访问的历史。只有当数据的访问次数达到K次的时候,才将数据放入缓存。当需要淘汰数据时,LRU-K会淘汰第K次访问时间距当前时间最大的数据。
    实现:
    1)数据第一次被访问,加入到访问历史列表;
    2)如果数据在访问历史列表里后没有达到K次访问,则按照一定规则(FIFO,LRU)淘汰;
    3)当访问历史队列中的数据访问次数达到K次后,将数据索引从历史队列删除,将数据移到缓存队列中,并缓存此数据,缓存队列重新按照时间排序;
    4)缓存数据队列中被再次访问后,重新排序;
    5)需要淘汰数据时,淘汰缓存队列中排在末尾的数据,即:淘汰“倒数第K次访问离现在最久”的数据。
    针对问题:
    LRU-K的主要目的是为了解决LRU算法“缓存污染”的问题,其核心思想是将“最近使用过1次”的判断标准扩展为“最近使用过K次”。
  5. 2Q
    类似LRU-2。使用一个FIFO队列和一个LRU队列。
    实现:
    1)新访问的数据插入到FIFO队列;
    2)如果数据在FIFO队列中一直没有被再次访问,则最终按照FIFO规则淘汰;
    3)如果数据在FIFO队列中被再次访问,则将数据移到LRU队列头部;
    4)如果数据在LRU队列再次被访问,则将数据移到LRU队列头部;
    5)LRU队列淘汰末尾的数据。
    针对问题:LRU的缓存污染
    弊端:当FIFO容量为2时,访问负载是:ABCABCABC会退化为FIFO,用不到LRU。

请你说一说OS缺页置换算法

当访问一个内存中不存在的页, 并且内存已满, 则需要从内存中调出一个页或将数据送至磁盘对换区, 替换一个页, 这种现象叫做缺页置换。当前操作系统最常采用的缺页置换算法如下:
先进先出(FIFO)算法: 置换最先调入内存的页面, 即置换在内存中驻留时间最久的页面。按照进入内存的先后次序排列成队列, 从队尾进入, 从队首删除。
最近最少使用(LRU)算法: 置换最近一段时间以来最长时间未访问过的页面。根据程序局部性原理, 刚被访问的页面, 可能马上又要被访问;而较长时间内没有被访问的页面, 可能最近不会被访问。
当前最常采用的就是LRU算法。

Linux内核线程实现原理

类Unix系统中, 早期是没有“线程”概念的, 80年代才引入, 借助进程机制实现出了线程的概念。因此在这类系统中, 进程和线程关系密切。

  1. 轻量级进程(light-weight process), 也有PCB, 创建线程使用的底层函数和进程一样, 都是clone
  2. 从内核里看进程和线程是一样的, 都有各自不同的PCB, 但是PCB中指向内存资源的三级页表是相同的
  3. 进程可以蜕变成线程
  4. 线程可看做寄存器和栈的集合
  5. 在linux下, 线程最是小的执行单位;进程是最小的分配资源单位

二级页表的图,它的映射只有两层:页目录→页表→页 而PAE则是:页目录指针页→页目录→页表→页
三级映射: 进程PCB --> 页目录(可看成数组, 首地址位于PCB中) --> 页表 --> 物理页面 --> 内存单元

对于进程来说, 相同的地址(同一个虚拟地址)在不同的进程中, 反复使用而不冲突。原因是他们虽虚拟址一样, 但, 页目录、页表、物理页面各不相同. 相同的虚拟址, 映射到不同的物理页面内存单元, 最终访问不同的物理页面
但!线程不同!两个线程具有各自独立的PCB, 但共享同一个页目录, 也就共享同一个页表和物理页面. 所以两个PCB共享一个地址空间.

实际上, 无论是创建进程的fork, 还是创建线程的pthread_create, 底层实现都是调用同一个内核函数clone. 如果复制对方的地址空间, 那么就产出一个“进程”; 如果共享对方的地址空间, 就产生一个“线程”. 因此: Linux内核是不区分进程和线程的. 只在用户层面上进行区分. 所以, 线程所有操作函数pthread_* 是库函数, 而非系统调用。

进程如何创建(底层实现原理)

进程被存放在一个叫做任务队列的双向循环链表当中.链表当中的每一项都是类型为task_struct成为进程描述符的结构, 也就是进程PCB

当进程调用fork后,当控制转移到内核中的fork代码后,内核会做4件事情:

  1. 分配新的内存块和内核数据结构给子进程
  2. 将父进程部分数据结构内容拷贝至子进程
  3. 添加子进程到系统进程列表当中
  4. fork返回,开始调度器调度

第一个问题,为什么frok成功调用后返回两个值?
由于在复制时复制了父进程的堆栈段,所以两个进程都停留在fork函数中,等待返回.因为fork函数会返回两次,一次是在父进程中返回,
另一次是在子进程中返回,这两次的返回值不同.

从fork函数开始以后的代码父子共享,既父进程要执行这段代码,子进程也要执行这段代码.(子进程获得父进程数据空间,堆和栈的副本. 但是父子进程并不共享这些存储空间部分. 父,子进程共享代码段.)现在很多现实并不执行一个父进程数据段,堆和栈的完全复制. 而是采用写时拷贝技术不懂可以戳进去看一看).这些区域有父子进程共享,而且内核地他们的访问权限改为只读的.如果父子进程中任一个试图修改这些区域,则内核值为修改区域的那块内存制作一个副本, 也就是如果你不修改我们一起用,你修改了之后对于修改的那部分

fork()函数在底层中做了什么?

  1. 调用系统函数clone. 其中fork, vfork函数根据各自需要的参数标志去调用clone
  2. clone继续调用do_fork
  3. 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线程是作为单独的进程实现的,但它们共享其虚拟内存空间和其他资源

  1. 但是,使用fork创建的子进程可以获取这些项的副本。
  2. Linux clone系统调用是fork和pthread_create的通用形式,它允许调用者指定在调用进程和新创建的进程之间共享哪些资源。
  3. clone()的主要用途是实现线程:在共享内存空间中并发运行的程序中的多个控制线程。
  4. 与fork()不同,这些调用允许子进程与调用进程共享其执行上下文的一部分,例如内存空间,文件描述符表和信号处理程序表。
    传送门

进程上下文怎么切换Linux进程上下文切换过程

挂起一个进程,将这个进程在 CPU 中的状态(上下文)存储于内存中的PCB
在内存中检索下一个进程的上下文并将其在 CPU 的寄存器中恢复
跳转到程序计数器所指向的位置(即跳转到进程被中断时的代码行),以恢复该进程

分页和分段

请你说一说并发(concurrency)和并行(parallelism)

并发(concurrency): 指宏观上看起来两个程序在同时运行, 比如说在单核cpu上的多任务。但是从微观上看两个程序的指令是交织着运行的, 你的指令之间穿插着我的指令, 我的指令之间穿插着你的, 在单个周期内只运行了一个指令。这种并发并不能提高计算机的性能, 只能提高效率。
并行(parallelism): 指严格物理意义上的同时运行, 比如多核cpu, 两个程序分别运行在两个核上, 两者之间互不影响, 单个周期内每个程序都运行了自己的指令, 也就是运行了两条指令。这样说来并行的确提高了计算机的效率。所以现在的cpu都是往多核方面发展。

进程与线程的概念

程序: 完成特定功能的一系列有序指令集合. 可执行文件称为程序, 可执行文件安装一定格式来组织: (1)指令组织保存在代码段中; (2)指令操作的数据保存在数据段中. 代码段+数据段

进程: 程序的一次动态执行过程. 比如我们运行一个游戏,打开一个软件,就是开启了一个进程。进程拥有代码和打开的文件资源、数据资源、独立的内存空间。
是系统进行资源调度和分配的的基本单位, 实现了操作系统的并发

线程: 在一个程序里的一个执行路线就叫做线程(thread), 线程从属于进程,是程序的实际执行者。一个进程至少包含一个主线程,也可以有更多的子线程。线程拥有自己的栈空间。 更准确的定义是: 线程是“一个进程内部的控制序列”. 一切进程至少都有一个执行线程, 实现进程内部的并发

对操作系统来说,线程是最小的执行单元,进程是最小的资源管理单元。无论进程还是线程,都是由操作系统所管理的。

协程

进程和线程的缺陷的如线程之间是如何进行协作的呢?
最经典的例子就是生产者/消费者模式:若干个生产者线程向队列中写入数据,若干个消费者线程从队列中消费数据。

生产者/消费者模式,但是却并不是一个高性能的实现。为什么性能不高呢?原因如下:

  1. 涉及到同步锁。
  2. 涉及到线程阻塞状态和可运行状态之间的切换。
  3. 涉及到线程上下文的切换。
    以上涉及到的任何一点,都是非常耗费性能的操作。

协程,英文Coroutines,是一种比线程更加轻量级的存在。正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。
2)协程和线程区别

  1. 更高的执行效率. 协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行), 因此子程序切换不是线程切换, 而是由程序自身控制. 没有线程切换的开销, 和多线程比, 线程数量越多, 协程的性能优势就越明显。
  2. 不需要多线程的锁机制. 协程暂停和线程的阻塞是有本质区别的。协程的暂停完全由程序控制,线程的阻塞状态是由操作系统内核来进行切换。因为只有一个线程, 也不存在同时写变量冲突, 在协程中控制共享资源不加锁, 只需要判断状态就好了, 所以执行效率比多线程高很多。

在协程上利用多核CPU呢——多进程+协程, 既充分利用多核, 又充分发挥协程的高效率, 可获得极高的性能。
Python对协程的支持还非常有限, 用在generator中的yield可以一定程度上实现协程。虽然支持不完全, 但已经可以发挥相当大的威力了。

进程与线程的区别

资源 调度 通信 健壮

  1. 一个线程只能属于一个进程, 而一个进程可以有多个线程, 但至少有一个线程, 线程依赖于进程而存在. 进程是资源分配的最小单位, 线程是CPU调度的最小单位;
  2. 进程在执行过程中拥有独立的内存单元, 而多个线程共享进程的内存, 每个线程只拥有一些在运行中必不可少的私有属性, 比如pcb, 线程Id, 栈, 寄存器.
    (资源分配给进程, 同一进程的所有线程共享该进程的所有资源. 同一进程中的多个线程共享代码段(代码和常量), 数据段(全局变量和静态变量), 扩展段(堆存储). 但是每个线程拥有自己的栈段, 栈段又叫运行时段, 用来存放所有局部变量和临时变量.)
  3. 系统开销: 由于在创建或撤消进程时, 系统都要为之分配或回收资源, 如内存空间、IO设备等. 因此, 操作系统所付出的开销将显著地大于在创建或撤消线程时的开销。类似地, 在进行进程切换时, 涉及到整个当前进程CPU环境的保存以及新被调度运行的进程的CPU环境的设置。而线程切换只须保存和设置少量寄存器的内容, 并不涉及存储器管理方面的操作。可见, 进程切换的开销也远大于线程切换的开销。
  4. 进程间通信比较复杂, 需要进程同步和互斥手段的辅助, 以保证数据的一致性, 而同一进程的线程由于共享代码段和数据段, 所以通信比较容易。
  5. 进程编程调试简单可靠性高, 但是创建销毁开销大;线程正相反, 开销小, 切换速度快, 但是编程调试相对复杂。
  6. 一个进程崩溃, 不会对其他进程产生影响;而一个线程崩溃, 会让同一进程内的其他线程也死掉。
  7. 进程适应于多核、多机分布; 线程适用于多核

有了进程, 为什么还要有线程

线程产生的原因:
进程可以使多个程序能并发执行, 以提高资源的利用率和系统的吞吐量. 但是其具有一些缺点

  1. 进程在同一时间只能干一件事
  2. 进程在执行的过程中如果阻塞, 整个进程就会挂起, 即使进程中有些工作不依赖于等待的资源, 仍然不会执行
    因此, 操作系统引入了比进程粒度更小的线程, 作为并发执行的基本单位, 从而减少程序在并发执行时所付出的时空开销, 提高并发性

与进程相比, 线程的优势如下:

  1. 从资源上来讲, 线程是一种非常"节俭"的多任务操作方式. 在linux系统下, 启动一个新的进程必须分配给它独立的地址空间, 建立众多的数据表来维护它的代码段、堆栈段和数据段, 这是一种"昂贵"的多任务工作方式
  2. 从切换效率上来讲, 运行于一个进程中的多个线程, 它们之间使用相同的地址空间, 而且线程间彼此切换所需时间也远远小于进程间切换所需要的时间. 据统计, 一个进程的开销大约是一个线程开销的30倍左右
  3. 从通信机制上来讲, 线程间方便的通信机制. 对不同进程来说, 它们具有独立的数据空间, 要进行数据的传递只能通过进程间通信的方式进行, 这种方式不仅费时, 而且很不方便. 线程则不然, 由于同一进城下的线程之间贡献数据空间, 所以一个线程的数据可以直接为其他线程所用, 这不仅快捷, 而且方便

除以上优点外, 多线程程序作为一种多任务、并发的工作方式, 还有如下优点:

  1. 使多CPU系统更加有效. 操作系统会保证当线程数不大于CPU数目时, 不同的线程运行于不同的CPU上
  2. 改善程序结构. 一个既长又复杂的进程可以考虑分为多个线程, 成为几个独立或半独立的运行部分, 这样的程序才会利于理解和修改

请你说一下多进程和多线程的使用场景

多进程模型的优势是CPU, 适用于CPU密集型。同时, 多进程模型也适用于多机分布式场景中, 易于多机扩展
多线程模型的优势是线程间切换代价较小, 因此适用于I/O密集型的工作场景, 因此I/O密集型的工作场景经常会由于I/O阻塞导致频繁的切换线程。同时, 多线程模型也适用于单机多核分布式场景

游戏服务器应该为每个用户开辟一个线程还是一个进程, 为什么

游戏服务器应该为每个用户开辟一个进程. 因为同一进程间的线程会相互影响, 一个线程死掉会影响其他线程, 从而导致进程崩溃. 因此为了保证不同用户之间不会相互影响, 应该为每个用户开辟一个进程

线程的共享资源与非共享资源

共享资源: (1)文件描述符表; (2)每种信号的处理方式; (3)当前工作目录; (4)用户ID和组ID; (5)内存地址空间 (.text/.data/.bss/heap/共享库); (6)环境变量, 命令行参数
非共享资源: (1)线程id; (2)处理器现场和栈指针(内核栈); (3)独立的栈空间(用户空间栈); (4)errno变量; (5)信号屏蔽字; (6)调度优先级; (7)栈, 若一共有5个线程, 则栈区被平均分成5份

线程优点与缺点

优点: (1)提高程序并发性; (2)开销小; (3)数据通信、共享数据方便
缺点: (1)库函数, 不稳定; (2)调试、编写困难、gdb不支持; (3)对信号支持不好
优点相对突出, 缺点均不是硬伤。Linux下由于实现方法导致进程、线程差别不是很大

线程使用注意事项

  1. 主线程退出其他线程不退出, 主线程应调用pthread_exit
  2. 避免僵尸线程pthread_join, pthread_detach, pthread_create指定分离属性, 被join线程可能在join函数返回前就释放完自己的所有内存资源, 所以不应当返回被回收线程栈中的值;
  3. malloc和mmap申请的内存可以被其他线程释放
  4. 应避免在多线程模型中调用fork除非, 马上exec, 子进程中只有调用fork的线程存在, 其他线程在子进程中均pthread_exit
  5. 信号的复杂语义很难和多线程共存, 应避免在多线程引入信号机制

进程的回收

一个进程在终止时会关闭所有文件描述符, 释放用户空间分配的内存, 但它的PCB还保留着, 内核在其中保存了一些信息: 如果是正常终止则保存着退出状态, 如果是异常终止则保存着退出状态, 如果是异常终止则保存着导致该进程终止的信号是哪个. 这个进程的父进程可以调用wait或waitpid获取这些信息, 然后彻底清除掉这个进程. 一个进程的退出状态可以在shell中用特殊变量$?查看, 因为shell是它的父进程, 当它终止时shell调用wait或waitpid得到它的退出状态同时彻底清除掉这个进程

父进程调用wait函数可以回收子进程终止信息, 该函数有三个功能:

  1. 阻塞等待子进程退出
  2. 回收子进程残留资源
  3. 获取子进程结束状态(退出原因)

当进程终止时, 操作系统的隐式回收机制: (1) 关闭所有文件描述符; (2) 释放用户空间分配的内存. 内核的PCB仍存在. 其中保存该进程的退出状态(正常终止-->退出值; 异常终止-->终止信号)

孤儿进程&僵尸进程

1)正常进程
正常情况下, 子进程是通过父进程创建的, 子进程再创建新的进程. 子进程的结束和父进程的运行是一个异步过程, 即父进程永远无法预测子进程到底什么时候结束. 当一个进程完成它的工作终止之后, 它的父进程需要调用wait或者waitpid系统调用取得子进程的终止状态。

unix提供了一种机制可以保证只要父进程想知道子进程结束时的状态信息, 就可以得到: 在每个进程退出的时候, 内核释放该进程所有的资源, 包括打开的文件, 占用的内存等. 但是仍然为其保留一定的信息, 直到父进程通过wait/waitpid来取时才释放。保存信息包括:

  1. 进程号the process ID
  2. 退出状态the termination status of the process
  3. 运行时间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()这些孤儿进程, 释放它们占用的系统进程表中的资源

内部解决:

  1. 子进程退出时向父进程发送SIGCHILD信号, 父进程处理SIGCHILD信号。在信号处理函数中调用wait进行处理僵尸进程。
  2. 父进程调用wait/waitpid等函数等待子进程结束,如果尚无子进程退出wait会导致父进程阻塞。waitpid可以通过传递WNOHANG使父进程不阻塞立即返回。
  3. 通过两次调用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的区别:

  1. fork会复制父进程的页表,而vfork不会复制,直接让子进程共用父进程的页表;
  2. fork使用了写时复制技术,而vfork没有,它任何时候都不会复制父进程地址空间。
  3. fork的父子进程的执行次序不确定;vfork保证子进程先运行, 在调用exec或exit之前与父进程数据是共享的, 在它调用exec或exit之后父进程才可能被调度运行, 如果在调用这两个函数之前子进程依赖于父进程的进一步动作, 则会导致死锁。
  4. 就算是fork使用了写时拷贝,也没有vfork性能高.
  5. 每个系统上的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
针对以上问题, 提出了两种解决方法:

  1. 交换技术: 换出一部分进程到外存, 腾出内存空间。
  2. 虚拟存储技术: 每个进程只能装入一部分程序和数据。

在交换技术上, 将内存暂时不能运行的进程, 或者暂时不用的数据和程序, 换出到外存, 来腾出足够的内存空间, 把已经具备运行条件的进程, 或进程所需的数据和程序换入到内存. 从而出现了进程的挂起状态: 进程被交换到外存, 进程状态就成为了挂起状态。

3、活动阻塞, 静止阻塞, 活动就绪, 静止就绪

  1. 活动阻塞: 进程在内存, 但是由于某种原因被阻塞了。
  2. 静止阻塞: 进程在外存, 同时被某种原因阻塞了。
  3. 活动就绪: 进程在内存, 处于就绪状态, 只要给CPU和调度就可以直接运行。
  4. 静止就绪: 进程在外存, 处于就绪状态, 只要调度到内存, 给CPU和调度就可以运行。

从而出现了:
活动就绪 -> 静止就绪(内存不够, 调到外存)
活动阻塞 -> 静止阻塞(内存不够, 调到外存)
执行 -> 静止就绪(时间片用完)

就绪状态的进程在等待什么?

被调度使用cpu的运行权

进程间通信

linux环境下, 进程地址空间相互独立, 每个进程各自有一个不同的用户地址空间. 任何一个进程的全局变量在另一个进程中都看不到, 所以进程之间不能相互访问, 要交换数据必须通过内核. 在内核中开辟一块缓冲区, 进程1把数据从用户空间拷贝到内核缓冲区, 进程2再从内核缓冲区把数据读走, 内核提供的这种机制称为进程通信(IPC, InterProcess Communication)

在进程间完成数据传递需要借助操作系统提供的特殊方法, 如: 文件, 管道, 信号, 共享内存, 消息队列, 套接字, 命名管道. 随着计算机的蓬勃发展, 一些方法由于自身设计缺陷被淘汰或者弃用. 现今常用的进程间通信方式有:

  1. 管道(使用最简单, 有血缘关系, 无血缘关系时使用命名管道)
  2. 信号(开销最小)
  3. 共享内存映射区(无血缘关系)
  4. 本地套接字(最稳定)

  5. 管道: 管道主要包括无名管道和命名管道:管道可用于具有亲缘关系的父子进程间的通信, 有名管道除了具有管道所具有的功能外, 它还允许无亲缘关系进程间的通信
      1.1 普通管道PIPE:
        1)它是半双工的(即数据只能在一个方向上流动), 具有固定的读端和写端
        2)它只能用于具有亲缘关系的进程之间的通信(也是父子进程或者兄弟进程之间)
        3)它可以看成是一种特殊的文件, 对于它的读写也可以使用普通的read、write等函数。但是它不是普通的文件, 并不属于其他任何文件系统, 并且只存在于内存中。
      1.2 命名管道FIFO:
        1)FIFO可以在无血缘关系的进程之间交换数据
        2)FIFO有路径名与之相关联, 它以一种特殊设备文件形式存在于文件系统中。
  6. 系统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)信号量+共享内存通常结合在一起使用, 信号量用来同步对共享内存的访问
  7. 套接字SOCKET: socket也是一种进程间通信机制, 与其他通信机制不同的是, 它可用于不同主机之间的进程通信。

线程间通信的方式

线程间通信的方式:

  1. 临界区: 通过多线程的串行化来访问公共资源或一段代码, 速度快, 适合控制数据访问;
  2. 互斥量: 采用互斥对象机制, 只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个, 所以可以保证公共资源不会被多个线程同时访问
  3. 信号量: 为控制具有有限数量的用户资源而设计的, 它允许多个线程在同一时刻去访问同一个资源, 但一般需要限制同一时刻访问此资源的最大线程数目。
  4. 事件(信号), 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时, 读者几乎不需要同步开销, 既不需要获得锁, 也不使用原子指令, 不会导致锁竞争, 因此就不用考虑死锁问题了。而对于写者的同步开销较大, 它需要复制被修改的数据, 还必须使用锁机制同步并行其它写者的修改操作。在有大量读操作, 少量写操作的情况下效率非常高。

死锁发生的条件以及如何解决死锁

死锁是指两个或两个以上进程在执行过程中, 因争夺资源而造成的相互等待的现象。

死锁发生的四个必要条件如下:

  1. 互斥条件: 进程对所分配到的资源不允许其他进程访问, 若其他进程访问该资源, 只能等待, 直至占有该资源的进程使用完成后释放该资源;
  2. 请求和保持条件: 进程获得一定的资源后, 又对其他资源发出请求, 但是该资源可能被其他进程占有, 此时请求阻塞, 但该进程不会释放自己已经占有的资源
  3. 不可剥夺条件: 进程已获得的资源, 在未完成使用之前, 不可被剥夺, 只能在使用后自己释放
  4. 环路等待条件: 进程发生死锁后, 必然存在一个进程-资源之间的环形链

解决死锁的方法即破坏上述四个条件之一, 主要方法如下:

  1. 资源一次性分配, 从而剥夺请求和保持条件
  2. 可剥夺资源: 即当进程新的资源未得到满足时, 释放已占有的资源, 从而破坏不可剥夺的条件
  3. 资源有序分配法: 系统给每类资源赋予一个序号, 每个进程按编号递增的请求资源, 释放则相反, 从而破坏环路等待的条件

介绍一下5种IO模型

  1. 阻塞IO: 调用者调用了某个函数, 等待这个函数返回, 期间什么也不做, 不停的去检查这个函数有没有返回, 必须等这个函数返回才能进行下一步动作
  2. 非阻塞IO: 非阻塞等待, 每隔一段时间就去检测IO事件是否就绪, 没有就绪就可以做其他事。
  3. 信号驱动IO: linux用套接字进行信号驱动IO, 安装一个信号处理函数, 进程继续运行并不阻塞, 当IO时间就绪, 进程收到SIGIO信号。然后处理IO事件。
  4. IO复用/多路转接IO: linux用select/poll函数实现IO复用模型, 这两个函数也会使进程阻塞, 但是和阻塞IO所不同的是这两个函数可以同时阻塞多个IO操作. 而且可以同时对多个读操作、写操作的IO函数进行检测, 直到有数据可读或可写时, 才真正调用IO操作函数
  5. 异步IO: linux中, 可以调用aio_read函数告诉内核描述字缓冲区指针和缓冲区的大小、文件偏移及通知的方式, 然后立即返回, 当内核将数据拷贝到缓冲区后, 再通知应用程序。

异步编程的事件循环

事件循环就是不停循环等待时间的发生, 然后将这个事件的所有处理器, 以及他们订阅这个事件的时间顺序依次依次执行。当这个事件的所有处理器都被执行完毕之后, 事件循环就会开始继续等待下一个事件的触发, 不断往复。当同时并发地处理多个请求时, 以上的概念也是正确的, 可以这样理解: 在单个的线程中, 事件处理器是一个一个按顺序执行的。即如果某个事件绑定了两个处理器, 那么第二个处理器会在第一个处理器执行完毕后, 才开始执行。在这个事件的所有处理器都执行完毕之前, 事件循环不会去检查是否有新的事件触发。在单个线程中, 一切都是有顺序地一个一个地执行的!

怎么实现线程池

  1. 设置一个生产者消费者队列, 作为临界资源
  2. 初始化n个线程, 并让其运行起来, 加锁去队列取任务运行
  3. 当任务队列为空的时候, 所有线程阻塞
  4. 当生产者队列来了一个任务后, 先对队列加锁, 把任务挂在到队列上, 然后使用条件变量去通知阻塞中的一个线程

死循环+来连接时新建线程的方法效率有点低, 怎么改进?

提前创建好一个线程池, 用生产者消费者模型, 创建一个任务队列, 队列作为临界资源, 有了新连接, 就挂在到任务队列上, 队列为空所有线程睡眠。
改进死循环: 使用select epoll这样的技术

常用线程模型

1、Future模型
该模型通常在使用的时候需要结合Callable接口配合使用。
Future是把结果放在将来获取, 当前主线程并不急于获取处理结果。允许子线程先进行处理一段时间, 处理结束之后就把结果保存下来, 当主线程需要使用的时候再向子线程索取。
Callable是类似于Runnable的接口, 其中call方法类似于run方法, 所不同的是run方法不能抛出受检异常没有返回值, 而call方法则可以抛出受检异常并可设置返回值。两者的方法体都是线程执行体。

2、fork&join模型
该模型包含递归思想和回溯思想, 递归用来拆分任务, 回溯用合并结果。可以用来处理一些可以进行拆分的大任务。其主要是把一个大任务逐级拆分为多个子任务, 然后分别在子线程中执行, 当每个子线程执行结束之后逐级回溯, 返回结果进行汇总合并, 最终得出想要的结果。
这里模拟一个摘苹果的场景: 有100棵苹果树, 每棵苹果树有10个苹果, 现在要把他们摘下来。为了节约时间, 规定每个线程最多只能摘10棵苹树以便于节约时间。各个线程摘完之后汇总计算总苹果树。

3、actor模型
actor模型属于一种基于消息传递机制并行任务处理思想, 它以消息的形式来进行线程间数据传输, 避免了全局变量的使用, 进而避免了数据同步错误的隐患。actor在接受到消息之后可以自己进行处理, 也可以继续传递(分发)给其它actor进行处理。在使用actor模型的时候需要使用第三方Akka提供的框架。

4、生产者消费者模型
生产者消费者模型都比较熟悉, 其核心是使用一个缓存来保存任务。开启一个/多个线程来生产任务, 然后再开启一个/多个来从缓存中取出任务进行处理。这样的好处是任务的生成和处理分隔开, 生产者不需要处理任务, 只负责向生成任务然后保存到缓存。而消费者只需要从缓存中取出任务进行处理。使用的时候可以根据任务的生成情况和处理情况开启不同的线程来处理。比如, 生成的任务速度较快, 那么就可以灵活的多开启几个消费者线程进行处理, 这样就可以避免任务的处理响应缓慢的问题。

5、master-worker模型
master-worker模型类似于任务分发策略, 开启一个master线程接收任务, 然后在master中根据任务的具体情况进行分发给其它worker子线程, 然后由子线程处理任务。如需返回结果, 则worker处理结束之后把处理结果返回给master。

结构体相等是否能够使用内存比较, 分是否有内存对齐两种情况

不可以,memcmp是逐个字节对比的,但当字节对齐时,中间的填充部分是随机的,不确定的地址,所以比较的结果是不正确的

请你说一说操作系统中的结构体对齐, 字节对齐

原因:

  1. 不同硬件平台不一定支持访问任意内存地址数据,使用内存对齐可以保证每次访问都从块内存地址头部开始存取
  2. 提高cpu内存访问速度,内存是分块的,如两字节一块,四字节一块,考虑这种情况:一个四字节变量存在一个四字节地址的后三位和下一个四字节地址的前一位,这样cpu从内存中取数据便需要访问两个内存并将他们组合起来,降低cpu性能

三个准则:

  1. 结构体变量的首地址能够被其最宽基本类型成员的大小所整除;
  2. 结构体每个成员相对结构体首地址的偏移量(offset)都是成员大小的整数倍,如有需要编译器会在成员之间加上填充字节(internal adding);
  3. 结构体的总大小为结构体最宽基本类型成员大小的整数倍,如有需要编译器会在最末一个成员之后加上填充字节{trailing padding}。

练习

关闭内存对齐

什么是大端小端以及如何判断大端小端

大端是指低字节存储在高地址;小端存储是指低字节存储在低地址。我们可以根据联合体来判断该系统是大端还是小端。因为联合体变量总是从低地址存储

// 判断系统大小端, 通过联合体, 因为联合体的所有成员都从低地址开始存放
int function() {
    union test {
        int i;
        char c;
    };
    test t;
    t.i = 1;
    
    // 如果是大端, 则t.c=0x00, 返回false, 
    // 如果是小端, 则t.c=1, 返回true
    return (t.c == 1);
}

linux内核中的Timer定时器机制

1)低精度时钟
Linux 2.6.16之前, 内核只支持低精度时钟, 内核定时器的工作方式:

  1. 系统启动后, 会读取时钟源设备(RTC, HPET, PIT…), 初始化当前系统时间。
  2. 内核会根据HZ(系统定时器频率, 节拍率)参数值, 设置时钟事件设备, 启动tick(节拍)中断。HZ表示1秒种产生多少个时钟硬件中断, tick就表示连续两个中断的间隔时间。
  3. 设置时钟事件设备后, 时钟事件设备会定时产生一个tick中断, 触发时钟中断处理函数, 更新系统时钟,并检测timer wheel, 进行超时事件的处理。

在上面工作方式下, Linux 2.6.16 之前, 内核软件定时器采用timer wheel多级时间轮的实现机制, 维护操作系统的所有定时事件。timer wheel的触发是基于系统tick周期性中断。

所以说这之前, linux只能支持ms级别的时钟, 随着时钟源硬件设备的精度提高和软件高精度计时的需求, 有了高精度时钟的内核设计。

2)高精度时钟
Linux 2.6.16 , 内核支持了高精度的时钟, 内核采用新的定时器hrtimer, 其实现逻辑和Linux 2.6.16 之前定时器逻辑区别:

  1. hrtimer采用红黑树进行高精度定时器的管理, 而不是时间轮;
  2. 高精度时钟定时器不在依赖系统的tick中断, 而是基于事件触发。
  3. 旧内核的定时器实现依赖于系统定时器硬件定期的tick, 基于该tick, 内核会扫描timer wheel处理超时事件, 会更新jiffies, wall time(墙上时间, 现实时间), process的使用时间等等工作。
    新的内核不再会直接支持周期性的tick, 新内核定时器框架采用了基于事件触发, 而不是以前的周期性触发。新内核实现了hrtimer(high resolution timer): 于事件触发。

hrtimer的工作原理:
通过将高精度时钟硬件的下次中断触发时间设置为红黑树中最早到期的Timer 的时间, 时钟到期后从红黑树中得到下一个 Timer 的到期时间, 并设置硬件, 如此循环反复。

在高精度时钟模式下, 操作系统内核仍然需要周期性的tick中断, 以便刷新内核的一些任务。hrtimer是基于事件的, 不会周期性出发tick中断, 所以为了实现周期性的tick中断(dynamic tick): 系统创建了一个模拟 tick 时钟的特殊 hrtimer, 将其超时时间设置为一个tick时长, 在超时回来后, 完成对应的工作, 然后再次设置下一个tick的超时时间, 以此达到周期性tick中断的需求。

引入了dynamic tick, 是为了能够在使用高精度时钟的同时节约能源, 这样会产生tickless 情况下, 会跳过一些 tick。

新内核对相关的时间硬件设备进行了统一的封装, 定义了主要有下面两个结构:

时钟源设备(closk source device): 抽象那些能够提供计时功能的系统硬件, 比如 RTC(Real Time Clock)、TSC(Time Stamp Counter), HPET, ACPI PM-Timer, PIT等。不同时钟源提供的精度不一样, 现在pc大都是支持高精度模式(high-resolution mode)也支持低精度模式(low-resolution mode)。

时钟事件设备(clock event device): 系统中可以触发 one-shot(单次)或者周期性中断的设备都可以作为时钟事件设备。

当前内核同时存在新旧timer wheel 和 hrtimer两套timer的实现, 内核启动后会进行从低精度模式到高精度时钟模式的切换, hrtimer模拟的tick中断将驱动传统的低精度定时器系统(基于时间轮)和内核进程调度。

server端监听端口, 但还没有客户端连接进来, 此时进程处于什么状态?

这个需要看服务端的编程模型, 如果如上一个问题的回答描述的这样, 则处于阻塞状态, 如果使用了epoll,select等这样的io复用情况下, 处于运行状态

如何修改文件最大句柄数

linux默认最大文件句柄数是1024个, 在linux服务器文件并发量比较大的情况下, 系统会报"too many open files"的错误。故在linux服务器高并发调优时, 往往需要预先调优Linux参数, 修改Linux最大文件句柄数。有两种方法:

  1. ulimit -n <可以同时打开的文件数>, 将当前进程的最大句柄数修改为指定的参数(注: 该方法只针对当前进程有效, 重新打开一个shell或者重新开启一个进程, 参数还是之前的值)
    首先用ulimit -a查询Linux相关的参数, 如下所示:

    core file size (blocks, -c) 0
    data seg size (kbytes, -d) unlimited
    scheduling priority (-e) 0
    file size (blocks, -f) unlimited
    pending signals (-i) 14536
    max locked memory (kbytes, -l) 64
    max memory size (kbytes, -m) unlimited
    open files (-n) 1024
    pipe size (512 bytes, -p) 8
    POSIX message queues (bytes, -q) 819200
    real-time priority (-r) 0
    stack size (kbytes, -s) 8192
    cpu time (seconds, -t) unlimited
    max user processes (-u) 14536
    virtual memory (kbytes, -v) unlimited
    file locks (-x) unlimited
    修改Linux最大文件句柄数: ulimit -n 2048, 将最大句柄数修改为 2048个。

  2. 对所有进程都有效的方法, 修改Linux系统参数
    vi /etc/security/limits.conf 添加

      soft  nofile  65536
      hard  nofile  65536
    将最大句柄数改为65536
    修改以后保存, 注销当前用户, 重新登录, 修改后的参数就生效了

MySQL的端口号是多少, 如何修改这个端口号

查看端口号: 使用命令show global variables like 'port';
mysql的默认端口是3306。(补充: sqlserver默认端口号为: 1433;oracle默认端口号为: 1521;DB2默认端口号为: 5000;PostgreSQL默认端口号为: 5432)

修改端口号: 编辑/etc/my.cnf文件, 早期版本有可能是my.conf文件名, 增加端口参数, 并且设定端口, 注意该端口未被使用, 保存退出。

请你回答一下软链接和硬链接区别

为了解决文件共享问题, Linux引入了软链接和硬链接。除了为Linux解决文件共享使用, 还带来了隐藏文件路径、增加权限安全及节省存储等好处。若1个inode号对应多个文件名, 则为硬链接, 即硬链接就是同一个文件使用了不同的别名,使用ln创建。若文件用户数据块中存放的内容是另一个文件的路径名指向, 则该文件是软连接。软连接是一个普通文件, 有自己独立的inode,但是其数据块内容比较特殊。

怎么唤醒被阻塞的socket线程?

给阻塞时候缺少的资源

怎样确定当前线程是繁忙还是阻塞?

使用ps命令查看

请自己设计一下如何采用单线程的方式处理高并发

在单线程模型中,可以采用I/O复用来提高单线程处理多个请求的能力,然后再采用事件驱动模型,基于异步回调来处理事件来

请你说说select,epoll的区别,原理,性能,限制都说一说

  1. 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。

  1. select
    select:是最初解决IO阻塞问题的方法。用结构体fd_set来告诉内核监听多个文件描述符,该结构体被称为描述符集。由数组来维持哪些描述符被置位了。对结构体的操作封装在三个宏定义中。通过轮寻来查找是否有描述符要被处理。
    存在的问题:
    (1)内置数组的形式使得select的最大文件数受限与FD_SIZE;
    (2)每次调用select前都要重新初始化描述符集,将fd从用户态拷贝到内核态,每次调用select后,都需要将fd从内核态拷贝到用户态;
    (3)轮寻排查当文件描述符个数很多时,效率很低;
  2. 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时,不会再次响应应用程序并通知此事件。

琐碎知识点

父子进程相同之处: 全局变量, .data, .text, 栈, 堆, 环境变量, 用户ID, 宿主目录, 进程工作目录, 信号处理方式...

父子进程不同之处: 进程ID, fork返回值, 父进程ID, 进程运行时间, 闹钟(定时器), 未决信号集

似乎子进程复制了父进程0-3G用户空间内容, 以及父进程的PCB, 但pid不同. 每fork一个子进程都要将父进程的0-3G地址空间完全拷贝一份然后再映射至物理内存?
父子进程间遵循读共享写复制的原则. 这样设计(全局变量不能作为进程之间通信管道), 无论子进程执行父进程的逻辑还是执行自己的逻辑都能节省内存开销

刚fork出来之后: 两个地址空间用户区数据完全相同. 后续各自进行了不同的操作: 各个进程的地址空间中的数据是完全独立的

问题:

fork函数的返回值: >0, 父进程的返回值; =0子进程的返回值
子进程创建成功之后, 代码的执行位置? 父进程执行到什么地方, 子进程就从什么位置开始
父子进程的执行顺序? 不能确定谁先执行
如何区分父子进程: 通过fork函数的返回值

父子进程永远共享的:
  文件描述符
  内存映射区

标准IO和文件IO的区别

假设一个抽象跑了很多天突然崩了, 怎么查询问题

有日志查询日志, 如果通过日志无法定位, 就还原现场, 产生core文件分析core文件来进行解决

Linux线程挂掉是否影响进程

主线程挂了, 进程就挂了

ps命令

用条件变量实现事件等待器的正确与错误做法

原文地址:https://www.cnblogs.com/hesper/p/11387819.html