Home 《虚拟内存的架构和操作系统支持》笔记(五):缓存与内存一致性
Post
Cancel

《虚拟内存的架构和操作系统支持》笔记(五):缓存与内存一致性

我们在本章中讨论的基本内容是TLB通常不与内存系统的其他部分保持一致性,也就是说更新页表的操作不会自动传播到TLB中,且旧的TLB条目也不会自动失效。这种不一致性给程序员和操作系统增添了额外的负担,当虚拟内存的状态被更新时,需要明确地进行同步。我们在本章仔细研究TLB和一般情况下指令缓存不保证硬件上的一致性的额原因,接下来研究这种不一致性对程序的同步要求,包括单线程和多线程,最后我们简单讨论即使是保持了一致性的缓存也会在弱内存一致性模型中发生意外的行为。

不一致缓存和TLB

尽管大多数CPU都用硬件实现缓存一致性,但通常不会对TLB保持同样的一致性,这也是架构设计中对性能、功耗、面积、易用性之间的复杂权衡。数据缓存保存了频繁读写的数据,且有时由一个以上的核心同时读写,要求软件管理的话对程序员负担过重,因此架构师会使用硬件来实现一致性。

相比之下,TLB和指令缓存存储的数据大多是只读的,而且变化的频率相对较低。此外,指令获取和页表遍历经常通过和正常内存数据访问不同的途径进行。因此,将一致性扩展到所有缓存和TLB的开销是很大的,但带来的好处很小。事实上,即使不考虑开销,我们也很难保持TLB的一致性,这是因为TLB条目本身一般不会加上物理地址的tag,但即使加上这一信息,我们注意到组相联TLB是由虚拟地址来索引的,而非页表所在的物理地址,也就是说如果TLB需要参与一致性协议,就必须搜索整个TLB以找到与一致性消息tag相匹配的条目,这一开销是比较大的。

在实践中,大多数TLB条目最终都会因为上下文切换或替换而被自然evict,但如果一个旧的TLB条目在哪怕一小段时间内被访问,也会导致错误的行为,因此软件需要确保虚拟系统的同步,我们接下来仔细讨论这一点。

TLB Shootdowns

在我们讨论TLB如何与页表更新同步之前,让我们先考虑一下,如果不执行这种同步,会出现什么问题。假设一个属于某个多线程进程的页帧正在被换出到磁盘,然后使页表中指向该物理地址范围的PTE失效,此时页表被更新,然而并没有更新TLB,如果操作系统认为原来的物理地址范围是可用的,然后将其他虚拟页面映射到相同的物理地址范围,那么运行在旧TLB条目的线程就有可能非法访问新映射的内存。任何期望访问原始页面的线程的读取都会看到来自新页面的不相关的数据,而任何针对原始页面的写入都不会到达最初的目标物理页面,并且会破坏新页面的数据。

这显然是一个正确性的问题,但也会引入安全问题,破坏了虚拟内存抽象的关键要求。如果一个线程能够以某种方式延迟TLB的失效,就可以利用这种特性实现侧信道攻击。例如,一个线程可以在不刷新TLB的情况下释放一个虚拟内存的范围,然后就可以继续从现在未映射的虚拟地址范围中进行读取,同时等待其他进程的页面被载入到该页面对应的原始物理页面中,并访问原本不应该访问到的数据。

移除旧TLB条目的过程被称为TLB shootdown,如何执行这一过程的细节因架构不同可能会有很大差异,如可以使用特定的指令或CSR来触发。每个核心可能可以从自己的TLB移除任何旧的条目,但是可能没有机制帮助移除其他核心的TLB旧条目,修改页表的核心需要负责向其他核心发出信号来实现shootdown。

无效化的粒度

如果一个特定的PTE被修改了,那么其实只有这一项需要从其他核心的TLB中失效,因此架构经常提供一种机制使TLB可以在一个条目的粒度上失效。另一方面,在没有ASID的TLB上进行上下文切换(或在不使用ASID的操作系统下),通常需要将所有非全局TLB条目刷新,如果每次使TLB的一个条目失效,会使上下文切换变得很慢,因此架构也经常提供一条指令来更有效地使整个TLB(或所有非全局TLB条目)失效。

在这两个粒度之间有一个灰色区域,粒度的选择可能并不明确。假设一个中等大小的内存范围正在从一个进程的虚拟地址空间中被取消分配,是否需要每次都在这个范围内一页一页地进行循环,还是直接使整个TLB失效更好?这个问题仍然是一个开放的研究和发展领域。下面的代码(/arch/x86/mm/tlb.c)展示了Linux是如何确定逐页无效还是全局无效的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/*
* See Documentation/x86/tlb.txt for details. We choose 33
* because it is large enough to cover the vast majority (at
* least 95%) of allocations, and is small enough that we are
* confident it will not cause too much overhead. Each single
* flush is about 100 ns, so this caps the maximum overhead at
* _about_ 3,000 ns.
*
* This is in units of pages.
*/
static unsigned long tlb_single_page_flush_ceiling __read_mostly = 33;
void flush_tlb_mm_range(struct mm_struct *mm, unsigned long start,
                        unsigned long end, unsigned long vmflag)
{
    /* skipping some code... */
    if (base_pages_to_flush > tlb_single_page_flush_ceiling) {
        base_pages_to_flush = TLB_FLUSH_ALL;
        count_vm_tlb_event(NR_TLB_LOCAL_FLUSH_ALL);
        local_flush_tlb();
    } else {
        /* flush range by one by one 'invlpg' */
        for (addr = start; addr < end; addr += PAGE_SIZE) {
            count_vm_tlb_event(NR_TLB_LOCAL_FLUSH_ONE);
            __flush_tlb_single(addr);
        }
    }
    /* skipping some code... */
}

处理器间中断(IPI)

在一些架构上,一个核心只能使自己的TLB无效,可能需要向其他核心发送TLB无效的消息,不同的架构对此的选择不尽相同。ARMv8采用了可以在所有核心上生效的指令,IBM Power提供了不同的指令,既可以使本地TLB失效,也可以使所有核心的TLB都失效。x86架构则两者都不是,而是使用处理器间中断(IPI)。

IPI是一种特殊的中断,直接从一个核心发送到另一个核心,或者发送到多个核心,也可能是所有核心,包括发出请求的核心本身。使用IPI可以确保请求被及时处理,大多数处理器采用可编程中断控制器(PIC)来进行处理。下面两个算法是两种不同的使用IPI来执行TLB shootdown的过程。

5-1

5-2

优化TLB Shootdowns

TLB shootdown是非常耗时的,占普通进程运行时间的5%到10%,占一个虚拟化进程运行时间的25%,且会在整个系统中增加很多额外开销,因此研究人员提出了很多不同方法来优化TLB shootdown机制。具体的细节比较复杂,可以参考原书。

其他细节

我们在讨论TLB shootdown时忽略了两个细节。首先是synonym的问题,比如如果一个页帧被换出到磁盘,那么所有指向该页的虚拟地址都需要设为无效,只shootdown同一进程中或者第一个匹配的虚拟地址是不够的,可能还需要执行反向映射查找,遍历所有指向该物理页帧的虚拟地址。另一个需要注意的点是,除了TLB之外,许多架构也有专门的页表缓存,这些缓存也经常会与内存以及TLB不一致,且对软件不可见,在这些系统上当TLB失效时,缓存内容会被简单地清空,这不影响shootdown机制本身,但是也是值得注意的一点。

自我修改的代码

指令缓存通常是只读的,且通常也不维护硬件缓存一致性。即使指令缓存可以保持一致性,在指令存储器被更新之前,可能处理器已经取走了大量指令,这些指令可能无法保证一致性。尽管指令内存可能很少更新,但是确实会在现实中发生。比如动态linker经常使用PLT来把代码和动态库中的函数链接起来,对PLT的每一次更新都是对指令内存的一次写入;JIT也动态生成代码,并存储到内存中进行执行。更广泛地说,自我修改的代码一般可用于执行任何运行时优化或实现各种低级调试或优化机制。然而,由于指令缓存或流水线可能已经不再保持一致性,需要可以由软件进行刷新,保证更新后的指令可以被正确获取。

由于W^X保护,自我修改的代码往往需要操作系统的协助,在代码生成和执行之间的某个时间,存储代码的内存区域的权限需要由R+W切换到R+X

在x86处理器中,硬件可以自动处理大多数情况,然而有两种情况需要更强的同步指令。首先,如果自我修改的代码是在多线程环境下进行的,那么必须以类似于TLB shootdown的机制来通知远程线程,并使用同步指令来更新代码;其次,如果代码修改是通过虚拟地址的synnonym完成的,也同样需要同步指令来进行更新。相比之下,ARM和Power处理器通常无法自动处理大多数自我修改的代码,并需要用户插入明确的fence指令。

内存一致性模型

我们讨论的最后一个主题是虚拟内存系统中的内存一致性模型。内存模型提供了一套规则,规定了load和store指令的执行顺序,然而精确而完整地定义内存模型是一件非常困难的事情,引入虚拟内存之后则变得甚至更加困难。

大多数现代处理器实现了某种形式的弱内存一致性模型,内存访问的执行顺序可能和代码中的顺序不同,如x86的TSO内存模型,或Power和ARM更弱的内存模型,ISA通常会提供一种或多种机制来保证内存访问的顺序。

内存模型与虚拟内存系统

讨论这个主题时出现的第一个问题是,内存模型是否适用于虚拟内存、或物理内存、或两者同时适用。研究人员认为虚拟内存的一致性和物理内存一致性在本质上是不同的,因为虚拟内存需要处理synnonym、映射变化等问题,这些问题并不存在于物理内存之中。

第二个主要问题是如何理解特殊的内存读取,如访问页表和取指,这些和读取数据内存有什么不同。我们已经注意到TLB、页表缓存和指令缓存不一定需要保证缓存一致性,这本身就引入了一些新的行为。在此基础上,访问页表的内存访问可以相对于正常的内存访问进行重排,不需要考虑内存模型本身是否允许这种重排。在许多架构上,通常的fence可能不会对页表访问进行任何约束,如果需要对页表访问也进行约束,可能需要更进一步的fence,如使指令缓存无效、使TLB无效、修改PTW FSM的状态等。例如,ARM架构区分的DMBDSB两种fence,前者只对普通的load和store进行排序,但后者对所有内存访问(包括取指和访问页表)都进行排序。

5-3 虚拟内存系统和内存模型之间复杂的相互作用的例子

现代虚拟内存系统非常容易出现一些非常微妙的内存访问排序错误。我们观察上图中的例子,这来自于2016年在Linux内核中发现的一个bug。Linux使用cpu_mask来追踪当前进程上下文中活跃的CPU,这个mask用来筛选出需要进行TLB shootdown的CPU,但是可能会出现竞争。假设线程0正在更新当前上下文的页表,并根据cpu_mask来发送TLB shootdown请求,与此同时线程1正在从其他上下文切换到与CPU 0相同的上下文,设置cpu_mask,刷新TLB,并开始执行,假设碰到了刚刚更新的PTE所引用的页面,就会在此时将PTE读取到TLB中。从内存系统的角度来看,访问页表只是特殊的内存读取,简单起见我们就只假设使用单级页表。

竞争出现在上图中四个标记的内存访问之间。假设我们现在使用x86-TSO内存模型,这个例子中cpu_mask作为了一种特殊的同步变量,但是(a)和(b)之间没有任何联系,因此(b)可以放到(a)之前,在写入pte指针之前访问cpu_mask指针,这使得(b)、(c)、(d)、(a)这样的内存访问顺序是可以的,在这种情况下,(d)发生在(a)之前,因此线程1会得到旧的PTE,然而线程1的CPU并没有收到shootdown,原因是(b)发生在(c)之前,因此线程0没有看到对应于线程1的CPU被更新。

This post is licensed under CC BY 4.0 by the author.

《虚拟内存的架构和操作系统支持》笔记(四):现代虚拟内存的软件实现

《虚拟内存的架构和操作系统支持》笔记(六):异构系统与虚拟化