【操作系统】读书笔记

冯·诺依曼结构

冯·诺依曼结构也称为普林斯顿结构,它是一种把程序指令存储器和数据存储器合并在一起的存储器结构。
冯诺依曼结构的组成
(1)运算器:运算器是计算机中执行各种运算操作的设备,比如加、减、乘、除四则运算、与或非异或这些逻辑操作;
(2)控制器:控制器是发布命令的"决策机构",也就是协调和指挥整个计算机系统的操作(南桥北桥)。
运算器和控制器统称中央处理器,也就是我们电脑里的CPU
(3)存储器:存储器分为内存和外存。RAM,计算机所有程序的运行都在内存中进行,断电后内存会丢失数据。然后是外存,外存用来存放一些需要长期保存的程序或数据,断电后也不会丢失,容量比较大,但存取速度慢,比如ssd、机械硬盘等。
电脑执行程序的时候,需要先把外存的数据读到内存里面,然后再让CPU进行处理。
(4)输入设备:输入设备是计算机和其他设备通信的桥梁。常见的输入设备有键盘,鼠标,摄像头等等。
(5)输出设备:用于接收计算机数据的输出显示、打印、声音等。它会把各种结果数据以图像、声音的形式表现出来。常见的输出设备有显示器、打印机等。
CPU运算速度 > 寄存器速度 > L1~L3Cache > 内存 > 磁盘> 光盘磁带

操作系统内核

内核是应用程序连接硬件设备的桥梁,应用程序只需要和与内核交互,不用关心硬件的细节。

内存分为两个区域
内核空间,这个内存空间只有内核程序可以访问;
用户空间,这个内存空间专门给应用程序使用;
内核空间的代码可以访问所有内存空间,而用户空间的代码只能访问当前的局部空间,应用程序如果需要进入内核空间,就需要通过系统调用。

进程、线程和协程

进程
本质上来说,进程是操作系统对一个运行时程序的抽象。一个系统里可以同时运行多个进程,然后通过CPU在进程之间不停的上下文切换来实现每一个进程独占硬件资源,每个进程都有自己独立的内存空间。
线程
相比于进程,线程更简单些。线程又叫轻量级进程,一个进程里可以包含多个线程,线程是程序的实际执行者,也是CPU调度的最小单位。线程自己只有少部分的私有数据比如寄存器、栈等必不可少的资源,同一进程的所有线程都可以共享这个进程的所有资源;

进程是操作系统进行资源分配的最小单元,而线程是操作系统进行调度执行的最小单元。

协程
协程也可以称为轻量级线程,一个线程可以拥有多个协程,它并不是被操作系统管理的,而是由我们用户程序自己控制的。协程就像一个函数一样,在执行过程中,它可以在程序里中断,然后去执行别的程序,执行完之后再返回来接着执行原程序。因为线程的调度是在操作系统中进行的,而协程调度是在用户空间进行的,所以协程并不会像线程的上下文切换那么消耗资源。
举个例子来说明进程和线程的区别:
比如现在有个工厂开业,造了一条生产线,生产线里拥有生产所需要的机器、原材料等全部的资源。运行起来之后这条生产线就像一个进程。
但只有材料没有工人是不行的,所以需要找工人进行生产,工人能够利用这些机器和材料一步步把产品制造出来。那这个实际来生产的工人就是线程,多个工人就是多线程。

一个进程最多可以创建多少个线程?

和两个方面有关系
进程的虚拟内存上限,因为创建一个线程,操作系统需要为其分配一个栈空间,如果线程数量越多,所需的栈空间就要越大,那么虚拟内存就会占用的越多。
系统参数限制,比如linux系统有系统级别的参数来控制整个系统的最大线程个数。

32 位系统:用户态的虚拟空间只有 3G,如果创建线程时分配的栈空间是 10M,那么一个进程最多只能创建 300 个左右的线程。
64 位系统:用户态的虚拟空间大到有 128T,理论上不会受虚拟内存大小的限制,而会受系统的参数或性能限制。

进程的几种状态

  1. 创建状态:进程正在被创建。
  2. 就绪状态:进程已处于准备运行状态,这时进程已经获得了除处理器之外的全部所需资源,一旦得到处理器就可以运行了。万事俱备,只欠CPU
  3. 运行状态:进程已经获得CPU正在运行中。
  4. 阻塞状态:又称为等待状态,进程正在等待某一事件而暂停运行,比如在等待输入/输出完成,这时即使CPU空闲进程也不能运行。
  5. 结束状态:进程正常结束或其他原因中断退出。

当一个就绪进程获得处理器时,状态由就绪变为运行。
当一个运行进程被剥夺处理器时,比如出现更高优先级别的其他进程,它的状态会由运行变为就绪。
当一个阻塞进程所等待的事件发生后,它的状态由阻塞变为就绪。

中断和异常

中断是指发生了一些状况使CPU停止运行当前程序,而去执行另一段程序,处理结束后再返回到原程序处继续执行,这个过程就称为中断。它的主要目的是让操作系统具备应对处理突发事件的能力。
按照中断源头可以分为内外中断和软硬中断。
外中断:狭义上的中断指的就是外中断,它指来源于CPU执行指令外部的事件发生的中断,比如说设备发出的I/O结束中断,表示设备IO处理已经完成,希望处理机能够向设备发下一个IO请求,同时让已经完成IO请求后的程序继续运行。
简单来说,外中断通常是与当前运行程序无关的事件,也就是外因
内中断:内中断也可以叫做异常,指来源于CPU执行指令内部的事件发生的中断,比如非法指令、地址越界等等,内因
软中断:软中断从字面意思理解就是由被调用执行程序所引起的中断,软中断大部分都是人为主动引起的。
硬中断:硬件中断是由一些和系统相连的外设比如网卡、键盘等自动产生的,每个设备都有自己的中断请求。

多进程和多线程

进程是操作系统对一个运行时程序的抽象,比如打开一个浏览器,就开启了一个浏览器进程,多进程就是同时运行多个进程。
线程是操作系统进行运算调度的最小单位,在一个进程中,可以同时处理很多事情,例如在浏览器进程中,可以在多个选项卡中打开多个页面,有的页面播放音乐,有的页面播放视频,那播放音乐和播放视频就是两个线程,所以多线程就是一个进程中同时执行多个线程。

并发和并行

并发是指多个线程对应的多条指令被快速轮换地执行。比如CPU先执行线程A的指令,再执行线程B的指令,然后再从B切回线程A执行一段时间。CPU执行指令和切换线程的速度非常快,我们一般感知不到切换线程这个过程,这使得多个线程看起来是同时在运行,但其实同一时刻只有一个线程在执行。
并行是指同一时刻有多条指令在多个处理器上同时执行,并行必须依赖多个CPU。

一个简单的栗子就是看电视的时候,有人打电话进来,我们暂停电视,去接电话,接完了回来继续看电视,这是并发,同一时间只能干一件事。如果说我们边看电视边接电话,这就是并行,同一时间干多件事。

进程同步与互斥

进程互斥
进程互斥指的是当一个进程访问临界资源(一个时间段内只允许一个进程使用的资源)时,其他想要访问这个临界资源的进程必须等待,直到临界资源被释放。

进程同步
进程是并发执行的,不同进程之间存在着不同的相互制约关系。为了协调进程之间的先后工作次序,引入了进程同步的概念。
比如让系统计算1+2*3,假设系统有1个加法进程,1个乘法进程。要让计算结果正确,必须先乘法后加法,如果不加以制约那么加法的进程可能在乘法之前,这就导致结果会出错,因此引入了同步机制。
进程同步的四种方法:
临界区:通过对多线程的串行化来访问公共资源,速度快。但只能用来同步本进程内的线程,而不可用来同步多个进程中的线程。
信号量:它允许多个线程在同一时刻访问同一资源,但是需要限制在同一时刻访问此资源的最大线程数目。它可以用来同步多个进程中的线程。
互斥量:为协调对一个共享资源的单独访问而设计的。互斥对象只有一个,只有拥有互斥对象的线程才具有访问资源的权限。互斥量是信号量的一种特殊情况,当信号量的最大资源数=1就是互斥量了。
管程:因为大量的同步操作分散在各个进程中,给系统的管理带来麻烦。所以可以为每个共享资源设立一个专门的管程,来统一管理各进程对该资源的访问。

进程、线程通信

首先进程之间正常应该是相互独立的,但是由于不同的进程之间可能要共享某些信息,所以就有了进程通信。然后进程间通信依赖于操作系统所提供的公共资源,因为这些资源可能以文件、队列或者原始内存块儿的形式来提供,所以进程间通信方式也有几种分类,主要是管道通信、消息队列和共享内存这几种。

首先线程是操作系统调度的最小单位,它有自己的栈空间。我们经常需要多个线程按照指定的规则共同完成一件任务,这些线程之间需要互相协调共享的资源。所以线程通信就是当多个线程共同操作共享资源时,互相去告知自己的状态以避免资源的争夺

管道通信:​ 所谓管道它的实质是一个内核的缓冲区,是一种半双工的通信方式,数据只能单向流动,进程以先进先出的方式从缓冲区中存取数据。管道一端的进程顺序地把数据写入缓冲区,另一端的进程就顺序地读取数据。
管道通信系统可分为两种:
1.匿名管道:一般用于相关进程之间的通信,比如父子进程。
2.命名管道:允许在无关的进程之间使用。
这两种管道只有建立、打开和删除的方式不同,其他方面都是一模一样的。
消息队列: 所谓消息队列,就是一系列保存在内核中消息的列表。在发送数据的时候,它会分成多个独立的消息体,消息的发送方和接收方要提前约定好消息体的数据类型,每个消息体都是固定大小的存储块,而不像管道是无格式的字节流数据。
消息队列的缺点是在通信过程中,存在用户态和内核态之间的数据拷贝开销。因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程。
共享内存:所谓共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。这样这个进程写入的东西,其他一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度。

常见进程调度算法

进程调度算法指的是系统的资源分配算法,常见的调度算法有先来先服务、短进程优先调度、优先级调度、时间片轮转等。
先来先服务算法:先来先服务调度是一种比较简单的调度算法。当每个进程就绪后,它加入就绪队列。按照进程在就绪队列的先后次序进行调用。一旦一个进程得到调度,它就会一直运行下去,直到这个进程完成任务或者因为等待事件不能继续运行,才会让出处理机。
这个算法有个问题就是当一个大进程先到达就绪状态时,它执行的时间会比较长,这就导致许多小进程等待很长时间。
短进程优先调度算法:短进程优先会从进程的就绪队列中挑选一些估计运行时间比较短的进程先来运行。这样会减少在就绪队列中等待的进程数,同时也缩短了进程的平均等待时间。
但这个算法有个问题就是当短进程过多时,大进程可能没有机会运行,导致大进程一直在等待。
优先级调度算法:优先级调度是根据进程的优先级进行调度的,这就保证优先级高的进程先运行。然后在实际情况下,一个进程的优先级可能不是固定的,它会有变化。所以优先级调度算法又可以分为两种:
①非剥夺优先级调度算法。一旦有个高优先级的进程得到处理机,就一直运行下去,直到任务完成或或等待事件而主动让出处理机后,其他高优先级进程才会接着运行。
②可剥夺优先级调度算法。在进程运行过程中,一旦有另一个优先级更高的进程出现,进程调度程序就强制使原运行进程让出处理机给更高优先级的进程使用。
时间片轮转算法:在轮转算法中,系统会把所有的就绪进程按先来先服务算法排成一个就绪队列。系统设置每隔一个时间片就产生一次中断,当时间到后,无论进程有没有执行完都要把处理机分配给队列里新的第一个进程,然后把原进程分配到队列末尾进行排队。这样就可以保证就绪队列里的所有进程在确定的时间段内,都能获得一个时间片的处理机时间。

让进程后台运行

nohup命令,标准输出和标准错误输出会被重定向到 nohup.out 文件中。

nohup python manage.py runserver 0.0.0.0:8001 &

进程终止方式

1、main函数的自然返回 return
2、调用 c语言函数库里的exit函数
3、调用_exit函数,属于系统调用
4、调用ctrl+c 发终止信号

exit()和_exit()区别

这两个函数作用都是退出一个进程,区别如下:
1)exit()是一个标准c库函数;_exit()是一个系统调用函数,
2)exit()会清空输出缓冲区的缓存,执行标准I/0库的清理关闭操作;
3)exit()会执行调用使用atexit注册的终止处理程序;
注:对于atexit()注册的终止处理程序是先注册后调用,ANSI C规定最多可以注册32个终止处理程序。

父子进程、僵尸进程、孤儿进程、守护进程

父子进程
所谓父子进程,就是在一个进程的基础上创建出另一条完全独立的进程,这个就是子进程,相当于父进程的副本。当一个进程需要做一些可能发生阻塞或中断的任务,父进程可通过子进程去做,来避免自己进程的崩溃。
僵尸进程
当一个进程退出时,内核会释放这个进程的所有资源,但是会留下一个保留了进程号、退出状态等信息的数据结构,这些信息直到有父进程接收后才会被释放。这样设计的目的主要是保证父进程能够知道子进程结束时的状态信息。
所以如果子进程退出,但父进程并没有接收子进程的这个状态信息,那这个进程称之为僵尸进程。
如果父进程一直不调用 wait() 进行接收的话,那僵尸进程保留的信息就不会释放,它的进程号也就一直会被占用。而系统所能使用的进程号是有限的,如果产生大量的僵尸进程,系统能用的进程号就会十分紧缺甚至不能产生新的进程。
孤儿进程
如果父进程先于子进程退出,子进程就会变成孤儿进程。孤儿进程将被 init 进程所收养,并由 init 进程对它们完成状态收集工作。init 进程就好像是一个孤儿院,专门负责处理孤儿进程的善后工作。来一个孤儿进程,init 进程就会当它的父亲,然后再用wait() 处理这个子进程,所以孤儿进程并不会有什么危害。
守护进程
守护进程是个特殊的孤儿进程,用来执行特定的系统任务。它的生存周期比较长,一般是随操作系统启动,随操作系统关闭。它是独立于终端的,也就是说终端退出也不会影响到守护进程,并且周期性地执行或等待任务。

如何避免僵尸进程

1、让僵尸进程的父进程每隔一段时间来查询子进程是否结束并回收,调用wait()或者waitpid()来释放僵尸进程。
2、借助SIGCHLD信号回收子进程。
3、先kill掉父进程,让僵尸进程变成孤儿进程,再由init自动回收。

进程是如何崩溃的

正常情况下,操作系统为了保证系统安全,所以针对非法内存访问会发送一个 SIGSEGV 信号,操作系统一般默认让相关进程崩溃。但如果进程自定义了自己的信号处理函数,那它就可以做一些自定义的操作,比如记录 crash 信息等有意义的事。线程崩溃不会导致 JVM 进程崩溃就是因为虚拟机内部定义了信号处理函数。

进程写文件时,进程崩溃,已写入的数据会丢失吗?

不会丢失。
因为进程在执行write操作的时候,实际上是将数据写到了内核的 page cache里,page cache是文件系统中用于缓存文件数据的缓冲,由多个 page 构成,那么即使进程崩溃了,文件数据还是保留在内核的 page cache。我们读数据的时候,也是从内核的 page cache 读取,因此依然能读到进程崩溃前写入的数据。之后内核会找一个合适的机会,把 page cache 里的数据持久化到磁盘里。但是如果 page cache 里的文件数据,在持久化到磁盘化到磁盘之前,发生了系统崩溃,那这部分数据就会丢失了。

线程崩溃,进程一定会崩溃吗

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

磁盘调度算法

首先一个合适的磁盘调度算法能够减少IO读取的时间。
1、FCFS:先来先服务算法
算法思想非常简单,就是不论初始磁头在什么位置,都是按照服务队列的先后顺序依次处理进程,类比于队列的先进先出。优点是进程处理起来非常简单,但是平均寻道长度会变长。

2、SSTF:最短寻道时间算法
最短寻道时间算法的本质是贪心,已知磁头的初始位置,那最先被处理就是距离磁头位置最近的进程,处理完成后再处理距离当前磁道最近的进程,以此类推直到所有的进程被处理。这个算法的优点是平均寻道长度会大大减少,缺点是距离初始磁头较远的服务长期得不到处理,产生"饥饿"现象。

3.SCAN:电梯扫描算法
电梯扫描算法和电梯的运行过程类似,总是按一个方向来进行磁盘调度,直到该方向上没有未完成的磁盘请求,然后改变方向。
因为考虑了移动方向,因此所有的磁盘请求都会被满足,解决了最短寻道时间算法的饥饿问题。

虚拟内存的目的

首先讲一下为什么需要虚拟内存。
比如一个单片机,它是没有操作系统的,每次写完代码都需要用工具把程序烧录进去,才能跑起来,单片机的 CPU 是直接操作内存的「物理地址」。如果第一个程序在内存写入了一个值,第二个程序如果用到了相同位置,将会覆盖掉第一个程序写的东西,所以同时运行两个程序根本行不通,这两个程序会立刻崩溃掉。
虚拟内存是一种内存管理技术,它为每个进程分配一个独立的虚拟内存地址,然后把虚拟地址和内存物理地址映射起来,为进程提供内存的安全保证。这样不同的进程运行的时候,写入的物理地址不同就不会再有冲突了。
每个进程都有自己的虚拟空间,而物理内存只有一个,所以当启用了大量的进程,物理内存必然会很紧张。因为程序运行符合局部性原理,CPU 访问内存会有很明显的重复访问的倾向性,于是操作系统会通过内存交换技术,把不常使用的内存换出到硬盘上,在需要的时候再换入到物理内存上。所以虚拟内存可以运行进程需要的内存超过实际物理内存大小。

内存分配过程

应用程序通过 malloc 函数申请内存的时候,实际上申请的是虚拟内存,此时并不会分配物理内存。
当应用程序读写了这块虚拟内存,CPU 就会去访问这个虚拟内存, 这时会发现这个虚拟内存没有映射到物理内存, CPU 就会产生缺页中断,进程会从用户态切换到内核态,并将缺页中断交给内核的缺页中断函数处理。
缺页中断处理函数会看是否有空闲的物理内存,如果有,就直接分配物理内存,并建立虚拟内存与物理内存之间的映射关系。
如果没有空闲的物理内存,那么内核就会开始进行回收内存。
如果直接内存回收后,空闲的物理内存仍然无法满足此次物理内存的申请,那么内核就会触发 OOM Out of Memory机制。OOM Killer 机制会选择一个占用物理内存较高的进程杀掉,然后释放它的内存资源,如果物理内存依然不足,OOM Killer 会继续杀死占用物理内存较高的进程,直到释放足够的内存位置。我们可以通过调整进程的 /proc/[pid]/oom_score_adj 值,来降低被 OOM killer 杀掉的概率。

内存覆盖与内存交换

首先内存覆盖和内存交换都是当内存空间紧张时进行扩充内存用的。
内存覆盖
由于早期的计算机内存很小所以会经常出现内存大小不够的情况,覆盖技术的思想就是把用户空间分为固定区和覆盖区。固定区中存放需要常用的程序段,而覆盖区中存放不常用的程序段,覆盖区的程序段会在需要时再调入内存,以此来减少内存压力。
内存交换
交换技术的设计思想是当内存空间紧张时,系统把处于等待状态下的程序从内存移到外存,把内存空间腾出来。然后把已经处在就绪态的程序从外存移到内存。

页面置换算法

我们知道在进程运行的过程中,如果进程需要访问的页面不在内存里就会发生缺页中断,我们需要把这个页面调入内存里。而如果此时内存已经没有空闲空间了,就要让系统从内存中调出页面放到磁盘的对换区里,然后把需要的页面再调进内存里。这个调出哪个页面的算法就是页面置换算法。
最佳置换算法OPT
算法思想:把未来长时间都不再访问的页面换出去,但实际无法保证某个页面能够在未来的长时间内不被访问,所以算法只有理论可行性。
先进先出置换算法FIFO
算法思想:总是淘汰最先进入内存的页面,但这样可能会造成使用频率较多的页面被换出去,影响效率。
最少使用置换算法LRU
算法思想:每次选择内存中离当前时刻最长时间没被使用的页面换出去,主要考虑的是程序访问的时间局部性,一般能有较好的性能。

抖动

当分配给进程的存储块数量小于进程所需要的最小值,进程将很频繁地产生缺页中断,这种频率非常高的页面置换现象称为抖动。
在请求分页存储管理中,有这么一种情况,从主存里刚刚换出某一个页面到硬盘后,又有请求要换入这个页,这种反复换出换入的现象就叫系统抖动。产生这种现象的主要原因是置换算法选择不当。

内存交换中,被换出的进程保存在哪里?

保存在磁盘中,也就是外存中。操作系统通常把磁盘空间分为文件区和对换区两部分。文件区主要用于存放文件,为了追求存储空间的利用率,文件区的空间管理采用离散分配方式; 而对换区空间只占磁盘的一小部分空间,被换出的进程数据就存放在对换区。由于对换的速度直接影响到系统的整体速度,因此对换区的空间管理主要追求换入换出速度,因此通常对换区采用连续分配方式。

分页、分段与段页式存储管理

它们都是维护了虚拟地址与物理地址的映射关系

内碎片:已经分配给进程的内存里有部分没用上
外碎片:内存中有些空闲区域因为比较小,而难以利用上

分页存储
分页是指程序的逻辑空间被划分成多个固定大小的页,同时内存空间也被分成多个和页大小相等的物理块,然后把程序的页放在内存块儿里。分页是系统自动进行的,所以对用户是透明的。
在分页系统中,系统会为每个进程建立一张页表,这个页表的作用是实现从页号到物理块号的地址映射。由于进程的最后一页经常装不满一整块所以会形成了不可利用的碎片,我们叫做"页内碎片"。
分页存储的优点是内存利用率高,没有外部碎片。但是各页中的内容没有什么关联,不利于编程和共享。
分段存储
分段是指按程序的内容把逻辑空间划分为若干个大小不等的段,比如程序段、数据段等,然后以段为单位来分配内存。
段长可以根据需要动态改变,有多少需求就分配多大的段,所以不会产生内部碎片。但是段和段之间容易留下碎片,也就是外部碎片。
段页式存储
段页式存储是分段和分页结合的存储方法,它兼顾了分段管理和分页管理的优点。
(1) 第一步用分段方法把程序的逻辑空间按程序的内容分成各个段,每个段都有自己的段名,再把每段分成固定大小的多个页面。
(2) 第二步用分页方法来把内存分成和页大小相等的存储块,程序对内存的调入和调出都按页进行。系统会给每个进程建立一张段表用来管理各个段,然后在每个段里建立一张页表来管理段里面的实际页面。
段页式存储既有独立逻辑功能的段,又以大小相同的页为内存分配单位,所以不会产生外部碎片,但它仍会有内部碎片,而且也增加了系统的复杂度和管理上的开销。
Linux 系统主要采用了分页管理

页表、多级页表和快表

页表
简单来说,页表的作用就是保存虚拟地址和物理地址之间的映射关系,每个进程都有自己的页表。CPU有一个寄存器用来指向页表在内存中的起始地址,在上下文切换时,切换页表只需要修改寄存器中的值就可以。
多级页表
为了更好的提高内存的利用率,每一页应该做得小一些,但是每页越小总页数就越多,页表也就会越大。页表如果很大的话就会对内存造成浪费,因为存放页表的这部分内存是不能给程序使用的,所以单层页表容易导致维护页表的开销开始变大。
多层级页表的表现形式有点像书籍的索引,是目录和子目录的关系。这样就会形成一级页表,二级页表。查询时首先根据页目录号从页目录表中查询到页表基址。这样就减少了内存中存储页表的内存浪费,提高了内存使用率;
快表
因为页表是存储在内存中的,CPU要经常去内存读取页表,这样速度会有些慢,所以为了加快读取页表的速度,就创建了快表的概念;
所谓快表就是在CPU和内存之间加一组由寄存器构成的缓存也就是TLB,专门用来存取最近使用过的页表信息。当CPU需要访问某一页时首先在缓存里找,如果有就直接返回给CPU,没有再去内存查找剩余的页表;因为CPU访问寄存器的速度远大于对内存的访问速度,所以就这样可以提升读取性能。

动态分区分配算法

动态分区分配算法会为程序分配一个连续的内存空间,主要包括四种类型:首次适应算法、邻近适应算法、最佳适应算法和最坏适应算法。
首次适应算法:指的是在进行内存分配的时候,从内存空闲链的首位置开始顺序查找,找到能满足要求的第一个空闲分区,从这个分区里分配适合的内存来用,并且把剩下的空闲分区仍然链在空闲分区链中。
首次适应算法有个问题就是每次都从链头开始查找,这可能会导致出现很多小的空闲分区,之后再查找都要经过这些小分区,增加了查找的开销。
邻近适应算法:设计这个算法的初衷是为了解决首次适应算法查找开销高的问题。邻近适应算法每次都会从上次查找结束的位置开始检索,找到大小能满足要求的第一个空闲分区,以此解决首次适应算法查找开销高的问题。
最佳适应算法:指的是把空闲分区链中的空闲分区按照由小到大的顺序进行排序。每次从链首开始查找到大小能满足要求的第一个空闲分区,这时找到的这个分区大小是最合适的。
最坏适应算法:最坏适应算法和最佳适应算法刚好相反,它会把空闲分区链的分区按照从大到小的顺序排序,每次查找时只要看第一个空闲分区是否满足,这样分配后剩余的空闲区就不会太小,更方便使用。

装入的三种方式(完成逻辑地址到物理地址的转换)

1、绝对装入: 在编译时,如果知道程序将放到内存中的哪个位置,编译程序会产生绝对地址的目标代码。装入程序按照装入模块中的地址,把程序和数据装入内存。

2、静态重定位: 指令中使用的地址是相对于起始地址而言的逻辑地址,在装入时会对地址进行"重定位",把逻辑地址转变为物理地址。作业一旦进入内存后,运行期间不能再移动,也不能再申请内存空间。

3、动态重定位: 装入模块进入内存后,并不会直接把逻辑地址转换为物理地址,而是把地址转换推迟到程序真正要运行时才进行。因此装入内存后所有的地址依然是逻辑地址。这种方式需要一个重定位寄存器的支持,它允许程序在内存中发生移动。

读者-写者问题

如果一个数据可以被多个进程共享,我们把只读该文件的进程称为"读进程",其他进程为"写进程"。
特点:写进程和写进程是互斥的,写进程和读进程是互斥的,但是读进程和读进程不互斥。
一共有三种模式
解决:要解决读-写问题,核心思想是设置一个计数器count用来记录当前正在访问共享文件的进程数。
①读优先:此时读优先级高于写。当有进程正在读数据时,读进程可以不受阻塞地直接读取内容,而写进程需要等所有读取请求完成后再开始写入。
②写优先:当有写进程要写入数据时,所有等待的读进程都退后让写进程先进行写入。当没有写进程要写入时,读进程可以非互斥地读取。
③读写公平:读写公平模式下,读进程和写进程公平地参与一个通用锁的竞争。当读进程正在读时,其余的读进程可以进入读取过程。但是当写进程试图写入时,写进程和读进程加入一个共同的等待队列里,等前面的读进程运行完成后,写进程就开始写入。排在写进程后面的读进程,需要等待写入完成后才能开始读取。也就是先来先服务原则。

操作系统中典型的锁

首先讲一下为什么需要锁:当多个线程访问共享资源的时候,可能会有资源竞争的情况,所以加锁就是为了保证共享资源在任意时间⾥只有⼀个线程访问,这样来避免多线程抢占资源导致的共享数据错乱问题。常用的锁有互斥锁、自旋锁、读写锁等。
1、互斥锁
互斥锁在访问共享资源之前进行上锁,在访问完成后进行解锁;对互斥锁进行上锁之后,其它试图再对互斥锁进行加锁的线程都会被阻塞。当解锁互斥锁之后其他被阻塞的线程会被唤醒然后尝试加锁。
2、自旋锁
自旋锁相较于互斥锁来说更加底层。如果在获取自旋锁时,自旋锁处于未锁定状态,那么将立即获得锁对自旋锁上锁;如果在获取自旋锁时,自旋锁已经处于锁定状态了,那么获取锁操作将会在原地"自旋",直到当前自旋锁被解锁。所以互斥锁在无法获取到锁时会让线程陷入阻塞等待状态;而自旋锁在无法获取到锁时,将会在原地"自旋"等待。
自旋锁的不足之处就在于线程在未获得锁的情况下,会一直处于自旋状态,所以会占着 CPU,这就使 CPU 效率降低。

互斥锁加锁失败后,线程会释放 CPU ,给其他线程;
自旋锁加锁失败后,线程会忙等待,直到它拿到锁;

3、读写锁
互斥锁或自旋锁要么是加锁状态、要么是不加锁状态,而且一次只能有一个线程可以对它加锁。
而读写锁有3 种状态:读锁状态、写锁状态和不加锁状态。一次可以有多个线程占有读模式的读写锁,但能只有一个线程可以占有写模式的读写锁。

所以,读写锁非常适合于对共享数据的读次数远大于写次数的情况。

4、悲观锁
悲观锁比较悲观,它认为多线程同时修改共享资源的概率比较高,很容易出现冲突,所以访问共享资源前,都先要上锁。
互斥锁、自旋锁、读写锁,都属于悲观锁。
5、乐观锁
乐观锁比较乐观,它认为出现冲突的概率很低,在修改完共享资源后,乐观锁会验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成。如果发现有其他线程已经修改过这个资源,就放弃本次操作。

乐观锁是先修改同步资源,再验证有没有发生冲突。
悲观锁是修改共享数据前,都要先加锁,防止竞争。

局部性原理

局部性原理是指CPU在访问数据时会倾向于重复访问一些数据,局部性原理主要分为时间局部性和空间局部性。
时间局部性
如果程序访问了某个数据,可能不久之后这个数据还会被再次访问。 因为程序里循环多。
空间局部性
如果程序访问了某个存储单元,可能在不久之后这个存储单元附近的存储单元也会被访问。因为很多数据在内存中都是连续存放的。

内存分布

从高地址到低地址依次是:内核、栈、堆、bss段、全局区、常量区、代码段

栈空间的大小

Linux由操作系统决定,一般是8MB
Windows环境由编译器决定,VC++6.0一般是1M

ASCII,Unicode和UTF-8

ASCII
首先ASCII码是一套字符编码,是20世纪60年代美国制定的,它对英语字符与二进制位之间的关系做了统一规定。
我们知道,计算机内部,所有信息都是二进制构成的。每一个二进制位bit有0和1两种状态,因此八个二进制位就可以组合出256种状态,也就是一个字节byte,这256个状态每个对应一个符号。
ASCII 码一共规定了128个字符的编码,比如大写A是65,空格是32,小写a是97。
Unicode
因为世界上有多种编码方式,比如除了ASCII还有GB2312,同一个二进制数字可能被解释成不同的符号,所以要想打开一个文件,就必须知道它的编码方式,否则就会出现乱码。所以如果有一种编码,能把世界上所有的符号都包括起来,每个符号都有一个独一无二的编码,那么乱码问题就会消失,那这就是Unicode字符集的由来,Unicode 就相当于一张表,表里维护了字符和编号之间的联系。
UTF-8
Unicode 只是一个符号集,它只规定了符号的二进制代码,却没有规定这个二进制代码应该如何存储。比如通常两个字节表示一个字符,而ASCII是一个字节表示一个字符,这样如果我们编译的文本是全英文的,那用Unicode编码就要比ASCII编码多一倍的存储空间,在存储和传输上就十分不划算。而UTF-8 就是在互联网上使用最广的一种 Unicode 的实现方式。UTF-8 最大的一个特点,就是它是一种变长的编码方式。UTF-8使用1~4字节为每个字符编码,英文字母被编码成一个字节,常用汉字被编码成三个字节。
总结
在计算机内存里统一使用Unicode编码,当需要保存到硬盘或需要传输时,就转换为UTF-8编码。
用记事本编辑的时候,从文件读取的UTF-8字符被转换为Unicode字符到内存里,编辑结束保存时再把Unicode转换为UTF-8保存到文件中。

原子操作的是如何实现的

处理器使用基于总线锁缓存锁的方式来实现多处理器之间的原子操作。
总线锁
我们知道,如果多个处理器同时对共享变量进行读写操作,那操作完之后共享变量的值可能就会和期望的不一致,这样的操作就是非原子性的。总线锁就是把cpu和内存之间的通信锁住,这样在锁定期间,其他cpu处理器就不能操作其他内存中数据,所以总线锁开销比较大。
缓存锁
缓存锁是采用"缓存锁定"把原子操作放在cpu缓存里进行(L1、L2、L3高速缓存)。当发生共享内存的锁定时,处理器不会在总线上发布锁信号,而是修改内存地址,并通过缓存一致性机制保证原子性。因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的数据,当其他处理器回写已被锁定的缓存数据时,会使缓存行无效。
缓存一致性机制?
缓存一致性机制是当某块CPU对缓存中的数据进行操作了之后,就通知其他CPU放弃储存在它们内部的缓存,或者从主内存中重新读取。

死锁

所谓死锁,是指多个进程在运行过程中因为争夺资源而造成的一种僵持状态,如果没有外部作用,它们会一直僵持着。比如迪杰斯特拉提出的哲学家进餐问题,就是多进程竞争临界资源而导致的典型死锁例子。

产生死锁的必要条件
1、互斥:这个资源在一段时间内只能被一个进程所占用。
2、不可剥夺:进程在主动释放资源前,资源不能被剥夺。
3、请求和保持:当进程因请求资源而被阻塞时,也不放弃自己已经获得的资源。
4、循环等待:存在循环链。比如P1占有P2的资源,P2占有P3的资源,P3占有P1的资源,这样就形成了循环等待。

预防死锁:打破死锁产生的任一必要条件。
解决死锁:常用的解决方法是挂起一些进程,然后回收资源,再把这些资源分配给已处于阻塞状态的进程,让它继续运行。

哲学家进餐问题详解

哲学家进餐问题是由迪杰斯特拉提出的,这是一个多进程间竞争临界资源而导致死锁的典型例子。
有五个哲学家围在一张桌子上吃饭,有五个碗但只有五根筷子。哲学家要不进行思考,要不就吃饭,但要吃饭必须拿到自己左右的两根筷子才行。很明显在这里筷子是临界资源,同一根筷子同一时刻只能有一个哲学家能拿到,而哲学家想要吃饭必须同时获取两个筷子临界资源才可以。
死锁产生:如果说5个人想要吃饭,他们同时拿起了自己左边的筷子,再去拿右边筷子时因为右边筷子已经被其他人拿着了,所以这五个人都要因为没有右边的筷子一直地等下去,这就可能会引起死锁。
解决方法
①比如最多允许四个哲学家同时进餐。这样可以保证至少有一个哲学家是可以拿到左右两根筷子。他吃完饭之后就会释放这两根筷子给其他哲学家。
②要求奇数哲学家先拿左边的筷子,再拿右边筷子。而偶数哲学家刚好相反,先拿右边,再拿左边。用这种方法可以保证最后总会有一个哲学家能获得两支筷子。
③只有一个哲学家左右两根筷子都可用时才允许他拿筷子。也就是这多个临界资源,要么全部分配,要么一个都不分配,这样就不会出现死锁的情况。

服务器高并发解决方案

一致性哈希

大多数网站是用多台服务器构成集群来对外提供服务,那么就存在负载均衡的问题。
1、要解决负载均衡可以使用加权轮询,引入一个权重值,让硬件配置更好的节点承担更多的请求。但是加权轮询算法是不能应对分布式系统的,因为分布式系统里面数据是要分片的,每个节点存储的数据都不同,要获取这个数据你用的key也不一样,不可能说是任意访问一个节点都能得到想要的结果。
2、用哈希算法,因为对同一个关键字进行哈希计算(取模),每次计算的结果都是相同的值,这样就可以把某个 key 确定到同一个节点,也就能满足分布式系统的负载均衡。但哈希也有一个很重要的问题,就是如果系统做扩容或者缩容导致节点数量发生了变化,大部分映射关系也会跟着改变,那就必须要去迁移这些数据。
3、那最后就来了我们的一致性哈希算法。一致哈希也用了哈希取模,但和哈希算法不同的是,哈希算法是对节点的数量进行取模运算,而一致哈希算法是对2[^32]进行取模,这样它的结果是一个固定的值。我们可以把一致哈希算法是对 2[^32] 进行取模运算,它的的结果值构成一个哈希环,这个环有 2^32 个点。
也就是一致性哈希会把「存储节点」和「数据」都映射到一个首尾相连的哈希环上,如果增加或者移除一个节点,仅仅只会影响这个节点在哈希环上顺时针相邻的后继节点,其它数据不会受到影响。
但是一致性哈希算法不能均匀的分布节点,可能会出现大量请求都集中在一个节点的情况,在这种情况下进行容灾和扩容时,容易出现雪崩的连锁反应。
4、为了解决一致性哈希算法不能够均匀的分布节点的问题,可以引入虚拟节点,对一个真实节点做多个副本。不再把真实节点映射到哈希环上,而是把虚拟节点映射到哈希环上的实际节点。节点数量多了后,节点在哈希环上的分布就相对均匀了。这时候,如果有访问请求寻址到「A-01」这个虚拟节点,接着再通过「A-01」虚拟节点访问到真实节点 A。所以这里有「两层」映射关系。
引入虚拟节点后,不仅会提高节点的均衡度,还会提高系统的稳定性。所以,带虚拟节点的一致性哈希方法不仅适合硬件配置不同的节点的场景,而且适合节点规模会发生变化的场景。

CPU缓存

CPU 内部嵌入了 CPU Cache(高速缓存),它的存储容量很小,但是离 CPU 核心很近,所以缓存的读写速度很快,那么如果 CPU 运算时,直接从 CPU Cache 读取数据,那运算速度就会比直接去内存读快很多。
CPU Cache 通常分 L1 Cache、L2 Cache 和 L3 Cache。L1 Cache 和 L2 Cache 都是每个 CPU 核心独有的,而 L3 Cache 是多个 CPU 核心共享的,所以 L3 Cache容量比较大。
CPU 从 L1 Cache 读取数据的速度,大概比从内存读的速度快 100 多倍。

当 CPU 访问数据的时候,会先访问 CPU Cache,如果缓存命中的话,则直接返回数据,就不用每次都从内存读取数据了。
如果 CPU Cache 没有缓存需要的数据,就会从内存读取数据,但是并不是只读一个数据,而是一次性读取多个小块儿的数据存放到 CPU Cache 中,再被 CPU 读取。要想写出让 CPU 跑得更快的代码,就需要写出缓存命中率高的代码,比如在遍历的时候,应该按照内存布局的顺序操作,也就是二维数组先nums[0][0],再nums[0][1]

CPU缓存一致性

CPU 在读写数据的时候,都是在 CPU Cache 读写数据的,原因是 Cache 离 CPU 很近,读写性能相比内存高出很多。对于CPU Cache 里没有 CPU 所需要的数据时,CPU 就会从内存中读,然后把数据缓存到 Cache 里面,最后 CPU 再从 Cache 读取数据。
而现在 CPU 一般都是多核的,每个核都有自己独立的 一级二级Cache。所以我们要确保多核缓存的一致性,否则可能会出现错误的结果。

要想实现缓存一致性,主要是要满足 2 点:
第一点是写传播,也就是当某个 CPU 核心发生写入操作时,需要把这个事件广播通知给其他核心;
第二点是事物的串行化,也就是要保证这个数据有多个变化时其他核看到的变化顺序一致,这样我们的程序在不同的核心上运行的结果也是一致的;
我们可以用 MESI 协议来保证 CPU 缓存的一致性,MESI是已修改、独占、共享、已失效这四个状态的英文缩写的组合,这四种状态来标记 Cache Line 四个不同的状态。

为什么负数要用补码表示?

首先我们知道数字不管是几进制在计算机内部都是用二进制来表示的,比如一个32位的int,它只有31位是用来表示具体数字的,最高位是作为「符号标志位」,正数的符号位是 0,负数的符号位是 1。

那比如1 的二进制就是
0000 0000 0000 0000 0000 0000 0000 0001
而负数在计算机里需要以[补码]表示,也就是就是把正数的二进制全部取反再加 1,比如-1
1111 1111 1111 1111 1111 1111 1111 1110 //取反
1111 1111 1111 1111 1111 1111 1111 1111	//加1

1、如果单纯用最高位的符号标志位1来表示负数,则-2 二进制为1000 0000 0000 0000 0000 0000 0000 0010

1000 0000 0000 0000 0000 0000 0000 0010		// -2
				+
0000 0000 0000 0000 0000 0000 0000 0001		// 1
				=
1000 0000 0000 0000 0000 0000 0000 0011		//这就是 -3了
所以如果单纯用最高位的符号标志位1来表示负数,那么在进行计算时需要先判断数字是否为负数,如果是负数需要把加法变成减法

2、如果用补码方式,则-2 二进制为1111 1111 1111 1111 1111 1111 1111 1110

1111 1111 1111 1111 1111 1111 1111 1110		// -2
				+
0000 0000 0000 0000 0000 0000 0000 0001		// 1
				=
1111 1111 1111 1111 1111 1111 1111 1111		// 即 -1的二进制补码表示

所以,如果负数不是用补码的方式表示,那么在做加减法运算的时候,还需要多一步操作来判断操作数是否为负数。如果为负数,还要把加法反转成减法,或者把减法反转成加法,这就非常不好了,毕竟加减法运算在计算机里是很常使用的,所以为了性能考虑,应该要尽量简化这个运算过程。
而用补码的表示方式,加减法对正负数都可以一视同仁。

小数转二进制

8.625 转 二进制
8 -->  0000 1000
0.625 * 2 = 1.25	--> 1
0.25  * 2 = 0.5		--> 0
0.5   * 2 = 1.0		--> 1
0 * 2没用,结束
8.625 转二进制 1000.101(前到后)
0.1的二进制是无限循环的

所以,对于类似于0.1这种无限循环的数,计算机内部只能用「近似值」来表示它的二进制,所以
0.1 + 0.2 并不等于完整的 0.3

4GB 内存的机器上,申请 8G 内存

在 32 位操作系统上失败,因为进程理论上最大只能申请 3 GB 大小的虚拟内存。
在 64 位操作系统上成功,因为进程理论上最大能申请 128 TB 大小的虚拟内存,即使物理内存只有 4GB,申请 8G 内存也是没问题,前提是系统开了swap交换分区功能。

DMA和零拷贝

早期的 I/O 操作和内存与磁盘的数据传输工作都是由 CPU 完成的,CPU在执行时不能执行其他任务,这就会特别浪费 CPU 资源。
DMA
英文全称是Direct Memory Access,即直接内存访问。DMA本质上是一块主板上独立的芯片,允许外设设备和内存存储器之间直接进行IO数据传输,其过程不需要CPU的参与。
它可以帮CPU转发IO请求和拷贝数据,然后CPU就可以闲下来去做别的事情,这就提高了CPU的利用效率。
零拷贝
零是指拷贝的次数为零,零拷贝是指计算机执行IO操作时,CPU不需要将数据从一个存储区域复制到另一个存储区域,进而减少上下文切换以及CPU的拷贝时间。它是一种IO操作优化技术。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值