Linux 中线程创建会触发页面错误吗?它与软脏 PTE 有何关系?

TOM*_*ANG 4 linux multithreading pthreads linux-kernel page-fault

我问这个问题的原因是,在测试Linux软脏位的行为时,我发现如果我创建一个线程而不接触任何内存,所有页面的软脏位都会被设置为1(脏)。

例如,malloc(100MB)在主线程中,然后清理软脏位,然后创建一个只是休眠的线程。创建线程后,所有 100MB 内存块的软脏位都设置为 1。

这是我正在使用的测试程序:

#include <thread>
#include <iostream>
#include <vector>
#include <cstdint>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>

#define PAGE_SIZE_4K 0x1000

int GetDirtyBit(uint64_t vaddr) {
  int fd = open("/proc/self/pagemap", O_RDONLY);
  if (fd < 0) {
    perror("Failed open pagemap");
    exit(1);
  }

  off_t offset = vaddr / 4096 * 8;
  if (lseek(fd, offset, SEEK_SET) < 0) {
    perror("Failed lseek pagemap");
    exit(1);
  }

  uint64_t pfn = 0;
  if (read(fd, &pfn, sizeof(pfn)) != sizeof(pfn)) {
    perror("Failed read pagemap");
    sleep(1000);
    exit(1);
  }
  close(fd);

  return pfn & (1UL << 55) ? 1 : 0;
}

void CleanSoftDirty() {
  int fd = open("/proc/self/clear_refs", O_RDWR);
  if (fd < 0) {
    perror("Failed open clear_refs");
    exit(1);
  }

  char cmd[] = "4";
  if (write(fd, cmd, sizeof(cmd)) != sizeof(cmd)) {
    perror("Failed write clear_refs");
    exit(1);
  }

  close(fd);
}

int demo(int argc, char *argv[]) {
  int x = 1;
  // 100 MB
  uint64_t size = 1024UL * 1024UL * 100;
  void *ptr = malloc(size);
  for (uint64_t s = 0; s < size; s += PAGE_SIZE_4K) {
    // populate pages
    memset(ptr + s, x, PAGE_SIZE_4K);
  }

  char *cptr = reinterpret_cast<char *>(ptr);
  printf("Soft dirty after malloc: %ld, (50MB offset)%ld\n",
        GetDirtyBit(reinterpret_cast<uint64_t>(cptr)),
        GetDirtyBit(reinterpret_cast<uint64_t>(cptr + 50 * 1024 * 1024)));

  printf("ALLOCATE FINISHED\n");

  std::string line;
  std::vector<std::thread> threads;
  while (true) {
    sleep(2);
    // Set soft dirty of all pages to 0.
    CleanSoftDirty();

    char *cptr = reinterpret_cast<char *>(ptr);
    printf("Soft dirty after reset: %ld, (50MB offset)%ld\n",
      GetDirtyBit(reinterpret_cast<uint64_t>(cptr)),
      GetDirtyBit(reinterpret_cast<uint64_t>(cptr + 50 * 1024 * 1024)));
    
    // Create thread.
    threads.push_back(std::thread([]() { while(true) sleep(1); }));

    sleep(2);
    
    printf("Soft dirty after create thread: %ld, (50MB offset)%ld\n",
      GetDirtyBit(reinterpret_cast<uint64_t>(cptr)),
      GetDirtyBit(reinterpret_cast<uint64_t>(cptr + 50 * 1024 * 1024)));

    // memset the first 20MB
    memset(cptr, x++, 1024UL * 1024UL * 20);
    printf("Soft dirty after memset: %ld, (50MB offset)%ld\n",
      GetDirtyBit(reinterpret_cast<uint64_t>(cptr)),
      GetDirtyBit(reinterpret_cast<uint64_t>(cptr + 50 * 1024 * 1024)));
  }

  return 0;
}

int main(int argc, char *argv[]) {
  std::string last_arg = argv[argc - 1];
  printf("PID: %d\n", getpid());

  return demo(argc, argv);
}
Run Code Online (Sandbox Code Playgroud)

我打印第一页的脏位以及 offset 处的页面50 * 1024 * 1024。发生的情况如下:

  1. 之后的软脏位malloc()为 1,这是预期的。
  2. clean soft-dirty之后,它们就变成0了。
  3. 创建一个只休眠的线程。
  4. 检查脏位,100MB区域中的所有页面(我没有打印所有页面的脏位,但我自己检查了)现在软脏位设置为1。
  5. 重新启动循环,现在行为正确,创建附加线程后软脏位仍为 0。
  6. 自从我这样做以来,偏移量 0 处的页面的软脏位为 1 memset(),并且页面的软脏位50 MB仍然为 0。

这是输出:

Soft dirty after malloc: 1, (50MB offset)1
ALLOCATE FINISHED
Soft dirty after reset: 0, (50MB offset)0
Soft dirty after create thread: 1, (50MB offset)1
Soft dirty after memset: 1, (50MB offset)1

(steps 1-4 above)
(step 5 starts below)
Soft dirty after reset: 0, (50MB offset)0
Soft dirty after create thread: 0, (50MB offset)0
Soft dirty after memset: 1, (50MB offset)0

Soft dirty after reset: 0, (50MB offset)0
Soft dirty after create thread: 0, (50MB offset)0
Soft dirty after memset: 1, (50MB offset)0

Soft dirty after reset: 0, (50MB offset)0
Soft dirty after create thread: 0, (50MB offset)0
Soft dirty after memset: 1, (50MB offset)0
Run Code Online (Sandbox Code Playgroud)

我认为线程创建只会将页面标记为处于“共享”状态,而不是修改它们,因此软脏位应该保持不变。显然,行为是不同的。因此我在想:创建线程是否会触发所有页面上的页面错误?因此,操作系统在处理页面错误时将所有页面的软脏位设置为 1。

如果不是这样的话,为什么创建线程会让进程的所有内存页都变成“脏”呢?为什么只有第一个线程创建才有这样的行为?

我希望我很好地解释了这个问题,如果需要更多细节,或者如果有什么没有意义,请告诉我。

Mar*_*lli 6

所以,这有点有趣。您的具体情况以及软脏位的行为非常特殊。没有发生页面错误,并且软脏位并未在所有内存页面上设置,而只是在其中一些页面上设置(您通过分配的内存页面malloc)。

如果您在下面运行程序,strace您会注意到一些有助于解释您所观察到的情况的事情:

[1] mmap(NULL, 104861696, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f8669b66000
    ...
[2] mmap(NULL, 8392704, PROT_NONE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0) = 0x7f8669365000
[2] mprotect(0x7f8669366000, 8388608, PROT_READ|PROT_WRITE) = 0
[2] clone(child_stack=0x7f8669b64fb0, ...) = 97197
    ...
Run Code Online (Sandbox Code Playgroud)

正如你在上面看到的:

  1. 您的内存malloc()非常大,因此您不会获得正常的堆块,而是通过系统调用保留的专用内存区域mmap

  2. mmap当您创建线程时,库代码通过另一个后跟 的线程为该线程设置一个堆栈mprotect

Linux 中的正常mmap行为是从mmap_base进程创建时选择的地址开始保留内存,每次减去请求的大小(除非明确请求特定地址,在这种情况下mmap_base不考虑)。因此,mmapat 点 1 将在动态加载器映射的最后一个共享库的正上方保留页面,而mmap上面的 at 点 2 将在点 1 映射的页面之前保留页面。然后将mprotect标记第二个区域(除了对于第一页)作为 RW。

由于这些映射是连续的,都是匿名的并且都具有相同的保护(RW),因此从内核的角度来看,这看起来像是一个大小已增长的单个内存区域。事实上,内核将其视为单个 VMA ( vm_area_struct)。

现在,我们可以从内核文档中读到有关软脏位的信息(请注意我以粗体突出显示的部分):

虽然在大多数情况下通过 #PF-s 跟踪内存变化已经足够了,但仍然存在一种情况,我们可能会丢失软脏位——任务取消映射先前映射的内存区域,然后在完全相同的位置映射一个新的内存区域。当调用 unmap 时,内核会在内部清除 PTE 值,包括软脏位。为了通知用户空间应用程序有关此类内存区域更新的信息,内核始终将新内存区域(和扩展区域)标记为软脏

因此,您看到软脏位在清除后重新出现在初始分配的内存块上的原因是一个有趣的巧合:内存区域(VMA)不那么直观的“扩展”的结果包含它是由线程堆栈的分配引起的。


为了让事情更清楚,我们可以在不同阶段检查进程的虚拟内存布局/proc/[pid]/maps。它看起来像这样(取自我的机器):

  • malloc()

    ...
    5653d8b82000-5653d8b83000 r--p 00005000 00:18 77464613     [your program]
    5653d8b83000-5653d8b84000 rw-p 00006000 00:18 77464613     [your program]
    5653d983f000-5653d9860000 rw-p 00000000 00:00 0            [heap]
    7f866ff6c000-7f866ff79000 r--p 00000000 00:18 77146186     [shared libraries]
    7f866ff79000-7f8670013000 r-xp 0000d000 00:18 77146186     [shared libraries]
    ...
    
    Run Code Online (Sandbox Code Playgroud)
  • malloc()

    ...
    5653d8b82000-5653d8b83000 r--p 00005000 00:18 77464613     [your program]
    5653d8b83000-5653d8b84000 rw-p 00006000 00:18 77464613     [your program]
    5653d983f000-5653d9860000 rw-p 00000000 00:00 0            [heap]
    7f8669b66000-7f866ff6c000 rw-p 00000000 00:00 0        *** MALLOC'D MEMORY
    7f866ff6c000-7f866ff79000 r--p 00000000 00:18 77146186     [shared libraries]
    7f866ff79000-7f8670013000 r-xp 0000d000 00:18 77146186     [shared libraries]
    ...
    
    Run Code Online (Sandbox Code Playgroud)
  • 创建第一个线程后(注意 VMA 的开始位置如何从 变为 ,7f8669b66000因为7f8669366000它的大小已增长):

    ...
    5653d8b82000-5653d8b83000 r--p 00005000 00:18 77464613     [your program]
    5653d8b83000-5653d8b84000 rw-p 00006000 00:18 77464613     [your program]
    5653d983f000-5653d9860000 rw-p 00000000 00:00 0            [heap]
    7f8669365000-7f8669366000 ---p 00000000 00:00 0        *** GUARD PAGE
    7f8669366000-7f866ff6c000 rw-p 00000000 00:00 0        *** THREAD STACK + MALLOC'D MEMORY
    7f866ff6c000-7f866ff79000 r--p 00000000 00:18 77146186     [shared libraries]
    7f866ff79000-7f8670013000 r-xp 0000d000 00:18 77146186     [shared libraries]
    ...
    
    Run Code Online (Sandbox Code Playgroud)

您可以清楚地看到,创建线程后,内核将两个内存区域(线程堆栈 + 您的 malloc 块)一起显示为单个 VMA,因为它们是连续的、匿名的并且具有相同的保护 ( ) rw

线程堆栈上方的保护页被视为单独的 VMA(它具有不同的保护),后续线程将mmap在其上方放置堆栈,因此它们不会影响原始内存区域的软脏位:

...
5653d8b82000-5653d8b83000 r--p 00005000 00:18 77464613     [your program]
5653d8b83000-5653d8b84000 rw-p 00006000 00:18 77464613     [your program]
5653d983f000-5653d9860000 rw-p 00000000 00:00 0            [heap]
7f8668363000-7f8668364000 ---p 00000000 00:00 0        *** GUARD PAGE
7f8668364000-7f8668b64000 rw-p 00000000 00:00 0        *** THREAD 3 STACK
7f8668b64000-7f8668b65000 ---p 00000000 00:00 0        *** GUARD PAGE
7f8668b65000-7f8669365000 rw-p 00000000 00:00 0        *** THREAD 2 STACK
7f8669365000-7f8669366000 ---p 00000000 00:00 0        *** GUARD PAGE
7f8669366000-7f866ff6c000 rw-p 00000000 00:00 0        *** THREAD 1 STACK + MALLOC'D MEMORY
7f866ff6c000-7f866ff79000 r--p 00000000 00:18 77146186     [shared libraries]
7f866ff79000-7f8670013000 r-xp 0000d000 00:18 77146186     [shared libraries]
...
Run Code Online (Sandbox Code Playgroud)

这就是为什么从第二个线程开始,您不会看到任何意外发生。