多线程下mprotect函数的行为

6

为了实现并发/并行GC,我对mprotect系统调用提供的内存顺序保证感兴趣(即在多线程或mprotect的内存模型中的行为)。我的问题是(假设没有编译器重新排序或具有足够的编译器障碍):

  1. If thread 1 triggers a segfault on an address due to a mprotect on thread 2, can I be sure that everything happens on thread 2 before the syscall can be observed in thread 1 in the signal handler of the segfault? What if a full memory barrier is placed in the signal handler before performing load on thread1?

  2. If thread 1 does an volatile load on an address that is set to PROT_NONE by thread 2 and didn't trigger a segfault, is this enough of a happens before relation between the two. Or in another word, if the two threads do (*ga starts as 0, p is a page aligned address started readonly)

    // thread 1
    *ga = 1;
    *(volatile int*)p; // no segfault happens
    
    // thread 2
    mprotect(p, 4096, PROT_NONE); // Or replace 4096 by the real userspace-visible page size
    a = *ga;
    

    is there a guarantee that a on thread 2 will be 1? (assuming no segfault observed on thread 1 and no other code modifies *ga)

我主要关心Linux的行为,特别是在x86(_64),arm/aarch64和ppc上的行为,尽管其他架构/操作系统的信息也很受欢迎(对于Windows,请使用VirtualProtect或类似方法替换mprotect...)。到目前为止,在x64和aarch64 Linux上的测试表明没有违规行为,但我不确定我的测试是否具有决定性,或者这种行为在长期内是否可靠。

一些搜索表明,当权限被移除时,mprotect可能会对所有映射地址的线程发出TLB shootdown,这可能提供了此处所述的保证(或者换句话说,提供此保证似乎是此操作的目标),但我不清楚内核代码的未来优化是否会破坏此保证。

参考LKML帖子,我在一周前提出了这个问题,但还没有得到回复...

编辑:关于问题的澄清。我知道TLB shootdown应该提供我正在寻找的保证,但我想知道是否可以依赖这种行为。换句话说,为什么内核会发出这样的请求,因为如果不提供某种排序保证,则不需要这样做。


“在系统调用被观察到之前”是什么意思?如何观察到?您是指系统调用的效果吗?如果我们从内核的角度来看,系统调用是请求更改虚拟内存属性,其效果将通过翻译缓冲器传播,如果有多个用户空间线程在不同的核心(或不同的CPU,这取决于架构)上运行,则可能在不同的物理时刻看到效果,除非进行刷新。在Linux中,TLB刷新是必须的,除非旧属性禁止访问。[...]” - Nominal Animal
这意味着行为取决于系统调用是否允许(PROT_NONEPROT_READ)或不允许(PROT_READPROT_NONE)访问。在Linux中,前者情况下省略了TLB刷新,因此同时运行的线程可能会在不同时间看到更改。在后一种情况下,进行TLB刷新,因此线程应该同时观察到更改(在TLB刷新时刻)--尽管我不确定是否存在TLB刷新在不同CPU(包!)上是非同时的硬件。 - Nominal Animal
我怀疑 LKLM 上没有回复的原因有两个:一是这听起来很抽象,内核开发人员是务实的;大多数人有足够有趣的现实问题需要处理,不会太关心没有现实意义的抽象问题。二是很难理解这里的确切情况。一个真实的用例——“mm 逻辑是否保证此场景可行?”——带有 ASCII 艺术图表,展示了在同时线程中发生的情况,将会有很大帮助。 - Nominal Animal
@NominalAnimal 观察系统调用意味着在权限被翻转的页面上进行的内存操作会由于mprotect调用而故障(或者不故障)。关于TLB刷新,是的,我知道它在Linux上完成,但我的问题更多的是:我可以依赖哪些行为或另一种说法是什么保证它试图通过发出这样的刷新来维护。关于一个真实的用例,我希望情况2中的伪代码足以开始,看起来不是这样的 =( - yuyichao
1个回答

4
所以我在发布后的一天在机械同理心群组上提出了这个问题,并得到了Gil Tene的答案。在他的允许下,这里是他回答的摘要。如有不清楚之处,完整的内容可在此处获得。
对于操作系统(Os)的整体行为,可以预期:
1. 使用mprotect()调用时,与该调用之前和之后发生的负载和存储完全有序。这通常在CPU和OS级别很容易实现,因为mprotect是系统调用,涉及陷阱,进而涉及完全排序。[在奇怪的无环过渡实现(例如内核执行等)中,保护调用可能负责模拟这种排序假设]。
2. mprotect()调用在请求在进程内的所有地方都被语义化地承载之前不会返回。如果mprotect()调用设置了导致故障的保护,则在此mprotect()调用之后的任何线程上的任何操作都需要产生故障。类似地,如果mprotect()调用设置了防止故障的保护,那么在此mprotect()调用之后的任何线程上的任何操作都需要不产生故障。
这意味着在调用mprotect()函数的线程中,在受影响页上的存储操作与其他线程上的内存操作是同步的。更具体地说,可以期望原始问题中提到的两种情况都是有保证的。即:
1. 如果观察到受影响页面中一个线程上的负载由于mprotect()调用而产生故障,则此故障发生在mprotect()调用之后,并且因此在能够观察到mprotect()调用之前的所有内存操作之后。
2. 如果观察到受影响页面中一个线程上的负载不会由于mprotect()调用而产生故障,则该负载发生在mprotect()调用之前,并且mprotect()调用和任何代码都在该负载之后,并且能够观察到发生在该负载之前的任何内存操作。
还指出了传递性可能无法工作,即一个线程上的故障负载可能不在另一个线程上的非故障负载之后。这可以(有效地)由TLB刷新的不原子性引起,导致不同的线程/ CPU在不同的时间观察到访问权限的更改。

1
我对你引用中的第二点并不确定。这是一个自证命题。“在观察到X之后发生的任何事情都是在你观察到X之后发生的”。我的意思是,实际上应该是“在此mprotect调用的效果被线程观察到之后发生的任何事情”。关于一些内存操作的顺序问题,只有当你使用内存屏障或显式排序指令时,“之前”和“之后”才有意义。我认为我们能够解决这个问题的原因是TLB shootdown必须是一个内存屏障(我无法想象在一个没有内存屏障的架构上工作)。 - Art
1
但是你实际的问题很可能没有答案:我从未见过系统调用的正式内存模型。我认为假设是“当然它们是同步的,否则不可能完成任何任务”,但是官方上来说这很可能是未定义的。即使在我使用过的最疯狂的内存模型中,系统调用也不会返回,直到所有东西都同步(我曾经编写过代码,在返回到用户空间之前我们没有等待tlb完全同步,但这太危险了,而且没有什么好处)。 - Art
这意味着您超出了线程实现的内存模型所定义的范围。因此,我的回答是:“由于您没有像pthread所告诉您那样同步,因此行为未定义。” - Art
Pthread在这里是无关紧要的,它甚至没有包括任何关于页面保护或段错误的内容。显然,在pthread下这不会有一个定义好的行为,这也不是它的工作,也不是我所要求的。Pthread/C标准并不是唯一的标准/保证。顺便说一句,我明确提到了伪代码假设没有编译器重排序,因此它基本上是一种更容易编写汇编语言的方式,在实践中,代码将被JIT化,与C没有任何关系。 - yuyichao
你在忽略编译器这里是正确的,问题不在这里。在任何非x86(以及在某种程度上也是x86,但那里要简单得多)的情况下,你将处理CPU重新排序和组合写入。我提到pthread(或任何其他线程实现)的原因是因为那几乎是操作系统必须具有明确定义的内存排序规则的唯一级别(还有C11 stdatomic.h)。超出此范围的任何内容基本上都是未定义的。 - Art
显示剩余5条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接