一、前言
进程切换是一个复杂的过程,本文不准备详细描述整个进程切换的方方面面,而是关注进程切换中一个小小的知识点:tlb的处理。为了能够讲清楚这个问题,我们在第二章描述在单cpu场景下一些和tlb相关的细节,第三章推进到多核场景,至此,理论部分结束。在第二章和第三章,我们从基本的逻辑角度出发,并不拘泥于特定的cpu和特定的os,这里需要大家对基本的tlb的组织原理有所了解,具体可以参考本站的《tlb操作》一文。再好的逻辑也需要体现在hw block和sw block的设计中,在第四章,我们给出了linux4.4.6内核在arm64平台上的tlb代码处理细节(在描述tlb lazy mode的时候引入部分x86架构的代码),希望能通过具体的代码和实际的cpu硬件行为加深大家对原理的理解。
二、单核场景的工作原理
1、block diagram
我们先看看在单核场景下,和进程切换相关的逻辑block示意图:
cpu上运行了若干的用户空间的进程和内核线程,为了加快性能,cpu中往往设计了tlb和cache这样的hw block。cache为了更快的访问main memory中的数据和指令,而tlb是为了更快的进行地址翻译而将部分的页表内容缓存到了translation lookasid buffer中,避免了从main memory访问页表的过程。
假如不做任何的处理,那么在进程a切换到进程b的时候,tlb和cache中同时存在了a和b进程的数据。对于kernel space其实无所谓,因为所有的进程都是共享的,但是对于a和b进程,它们各种有自己的独立的用户地址空间,也就是说,同样的一个虚拟地址x,在a的地址空间中可以被翻译成pa,而在b地址空间中会被翻译成pb,如果在地址翻译过程中,tlb中同时存在a和b进程的数据,那么旧的a地址空间的缓存项会影响b进程地址空间的翻译,因此,在进程切换的时候,需要有tlb的操作,以便清除旧进程的影响,具体怎样做呢?我们下面一一讨论。
2、绝对没有问题,但是性能不佳的方案
当系统发生进程切换,从进程a切换到进程b,从而导致地址空间也从a切换到b,这时候,我们可以认为在a进程执行过程中,所有tlb和cache的数据都是for a进程的,一旦切换到b,整个地址空间都不一样了,因此需要全部flush掉(注意:我这里使用了linux内核的术语,flush就是意味着将tlb或者cache中的条目设置为无效,对于一个arm平台上的嵌入式工程师,一般我们会更习惯使用invalidate这个术语,不管怎样,在本文中,flush等于invalidate)。
这种方案当然没有问题,当进程b被切入执行的时候,其面对的cpu是一个干干净净,从头开始的硬件环境,tlb和cache中不会有任何的残留的a进程的数据来影响当前b进程的执行。当然,稍微有一点遗憾的就是在b进程开始执行的时候,tlb和cache都是冰冷的(空空如也),因此,b进程刚开始执行的时候,tlb miss和cache miss都非常严重,从而导致了性能的下降。
3、如何提高tlb的性能?
对一个模块的优化往往需要对该模块的特性进行更细致的分析、归类,上一节,我们采用进程地址空间这样的术语,其实它可以被进一步细分为内核地址空间和用户地址空间。对于所有的进程(包括内核线程),内核地址空间是一样的,因此对于这部分地址翻译,无论进程如何切换,内核地址空间转换到物理地址的关系是永远不变的,其实在进程a切换到b的时候,不需要flush掉,因为b进程也可以继续使用这部分的tlb内容(上图中,橘色的block)。对于用户地址空间,各个进程都有自己独立的地址空间,在进程a切换到b的时候,tlb中的和a进程相关的entry(上图中,青色的block)对于b是完全没有任何意义的,需要flush掉。
在这样的思路指导下,我们其实需要区分global和local(其实就是process-specific的意思)这两种类型的地址翻译,因此,在页表描述符中往往有一个bit来标识该地址翻译是global还是local的,同样的,在tlb中,这个标识global还是local的flag也会被缓存起来。有了这样的设计之后,我们可以根据不同的场景而flush all或者只是flush local tlb entry。
4、特殊情况的考量
我们考虑下面的场景:进程a切换到内核线程k之后,其实地址空间根本没有必要切换,线程k能访问的就是内核空间的那些地址,而这些地址也是和进程a共享的。既然没有切换地址空间,那么也就不需要flush 那些进程特定的tlb entry了,当从k切换会a进程后,那么所有tlb的数据都是有效的,从大大降低了tlb miss。此外,对于多线程环境,切换可能发生在一个进程中的两个线程,这时候,线程在同样的地址空间,也根本不需要flush tlb。
4、进一步提升tlb的性能
还有可能进一步提升tlb的性能吗?有没有可能根本不flush tlb?
当然可以,不过这需要我们在设计tlb block的时候需要识别process specific的tlb entry,也就是说,tlb block需要感知到各个进程的地址空间。为了完成这样的设计,我们需要标识不同的address space,这里有一个术语叫做asid(address space id)。原来tlb查找是通过虚拟地址va来判断是否tlb hit。有了asid的支持后,tlb hit的判断标准修改为(虚拟地址+asid),asid是每一个进程分配一个,标识自己的进程地址空间。tlb block如何知道一个tlb entry的asid呢?一般会来自cpu的系统寄存器(对于arm64平台,它来自ttbrx_el1寄存器),这样在tlb block在缓存(va-pa-global flag)的同时,也就把当前的asid缓存在了对应的tlb entry中,这样一个tlb entry中包括了(va-pa-global flag-asid)。
有了asid的支持后,a进程切换到b进程再也不需要flush tlb了,因为a进程执行时候缓存在tlb中的残留a地址空间相关的entry不会影响到b进程,虽然a和b可能有相同的va,但是asid保证了硬件可以区分a和b进程地址空间。
三、多核的tlb操作
1、block diagram
完成单核场景下的分析之后,我们一起来看看多核的情况。进程切换相关的tlb逻辑block示意图如下:
在多核系统中,进程切换的时候,tlb的操作要复杂一些,主要原因有两点:其一是各个cpu core有各自的tlb,因此tlb的操作可以分成两类,一类是flush all,即将所有cpu core上的tlb flush掉,还有一类操作是flush local tlb,即仅仅flush本cpu core的tlb。另外一个原因是进程可以调度到任何一个cpu core上执行(当然具体和cpu affinity的设定相关),从而导致task处处留情(在各个cpu上留有残余的tlb entry)。
2、tlb操作的基本思考
根据上一节的描述,我们了解到地址翻译有global(各个进程共享)和local(进程特定的)的概念,因而tlb entry也有global和local的区分。如果不区分这两个概念,那么进程切换的时候,直接flush该cpu上的所有残余。这样,当进程a切出的时候,留给下一个进程b一个清爽的tlb,而当进程a在其他cpu上再次调度的时候,它面临的也是一个全空的tlb(其他cpu的tlb不会影响)。当然,如果区分global 和local,那么tlb操作也基本类似,只不过进程切换的时候,不是flush该cpu上的所有tlb entry,而是flush所有的tlb local entry就ok了。
对local tlb entry还可以进一步细分,那就是了asid(address space id)或者pcid(process context id)的概念了(global tlb entry不区分asid)。如果支持asid(或者pcid)的话,tlb操作变得简单一些,或者说我们没有必要执行tlb操作了,因为在tlb搜索的时候已经可以区分各个task上下文了,这样,各个cpu中残留的tlb不会影响其他任务的执行。在单核系统中,这样的操作可以获取很好的性能。比如a---b--->a这样的场景中,如果tlb足够大,可以容纳2个task的tlb entry(现代cpu一般也可以做到这一点),那么a再次切回的时候,tlb是hot的,大大提升了性能。
不过,对于多核系统,这种情况有一点点的麻烦,其实也就是传说中的tlb shootdown带来的性能问题。在多核系统中,如果cpu支持pcid并且在进程切换的时候不flush tlb,那么系统中各个cpu中的tlb entry则保留各种task的tlb entry,当在某个cpu上,一个进程被销毁,或者修改了自己的页表(也就是修改了va pa映射关系)的时候,我们必须将该task的相关tlb entry从系统中清除出去。这时候,你不仅仅需要flush本cpu上对应的tlb entry,还需要shootdown其他cpu上的和该task相关的tlb残余。而这个动作一般是通过ipi实现(例如x86),从而引入了开销。此外pcid的分配和管理也会带来额外的开销,因此,os是否支持pcid(或者asid)是由各个arch代码自己决定(对于linux而言,x86不支持,而arm平台是支持的)。
四、进程切换中的tlb操作代码分析
1、tlb lazy mode
在context_switch中有这样的一段代码:
if (!mm) {
next->active_mm = oldmm;
atomic_inc(&oldmm->mm_count);
enter_lazy_tlb(oldmm, next);
} else
switch_mm(oldmm, mm, next);
这段代码的意思就是如果要切入的next task是一个内核线程(next->mm == null )的话,那么可以通过enter_lazy_tlb函数标记本cpu上的next task进入lazy tlb mode。由于arm64平台上的enter_lazy_tlb函数是空函数,因此我们采用x86来描述lazy tlb mode。
当然,我们需要一些准备工作,毕竟对于熟悉arm平台的嵌入式工程师而言,x86多少有点陌生。
到目前,我们还都是从逻辑角度来描述tlb操作,但是在实际中,进程切换中的tlb操作是hw完成还是sw完成呢?不同的处理器思路是不一样的(具体原因未知),有的处理器是hw完成,例如x86,在加载cr3寄存器进行地址空间切换的时候,hw会自动操作tlb。而有的处理是需要软件参与完成tlb操作,例如arm系列的处理器,在切换ttbr寄存器的时候,hw没有tlb动作,需要sw完成tlb操作。因此,x86平台上,在进程切换的时候,软件不需要显示的调用tlb flush函数,在switch_mm函数中会用next task中的mm->pgd加载cr3寄存器,这时候load cr3的动作会导致本cpu中的local tlb entry被全部flush掉。
在x86支持pcid(x86术语,相当与arm的asid)的情况下会怎样呢?也会在load cr3的时候flush掉所有的本地cpu上的 local tlb entry吗?其实在linux中,由于tlb shootdown,普通的linux并不支持pcid(kvm中会使用,但是不在本文考虑范围内),因此,对于x86的进程地址空间切换,它就是会有flush local tlb entry这样的side effect。
另外有一点是arm64和x86不同的地方:arm64支持在一个cpu core执行tlb flush的指令,例如tlbi vmalle1is,将inner shareablity domain中的所有cpu core的tlb全部flush掉。而x86不能,如果想要flush掉系统中多有cpu core的tlb,只能是通过ipi通知到其他cpu进行处理。
好的,至此,所有预备知识都已经ready了,我们进入tlb lazy mode这个主题。虽然进程切换伴随tlb flush操作,但是某些场景亦可避免。在下面的场景,我们可以不flush tlb(我们仍然采用a--->b task的场景来描述):
(1)如果要切入的next task b是内核线程,那么我们也暂时不需要flush tlb,因为内核线程不会访问usersapce,而那些进程a残留的tlb entry也不会影响内核线程的执行,毕竟b没有自己的用户地址空间,而且和a共享内核地址空间。
(2)如果a和b在一个地址空间中(一个进程中的两个线程),那么我们也暂时不需要flush tlb。
除了进程切换,还有其他的tlb flush场景。我们先看一个通用的tlb flush场景,如下图所示:
一个4核系统中,a0 a1和a2 task属于同一个进程地址空间,cpu_0和cpu_2上分别运行了a0和a2 task,cpu_1有点特殊,它正在运行一个内核线程,但是该内核线程正在借用a1 task的地址空间,cpu_3上运行不相关的b task。
当a0 task修改了自己的地址翻译,那么它不能只是flush cpu_0的tlb,还需要通知到cpu_1和cpu_2,因为这两个cpu上当前active的地址空间和cpu_0是一样的。由于a1 task的修改,cpu_1和cpu_2上的这些缓存的tlb entry已经失效了,需要flush。同理,可以推广到更多的cpu上,也就是说,在某个cpux上运行的task修改了地址映射关系,那么tlb flush需要传递到所有相关的cpu中(当前的mm等于cpux的current mm)。在多核系统中,这样的通过ipi来传递tlb flush的消息会随着cpu core的增加而增加,有没有办法减少那些没有必要的tlb flush呢?当然有,也就是上图中的a1 task场景,这也就是传说中的lazy tlb mode。
我先回头看看代码。在代码中,如果next task是内核线程,我们并不会执行switch_mm(该函数会引起tlb flush的动作),而是调用enter_lazy_tlb进入lazy tlb mode。在x86架构下,代码如下:
static inline void enter_lazy_tlb(struct mm_struct *mm, struct task_struct *tsk)
{
#ifdef config_smp
if (this_cpu_read(cpu_tlbstate.state) == tlbstate_ok)
this_cpu_write(cpu_tlbstate.state, tlbstate_lazy);
#endif
}
在x86架构下,进入lazy tlb mode也就是在该cpu的cpu_tlbstate变量中设定tlbstate_lazy的状态就ok了。因此,进入lazy mode的时候,也就不需要调用switch_mm来切换进程地址空间,也就不会执行flush tlb这样毫无意义的动作了。enter_lazy_tlb并不操作硬件,只要记录该cpu的软件状态就ok了。
切换之后,内核线程进入执行状态,cpu_1的tlb残留进程a的entry,这对于内核线程的执行没有影响,但是当其他cpu发送ipi要求flush tlb的时候呢?按理说应该立刻flush tlb,但是在lazy tlb mode下,我们可以不执行flush tlb操作。这样问题来了:什么时候flush掉残留的a进程的tlb entry呢?答案是在下一次进程切换中。因为一旦内核线程被schedule out,并且切入一个新的进程c,那么在switch_mm,切入到c进程地址空间的时候,所有之前的残留都会被清除掉(因为有load cr3的动作)。因此,在执行内核线程的时候,我们可以推迟tlb invalidate的请求。也就是说,当收到ipi中断要求进行该mm的tlb invalidate的动作的时候,我们暂时没有必要执行了,只需要记录状态就ok了。
2、arm64中如何管理asid?
和x86不同的是:arm64支持了asid(类似x86的pcid),难道arm64解决了tlb shootdown的问题?其实我也在思考这个问题,但是还没有想明白。很显然,在arm64中,我们不需要通过ipi来进行所有cpu core的tlb flush动作,arm64在指令集层面支持shareable domain中所有pes上的tlb flush动作,也许是这样的指令让tlb flush的开销也没有那么大,那么就可以选择支持asid,在进程切换的时候不需要进行任何的tlb操作,同时,由于不需要ipi来传递tlb flush,那么也就没有特别的处理lazy tlb mode了。
既然linux中,arm64选择支持asid,那么它就要直面asid的分配和管理问题了。硬件支持的asid有一定限制,它的编址空间是8个或者16个bit,最大256或者65535个id。当asid溢出之后如何处理呢?这就需要一些软件的控制来协调处理。我们用硬件支持上限为256个asid的情景来描述这个基本的思路:当系统中各个cpu的tlb中的asid合起来不大于256个的时候,系统正常运行,一旦超过256的上限后,我们将全部tlb flush掉,并重新分配asid,每达到256上限,都需要flush tlb并重新分配hw asid。具体分配asid代码如下:
static u64 new_context(struct mm_struct *mm, unsigned int cpu)
{
static u32 cur_idx = 1;
u64 asid = atomic64_read(&mm->context.id);
u64 generation = atomic64_read(&asid_generation);
if (asid != 0) {-------------------------(1)
u64 newasid = generation | (asid & ~asid_mask);
if (check_update_reserved_asid(asid, newasid))
return newasid;
asid &= ~asid_mask;
if (!__test_and_set_bit(asid, asid_map))
return newasid;
}
asid = find_next_zero_bit(asid_map, num_user_asids, cur_idx);---(2)
if (asid != num_user_asids)
goto set_asid;
generation = atomic64_add_return_relaxed(asid_first_version,----(3)
&asid_generation);
flush_context(cpu);
asid = find_next_zero_bit(asid_map, num_user_asids, 1); ------(4)
set_asid:
__set_bit(asid, asid_map);
cur_idx = asid;
return asid | generation;
}
(1)在创建新的进程的时候会分配一个新的mm,其software asid(mm->context.id)初始化为0。如果asid不等于0那么说明这个mm之前就已经分配过software asid(generation+hw asid)了,那么new context不过就是将software asid中的旧的generation更新为当前的generation而已。
(2)如果asid等于0,说明我们的确是需要分配一个新的hw asid,这时候首先要找一个空闲的hw asid,如果能够找到(jump to set_asid),那么直接返回software asid(当前generation+新分配的hw asid)。
(3)如果找不到一个空闲的hw asid,说明hw asid已经用光了,这是只能提升generation了。这时候,多有cpu上的所有的old generation需要被flush掉,因为系统已经准备进入new generation了。顺便一提的是这里generation变量已经被赋值为new generation了。
(4)在flush_context函数中,控制hw asid的asid_map已经被全部清零了,因此,这里进行的是new generation中hw asid的分配。
3、进程切换过程中arm64的tlb操作以及asid的处理
代码位于arch/arm64/mm/context.c中的check_and_switch_context:
void check_and_switch_context(struct mm_struct *mm, unsigned int cpu)
{
unsigned long flags;
u64 asid;
asid = atomic64_read(&mm->context.id); -------------(1)
if (!((asid ^ atomic64_read(&asid_generation)) >> asid_bits) ------(2)
&& atomic64_xchg_relaxed(&per_cpu(active_asids, cpu), asid))
goto switch_mm_fastpath;
raw_spin_lock_irqsave(&cpu_asid_lock, flags);
asid = atomic64_read(&mm->context.id);
if ((asid ^ atomic64_read(&asid_generation)) >> asid_bits) { ------(3)
asid = new_context(mm, cpu);
atomic64_set(&mm->context.id, asid);
}
if (cpumask_test_and_clear_cpu(cpu, &tlb_flush_pending)) ------(4)
local_flush_tlb_all();
atomic64_set(&per_cpu(active_asids, cpu), asid);
raw_spin_unlock_irqrestore(&cpu_asid_lock, flags);
switch_mm_fastpath:
cpu_switch_mm(mm->pgd, mm);
}
看到这些代码的时候,你一定很抓狂:本来期望支持asid的情况下,进程切换不需要tlb flush的操作了吗?怎么会有那么多代码?呵呵~~实际上理想很美好,现实很骨干,代码中嵌入太多管理asid的内容了。
(1)现在准备切入mm变量指向的地址空间,首先通过内存描述符获取该地址空间的id(software asid)。需要说明的是这个id并不是hw asid,实际上mm->context.id是64个bit,其中低16 bit对应hw 的asid(arm64支持8bit或者16bit的asid,但是这里假设当前系统的asid是16bit)。其余的bit都是软件扩展的,我们称之generation。
(2)arm64支持asid的概念,理论上进程切换不需要tlb的操作,不过由于hw asid的编址空间有限,因此我们扩展了64 bit的software asid,其中一部分对应hw asid,另外一部分被称为asid generation。asid generation从asid_first_version开始,每当hw asid溢出后,asid generation会累加。asid_bits就是硬件支持的asid的bit数目,8或者16,通过id_aa64mmfr0_el1寄存器可以获得该具体的bit数目。
当要切入的mm的software asid仍然处于当前这一批次(generation)的asid的时候,切换中不需要任何的tlb操作,可以直接调用cpu_switch_mm进行地址空间的切换,当然,也会顺便设定active_asids这个percpu变量。
(3)如果要切入的进程和当前的asid generation不一致,那么说明该地址空间需要一个新的software asid了,更准确的说是需要推进到new generation了。因此这里调用new_context分配一个新的context id,并设定到mm->context.id中。
(4)各个cpu在切入新一代的asid空间的时候会调用local_flush_tlb_all将本地tlb flush掉。
原文标题:郭健: 进程切换分析之——tlb处理
文章出处:【微信公众号:linuxer】欢迎添加关注!文章转载请注明出处。
北京大兴国际机场正式投入运营,打造出名副其实的全球绿色能源新地标
国星光电推动智能穿戴产业发展
基于主控STM32控制系统的硬件设计喷绘系统设计
快商通正式通过CMMI5级评估认证
人工智能有很大的潜力和风险我们需要将社会责任融入到技术的结构中
CPU场景下的TLB相关细节
全志智能语音,打造从算法、设计、制造到云的完整生态圈
如何使用USB串行电缆将文件从计算机传输到树莓派
智慧停车将成为人工智能下一战场
【技能秒get】 CYUSB3014固件部分低版本工程在Eclipse中编译得到img文件
基于KeyStone器件特性的鲁棒性系统设计及例程解析
变频电机怎么调速度快_变频电机维修
好用的EDA软件推荐
罗永浩从锤子法人变成执行董事
电机驱动器如何通过I2C接口使用PID算法控制电机
关于全自动在线缠绕包装机的技术参数及其应用介绍
小米联手宁德时代、比亚迪推出"SU7"电动汽车
东软载波微电子推出精度更高的系列化产品ESB1340
博通停止VMware合作,只向高收入经销商开放
全新造型的iPhone5震撼登场(多图)