Intel将ASIDs称为进程上下文标识符(PCIDs)。在支持PCIDs的所有Intel处理器上,PCID的大小为12位。它们构成CR3寄存器的11:0位。默认情况下,在处理器复位时,CR4.PCIDE(CR4的第17位)被清除,CR3.PCID为零,因此如果操作系统想要使用PCIDs,则必须首先设置CR4.PCIDE以启用该功能。只有在设置了CR4.PCIDE时才允许写入大于零的PCID值。也就是说,当设置了CR4.PCIDE时,还可以将零写入CR3.PCID。因此,可以同时使用的PCID数量的最大值为2^12 = 4096。
我将讨论Linux内核如何分配PCIDs。实际上,即使对于Intel处理器,Linux内核本身也使用术语ASIDs,因此我也将使用这个术语。
一般来说,管理ASID空间的方法有很多种,例如:
- 当需要创建新进程时,为该进程分配一个专用的ASID。如果ASID空间已经用尽,则拒绝创建进程并失败。这种方法简单高效,但可能严重限制进程的数量。
- 不要将进程数量限制为ASID的可用性,当ASID空间已满时,表现得好像不支持ASID一样。也就是说,在所有进程的上下文切换时刷新整个TLB。实际上,这是一种糟糕的方法,因为您可能会在启用和禁用ASID之间切换,因为进程被创建和终止。此方法可能会带来潜在的高性能惩罚。
- 允许多个进程使用同一个ASID。在这种情况下,在切换使用相同ASID的进程时需要小心,因为标记有该ASID的TLB条目仍然需要被刷新。
- 在所有先前的方法中,每个进程都有一个ASID,因此表示进程的OS数据结构需要具有存储ASID的字段。另一种替代方法是将当前分配的ASID存储在单独的结构中。ASIDs动态地分配给需要执行的进程。未激活的进程将不会分配ASIDs。这比以前的方法有两个优点。首先,ASID空间更有效地使用,因为大多数休眠进程不会不必要地消耗ASIDs。其次,所有当前分配的ASID都存储在同一数据结构中,该数据结构可以小到足以适合几个高速缓存线路内。通过这种方式,可以有效地找到新的ASID。
Linux使用最后一种方法,我将详细讨论它。
Linux仅记住每个核心上使用的最后6个ASID。这由TLB_NR_DYN_ASIDS宏指定。系统为每个类型为tlb_state的核心创建一个数据结构,该数据结构定义一个如下的数组:
struct tlb_context {
u64 ctx_id;
u64 tlb_gen;
};
struct tlb_state {
.
.
.
u16 next_asid;
struct tlb_context ctxs[TLB_NR_DYN_ASIDS];
};
DECLARE_PER_CPU_SHARED_ALIGNED(struct tlb_state, cpu_tlbstate);
该类型包括其他字段,但出于简洁起见,我只显示了其中两个。Linux定义了以下ASID空间:
- 规范ASID空间:这些包括ASID 0到6(`TLB_NR_DYN_ASIDS`)。这些值存储在`next_asid`字段中,并用作`ctxs`数组的索引。
- 内核ASID(kPCID)空间:这些包括ASID 1到7(`TLB_NR_DYN_ASIDS` + 1)。这些值实际上存储在CR3.PCID中。
- 用户ASID(uPCID)空间:这些包括ASID 2048 + 1到2048 + 7(2048 + `TLB_NR_DYN_ASIDS` + 1)。这些值实际上存储在CR3.PCID中。
每个进程都有一个单独的规范ASID。这是Linux本身使用的值。每个规范ASID与一个kPCID和一个uPCID相关联,这些值实际上存储在CR3.PCID中。每个进程具有两个ASID是为了支持页面表隔离(PTI),以缓解Meltdown漏洞。事实上,使用PTI后,每个进程具有两个虚拟地址空间,每个都有自己的ASID,但是这两个ASID具有固定的算术关系,如上所示。因此,即使Intel处理器每个核支持4096个ASID,Linux每个核只使用12个。我将介绍`ctxs`数组,请稍等片刻。
Linux在上下文切换时动态分配ASIDs给进程,而不是在创建时。同一进程可能会在不同的核心上获得不同的ASID,并且每当该进程的线程被调度到核心上运行时,其ASID都可能会动态更改。这是在
switch_mm_irqs_off函数中完成的,无论两个线程是否属于同一进程,在调度程序在核心上从一个线程切换到另一个线程时都会调用该函数。需要考虑两种情况:
- 用户线程被中断或执行了系统调用。在这种情况下,系统会切换到内核模式来处理中断或系统调用。由于用户线程刚刚在运行,因此其进程必须已经分配了ASID。如果操作系统稍后决定恢复执行相同的线程或同一进程的另一个线程,则将继续使用相同的ASID。这种情况很无聊。
- 操作系统决定在核心上调度另一个进程的线程运行。因此,操作系统必须为该进程分配ASID。这种情况非常有趣,并且将在本答案的其余部分详细讨论。
在这种情况下,内核执行以下函数调用:
choose_new_asid(next, next_tlb_gen, &new_asid, &need_flush)
第一个参数next
指向调度程序选择恢复的线程所属进程的内存描述符。该对象包含许多内容。但在这里我们关心的是ctx_id
,它是一个64位的值,每个现有进程都是唯一的。 next_tlb_gen
用于确定是否需要TLB失效,稍后我将讨论。函数返回new_asid
,它保存分配给进程的ASID和need_flush
,表示是否需要TLB失效。函数的返回类型为void
。
static void choose_new_asid(struct mm_struct *next, u64 next_tlb_gen,
u16 *new_asid, bool *need_flush)
{
u16 asid;
if (!static_cpu_has(X86_FEATURE_PCID)) {
*new_asid = 0;
*need_flush = true;
return;
}
if (this_cpu_read(cpu_tlbstate.invalidate_other))
clear_asid_other();
for (asid = 0; asid < TLB_NR_DYN_ASIDS; asid++) {
if (this_cpu_read(cpu_tlbstate.ctxs[asid].ctx_id) !=
next->context.ctx_id)
continue;
*new_asid = asid;
*need_flush = (this_cpu_read(cpu_tlbstate.ctxs[asid].tlb_gen) <
next_tlb_gen);
return;
}
*new_asid = this_cpu_add_return(cpu_tlbstate.next_asid, 1) - 1;
if (*new_asid >= TLB_NR_DYN_ASIDS) {
*new_asid = 0;
this_cpu_write(cpu_tlbstate.next_asid, 1);
}
*need_flush = true;
}
从逻辑上讲,该函数的工作方式如下。如果处理器不支持PCID,则所有进程都将获得零的ASID值,并且始终需要进行TLB刷新。我将跳过invalidate_other
检查,因为它与本题无关。接下来,循环遍历所有6个规范化的ASID,并将它们用作索引到ctxs
中。具有cpu_tlbstate.ctxs[asid].ctx_id
上下文标识符的进程当前被分配ASID值asid
。所以循环检查进程是否仍然具有已分配给它的ASID。在这种情况下,将使用相同的ASID,并根据next_tlb_gen
更新need_flush
状态。即使没有回收ASID,我们可能需要刷新与ASID相关联的TLB条目的原因是懒惰的TLB失效机制,这超出了你的问题的范围。
如果当前使用的ASID都没有分配给该进程,则需要分配一个新的ASID。调用
this_cpu_add_return
函数只是将
next_asid
的值增加1。这给我们提供了一个kPCID值。然后再减去1,我们就得到了规范化的ASID。如果超过了最大规范化ASID值(
TLB_NR_DYN_ASIDS
),则会回绕到规范化的ASID零,并将相应的kPCID(即1)写入
next_asid
。当发生这种情况时,这意味着其他一些进程被分配了相同的规范化ASID,因此我们肯定要在核心上刷新与该ASID相关联的TLB条目。然后,当
choose_new_asid
返回到
switch_mm_irqs_off
时,
ctxs
数组和CR3相应地更新。写入CR3将使核心自动刷新与该ASID相关联的TLB条目。如果重新分配ASID的进程仍然活着,那么在其线程下次运行时,它将在该核心上分配一个新的ASID。整个过程是针对每个核心进行的。否则,如果该进程已经死亡,那么在将来的某个时候,它的ASID将被回收。
Linux每个核心使用6个ASID的原因是为了使
tlb_state
类型的大小恰好足以适应两个64字节缓存行。通常,Linux系统上可以同时存在数十个进程。但是,其中大多数通常处于休眠状态。因此,Linux管理ASID空间的方式实际上非常高效。尽管有趣的是,我们可以看到关于
TLB_NR_DYN_ASIDS
值对性能影响的实验评估,但我不知道是否有任何已发布的研究。