根据一些操作系统的教科书,对于更快的上下文切换,人们在TLB标签字段中为每个进程添加了ASID,因此我们无需在上下文切换中刷新整个TLB。

我听说有些ARM处理器和MIPS处理器在TLB中确实具有ASID。但是我不确定Intel x86处理器是否具有ASID。

同时,似乎ASID通常具有比PID(32位)少的位(例如8位)。那么,如果在上述8位ASID情况下内存中的进程比2 ^ 8多,那么系统如何处理“ASID溢出”?

最佳答案

英特尔将ASID称为过程上下文标识符(PCID)。在所有支持PCID的Intel处理器上,PCID的大小为12位。它们构成CR3寄存器的位11:0。默认情况下,在处理器复位时,CR4.PCIDE(CR4的第17位)被清除并且CR3.PCID为零,因此,如果操作系统要使用PCID,则必须先设置CR4.PCIDE才能启用该功能。仅当设置了CR4.PCIDE时,才允许写入大于零的PCID值。就是说,当设置CR4.PCIDE时,也可以将零写入CR3.PCID。因此,可以同时使用的PCID的最大数量为2 ^ 12 = 4096。

我将讨论Linux内核如何分配PCID。 Linux内核本身甚至对英特尔处理器也使用术语ASID,因此我也将使用该术语。

通常,实际上有很多方法可以管理ASID空间,例如:

  • 当需要创建新流程时,请为该流程分配专用的ASID。如果ASID空间已用完,则拒绝创建进程并失败。这是简单而有效的,但可能会严重限制进程数。
  • 当ASID空间用尽时,不要将进程数限制为ASID的可用性,而应表现为不支持ASID。也就是说,在所有进程的进程上下文开关中刷新整个TLB。实际上,这是一种可怕的方法,因为随着进程的创建和终止,您最终可能会在禁用和启用ASID之间进行切换。此方法可能会导致高性能下降。
  • 允许多个进程使用相同的ASID。在这种情况下,在使用相同ASID的进程之间进行切换时需要小心,因为标记有该ASID的TLB条目都仍需要清除。
  • 在所有前面的方法中,每个进程都有一个ASID,因此表示一个进程的OS数据结构需要具有一个存储ASID的字段。一种替代方法是将当前分配的ASID存储在单独的结构中。 ASID是在需要执行时动态分配给进程的。处于非事件状态的进程将不会分配ASID。与以前的方法相比,这有两个优点。首先,由于大多数休眠进程不会不必要地消耗ASID,因此ASID空间的使用效率更高。其次,所有当前分配的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)空间:其中包括2048 + 1至2048 + 7(2048 + TLB_NR_DYN_ASIDS +1)的ASID。这些值实际上存储在CR3.PCID中。

  • 每个进程都有一个规范的ASID。这是Linux本身使用的值。每个规范的ASID与一个kPCID和一个uPCID相关联,它们是实际存储在CR3.PCID中的值。每个进程具有两个ASID的原因是为了支持页面表隔离(PTI),这可以缓解Meltdown漏洞。实际上,使用PTI,每个进程都有两个虚拟地址空间,每个都有自己的ASID,但是两个ASID具有固定的算术关系,如上所示。因此,即使英特尔处理器每个内核支持4096个ASID,Linux还是每个内核仅使用12个。我将转到ctxs数组,请耐心一点。

    Linux在上下文切换(而不是创建)上为进程动态分配ASID。同一进程可能在不同的内核上获得不同的ASID,并且每当该进程的线程计划在内核上运行时,其ASID可能会动态更改。这是在switch_mm_irqs_off函数中完成的,只要调度程序从一个线程切换到内核上的另一个线程,即使两个线程属于同一进程,该函数都会被调用。有两种情况需要考虑:
  • 用户线程被中断或执行了系统调用。在这种情况下,系统将切换到内核模式以处理中断或系统调用。由于用户线程刚刚运行,因此其进程必须具有已分配的ASID。如果OS稍后决定恢复执行同一线程或同一进程的另一个线程,则它将继续使用相同的ASID。这种情况很无聊。
  • 操作系统决定安排另一个进程的线程在内核上运行。因此,操作系统必须为该进程分配一个ASID。这种情况非常有趣,将在本答案的其余部分中详细讨论。

  • 在这种情况下,内核执行以下函数调用:
    choose_new_asid(next, next_tlb_gen, &new_asid, &need_flush);
    

    第一个参数next指向调度程序选择要恢复的线程所属的进程的内存描述符。该对象包含许多东西。但是,我们在这里关心的一件事是ctx_id,它是一个64位值,对于每个现有进程都是唯一的。 next_tlb_gen用于确定是否需要TLB无效,我将在稍后讨论。该函数返回new_asidneed_flushvoid保留分配给该进程的ASID,invalidate_other表示是否需要TLB无效。该函数的返回类型为ctxs
    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;
        }
    
        /*
         * We don't currently own an ASID slot on this CPU.
         * Allocate a slot.
         */
        *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刷新。由于它不相关,因此我将跳过cpu_tlbstate.ctxs[asid].ctx_id检查。接下来,循环遍历所有6个规范的ASID,并将它们用作asid的索引。当前为上下文标识符为need_flush的进程分配了ASID值next_tlb_gen。因此,循环将检查进程是否仍为其分配了ASID。在这种情况下,将使用相同的ASID并根据this_cpu_add_return更新next_asid。即使未回收ASID,我们可能仍需要刷新与ASID相关的TLB条目的原因是由于惰性TLB无效机制所致,这超出了您的问题范围。

    如果没有将当前使用的ASID分配给该进程,那么我们需要分配一个新的ASID。调用TLB_NR_DYN_ASIDS只会将next_asid中的值增加1。这将为我们提供一个kPCID值。然后,当减去1时,我们得到规范的ASID。如果我们超过了最大的规范ASID值(choose_new_asid),那么我们将规范ASID换为零,并将相应的kPCID(即1)写入switch_mm_irqs_off。发生这种情况时,这意味着为其他进程分配了相同的规范ASID,因此我们绝对希望在核心上刷新与该ASID相关联的TLB条目。然后,当ctxs返回到tlb_state时,TLB_NR_DYN_ASIDS数组和CR3会相应更新。写入CR3将使内核自动刷新与该ASID相关的TLB条目。如果将其ASID重新分配给另一个进程的进程仍处于事件状态,则下一次其线程运行时,将在该内核上为其分配一个新的ASID。这整个过程是每个核心发生的。否则,如果该进程已终止,则在将来的某个时候,其ASID将被回收。

    Linux在每个内核中仅使用6个ASID的原因是,它使ojit_code类型的大小小到足以容纳两个64字节高速缓存行。通常,在Linux系统上可以同时存在数十个同时运行的进程。但是,大多数都通常处于休眠状态。因此,Linux管理ASID空间的方式实际上非常有效。尽管有趣的是,对ojit_code的值对性能的影响进行了实验评估。但是我不知道有任何这样的发表的研究。

    关于x86 - 用于英特尔处理器的TLB ASID标签中有多少位?以及如何处理 'ASID overflow'呢?,我们在Stack Overflow上找到一个类似的问题:https://stackoverflow.com/questions/52813239/

    10-11 22:59