多核MMU和ASID管理逻辑
之前在看所有资料的时候,都没有思考过多核的关于MMU的资料,总是没有对所学的所有知识都建立起多核的概念。最近一段时间考虑问题的时候,都会强迫自己把一些知识点往多核上面思考。本来想自己写一篇,但是发现知乎上面一篇文章写得不错,自己也就懒得写了。
原文地址
本文是回答一位同事问到的,关于有多个核运行一个或者多个进程的时候,MMU和ASID怎么应用的问题。复杂的问题总是来自简单问题的组合,所以,我们还是从最基本的概念开始建模。
一、MMU是每个核一个
MMU是CPU的地址翻译器,每个CPU一个,示意如下:
你从全系统看,pa只有一份,而每个CPU都有自己的一份VA,翻译方法由页表指定,放在物理内存里面,T LB充当这个页表内存的Cache,把常用的翻译项内置在MMU中。这是硬件角度提供的模型。
好,现在看软件怎么用。假设我创建一个进程,我把它部署到左边的CPU上。我要设定这个进程的页表空间,它就是这样的:
如果你在另一个CPU上再创建一个进程,就是把左边的事情再做一次,这个我们就不画图了。如果你现在要把左边这个CPU的进程切换出去,交给另一个进程,就会这样:
进程1暂时放一边,页表换成进程2的页表就行。但这个过程成本很高,因为你首先得把T LB里面属于进程1页表的缓冲清掉,才能保证不会影响进程2的地址空间。为了解决这个问题,我们把每个翻译条目都加上一个进程ID,简称ASID。在CPU的系统寄存器中设置上这个ASID,这样进程1用进程1的ASID,进程2用进程2的ASID,两者都在T LB中,但进程2占据CPU的时候,不会使用进程1的项,等切换回进程1的时候,原来的东西还在,也不需要重新加载,这提高了效率。
我们当然想最好ASID和软件的pid是一样的,但一般做不到,因为软件的PID通常是一个标准字长,而ASID必须嵌入页表项里,没法放太大,所以,它常常只有16位,甚至8位之类的,需要一个稀疏映射表才能把两者关联起来。
现在假设我在左边CPU的进程中再创建一个线程,而且把这个线程调度到第二个CPU上,这个结果是这样的:
两个CPU共享同一个进程,它们就需要共享同一个页表,但它们需要共享同一个ASID吗?答案是:不需要。因为ASID本来就不大,明明可以分开用,只要达到每个CPU的调度上限就可以了,你现在让我公用?如果有一个进程永远不调度到我这边,我不是亏(白分配)了?
所以,靠谱的实现(比如Linux Kernel)中,ASID仅在本CPU有效,扩展到IOMMU,也仅仅对那个设备有效,不是全局的。所以,对于每个进程的ASID,都是per_cpu结构,每个CPU都有一个实例。
有趣的而是,RISC-V的20190608-Priv-MSU-Ratified版本里面有这样一条修改记录:Software is strongly recommended to allocate ASIDs globally, so that a future extension can globalize ASIDs for imporved performance and hardware flexibility。做这个统一对软件来说未来表面上肯定是利好的,因为很多方案多了一个假设可以用。但综合性能是否能够做上去,还真要用上一段时间才知道。
这时,如果其中一个CPU进行调度,把时间让给另一个进程,结果就会是这样的:
T LB里面谁的页表都可以有,反正有asid区分,有一个线程被挂起,以后属于哪个CPU等调度的时候另说,剩下就是谁占着那个CPU,谁在那个CPU上的asid生效,自然就会查那个asid的翻译项,如果没有,就从真正的内存页表里面读进去了。
如果进程实在太多,在某个CPU上没法给他分配一个实在的了,这个好办,只要分配一个不是当前的,然后把新分配的这个asid的内容从T LB里面全部抹掉就可以了。代码在各个平台的上下文切换逻辑中,比如ARM64,代码在arch/arm64/mm/context.c中。但其实概念空间逻辑都是一样的。
Linux在实现的时候用了大量的Lazy算法,所以,其实asid都不是在进程创建的时候生成的,而是在调度前发现没有了,就临时生成的,这对于新手来说,看代码会比较困扰的,但还是那句话,习惯就好。
二、参考链接
https://stackoverflow.com/questions/9929755/do-multi-core-cpus-share-the-mmu-and-page-tables