目录
进程调度算法
内存页面置换算法
LRU算法实现
LFU算法实现
磁盘调度算法
当 CPU 空闲时,操作系统就选择内存中的某个「就绪状态」的进程,并给其分配 CPU。
什么时候会发生 CPU 调度呢?通常有以下情况:
非抢占式和抢占式:
1、先来先服务调度算法
最简单的一个调度算法,就是非抢占式的先来先服务算法了。
顾名思义,先来后到,每次从就绪队列选择最先进入队列的进程,然后一直运行,直到进程退出或被阻塞,才会继续从队列中选择第一个进程接着运行。
但是当一个长作业先运行了,那么后面的短作业等待的时间就会很长,不利于短作业。
2、最短作业优先调度算法
最短作业优先调度算法,优先选择运行时间最短的进程来运行,这有助于提高系统的吞吐量。
这显然对长作业不利,很容易造成一种极端现象。
比如,一个长作业在就绪队列等待运行,而这个就绪队列有非常多的短作业,那么就会使得长作业不断的往后推,周转时间变长,致使长作业长期不会被运行。
3、高响应比优先调度算法
高响应比优先 调度算法主要是权衡了短作业和长作业。
每次进行进程调度时,先计算「响应比优先级」,然后把「响应比优先级」最高的进程投入运行,「响应比优先级」的计算公式:
从上面的公式,可以发现:
4、时间片轮转调度算法
每个进程被分配一个时间段,称为时间片,即允许该进程在该时间段中运行。
通常时间片设为 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 访问的页面不在物理内存时,便会产生一个缺页中断,请求操作系统将所缺页调入到物理内存。
我们来看一下缺页中断的处理流程:
上面所说的过程,第 4 步是能在物理内存找到空闲页的情况,那如果找不到呢?
页面置换算法的功能是,当出现缺页异常,需调入新页面而内存已满时,选择被置换的物理页面,也就是说选择一个物理页面换出到磁盘,然后把需要访问的页面换入到物理页。
那其算法目标则是,尽可能减少页面的换入换出的次数,常见的页面置换算法有如下几种:
1、最佳页面置换算法
最佳页面置换算法基本思路是,置换在「未来」最长时间不访问的页面。
所以,该算法实现需要计算内存中每个逻辑页面的「下一次」访问时间,然后比较,选择未来最长时间不访问的页面。
很理想但是实际系统中无法实现,我们是无法预知每个页面在「下一次」访问前的等待时间。
2、先进先出置换算法
既然我们无法预知页面在下一次访问前所需的等待时间,那我们可以选择在内存驻留时间很长的页面进行中置换,这个就是「先进先出置换」算法的思想。
3、最近最久未使用的置换算法LRU
发生缺页时,选择最长时间没有被访问的页面进行置换,也就是说,该算法假设已经很久没有使用的页面很有可能在未来较长的一段时间内仍然不会被使用。
这种算法近似最优置换算法,最优置换算法是通过「未来」的使用情况来推测要淘汰的页面,而 LRU 则是通过「历史」的使用情况来推测要淘汰的页面。
4、时钟页面置换算法
时钟页面置换算法跟 最近最久未使用的置换算法 近似,又是对 先进先出置换算法 的一种改进。
该算法的思路是,把所有的页面都保存在一个类似钟面的「环形链表」中,一个表针指向最老的页面。
当发生缺页中断时,算法首先检查表针指向的页面:
5、最不常用算法LFU
是当发生缺页中断时,选择「访问次数」最少的那个页面,并将其淘汰。
它的实现方式是,对每个页面设置一个「访问计数器」,每当一个页面被访问时,该页面的访问计数器就累加 1。在发生缺页中断时,淘汰计数器值最小的那个页面。
选择最长时间没有被访问的页面进行删除
"最近常用"的元素是位于双向链表的尾部,
"最久未使用"的元素是位于链表的头部。
get方法:当调用get方法获取一个键的值时,如果键存在于缓存中,会将该键移到链表尾部,表示它是最近使用的元素。这是通过makeRecently方法实现的,其中删除了原来的位置并将键添加到链表尾部。
put方法:当调用put方法插入一个新的键值对时,新元素被插入到链表尾部,表示它是最近使用的元素。如果缓存已满,会移除链表头部元素,即最久未使用的键,以腾出空间。
以下是为什么设置这几个成员变量以及代码的核心思路:
int cap(缓存容量):这个成员变量表示LRU缓存的容量,即缓存可以存储的键值对的最大数量。它是必需的,因为它决定了缓存的大小,当缓存达到容量上限时,需要淘汰最久未使用的元素,以便为新元素腾出空间。
std::unordered_map
cache(存储键值对的哈希表):这个哈希表用于实际存储缓存中的键值对。其中,键是缓存中的键,值是键对应的值。这个数据结构用于快速查找和更新缓存中的元素,以便在get和put操作中高效地访问和修改缓存。 std::list
lruList(存储最近使用的键的双向链表):这个双向链表用于跟踪键的使用顺序。最近使用的键会被添加到链表的尾部,而最久未使用的键位于链表的头部。这个链表是LRU缓存的核心,它帮助我们维护键的访问顺序,以便在淘汰元素时能够轻松地选择最久未使用的键。 void makeRecently(int key)(辅助函数):这个函数用于将指定的键标记为最近使用,即将其从链表中删除,然后添加到链表的尾部。这是确保最近使用的键总是在链表尾部的关键操作。
LRU缓存的核心思路是,通过双向链表来维护键的使用顺序,最近使用的键位于链表尾部,最久未使用的键位于链表头部。当执行get或put操作时,会调用makeRecently函数来确保访问的键被
#include
#include
#include
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 cache; // 存储键值对的哈希表
std::list 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;
}
选择「访问次数」最少的那个页面并删除
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
#include
#include
#include
右边的图就是一个盘片的结构,盘片中的每一层分为多个磁道,每个磁道分多个扇区,每个扇区是 512 字节。那么,多个具有相同编号的磁道形成一个圆柱,称之为磁盘的柱面。
磁盘调度算法的目的很简单,就是为了提高磁盘的访问性能,一般是通过优化磁盘的访问请求顺序来做到的。
寻道的时间是磁盘访问最耗时的部分,如果请求顺序优化的得当,必然可以节省一些不必要的寻道时间,从而提高磁盘的访问性能。
1、先来先服务
先来先服务,顾名思义,先到来的请求,先被服务。
在寻道过程中,可能已经遇到⼀些以后可能需要访问的 磁道,但是会跳过,⽽造成 访问磁道 耗费时间较多。
2、最短寻道时间优先
最短寻道时间优先算法的工作方式是,优先选择从当前磁头位置所需寻道时间最短的请求
每次选择距离当前磁头最近的待处理请求
但这个算法可能存在某些请求的饥饿, 可能造成部分请求 “饥饿”(当某个请求的磁盘距离磁头较远,⽽⼀直有⽐其更近的请求时,这个请求⼀直⽆ 法执⾏) 这里产生饥饿的原因是磁头在一小块区域来回移动。
3、扫描算法
最短寻道时间优先算法会产生饥饿的原因在于:磁头有可能再一个小区域内来回得移动。
为了防止这个问题,可以规定:磁头在一个方向上移动,访问所有未完成的请求,直到磁头到达该方向上的最后的磁道,才调换方向,这就是扫描算法。
这种算法也叫做电梯算法,比如电梯保持按一个方向移动,直到在那个方向上没有请求为止,然后改变方向。
磁头先响应左边的请求,直到到达最左端( 0 磁道)后,才开始反向移动,响应右边的请求。
扫描调度算法性能较好,不会产生饥饿现象,但是存在这样的问题,中间部分的磁道会比较占便宜,中间部分相比其他部分响应的频率会比较多,也就是说每个磁道的响应频率存在差异。
4、循环扫描算法
扫描算法使得每个磁道响应的频率存在差异,那么要优化这个问题的话,可以总是按相同的方向进行扫描,使得每个磁道的响应频率基本一致。
循环扫描规定:只有磁头朝某个特定方向移动时,才处理磁道访问请求,而返回时直接快速移动至最靠边缘的磁道,也就是复位磁头,这个过程是很快的,并且返回中途不处理任何请求,该算法的特点,就是磁道只响应一个方向上的请求。
磁头先响应了右边的请求,直到碰到了最右端的磁道 199,就立即回到磁盘的开始处(磁道 0),但这个返回的途中是不响应任何请求的,直到到达最开始的磁道后,才继续顺序响应右边的请求。
循环扫描算法相比于扫描算法,对于各个位置磁道响应频率相对比较平均。
5、LOOK 与 C-LOOK算法
我们前面说到的扫描算法和循环扫描算法,都是磁头移动到磁盘「最始端或最末端」才开始调换方向。
那这其实是可以优化的,优化的思路就是磁头在移动到「最远的请求」位置,然后立即反向移动。
那针对 SCAN 算法的优化则叫 LOOK 算法,它的工作方式,磁头在每个方向上仅仅移动到最远的请求位置,然后立即反向移动,而不需要移动到磁盘的最始端或最末端,反向移动的途中会响应请求。
而针 C-SCAN 算法的优化则叫 C-LOOK,它的工作方式,磁头在每个方向上仅仅移动到最远的请求位置,然后立即反向移动,而不需要移动到磁盘的最始端或最末端,反向移动的途中不会响应请求。