发布且更更新于个人小破站:进去瞅瞅
这篇文章侧重于实践部分,对于四个页面置换算法的概念描述一笔带过,不太清楚的可以先从课本上读一读相关概念,之后结合代码来理解。全文的叙事逻辑是从「代码撰写」的先后顺序展开的,先实现基础的功能,之后搭建整个算法的框架,然后实现并测试算法的核心功能,最后对结果进行分析以及思考。
为了用来比较各个算法的优劣以及测试算法的准确度,需要首先创建出来一个指令序列,对于指令序列的要求如下:
具体的实现的时候也是有一定的套路而言的:
上面的所有区间都是左闭右开的
从上面的步骤中可以看出,步骤 2 执行的指令相对于上一条指令是「顺序执行」,步骤 3 执行的指令相对于步骤 2 的 m+1 是「前地址」的指令,步骤 4 执行的指令相对于步骤 3 的 m‘ 是「顺序执行」,步骤 5 相对于步骤 4 的指令 m’+1 是「后地址」指令。
这里需要注意的是,在循环进行的时候,步骤 5 会产生一个新的 m 并执行,这样下一轮循环的步骤 2 就会在新的 m 后顺序执行,也就是执行 m+1 处的指令。下图中的箭头表示两个指令的先后关系。如:2 相对于 1 是顺序执行,3 相对于 2 是前地址指令。
这样在每次循环中会执行 4 条指令,其中 2 条是顺序执行,1 条是前地址部分,1 条是后地址部分,完美符合要求。
根据上面的原理,编写代码:
def produceAddstream():
instruct = []
m = np.random.randint(0, 319)
# 每次循环生成 4 条,所以需要循环 80 次
for i in range(80):
instruct.append(m+1) # 顺序
n = np.random.randint(0, m+1)
instruct.append(n) # 前地址
instruct.append(n+1) # 顺序
m = np.random.randint(n+2, 319)
instruct.append(m) # 后地址
return instruct
同样,在写算法的过程中,这些随机变量虽然能够看到最终的效果,但是在调试的时候我们难以准确的知道自己算法的准确性,所以我还根据书本上的例子给出了一份测试数据(共计 20 条指令);
return [70, 0, 10, 20, 0, 30, 0, 40, 20, 30, 0, 30, 20, 10, 20, 0, 10, 70, 0, 10]
假定页面大小为 1k,「用户虚存」容量为 32k,「用户内存容量」为 4 页到 32 页。在用户虚存中,按每 k 存放 10 条指令排列虚存地址,即 320 条指令在虚存中的存放方式为:
第 0 条 ~ 第 9 条指令为第 0 页(对应虚存地址为 [0,9]);
第 10 条 ~ 第 19 条指令为第 1 页(对应虚存地址为 [10,19]);
……
第 310 条 ~ 第 319 条指令为第 31 页(对应虚存地址为 [310,319]);
按以上方式,用户指令可组成 32 页。
所以可以直接使用 「指令 // 10」的方法来得到指令所在的页数;
使用「命中的次数」除以「指令的总数」来表示命中率,其中命中的意思是指当访问某个指令的时候,正好该指令对应的页在「用户内存」中;
程序一开始,创建 320 条指令,然后对于用户内存容量在 4 - 32 中的每一种情况下,测试四种算法( 最佳置换算法「OPT」、先进先出算法「IFO」、最近最久未使用页面置换「LRU」、最少使用页面淘汰算法「LFU 」)的命中率,最终绘制成图来展示;
def main():
"""主函数"""
ins = produceAddstream()
result = np.zeros([4, 29])
x = np.arange(4, 33)
for i in x:
result[0, i-4] = OPT(i, ins)
result[1, i-4] = FIFO(i, ins)
result[2, i-4] = LRU(i, ins)
result[3, i-4] = LFU(i, ins)
# 画图
plt.figure(figsize=(8, 4))
plt.plot(x, result[0], label="OPT")
plt.plot(x, result[1], label="FIFO")
plt.plot(x, result[2], label="LRU")
plt.plot(x, result[3], label="LFU")
plt.legend()
plt.show()
return
四种置换算法有着相同的核心,而最重要的部分就是在遍历指令的过程中,某个指令所在的页不在用户内存中,而此时用户内存也已经满了;这就是多个算法的关键区别。
def alg(n, ins):
"""置换算法"""
hit = 0 # 命中的个数
user_mem = [] # 用户内存
# 遍历所有指令
for (ind, i) in enumerate(ins):
# 如果命中
if (i//10) in user_mem:
# 命中的操作
else:
if len(user_mem) == n: # 如果用户内存已满
# 内存满的操作,移出一个页面
user_mem.append(i//10)
return hit / len(ins) # 计算命中率
先从较为简单一点的算法入手;这个算法在处理内存满的时候,把最早进入内存的那个页面淘汰出去,也就是相当于一个队列,先进先出。在 Python 里面使用 pop(0) 和 append 来实现移出内存和进入内存的操作。
def FIFO(n, ins):
"""先进先出置换算法"""
user_mem = []
hit = 0
for i in ins:
if i // 10 in user_mem:
hit += 1
else:
if len(user_mem) == n:
user_mem.pop(0)
user_mem.append(i//10)
return hit / len(ins)
原理是从内存中选择今后不再访问的页面或者在最长一段时间后才需要访问的页面进行淘汰。
这个算法简单粗暴且命中率很高,但是在实际应用中不会采用,因为根本做不到,系统无法确切的知道之后要执行的所有指令所在的位置,也就没法满足条件,所以一般情况下,这个算法只是用作检验算法好坏的一个参考。
在模拟实现的时候,这个算法的难点在于怎样知道某个页面的下次访问时间,最容易想到的办法就是当内存满的时候,挨个「遍历」指令以及内存中的页面,统计出来每个页面下次被访问的时间,把其中最大(晚)的那个淘汰出去,放入新的页面;实现起来也没有问题。
不过这里我使用字典来存储下次访问的时间,可以减少一部分的时间复杂度,空间换时间;孰好孰坏说不准。在循环开始之前,先创建了一个字典,字典的大小跟页面的个数是一样的,也就是 32 个,字典中每个「键值对」的键是页面的「页号」,而值是一个「指令位置数组」,记录的是该页面的指令在指令执行序列中的位置。如果有点绕,先别急,继续往下看。
那么某个页面对应的「指令位置数组」的第一个元素,也就是该页面的指令中,最先执行的那个指令在指令序列中的位置。通过比较不同页面对应的「指令位置数组」的第一个元素的大小,就可以比较哪个页面中的指令会在接下来的指令中先被执行。
在遍历指令的时候,首先从当前指令所在的页面对应的「指令位置数组」中,删除当前指令,由于字典中的「指令位置数组」都是按照执行的顺序排列的,所以直接删除第一个就行;
看文字有点绕,可以结合下面的代码理解;
def OPT(n, ins):
"""最佳置换算法"""
hit = 0
user_mem = []
dic = dict.fromkeys(range(32), [])
# 使用字典来保存下一个指令的位置
for (ind, i) in enumerate(ins):
# 这里不能使用 append
dic[i//10] = dic[i//10] + [ind]
for (ind, i) in enumerate(ins):
# 更新字典
dic[i//10].pop(0)
if (i//10) in user_mem:
hit += 1
else:
if len(user_mem) == n:
temp = [321] * n
for (index, page) in enumerate(user_mem):
if len(dic[page]) > 0:
temp[index] = dic[page][0]
user_mem.pop(np.argmax(temp))
user_mem.append(i//10)
return hit / len(ins)
注意对指令数组添加元素的时候不要使用 append,因为在创建字典的时候是使用的空数组,那么对于字典中的 32 个「键值对」而言,所指向的键都是同一个数组,所以对一个数组进行 append 操作的时候,所有的「键值对」都会更新。那么,这时候就需要采用重新赋值的形式来更新「键值对」的值。
FIFO 的置换方法并不能够准确的反映出页面的使用情况,对于某些页面可能需要频繁的访问,有些页面可能就访问一次,而最久未使用置换算法根据页面调入到内存后的使用情况来做出决策的,因此 LRU 是选择最近最久未使用的页面进行置换。
具体的实现方法是创建一个数组,当访问内存中已经存在的页面时,将该页面放在最后面,这样最前面的那个页面就是最近最久未使用的页面,如果内存已满,就将最前面的页面淘汰掉。
def LRU(n, ins):
"""最近最久未使用置换算法"""
user_mem = []
hit = 0
for i in ins:
if i // 10 in user_mem:
hit += 1
temp = user_mem.pop(user_mem.index(i//10))
user_mem.append(temp)
else:
if len(user_mem) == n:
user_mem.pop(0)
user_mem.append(i//10)
return hit / len(ins)
前面所采用的是访问的早晚来判断一个页面再次被访问的可能,有一定的局限,LFU 使用最近的一段时间内某个页面被访问的次数作为判断依据,所以当内存满的时候,需要使用循环来判断页面的访问次数,这里采用过去 50 条指令作为参考依据。
def LFU(n, ins):
"""最少使用置换算法"""
user_mem = []
hit = 0
for (ind, i) in enumerate(ins):
if i//10 in user_mem:
hit += 1
else:
if len(user_mem) == n:
temp = [0] * n
# 使用前 50 条指令来测试,如果不足 50 就从开头
for item in ins[max(0, ind-20):ind]:
# 统计内存中的页在接下来20条里被访问的次数
for k in range(n):
if user_mem[k] == item // 10:
temp[k] += 1
break
# 访问次数最少的淘汰
user_mem.pop(np.argmin(temp))
user_mem.append(i//10)
return hit / len(ins)
上面几种算法的基本思想都很简单,只是实现起来有一点点的问题。
对于这四个算法分别计算在用户内存大小为 4 - 32 的时候的命中率,然后绘制成图表展示出来。
def main():
"""主函数"""
ins = produceAddstream()
result = np.zeros([4, 29])
x = np.arange(4, 33)
for i in x:
result[0, i-4] = OPT(i, ins)
result[1, i-4] = FIFO(i, ins)
result[2, i-4] = LRU(i, ins)
result[3, i-4] = LFU(i, ins)
# 画图
plt.figure(figsize=(8, 4))
plt.plot(x, result[0], label="OPT")
plt.plot(x, result[1], label="FIFO")
plt.plot(x, result[2], label="LRU")
plt.plot(x, result[3], label="LFU")
plt.legend()
plt.show()
return
运行结果如下:
可以看到在随机的情况下,除了「最佳置换算法」之外,其他三个算法的差别并不是很大,因为 LRU 和 LFU 基于过去访问情况的总结在随机的指令面前毫无用处,所以最终效果也跟 FIFO 差别不是很大,但是在实际应用领域,指令的分布往往是有规律可言的,所以 LRU 和 LFU 也是有很大的应用空间的。
无论是这次的页面置换算法还是前面的作业调度算法,很多个算法的内部只是有一丝丝的区别,只要能够掌握算法的主要框架以及每个算法的核心思想就可以很容易的写出这些算法,在具体的实现的时候,Python 这个工具也可以很好的将思想转化为代码,更快的实现。
[1] 西安电子科技大学出版社《计算机操作系统(第四版)》汤子瀛等编著