关于操作系统线程调度中,链表实现的讨论
这里我们默认以下约定:
- 链表是双向链表,带头节点
- 考虑在进程调度时使用
实现
我们先列出两种算法的代码实现,再开始讨论。
第一种实现:
struct PCB {
// info
};
struct Node {
PCB *data;
Node *nex;
Node *pre;
};
const int max_pcb = 5;
PCB pcb_pool[max_pcb];
const int max_node = 5;
Node node_pool[max_node];
struct List {
Node *head;
// info
Node *new_node() {
for (Node *ret = node_pool; ret != node_pool + max_node; ++ret)
if (ret->data == 0) return ret;
return nullptr;
}
bool push_front(PCB *x) {
Node *p = new_node();
if (p == nullptr) return false;
p->data = x;
p->pre = head;
p->nex = head->nex;
head->nex = p;
head->nex->pre = p;
return true;
}
};
第二种实现:
struct PCB {
// info
Node tag;
};
struct Node {
Node *nex;
Node *pre;
};
const int max_pcb = 5;
PCB pcb_pool[max_pcb];
PCB *tag_to_PCB(Node *p) { return (PCB *)((int)p - (int)&(((PCB *)0)->tag)); }
struct List {
Node head;
// info
void push_front(PCB *x) {
x->tag.pre = &head;
x->tag.nex = head.nex;
head.nex = &x->tag;
head.nex->pre = &x->tag;
}
PCB *front() { return tag_to_PCB(head.nex); }
};
分析
首先,在操作系统的设计中,我们无法使用malloc()
函数来辅助我们管理内存分配。所以我们需要在堆空间中,提前分配好足够的空间,然后使用自己的内存分配算法管理这些空间。
自然地,有朴素算法:
- 提前申请数组空间
- 分配时,遍历数组,找未分配的空间,打标记
- 释放时,取消标记
时间复杂度 O(n)
空间复杂度 O(n)
可用循环队列优化标记数组,从而时间复杂度为 O(1)
然后,我们开始分析两种实现。
实现 1 是最朴素的带头节点的双向链表实现,节点分指针域和数据域两部分。
实现 2 是更优版实现(我们老师的写法),节点只包含指针域,节点本身的地址在数据域中(绕晕了的去看一下实现 2 的 PCB 声明)。
显然,实现 2 相比实现 1 有以下优点:
- 避免了节点指针数组的开销
- 不需要额外的节点指针数组,减少不必要的空间开销。
- 不需要节点的内存管理,减少任务量
- 不需要寻址算法
new_node
- 不需要考虑提前分配多少空间
max_node
给结点指针- 在多级反馈队列调度算法中,效果尤为显著
- 不需要寻址算法
- 操作链表时,不需要考虑节点的内存管理,只剩几个算术运算,加速
- 链表中的所有节点都在数据域空间中,cache 命中概率大大提升
- 考虑实现 1 的
p->nex->data
,访问了两块内存,指针数组的内存和 PCB 数组的内存。 - 考虑实现 2 的
tag_to_PCB(p->nex)
,访问的内存一直在 PCB 数组中。
- 考虑实现 1 的
tag_to_PCB()
拿算术运算多的时间,来换取 cache 命中率,从而进一步提升效率。- 函数通过结构体的存储特性来实现,通过算术运算从节点中获得地址,而不是实现 1 的访存。
- 自然地,可以写成宏函数/内联函数,消除函数调用开销
总结
实现 2 是时、空、硬件均最优的写法,全方面吊打实现 1