一文带你吃透操作系统

Posted 夏沫の梦

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了一文带你吃透操作系统相关的知识,希望对你有一定的参考价值。

文章目录


文章字数大约1.9万字,阅读大概需要65分钟,建议收藏后慢慢阅读!!!

1. 进程、线程管理

  1. 进程和线程基础知识

    进程:进程是系统进行资源分配和调度的一个独立单位,是系统中的并发执行的单位。

    线程:线程是进程的一个实体,也是 CPU 调度和分派的基本单位,它是比进程更小的能独立运行的基本单位,有时又被称为轻权进程或轻量级进程。

    1. 进程

      运行中的程序,就被称为「进程」(Process)。

      在一个进程的活动期间至少具备三种基本状态,即运行状态、就绪状态、阻塞状态。

      • 运行状态(Running):该时刻进程占用 CPU;
      • 就绪状态(Ready):可运行,由于其他进程处于运行状态而暂时停止运行;
      • 阻塞状态(Blocked):该进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时,即使给它CPU控制权,它也无法运行;

      当然,进程还有另外两个基本状态:

      • 创建状态(new):进程正在被创建时的状态;
      • 结束状态(Exit):进程正在从系统中消失时的状态;

      挂起状态可以分为两种:

      • 阻塞挂起状态:进程在外存(硬盘)并等待某个事件的出现;
      • 就绪挂起状态:进程在外存(硬盘),但只要进入内存,即刻立刻运行;

      PCB 进程控制块 是进程存在的唯一标识

      PCB 具体包含什么信息呢?

      进程描述信息:

      • 进程标识符:标识各个进程,每个进程都有一个并且唯一的标识符;
      • 用户标识符:进程归属的用户,用户标识符主要为共享和保护服务;

      进程控制和管理信息:

      • 进程当前状态,如 new、ready、running、waiting 或 blocked 等;
      • 进程优先级:进程抢占 CPU 时的优先级;

      资源分配清单:

      • 有关内存地址空间或虚拟地址空间的信息,所打开文件的列表和所使用的 I/O 设备信息。

      CPU 相关信息:

      • CPU 中各个寄存器的值,当进程被切换时,CPU 的状态信息都会被保存在相应的 PCB 中,以便进程重新执行时,能从断点处继续执行。
    2. 线程

      线程是进程当中的一条执行流程。

      同一个进程内多个线程之间可以共享代码段、数据段、打开的文件等资源,但每个线程各自都有一套独立的寄存器和栈,这样可以确保线程的控制流是相对独立的。

      线程的实现

      主要有三种线程的实现方式:

      • 用户线程(*User Thread*):在用户空间实现的线程,不是由内核管理的线程,是由用户态的线程库来完成线程的管理;
      • 内核线程(*Kernel Thread*):在内核中实现的线程,是由内核管理的线程;
      • 轻量级进程(*LightWeight Process*):在内核中来支持用户线程;

      第一种关系是多对一的关系,也就是多个用户线程对应同一个内核线程

      第二种是一对一的关系,也就是一个用户线程对应一个内核线程

      第三种是多对多的关系,也就是多个用户线程对应到多个内核线程

      用户线程的整个线程管理和调度,操作系统是不直接参与的,而是由用户级线程库函数来完成线程的管理,包括线程的创建、终止、同步和调度等。

      线程的优点:

      • 一个进程中可以同时存在多个线程;
      • 各个线程之间可以并发执行;
      • 各个线程之间可以共享地址空间和文件等资源;

      线程的缺点:

      • 当进程中的一个线程崩溃时,会导致其所属进程的所有线程崩溃(这里是针对 C/C++ 语言,Java语言中的线程奔溃不会造成进程崩溃

      内核线程是由操作系统管理的,线程对应的 TCB 自然是放在操作系统里的,这样线程的创建、终止和管理都是由操作系统负责。

  2. 进程/线程上下文切换

    1. 进程

      一个进程切换到另一个进程运行,称为进程的上下文切换

      进程的上下文切换到底是切换什么呢?

      进程是由内核管理和调度的,所以进程的切换只能发生在内核态。

      所以,进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。

      通常,会把交换的信息保存在进程的 PCB,当要运行另外一个进程的时候,我们需要从这个进程的 PCB 取出上下文,然后恢复到 CPU 中,这使得这个进程可以继续执行

      发生进程上下文切换有哪些场景?

      • 为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片耗尽了,进程就从运行状态变为就绪状态,系统从就绪队列选择另外一个进程运行;
      • 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行;
      • 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度;
      • 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行;
      • 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序;
    2. 线程

      1. 线程上下文切换的是什么?

        这还得看线程是不是属于同一个进程:

        1. 当两个线程不是属于同一个进程,则切换的过程就跟进程上下文切换一样;
        2. 当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据

        所以,线程的上下文切换相比进程,开销要小很多。

  3. 进程/线程间通信方式

    进程间通信(IPC,InterProcess Communication)是指在不同进程之间传播或交换信息。IPC 的方式通常有管道(包括无名管道和命名管道)、消息队列、信号量、共享存储、Socket、Streams 等。其中 Socket 和 Streams 支持不同主机上的两个进程 IPC。

    管道

    1. 它是半双工的,具有固定的读端和写端;
    2. 它只能用于父子进程或者兄弟进程之间的进程的通信;
    3. 它可以看成是一种特殊的文件,对于它的读写也可以使用普通的 read、write 等函数。但是它不是普通的文件,并不属于其他任何文件系统,并且只存在于内存中。

    命名管道

    1. FIFO 可以在无关的进程之间交换数据,与无名管道不同;
    2. FIFO 有路径名与之相关联,它以一种特殊设备文件形式存在于文件系统中。

    消息队列

    1. 消息队列,是消息的链接表,存放在内核中。一个消息队列由一个标识符 ID 来标识;
    2. 消息队列是面向记录的,其中的消息具有特定的格式以及特定的优先级;
    3. 消息队列独立于发送与接收进程。进程终止时,消息队列及其内容并不会被删除;
    4. 消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取。

    信号量

    1. 信号量(semaphore)是一个计数器。用于实现进程间的互斥与同步,而不是用于存储进程间通信数据;
    2. 信号量用于进程间同步,若要在进程间传递数据需要结合共享内存;
    3. 信号量基于操作系统的 PV 操作,程序对信号量的操作都是原子操作;
    4. 每次对信号量的 PV 操作不仅限于对信号量值加 1 或减 1,而且可以加减任意正整数;
    5. 支持信号量组。

    共享内存

    1. 共享内存(Shared Memory),指两个或多个进程共享一个给定的存储区;
    2. 共享内存是最快的一种 IPC,因为进程是直接对内存进行存取。

    Socket通信

    前面说到的通信机制,都是工作于同一台主机,如果要与不同主机的进程间通信,那么就需要 Socket 通信了。Socket 实际上不仅用于不同的主机进程间通信,还可以用于本地主机进程间通信,可根据创建 Socket 的类型不同,分为三种常见的通信方式,一个是基于 TCP 协议的通信方式,一个是基于 UDP 协议的通信方式,一个是本地进程间通信方式。

    以上,就是进程间通信的主要机制了。你可能会问了,那线程通信间的方式呢?

    同个进程下的线程之间都是共享进程的资源,只要是共享变量都可以做到线程间通信,比如全局变量,所以对于线程间关注的不是通信方式,而是关注多线程竞争共享资源的问题,信号量也同样可以在线程间实现互斥与同步:

    • 互斥的方式,可保证任意时刻只有一个线程访问共享资源;
    • 同步的方式,可保证线程 A 应在线程 B 之前执行;
  4. 线程、进程崩溃发生什么

    一般来说如果线程是因为非法访问内存引起的崩溃,那么进程肯定会崩溃,为什么系统要让进程崩溃呢,这主要是因为在进程中,各个线程的地址空间是共享的,既然是共享,那么某个线程对地址的非法访问就会导致内存的不确定性,进而可能会影响到其他线程,这种操作是危险的,操作系统会认为这很可能导致一系列严重的后果,于是干脆让整个进程崩溃

    崩溃机制

    1. CPU 执行正常的进程指令
    2. 调用 kill 系统调用向进程发送信号
    3. 进程收到操作系统发的信号,CPU 暂停当前程序运行,并将控制权转交给操作系统
    4. 调用 kill 系统调用向进程发送信号(假设为 11,即 SIGSEGV,一般非法访问内存报的都是这个错误)
    5. 操作系统根据情况执行相应的信号处理程序(函数),一般执行完信号处理程序逻辑后会让进程退出

    注意上面的第五步,如果进程没有注册自己的信号处理函数,那么操作系统会执行默认的信号处理程序(一般最后会让进程退出),但如果注册了,则会执行自己的信号处理函数,这样的话就给了进程一个垂死挣扎的机会,它收到 kill 信号后,可以调用 exit() 来退出,但也可以使用 sigsetjmp,siglongjmp 这两个函数来恢复进程的执行

  5. 守护进程、僵尸进程和孤儿进程

    守护进程

    指在后台运行的,没有控制终端与之相连的进程。它独立于控制终端,周期性地执行某种任务。Linux的大多数服务器就是用守护进程的方式实现的,如web服务器进程http等

    创建守护进程要点:

    (1)让程序在后台执行。方法是调用fork()产生一个子进程,然后使父进程退出。

    (2)调用setsid()创建一个新对话期。控制终端、登录会话和进程组通常是从父进程继承下来的,守护进程要摆脱它们,不受它们的影响,方法是调用setsid()使进程成为一个会话组长。setsid()调用成功后,进程成为新的会话组长和进程组长,并与原来的登录会话、进程组和控制终端脱离。

    (3)禁止进程重新打开控制终端。经过以上步骤,进程已经成为一个无终端的会话组长,但是它可以重新申请打开一个终端。为了避免这种情况发生,可以通过使进程不再是会话组长来实现。再一次通过fork()创建新的子进程,使调用fork的进程退出。

    (4)关闭不再需要的文件描述符。子进程从父进程继承打开的文件描述符。如不关闭,将会浪费系统资源,造成进程所在的文件系统无法卸下以及引起无法预料的错误。首先获得最高文件描述符值,然后用一个循环程序,关闭0到最高文件描述符值的所有文件描述符。

    (5)将当前目录更改为根目录。

    (6)子进程从父进程继承的文件创建屏蔽字可能会拒绝某些许可权。为防止这一点,使用unmask(0)将屏蔽字清零。

    (7)处理SIGCHLD信号。对于服务器进程,在请求到来时往往生成子进程处理请求。如果子进程等待父进程捕获状态,则子进程将成为僵尸进程(zombie),从而占用系统资源。如果父进程等待子进程结束,将增加父进程的负担,影响服务器进程的并发性能。在Linux下可以简单地将SIGCHLD信号的操作设为SIG_IGN。这样,子进程结束时不会产生僵尸进程。

    孤儿进程

    如果父进程先退出,子进程还没退出,那么子进程的父进程将变为init进程。(注:任何一个进程都必须有父进程)。

    一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。

    僵尸进程

    如果子进程先退出,父进程还没退出,那么子进程必须等到父进程捕获到了子进程的退出状态才真正结束,否则这个时候子进程就成为僵尸进程。

    设置僵尸进程的目的是维护子进程的信息,以便父进程在以后某个时候获取。这些信息至少包括进程ID,进程的终止状态,以及该进程使用的CPU时间,所以当终止子进程的父进程调用wait或waitpid时就可以得到这些信息。如果一个进程终止,而该进程有子进程处于僵尸状态,那么它的所有僵尸子进程的父进程ID将被重置为1(init进程)。继承这些子进程的init进程将清理它们(也就是说init进程将wait它们,从而去除它们的僵尸状态)。

    如何避免僵尸进程

    • 通过signal(SIGCHLD, SIG_IGN)通知内核对子进程的结束不关心,由内核回收。如果不想让父进程挂起,可以在父进程中加入一条语句:signal(SIGCHLD,SIG_IGN);表示父进程忽略SIGCHLD信号,该信号是子进程退出的时候向父进程发送的。
    • 父进程调用wait/waitpid等函数等待子进程结束,如果尚无子进程退出wait会导致父进程阻塞。waitpid可以通过传递WNOHANG使父进程不阻塞立即返回。
    • 如果父进程很忙可以用signal注册信号处理函数,在信号处理函数调用wait/waitpid等待子进程退出。
    • 通过两次调用fork。父进程首先调用fork创建一个子进程然后waitpid等待子进程退出,子进程再fork一个孙进程后退出。这样子进程退出后会被父进程等待回收,而对于孙子进程其父进程已经退出所以孙进程成为一个孤儿进程,孤儿进程由init进程接管,孙进程结束后,init会等待回收。

    第一种方法忽略SIGCHLD信号,这常用于并发服务器的性能的一个技巧因为并发服务器常常fork很多子进程,子进程终结之后需要服务器进程去wait清理资源。如果将此信号的处理方式设为忽略,可让内核把僵尸子进程转交给init进程去处理,省去了大量僵尸进程占用系统资源。

  6. 进程和线程的比较

    线程是调度的基本单位,而进程则是资源拥有的基本单位

    线程与进程的比较如下:

    • 进程是资源(包括内存、打开的文件等)分配的单位,线程是 CPU 调度的单位;
    • 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈;
    • 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系;
    • 线程能减少并发执行的时间和空间开销;

    对于,线程相比进程能减少开销,体现在:

    • 线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们;
    • 线程的终止时间比进程快,因为线程释放的资源相比进程少很多;
    • 同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的;
    • 由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了;

    所以,不管是时间效率,还是空间效率线程比进程都要高。

    1、线程启动速度快,轻量级

    2、线程的系统开销小

    3、线程使用有一定难度,需要处理数据一致性问题

    4、同一线程共享的有堆、全局变量、静态变量、指针,引用、文件等,而独自占有栈

    1. 进程是资源分配的最小单位,而线程是 CPU 调度的最小单位;
    2. 创建进程或撤销进程,系统都要为之分配或回收资源,操作系统开销远大于创建或撤销线程时的开销;
    3. 不同进程地址空间相互独立,同一进程内的线程共享同一地址空间。一个进程的线程在另一个进程内是不可见的;
    4. 进程间不会相互影响,而一个线程挂掉将可能导致整个进程挂掉;

2. 内存管理

  1. 物理地址、逻辑地址、虚拟内存的概念

    1. 物理地址:它是地址转换的最终地址,进程在运行时执行指令和访问数据最后都要通过物理地址从主存中存取,是内存单元真正的地址。
    2. 逻辑地址:是指计算机用户看到的地址。例如:当创建一个长度为 100 的整型数组时,操作系统返回一个逻辑上的连续空间:指针指向数组第一个元素的内存地址。由于整型元素的大小为 4 个字节,故第二个元素的地址时起始地址加 4,以此类推。事实上,逻辑地址并不一定是元素存储的真实地址,即数组元素的物理地址(在内存条中所处的位置),并非是连续的,只是操作系统通过地址映射,将逻辑地址映射成连续的,这样更符合人们的直观思维。
    3. 虚拟内存:是计算机系统内存管理的一种技术。它使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。
  2. 虚拟内存有什么好处

    • 第一,虚拟内存可以使得进程对运行内存超过物理内存大小,因为程序运行符合局部性原理,CPU 访问内存会有很明显的重复访问的倾向性,对于那些没有被经常使用到的内存,我们可以把它换出到物理内存之外,比如硬盘上的 swap 区域。
    • 第二,由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是相互独立的。进程也没有办法访问其他进程的页表,所以这些页表是私有的,这就解决了多进程之间地址冲突的问题。
    • 第三,页表里的页表项中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等。在内存访问方面,操作系统提供了更好的安全性。
  3. 内存管理

    内存管理的概念就是操作系统对内存的划分和动态分配。

    内存管理功能:

    内存空间的分配与回收:由操作系统完成主存储器空间的分配和管理,是程序员摆脱存储分配的麻烦,提高编程效率。
    地址转换:将逻辑地址转换成相应的物理地址。
    内存空间的扩充:利用虚拟存储技术或自动覆盖技术,从逻辑上扩充主存。
    存储保护:保证各道作业在各自的存储空间内运行,互不干扰。
    创建进程首先要将程序和数据装入内存。将用户源程序变为可在内存中执行的程序,通常需要以下几个步骤:

    编译:由编译程序将用户源代码编译成若干目标模块(把高级语言翻译成机器语言)
    链接:由链接程序将编译后形成的一组目标模块及所需的库函数连接在一起,形成一个完整的装入模块(由目标模块生成装入模块,链接后形成完整的逻辑地址)
    装入:由装入程序将装入模块装入内存运行,装入后形成物理地址
    程序的链接有以下三种方式:

    静态链接:在程序运行之前,先将各目标模块及它们所需的库函数连接成一个完整的可执行文件(装入模块),之后不再拆开。
    装入时动态链接:将各目标模块装入内存时,边装入边链接的链接方式。
    运行时动态链接:在程序执行中需要该目标模块时,才对它进行链接。其优点是便于修改和更新,便于实现对目标模块的共享。
    内存的装入模块在装入内存时,有以下三种方式:

    重定位:根据内存的当前情况,将装入模块装入内存的适当位置,装入时对目标程序中的指令和数据的修改过程称为重定位。

    静态重定位:地址的变换通常是在装入时一次完成的。一个作业装入内存时,必须给它分配要求的全部内存空间,若没有足够的内存,则不能装入该作业。此外,作业一旦装入内存,整个运行期间就不能在内存中移动,也不能再申请内存空间。
    动态重定位:需要重定位寄存器的支持。可以将程序分配到不连续的存储区中;在程序运行之前可以只装入它的部分代码即可投入运行,然后在程序运行期间,根据需要动态申请分配内存。
    内存分配前,需要保护操作系统不受用户进程的影响,同时保护用户进程不受其他用户进程的影响。内存保护可采取如下两种方法:

    在CPU中设置一对上、下限寄存器,存放用户作业在主存中的上限和下限地址,每当CPU要访问一个地址时,分别和两个寄存器的值相比,判断有无越界。
    采用重定位寄存器(或基址寄存器)和界地址寄存器(又称限长存储器)来实现这种保护。重定位寄存器包含最小的物理地址值,界地址寄存器含逻辑地址的最大值。每个逻辑地址值必须小于界地址寄存器;内存管理机构动态得将逻辑地址与界地址寄存器进行比较,若未发生地址越界,则加上重定位寄存器的值后映射成物理地址,再送交内存单元。

  4. 常见的内存分配方式

    (1) 从静态存储区域分配。内存在程序编译的时候就已经分配好,这块内存在程序的整个运行期间都存在。例如全局变量,static变量。

    (2) 在栈上创建。在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。

    (3) 从堆上分配,亦称动态内存分配。程序在运行的时候用malloc或new申请任意多少的内存,程序员自己负责在何时用free或delete释放内存。动态内存的生存期由我们决定,使用非常灵活,但问题也最多。

    常见内存分配内存错误

    (1)内存分配未成功,却使用了它。

    (2)内存分配虽然成功,但是尚未初始化就引用它。

    (3)内存分配成功并且已经初始化,但操作越过了内存的边界。

    (4)忘记了释放内存,造成内存泄露。

    (5)释放了内存却继续使用它。常见于以下有三种情况:

    • 程序中的对象调用关系过于复杂,实在难以搞清楚某个对象究竟是否已经释放了内存,此时应该重新设计数据结构,从根本上解决对象管理的混乱局面。
    • 函数的return语句写错了,注意不要返回指向“栈内存”的“指针”或者“引用”,因为该内存在函数体结束时被自动销毁。
    • 使用free或delete释放了内存后,没有将指针设置为NULL。导致产生“野指针”。
  5. malloc如何分配内存

    从操作系统层面上看,malloc是通过两个系统调用来实现的: brk和mmap

    • brk是将进程数据段(.data)的最高地址指针向高处移动,这一步可以扩大进程在运行时的堆大小
    • mmap是在进程的虚拟地址空间中寻找一块空闲的虚拟内存,这一步可以获得一块可以操作的堆内存。

    通常,分配的内存小于128k时,使用brk调用来获得虚拟内存,大于128k时就使用mmap来获得虚拟内存。

    进程先通过这两个系统调用获取或者扩大进程的虚拟内存,获得相应的虚拟地址,在访问这些虚拟地址的时候,通过缺页中断,让内核分配相应的物理内存,这样内存分配才算完成。

  6. 如何避免预读失效和缓存污染

    预读失效

    这些被提前加载进来的页,并没有被访问,相当于这个预读工作是白做了,这个就是预读失效

    传统的 LRU 算法法无法避免下面这两个问题:

    • 预读失效导致缓存命中率下降;
    • 缓存污染导致缓存命中率下降;

    为了避免「预读失效」造成的影响,Linux 和 mysql 对传统的 LRU 链表做了改进:

    • Linux 操作系统实现两个了 LRU 链表:活跃 LRU 链表(active list)和非活跃 LRU 链表(inactive list)
    • MySQL Innodb 存储引擎是在一个 LRU 链表上划分来 2 个区域:young 区域 和 old 区域

    缓存污染

    如果这些大量的数据在很长一段时间都不会被访问的话,那么整个活跃 LRU 链表(或者 young 区域)就被污染了。

    为了避免「缓存污染」造成的影响,Linux 操作系统和 MySQL Innodb 存储引擎分别提高了升级为热点数据的门槛:

    • Linux 操作系统:在内存页被访问第二次的时候,才将页从 inactive list 升级到 active list 里。

    • MySQL Innodb:在内存页被访问

      第二次

      的时候,并不会马上将该页从 old 区域升级到 young 区域,因为还要进行

      停留在 old 区域的时间判断:

      • 如果第二次的访问时间与第一次访问的时间在 1 秒内(默认值),那么该页就不会被从 old 区域升级到 young 区域;
      • 如果第二次的访问时间与第一次访问的时间超过 1 秒,那么该页就从 old 区域升级到 young 区域;

    通过提高了进入 active list (或者 young 区域)的门槛后,就很好了避免缓存污染带来的影响。

  7. 物理内存管理

    操作系统物理内存管理主要包括程序装入、交换技术、连续分配管理方式和非连续分配管理方式(分页、分段、段分页)。

    连续分配管理方式

    连续内存分配
    内存碎片
    当给程序分配空间时,可能会出现一些无法被利用的空闲碎片空间。
    1.外部碎片:分配单元之间无法被利用的内存碎片
    2.内部碎片:分配给任务内存大小大于任务所需内存大小时,多出来的内存碎片。

    分区的动态分配
    连续内存分配情况:将应用程序从硬盘加载到内存中,给内存分配一块内存。应用程序运行访问数据,数据的连续内存空间。

    内存分配算法:
    具体问题具体分析适配算法。

    1. 首次适配:
      定义:使用第一块内存大小大于需求大小的可用空闲块
      实现方法:需要有一个按地址排序的空闲块列表。寻找第一个满足内存需求的空闲块。对于回收,要考虑空闲块与相邻空闲块合并问题。
      优点:简单,易于产生更大的空闲块,想着地址空间结尾分配
      缺点:产生外部碎片,具有不确定性

    2. 最优适配:
      定义:寻找整个空闲块中,最满足分配请求的空闲块,内存差值最小。
      实现方法:需要有一个按尺寸排序的空闲块列表,寻找最满足分配的内存块。对于回收,要考虑空闲块与相邻空闲块合并问题。
      优点:避免分割大空闲块,最小化外部碎片的产生,简单
      缺点:外部碎片很细,有很多微小碎片,不利于后续分配管理。

    3. 最差适配:
      定义:找到差距最大的内存空闲块。
      实现:需要有一个按尺寸排序的空闲块列表,寻找差距最大的内存块。对于回收,要考虑空闲块与相邻空闲块合并问题。
      优点:避免产生微小外部碎片,分配效率高,适用于分配中大快
      缺点:对于大块请求带来一定影响

    减少内存碎片方法

    1. 紧致:压缩式碎片整理
      调整运行程序的位置。
      1.重定位的时机。不能在程序运行时进行,可以在程序等待时拷贝。
      2.内存拷贝开销很大。

    2. swaping:交换式碎片整理
      把硬盘作为一个备份。把等待的程序包括数据(主存)挪到硬盘上。当硬盘上程序需要执行时再拷贝到主存上。
      1.交换那个程序,减小开销
      2.交换的时机

    非连续内存分配

    连续内存分配和非连续内存分配
    连续内存分配缺点:1.分配给一个程序的物理内存是连续的,内存利用率较低,由外碎片内碎片的问题。
    非连续内存分配优点:1.程序物理地址是非连续的 2.更好的内存利用和管理 3.允许共享代码与数据 4.支持动态加载和动态链接
    非连续内存分配缺点:建立虚拟地址到物理地址的映射,软件开销太大,可以用硬件支持
    -》硬件两种管理方案:分段和分页
    分段
    分段地址空间
    对于一段程序内存可以分为:程序(主程序+子程序+共享库)+变量(栈、堆、共享数据段)
    分段:更好的分离与共享,将逻辑地址空间分散到多个物理地址空间
    逻辑地址是连续的,将具有不同功能的映射到物理空间中,这些段大小不一,位置不一

    硬件实现分段寻址机制
    一维逻辑地址有不同段组成,首先将逻辑地址分为两段:段寻址(段号)+段偏移寻址(addr)
    通过段号在段表找到逻辑内存的段起始地址,看段起始地址是否满足段大小限制,不满足返回内存异常,满足将逻辑地址加偏移量是物理地址。

    段表:
    1.存储逻辑地址段段号到物理地址段号之间的映射关系
    2.存储段大小,起始地址
    段表的建立:操作系统在寻址前建立。

    分页
    分页地址空间
    需要页号和页地址偏移。相比分段,分页页帧大小固定不变。
    可以划分物理内存至固定大小的帧,将逻辑地址的页也划分至相同内存大小。大小是2的幂。
    建立方案
    页帧(Frame):物理内存被分割为大小相等的帧
    一个内存物理地址是一个二元组(f,o)
    物理地址 = 2^S*f+o
    f是帧号(F位,2F个帧),o是帧内偏移(S位,每帧2S字节),

    页(Page):逻辑地址空间分割为大小相等的页
    页内偏移大小 = 帧内偏移大小(页帧大小和页大小一致)
    页号大小和帧号大小可能不一致

    一个逻辑地址是一个二元组(p,o)
    逻辑地址 = 2^S*p+o
    p:页号(P位,2P个页),o:页内偏移(S位,每页2S个字节)

    页寻址机制
    CPU寻址(逻辑地址),逻辑地址包含两部分(p,o),首先把p(页号)作为索引,再加上页表基址查页表(pagetable)中对应帧号(物理地址中f),知道帧号加上页内偏移就是物理地址(f,o)。

    页表:以页号为索引的对应的帧号(物理地址),为此需要知道页表的基址位置(页号从哪个地址开始查)。
    页表的建立:操作系统初始化时,enable分页机制前就需要建立好

    分页与分段:
    分页:相比分段,分页页内存固定,导致页内偏移大小范围是固定的。不需要想分段一样考虑分页大小不一致的问题。
    逻辑地址和物理地址空间
    1.总逻辑页大小和总物理帧大小不一致,一般逻辑地址空间大于物理地址空间。
    2.逻辑地址空间连续,物理地址空间不连续。减少内外碎片。
    页表
    页表结构
    页表是个数组,索引是页号,对应的数组项内容里有帧号。

    分页机制性能问题
    1.时间开销:访问一个内存单元需要两次内存访问

    页表不能放在CPU里,只能放在内存里,CPU先做内存寻址找页表基址,再进行页表访问,进行两次内存访问,访问速度很慢

    2空间代价:页表占用空间

    1.64位计算机,每页1KB,页表大小?2^54个数的页表,很大
    2.多个程序有多个页表

    解决方法
    时间上:缓存 ——快表,TLB,Translation Look-aside Buffer
    TLB:

    位于CPU中的一块缓存区域,存放常用的页号-帧号对,采用关联内存的方式实现,具有快速访问功能。
    -CPU寻址时会先通过页号在TLB查找,看是否存在页号的Key,对应得到帧号,进而得到物理地址(减少对物理地址的访问)。TLB未命中,把该项更新到TLB中(x86CPU这个过程是硬件完成,对于mps是操作系统完成)。
    编写程序时,把访问的地址写在一个页号里。
    空间上:间接访问(多级页表机制),以时间换空间
    二级页表:

    对于逻辑地址(p1,p2,o)
    CPU寻址时先通过p1查找一级页表,一级页表中存放的是二级页表的p2的起始地址,再在二级页表对应起始地址查找偏移p2,对应存放的就是帧号。提高时间开销,但一级页表中不存在页表项就不需要占用二级页表项的内存,节省空间。
    多级页表

    页号分为K个部分,建立页表“树”

  8. 快表

    快表,又称联想寄存器(TLB) ,是一种访问速度比内存快很多的高速缓冲存储器,用来存放当前访问的若干页表项,以加速地址变换的过程。与此对应,内存中的页表常称为慢表。

    地址变换过程访问一个逻辑地址的访存次数
    基本地址变换机构①算页号、页内偏移量 ②检查页号合法性 ③查页表,找到页面存放的内存块号 ④根据内存块号与页内偏移量得到物理地址 ⑤访问目标内存单元两次访存
    具有快表的地址变换机构①算页号、页内偏移量 ②检查页号合法性 ③查快表。若命中,即可知道页面存放的内存块号,可直接进行⑤;若未命中则进行④ ④查页表,找到页面存放的内存块号,并且将页表项复制到快表中 ⑤根据内存块号与页内偏移量得到物理地址 ⑥访问目标内存单元快表命中,只需一次访存 快表未命中,需要两次访存
  9. 内存交换技术

    交换(对换)技术的设计思想:内存空间紧张时,系统将内存中某些进程暂时换出外存,把外存中某些已具备运行条件的进程换入内存(进程在内存与磁盘间动态调度)

    换入:把准备好竞争CPU运行的程序从辅存移到内存。 换出:把处于等待状态(或CPU调度原则下被剥夺运行权力)的程序从内存移到辅存,把内存空间腾出来。

    **交换时机:**内存交换通常在许多进程运行且内存吃紧时进行,而系统负荷降低就暂停。例如:在发现许多进程运行时经常发生缺页,就说明内存紧张,此时可以换出一些进程;如果缺页率明显下降,就可以暂停换出。

    关键点

    1. 交换需要备份存储,通常是快速磁盘,它必须足够大,并且提供对这些内存映像的直接访问。
    2. 为了有效使用CPU,需要每个进程的执行时间比交换时间长,而影响交换时间的主要是转移时间,转移时间与所交换的空间内存成正比。
    3. 如果换出进程,比如确保该进程的内存空间成正比。
    4. 交换空间通常作为磁盘的一整块,且独立于文件系统,因此使用就可能很快。
    5. 交换通常在有许多进程运行且内存空间吃紧时开始启动,而系统负荷降低就暂停。
    6. 普通交换使用不多,但交换的策略的某些变种在许多系统中(如UNIX系统)仍然发挥作用。
  10. 分页与分段的区别

    1. 段是信息的逻辑单位,它是根据用户的需要划分的,因此段对用户是可见的 ;页是信息的物理单位,是为了管理主存的方便而划分的,对用户是透明的;
    2. 段的大小不固定,有它所完成的功能决定;页大大小固定,由系统决定;
    3. 段向用户提供二维地址空间;页向用户提供的是一维地址空间;
    4. 段是信息的逻辑单位,便于存储保护和信息的共享,页的保护和共享受到限制。

3. 进程调度算法

  1. 进程调度算法详细介绍

    选择一个进程运行这一功能是在操作系统中完成的,通常称为调度程序scheduler)。

    调度时机

    ​ 在进程的生命周期中,当进程从一个运行状态到另外一状态变化的时候,其实会触发一次调度。

    比如,以下状态的变化都会触发操作系统的调度:

    • 从就绪态 -> 运行态:当进程被创建时,会进入到就绪队列,操作系统会从就绪队列选择一个进程运行;
    • 从运行态 -> 阻塞态:当进程发生 I/O 事件而阻塞时,操作系统必须选择另外一个进程运行;
    • 从运行态 -> 结束态:当进程退出结束后,操作系统得从就绪队列选择另外一个进程运行;

    因为,这些状态变化的时候,操作系统需要考虑是否要让新的进程给 CPU 运行,或者是否让当前进程从 CPU 上退出来而换另一个进程运行。

    另外,如果硬件时钟提供某个频率的周期性中断,那么可以根据如何处理时钟中断 ,把调度算法分为两类:

    • 非抢占式调度算法挑选一个进程,然后让该进程运行直到被阻塞,或者直到该进程退出,才会调用另外一个进程,也就是说不会理时钟中断这个事情。
    • 抢占式调度算法挑选一个进程,然后让该进程只运行某段时间,如果在该时段结束时,该进程仍然在运行时,则会把它挂起,接着调度程序从就绪队列挑选另外一个进程。这种抢占式调度处理,需要在时间间隔的末端发生时钟中断,以便把 CPU 控制返回给调度程序进行调度,也就是常说的时间片机制

    调度原则

    • CPU 利用率:调度程序应确保 CPU 是始终匆忙的状态,这可提高 CPU 的利用率;
    • 系统吞吐量:吞吐量表示的是单位时间内 CPU 完成进程的数量,长作业的进程会占用较长的 CPU 资源,因此会降低吞吐量,相反,短作业的进程会提升系统吞吐量;
    • 周转时间:周转时间是进程运行+阻塞时间+等待时间的总和,一个进程的周转时间越小越好;
    • 等待时间:这个等待时间不是阻塞状态的时间,而是进程处于就绪队列的时间,等待的时间越长,用户越不满意;
    • 响应时间:用户提交请求到系统第一次产生响应所花费的时间,在交互式系统中,响应时间是衡量调度算法好坏的主要标准。

    调度算法

    ​ 调度算法是指:根据系统的资源分配策略所规定的资源分配算法。常用的调度算法有:先来先服务调度算法、时间片轮转调度法、短作业优先调度算法、最短剩余时间优先、高响应比优先调度算法、优先级调度算法等等。

    • 先来先服务调度算法

    先来先服务调度算法是一种最简单的调度算法,也称为先进先出或严格排队方案。当每个进程就绪后,它加入就绪队列。当前正运行的进程停止执行,选择在就绪队列中存在时间最长的进程运行。该算法既可以用于作业调度,也可以用于进程调度。先来先服务比较适合于常作业(进程),而不利于段作业(进程)。

    • 时间片轮转调度算法

    时间片轮转调度算法主要适用于分时系统。在这种算法中,系统将所有就绪进程按到达时间的先后次序排成一个队列,进程调度程序总是选择就绪队列中第一个进程执行,即先来先服务的原则,但仅能运行一个时间片。

    • 短作业优先调度算法

    短作业优先调度算法是指对短作业优先调度的算法,从后备队列中选择一个或若干个估计运行时间最短的作业,将它们调入内存运行。 短作业优先调度算法是一个非抢占策略,他的原则是下一次选择预计处理时间最短的进程,因此短进程将会越过长作业,跳至队列头。

    • 最短剩余时间优先调度算法

    最短剩余时间是针对最短进程优先增加了抢占机制的版本。在这种情况下,进程调度总是选择预期剩余时间最短的进程。当一个进程加入到就绪队列时,他可能比当前运行的进程具有更短的剩余时间,因此只要新进程就绪,调度程序就能可能抢占当前正在运行的进程。像最短进程优先一样,调度程序正在执行选择函数是必须有关于处理时间的估计,并且存在长进程饥饿的危险。

    • 高响应比优先调度算法

    高响应比优先调度算法主要用于作业调度,该算法是对 先来先服务调度算法和短作业优先调度算法的一种综合平衡,同时考虑每个作业的等待时间和估计的运行时间。在每次进行作业调度时,先计算后备作业队列中每个作业的响应比,从中选出响应比最高的作业投入运行。

    • 优先级调度算法

    优先级调度算法每次从后备作业队列中选择优先级最髙的一个或几个作业,将它们调入内存,分配必要的资源,创建进程并放入就绪队列。在进程调度中,优先级调度算法每次从就绪队列中选择优先级最高的进程,将处理机分配给它,使之投入运行。

4. 磁盘调度算法

  1. 磁盘调度算法详细介绍

    常见的磁盘调度算法有:

    • 先来先服务算法
    • 最短寻道时间优先算法
    • 扫描算法
    • 循环扫描算法
    • LOOK 与 C-LOOK 算法

    先来先服务

    ​ 先来先服务(First-Come,First-Served,FCFS),顾名思义,先到来的请求,先被服务。

    最短寻道时间优先

    ​ 最短寻道时间优先(Shortest Seek First,SSF)算法的工作方式是,优先选择从当前磁头位置所需寻道时间最短的请求

    扫描算法

    ​ 最短寻道时间优先算法会产生饥饿的原因在于:磁头有可能再一个小区域内来回得移动。

    ​ 为了防止这个问题,可以规定:磁头在一个方向上移动,访问所有未完成的请求,直到磁头到达该方向上的最后的磁道,才调换方向,这就是扫描(*Scan*)算法

    ​ 这种算法也叫做电梯算法,比如电梯保持按一个方向移动,直到在那个方向上没有请求为止,然后改变方向。

    循环扫描算法

    ​ 扫描算法使得每个磁道响应的频率存在差异,那么要优化这个问题的话,可以总是按相同的方向进行扫描,使得每个磁道的响应频率基本一致。

    ​ 循环扫描(Circular Scan, CSCAN )规定:只有磁头朝某个特定方向移动时,才处理磁道访问请求,而返回时直接快速移动至最靠边缘的磁道,也就是复位磁头,这个过程是很快的,并且返回中途不处理任何请求,该算法的特点,就是磁道只响应一个方向上的请求。

    LOOK 与 C-LOOK算法

    ​ 扫描算法和循环扫描算法,都是磁头移动到磁盘「最始端或最末端」才开始调换方向。

    那这其实是可以优化的,优化的思路就是磁头在移动到「最远的请求」位置,然后立即反向移动。

    针对 SCAN 算法的优化叫 LOOK 算法,它的工作方式,磁头在每个方向上仅仅移动到最远的请求位置,然后立即反向移动,而不需要移动到磁盘的最始端或最末端,反向移动的途中会响应请求

    针对C-SCAN 算法的优化叫 C-LOOK,它的工作方式,磁头在每个方向上仅仅移动到最远的请求位置,然后立即反向移动,而不需要移动到磁盘的最始端或最末端,反向移动的途中不会响应请求

5. 页面置换算法

  1. 页面置换算法详细介绍

    请求调页,也称按需调页,即对不在内存中的“页”,当进程执行时要用时才调入,否则有可能到程序结束时也不会调入。而内存中给页面留的位置是有限的,在内存中以帧为单位放置页面。为了防止请求调页的过程出现过多的内存页面错误(即需要的页面当前不在内存中,需要从硬盘中读数据,也即需要做页面的替换)而使得程序执行效率下降,我们需要设计一些页面置换算法,页面按照这些算法进行相互替换时,可以尽量达到较低的错误率。常用的页面置换算法如下:

    • 先进先出置换算法(FIFO)

    先进先出,即淘汰最早调入的页面。

    • 最佳置换算法(OPT)

    选未来最远将使用的页淘汰,是一种最优的方案,可以证明缺页数最小。

    • 最近最久未使用(LRU)算法

    即选择最近最久未使用的页面予以淘汰

    • 时钟(Clock)置换算法

    时钟置换算法也叫最近未用算法 NRU(Not RecentlyUsed)。该算法为每个页面设置一位访问位,将内存中的所有页面都通过链接指针链成一个循环队列。

6. 网络系统

  1. 什么是零拷贝

    为了提高文件传输的性能,于是就出现了零拷贝技术,它通过一次系统调用(sendfile 方法)合并了磁盘读取与网络发送两个操作,降低了上下文切换次数。另外,拷贝数据都是发生在内核中的,天然就降低了数据拷贝的次数。

    Kafka 和 nginx 都有实现零拷贝技术,这将大大提高文件传输的性能。

    零拷贝技术是基于 PageCache 的,PageCache 会缓存最近访问的数据,提升了访问缓存数据的性能,同时,为了解决机械硬盘寻址慢的问题,它还协助 I/O 调度算法实现了 IO 合并与预读,这也是顺序读比随机读性能好的原因。这些优势,进一步提升了零拷贝的性能。

  2. I/O多路复用

    既然为每个请求分配一个进程/线程的方式不合适,那有没有可能只使用一个进程来维护多个 Socket 呢?答案是有的,那就是 I/O 多路复用技术。

    一个进程虽然任一时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在 1 毫秒以内,这样 1 秒内就可以处理上千个请求,把时间拉长来看,多个请求复用了一个进程,这就是多路复用,这种思想很类似一个 CPU 并发多个进程,所以也叫做时分多路复用。

    我们熟悉的 select/poll/epoll 内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件

    select/poll/epoll 是如何获取网络事件的呢?在获取事件时,先把所有连接(文件描述符)传给内核,再由内核返回产生了事件的连接,然后在用户态中再处理这些连接对应的请求即可。

  3. select/poll/epoll

    select 和 poll 并没有本质区别,它们内部都是使用「线性结构」来存储进程关注的 Socket 集合。

    在使用的时候,首先需要把关注的 Socket 集合通过 select/poll 系统调用从用户态拷贝到内核态,然后由内核检测事件,当有网络事件产生时,内核需要遍历进程关注 Socket 集合,找到对应的 Socket,并设置其状态为可读/可写,然后把整个 Socket 集合从内核态拷贝到用户态,用户态还要继续遍历整个 Socket 集合找到可读/可写的 Socket,然后对其处理。

    很明显发现,select 和 poll 的缺陷在于,当客户端越多,也就是 Socket 集合越大,Socket 集合的遍历和拷贝会带来很大的开销,因此也很难应对 C10K。

    epoll 是解决 C10K 问题的利器,通过两个方面解决了 select/poll 的问题。

    • epoll 在内核里使用「红黑树」来关注进程所有待检测的 Socket,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn),通过对这棵黑红树的管理,不需要像 select/poll 在每次操作时都传入整个 Socket 集合,减少了内核和用户空间大量的数据拷贝和内存分配。
    • epoll 使用事件驱动的机制,内核里维护了一个「链表」来记录就绪事件,只将有事件发生的 Socket 集合传递给应用程序,不需要像 select/poll 那样轮询扫描整个集合(包含有和无事件的 Socket ),大大提高了检测的效率。

    而且,epoll 支持边缘触发和水平触发的方式,而 select/poll 只支持水平触发,一般而言,边缘触发的方式会比水平触发的效率高。

  4. 高性能网络模式:Reactor和Proactor

    常见的 Reactor 实现方案有三种。

    第一种方案单 Reactor 单进程 / 线程,不用考虑进程间通信以及数据同步的问题,因此实现起来比较简单,这种方案的缺陷在于无法充分利用多核 CPU,而且处理业务逻辑的时间不能太长,否则会延迟响应,所以不适用于计算机密集型的场景,适用于业务处理快速的场景,比如 Redis(6.0之前 ) 采用的是单 Reactor 单进程的方案。

    第二种方案单 Reactor 多线程,通过多线程的方式解决了方案一的缺陷,但它离高并发还差一点距离,差在只有一个 Reactor 对象来承担所有事件的监听和响应,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能的瓶颈的地方。

    第三种方案多 Reactor 多进程 / 线程,通过多个 Reactor 来解决了方案二的缺陷,主 Reactor 只负责监听事件,响应事件的工作交给了从 Reactor,Netty 和 Memcache 都采用了「多 Reactor 多线程」的方案,Nginx 则采用了类似于 「多 Reactor 多进程」的方案。

    Reactor 可以理解为「来了事件操作系统通知应用进程,让应用进程来处理」,而 Proactor 可以理解为「来了事件操作系统来处理,处理完再通知应用进程」。

    因此,真正的大杀器还是 Proactor,它是采用异步 I/O 实现的异步网络模型,感知的是已完成的读写事件,而不需要像 Reactor 感知到事件后,还需要调用 read 来从内核中获取数据。

    不过,无论是 Reactor,还是 Proactor,都是一种基于「事件分发」的网络编程模式,区别在于 Reactor 模式是基于「待完成」的 I/O 事件,而 Proactor 模式则是基于「已完成」的 I/O 事件。

  5. 一致性哈希介绍

    一致性哈希是指将「存储节点」和「数据」都映射到一个首尾相连的哈希环上,如果增加或者移除一个节点,仅影响该节点在哈希环上顺时针相邻的后继节点,其它数据也不会受到影响。

    但是一致性哈希算法不能够均匀的分布节点,会出现大量请求都集中在一个节点的情况,在这种情况下进行容灾与扩容时,容易出现雪崩的连锁反应。

    为了解决一致性哈希算法不能够均匀的分布节点的问题,就需要引入虚拟节点,对一个真实节点做多个副本。不再将真实节点映射到哈希环上,而是将虚拟节点映射到哈希环上,并将虚拟节点映射到实际节点,所以这里有「两层」映射关系。

    引入虚拟节点后,可以会提高节点的均衡度,还会提高系统的稳定性。所以,带虚拟节点的一致性哈希方法不仅适合硬件配置不同的节点的场景,而且适合节点规模会发生变化的场景。

7. 锁

  1. 什么是死锁和产生死锁原因

    死锁,是指多个进程在运行过程中因争夺资源而造成的一种僵局,当进程处于这种僵持状态时,若无外力作用,它们都将无法再向前推进。 如下图所示:如果此时有一个线程 A,已经持有了锁 A,但是试图获取锁 B,线程 B 持有锁 B,而试图获取锁 A,这种情况下就会产生死锁。

    产生死锁原因

    ​ 由于系统中存在一些不可剥夺资源,而当两个或两个以上进程占有自身资源,并请求对方资源时,会导致每个进程都无法向前推进,这就是死锁。

    • 竞争资源

    例如:系统中只有一台打印机,可供进程 A 使用,假定 A 已占用了打印机,若 B 继续要求打印机打印将被阻塞。

    系统中的资源可以分为两类:

    1. 可剥夺资源:是指某进程在获得这类资源后,该资源可以再被其他进程或系统剥夺,CPU 和主存均属于可剥夺性资源;
    2. 不可剥夺资源,当系统把这类资源分配给某进程后,再不能强行收回,只能在进程用完后自行释放,如磁带机、打印机等。
    • 进程推进顺序不当

    例如:进程 A 和 进程 B 互相等待对方的数

    进程线程与协程傻傻分不清?一文带你吃透!

    前言

    欢迎来到操作系统系列,依然采用图解 + 大白话的形式来讲解,让小白也能看懂,帮助大家快速科普入门

    本篇开始介绍进程、线程、协程,相信很多小白们对这几个概念理解的不清晰,这里全部给你们安排的明明白白,我们开始进入正文吧

    内容大纲

    小故事

    小明(操作系统)创办了一家互联网小公司,因为准备同时开发A与B两个软件,所以小明请了两个开发团队来做这件事情,分别是小王开发团队与小李开发团队,可是公司特别小,只有一个房间(C P U),而且房间(C P U)只能容纳一个开发团队,为了能两个软件开发不被耽误,小明(操作系统)决定,上午小王团队开发,下午小李团队开发(这个过程称为调度)。

    小李(进程)与小王(进程)身为团队负责人,他们要操心的事情比较多,需要对软件进行分析整理,做架构设计,最后再把任务细化分配给团队的每个开发人员(线程),在团队交换房间的时候,还需要把整个软件开发进度记录下来,方便下次接着开发,相比开发人员就轻松多了,每个人只负责一小块,需要记录的也只有一小块。

    通过这个小故事,大伙也看出来了,一个进程管理着多个线程,就像团队负责人(进程)管理着多个开发人员(线程)一样。


    进程

    什么是进程

    你打开网易云音乐会产生一个进程 ,你打开QQ会产生一个进程 ,你电脑上运行的程序都是进程 ,进程就是这么简单暴力。

    现在我们思考一个问题,有一个进程读取硬盘里的文件,这个文件特别大,需要读取很长时间,如果 C P U 一直傻傻的等硬盘返回数据,那 C P U 的利用率是非常低的。

    就像烧开水,你会傻傻等水烧开吗?很明显,这段时间完全可以去做其他的事情(比如玩玩赛博朋克2077),水烧开了再过来把水倒入水杯中,这样不香吗?

    C P U 也是一样,它发现 进程 在读取硬盘文件,不需要阻塞等待硬盘返回数据,直接去执行其他进程 ,当硬盘返回数据时,C P U 会收到 中断 的信号,于是 C P U 再回到之前的 进程 继续运行

    这种多程序 交替执行 的方式,就是 C P U 管理多进程初步思想。

    可能会有人问了, 交替执行会不会很慢,这个不用担心,因为 C P U 的执行速度与切换速度非常的快,可能就是几十或几百毫秒,超出了人类的感知,一秒钟内可能就交替运行了多个进程,所以给我们产生 并行 的错觉,其实这叫并发。

    单核 多进程交替执行 就是并发,多进程在多核运行就是并行。


    进程的控制结构

    创造任何东西的时候,都要先有形,才有物,你造房子、造汽车或造其他东西,都要有设计图(结构),再根据设计图来创造, 进程也不例外,它也有属于自己的设计图,那就是 进程控制块(process control block,PCB),后面就简称 P C B 好了

    P C B的结构信息

    P C B 进程 存在的唯一标识,这意味一个 进程 一定会有对应的PCB,进程消失,P C B也会随之消失

    • 进程描述信息
      • 进程唯一的标记符,类似唯一id
      • 用户标识符,进程归属的用户,用户标识符主要为共享和保护服务
    • 进程控制和管理信息
      • 进程当前状态,比如运行、就绪、阻塞等,作为处理机分配调度的依据
      • 进程优先级,描述进程抢占处理机的优先级,优先级高的进程可以优先获得处理机
    • 资源分配清单
      • 用于说明有关内存地址空间或虚拟地址空间的状况,所打开文件的列表和所使用的输入/输出设备信息
    • CPU 相关信息
      • 指 C P U 中各寄存器值,当进程被切换时,C P U状态信息都必须保存在相应的 P C B 中,以便进程重新执行时,能再从断点继续执行。

    P C B组成的队列

    P C B通过链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列

    • 将所有处于就绪状态的 进程 链在一起,称为就绪队列

    • 把所有因等待某事件而处于等待状态的 进程 链在一起就组成各种阻塞队列


    进程的状态

    通过观察,我们发现进程执行的过程遵循这样的 运行-暂停-运行 规律,虽然看起来十分简单,但是它的背后涉及到了进程状态的转换

    进程三态

    进程的执行期间,至少具备三种基本状态,即运行态、就绪态、阻塞态。

     上图状态的意义

    • 运行态(Runing):时刻进程占用 C P U
    • 就绪态(Ready):可运行,但因为其他进程正在运行而暂停停止
    • 阻塞状态(Blocked):该进程等待某个事件(比如IO读取)停止运行,这时,即使给它CPU控制权,它也无法运行

    上图状态转换流程

    1. C P U 调度绪态进程执行,进入运行状态,时间片使用完了,回到就绪态,等待 C P U 调度
    2. C P U 调度绪态进程执行,进入运行状态,执行IO请求,进入阻塞态,IO请求完成,CPU收到 中断 信号,进入就绪态,等待 C P U 调度

    进程五态

    在三态基础上,做一次细化,出现了另外两个基本状态,创建态和结束态。

    上图状态的意义

    • 创建态(new):进程正在被创建
    • 就绪态(Ready):可运行,但因为其他进程正在运行而暂停停止
    • 运行态(Runing):时刻进程占用 C P U
    • 结束态(Exit):进程正在从系统中消失时的状态
    • 阻塞状态(Blocked):该进程等待某个事件(比如IO读取)停止运行,这时,即使给它CPU控制权,它也无法运行

    状态的变迁

    • NULL => 创建态(new):一个新进程被创建时的第一个状态
    • 创建态(new) => 就绪态(Ready):当进程创建完成,进入就绪态
    • 就绪态(Ready)=> 运行态(Runing):C P U 从就绪队列选择进程执行,进入运行态
    • 运行态(Runing)=> 结束态(Exit):当进程已经运行完成或出错时,进入结束状
    • 运行态(Runing) => 就绪态(Ready):分配给进程的时间片使用完,进入就绪态
    • 运行态(Runing) => 阻塞状态(Blocked): 进程执行等待事件,进入阻塞态
    • 阻塞状态(Blocked) => 就绪态(Ready):进程事件完成,C P U 收到 中断 信号 ,进入就绪态

    进程七态

    其实进程还有一种状态叫挂起态,挂起态代表该进程不会占用内存空间,它会被换出到硬盘空间保存,当需要使用它的时候,会被换入,加载到内存,挂起态可以分为下面两种

    • 阻塞挂起状态:进程在外存(硬盘)并等待某个事件的出现
    • 就绪挂起状态:进程在外存(硬盘),但只要进入内存,即刻立刻运行

    结合上述的两种挂起态,就组成了进程七态 

    从上图我们发现,创建态、就绪态、运行态,阻塞挂起态、阻塞态都可以转入挂起态,这时问题就产生了,什么情况会转入 挂起态 ,什么情况又会从 挂起态 转入到 非挂起态(就绪态与阻塞态), 操作系统会根据当前资源状况和性能要求、进程的优先级来进行挂起与激活操作,没有固定的说法。


    进程的上下文切换

    C P U把一个进程切换到另一个进程运行的过程,称为进程上下文切换。

    在说进程上下文切换之前,先来聊聊 C P U 上下文切换

    C P U上下文 是指 C P U 寄存器 和 程序计数器

    • C P U 寄存器 是 C P U 内置的容量小,速度极快的缓存
    • 程序计数器是用来存储 是 C P U 正在执行的指令位置或即将执行的下一条指令位置

    C P U 上下文切换 就很好理解了,就是把前一个任务的 C P U上下文 保存起来,然后在加载当前任务的 C P U上下文,最后再跳转到 程序计数器 所指的新位置,运行任务。

    上面说到所谓的「任务」,主要包含进程、线程和中断。所以,可以根据任务的不同,把 CPU 上下文切换分成: 进程上下文切换、线程上下文切换和中断上下文切换。

    进程的上下文是怎么切换的

    首先进程是由内核管理与调度的,所以 进程上下文切换 发生在内核态,进程上下文切换的内容包含用户空间资源(虚拟内存、栈、全局变量等)与内核空间资源(内核堆栈、寄存器等)。

    在做上下文切换的时候,会把前一个 进程 的上下文保存到它的 P C B 中,然后加载当前 进程 的 P C B 上下文到 C P U 中,使得 进程 继续执行

    发生进程上下文切换的场景

    • 为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片耗尽了,切换到其它正在等待 CPU 的进程运行
    • 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行。
    • 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度。
    • 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行
    • 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序。

    线程

    什么是线程

    在早期操作系统都是以 进程 为独立运行的基本单位,直到后面,计算机科学家又提出了更小的能独立运行的基本单位,它就是线程

    在现代操作系统,进程是最小的资源分配单位,线程是最小的运行单位,一个进程下面能有一个或多个线程,每个线程都有独立一套的寄存器和栈,这样可以确保线程的控制流是相对独立的。

    线程带来的好处有以下几点

    • 一个进程中可以同时存在多个线程
    • 让进程具备多任务并行处理能力
    • 同进程下的各个线程之间可以共享进程资源 (同进程内的多线程通信十分简单高效)
    • 更轻量与高效

    线程带来的坏处有以下几点

    • 因为进程资源共享,所以会产生资源竞争,需要通过锁机制来协同
    • 当进程中的一个线程奔溃时,会导致其所属进程的所有线程奔溃(一般游戏的用户设计不会采用多线程方式)

    线程与进程的对比

    • 进程是最小的资源(包括内存、打开的文件等)分配单位,线程是最小的运行单位
    • 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈
    • 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系(和进程大同小异)
    • 线程的创建、终止时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,所以线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们(线程管理的资源较少)
    • 同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的
    • 由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了

    线程比进程不管是时间效率,还是空间效率都要高


    线程的上下文切换

    当进程只有一个线程时,可以认为进程等于线程,线程上下文的切换分两种情况

    1. 不同进程的线程,切换的过程就跟进程上下文切换一样
    2. 两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据

    所以线程的上下文切换相比进程,开销要小很多


    线程的模型

    在说线程模式之前,先介绍3个概念

    • 内核线程:在内核空间就实现的线程,由内核管理
    • 用户线程:在用户空间实现的线程,不归内核管理,是由用户态通过线程库完成线程的管理(用户态是指线程或进程在用户空间运行)
    • 轻量级进程:在内核中来支持用户线程(用户线程与内核线程的中间层,内核线程的高度抽象)

    内核线程

    因为内核线程是由内核空间管理,所以它的 结构线程控制块(Thread Control Block, TCB) 在内核空间,操作系统对 T C B 是可见的

    内核线程

    内核线程有什么优点

    • 内核线程的由内核空间管理,线程的创建、销毁、调度等,都不用你操心,全自动化,属于智能型
    • 内核线程能利用cpu多核的特性,实现并行执行(因为由内核管理,非常智能)
    • 内核线程阻塞,不会影响其他内核线程(因为由内核管理,非常智能)

    内核线程有什么缺点

    • 因为是内核管理,所以内核线程的大部分操作都涉及到内核态,即需要从用户态切换到内核态,开销较大
    • 因为内核资源有限,所以无法大量创建内核线程

    用户线程

    因为 用户线程 在用户空间,是由 用户态 通过线程库来管理,所以它的 结构线程控制块(Thread Control Block, TCB) 也是在线程库里面,对于操作系统而言是看不到 T C B 的,它只能看到整个进程的 P C B(内核无法管理用户线程,也感知不到用户线程)

    用户线程有什么优点

    • 因为用户线程创建、销毁、调度等都不走内核态,直接在用户态进行操作,所以速度特别快
    • 不依赖内核,可用于不支持线程技术的操作系统
    • 可以大量创建用户线程,不消耗内核资源

    用户线程有什么缺点

    • 用户线程创建、销毁、调度等需要自己实现相应线程库
    • 用户线程阻塞会导致整个进程内的其他用户线程阻塞(整个进程阻塞),因为内核感知不到用户线程,所以无法去调度其他用户线程
    • 无法利用cpu多核特性,还是因为内核感知不到用户线程

    轻量级进程(Light-weight process,LWP)

    轻量级进程(Light-weight process,LWP)可以理解成内核线程的高级抽象,一个 进程 可以有一个或多个L W P ,因为每个 L W P 与 内核线程 一对一映射,所以 L W P 都是由一个 内核线程 支持(用户线程关联L W P,即成为内核支持的用户线程)。

    在大多数系统中,L W P与 普通进程 的区别也在于它只有一个最小的执行上下文和调度程序所需的统计信息。一般来说,一个进程 代表程序的一个实例,而 L W P 代表程序的执行线程,因为一个 执行线程 不像进程那样需要那么多状态信息,所以 L W P 也不带有这样的信息。

    一对一模型(内核级线程模型)

    L W P就是一对一模型,即 进程 只需要创建使用L W P ,因为一个 L W P 由一个 内核线 程支持,所以最终是内核管理线程,可以调度到其他处理器上(再简单点解释,直接使用内核线程

    一对一模型(1:1)的优缺点就不多说了,上面介绍内核线程的时候已经说过了,但是值得一提的是,jvm采用该模型实现线程,所以在Java中启动一个线程需要谨慎

    一对多模型(用户级线程模型)

    一对多模型,即多个用 户级线程 对用到同一个 L W P 上实现,因为是用户态通过用户空间的线程库对线程管理,所以速度特别快,不会涉及到用户态与内核态的转换

    一对多模型(n:1)的优点缺点体现在用户级线程上面,用户线程的优缺点前面说过,这里不做概述,值得一提的是 Python 中的协程就是通过该模型实现。

    多对多模型(两级线程模型)

    多对多模型是集各家所长诞生的产物,它充分吸收前两种线程模型的优点且尽量避免它们的缺点。

    首先它区别于多对一模型多对多模型进程内的 多用户线程 可以绑定不同的内核线程 ,这点与 一对一模型 类似,其次又区别于一对一模型,进程内的 多用户线程 与 内核线程 不是一对一绑定,而是动态绑定,当某个 内核线程 因绑定的 用户线程 执行阻塞操作,让出 C P U 时,绑定该 内核线程 的其他 用户线程 可以解绑,重新绑定到其他 内核线程 继续运行。

    所以多对多模型(m:n),即不是多对一模型完全靠自己实现的线程库调度,也不是一对一模型完全靠操作系统调度,而是一个中间态系统(负责自身调度与操作系统调度的协同工作),最后提一句Go语言使用的是多对多模型,这也是其高并发的原因,它的线程模型与Java中的ForkJoinPool非常类似。

    多对多模型优点

    • 兼具多对一模型的轻量
    • 由于对应了多个内核线程,则一个用户线程阻塞时,其他用户线程仍然可以执行
    • 由于对应了多个内核线程,则可以实现较完整的调度、优先级等;

    多对多模型缺点

    • 实现复杂(因为这种模型的高度复杂性,操作系统内核开发者一般不会使用,所以更多时候是作为第三方库的形式出现)

    调度

    调度原则

    CPU 利用率

    • 运行程序发生了I/O 事件的请求,因此阻塞,导致进程在等待硬盘的数据返回。这样的过程,势必会造成 C P U 突然的空闲。所以为了提高 C P U 利用率,发生等待事件使 C P U 空闲的情况下,调度程序需要从就绪队列中选择一个进程来运行。(PS:调度程序应确保 C P U 一直保持匆忙的状态,可提高 C P U 的利用率)

    系统吞吐量

    • 程序执行某个任务花费的时间会比较长,如果这个程序一直占用着 C P U,会造成系统吞吐量的降低。所以要提高系统的吞吐率,调度程序要权衡长任务和短任务进程的运行完成数量。(吞吐量表示的是单位时间内 C P U 完成进程的数量,长作业的进程会占用较长的 C P U 资源,因此会降低吞吐量,相反,短作业的进程会提升系统吞吐量)

    周转时间

    • 从进程开始到结束的过程中,实际上是包含两个时间,分别是进程运行时间和进程等待时间,这两个时间总和就称为周转时间。进程的周转时间越小越好,如果进程的等待时间很长,而运行时间很短,那周转时间就很长,调度程序应该避免这种情况发生。(周转时间是进程运行和阻塞时间总和,一个进程的周转时间越小越好)

    如何使用JDBC操作数据库?一文带你吃透JDBC规范

    如何使用JDBC操作数据库?一文带你吃透JDBC规范

    (建议收藏)万字长文,带你一文吃透 Linux 提权

    进程线程与协程傻傻分不清?一文带你吃透!

    进程线程与协程傻傻分不清?一文带你吃透!

    一文带你吃透Spring Cloud相关微服务组件及Spring Cloud Config框架