Setter de FileSystemInfo.LastWriteTime vs File.SetLastWriteTime en .NET
Ambos terminan en la misma llamada al sistema operativo, así que la marca de tiempo que se escribe es idéntica. Las diferencias están alrededor: qué caché se invalida, si los directorios funcionan en Windows, cómo se convierte un DateTime que no es UTC y si puedes apuntar a un handle abierto. Usa File.SetLastWriteTimeUtc, por handle cuando el archivo está abierto. Verificado en .NET 10.0.10.
Usa File.SetLastWriteTimeUtc (o Directory.SetLastWriteTimeUtc para carpetas), y usa la sobrecarga con SafeFileHandle si vas a fijar la hora de un archivo en el que todavía estás escribiendo. Usa el setter de FileInfo.LastWriteTimeUtc solo cuando ya tengas ese FileInfo y vayas a leer sus propiedades de nuevo después, porque el setter es la única API que también refresca la caché de esa instancia. Ambos caminos llaman al mismo FileSystem.SetLastWriteTime interno, así que los bits que llegan al disco son idénticos. Lo que cambia es todo lo que rodea esa llamada. En cualquier caso, pasa un DateTime con Kind = Utc al miembro ...Utc, y nunca una hora local o sin especificar al que no es UTC.
Todo lo que sigue se ejecutó en .NET 10.0.10 (SDK 10.0.302, C# 14) sobre macOS 27 con APFS, en una máquina cuya zona horaria local es Europe/Bucharest (UTC+2 en la fecha de la prueba). El comportamiento en Windows está citado de la rama release/10.0 de dotnet/runtime. El código involucrado casi no se ha movido desde .NET 7, cuando se añadieron las sobrecargas con handle, así que las mismas reglas aplican a .NET 8, 9 y las versiones preliminares de .NET 11.
La comparación de un vistazo
fileInfo.LastWriteTimeUtc = t | File.SetLastWriteTimeUtc(path, t) | |
|---|---|---|
| Llamada al SO (Windows) | SetFileInformationByHandle(FileBasicInfo) | igual |
| Llamada al SO (Unix) | utimensat(..., AT_SYMLINK_NOFOLLOW) | igual |
| Invalida la caché de | solo esa instancia de FileInfo | nada (no hay caché) |
Otros objetos FileInfo para la misma ruta | obsoletos hasta Refresh() | obsoletos hasta Refresh() |
| Directorio como destino en Windows | solo mediante DirectoryInfo | falla, usa Directory.SetLastWriteTimeUtc |
Acepta un SafeFileHandle abierto | no | sí, desde .NET 7 |
DateTime.MinValue con un setter de hora local, zona UTC+ | se ajusta en silencio | lanza ArgumentOutOfRangeException |
| Archivo inexistente | FileNotFoundException | FileNotFoundException |
| Costo por llamada (10k llamadas, APFS, M4) | 10.4 us | 10.7 us (por handle: 8.6 us) |
Qué hacen realmente las dos APIs
Si quitas la superficie pública, ambas se encuentran en un solo lugar. Este es el lado de FileSystemInfo, de FileSystemInfo.cs y FileSystemInfo.Windows.cs:
// dotnet/runtime release/10.0, System.Private.CoreLib
public DateTime LastWriteTime
{
get => LastWriteTimeUtc.ToLocalTime();
set => LastWriteTimeUtc = value.ToUniversalTime();
}
public DateTime LastWriteTimeUtc
{
get => LastWriteTimeCore.UtcDateTime;
set => LastWriteTimeCore = File.GetUtcDateTimeOffset(value);
}
internal DateTimeOffset LastWriteTimeCore // Windows
{
set
{
FileSystem.SetLastWriteTime(FullPath, value, (this is DirectoryInfo));
_dataInitialized = -1; // invalidate this instance's cache
}
}
Y el lado de File:
// dotnet/runtime release/10.0, File.cs
public static void SetLastWriteTime(string path, DateTime lastWriteTime)
=> FileSystem.SetLastWriteTime(Path.GetFullPath(path), lastWriteTime, false);
public static void SetLastWriteTimeUtc(string path, DateTime lastWriteTimeUtc)
=> FileSystem.SetLastWriteTime(Path.GetFullPath(path), GetUtcDateTimeOffset(lastWriteTimeUtc), false);
Ese false final es la bandera asDirectory, y importa en Windows (más abajo). En Unix, el setter de FileInfo pasa por su propio struct FileStatus, mientras que File usa un default(FileStatus) desechable, pero ambos llaman al mismo SetAccessOrWriteTimeCore. Ese método primero vuelve a hacer stat del archivo para poder reescribir sin cambios la hora de acceso actual, luego llama a utimensat con AT_SYMLINK_NOFOLLOW y después invalida la caché de estado. En Windows ambos abren la ruta con FILE_WRITE_ATTRIBUTES y llaman a SetFileInformationByHandle con un FILE_BASIC_INFO donde todos los campos excepto LastWriteTime son cero, lo que Windows interpreta como “dejar sin cambios”.
Así que no existe un comportamiento tipo “el setter de FileInfo es perezoso” o “solo cambia el objeto en memoria”, que es una suposición común. El setter escribe en el disco de inmediato, igual que el método estático.
Invalidación de caché: la diferencia con la que sí te vas a topar
FileSystemInfo guarda en caché todo lo que lee. El primer acceso a una propiedad hace stat del archivo, y los accesos posteriores devuelven la instantánea hasta que algo llame a Refresh(). El setter invalida la caché de la instancia sobre la que lo llamaste, y nada más. Esta prueba hace visibles los tres casos:
// .NET 10.0.10, C# 14 - run with: dotnet run probe.cs
var path = Path.Combine(Path.GetTempPath(), "a.txt");
File.WriteAllText(path, "hello");
var target = new DateTime(2020, 1, 2, 3, 4, 5, DateTimeKind.Utc);
var fi1 = new FileInfo(path);
var fi2 = new FileInfo(path);
_ = fi1.LastWriteTimeUtc; _ = fi2.LastWriteTimeUtc; // populate both caches
fi1.LastWriteTimeUtc = target;
Console.WriteLine(fi1.LastWriteTimeUtc.ToString("O")); // 2020-01-02T03:04:05.0000000Z
Console.WriteLine(fi2.LastWriteTimeUtc.ToString("O")); // 2026-10-30T12:04:26.3262150Z (stale)
fi2.Refresh();
Console.WriteLine(fi2.LastWriteTimeUtc.ToString("O")); // 2020-01-02T03:04:05.0000000Z
var fi3 = new FileInfo(path); _ = fi3.Length; // snapshot now
File.SetLastWriteTimeUtc(path, target.AddYears(1));
Console.WriteLine(fi3.LastWriteTimeUtc.ToString("O")); // 2020-01-02... (stale)
Console.WriteLine(File.GetLastWriteTimeUtc(path).ToString("O")); // 2021-01-02...
Esta es la única razón real para preferir el setter. Si un método recibe un FileInfo, fija su marca de tiempo y luego quien lo llamó registra fileInfo.LastWriteTime o lo compara con otro archivo, el setter mantiene ese objeto honesto. Llamar a File.SetLastWriteTimeUtc(fileInfo.FullName, t) en el mismo lugar le deja al llamador un valor obsoleto, y ese error solo aparece cuando alguien lee la propiedad de nuevo. Si tomas ese camino, añade fileInfo.Refresh() después.
Lo inverso es igual de importante: el setter no alcanza a otras instancias de FileInfo, incluidas las que salieron de DirectoryInfo.EnumerateFiles. Esas se llenaron durante la enumeración y quedan congeladas. El mismo comportamiento de instantánea es lo que hace que FileInfo.Length mienta sobre archivos que crecen, lo cual cubrí en detalle en por qué FileInfo.Length devuelve un tamaño obsoleto en Windows.
Directorios: la trampa solo de Windows en File.SetLastWriteTime
File.SetLastWriteTime siempre pasa asDirectory: false. En Windows esa bandera decide si OpenHandleToWriteAttributes añade FILE_FLAG_BACKUP_SEMANTICS a la llamada a CreateFile:
// dotnet/runtime release/10.0, FileSystem.Windows.cs
int dwFlagsAndAttributes = Interop.Kernel32.FileOperations.FILE_FLAG_OPEN_REPARSE_POINT;
if (asDirectory)
{
dwFlagsAndAttributes |= Interop.Kernel32.FileOperations.FILE_FLAG_BACKUP_SEMANTICS;
}
La documentación de CreateFile es explícita en que debes establecer FILE_FLAG_BACKUP_SEMANTICS para obtener un handle a un directorio, así que File.SetLastWriteTimeUtc(@"C:\data\folder", t) falla con un error de acceso denegado en Windows. DirectoryInfo.LastWriteTimeUtc = t pasa this is DirectoryInfo y funciona, igual que Directory.SetLastWriteTimeUtc.
En Linux y macOS la bandera solo afecta qué tipo de excepción se lanza para una ruta inexistente, así que la misma llamada a File sobre un directorio tiene éxito. Mi prueba lo confirmó en APFS. Es exactamente el tipo de código que pasa el CI en un runner de Linux y luego falla en la laptop con Windows de un desarrollador. Si tu código puede recibir un archivo o una carpeta, ramifica con Directory.Exists y llama a la clase estática correspondiente, o construye el subtipo correcto de FileSystemInfo.
Una rareza relacionada: new FileInfo(directoryPath).Exists es false, pero en Unix new FileInfo(directoryPath).LastWriteTimeUtc = t tiene éxito y marca el directorio. No te apoyes en eso.
Local vs UTC: dónde divergen los dos caminos en valores extremos
Los miembros que no son UTC son donde las dos APIs dejan de ser simétricas. File.SetLastWriteTime(string, DateTime) pasa el DateTime directamente a un parámetro DateTimeOffset, así que se ejecuta la conversión implícita de DateTime a DateTimeOffset. El setter de FileInfo.LastWriteTime llama a value.ToUniversalTime() en su lugar. Para fechas normales coinciden, y ambos tratan DateTimeKind.Unspecified como hora local:
File.SetLastWriteTime(Unspecified 12:00) -> 09:00Z (treated as local, UTC+3 in June)
File.SetLastWriteTimeUtc(Unspecified 12:00) -> 12:00Z (treated as UTC)
File.SetLastWriteTime(Utc-kind 03:04:05Z) -> 03:04:05Z (Kind honoured)
fi.LastWriteTime = Unspecified 12:00 -> 09:00Z
fi.LastWriteTimeUtc = Local-kind 12:00 -> 09:00Z (Local converted, not reinterpreted)
Los extremos son diferentes. En cualquier zona horaria al este de UTC, DateTime.MinValue no tiene un equivalente UTC válido. ToUniversalTime() lo ajusta silenciosamente a DateTime.MinValue, mientras que la conversión implícita a DateTimeOffset se niega:
// .NET 10.0.10, Europe/Bucharest (UTC+2)
File.SetLastWriteTime(path, DateTime.MinValue);
// ArgumentOutOfRangeException: The UTC time represented when the offset is applied
// must be between year 0 and 10,000. (Parameter 'offset')
new FileInfo(path).LastWriteTime = DateTime.MinValue; // no exception here
Al oeste de UTC, DateTime.MaxValue lanza de la misma manera. No usarías esos valores a propósito, pero las rutas de código de “restablecer a valor predeterminado” y los campos sin inicializar los ponen ahí más a menudo de lo que esperarías.
Superar la conversión no significa que el valor se pueda almacenar. El FILETIME de Windows empieza en 1601, y DateTimeOffset.ToFileTime() lanza ArgumentOutOfRangeException: Not a valid Win32 FileTime. para cualquier valor anterior, así que en Windows ambas APIs lanzan con DateTime.MinValue. APFS almacena un conteo de 64 bits con signo de nanosegundos desde 1970, así que en macOS la escritura “exitosa” de DateTime.MinValue termina en 1677-09-21T00:12:43.1452241Z, el instante más temprano que APFS puede representar. Ninguno es lo que querías. Valida el rango tú mismo antes de llamar a cualquiera de las dos APIs.
El horario de verano es la otra razón para quedarte en los miembros ...Utc. Una hora local durante la hora de retroceso del otoño es ambigua, y ToUniversalTime() elige la interpretación de hora estándar sin avisarte. Si la marca de tiempo vino de otra máquina, una base de datos o una entrada de archivo comprimido, casi seguro era UTC desde el principio. Mantenla así hasta el disco. El mismo consejo aplica a los valores DateTime en payloads JSON.
La sobrecarga con handle, y por qué los archivos abiertos deshacen tu marca de tiempo
Solo la clase estática tiene File.SetLastWriteTime(SafeFileHandle, DateTime) y File.SetLastWriteTimeUtc(SafeFileHandle, DateTime), añadidos en .NET 7. Llaman a SetFileInformationByHandle o futimens sobre el handle que ya tienes, lo que evita la búsqueda por ruta y la apertura adicional.
La razón más importante para usarlos es el orden. Fijar la marca de tiempo mientras tu propio FileStream aún tiene bytes en el búfer es un error clásico, porque el SO vuelve a actualizar la hora de modificación cuando esos bytes se vacían en Dispose:
// .NET 10.0.10, C# 14
using (var fs = new FileStream(p, FileMode.Create, FileAccess.Write, FileShare.ReadWrite))
{
fs.Write(new byte[100]); // sits in FileStream's 4 KB buffer
File.SetLastWriteTimeUtc(p, target);
// File.GetLastWriteTimeUtc(p) here: 2020-01-02T03:04:05Z
}
// File.GetLastWriteTimeUtc(p) here: 2026-10-30T12:04:26Z <- your value is gone
using (var fs = new FileStream(p, FileMode.Create, FileAccess.Write, FileShare.ReadWrite))
{
fs.Write(new byte[100]);
fs.Flush(); // all writes reach the OS first
File.SetLastWriteTimeUtc(fs.SafeFileHandle, target);
}
// File.GetLastWriteTimeUtc(p) here: 2020-01-02T03:04:05Z <- kept
La regla es “gana la última escritura”: la marca de tiempo debe ser lo último que toque el archivo. Vacía, fija por handle y luego cierra. Fijarla después del bloque using por ruta también funciona, pero cuesta una segunda apertura y deja una ventana en la que otro proceso puede ver el archivo con la hora equivocada. Eso importa si un consumidor está esperando a que el archivo termine de escribirse o consulta las marcas de tiempo periódicamente, como lo hace el enfoque de comparación de instantáneas en recursos compartidos de red.
FileSystemInfo no tiene un equivalente basado en handle, así que si necesitas este patrón, estás en File sin importar tu preferencia.
Enlaces simbólicos, hora de creación y precisión
Tres cosas son iguales en ambos caminos, pero la gente suele equivocarse con ellas:
- Enlaces simbólicos: Windows abre con
FILE_FLAG_OPEN_REPARSE_POINTy Unix llama autimensatconAT_SYMLINK_NOFOLLOW, así que ambas APIs marcan el enlace en sí y dejan intacto el destino. Si quieres el destino, resuélvelo primero conFileSystemInfo.ResolveLinkTarget(returnFinalTarget: true)y fija la hora sobre el resultado. - Hora de creación en macOS: fijar una hora de modificación anterior a la hora de nacimiento hace que el kernel retroceda la hora de nacimiento para igualarla. .NET detecta esto en macOS y restaura la hora de creación original con
setattrlist, así queFile.GetCreationTimeUtcno cambia después de que retrocedesLastWriteTime. En Linux no hay una hora de nacimiento configurable, y .NET informa la más antigua entrectimeymtimecomo hora de creación. - Precisión:
DateTimetiene ticks de 100 ns. NTFS, APFS y ext4 los conservan todos (mi ida y vuelta de...07.1234567Zregresó exacta), FAT32 redondea a 2 segundos y exFAT conserva 10 ms. Si comparas marcas de tiempo entre volúmenes, por ejemplo en una herramienta de respaldo o sincronización, compara con una tolerancia en lugar de==.
Ambas APIs lanzan FileNotFoundException para un archivo inexistente. Esto es asimétrico con los getters, que devuelven 1601-01-01T00:00:00Z en lugar de lanzar. Por eso, el código que lee la hora, decide y luego escribe puede ver cómo un archivo desaparece entre las dos llamadas y recibir una excepción de un método que parecía inofensivo. Si el archivo puede eliminarse de forma concurrente, captura FileNotFoundException alrededor de la escritura.
El benchmark que no importa
Para completar: 10 000 llamadas cada una en un volumen APFS caliente (Apple M4, .NET 10.0.10, Release, Stopwatch alrededor de un bucle) cuestan 10.72 us por llamada para File.SetLastWriteTimeUtc(path, t), 10.40 us para el setter de FileInfo y 8.60 us para la sobrecarga con handle sobre un handle ya abierto. Las variantes por ruta son iguales dentro del ruido, porque cada una hace un stat, un utimensat y, del lado de FileInfo, un reinicio de caché. El handle ahorra la resolución de la ruta. Nada de esto debería guiar tu elección. Si estás marcando miles de archivos, el stat dentro de cada llamada de escritura domina, y paralelizar sobre los archivos ayudará mucho más que cambiar de API.
La recomendación, en resumen
Usa por defecto File.SetLastWriteTimeUtc(path, utc) y Directory.SetLastWriteTimeUtc(path, utc). Son explícitos sobre qué tipo de entrada estás apuntando, no pueden dejar un objeto en caché desactualizado y tienen una sobrecarga con handle para el caso de “fijarlo en el archivo que acabo de escribir”, que es donde nacen la mayoría de los errores con marcas de tiempo. Cambia a fileInfo.LastWriteTimeUtc = utc cuando el FileInfo sea la unidad de trabajo en tu código y alguien vaya a leer sus propiedades de nuevo, porque el setter es la única API que mantiene esa instancia sincronizada. Evita los miembros que no son UTC de ambos, y valida que el valor quepa en el rango del sistema de archivos de destino antes de escribirlo.
Lecturas relacionadas
- Por qué FileInfo.Length devuelve un tamaño obsoleto en Windows profundiza en la misma caché de
FileSystemInfodesde el ángulo del tamaño. - Cómo detectar cuándo un archivo termina de escribirse en .NET cubre el lado del consumidor de “gana la última escritura”.
- FileSystemWatcher vs sondeo en un recurso compartido de red depende de marcas de tiempo que acabas de aprender a fijar correctamente.
- Cómo seguir un archivo de registro mientras otro proceso escribe en él muestra las banderas
FileShareusadas en las pruebas anteriores. - Cómo ejecutar una app de C# basada en archivo con dotnet run si quieres volver a ejecutar
probe.cstú mismo.
Fuentes
FileSystemInfo.LastWriteTimeyFile.SetLastWriteTimeUtc(Microsoft Learn)FileSystemInfo.cs,FileSystemInfo.Windows.cs,File.cs(dotnet/runtime)FileSystem.Windows.cs,FileStatus.Unix.cs,pal_time.c(dotnet/runtime)CreateFileW,FILE_FLAG_BACKUP_SEMANTICSyFILE_BASIC_INFO(Microsoft Learn)utimensat(2)(páginas man de Linux)- Horas de archivo, resolución por sistema de archivos (Microsoft Learn)
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.