目录
进程调度算法
内存页面置换算法
LRU算法实现
LFU算法实现
磁盘调度算法
进程调度算法
当 CPU 空闲时,操作系统就选择内存中的某个「就绪状态」的进程,并给其分配 CPU。
什么时候会发生 CPU 调度呢?通常有以下情况:
- 当进程从运行状态转到等待状态;「非抢占式调度」
- 当进程从运行状态转到就绪状态;「抢占式调度」
- 当进程从等待状态转到就绪状态;「抢占式调度」
- 当进程从运行状态转到终止状态;「非抢占式调度」
非抢占式和抢占式:
- 非抢占式:当就绪队列中出现优先级高的进程,运行完当前进程,再选择优先级高的进程。
- 抢占式:当就绪队列中出现优先级高的进程,当前进程挂起,调度优先级高的进程运行。
1、先来先服务调度算法
最简单的一个调度算法,就是非抢占式的先来先服务算法了。
顾名思义,先来后到,每次从就绪队列选择最先进入队列的进程,然后一直运行,直到进程退出或被阻塞,才会继续从队列中选择第一个进程接着运行。
但是当一个长作业先运行了,那么后面的短作业等待的时间就会很长,不利于短作业。
2、最短作业优先调度算法
最短作业优先调度算法,优先选择运行时间最短的进程来运行,这有助于提高系统的吞吐量。
这显然对长作业不利,很容易造成一种极端现象。
比如,一个长作业在就绪队列等待运行,而这个就绪队列有非常多的短作业,那么就会使得长作业不断的往后推,周转时间变长,致使长作业长期不会被运行。
3、高响应比优先调度算法
高响应比优先 调度算法主要是权衡了短作业和长作业。
每次进行进程调度时,先计算「响应比优先级」,然后把「响应比优先级」最高的进程投入运行,「响应比优先级」的计算公式:
从上面的公式,可以发现:
- 如果两个进程的「等待时间」相同时,「要求的服务时间」越短,「响应比」就越高,这样短作业的进程容易被选中运行;
- 如果两个进程「要求的服务时间」相同时,「等待时间」越长,「响应比」就越高,这就兼顾到了长作业进程,因为进程的响应比可以随时间等待的增加而提高,当其等待时间足够长时,其响应比便可以升到很高,从而获得运行的机会;
4、时间片轮转调度算法
每个进程被分配一个时间段,称为时间片,即允许该进程在该时间段中运行。
- 如果时间片用完,进程还在运行,那么将会把此进程从 CPU 释放出来,并把 CPU 分配另外一个进程;
- 如果该进程在时间片结束前阻塞或结束,则 CPU 立即进行切换;
- 如果时间片设得太短会导致过多的进程上下文切换,降低了 CPU 效率;
- 如果设得太长又可能引起对短作业进程的响应时间变长。
通常时间片设为 20ms~50ms 通常是一个比较合理的折中值。
5、最高优先级调度算法
调度程序能从就绪队列中选择最高优先级的进程进行运行,这称为最高优先级调度算法。
该算法也有两种处理优先级高的方法,非抢占式和抢占式:
- 非抢占式:当就绪队列中出现优先级高的进程,运行完当前进程,再选择优先级高的进程。
- 抢占式:当就绪队列中出现优先级高的进程,当前进程挂起,调度优先级高的进程运行。
但是依然有缺点,可能会导致低优先级的进程永远不会运行。
6、多级反馈队列调度算法
多级反馈队列调度算法是「时间片轮转算法」和「最高优先级算法」的综合和发展。
- 「多级」表示有多个队列,每个队列优先级从高到低,同时优先级越高时间片越短。
- 「反馈」表示如果有新的进程加入优先级高的队列时,立刻停止当前正在运行的进程,转而去运行优先级高的队列;
- 设置了多个队列,赋予每个队列不同的优先级,每个队列优先级从高到低,同时优先级越高时间片越短;
- 新的进程会被放入到第一级队列的末尾,按先来先服务的原则排队等待被调度,如果在第一级队列规定的时间片没运行完成,则将其转入到第二级队列的末尾,以此类推,直至完成;
- 当较高优先级的队列为空,才调度较低优先级的队列中的进程运行。如果进程运行时,有新进程进入较高优先级的队列,则停止当前运行的进程并将其移入到原队列末尾,接着让较高优先级的进程运行;
可以发现,对于短作业可能可以在第一级队列很快被处理完。对于长作业,如果在第一级队列处理不完,可以移入下次队列等待被执行,虽然等待的时间变长了,但是运行时间也会更长了,所以该算法很好的兼顾了长短作业,同时有较好的响应时间。
Linux操作系统采用的是多级反馈队列调度的调度方式。该调度算法将就绪队列分成多个优先级队列,每个队列具有不同的优先级,并且可以采用不同的调度策略。
在Linux中,CFS是一种实现该调度策略的具体实现。CFS旨在提供公平性和响应性,确保所有进程都有公平的机会获得CPU时间片。每个进程都被赋予一个权重,CFS会根据进程的权重来分配CPU时间片,以确保进程获得的CPU时间与其权重成比例。
CFS是Linux的默认进程调度器,用于普通进程(非实时进程)。对于实时进程,Linux还提供了一个实时调度器,如先进先出(FIFO)和循环调度(RR),用于满足实时任务的需求。
以下是CFS的一些关键特点和工作原理:
公平性:CFS的核心目标是实现公平的CPU分配。每个进程都被赋予一个权重(weight),CFS根据进程的权重来分配CPU时间片,以确保进程获得的CPU时间与其权重成比例。这意味着高权重进程会获得更多的CPU时间,低权重进程会获得更少的CPU时间。
虚拟运行时间:CFS使用虚拟运行时间来衡量每个进程已经使用的CPU时间。虚拟运行时间越小的进程被认为更"饥饿",因此它们会在调度时获得更高的优先级,以获得更多的CPU时间。
红黑树:CFS使用红黑树来管理就绪队列。每个进程都在红黑树上维护一个节点,节点按照虚拟运行时间排序。这样,CFS可以以O(log n)的时间复杂度找到具有最小虚拟运行时间的进程。
时间片分配:CFS不像一些传统的调度器那样使用固定的时间片(例如,10毫秒)。相反,它动态计算每个进程的时间片,以适应不同的权重和进程的需求。
动态调整权重:CFS支持动态调整进程的权重,以允许管理员或应用程序根据需要调整进程的调度优先级。
CFS的设计使其成为一个高度公平和响应性的调度器,适用于多用户和多任务环境。CFS确保不会发生某些进程长期霸占CPU资源的情况,从而提高了系统的整体性能和用户体验。这使得CFS成为Linux默认的普通进程调度器。
内存页面置换算法
在了解内存页面置换算法前,我们得先谈一下缺页异常(缺页中断)。
当 CPU 访问的页面不在物理内存时,便会产生一个缺页中断,请求操作系统将所缺页调入到物理内存。
我们来看一下缺页中断的处理流程:
- 在 CPU 里访问一条 Load M 指令,然后 CPU 会去找 M 所对应的页表项。
- 如果该页表项的状态位是「有效的」,那 CPU 就可以直接去访问物理内存了,如果状态位是「无效的」,则 CPU 则会发送缺页中断请求。
- 操作系统收到了缺页中断,则会执行缺页中断处理函数,先会查找该页面在磁盘中的页面的位置。
- 找到磁盘中对应的页面后,需要把该页面换入到物理内存中,但是在换入前,需要在物理内存中找空闲页,如果找到空闲页,就把页面换入到物理内存中。
- 页面从磁盘换入到物理内存完成后,则把页表项中的状态位修改为「有效的」。
- 最后,CPU 重新执行导致缺页异常的指令。
上面所说的过程,第 4 步是能在物理内存找到空闲页的情况,那如果找不到呢?
页面置换算法的功能是,当出现缺页异常,需调入新页面而内存已满时,选择被置换的物理页面,也就是说选择一个物理页面换出到磁盘,然后把需要访问的页面换入到物理页。
那其算法目标则是,尽可能减少页面的换入换出的次数,常见的页面置换算法有如下几种:
1、最佳页面置换算法
最佳页面置换算法基本思路是,置换在「未来」最长时间不访问的页面。
所以,该算法实现需要计算内存中每个逻辑页面的「下一次」访问时间,然后比较,选择未来最长时间不访问的页面。
很理想但是实际系统中无法实现,我们是无法预知每个页面在「下一次」访问前的等待时间。
2、先进先出置换算法
既然我们无法预知页面在下一次访问前所需的等待时间,那我们可以选择在内存驻留时间很长的页面进行中置换,这个就是「先进先出置换」算法的思想。
3、最近最久未使用的置换算法LRU
发生缺页时,选择最长时间没有被访问的页面进行置换,也就是说,该算法假设已经很久没有使用的页面很有可能在未来较长的一段时间内仍然不会被使用。
这种算法近似最优置换算法,最优置换算法是通过「未来」的使用情况来推测要淘汰的页面,而 LRU 则是通过「历史」的使用情况来推测要淘汰的页面。
4、时钟页面置换算法
时钟页面置换算法跟 最近最久未使用的置换算法 近似,又是对 先进先出置换算法 的一种改进。
该算法的思路是,把所有的页面都保存在一个类似钟面的「环形链表」中,一个表针指向最老的页面。
当发生缺页中断时,算法首先检查表针指向的页面:
- 如果它的访问位位是 0 就淘汰该页面,并把新的页面插入这个位置,然后把表针前移一个位置;
- 如果访问位是 1 就清除访问位,并把表针前移一个位置,重复这个过程直到找到了一个访问位为 0 的页面为止;
5、最不常用算法LFU
是当发生缺页中断时,选择「访问次数」最少的那个页面,并将其淘汰。
它的实现方式是,对每个页面设置一个「访问计数器」,每当一个页面被访问时,该页面的访问计数器就累加 1。在发生缺页中断时,淘汰计数器值最小的那个页面。
LRU算法实现
选择最长时间没有被访问的页面进行删除
"最近常用"的元素是位于双向链表的尾部,
"最久未使用"的元素是位于链表的头部。
get方法:当调用get方法获取一个键的值时,如果键存在于缓存中,会将该键移到链表尾部,表示它是最近使用的元素。这是通过makeRecently方法实现的,其中删除了原来的位置并将键添加到链表尾部。
put方法:当调用put方法插入一个新的键值对时,新元素被插入到链表尾部,表示它是最近使用的元素。如果缓存已满,会移除链表头部元素,即最久未使用的键,以腾出空间。
以下是为什么设置这几个成员变量以及代码的核心思路:
int cap(缓存容量):这个成员变量表示LRU缓存的容量,即缓存可以存储的键值对的最大数量。它是必需的,因为它决定了缓存的大小,当缓存达到容量上限时,需要淘汰最久未使用的元素,以便为新元素腾出空间。
std::unordered_map<int, int> cache(存储键值对的哈希表):这个哈希表用于实际存储缓存中的键值对。其中,键是缓存中的键,值是键对应的值。这个数据结构用于快速查找和更新缓存中的元素,以便在get和put操作中高效地访问和修改缓存。
std::list<int> lruList(存储最近使用的键的双向链表):这个双向链表用于跟踪键的使用顺序。最近使用的键会被添加到链表的尾部,而最久未使用的键位于链表的头部。这个链表是LRU缓存的核心,它帮助我们维护键的访问顺序,以便在淘汰元素时能够轻松地选择最久未使用的键。
void makeRecently(int key)(辅助函数):这个函数用于将指定的键标记为最近使用,即将其从链表中删除,然后添加到链表的尾部。这是确保最近使用的键总是在链表尾部的关键操作。
LRU缓存的核心思路是,通过双向链表来维护键的使用顺序,最近使用的键位于链表尾部,最久未使用的键位于链表头部。当执行get或put操作时,会调用makeRecently函数来确保访问的键被
#include <iostream>
#include <unordered_map>
#include <list>
class LRUCache {
public:
LRUCache(int capacity) : cap(capacity) {}
// 获取键对应的值
int get(int key) {
// 如果键不存在于缓存中,返回-1
if (cache.find(key) == cache.end()) {
return -1;
}
// 将 key 变为最近使用,即更新其在链表中的位置
makeRecently(key);
return cache[key]; // 返回键对应的值
}
// 向缓存中插入键值对
void put(int key, int val) {
// 如果键已存在于缓存中
if (cache.find(key) != cache.end()) {
// 修改键的值
cache[key] = val;
// 将 key 变为最近使用,即更新其在链表中的位置
makeRecently(key);
return;
}
// 如果缓存已满
if (cache.size() >= cap) {
// 移除链表头部元素,即最久未使用的键
int oldestKey = lruList.front();
lruList.pop_front(); // 从链表中移除
cache.erase(oldestKey); // 从缓存中移除
}
// 将新的键值对插入到链表尾部,即最近使用的位置
lruList.push_back(key); // 添加到链表尾部
cache[key] = val; // 添加到缓存
}
private:
int cap; // 缓存容量
std::unordered_map<int, int> cache; // 存储键值对的哈希表
std::list<int> lruList; // 存储最近使用的键的双向链表
// 辅助函数,将 key 变为最近使用
void makeRecently(int key) {
// 从链表中删除 key
lruList.remove(key);
// 添加到链表尾部,表示最近使用
lruList.push_back(key);
}
};
int main() {
// 创建容量为2的LRU缓存
LRUCache lruCache(2);
// 插入键值对 (1, 1) 和 (2, 2)
lruCache.put(1, 1);
lruCache.put(2, 2);
// 获取键 1 对应的值,输出 1
std::cout << lruCache.get(1) << std::endl;
// 插入键值对 (3, 3),此时缓存已满,会移除键 2
lruCache.put(3, 3);
// 获取键 2 对应的值,输出 -1,因为键 2 已被移除
std::cout << lruCache.get(2) << std::endl;
// 插入键值对 (4, 4),此时缓存已满,会移除键 1
lruCache.put(4, 4);
// 获取键 1 对应的值,输出 -1,因为键 1 已被移除
std::cout << lruCache.get(1) << std::endl;
// 获取键 3 对应的值,输出 3
std::cout << lruCache.get(3) << std::endl;
// 获取键 4 对应的值,输出 4
std::cout << lruCache.get(4) << std::endl;
return 0;
}
LFU算法实现
选择「访问次数」最少的那个页面并删除
LFUCache类:这是LFU缓存的主要类。它包含了以下成员变量和方法:
cap:表示缓存的容量,即最多可以存储多少个键值对。
minFreq:表示缓存中最低的使用频率。初始值为0。
cache:使用std::unordered_map来存储缓存的键值对,其中键是键值对的键,值是一个std::pair,包含值和频率。
freqList:使用std::unordered_map来存储不同频率的键的集合,其中频率是键,值是一个std::list,表示具有相同频率的键的链表。
get方法:用于获取指定键的值。如果键不存在于缓存中,返回-1。如果存在,则更新键的频率信息(通过updateFreq方法),然后返回键对应的值。
put方法:用于插入新的键值对或更新现有键的值。首先检查缓存是否已满,如果满了则需要淘汰一个元素。然后,检查键是否已存在于缓存中。如果键已存在,则更新值并更新频率信息。如果键不存在,则插入新的键值对,频率初始化为1。如果缓存已满,会淘汰最低频率的键,即在freqList中最靠后的频率。
updateFreq方法:用于更新指定键的频率信息。它会获取键的当前频率,增加频率,然后将键从旧的频率列表中移除,并添加到新的频率列表的头部。如果更新后的频率列表为空且更新的频率等于最低频率,会更新最低频率。
main函数:在main函数中,我们创建了一个LFU缓存对象,并演示了如何使用该缓存对象来插入、获取和移除键值对,以及处理缓存容量不足的情况。通过这些操作,我们可以观察LFU缓存的行为。
以下是我在设计LFU缓存时的思路和这些成员变量的作用:
cap(缓存容量):cap成员变量表示缓存的最大容量,即缓存可以存储的键值对的数量。这个成员变量很重要,因为它决定了缓存的大小,当缓存容量达到上限时,需要淘汰元素来为新元素腾出空间。
minFreq(最低频率):minFreq成员变量用于跟踪缓存中最低的使用频率。初始时,它被设置为0,表示缓存中还没有任何元素被访问过。随着操作的进行,minFreq可能会不断更新,因为频率较低的元素被淘汰后,可能会影响到最低频率。
cache(缓存数据结构):cache是一个哈希表,用于存储缓存中的键值对。键是缓存中的键,值是一个std::pair,其中包含值和频率信息。这个哈希表用于快速查找缓存中的键值对,以及更新键的值和频率。
freqList(频率信息数据结构):freqList也是一个哈希表,用于存储不同频率的键的集合。每个频率对应一个链表,链表中包含了具有相同频率的键。这个数据结构用于管理键的频率信息,以及在淘汰元素时找到最低频率的键。
#include <iostream>
#include <unordered_map>
#include <list>
#include <map>
class LFUCache {
public:
LFUCache(int capacity) : cap(capacity), minFreq(0) {}
int get(int key) {
if (cache.find(key) == cache.end()) {
return -1;
}
// 更新频率信息
updateFreq(key);
return cache[key].first;
}
void put(int key, int value) {
if (cap <= 0) {
return;
}
// 如果键已存在,更新值并更新频率信息
if (cache.find(key) != cache.end()) {
cache[key].first = value;
updateFreq(key);
return;
}
// 如果缓存已满,淘汰最低频率且最久未使用的键
if (cache.size() >= cap) {
int removedKey = freqList[minFreq].back();
freqList[minFreq].pop_back();
cache.erase(removedKey);
}
// 插入新键值对,频率初始化为1
cache[key] = {value, 1};
freqList[1].push_front(key);
minFreq = 1;
}
private:
int cap; // 缓存容量
int minFreq; // 最低频率
std::unordered_map<int, std::pair<int, int>> cache; // 存储缓存数据,{key, {value, frequency}}
std::unordered_map<int, std::list<int>> freqList; // 存储频率信息,{frequency, [keys]}
// 辅助函数,更新键的频率信息
void updateFreq(int key) {
int prevFreq = cache[key].second; // 获取键的当前频率
cache[key].second++; // 更新频率
// 更新频率列表
freqList[prevFreq].remove(key); // 从旧的频率列表中移除
freqList[prevFreq + 1].push_front(key); // 添加到新的频率列表的头部
// 如果更新后的频率列表为空且更新的频率等于最低频率,更新最低频率
if (freqList[prevFreq].empty() && prevFreq == minFreq) {
minFreq++;
}
}
};
磁盘调度算法
右边的图就是一个盘片的结构,盘片中的每一层分为多个磁道,每个磁道分多个扇区,每个扇区是 512 字节。那么,多个具有相同编号的磁道形成一个圆柱,称之为磁盘的柱面。
磁盘调度算法的目的很简单,就是为了提高磁盘的访问性能,一般是通过优化磁盘的访问请求顺序来做到的。
寻道的时间是磁盘访问最耗时的部分,如果请求顺序优化的得当,必然可以节省一些不必要的寻道时间,从而提高磁盘的访问性能。
1、先来先服务
先来先服务,顾名思义,先到来的请求,先被服务。
在寻道过程中,可能已经遇到⼀些以后可能需要访问的 磁道,但是会跳过,⽽造成 访问磁道 耗费时间较多。
2、最短寻道时间优先
最短寻道时间优先算法的工作方式是,优先选择从当前磁头位置所需寻道时间最短的请求
每次选择距离当前磁头最近的待处理请求
但这个算法可能存在某些请求的饥饿, 可能造成部分请求 “饥饿”(当某个请求的磁盘距离磁头较远,⽽⼀直有⽐其更近的请求时,这个请求⼀直⽆ 法执⾏) 这里产生饥饿的原因是磁头在一小块区域来回移动。
3、扫描算法
最短寻道时间优先算法会产生饥饿的原因在于:磁头有可能再一个小区域内来回得移动。
为了防止这个问题,可以规定:磁头在一个方向上移动,访问所有未完成的请求,直到磁头到达该方向上的最后的磁道,才调换方向,这就是扫描算法。
这种算法也叫做电梯算法,比如电梯保持按一个方向移动,直到在那个方向上没有请求为止,然后改变方向。
磁头先响应左边的请求,直到到达最左端( 0 磁道)后,才开始反向移动,响应右边的请求。
扫描调度算法性能较好,不会产生饥饿现象,但是存在这样的问题,中间部分的磁道会比较占便宜,中间部分相比其他部分响应的频率会比较多,也就是说每个磁道的响应频率存在差异。
4、循环扫描算法
扫描算法使得每个磁道响应的频率存在差异,那么要优化这个问题的话,可以总是按相同的方向进行扫描,使得每个磁道的响应频率基本一致。
循环扫描规定:只有磁头朝某个特定方向移动时,才处理磁道访问请求,而返回时直接快速移动至最靠边缘的磁道,也就是复位磁头,这个过程是很快的,并且返回中途不处理任何请求,该算法的特点,就是磁道只响应一个方向上的请求。
磁头先响应了右边的请求,直到碰到了最右端的磁道 199,就立即回到磁盘的开始处(磁道 0),但这个返回的途中是不响应任何请求的,直到到达最开始的磁道后,才继续顺序响应右边的请求。
循环扫描算法相比于扫描算法,对于各个位置磁道响应频率相对比较平均。
5、LOOK 与 C-LOOK算法
我们前面说到的扫描算法和循环扫描算法,都是磁头移动到磁盘「最始端或最末端」才开始调换方向。
那这其实是可以优化的,优化的思路就是磁头在移动到「最远的请求」位置,然后立即反向移动。
那针对 SCAN 算法的优化则叫 LOOK 算法,它的工作方式,磁头在每个方向上仅仅移动到最远的请求位置,然后立即反向移动,而不需要移动到磁盘的最始端或最末端,反向移动的途中会响应请求。
而针 C-SCAN 算法的优化则叫 C-LOOK,它的工作方式,磁头在每个方向上仅仅移动到最远的请求位置,然后立即反向移动,而不需要移动到磁盘的最始端或最末端,反向移动的途中不会响应请求。