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

zzlelutf  于 2023-05-22  发布在  Linux
关注(0)|答案(1)|浏览(78)

我问这个问题的原因是,在测试Linux soft-dirty bit的行为时,我发现如果我创建一个线程而不触及任何内存,所有页面的soft-dirty bit将被设置为1(脏)。
比如主线程中的malloc(100MB),然后清理软脏位,然后创建一个只是休眠的线程。创建线程后,所有100 MB内存块的软脏位都设置为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);
}

我打印第一页的脏位,以及偏移量为50 * 1024 * 1024的页面。以下是发生的情况:

  1. malloc()之后的软脏位为1,这是预期的。
    1.在清洁软脏之后,它们变成0。
    1.创建一个线程,只是睡眠。
    1.检查脏位,100 MB区域中的所有页面(我没有打印所有页面的脏位,但我自己做了检查)现在都将软脏位设置为1。
    1.重新开始循环,现在行为是正确的,在创建额外的线程之后,软脏位保持为0。
    1.由于我执行了memset(),因此偏移量为0的页面的软脏位为1,而页面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

我认为线程创建只是将页面标记为处于“共享”状态,而不是修改它们,因此软脏位应该保持不变。显然,行为是不同的。所以我在想:创建一个线程是否会触发所有页面上的页面错误?因此,操作系统在处理页面错误时将所有页面的软脏位设置为1。
如果不是这样,为什么创建线程会使进程的所有内存页都变得“脏”呢?为什么只有第一个线程创建有这样的行为?
我希望我解释了这个问题,如果需要更多的细节,请让我知道,或者如果任何事情都没有意义。

sbtkgmzw

sbtkgmzw1#

所以,这是一种有趣和有趣的。你的具体情况,以及软脏位的行为,都是相当奇特的。没有页面错误发生,并且软脏位不是在所有内存页面上设置的,而是在其中的一些页面上设置的(通过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
    ...

正如你在上面看到的:
1.您的malloc()非常大,因此您将不会获得普通的堆块,而是通过mmap系统调用保留的专用内存区域。
1.当您创建线程时,库代码通过另一个mmapmprotect为线程设置堆栈。
Linux中的正常mmap行为是从进程创建时选择的mmap_base开始保留内存,每次减去请求的大小(除非显式请求特定地址,在这种情况下不考虑mmap_base)。因此,点1处的mmap将保留动态加载器Map的最后一个共享库正上方的页面,而点2处的mmap将保留点1处Map的页面正前方的页面。然后mprotect将第二个区域(除了第一页)标记为RW。
由于这些Map是连续的,都是匿名的,并且都具有相同的保护(RW),因此从内核的Angular 来看 * 这看起来像是一个大小增加了的单个内存区域 。实际上,内核将其视为单个VMA(vm_area_struct)。
现在,我们可以阅读from the kernel documentation关于软脏位的内容(注意我用粗体突出显示的部分):
虽然在大多数情况下,通过#PF-s跟踪内存更改已经足够了,但仍然存在一种情况,即我们可能会丢失软脏位-任务取消先前Map的内存区域,然后在完全相同的位置Map新的内存区域。当调用unmap时,内核在内部清除PTE值,包括软脏位。为了通知用户空间应用程序有关这种内存区域更新
*,内核总是将新的内存区域(和扩展的区域)标记为软脏**。
因此,为什么您会看到软脏位在清除后重新出现在初始malloc的内存块上,这是一个有趣的巧合:由线程堆栈的分配引起的包含它的内存区域(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]
...
  • 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]
...
  • 创建第一个线程后(注意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]
...

您可以清楚地看到,在创建线程之后,内核将两个内存区域(线程堆栈+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]
...

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

相关问题