לדלג לתוכן

מטמון וגישה ללא אגירה

נניח שקראנו קובץ פעמיים והפעם השנייה הייתה מהירה בהרבה. ייתכן שהכונן לא השתנה כלל; הנתונים כבר נמצאים בזיכרון. כדי לפרש מדידת I/O צריך לדעת אם אנחנו מודדים את היישום, את מערכת הקבצים, את המטמון או את ההתקן.

בקריאה רגילה המערכת יכולה להשתמש ב־cache, להביא מידע מראש ולשמור דפים לשימוש חוזר. בכתיבה רגילה, הצלחת WriteFile יכולה להתייחס לקבלת הנתונים בשכבת המערכת לפני שהתמידו באמצעי האחסון. נלמד לבחור מצב גישה לפי חוזה ונתונים, ולא לפי ההנחה שעקיפת שכבה תמיד מהירה יותר.

גישה ללא buffering דורשת יישור

התוכנית הבאה פותחת קובץ לקריאה ללא buffering של נתוני הקובץ, שואלת על מאפייני האחסון ומנסה לקרוא יחידה מיושרת אחת. היא מיועדת לקובץ מקומי גדול דיו ולמערכת שתומכת בשאילתה:

#define _WIN32_WINNT 0x0A00
#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
#include <algorithm>
#include <iostream>
#include <malloc.h>
#include <memory>

struct Handle {
    HANDLE value;
    explicit Handle(HANDLE v) : value(v) {}
    ~Handle() { if (value != INVALID_HANDLE_VALUE) CloseHandle(value); }
    Handle(const Handle&) = delete;
    Handle& operator=(const Handle&) = delete;
};

int wmain(int argc, wchar_t** argv) {
    if (argc != 2) return 2;
    Handle file(CreateFileW(argv[1], GENERIC_READ, FILE_SHARE_READ,
        nullptr, OPEN_EXISTING, FILE_FLAG_NO_BUFFERING, nullptr));
    if (file.value == INVALID_HANDLE_VALUE) return 1;

    FILE_STORAGE_INFO storage{};
    if (!GetFileInformationByHandleEx(
            file.value, FileStorageInfo, &storage, sizeof(storage))) {
        std::cerr << "storage query: " << GetLastError() << '\n';
        return 1;
    }
    const DWORD logical = storage.LogicalBytesPerSector;
    const DWORD alignment = std::max(
        logical, storage.PhysicalBytesPerSectorForPerformance);
    if (logical == 0 || alignment == 0 ||
        (alignment & (alignment - 1)) != 0 ||
        alignment % logical != 0 || alignment > 1024 * 1024)
        return 1;

    LARGE_INTEGER length{};
    if (!GetFileSizeEx(file.value, &length)) return 1;
    if (length.QuadPart < alignment) {
        std::cerr << "use a file at least one aligned unit long\n";
        return 2;
    }

    std::unique_ptr<void, decltype(&_aligned_free)> buffer(
        _aligned_malloc(alignment, alignment), &_aligned_free);
    if (!buffer) return 1;
    DWORD transferred = 0;
    if (!ReadFile(file.value, buffer.get(), alignment,
                  &transferred, nullptr)) {
        std::cerr << "read: " << GetLastError() << '\n';
        return 1;
    }
    std::cout << "logical=" << logical
              << " alignment=" << alignment
              << " bytes=" << transferred << '\n';
}

הקוד בודק שהיישור מתאים למקצה ושגודל הקריאה הוא כפולה של היחידה הלוגית. נקודת ההתחלה היא אפס ולכן מיושרת. שאילתת המידע יכולה להיכשל בספקים מסוימים; אין להחליף אותה אוטומטית בהנחה של חמש מאות שנים עשר בתים.

הדוגמה אינה קורא כללי של סוף קובץ. כאשר הגודל אינו כפולה של יחידת הגישה, צריך לתכנן כיצד מבקשים יחידה מיושרת ומפרשים את מספר הבתים שהתקבל, בהתאם לחוזה ה־API וההתקן. שינוי אורך הקריאה לשארית לא מיושרת יכול להחזיר שגיאה.

כמה שכבות של cache

הדגל FILE_FLAG_NO_BUFFERING משנה את השימוש של המערכת באגירת נתוני הקובץ, אך אינו מבטיח שאין cache בבקר, בהתקן, במערכת מרוחקת או בשכבה וירטואלית. גם metadata יכול להישאר מטופל אחרת מנתוני הקובץ. לכן אין להסיק שהדגל לבדו הופך completion להבטחת עמידות לאחר אובדן חשמל.

מיפוי קובץ מתחבר למנגנוני מנהל הזיכרון ולדפי הקובץ. הוא אינו "קריאה בלי cache" רק משום שלא קראנו ReadFile. גישה לכתובת יכולה לגרום לתקלת דף, וכתיבה דרך view יכולה להפוך דף למלוכלך שיכתב מאוחר יותר. התצפית ברמת user mode מסתירה חלק מן העבודה המתבצעת בעקבותיה.

גם write-through ו־no-buffering הם בחירות שונות. הראשון עוסק במדיניות העברת כתיבות, והשני באופן אגירת הנתונים במסלול. יש לבדוק את שילוב הדגלים ואת התצורה בפועל. בפרק העמידות ננסח מה מותר לאשר ללקוח אחרי flush.

מדידה שאינה מוחקת את העולם

אין צורך לרוקן את כל cache המערכת או לשנות הגדרות של המחשב כדי לבדוק את התוכנית. אפשר לתעד במפורש הרצה ראשונה והרצה חוזרת, להשתמש בקבצי מעבדה שונים ולמדוד מספר חזרות. ההשוואה צריכה לשמור על אותו תוכן, אותו מספר בתים ואותה מדיניות completion.

מדידת זמן כוללת צריכה לכלול או להוציא פתיחה באופן עקבי. אם משווים מיפוי לקריאה, צריך לכלול את הגישה בפועל לדפים, ולא רק את יצירת ה־view. בדומה לכך, השוואת כתיבה שמסתיימת ב־WriteFile לכתיבה שמסתיימת ב־FlushFileBuffers משווה חוזי עמידות שונים.

נמדוד throughput לצד latency ובמיוחד אחוזונים גבוהים. תור גדול יכול לשפר קצב כולל ובו בזמן להגדיל זמן המתנה לבקשה בודדת. גם שימוש ב־CPU, מספר faults וגודל working set מספקים הסבר נוסף לתוצאה.

בחירה הנדסית

קריאה סדרתית גדולה יכולה ליהנות מ־prefetch ומ־cache, בעוד מערכת שמחזיקה cache ייעודי עשויה לבחור מסלול אחר. הבחירה תלויה בדפוס השימוש, במגבלת זיכרון ובהתנהגות האחסון. קוד מורכב של יישור וניהול buffers צריך להצדיק את עצמו במדידה ובחוזה.

הניסוי מלמד בעיקר לזהות את השכבה שאנחנו שולטים בה. בלי ההבחנה הזו אפשר להציג מספרים יפים שמודדים רק קריאה חוזרת מן הזיכרון, או להאשים את הכונן בזמן שהבעיה היא הקצאה והעתקה בתוכנית.

קריאה להעמקה

התיעוד של דרישות buffering ויישור ושל אגירת קבצים מגדיר את גבולות הדגלים. מאפייני מידע האחסון של קובץ צריכים להיקרא לפי משמעות כל שדה.