操作系统基础

操作系统 #

基础 #

什么是操作系统? #

  • 操作系统(Operating System,简称OS)是管理计算机软件与硬件资源的程序。
  • 本质上是一个运行在计算机上的软件程序。
  • 操作系统的存在屏蔽了硬件层的复杂性。
  • 操作系统的内核(Kernel)是操作系统的核心部分,它负责系统的内存管理,硬件设备的管理,文件系统的管理以及应用程序的管理。

什么是系统调用? #

根据进程访问资源的特点,我们可以把进程在系统上的运行分为两个级别:

1、用户态:用户态运行的进程可以直接读取用户程序的数据。

2、系统态:系统态运行的进程或程序几乎可以访问计算机的任何资源,不受限制。

我们运行的程序基本都是运行在用户态,凡是与系统态级别的资源有关的操作(如文件管理、进程控制、内存管理等),都必须通过系统调用的方式向操作系统提出服务请求,并由操作系统代为完成。

这些系统调用按功能大致可分为如下几类:

  • 设备管理。完成设备的请求或释放,以及设备启动等功能。
  • 文件管理。完成文件的读、写、创建及删除等功能。
  • 进程控制。完成进程的创建、撤销、阻塞及唤醒等功能。
  • 进程通信。完成进程之间的消息传递或信号传递等功能。
  • 内存管理。完成内存的分配、回收以及获取作业占用内存区大小及地址等功能。

简单说下你对并发和并行的理解? #

  • 并发

    在一个时间段中多个程序都启动运行在同一个处理机中

  • 并行

    假设目前A,B两个进程,两个进程分别由不同的 CPU 管理执行,两个进程不抢占 CPU 资源可以同时运行,这叫做并行。

同步、异步、阻塞、非阻塞的概念 #

同步:当一个同步调用发出后,调用者要一直等待返回结果。通知后,才能进行后续的执行。

异步:当一个异步过程调用发出后,调用者不能立刻返回结果。实际处理这个调用的部件在完成后,通过状态,通知和回调来通知调用者。

阻塞是指调用结果返回前,当前线程会被挂起,即阻塞。

非阻塞是指调用结果没返回,也不会阻塞当前线程。

形象比喻

  • 小Q去钓鱼,抛完线后就傻傻的看着有没有动静,有则拉杆(同步阻塞)
  • 小Q去钓鱼,拿鱼网捞一下,有没有鱼立即知道,不用等,直接就捞(同步非阻塞)
  • 小Q去钓鱼,这个鱼缸比较牛皮,扔了后自己就打王者荣耀去了,因为鱼上钩了这个鱼缸带的报警器会通知我。这样实现异步(异步非阻塞)

异常的类型 #

  • 故障
  • 终止
  • 自陷

缓存 #

为了缓解数据库的压力,往往在数据库前面增加一个缓存:

缓存穿透 #

在缓存中查不到key,只能去数据库查询;当有大量请求直接穿透了缓存打到数据库,就是缓存穿透。

解决

  • 系统写好参数校验
  • 缓存空值,过期时间短一些
  • 布隆过滤器

缓存雪崩 #

同一时间大规模key同时失效,大量的请求直接打在数据库上面,导致数据库压力巨大,如果在高并发的情况下,可能瞬间会导致数据库宕机。

原因

  • Redis宕机
  • 大规模key使用了相同的过期时间

解决

  • 原有实效时间加随机值
  • 熔断机制
  • 数据库容灾,分库分表、读写分离
  • 防止Redis宕机:Redis集群

缓存击穿 #

大并发集中对一个热点的key进行访问,突然这个key实效,导致大并发全部打在数据库上,导致数据库压力剧增。

解决

  • 如果业务允许的话,对于热点的key可以设置永不过期的key
  • 使用互斥锁。如果缓存失效的情况,只有拿到锁才可以查询数据库,降低了同一时刻打在数据库上的请求,防止数据库打死。当然这样会导致系统的性能变差。
  • singlefight
singlefight(防止缓存击穿) #

在go语言中可以用singleflightsingleflight能够在同一时间有大量针对同一key的请求的情况,只让一个请求去执行去获取数据,而其他协程阻塞等待结果的返回。

内存 #

操作系统的内存管理机制,内存管理有那几种方式? #

内存管理简单分为连续分配管理方式非连续性分配管理方式

连续分配管理方式是指为一个用户程序分配一个连续的内存空间,如块式管理。非连续分配管理方式运行一个程序使用的内存分布在离散或者说不相邻的内存中,如页式管理和段式管理。

  • 块式管理将内存分为几个固定大小的块,每个块中只包含一个进程。如果程序运行需要内存的话,操作系统就分配给它一块,如果程序运行只需要很小的空间的话,分配的这块内存很大一部分几乎被浪费了。这些在每个块中未被利用的空间,我们称之为碎片。
  • 页式管理把主存分为大小相等且固定的一页一页的形式,页较小,相比于块式管理的划分粒度更小,提高了内存利用率,减少了碎片。页式管理通过页表对应逻辑地址和物理地址。
  • 段式管理:页式管理虽然提高了内存利用率,但是页式管理中的页并无任何实际意义段式管理把主存分为一段段的,段是有实际意义的,每个段定义了一组逻辑信息,例如,有主程序段 MAIN、子程序段 X、数据段 D 及栈段 S 等。 段式管理通过段表对应逻辑地址和物理地址。
  • 段页式管理结合了段式管理和页式管理的优点。简单来说段页式管理机制就是把主存先分成若干段,每个段又分成若干页,也就是说 段页式管理机制中段与段之间以及段的内部的都是离散的。

简单来说:页是物理单位,段是逻辑单位。分页可以有效提高内存利用率,分段可以更好满足用户需求

分页机制和分段机制的共同点和区别 #

共同点 #
  • 分页机制和分段机制都是为了提高内存利用率,减少内存碎片。
  • 页和段都是离散存储的,所以两者都是离散分配的内存方式。但是,每个页和段中的内存是连续的。
不同点 #
  • 页的大小是固定的,由操作系统决定;而段的大小不固定,取决于我们当前运行的程序。
  • 分页仅仅是为了满足操作系统内存管理的需求,而段是逻辑信息单位,在程序中可以体现为代码段,数据段,能够更好的满足用户需要。

逻辑地址和物理地址 #

比如在 C 语言中,指针里面存储的数值就可以理解成为内存里的一个地址,这个地址也就是我们说的逻辑地址,逻辑地址由操作系统决定。物理地址指的是真实物理内存中地址,更具体一点来说就是内存地址寄存器中的地址。物理地址是内存单元真正的地址。

快表和多级页表 #

快表 #

为了提高虚拟地址到物理地址的转换速度,操作系统在页表方案基础上引入了快表来加速虚拟地址到物理地址的转换。我们可以把快表理解为一种特殊的高速缓冲存储器(Cache),其中的内容是页表的一部分或者全部内容。作为页表的Cache,它的作用与页表相似,但是提高了访问速率。由于采用页表做地址转换,读写内存数据时CPU要访问两次主存。有了快表,又是只需要访问一次高速缓冲存储器,一次主存,这样可加速查找并提高指令执行速度。

使用快表之后的地址转换流程是这样的:

1、根据虚拟地址中的页号查快表;

2、如果该页在快表中,直接从快表中读取响应的物理地址;

3、如果该页不在快表中,就访问内存中的页表,再从页表中得到物理地址,同时将页表中的该映射表项添加到快表中。

4、当快表填满后,又要登记新页时,就按照一定的淘汰策略淘汰掉快表中的一个页。

类似于Redis缓存

多级页表 #

引入多级页表的主要目的是为了避免把全部页表一直放在内存中占用过多的空间,特别是那些根本不需要的页表就不需要保留在内存中。

多级页表属于时间换空间场景。

总结 #

为了提高内存的空间性能,提出了多级页表的概念;但是提到空间性能是以浪费时间性能为基础的,因此为了补充损失的时间性能,提出了快表的概念。不论是快表还是多级页表实际上都利用到了程序的局部性原理。

内存溢出(out of memory,简称OOM) #

内存溢出是指程序在申请内存时,没有足够的内存空间供其使用,简单点说就是你要求分配的内存超过了系统能够给你的,系统不能满足需求,于是产生溢出out of memory异常。

内存泄露(memory leak) #

内存泄露是指程序在申请内存后,无法释放已申请的内存空间,简单点说就是你向系统申请分配内存进行使用(new),可是使用完了却不归还(delete),结果你申请到的那块内存你自己也不能再访问(也许你把它的地址给弄丢了),而系统也不能再次将它分配给所需要的程序。

内存泄露是指程序运行过程中已不再使用的内存,没有被释放掉,导致这些内存无法被使用,直到程序结束这些内存才被释放的问题。

Go虽然有GC来回收不再使用的堆内存,减轻了开发人员对内存管理的负担,但并不意味着Go程序不再有内存泄露问题。分配的内存不足以放下数据项序列,称为内存溢出。

内存泄露的定位 #

关于Go的内存泄露:10次内存泄露,有9次是goroutine泄露。

所以,掌握了如何定位和解决goroutine泄露,就掌握了Go内存泄露的大部分场景。

利用好 go pprof获取goroutine profile文件,然后利用3个命令top、traces、list定位内存泄露的原因。

内存泄露的场景 #

内存泄露的场景不仅限于以下两类,但因channel相关的泄漏是最多的。

1、channel的读或写:

  • 无缓冲channel的阻塞通常是写操作因为没有读而阻塞
  • 有缓存的channel因为缓冲区满了,写操作阻塞
  • 期待从channel读数据,结果没有goroutine写

2、select操作,select里也是channel操作,如果所有case上的操作阻塞,groutine也无法继续执行。

虚拟内存 #

局部性原理

时间局部性:如果执行了程序中的某条指令,那么不久后这条指令很有可能再次执行;如果某个数据被访问过,不久之后该数据很可能再次被访问。(因为程序中存在大量的循环)

空间局部性:一日程序访问了某个存储单元,在不久之后,其附近的存储单元也很有可能被访问。(因为很多数据在内存中都是连续存放的,并且程序的指令也是顺序地在内存中存放的)

基于局部性原理,在程序装入时,可以将程序中很快会用到的部分装入内存,暂时用不到的部分留在外存,就可以让程序开始执行。 在程序执行过程中,当所访问的信息不在内存时,由操作系统负责将所需信息从外存调入内存,然后继续执行程序。 若内存空间不够,由操作系统负责将内存中暂时用不到的信息换出到外存。 在操作系统的管理下,在用户看来似乎有一个比实际内存大得多的内存,这就是虚拟内存

CPU寻址,为什么需要虚拟地址空间? #

现代处理器使用的是一种称为虚拟寻址(virtual Addressing)的寻址方式。使用虚拟寻址,CPU需要将虚拟地址翻译成物理地址,这样才能访问到真实的物理地址。实际上完成虚拟地址转换为物理地址转换的硬件是CPU中含有一个被称为内存管理单元(Memory Management Unit,MMU)的硬件。

为什么需要虚拟地址空间呢? #

没有虚拟地址空间的时候,程序直接访问和操作的都是物理内存 。但是这样有什么问题呢?

  1. 用户程序可以访问任意内存,寻址内存的每个字节,这样就很容易(有意或者无意)破坏操作系统,造成操作系统崩溃。
  2. 想要同时运行多个程序特别困难,比如你想同时运行一个微信和一个 QQ 音乐都不行。为什么呢?举个简单的例子:微信在运行的时候给内存地址 1xxx 赋值后,QQ 音乐也同样给内存地址 1xxx 赋值,那么 QQ 音乐对内存的赋值就会覆盖微信之前所赋的值,这就造成了微信这个程序就会崩溃。

总结来说:如果直接把物理地址暴露出来的话会带来严重问题,比如可能对操作系统造成伤害以及给同时运行多个程序造成困难。

通过虚拟地址访问内存有以下优势:

  • 程序可以使用一系列相邻的虚拟地址来访问物理内存中不相邻的大内存缓冲区。
  • 程序可以使用一系列虚拟地址来访问大于可用物理内存的内存缓冲区。当物理内存的供应量变小时,内存管理器会将物理内存页(通常大小为4KB)保存到磁盘文件。数据或代码页会根据需要在物理内存与磁盘之间移动。
  • 不同进程使用的虚拟地址彼此隔离。一个进程中的代码无法更改正在由另一个进程或操作系统使用的物理内存。

虚拟内存的技术实现 #

虚拟内存的实现需要建立在离散分配的内存管理方式的基础上。 虚拟内存的实现有以下三种方式:

  1. 请求分页存储管理 :建立在分页管理之上,为了支持虚拟存储器功能而增加了请求调页功能和页面置换功能。请求分页是目前最常用的一种实现虚拟存储器的方法。请求分页存储管理系统中,在作业开始运行之前,仅装入当前要执行的部分段即可运行。假如在作业运行的过程中发现要访问的页面不在内存,则由处理器通知操作系统按照对应的页面置换算法将相应的页面调入到主存,同时操作系统也可以将暂时不用的页面置换到外存中。
  2. 请求分段存储管理 :建立在分段存储管理之上,增加了请求调段功能、分段置换功能。请求分段储存管理方式就如同请求分页储存管理方式一样,在作业开始运行之前,仅装入当前要执行的部分段即可运行;在执行过程中,可使用请求调入中断动态装入要访问但又不在内存的程序段;当内存空间已满,而又需要装入新的段时,根据置换功能适当调出某个段,以便腾出空间而装入新的段。
  3. 请求段页式存储管理

这里多说一下?很多人容易搞混请求分页与分页存储管理,两者有何不同呢?

请求分页存储管理建立在分页管理之上。他们的根本区别是是否将程序全部所需的全部地址空间都装入主存,这也是请求分页存储管理可以提供虚拟内存的原因,我们在上面已经分析过了。

它们之间的根本区别在于是否将一作业的全部地址空间同时装入主存。请求分页存储管理不要求将作业全部地址空间同时装入主存。基于这一点,请求分页存储管理可以提供虚存,而分页存储管理却不能提供虚存

不管是上面那种实现方式,我们一般都需要:

  1. 一定容量的内存和外存:在载入程序的时候,只需要将程序的一部分装入内存,而将其他部分留在外存,然后程序就可以执行了;
  2. 缺页中断:如果需执行的指令或访问的数据尚未在内存(称为缺页或缺段),则由处理器通知操作系统将相应的页面或段调入到内存,然后继续执行程序;
  3. 虚拟地址空间 :逻辑地址到物理地址的变换。

页面置换算法 #

虚拟内存管理很重要的一个概念就是页面置换算法。

地址映射过程中,若在页面中发现所要访问的页面不在内存中,则发生缺页中断 。

缺页中断 就是要访问的不在主存,需要操作系统将其调入主存后再进行访问。 在这个时候,被内存映射的文件实际上成了一个分页交换文件。

当发生缺页中断时,如果当前内存中并没有空闲的页面,操作系统就必须在内存选择一个页面将其移出内存,以便为即将调入的页面让出空间。用来选择淘汰哪一页的规则叫做页面置换算法,我们可以把页面置换算法看成是淘汰页面的规则

  • OPT 页面置换算法(最佳页面置换算法) :最佳(Optimal, OPT)置换算法所选择的被淘汰页面将是以后永不使用的,或者是在最长时间内不再被访问的页面,这样可以保证获得最低的缺页率。但由于人们目前无法预知进程在内存下的若千页面中哪个是未来最长时间内不再被访问的,因而该算法无法实现。一般作为衡量其他置换算法的方法。
  • FIFO(First In First Out) 页面置换算法(先进先出页面置换算法) : 总是淘汰最先进入内存的页面,即选择在内存中驻留时间最久的页面进行淘汰。
  • LRU (Least Recently Used)页面置换算法(最近最久未使用页面置换算法) :LRU 算法赋予每个页面一个访问字段,用来记录一个页面自上次被访问以来所经历的时间 T,当须淘汰一个页面时,选择现有页面中其 T 值最大的,即最近最久未使用的页面予以淘汰。
  • LFU (Least Frequently Used)页面置换算法(最少使用页面置换算法) : 该置换算法选择在之前时期使用最少的页面作为淘汰页。

进程、线程、协程 #

进程、线程、协程的区别 Goroutine #

  • 对操作系统而言,线程是最小的执行单元,进程是最小的资源管理单元。(资源包括:cpu、信号、设备、内存、文件、IO、网络资源等。)

  • 线程从属于进程,是程序的实际执行者,一个进程至少包含一个主线程,也可以有更多的子线程,线程拥有自己的栈空间。

  • 线程具有五种状态:初始化、可运行、运行中、阻塞、销毁

  • 进程是CPU资源分配的基本单位,线程是独立运行和独立调度的基本单位(CPU上真正运行的是线程)。

  • 进程拥有自己的资源空间,一个进程包含若干个线程,线程与CPU资源分配无关,多个线程共享同一进程内的资源。

  • 线程的调度与切换比进程快很多。

  • 协程既不是进程也不是线程,协程仅仅是一个特殊的函数,协程与进程和线程不是一个维度的。

    一个进程可以包含多个线程,一个线程可以包含多个协程

    一个线程内的多个协程虽然可以切换,但是多个协程是串行执行的,只能在一个线程内运行,没法利用CPU多核能力。

    协程与进程一样,切换是存在上下文切换问题的。

  • 进程的切换者是操作系统,切换时机是根据操作系统自己的切换策略,用户是无感知的。进程的切换内容包括页全局目录、内核栈、硬件上下文,切换内容保存在内存中。进程切换过程是由“用户态到内核态到用户态”的方式,切换效率低。

    用户态到内核态到用户态

    为了保证操作系统的健壮性或者安全性,操作系统会给一些指令进行分类。从宏观上,分为用户态和内核态。内核态主要是控制计算机的硬件资源,并提供上层应用的运行环境。用户态提供上层应用程序的活动空间,应用程序必须依托内核提供的资源环境(CPU资源,存储资源,I/O资源等)。

    为了使上层应用能够访问到内核提供的资源,内核必须为上层应用提供访问接口:即系统调用。

    内核态:CPU可以访问内存的所有数据(允许所有指令执行),包括外围设备,例如硬盘,网卡,CPU也可以将自己从一个程序切换到另一个程序(进程间的切换)。

    用户态:只能访问受限的内存(运行部分指令执行),且不允许访问外围设备,占用CPU的能力可以被剥夺,cpu资源可以被其他程序获取。

    用户态到内核态到切换

    所有用户程序都是运行在用户态的,但是有时候程序确需要做一些内核态到事情,例如从硬盘读取数据,或者从键盘获取输入等,而唯一可以做这些事情的就是操作系统,所以此时程序就需要先以操作系统的名义来执行这些操作。

    这时需要一个这样的机制:用户态程序切换到内核态,但是不能控制在内核态中执行的指令,这种机制叫系统调用,在CPU中的实现称之为陷阱指令

    工作流程如下:

    • 用户态程序将一些数据值放在寄存器中,或者使用参数创建一个堆栈(stack frame),以此表明需要操作系统提供的服务。
    • 用户态程序执行陷阱指令
    • cpu切换到内核态,并跳到位于内存指定位置的指令,这些指令是操作系统的一部分,他们具有内存保护,不可被用户态程序访问
    • 这些指令称之为陷阱或者系统调用处理器。他们会读取程序放入内存的数据参数,并执行程序请求的服务
    • 系统调用完成后,操作系统会重置CPU为用户态并返回系统调用的结果

    线程的切换者是操作系统,切换时机是根据操作系统自己的切换策略,用户无感知。线程的切换内容包括内核栈和硬件上下文。线程切换内容保存在内核栈中。线程切换过程是由“用户态到内核态到用户态”, 切换效率中等。

    协程的切换者是用户(编程者或应用程序),切换时机是用户自己的程序所决定的。协程的切换内容是硬件上下文,切换内存保存在用户自己的变量(用户栈或堆)中。协程的切换过程只有用户态,即没有陷入内核态,因此切换效率高。

1、进程

​ 进程是具有独立功能的程序关于某个数据集合上的一次运动活动,进程是系统进行资源分配和调度的一个独立单位。每个进程都有自己的独立内存空间,拥有自己独立的堆和栈,既不共享堆,也不共享栈,进程由操作系统调度。不同进程通过进程间通信来通信。由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。

2、线程

线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。线程自己基本上不拥有系统资源,而拥有自己独立的栈和共享的堆,共享堆,不共享栈,线程也有由操作系统调度。只拥有一点在运行中必不可少的资源,但是它可与同属于一个进程的其他的线程共享进程所拥有的全部资源。线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。

3、协程

协程是一种用户态的轻量级线程,协程的调度完全由用户控制。协程和线程一样共享堆,不共享栈,协程由程序员在协程的代码里显示调度。协程拥有自己的寄存器和上下文栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切换回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文切换非常快。

4、goroutine和协程的区别

本质上,goroutine就是协程。不同的是,Golang在runtime、系统调用等多方面对goroutine调度进行了封装和处理,当遇到长时间执行或者进行系统调用时,会主动把当前goroutine的CPU转让出去,让其他goroutine能被调度并执行,也就是Golang从语言层面支持了协程。Golang的一大特色就是从语言层面原生支持协程,在函数或者方法面前go关键字就可以创建一个协程。

  • goroutine在内存消耗方面远比java、C的线程少。
  • 线程和goroutine切换调度开销方面,goroutine远比线程小。

为什么有了进程,还要有线程呢? #

  • 进程如果在执行的过程中被阻塞,那这个进程将被挂起,这时候进程中有些等待的资源得不到执行。
  • 进程在同一时间只能做一件事情。

基于以上的缺点,操作系统引入了比进程粒度更小的线程,作为并发执行的基本单位,从而减少程序在并发执行时所付出的时间和空间开销,提高并发性能。

进程有哪几种状态? #

  • 创建状态(new):进程正在被创建,尚未到就绪状态
  • 就绪状态(ready):进程已经进入准备进行状态,即进程获得了除处理器之外的一切所需资源,一旦得到处理器资源(处理器分配的时间片)即可运行。
  • 运行状态(running):进程正在处理器上运行(单核CPU下任意时刻只有一个进程处于运行状态)。
  • 阻塞状态(waiting):又称为等待状态,进程正在等待某一事件而暂停运行,如等待某资源或等待IO操作完成。即使处理器空闲,该进程也不能运行。
  • 结束状态(terminated):进程正在从系统中消失。

进程间的通信七种方式 #

  • 管道/匿名管道(pipes):用于具有亲缘关系的父子进程间或者兄弟进程之间的通信。
  • 有名管道(Names pipes):匿名管道由于没有名字,只能用于亲缘关系的进程间通信。有名管道严格遵循先进先出,以磁盘文件的方式存在,可以实现本机任意两个进程通信。
  • 信号(signal):信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
  • 消息队列(Message Queuing):消息队列是消息的链表,具有特定的格式,存放在内存中并由消息队列标识符标识。管道和消息队列的通信数据都是先进先出原则。与管道(匿名管道:只存在于内存中的文件;有名管道:存在于实际的磁盘介质或者文件系统)不同的是消息队列存放在内核中,只有在内核重启(即,操作系统重启)或者显式地删除一个消息队列时,该消息队列才会被真正的删除。消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取.比 FIFO 更有优势。消息队列克服了信号承载信息量少,管道只能承载无格式字节流以及缓冲区大小受限等缺点。
  • 信号量(semaphores):信号量是一个计数器,用于多进程对共享数据的访问,信号量的意图在于进程间同步。这种通信方式主要用于解决和同步相关的问题并避免竞争条件。
  • 共享内存(shared memory):使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据的更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等。可以说这是最有用的进程间通信方式。
  • 套接字(sockets):此方法主要用于客户端和服务器之间通过网络进行通信。套接字是支持TCP/IP的网络通信的基本操作单元,可以看做是不同主机之间的进程进行双向通信的端点,简单的说就是通信的两方的一种约定,用套接字中的相关函数来完成通信。

goroutine的通信方式 #

  • channel
  • context
  • sync.Cond

进程的调度算法 #

为了确定首先执行哪个进程以及最后执行哪个进程以实现最大 CPU 利用率,计算机科学家已经定义了一些算法,它们是:

  • 先到先服务(FCFS)调度算法 : 从就绪队列中选择一个最先进入该队列的进程为之分配资源,使它立即执行并一直执行到完成或发生某事件而被阻塞放弃占用 CPU 时再重新调度。
  • 短作业优先(SJF)的调度算法 : 从就绪队列中选出一个估计运行时间最短的进程为之分配资源,使它立即执行并一直执行到完成或发生某事件而被阻塞放弃占用 CPU 时再重新调度。
  • 时间片轮转调度算法 : 时间片轮转调度是一种最古老,最简单,最公平且使用最广的算法,又称 RR(Round robin)调度。每个进程被分配一个时间段,称作它的时间片,即该进程允许运行的时间。
  • 多级反馈队列调度算法 :前面介绍的几种进程调度的算法都有一定的局限性。如短进程优先的调度算法,仅照顾了短进程而忽略了长进程 。多级反馈队列调度算法既能使高优先级的作业得到响应又能使短作业(进程)迅速完成。,因而它是目前被公认的一种较好的进程调度算法,UNIX 操作系统采取的便是这种调度算法。
  • 优先级调度 : 为每个流程分配优先级,首先执行具有最高优先级的进程,依此类推。具有相同优先级的进程以 FCFS 方式执行。可以根据内存要求,时间要求或任何其他资源要求来确定优先级。

线程间的三种同步方式 #

  • 互斥量:采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问。
  • 信号量:它允许同一时刻多个线程访问同一资源,但是需要控制同一时刻访问此资源的最大线程数量。
  • 事件:通过通知操作的方式来保持多线程同步,还可以方便的实现多线程优先级的比较操作。

子进程继承了父进程那些资源? #

用户号和用户组号,用户信息,目录信息,环境(表),打开的文件描述符,堆栈,(共享)内存等。

死锁 #

什么是死锁? #

导致线程卡死的锁冲突,

线程 1 已经成功拿到了互斥量 1 ,正在申请互斥量 2 ,而同时在另一个 CPU 上,线程 2 已经拿到了互斥量 2 ,正在申请互斥量 1 。彼此占有对方正在申请的互斥量,结局就是谁也没办法拿到想要的互斥量,于是死锁就发生了。

稍微复杂一点的情况

存在多个互斥量的情况下,避免死锁最简单的方法就是总是按照一定的先后顺序申请这些互斥量。还是以刚才的例子为例,如果每个线程都按照先申请互斥量 1 ,再申请互斥量 2 的顺序执行,死锁就不会发生。有些互斥量有明显的层级关系,但是也有一些互斥量原本就没有特定的层级关系,不过没有关系,可以人为干预,让所有的线程必须遵循同样的顺序来申请互斥量。

产生死锁的原因? #

  • 竞争资源

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

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

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

产生死锁的必要条件? #

  • 互斥

要求各个资源互斥,如果这些资源都是可以共享的,那么多个进程直接共享即可,不会存在等待的尴尬场景。

  • 非抢占

要求进程所占有的资源使用完后主动释放即可,其他进程休想抢占这些资源。原因很简单,如果可以抢占,直接拿就好了,不会进入尴尬的等待场景。

要求进程是在占有至少一个资源的情况下,请求新的资源的。由于新的资源被其他进程占有,此时,发出请求的进程会带着自己占有的资源进入阻塞状态。假设 P1,P2 分别都需要 R1,R2 资源,如果是下面这种方式:

P1:          P2:
request(R1)  request(R2)
request(R2)  request(R1) 

如果 P1 请求到了 R1 资源之后,P2 请求到了 R2 资源,那么此后不管是哪个进程再次请求资源,都是在占有资源的前提下请求的,此时就会带着这个资源陷入阻塞状态。P1 和 P2 需要互相等待,发生了死锁。

换一种情况:

P1:          P2:
request(R1)  request(R1)
request(R2)  request(R2) 

如果 P1 请求到了 R1 资源,那么 P2 在请求 R1 的时候虽然也会阻塞,但是是在不占有资源的情况下阻塞的,不像之前那样占有 R2。所以,此时 P1 可以正常完成任务并释放 R1,P2 拿到 R1 之后再去执行任务。这种情况就不会发生死锁。

  • 循环等待

要求存在一条进程资源的循环等待链,链中的每一个进程所占的资源同时被另一个进程所请求。

发生死锁时一定有循环等待(因为是锁的必要条件),但是发生循环等待的时候不一定会发生死锁。这是因为,如果循环等待链中的 P1 和 链外的 P6 都占有某个进程 P2 请求的资源,那么 P2 完全可以选择不等待 P1 释放该资源,而是等待 P6 释放资源。这样就不会发生死锁了。

解决死锁的基本方法? #

  • 破坏互斥

通过与锁完全不同的同步方式CAS,CAS提供原子性支持,实现各种无锁的数据结构,不仅可以避免互斥锁带来的开销也可避免死锁问题。

  • 破坏不抢占

如果一个线程已经获取到了一些锁,那么在这个线程释放锁之前这些锁是不会被强制抢占的。但是为了防止死锁的发生,我们可以选择让线程在获取后续的锁失败时主动放弃自己已经持有的锁并在之后重试整个任务,这样其他等待这些锁的线程就可以继续执行了。这样就完美了吗?当然不

这种方式虽然可以在一定程度上避免死锁,但是如果多个相互存在竞争的线程不断的放弃重启放弃循环,就会出现活锁的问题,此时线程虽然没有因为锁冲突被卡死,但是仍然会因为阻塞时间太长处于重试当中。怎么办?

方案1:给任务重试部分增加随机延迟时间,降低任务冲突的概率

  • 破坏循环等待

在实践的过程中,采用破坏环路等待的方式非常常见,这种技术叫做"锁排序"。很好理解,我们假设现在有个数组A,采用单向访问的方式(从前往后),依次访问并加锁,这样一来,线程只会向前单向等待锁释放,自然也就无法形成一个环路了。

说到这里,我想说死锁不仅仅出现在多线程编程领域,在数据库的访问也是非常的常见,比如我们需要更新数据库的几行数据,就得先获取这些数据的锁,然后通过排序的方式阻止数据层发生死锁。

这样就完美了?当然没有,那会出现什么问题?

这种方案也存在它的缺点,比如在大型系统当中,不同模块直接解耦和隔离得非常彻底,不同模块开发人员不清楚其细节,在这样的情况下就很难做到整个系统层面的全局锁排序了。在这种情况下,我们可以对方案进行扩充,例如Linux在内存映射代码就使用了一种锁分组排序的方式来解决这个问题。锁分组排序首先按模块将锁分为了不同的组,每个组之间定义了严格的加锁顺序,然后再在组内对具体的锁按规则进行排序,这样就保证了全局的加锁顺序一致。在Linux的对应的源码顶部,我们可以看到有非常详尽的注释定义了明确的锁排序规则。

这种解决方案如果规模过大的话即使可以实现也会非常的脆弱,只要有一个加锁操作没有遵守锁排序规则就有可能会引发死锁。不过在像微服务之类解耦比较充分的场景下,只要架构拆分合理,任务模块尽可能小且不会将加锁范围扩大到模块之外,那么锁排序将是一种非常实用和便捷的死锁阻止技术。

怎么预防死锁? #

  • 破坏请求条件:一次性分配所有资源,这样不会再有请求了。
  • 破坏请保持条件:只要有一个资源得不到分配,也不给这个进程分配其他的资源。
  • 破坏不可剥夺条件:当某进程获得了部分资源,但得不到其他资源,则释放已有的资源。
  • 破坏环路等待条件:系统给每类资源赋予一个编号,每个进程按编号递增的顺序请求资源,释放则相反。

怎么避免死锁? #

  • 银行家算法

当进程首次申请资源时,要测试该进程对资源的最大需求量,如果系统现存的资源可以满足它的最大需求量则按当前的申请量分配资源,否则就推迟分配。

当进程在执行中继续申请资源时,先测试该进程已占用的资源数与本次申请资源数之和是否超过了该进程对资源的最大需求量。若超过则拒绝分配资源。若没超过则再测试系统现存的资源能否满足该进程尚需的最大资源量,若满足则按当前的申请量分配资源,否则也要推迟分配。

  • 安全序列

是指系统能按某种进程推进顺序(P1, P2, P3, …, Pn),为每个进程 Pi 分配其所需要的资源,直至满足每个进程对资源的最大需求,使每个进程都可以顺序地完成。这种推进顺序就叫安全序列【银行家算法的核心就是找到一个安全序列】。

  • 系统安全状态

如果系统能找到一个安全序列,就称系统处于安全状态,否则,就称系统处于不安全状态。

怎么解除死锁? #

  • 资源剥夺:挂起某些死锁进程,并抢占它的资源,将这些资源分配给其他死锁进程(但应该防止被挂起的进程长时间得不到资源)
  • 撤销进程:强制撤销部分、甚至全部死锁进程并剥夺这些进程的资源(撤销的原则可以按进程优先级和撤销进程代价的高低进行)
  • 进程会退:让一个或多个进程会退到足以避免死锁的地步。进程会退时自愿释放资源而不是被剥夺。要求系统保持进程的历史信息,设置还原点。

golang中的死锁 #

goroutine会产生死锁,要么是因为它在等待管道消息,要么是因为它正在等待同步包中的锁。

当没有其他goroutine可以访问通道或锁的时候,一组goroutine正在等待对方,但没有一个能够继续,这时就会产生死锁。

目前,go只检测整个程序何时冻结,而不检测goroutine的子集何时产生死锁。使用管道通常很容易找出导致死锁的原因。

项目 未初始化 关闭的通道
关闭操作 panic panic
发送操作 死锁 panic
接收操作 死锁 通道缓冲区为空(无缓冲通道视为空),则一直读取0值;否则正常读取
第一种情形:无缓冲能力的管道,自己写完自己读 #
func main() {
    ch := make(chan int, 0)
    ch <- 666
    x := <- ch
    fmt.Println(x)
}

我们可以看到这是一个没有缓存能力的管道,然后往里面写666,然后就去管道里面读。这样肯定会出现问题啊!一个无缓存能力的管道,没有人读,你也写不了,没有人写,你也读不了,这正是一种死锁!

fatal error: all goroutines are asleep - deadlock!
第二种情形:协程来晚了 #
func main() {
    ch := make(chan int,0)
    ch <- 666
    go func() {
        <- ch
    }()
}

我们可以看到,这条协程开辟在将数字写入到管道之后,因为没有人读,管道就不能写,然后写入管道的操作就一直阻塞。这时候你就有疑惑了,不是开辟了一条协程在读吗?但是那条协程开辟在写入管道之后,如果不能写入管道,就开辟不了协程。

第三种情形:管道读写时,相互要求对方先读/写 #

如果相互要求对方先读/写,自己再读/写,就会造成死锁。

func main() {
    chHusband := make(chan int,0)
    chWife := make(chan int,0)
    go func() {
        select {
        case <- chHusband:
            chWife<-888
        }
    }()
    select {
        case <- chWife:
            chHusband <- 888
    }
}

先来看看老婆协程,chWife只要能读出来,也就是老婆有钱,就给老公发个八百八十八的大红包。

再看看老公的协程,一看不得了,咋啦?老公也说只要他有钱就给老婆包个八百八十八的大红包。

两个人都说自己没钱,老公也给老婆发不了红包,老婆也给老公发不了红包,这就是死锁!

第四种情形:读写锁相互阻塞,形成隐形死锁 #
func main() {
    var rmw09 sync.RWMutex
    ch := make(chan int,0)
    go func() {
        rmw09.Lock()
        ch <- 123
        rmw09.Unlock()
    }()
    go func() {
        rmw09.RLock()
        x := <- ch
        fmt.Println("读到",x)
        rmw09.RUnlock()
    }()
    for {
        runtime.GC()
    }
}

这两条协程,如果第一条协程先抢到了只写锁,另一条协程就不能抢只读锁了,那么因为另外一条协程没有读,所以第一条协程就写不进。

如果第二条协程先抢到了只读锁,另一条协程就不能抢只写锁了,那么因为另外一条协程没有写,所以第二条协程就读不到。