WriteFile ha restituito successo. Allora, dove sono i dati adesso?
La risposta è, quasi certamente, non sono ancora sul disco. Sono solo stati copiati nella cache in memoria. Per questo succede «l’avevo salvato, ma dopo l’interruzione di corrente era sparito»; per questo i benchmark di copia file segnano velocità fisicamente impossibili; e per questo i database chiamano scrupolosamente fsync.
La 4ª puntata della serie «Le profondità dell’I/O di Windows» è il Cache Manager, che sta in mezzo. Nella 2ª puntata si è scritto che «se è in cache, anche l’I/O asincrono completa in modo sincrono», e nella 1ª puntata si è lasciato come compito «la scorciatoia che non crea un IRP (Fast I/O)». Questa volta si recuperano tutti quei fili.
1. Prima di tutto, la conclusione
- La cache dei file di Windows è write-back. Le letture arrivano prima dalla cache file di sistema, e anche le scritture vanno prima lì. Il riflesso sul disco lo fa dopo il sistema operativo.1
- L’entità della cache è un file mapping. Il Cache Manager mappa tratti di 256 KB del file in slot dello spazio di indirizzi di sistema, e le letture e scritture con cache abilitata diventano «una copia in memoria da e verso quella vista» (capitolo 2).1
- Le scritture le insegue il lazy writer, ogni secondo. Il crash dell’app non fa perdere i dati, ma un’interruzione di corrente o un crash del sistema operativo fa perdere la cache dirty (capitolo 4).1
- Tre strumenti per «scrivere con certezza».
FlushFileBuffers(=Flush(true)in .NET),FILE_FLAG_WRITE_THROUGH,FILE_FLAG_NO_BUFFERING. Il flush a ogni scrittura frequente è inefficiente; la documentazione ufficiale indica la combinazione NO_BUFFERING+WRITE_THROUGH (capitolo 5).21 - NO_BUFFERING ha requisiti di allineamento. Dimensione e offset devono essere un multiplo intero della dimensione di settore, e anche l’indirizzo del buffer va allineato al confine di settore fisico. E anche con NO_BUFFERING i metadati continuano a essere messi in cache (sezione 5.3).31
- Vista mappata e cache condividono gli stessi dati. I file memory-mapped e l’I/O con cache ordinaria restano coerenti; rendere durevole una mappa è un procedimento a due passi:
FlushViewOfFile+FlushFileBuffers(capitolo 6).45 - Letture e scritture sincrone finite in cache possono fare a meno persino dell’IRP. La scorciatoia Fast I/O va dritta nel Cache Manager — è la risposta al compito della 1ª puntata (capitolo 7).6
Mappa della conoscenza di questo articolo
Il successo di WriteFile non significa persistenza su disco: la cache dei file di Windows copia prima nella vista della cache di sistema, e il riflesso su disco è inseguito ogni secondo dal lazy writer. In un’interruzione di corrente o in un crash del SO si perdono le pagine dirty non ancora scritte, quindi per i dati che si vogliono scrivere con certezza occorre scegliere tra FlushFileBuffers, FILE_FLAG_WRITE_THROUGH e FILE_FLAG_NO_BUFFERING.
flowchart LR
accTitle: Mappa della conoscenza del Cache Manager e della scrittura ritardata
accDescr: Diagramma che mostra le relazioni tra Cache Manager, cache write-back, vista della cache di sistema da 256 KB, lettura anticipata, scrittura ritardata del lazy writer, perdita delle pagine dirty in caso di interruzione di corrente, scrittura certa con FlushFileBuffers, FILE_FLAG_WRITE_THROUGH e FILE_FLAG_NO_BUFFERING, requisiti di allineamento, coerenza con i file mappati in memoria e Fast I/O
cache_manager["Cache Manager"]
write_behind_caching["Cache write-back (scrittura ritardata)"]
memory_mapped_file["File mapping (file mappato in memoria)"]
system_cache_view["Vista della cache di sistema (slot da 256 KB)"]
file_object["Oggetto file"]
lazy_writer["lazy writer (thread di scrittura ritardata)"]
unflushed_write_loss["Perdita di dati non ancora scritti"]
temp_file_attribute["FILE_ATTRIBUTE_TEMPORARY"]
dirty_page["Pagina dirty"]
power_loss["Interruzione di corrente o crash del SO"]
read_ahead["Lettura anticipata (read-ahead)"]
sequential_scan_hint["FILE_FLAG_SEQUENTIAL_SCAN"]
random_access_hint["FILE_FLAG_RANDOM_ACCESS"]
flushfilebuffers["FlushFileBuffers"]
write_through["FILE_FLAG_WRITE_THROUGH"]
no_buffering["FILE_FLAG_NO_BUFFERING"]
sector_alignment_requirement["Requisito di allineamento al settore"]
invalid_parameter_error["ERROR_INVALID_PARAMETER(87)"]
frequent_durable_write["Persistenza certa con scritture frequenti"]
flushviewoffile["FlushViewOfFile"]
fast_io["Fast I/O"]
procmon["Process Monitor (procmon.exe)"]
irp["IRP (pacchetto di richiesta I/O)"]
synchronous_io["I/O sincrono"]
cache_manager -->|"implementa"| write_behind_caching
write_behind_caching -->|"usa"| memory_mapped_file
cache_manager -->|"usa"| system_cache_view
system_cache_view -->|"usa"| memory_mapped_file
cache_manager -->|"usa"| file_object
cache_manager -->|"usa"| lazy_writer
lazy_writer -->|"automatizza"| write_behind_caching
lazy_writer -->|"mitiga"| unflushed_write_loss
temp_file_attribute -.->|"incompatibile con"| lazy_writer
write_behind_caching -->|"può causare"| dirty_page
dirty_page -->|"salvato in"| system_cache_view
power_loss -.->|"può causare"| unflushed_write_loss
cache_manager -.->|"previene"| unflushed_write_loss
cache_manager -->|"usa"| read_ahead
read_ahead -.->|"configurato da"| sequential_scan_hint
read_ahead -.->|"configurato da"| random_access_hint
flushfilebuffers -->|"previene"| unflushed_write_loss
write_through -->|"previene"| unflushed_write_loss
no_buffering -.->|"mitiga"| unflushed_write_loss
no_buffering -->|"richiede"| sector_alignment_requirement
sector_alignment_requirement -->|"previene"| invalid_parameter_error
no_buffering -->|"può causare"| invalid_parameter_error
flushfilebuffers -->|"sconsigliato per"| frequent_durable_write
no_buffering -->|"consigliato per"| frequent_durable_write
write_through -->|"consigliato per"| frequent_durable_write
no_buffering -->|"incompatibile con"| system_cache_view
flushviewoffile -->|"richiede"| memory_mapped_file
flushviewoffile -->|"deve precedere"| flushfilebuffers
fast_io -->|"verificato da"| procmon
fast_io -->|"richiede"| system_cache_view
fast_io -->|"incompatibile con"| irp
fast_io -->|"richiede"| synchronous_io
Nel diagramma, una linea continua indica una relazione che vale sempre e una linea tratteggiata indica una relazione condizionale (le condizioni sono nella spiegazione di ciascuna relazione nella pagina di dettaglio). L’elenco completo delle relazioni (in totale 32, con evidenza e livello di certezza) e le definizioni dei concetti principali sono raccolti nella pagina di dettaglio della mappa della conoscenza (in giapponese). Dati: JSON-LD / Turtle
2. L’identità della cache — Il file viene mappato in memoria
2.1. Slot da 256 KB e copie in memoria
Se si pensa alla cache dei file di Windows come a «un contenitore di blocchi disco», molti comportamenti restano inspiegabili. Il quadro corretto è questo: il Cache Manager mappa tratti di 256 KB del file in «slot» dello spazio di indirizzi di sistema, e le letture e scritture con cache abilitata vengono eseguite come copia in memoria tra quello slot e il buffer dell’app.1
flowchart TB
subgraph U["App (modalità utente)"]
BUF["Buffer dell'app<br/>(l'area passata a ReadFile/WriteFile)"]
end
subgraph S["Spazio di indirizzi di sistema"]
SLOT["Cache file di sistema<br/>slot che mappa un tratto di 256 KB del file"]
end
DISK[("File sul disco")]
BUF <-->|"ReadFile/WriteFile =<br/>copia in memoria da e verso lo slot"| SLOT
SLOT <-->|"Lettura al primo accesso e<br/>write-back successivo, per pagine"| DISK
Figura 1: Il quadro reale dell’I/O con cache abilitata. La «lettura e scrittura del file» vista dall’app, nella maggior parte dei casi, è solo una copia in memoria
Un punto in cui è facile sbagliarsi. 256 KB è la granularità della vista (del mapping), non significa che l’I/O su disco avvenga sempre in unità da 256 KB. Le pagine dentro lo slot vengono lette secondo necessità, e la quantità di I/O che va davvero sul disco cambia con la dimensione della richiesta e lo schema di accesso. Se il tratto si legge per la prima volta, per riempirlo nasce I/O su disco (qui l’IRP della 1ª puntata scende nello stack di storage inferiore). Se è già in cache, la lettura completa con la sola copia. Il «se c’è cache hit, anche un invio asincrono completa in modo sincrono» visto nel capitolo 5 della 2ª puntata è la manifestazione di questo movimento «le richieste a cui si può rispondere subito le si completa sul posto». Il rovescio: se la cache è abilitata ma la pagina non è in memoria, il trattamento del page fault non ha un meccanismo asincrono, e una lettura asincrona può essere trattata in modo sincrono — una trappola vista anch’essa nella 2ª puntata.7
2.2. L’identità di «la memoria libera è diminuita»
Se usare la cache e lo stato del read-ahead si gestiscono per modo di apertura (per oggetto file),1 il contenuto messo in cache in sé è condiviso per file (per flusso). Aprire lo stesso file più volte non crea cache separate: da qualsiasi handle si vede lo stesso contenuto in cache (è la base della coerenza del capitolo 6). La cache resta sotto la direzione del Cache Manager per tutto il tempo in cui Windows è in esecuzione.1 Se si copia un file grande o si legge e scrive in massa, la memoria fisica libera viene convertita sempre di più in cache. Anche se in Gestione attività la memoria libera sembra diminuire, gran parte è «memoria in Standby, usata in modo prezioso e pronta a essere restituita in tempi brevi se un’app la chiede». Perché nella diagnosi di carenza di memoria non si scambi questa distinzione, la pratica di osservazione è trattata anche in «Distinguere in .NET l’attesa del GC da un memory leak».
Questo movimento si può confermare sullo schermo. Aprendo Gestione attività > Prestazioni > Memoria, la barra in basso «Composizione della memoria» è divisa in In uso / Modificata / Standby / Libera. La maggior parte della cache dei file entra in questa Standby, e nel riquadro a destra è sommata come «Memorizzato nella cache». Per vederla più in dettaglio, Monitoraggio risorse > scheda Memoria mostra le stesse categorie con i volumi. Copiate un file da alcuni GB e poi riguardate: Standby aumenta e Libera diminuisce, mentre «In uso» quasi non cambia — cioè non è «la memoria è stata mangiata», ma «la memoria libera è stata usata come cache», visibile a occhio.
3. Read-ahead — Speculazione sulla lettura
Il Cache Manager, dagli schemi di accesso passati, legge in anticipo il tratto che sembra verrà letto dopo (read-ahead). In un file che si legge in ordine, i dati del seguito sono già in cache prima che l’app li chieda — è lo svelamento della velocità delle letture sequenziali. La quantità di read-ahead non è fissa: cambia secondo lo schema rilevato e la dimensione della richiesta.
flowchart LR
A["Cronologia delle richieste di lettura dell'app<br/>sta leggendo in ordine dall'inizio"]
D{"Il Cache Manager<br/>rileva lo schema"}
R["Read-ahead: il tratto successivo<br/>viene letto prima che sia richiesto<br/>(la quantità varia con schema e dimensione della richiesta)"]
H1["Suggerimento FILE_FLAG_SEQUENTIAL_SCAN<br/>= read-ahead più aggressivo"]
H2["Suggerimento FILE_FLAG_RANDOM_ACCESS<br/>= il read-ahead sarebbe sprecato, quindi si frena"]
A --> D
D --> R
H1 -.-> D
H2 -.-> D
Figura 2: Read-ahead. Oltre al rilevamento dello schema di accesso, si può dare un suggerimento con i flag di CreateFile
FileOptions.SequentialScan / RandomAccess della tabella di corrispondenza della 1ª puntata sono suggerimenti a questo motore di read-ahead. Per un batch che «scorre tutto» il primo, per un accesso che segue un indice il secondo — flag con cui l’app racconta al sistema operativo un futuro che solo lei conosce: così il punto in cui usarli diventa chiaro.
4. Scrittura ritardata — Il significato del «successo» di WriteFile
4.1. Il lazy writer arriva ogni secondo
Dal lato scrittura è una cache write-back. WriteFile restituisce successo nel momento in cui ha copiato i dati nello slot, e il riflesso sul disco viene rimandato. Questa politica di «scrivere più tardi» è la scrittura ritardata (lazy writing).1
A occuparsi del riflesso è il lazy writer, che il Cache Manager avvia ogni secondo. Mette in coda per la scrittura un ottavo delle pagine non flushate di recente e, se c’è ancora molto da scrivere, ne aggiunge. I file temporanei creati con l’attributo FILE_ATTRIBUTE_TEMPORARY sono esclusi dal bersaglio di flush del lazy writer: scrivere ciò che sta per essere cancellato è solo spreco.1 Però è un suggerimento dato dall’attributo: se la memoria è sotto pressione possono comunque essere scritti indietro, e non si applica a un file che «ha solo un nome che sembra temporaneo».
sequenceDiagram
participant App as App
participant C as Cache di sistema
participant LW as lazy writer (avvio ogni secondo)
participant D as Disco
App->>C: WriteFile (dati)
Note over C: Copia nello slot e<br/>la pagina diventa dirty (non ancora scritta)
C-->>App: Subito torna TRUE
Note over App,C: Da qui fino al write-back è la «finestra pericolosa»<br/>con interruzione di corrente o crash del SO questi dati spariscono
LW->>C: Sceglie 1/8 delle pagine dirty
LW->>D: Le scrive indietro in blocco
Note over D: Qui per la prima volta diventa durevole
Figura 3: Scrittura ritardata. Il successo di WriteFile è «consegnato al sistema operativo», non «reso durevole»
4.2. Che cosa succede, e quanto sparisce
Precisiamo il senso della «finestra pericolosa». Il destino si divide secondo il tipo di guasto.
flowchart TB
W["Dati subito dopo il successo di WriteFile<br/>(pagina dirty in cache)"]
Q{"Che cosa è successo"}
A1["Il processo dell'app<br/>è andato in crash / è stato terminato"]
A2["Si è fermato l'intero SO<br/>(interruzione di corrente, schermo blu)"]
S["I dati restano<br/>la cache è del SO, quindi<br/>il lazy writer la scrive indietro come previsto"]
L["Le pagine dirty si perdono<br/>resta solo ciò che era già arrivato sul disco"]
W --> Q
Q --> A1
Q --> A2
A1 --> S
A2 --> L
Figura 4: Tipo di guasto e bivio della sopravvivenza. La cache non è «proprietà del processo», è «proprietà del sistema operativo»
- Se muore l’app, i dati non spariscono. Dal momento in cui la copia in cache è finita, il proprietario dei dati è il sistema operativo. «Subito dopo il salvataggio l’app è caduta, eppure il file era integro» è grazie a questo.
- Se muore l’intero sistema operativo, la parte dirty sparisce. La frequenza del flush è regolata come trade-off tra prestazioni e affidabilità, e la documentazione dice esplicitamente che «se avviene una perdita improvvisa di alimentazione, i dati in cache non scritti si perdono».1
Quindi la domanda di progetto di un’app aziendale è: «questi dati si possono perdere nell’istante di un’interruzione di corrente?» Qualche secondo di log, forse sì. Il record confermato di un ordine, no. Gli strumenti del capitolo successivo si usano solo su ciò che non si può perdere.
5. La cassetta degli attrezzi per «scritto con certezza»
5.1. FlushFileBuffers — Scrivere fino in fondo adesso
FlushFileBuffers scrive fino in fondo sul dispositivo i dati bufferizzati del file indicato. I metadati del file system sono sempre in cache, quindi per far arrivare con certezza anche i metadati serve un flush (o WRITE_THROUGH): anche questo va tenuto fermo.12 In .NET l’equivalente è FileStream.Flush(true) (Flush() da solo consegna al sistema operativo solo il buffer interno di .NET; la cache del sistema operativo resta com’è).8
Però la documentazione ufficiale conficca il chiodo in modo esplicito: chiamarlo a ogni scrittura è inefficiente. Se serve durabilità a ognuna di molte scritture, va usata la combinazione NO_BUFFERING+WRITE_THROUGH di cui sotto.2
5.2. FILE_FLAG_WRITE_THROUGH — Togliere solo il ritardo
Aprendo con FILE_FLAG_WRITE_THROUGH, la scrittura va anche in cache e, senza aspettare il lazy writer, viene scritta subito anche sul disco.1 Il punto è che le letture continuano a beneficiare della cache: è la risposta diretta a «le letture restino veloci, si tolga solo il ritardo delle scritture».
5.3. FILE_FLAG_NO_BUFFERING — Senza passare dalla cache
FILE_FLAG_NO_BUFFERING toglie la cache di sistema stessa da letture e scritture. Ogni scambio diventa I/O verso il dispositivo disco, senza passare dalla cache.1 Però si può aggirare solo la cache di sistema di Windows; come in figura 5, la cache di scrittura dentro il dispositivo è un altro strato. Se si vuole anche la tenuta a un’interruzione di corrente, restano necessari la combinazione con WRITE_THROUGH o FlushFileBuffers. È uno strumento per trasferimenti in massa di grandi volumi e per motori di database che gestiscono da sé i buffer, ma il patto è severo.3
- Dimensione e offset nel file di letture e scritture devono essere un multiplo intero della dimensione di settore del volume (con settore da 512 byte: 512, 1024, 1536…).
- Anche l’indirizzo del buffer è allineato alla dimensione di settore fisico (serve attenzione anche ai dischi «Advanced Format» con settore fisico da 4096 byte).
- E comunque i metadati continuano a essere messi in cache, quindi per la durabilità completa servono la combinazione con WRITE_THROUGH o
FlushFileBuffers.12
Questo «patto» è il primo punto in cui cadono quelli che pensavano di cavarsela aggiungendo solo il flag. Letture e scritture senza rispettare l’allineamento falliscono con ERROR_INVALID_PARAMETER (87). I tre punti da rispettare.3
| Che cosa allineare | Condizione | Come soddisfarla |
|---|---|---|
| Dimensione di lettura e scrittura | Multiplo intero della dimensione di settore del volume | Prendere lpBytesPerSector da GetDiskFreeSpace e arrotondare a un suo multiplo |
| Offset nel file | Idem (anche quando si specifica Offset in OVERLAPPED) |
Avanzare a passi multipli della dimensione di settore |
| Indirizzo del buffer | Allineato alla dimensione di settore fisico | Allocare con VirtualAlloc (restituisce un’area allineata al confine di pagina, di solito 4096 byte) |
Il terzo è quello che si trascura di più. L’indirizzo restituito da malloc, new o da un array C# non garantisce l’allineamento al confine di settore. Usando VirtualAlloc, che alloca sul confine di pagina, si soddisfa insieme anche il requisito dei dischi «Advanced Format» con settore fisico da 4096 byte. La forma minima è questa.
// C++ / Win32. La gestione degli errori è ridotta al minimo
DWORD sectorsPerCluster = 0, bytesPerSector = 0, freeClusters = 0, totalClusters = 0;
if (!GetDiskFreeSpaceW(L"C:\\", §orsPerCluster, &bytesPerSector,
&freeClusters, &totalClusters))
{
return GetLastError();
}
// L'unità di lettura/scrittura è un multiplo intero della dimensione di settore (qui circa 1 MiB)
const DWORD chunk = (1024 * 1024 / bytesPerSector) * bytesPerSector;
// Il buffer è un'area allineata al confine di pagina (malloc/new non lo garantiscono)
BYTE* buffer = static_cast<BYTE*>(
VirtualAlloc(nullptr, chunk, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE));
if (buffer == nullptr) { return GetLastError(); }
HANDLE h = CreateFileW(L"C:\\temp\\big.bin", GENERIC_READ, FILE_SHARE_READ, nullptr,
OPEN_EXISTING, FILE_FLAG_NO_BUFFERING, nullptr);
if (h == INVALID_HANDLE_VALUE)
{
// GetLastError restituisce il risultato della «chiamata Win32 precedente». Se si
// chiama prima VirtualFree, il motivo del fallimento di CreateFileW (accesso
// negato, percorso assente ecc.) viene sovrascritto dal risultato della pulizia,
// e torna solo un codice di cui non si capisce la causa
const DWORD err = GetLastError();
VirtualFree(buffer, 0, MEM_RELEASE);
return err;
}
// Avanzando ogni volta di chunk byte, restano allineati sia la dimensione sia l'offset
DWORD read = 0;
while (ReadFile(h, buffer, chunk, &read, nullptr) && read > 0)
{
// Trattare i primi read byte di buffer
// (in fondo al file read < chunk. È normale)
}
CloseHandle(h);
VirtualFree(buffer, 0, MEM_RELEASE);
Inoltre, in FileOptions di .NET non c’è un valore corrispondente a FILE_FLAG_NO_BUFFERING. Se serve davvero, bisognerà chiamare CreateFile in modo diretto, e anche in quel caso i requisiti di allineamento di sopra vanno rispettati da sé. L’ordine di valutazione è «NO_BUFFERING perché gestisco io i buffer», non «NO_BUFFERING perché voglio più velocità».
5.4. Come scegliere quale usare
flowchart TB
A["Buffer dell'app"]
B["Cache file di sistema<br/>(pagine dirty)"]
C["Cache dentro il dispositivo disco"]
D[("Supporto di registrazione non volatile")]
A -->|"WriteFile di default: il successo torna arrivati qui"| B
B -->|"lazy writer (ogni secondo) / WRITE_THROUGH (immediato)"| C
C -->|"Tempi del dispositivo /<br/>FlushFileBuffers chiede di scrivere fino in fondo"| D
A -.->|"NO_BUFFERING salta la cache e va dritto"| C
Figura 5: Gli strati dei dati e fino a dove spinge ciascun strumento. Attenzione anche all’ultimo strato, «la cache dentro il dispositivo disco»
| Metodo | Che cosa succede | Dove sta bene |
|---|---|---|
| Default (cache abilitata) | Completa con la copia in cache. Il riflesso è del lazy writer | Quasi tutto l’I/O su file |
FlushFileBuffers / Flush(true) |
Scrive fino in fondo i dati di quel momento + i metadati | Conferma a una tappa (commit di transazione ecc.) |
FILE_FLAG_WRITE_THROUGH |
A ogni scrittura, subito sul disco (le letture dalla cache) | Log / journal che non si può perdere |
FILE_FLAG_NO_BUFFERING (+WRITE_THROUGH) |
Senza passare dalla cache. Ci sono requisiti di allineamento | Buffer propri, I/O in massa |
L’ordine di scelta è a due passi: prima si decide quante cose si possono perdere nell’istante di un’interruzione di corrente, poi si verifica quanto si può diventare lenti per quello. Non si scorre la tabella dall’alto in basso: si segue questo bivio.
flowchart TB
S["Si sta per scrivere questi dati"]
Q1{"Nell'istante di interruzione di corrente<br/>o schermo blu si può perdere?"}
A0["Lasciare il default (cache abilitata)<br/>il più veloce. Quasi tutto l'I/O sta qui"]
Q2{"Ciò che non si può perdere<br/>è «una tappa» o «ogni record»?"}
A1["Alla tappa FlushFileBuffers<br/>in .NET Flush(true)<br/>costo: solo l'attesa alla tappa"]
Q3{"Gestite voi i buffer e<br/>soddisfate i requisiti di allineamento del 5.3?"}
A2["FILE_FLAG_WRITE_THROUGH<br/>a ogni scrittura, subito sul disco<br/>le letture restano veloci via cache"]
A3["FILE_FLAG_NO_BUFFERING<br/>+ FILE_FLAG_WRITE_THROUGH<br/>la forma di «durabilità frequente» indicata dalla documentazione"]
S --> Q1
Q1 -->|"si può<br/>(log degli ultimi secondi ecc.)"| A0
Q1 -->|"non si può"| Q2
Q2 -->|"una tappa<br/>(conferma di una transazione ecc.)"| A1
Q2 -->|"ogni record"| Q3
Q3 -->|"no (app ordinaria)"| A2
Q3 -->|"sì (motore di DB ecc.)"| A3
Figura 6: Come scegliere lo strumento. Il primo bivio è il requisito di affidabilità, il secondo il costo che si può pagare. Non c’è il percorso «FlushFileBuffers a ogni record» perché, come visto in 5.1, la documentazione ufficiale lo considera inefficiente
Anche i pattern pratici, tre soli.
- «Scrivere su un file temporaneo → flush → rinomina» è la regola per non lasciare un file a metà. Prima si scrive fino in fondo il contenuto, poi si conferma col nome — questo passaggio atomico è trattato in dettaglio in «Fondamenti di mutua esclusione per integrazione basata su file - Best practice per lock file e claim atomici».
- Lasciarlo alla base di dati è anch’esso un progetto a pieno titolo. Come SQLite costruisce la durabilità con WAL e flush è in «Usare SQLite da C# in un’app aziendale — Modalità WAL, mutua esclusione, protezione dalla corruzione e quando usare EF Core». L’opzione «non scrivere da sé la strategia di flush» è sempre sul tavolo.
- Nei benchmark, sospettare la cache. Una misura «la lettura è troppo veloce» di solito sta misurando cache hit dal secondo passaggio in poi. Il modo di misurare è raccolto in «Riflessioni sul benchmark tra versioni di un programma Windows».
E non dimenticate l’ultimo strato della figura 5 — la cache dentro il dispositivo disco. FlushFileBuffers chiede di scrivere fino in fondo anche lì, ma su chiavette USB e dischi esterni entra in gioco la politica di cache di scrittura del dispositivo («Rimozione rapida» e «Prestazioni migliori»). Il trattamento dei dispositivi rimovibili è anche in «Come trattare i dispositivi USB da un’app Windows — Scelta tra COM virtuale, HID, WinUSB e SDK dedicato».
6. Coerenza con i file memory-mapped
Nella 1ª puntata, sentendo «l’entità della cache è un file mapping», qualcuno avrà pensato: allora la vista che mappo io con MapViewOfFile e la cache di ReadFile/WriteFile litigano?
No. Perché stanno sullo stesso meccanismo. L’oggetto di file mapping è appoggiato sul file, e l’espulsione di una pagina si esegue come write-back verso il file. Anche se più processi creano viste dello stesso file locale, il contenuto visibile è coerente (coherent).4
flowchart TB
subgraph P1["Spazio di indirizzi del processo A"]
V1["Vista di MapViewOfFile"]
end
subgraph SYS["Spazio di indirizzi di sistema"]
SC["Vista del Cache Manager<br/>(lo slot usato da ReadFile/WriteFile)"]
end
PAGES["Le stesse pagine fisiche<br/>(memoria con backing sul file)"]
DISK[("File sul disco")]
V1 --> PAGES
SC --> PAGES
PAGES --> DISK
NB["L'I/O con FILE_FLAG_NO_BUFFERING<br/>è fuori da questa condivisione (dritto sul disco)"]
NB -.-> DISK
Figura 7: Sia la vista mappata sia la cache guardano le stesse «pagine con backing sul file». Fuori dal cerchio c’è solo NO_BUFFERING
Due punti di attenzione.
- L’I/O con
FILE_FLAG_NO_BUFFERINGè fuori da questa coerenza. Letture e scritture che non passano dalla cache non vengono confrontate col contenuto visto via vista mappata / cache. Se si mescolano, la coerenza va tenuta da sé. - Rendere durevole una vista mappata è un procedimento a due passi.
FlushViewOfFileinizia la scrittura delle pagine dirty dell’intervallo, ma non scrive i metadati e non attende il completamento fisico dalla cache del dispositivo disco. Perché arrivi con certezza, dopoFlushViewOfFilesi chiamaFlushFileBuffers.5
La pratica del file mapping come memoria condivisa (condivisione con nome, sincronizzazione, pattern di incidente) è trattata in «Le trappole della memoria condivisa e le best practice operative».
7. Fast I/O — Si recupera il compito della 1ª puntata
Prima in due righe. Il Fast I/O è una scorciatoia preparata per letture e scritture sincrone verso un file che è già in cache; scambia i dati direttamente con la cache senza costruire un IRP (I/O Request Packet: il contenitore con cui il kernel passa una richiesta a un driver). Che nella colonna Operation di Procmon si mescolino IRP_MJ_READ e FASTIO_READ è la differenza tra se quella stessa «lettura» è passata per il percorso ordinario o per la scorciatoia.
Nella sezione 5.2 della 1ª puntata si è scritto «non tutto l’I/O diventa un IRP». Ecco la verifica.
Per un file in cache, letture e scritture, come si sa, si risolvono con una copia in memoria con la cache, senza arrivare a costruire un IRP e farlo scorrere nello stack di dispositivi. Perciò Windows, per l’I/O sincrono verso un file in cache, ha preparato la scorciatoia Fast I/O: non crea un IRP, chiama direttamente i «punti di ingresso Fast I/O» del file system e copia direttamente dal Cache Manager.6 Se il Fast I/O non può trattare (non è in cache, c’entra un lock, interviene un filtro ecc.), si torna sul percorso IRP ordinario. Si noti che è un percorso veloce per richieste sincrone, non «cache hit = sempre Fast I/O». Le operazioni di un handle asincrono (FILE_FLAG_OVERLAPPED), anche quando completano sul posto dalla cache (capitolo 5 della 2ª puntata), a volte vengono trattate sul percorso IRP.
flowchart TB
REQ["Letture e scritture sincrone su un handle con cache abilitata"]
Q{"Il Fast I/O può trattarlo<br/>(è già in cache ecc.)"}
FAST["Fast I/O<br/>copia diretta con la cache, senza creare IRP<br/>in Procmon compare come FASTIO_"]
IRP["Percorso ordinario<br/>si costruisce un IRP e si manda nello stack di dispositivi<br/>(il mondo della figura 6 della 1ª puntata)"]
REQ --> Q
Q -->|può| FAST
Q -->|non può| IRP
Figura 8: Il bivio del Fast I/O. Per questo in Procmon si vedono mescolati FASTIO_READ e IRP_MJ_READ
Perché nell’osservazione Procmon del capitolo 7 della 1ª puntata si mescolavano le righe FASTIO_, si spiega così. Per una lettura sincrona con cache hit, persino l’IRP è un lusso. L’esistenza di questo percorso influisce anche sui driver filtro della 6ª puntata (i minifilter possono intercettare anche il Fast I/O).
8. Conclusione
- La cache dei file di Windows è write-back, e l’entità è il mapping di tratti di 256 KB del file. Letture e scritture con cache abilitata diventano una copia in memoria con lo slot.1
- Le letture le anticipa il read-ahead, e
SequentialScan/RandomAccessne sono i suggerimenti.1 - Le scritture le insegue il lazy writer ogni secondo. Se muore l’app i dati restano, se muore l’intero sistema operativo sparisce solo la parte dirty. La domanda di progetto è «questi dati si possono perdere nell’istante di un’interruzione di corrente?».1
- Gli strumenti per scrivere con certezza sono
FlushFileBuffers(conferma a una tappa) /WRITE_THROUGH(a ogni scrittura) /NO_BUFFERING(senza passare dalla cache + requisiti di allineamento). Il flush ogni volta è inefficiente; per la durabilità frequente la raccomandazione ufficiale è la combinazione NO_BUFFERING+WRITE_THROUGH. Attenzione anche al fatto che i metadati sono sempre in cache.231 - Vista mappata e cache condividono le stesse pagine e restano coerenti. Fuori dal cerchio c’è solo NO_BUFFERING. Rendere durevole una mappa è un procedimento a due passi:
FlushViewOfFile+FlushFileBuffers.45 - Letture e scritture sincrone con cache hit omettono persino l’IRP via Fast I/O. È l’identità di
FASTIO_visto in Procmon nella 1ª puntata.6
Il seguito è la 5ª puntata «Le profondità dell’I/O di Windows (5ª puntata) — Struttura interna di NTFS: capire il file system dall’MFT». Finora il file è stato trattato come «offset e sequenza di byte»; da qui si scende a come NTFS dispone i dati — MFT, flussi di dati multipli, journal, hard link — verso la struttura statica sul disco.
Articoli correlati
- Le profondità dell’I/O di Windows (1ª puntata) — Ogni lettura e scrittura diventa un IRP: il quadro d’insieme del sistema I/O
- Le profondità dell’I/O di Windows (2ª puntata) — I/O sincrono e asincrono: il vero significato di OVERLAPPED
- Le profondità dell’I/O di Windows (3ª puntata) — Porte di completamento I/O (IOCP) e pool di thread .NET: lo scantinato di async/await
- Fondamenti di mutua esclusione per integrazione basata su file - Best practice per lock file e claim atomici
- Le trappole della memoria condivisa e le best practice operative
- Usare SQLite da C# in un’app aziendale — Modalità WAL, mutua esclusione, protezione dalla corruzione e quando usare EF Core
- Riflessioni sul benchmark tra versioni di un programma Windows
- Come trattare i dispositivi USB da un’app Windows — Scelta tra COM virtuale, HID, WinUSB e SDK dedicato
Aree di consulenza correlate
KomuraSoft LLC si occupa della progettazione e dell’indagine dei guasti dell’I/O su file delle app aziendali Windows, del tipo «i dati che avrei dovuto aver salvato sono spariti» o «la scrittura su file è lenta / sospettosamente veloce».
- Sviluppo di applicazioni Windows
- Analisi dei bug e delle cause
- Riutilizzo di asset esistenti e supporto alla migrazione
- Contattaci
Riferimenti
-
Microsoft Learn, File Caching. Sul fatto che Windows per default mette in cache i dati dei file, che le letture avvengono dalla cache file di sistema e che anche le scritture vanno in cache — cache write-back; sul fatto che la cache è gestita per oggetto file e opera sotto la direzione del Cache Manager; sul fatto che la politica di ritardare la scrittura su disco tenendo i dati in cache si chiama lazy writing; sul fatto che in lettura un tratto di 256 KB viene letto in uno slot da 256 KB dello spazio di indirizzi di sistema e il processo utente copia i dati da e verso quello slot; sul fatto che il Cache Manager avvia ogni secondo il lazy writer e mette in coda di scrittura su disco un ottavo delle pagine non flushate di recente, aggiungendone se serve; sul fatto che i file temporanei non vengono flushati; sul fatto che in un guasto improvviso di sistema come una perdita di alimentazione i dati in cache non scritti si perdono; sul fatto che anche disabilitando la cache con FILE_FLAG_NO_BUFFERING i metadati del file possono restare in cache; sul fatto che con FILE_FLAG_WRITE_THROUGH i dati vengono scritti anche in cache e, senza il ritardo del lazy writer, subito sul disco; sul fatto che i metadati del file system sono sempre in cache, quindi per la durabilità dei metadati servono un flush o FILE_FLAG_WRITE_THROUGH. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19
-
Microsoft Learn, FlushFileBuffers function. Sul fatto che WriteFile di solito scrive in un buffer interno e il sistema operativo lo versa periodicamente sul disco; sul fatto che FlushFileBuffers versa sul dispositivo tutte le informazioni bufferizzate del file indicato; sul fatto che chiamarlo a ognuna di molte scritture è inefficiente e che le app che hanno bisogno di durabilità di dati importanti con scritture frequenti dovrebbero usare I/O non bufferizzato tramite FILE_FLAG_NO_BUFFERING e FILE_FLAG_WRITE_THROUGH; sul fatto che chiamandolo su un handle di volume (con privilegi di amministratore) si possono flushare tutti i file aperti su quel volume. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, File Buffering. Sui requisiti di accesso a un file aperto con FILE_FLAG_NO_BUFFERING: dimensione di lettura e scrittura e offset nel file (compresa la specifica via OVERLAPPED) devono essere un multiplo intero della dimensione di settore del volume; l’indirizzo del buffer di lettura e scrittura deve essere allineato alla dimensione di settore fisico; serve attenzione ai dispositivi Advanced Format con settore fisico da 4.096 byte. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, File Mapping. Sul fatto che un oggetto di file mapping è appoggiato su un file su disco e che lo swap-out di una pagina si esegue come scrittura delle modifiche nel file; sul fatto che, quando più processi creano viste di un file locale dallo stesso oggetto di file mapping, i dati sono coerenti (lo stesso contenuto del file su disco). ↩ ↩2 ↩3
-
Microsoft Learn, FlushViewOfFile function. Sul fatto che FlushViewOfFile inizia la scrittura su disco delle pagine dirty nell’intervallo della vista mappata; sul fatto che questa funzione non flusha i metadati del file e non attende il completamento della scrittura fisica dalla cache disco hardware; sul fatto che per scrivere fino in fondo, fisicamente, tutte le pagine dirty e i metadati, dopo FlushViewOfFile si dovrebbe chiamare FlushFileBuffers. ↩ ↩2 ↩3
-
Microsoft Learn, IRPs Are Different From Fast I/O. Sul fatto che il Fast I/O è un percorso veloce di I/O sincrono per file in cache che chiama direttamente i punti di ingresso del file system e del Cache Manager senza generare un IRP; sul fatto che i dati vengono trasferiti direttamente dalla cache al buffer utente (e viceversa); sul fatto che se il Fast I/O non può trattare si usa il percorso ordinario basato su IRP. ↩ ↩2 ↩3
-
Microsoft Learn, Asynchronous disk I/O appears as synchronous on Windows. Sul fatto che se i dati sono in cache la richiesta completa sul posto e torna TRUE; sul fatto che la cache di Windows è implementata con file mapping e non c’è un meccanismo asincrono di page fault, quindi una lettura asincrona con cache abilitata a volte viene trattata in modo sincrono. ↩
-
Microsoft Learn, FileStream.Flush method (.NET). Sul fatto che Flush() versa al sistema operativo il buffer interno dello stream; sul fatto che Flush(true) inoltre flusha anche tutti i buffer file intermedi (i buffer del sistema operativo). ↩
Articoli correlati
Articoli recenti con gli stessi tag per approfondire argomenti vicini.
Le profondità dell'I/O di Windows (5ª puntata) — Struttura interna di NTFS: capire il file system dall'MFT
Quinta puntata della serie che spiega con i diagrammi la struttura interna di NTFS. Si organizzano, dal punto di vista dello sviluppatore...
Le profondità dell'I/O di Windows (6ª puntata · ultima) — Driver filtro e minifilter: perché Procmon e la scansione antivirus possono intercettare l'I/O
Puntata finale della serie che spiega con i diagrammi i driver filtro e i minifilter di Windows. Si organizzano Filter Manager e altitudi...
Best practice pratiche sul multithreading: edizione .NET — Cosa decidere prima di aggiungere altri thread
Le regole di progettazione per .NET/C# che impediscono che, dopo aver avviato un thread, l'app «crashi o si blocchi di tanto in tanto». L...
Usare WMI/CIM da C# e PowerShell — Guida pratica a lettura hardware, monitoraggio processi e interrogazioni remote
Il modo classico per leggere il numero di serie del PC, monitorare lo spazio disco e rilevare l'avvio dei processi è WMI/CIM. L'articolo ...
Come comprendere l'isolamento delle sessioni di Windows — Session 0, RDP ed esecuzione simultanea di più utenti
Questo articolo chiarisce il concetto di «sessione» di Windows, un argomento che genera costante confusione tra gli sviluppatori di appli...
Argomenti correlati
Queste pagine collocano l’argomento in un contesto più ampio di servizi e decisioni.
Argomenti tecnici Windows
Portale su sviluppo Windows, analisi dei problemi e valorizzazione delle risorse esistenti.
Servizi collegati all’argomento
L’articolo è direttamente collegato ai servizi seguenti.
Sviluppo di applicazioni Windows
Applicazioni aziendali, integrazione di dispositivi e strumenti di comunicazione, dai requisiti allo sviluppo.
Domande frequenti
Domande che ricorrono nelle consulenze sull’argomento dell’articolo.
- Quando WriteFile restituisce successo, i dati sono già scritti sul disco?
- Per default no. La cache dei file di Windows è write-back, e WriteFile restituisce successo nel momento in cui ha copiato i dati nella cache file di sistema. La scrittura sul disco la fa dopo il lazy writer del Cache Manager, che si avvia ogni secondo. Quello che conta è la differenza secondo il tipo di guasto. Se il processo dell'app va in crash, i dati che sono entrati in cache non si perdono: finché il sistema operativo è vivo, li scriverà dopo. Se invece cade l'intero sistema operativo — interruzione di corrente, schermo blu — la cache dirty che non era ancora stata scritta si perde. Il modo corretto di leggere «WriteFile è riuscito» non è «è stato reso durevole», ma «è stato consegnato al sistema operativo».
- Come si fa a scrivere sul disco in modo certo?
- Ci sono tre strumenti. Il primo è FlushFileBuffers, che scrive fino in fondo sul dispositivo i dati bufferizzati e i metadati di quel file (in .NET l'equivalente è FileStream.Flush(true)). Il secondo è FILE_FLAG_WRITE_THROUGH, che a ogni scrittura scrive nella cache e contemporaneamente sul disco, subito. Il terzo è FILE_FLAG_NO_BUFFERING, che non passa affatto dalla cache. La documentazione Microsoft osserva che chiamare FlushFileBuffers a ogni scrittura è inefficiente, e che le app che hanno bisogno di durabilità certa con scritture frequenti dovrebbero combinare FILE_FLAG_NO_BUFFERING e FILE_FLAG_WRITE_THROUGH. Tutti e tre rinunciano a parte del beneficio della cache, quindi diventano più lenti: il punto pratico non è «metterli su tutto», ma usarli solo per le scritture che non si possono perdere.
- Che differenza c'è tra FILE_FLAG_WRITE_THROUGH e FILE_FLAG_NO_BUFFERING?
- WRITE_THROUGH è «si scrive comunque in cache, ma prima del completamento si scrive anche sul disco». Le letture continuano a beneficiare della cache; si toglie solo il ritardo del lazy writer sulle scritture. NO_BUFFERING è «letture e scritture non passano dalla cache di sistema», quindi entrambe diventano I/O verso il dispositivo a ogni chiamata (però quello che si aggira è solo la cache di Windows: non si salta anche la cache di scrittura dentro il dispositivo di storage). In cambio ci sono vincoli severi. Dimensione e offset nel file di ogni lettura o scrittura devono essere un multiplo intero della dimensione di settore del volume, e anche l'indirizzo del buffer deve essere allineato al confine di settore fisico. Inoltre, anche con NO_BUFFERING i metadati del file system continuano a essere messi in cache, quindi per rendere durevoli anche i metadati servono FlushFileBuffers o WRITE_THROUGH in aggiunta. Tipicamente lo usa software che gestisce da sé i buffer, come un motore di database; per un'app ordinaria il percorso naturale è partire da WRITE_THROUGH o FlushFileBuffers.
- Se in Gestione attività la memoria libera sembra poca, è colpa della cache dei file?
- Spesso sì, ed è un comportamento normale. Windows usa attivamente la memoria fisica libera come cache dei file, quindi copiare un file grande o fare molta lettura e scrittura gonfia la cache e l'uso di memoria sembra aumentare. Però gran parte delle pagine usate dalla cache è di un tipo che, se un'app chiede memoria, viene riconvertita in tempi relativamente brevi: va distinto dallo stato «la memoria è stata mangiata e non basta». Quando si sospetta una vera carenza, in pratica si guardano indicatori come la memoria in commit e la frequenza dei hard fault, non solo la quantità libera apparente.
- Se si tocca lo stesso file con un file memory-mapped e con ReadFile/WriteFile, il contenuto può disallinearsi?
- Rispetto all'I/O ordinario con cache abilitata, no. La cache di Windows è essa stessa implementata come file mapping, quindi la vista mappata e la cache dello stesso file locale condividono gli stessi dati: una modifica dall'uno si vede anche dall'altro. Anche se più processi creano viste dallo stesso oggetto di file mapping, i dati sono coerenti (coherent). Tuttavia le letture e le scritture di un handle aperto con FILE_FLAG_NO_BUFFERING non passano dalla cache e restano fuori da questo quadro di coerenza. Inoltre, per scrivere sul disco in modo certo le modifiche di una vista mappata, FlushViewOfFile da solo non basta — non scrive i metadati e non attende la cache hardware — quindi dopo FlushViewOfFile occorre chiamare FlushFileBuffers.
Profilo dell’autore
Pagina di presentazione dell’autore dell’articolo.
Go Komura
Rappresentante di KomuraSoft LLC
Specializzato nello sviluppo di software Windows, nella consulenza tecnica e nell’analisi dei malfunzionamenti, soprattutto nei progetti con sistemi esistenti e guasti difficili da riprodurre.