如何防止在Windows临时删除关闭文件上打开的内存映射刷新到磁盘

16

更新2 / TL;DR

有没有什么方法可以防止在关闭打开的内存映射后,将 FILE_FLAG_DELETE_ON_CLOSE 的临时文件的脏页面刷新?

有。 如果您在文件创建后不需要对文件本身进行任何操作,并且实现了一些命名约定,则可以通过 this answer 中解释的策略实现此目的。

注意: 我仍然非常想知道为什么行为会因创建映射的方式和处理/取消映射的顺序而有如此大的差异。


我一直在研究一些策略,以实现在Windows上使用“内存块”链允许增长和缩小其承诺容量的进程间共享内存数据结构。
其中一种可能的方法是使用“页面文件支持的命名内存映射”作为块内存。这种策略的优点是可以使用“SEC_RESERVE”来保留大块内存地址空间,并使用“VirtualAlloc”和“MEM_COMMIT”逐步分配它。缺点是需要拥有“SeCreateGlobalPrivilege”权限才能允许在“Global\”名称空间中使用可共享的名称,以及所有提交的内存都会对系统提交费用产生贡献。
为了避免这些缺点,我开始研究使用临时文件支持的内存映射。即使用FILE_FLAG_DELETE_ON_CLOSE | FILE_ATTRIBUTE_TEMPORARY标志组合创建的文件上的内存映射。这似乎是一种推荐策略,根据例如this blog post,应该防止将映射的内存刷新到磁盘(除非内存压力导致脏映射页面被分页出去)。
然而,我注意到,在拥有进程退出之前关闭映射/文件句柄会导致脏页面刷新到磁盘。即使视图/文件句柄不是通过这些视图打开的,也不是这些视图在页面变脏之前打开的。
似乎改变处理顺序(即首先取消映射视图或首先关闭文件句柄)对启动磁盘刷新的时间有一定影响,但对刷新发生的事实没有影响。
所以我的问题是:
  • 是否有一种方法可以使用临时文件支持的内存映射,防止在关闭映射/文件时刷新脏页,考虑到一个进程中的多个线程/多个进程可能对这样的文件具有打开的句柄/视图?
  • 如果没有,观察到的行为的原因是什么?
  • 是否有我可能忽略的替代策略?

更新
一些额外的信息:在以下示例代码中运行“arena1”和“arena2”部分时,将它们在两个独立的进程中运行,其中“arena1”是创建共享内存区域的进程,“arena2”则打开它们。对于具有脏页的地图/块,观察到以下行为:

  • 如果在“arena1”进程中关闭视图之前关闭文件句柄,则会将每个这些块刷新到磁盘上,这似乎是一个(部分)同步过程(即它会阻塞处理线程几秒钟),无论“arena2”进程是否已启动。
  • 如果在视图之前关闭文件句柄,则仅对那些在“arena1”进程中关闭并且“arena2”进程仍然拥有对这些块的打开句柄的映射/块进行磁盘刷新,并且它们似乎是“异步”的,即不会阻塞应用程序线程。

请参考下面的(c++)示例代码,它允许在我的系统(x64,Win7)上重现该问题:

static uint64_t start_ts;
static uint64_t elapsed() {
    return ::GetTickCount64() - start_ts;
}

class PageArena {
public:
    typedef uint8_t* pointer;

    PageArena(int id, const char* base_name, size_t page_sz, size_t chunk_sz, size_t n_chunks, bool dispose_handle_first) :
        id_(id), base_name_(base_name), pg_sz_(page_sz), dispose_handle_first_(dispose_handle_first) {
        for (size_t i = 0; i < n_chunks; i++)
            chunks_.push_back(new Chunk(i, base_name_, chunk_sz, dispose_handle_first_));
    }
    ~PageArena() {
        for (auto i = 0; i < chunks_.size(); ++i) {
            if (chunks_[i])
                release_chunk(i);
        }
        std::cout << "[" << ::elapsed() << "] arena " << id_ << " destructed" << std::endl;
    }

    pointer alloc() {
        auto ptr = chunks_.back()->alloc(pg_sz_);
        if (!ptr) {
            chunks_.push_back(new Chunk(chunks_.size(), base_name_, chunks_.back()->capacity(), dispose_handle_first_));
            ptr = chunks_.back()->alloc(pg_sz_);
        }
        return ptr;
    }
    size_t num_chunks() {
        return chunks_.size();
    }
    void release_chunk(size_t ndx) {
        delete chunks_[ndx];
        chunks_[ndx] = nullptr;
        std::cout << "[" << ::elapsed() << "] chunk " << ndx << " released from arena " << id_ << std::endl;
    }

private:
    struct Chunk {
    public:
        Chunk(size_t ndx, const std::string& base_name, size_t size, bool dispose_handle_first) :
            map_ptr_(nullptr), tail_(nullptr),
            handle_(INVALID_HANDLE_VALUE), size_(0),
            dispose_handle_first_(dispose_handle_first) {

            name_ = name_for(base_name, ndx);
            if ((handle_ = create_temp_file(name_, size)) == INVALID_HANDLE_VALUE)
                handle_ = open_temp_file(name_, size);
            if (handle_ != INVALID_HANDLE_VALUE) {
                size_ = size;
                auto map_handle = ::CreateFileMappingA(handle_, nullptr, PAGE_READWRITE, 0, 0, nullptr);
                tail_ = map_ptr_ = (pointer)::MapViewOfFile(map_handle, FILE_MAP_ALL_ACCESS, 0, 0, size);
                ::CloseHandle(map_handle); // no longer needed.
            }
        }
        ~Chunk() {
            if (dispose_handle_first_) {
                close_file();
                unmap_view();
            } else {
                unmap_view();
                close_file();
            }
        }
        size_t capacity() const {
            return size_;
        }
        pointer alloc(size_t sz) {
            pointer result = nullptr;
            if (tail_ + sz <= map_ptr_ + size_) {
                result = tail_;
                tail_ += sz;
            }
            return result;
        }

    private:
        static const DWORD kReadWrite = GENERIC_READ | GENERIC_WRITE;
        static const DWORD kFileSharing = FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE;
        static const DWORD kTempFlags = FILE_ATTRIBUTE_NOT_CONTENT_INDEXED | FILE_FLAG_DELETE_ON_CLOSE | FILE_ATTRIBUTE_TEMPORARY;

        static std::string name_for(const std::string& base_file_path, size_t ndx) {
            std::stringstream ss;
            ss << base_file_path << "." << ndx << ".chunk";
            return ss.str();
        }
        static HANDLE create_temp_file(const std::string& name, size_t& size) {
            auto h = CreateFileA(name.c_str(), kReadWrite, kFileSharing, nullptr, CREATE_NEW, kTempFlags, 0);
            if (h != INVALID_HANDLE_VALUE) {
                LARGE_INTEGER newpos;
                newpos.QuadPart = size;
                ::SetFilePointerEx(h, newpos, 0, FILE_BEGIN);
                ::SetEndOfFile(h);
            }
            return h;
        }
        static HANDLE open_temp_file(const std::string& name, size_t& size) {
            auto h = CreateFileA(name.c_str(), kReadWrite, kFileSharing, nullptr, OPEN_EXISTING, kTempFlags, 0);
            if (h != INVALID_HANDLE_VALUE) {
                LARGE_INTEGER sz;
                ::GetFileSizeEx(h, &sz);
                size = sz.QuadPart;
            }
            return h;
        }
        void close_file() {
            if (handle_ != INVALID_HANDLE_VALUE) {
                std::cout << "[" << ::elapsed() << "] " << name_ << " file handle closing" << std::endl;
                ::CloseHandle(handle_);
                std::cout << "[" << ::elapsed() << "] " << name_ << " file handle closed" << std::endl;
            }
        }
        void unmap_view() {
            if (map_ptr_) {
                std::cout << "[" << ::elapsed() << "] " << name_ << " view closing" << std::endl;
                ::UnmapViewOfFile(map_ptr_);
                std::cout << "[" << ::elapsed() << "] " << name_ << " view closed" << std::endl;
            }
        }

        HANDLE          handle_;
        std::string     name_;
        pointer         map_ptr_;
        size_t          size_;
        pointer         tail_;
        bool            dispose_handle_first_;
    };

    int id_;
    size_t pg_sz_;
    std::string base_name_;
    std::vector<Chunk*> chunks_;
    bool dispose_handle_first_;
};

static void TempFileMapping(bool dispose_handle_first) {
    const size_t chunk_size = 256 * 1024 * 1024;
    const size_t pg_size = 8192;
    const size_t n_pages = 100 * 1000;
    const char*  base_path = "data/page_pool";
    start_ts = ::GetTickCount64();

    if (dispose_handle_first)
        std::cout << "Mapping with 2 arenas and closing file handles before unmapping views." << std::endl;
    else
        std::cout << "Mapping with 2 arenas and unmapping views before closing file handles." << std::endl;
    {
        std::cout << "[" << ::elapsed() << "] " << "allocating " << n_pages << " pages through arena 1." << std::endl;
        PageArena arena1(1, base_path, pg_size, chunk_size, 1, dispose_handle_first);
        for (size_t i = 0; i < n_pages; i++) {
            auto ptr = arena1.alloc();
            memset(ptr, (i + 1) % 256, pg_size); // ensure pages are dirty.
        }
        std::cout << "[" << elapsed() << "] " << arena1.num_chunks() << " chunks created." << std::endl;
        {
            PageArena arena2(2, base_path, pg_size, chunk_size, arena1.num_chunks(), dispose_handle_first);
            std::cout << "[" << ::elapsed() << "] arena 2 loaded, going to release chunks 1 and 2 from arena 1" << std::endl;
            arena1.release_chunk(1);
            arena1.release_chunk(2);
        }
    }
}

请参考这个 gist,其中包含运行上述代码的输出和运行 TempFileMapping(false)TempFileMapping(true) 时的 系统空闲内存 和磁盘活动的截图链接。

2
请勿添加无关标签以引起您的问题的注意。只使用与您的问题实际相关的标签。标签在这里实际上具有含义。不要滥用它们。 - Ken White
1
@Alex:请注意,服务始终具有“SeCreateGlobalPrivilege”(除非程序员有意删除它)。我不确定提交费用是否应该成为问题;实际上,最好使用操作系统的虚拟内存而不是创建自己的内存。(换句话说,隐藏您的内存使用情况并不一定是一件好事。) - Harry Johnston
2
@Alex:我认为这是不可能的,或者至少我不相信有一个受支持的解决方案。但问题是合理的,应该保持开放 - 可能有其他人知道得更好。 (如果您想尝试,可以尝试使用MEM_RESET,OfferVirtualMemory或DiscardVirtualMemory。) - Harry Johnston
你的临时文件分配大小超出了可用内存吗? - Simon Mourier
UnmapViewOfFile的注释非常详细(https://msdn.microsoft.com/en-us/library/windows/desktop/aa366882.aspx)。此外,CRT等效于FILE_ATTRIBUTE_TEMPORARY是_O_SHORT_LIVED(https://msdn.microsoft.com/en-us/library/z0kc8e3z.aspx),它指出“将文件创建为临时文件,并且`如果可能`不会刷新到磁盘”。 “如果可能”意味着您不能指望特定的行为。在您所做的事情中没有错,只是在我看来不完全符合您的期望。 - Simon Mourier
显示剩余9条评论
2个回答

3
在没有任何提供更多见解或解决所述问题的答案的情况下,赏金期限已到,我决定深入挖掘并尝试使用多个操作组合和顺序进行实验。作为结果,我相信我已经找到了一种方法,可以通过临时关闭删除的文件在进程之间共享内存映射,并在它们被关闭时不被刷新到磁盘上。
基本思想涉及在创建一个具有可以在调用OpenFileMapping中使用的映射名称的临时文件时创建内存映射:
// build a unique map name from the file name.
auto map_name = make_map_name(file_name); 

// Open or create the mapped file.
auto mh = ::OpenFileMappingA(FILE_MAP_ALL_ACCESS, false, map_name.c_str());
if (mh == 0 || mh == INVALID_HANDLE_VALUE) {
    // existing map could not be opened, create the file.
    auto fh = ::CreateFileA(name.c_str(), kReadWrite, kFileSharing, nullptr, CREATE_NEW, kTempFlags, 0);
    if (fh != INVALID_HANDLE_VALUE) {
        // set its size.
        LARGE_INTEGER newpos;
        newpos.QuadPart = desired_size;
        ::SetFilePointerEx(fh, newpos, 0, FILE_BEGIN);
        ::SetEndOfFile(fh);
        // create the map
        mh = ::CreateFileMappingA(mh, nullptr, PAGE_READWRITE, 0, 0, map_name.c_str());
        // close the file handle
        // from now on there will be no accesses using file handles.
        ::CloseHandle(fh);
    }
}

因此,文件句柄仅在文件新建时使用,并在创建映射后立即关闭,而映射句柄本身保持打开状态,以允许打开映射而不需要访问文件句柄。请注意,这里存在竞争条件,在任何“真实代码”中我们都需要处理它(以及添加良好的错误检查和处理)。
因此,如果我们获得了有效的映射句柄,我们可以创建视图:
auto map_ptr = MapViewOfFile(mh, FILE_MAP_ALL_ACCESS, 0, 0, 0);
if (map_ptr) {
    // determine its size.
    MEMORY_BASIC_INFORMATION mbi;
    if (::VirtualQuery(map_ptr, &mbi, sizeof(MEMORY_BASIC_INFORMATION)) > 0) 
        map_size = mbi.RegionSize;
}

在一段时间后关闭映射文件时,需要先关闭映射句柄再取消映射视图:

if (mh == 0 || mh == INVALID_HANDLE_VALUE) {
    ::CloseHandle(mh);
    mh = INVALID_HANDLE_VALUE;
}
if (map_ptr) {
    ::UnmapViewOfFile(map_ptr);
    map_ptr = 0;
    map_size = 0;
}

根据我目前所做的测试,这不会在关闭时导致将脏页刷新到磁盘中,问题解决了。至少部分地解决了,可能还存在跨会话映射名称共享问题。


1
有趣的是,虽然我不指望它在所有版本的Windows上都能像预期的那样工作。 (个人建议使用页面文件映射,但可能存在一些边缘情况。)跨会话问题可以通过确保服务始终负责创建映射来解决,只需要在添加同步时考虑到这一点即可。(请注意,您可以配置服务,使其无需管理员特权即可启动。) - Harry Johnston
@HarryJohnston 是的,这个结果有点出乎意料,我完全意识到如果不理解它为什么会表现出这样的行为,这可能是一种“不稳定”的策略。我对打开/关闭句柄和视图顺序变化时观察到的非常不同的刷新行为感到困惑。我希望未来有更多了解实现细节的人能够阐明这一点。 - Alex
1
@Alex 它实际上并不起作用,可以看到我的答案这里。问题在于脏页面仍然被写入文件,但由于文件被NTFS驱动程序切换为“关闭”状态,数据被简单地丢弃。如果出现内存压力,将使用零页替换映射中的随机页面。您也可以在我的答案中检查示例程序。 - user1143634

0
如果我理解正确的话,注释掉代码中的Arena2部分应该可以在不需要第二个进程的情况下重现问题。我已经尝试过这样做了:
  1. 为了方便,我将base_path编辑如下:

    char base_path[MAX_PATH];
    GetTempPathA(MAX_PATH, base_path);
    strcat_s(base_path, MAX_PATH, "page_pool");
    
  2. 我将n_pages = 1536 * 128编辑为使用1.5GB内存,与您的约800MB相比。
  3. 我已经分别测试了TempFileMapping(false)TempFileMapping(true),结果相同。
  4. 我已经测试过Arena2被注释和完整的情况,结果相同。
  5. 我已经在Win8.1 x64和Win7 x64上进行了测试,结果相差不到±10%。
  6. 在我的测试中,代码运行时间为2400ms ±10%,只有500ms ±10%用于解除分配。这显然不足以在低速静音HDD上刷新1.5GB。
所以,问题是,你观察到了什么?我建议你:
  1. 提供比较的时间
  2. 使用另一台电脑进行测试,注意排除软件问题,如“相同的杀毒软件”
  3. 验证你是否没有经历RAM短缺。
  4. 使用xperf查看冻结期间发生了什么。

更新 我已经在另一台Win7 x64上进行了测试,全程用时890ms,430ms用于dealloc。我已经研究了你的结果,非常可疑的是,在你的机器上每次几乎都花费了4000ms的时间来冻结。我认为这不可能是一个简单的巧合。现在很明显问题与你正在使用的特定机器有关。因此,我的建议是:

  1. 如上所述,请在另一台计算机上进行测试。
  2. 如上所述,请使用 XPerf,它将允许您查看在冻结期间用户模式和内核模式中发生的确切情况(我真的怀疑中间存在某些非标准驱动程序)。
  3. 尝试调整页面数量,并查看它对冻结持续时间的影响。
  4. 尝试将文件存储在同一台计算机上不同的磁盘驱动器上。

请参考此gist,其中包含了运行本问题所附代码的输出结果,以及一个详细讨论这些结果并链接到运行TempFileMapping(false)TempFileMapping(true)时的系统空闲内存和磁盘活动的屏幕截图的部分。 - Alex
我已经更新了我的答案,并提出了进一步的观察和实验建议。 - Codeguard

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