3

我试图了解通过 C# 中的托管代码完成的文件写入操作的一些内部细节。假设我在下面写了一段代码来将一些内容写入日志文件:

using System.IO;
public void Log(string logMessage, LogLevel logLevel)
{
    if (Directory.Exists(LogFileDirectory))
    {
        using (StreamWriter streamWriter = new StreamWriter(LogFileDirectory + "log.txt",true))
        {
            streamWriter.WriteLine(DateTime.Now.ToString() + " - " + logMessage);                                    
        }
    }
}

要在磁盘上写入文件,我可以列出一些必须发生的事情:

  • 在指定路径搜索文件
  • 检查用户对目录和路径的权限访问。打开磁盘上任何文件的文件句柄需要您通过必须受访问控制的本机操作系统调用。
  • 打开文件句柄
  • 加载磁盘驱动程序(这可能发生在操作系统启动时本身,但也需要对此进行确认)
  • 实际磁盘旋转
  • 硬盘驱动器磁头/主轴运动
  • 创建 I/O 缓冲区等
  • 将数据块从磁盘拉到 RAM。
  • <<I could be missing few more as I'm not very thorough with disk I/O at OS level>>

我试图理解的事情或内部细节是,对于StreamWriter我在应用程序中创建的每个新的类实例,是否会重复上述步骤,或者如果每个时间是同一进程谁要求将某些内容写入完全相同的文件?

4

3 回答 3

4

据了解您的问题,可以分为以下几部分。

  1. 我试图理解的内部细节是,对于我在应用程序中创建的 StreamWriter 类的每个新实例,是否重复上述步骤

  2. 如果每次都是同一个进程要求将某些内容写入完全相同的文件,那么操作系统(OS)或进程是否可以优化或缓存一些东西

  3. 您的特殊赏金要求“我还想了解当文件读/写请求来自同一进程或不同进程时,操作系统是否应用了一些额外的智能?或者操作系统是否仍然不知道请求读/写操作的进程”。


#回答第一个问题

免责声明:以下内容仅与您编写的实际代码相关(原样)。如果稍微改变一下,很多实现细节就变得无关紧要了。

实际上,每次创建StreamWriter. 然而,事情确实会发生。

让我们通过.Net Source创建StreamWriter您拥有的方式。

创建 StreamWrtier

using (StreamWriter streamWriter = new StreamWriter(LogFileDirectory + "log.txt",true))

调用链如下

  1. public StreamWriter(String path, bool append)

StreamWriter使用默认编码和缓冲区大小为指定文件初始化类的新实例。如果文件存在,它可以被覆盖或附加到。如果文件不存在,则此构造函数创建一个新文件。

  1. public StreamWriter(String path, bool append, Encoding encoding, int bufferSize)

  2. internal StreamWriter(String path, bool append, Encoding encoding, int bufferSize, bool checkHost)

  3. private static Stream CreateFile(String path, bool append, bool checkHost)

它专门用FileMode.Append标志调用以下内容。

如果文件存在,则打开文件并查找文件末尾,或创建一个新文件。这需要 FileIOPermissionAccess.Append 权限。FileMode.Append 只能与 FileAccess.Write 结合使用。试图在文件结尾之前寻找一个位置会引发 IOException 异常,并且任何读取尝试都会失败并引发 NotSupportedException 异常。

  1. internal FileStream(String path, FileMode mode, FileAccess access, FileShare share, int bufferSize, FileOptions options, String msgPath, bool bFromProxy, bool useLongPath, bool checkHost)

如您所见,对于在家玩的任何人,我们所做的就是创建一个文件流。从这里我们调用Marshal一些安全属性

  1. private void Init(String path, FileMode mode, FileAccess access, int rights, bool useRights, FileShare share, int bufferSize, FileOptions options, Win32Native.SECURITY_ATTRIBUTES secAttrs, String msgPath, bool bFromProxy, bool useLongPath, bool checkHost)

在这一点上发生了很多事情。检查权限;检查文件类型是什么;检查手柄。然而,故事的关键在于:

  1. internal static SafeFileHandle SafeCreateFile(String lpFileName, int dwDesiredAccess, System.IO.FileShare dwShareMode, SECURITY_ATTRIBUTES securityAttrs, System.IO.FileMode dwCreationDisposition, int dwFlagsAndAttributes, IntPtr hTemplateFile)

解决在一个DllImport

[DllImport(KERNEL32, SetLastError=true, CharSet=CharSet.Auto, BestFitMapping=false)]
[ResourceExposure(ResourceScope.Machine)]
private static extern SafeFileHandle CreateFile(String lpFileName, int dwDesiredAccess, System.IO.FileShare dwShareMode, SECURITY_ATTRIBUTES securityAttrs, System.IO.FileMode dwCreationDisposition, int dwFlagsAndAttributes, IntPtr hTemplateFile);

这是我们.Net故事的结尾,以我们心爱的KERNEL32结尾 CreateFile Function

创建或打开文件或 I/O 设备。最常用的 I/O 设备如下:文件、文件流、目录、物理磁盘、卷、控制台缓冲区、磁带驱动器、通信资源、邮槽和管道。该函数返回一个句柄,根据文件或设备以及指定的标志和属性,该句柄可用于访问文件或设备以进行各种类型的 I/O。

如果你曾经使用过,CreateFile你会在这里知道很多关于标志缓存缓冲的信息,坦率地说,还有很多与File System. 那是因为这是最古老的用户模式​​API 调用之一,它可以做各种各样的事情。但是,如果您遵循.Net源代码(在这种情况下),它实际上并没有充分利用其扩展特性。

唯一的主要例外是:

  • FILE_APPEND_DATA

要写入文件末尾,请将 OVERLAPPED 结构的 Offset 和 OffsetHigh 成员都指定为0xFFFFFFFF. 这在功能上等同于之前调用 CreateFile 函数以使用FILE_APPEND_DATAaccess 打开 hFile。

  • FILE_FLAG_OVERLAPPED指示异步 IO 的标志(在这种情况下您没有设置)

正在为异步 I/O 打开或创建文件或设备。

在此句柄上完成后续 I/O 操作时,OVERLAPPED结构中指定的事件将设置为信号状态。

如果指定了此标志,则该文件可用于同时读取和写入操作。

如果未指定此标志,则 I/O 操作将被序列化,即使对 read 和 write 函数的调用指定了 OVERLAPPED 结构。

同步和异步 I/O 句柄

如果打开文件或设备以进行同步 I/O(即 FILE_FLAG_OVERLAPPED未指定),则对函数的后续调用WriteFile可能会阻塞调用线程的执行,直到发生以下事件之一:

  • I/O 操作完成(在本例中为数据写入)。
  • 发生 I/O 错误。(例如,管道从另一端封闭。)
  • 调用本身出错(例如,一个或多个参数无效)。
  • 进程中的另一个线程CancelSynchronousIo使用阻塞线程的线程句柄调用该函数,这会终止该线程的 I/O,使 I/O 操作失败。
  • 被阻塞的线程被系统终止;例如,进程本身被终止,或者另一个线程 TerminateThread使用阻塞线程的句柄调用该函数。(这通常被认为是最后的手段,而不是好的应用程序设计。)

同步和异步 I/O

在某些情况下,这种延迟对于应用程序的设计和用途来说可能是不可接受的,因此应用程序设计人员应考虑使用异步 I/O 和适当的线程同步对象,例如I/O 完成端口。有关线程同步的更多信息,请参阅关于同步。进程通过在参数中CreateFile指定 FILE_FLAG_OVERLAPPED标志在其调用中为异步 I/O 打开一个文件。dwFlagsAndAttributes如果FILE_FLAG_OVERLAPPED未指定,则打开文件以进行同步 I/O。当为异步 I/O 打开文件时,指向 OVERLAPPED 结构的指针被传递到对 and 的调用 ReadFileWriteFileReadFile执行同步 I/O 时,调用和时不需要此结构WriteFile

CreateFile提供创建同步或异步的文件或设备句柄。同步句柄的行为使得使用该句柄的 I/O 函数调用在完成之前被阻塞,而异步文件句柄使系统可以立即从 I/O 函数调用返回,无论它们是否完成了 I/O 操作或不是。如前所述,这种同步与异步行为是通过 FILE_FLAG_OVERLAPPEDdwFlagsAndAttributes参数中指定来确定的。使用异步 I/O 时有几个复杂性和潜在的陷阱;有关详细信息,请参阅同步和异步 I/O,

I/O 完成端口

I/O 完成端口为在多处理器系统上处理多个异步 I/O 请求提供了一个高效的线程模型。当一个进程创建一个 I/O 完成端口时,系统会为请求创建一个关联的队列对象,这些请求的唯一目的是为这些请求提供服务。处理许多并发异步 I/O 请求的进程可以通过将 I/O 完成端口与预先分配的线程池结合使用,而不是通过在接收 I/O 请求时创建线程来更快、更有效地完成此操作。

I/O 完成端口如何工作

CreateIoCompletionPort函数创建一个 I/O 完成端口并将一个或多个文件句柄与该端口相关联。当对其中一个文件句柄的异步 I/O 操作完成时,I/O 完成数据包将按先进先出 (FIFO) 顺序排队到关联的 I/O 完成端口。这种机制的一个强大用途是将多个文件句柄的同步点组合到一个对象中,尽管还有其他有用的应用程序。请注意,虽然数据包按 FIFO 顺序排队,但它们可能会以不同的顺序出队。

注意:FileStreamuseAsync true可以通过在其中一个重载中设置来使用完成端口,FileStream但是您没有

public FileStream(String path, FileMode mode, FileAccess access, FileShare share, int bufferSize, bool useAsync)

实际写

您已经选择WriteLine()了实际的TextWriter方法,但是在我们开始之前,让我们先对FileStream. 使对共享日志文件的原子附加写入工作

  • 内部FileStream缓冲区需要足够大以容纳单次写入的所有数据
  • 数据必须从位置 0 写入缓冲区,以使其适合
  • StreamWriter通过包装写入的文本FileStream必须在每次写入之前完全刷新

在这些要求中,第一个 (1) 是最不愉快的工作。创建a 时缓冲区大小是固定的FileStream(上面的 4096 参数),因此原子写入更大事件的唯一方法是关闭并重新打开具有更大缓冲区的文件。

在写入之间刷新和StreamWriter巧妙地处理要求(2)和(3)。FileStream

  1. public virtual void WriteLine(String value)

  2. public virtual void Write(char\[\] buffer, int index, int count)

这个小宝石令人惊讶:

for (int i = 0; i < count; i++) Write(buffer[index + i]);
  1. public virtual void Write(char value)

当缓冲区已满时,它会调用一系列刷新,这有点难以理解,但我会尝试简单地

if (charPos == charLen) Flush(false, false);

默认情况下,其中charLen = DefaultBufferSize哪个被传递到您创建StreamWriter并定义如下的构造函数之一:

internal const int DefaultBufferSize = 1024;   // char[]
  1. private void Flush(bool flushStream, bool flushEncoder)

从这里开始,最重要的两件事是:

if (count > 0)
     stream.Write(byteBuffer, 0, count);

// By definition, calling Flush should flush the stream, but this is
// only necessary if we passed in true for flushStream.  The Web
// Services guys have some perf tests where flushing needlessly hurts.
if (flushStream)
     stream.Flush();

注意:你必须喜欢 MS 源代码注释,那些孩子完全暴动

无论如何,第二个Flush()(如果您遵循它)将在以下结束。记住我们StreamWriter是由一个FileStream类支持的,所以我们再次使用FileStreamWrite方法

  1. public override void Write(byte\[\] array, int offset, int count)

  2. private unsafe void WriteCore(byte\[\] buffer, int offset, int count)

  3. private unsafe int WriteFileNative(SafeFileHandle handle, byte\[\] bytes, int offset, int count, NativeOverlapped* overlapped, out int hr)

解决另一个DllImport

[DllImport(KERNEL32, SetLastError=true)]
[ResourceExposure(ResourceScope.None)]
internal static unsafe extern int WriteFile(SafeFileHandle handle, byte* bytes, int numBytesToWrite, out int numBytesWritten, IntPtr mustBeZero);

然后在你知道之前,我们又回到了KERNEL32,再次调用WriteFile Function

将数据写入指定的文件或输入/输出 (I/O) 设备。此函数设计用于同步和异步操作。对于专为异步操作设计的类似功能

再一次,有很多选项可以处理各种情况。再一次,.Net(在这种情况下)并不倾向于使用它。

从这里我们的故事切换到Windows 文件缓存,我们在.Net中几乎无法控制,但是您可以在Raw Api Calls中使用很多选项。

默认情况下,Windows 缓存从磁盘读取并写入磁盘的文件数据。这意味着读取操作从系统内存中称为系统文件缓存的区域读取文件数据,而不是从物理磁盘读取文件数据。相应地,写操作将文件数据写入系统文件缓存而不是磁盘,这种缓存称为回写缓存。缓存是按文件对象管理的。

所以忽略实际的内核模式,我们在用户模式中实际做了什么?不多......当创建一个Stream .Net已经完成了一系列检查和平衡来调用一个简单的CreateFile Win32 Api Call时,它又拥有一个句柄。当然有一堆IL被调用,但在它的基本级别它只是使用Win32 API创建一个文件并持有一个句柄(沸腾了一些.Net安全和权限检查等......)

然后会发生什么?好吧,我们处理一些编码,将一些字节写入内存,然后以预定的缓冲区大小,我们使用Win32 Api Call将其写入/刷新到磁盘FileWrite

操作系统是否必须做任何其他简单的用户模式文件创建和写入不需要做的事情?其实真的不是...

唯一的警告再次开始,确保.Net会唱歌跳舞,如果您真的想从用户模式文件系统进行原子访问,那么请考虑自己调用这些函数和/或使用IO 完成端口。通过这种方式,您可以获得异步工作和/或躲避.Net的优势,并且能够访问一堆扩展参数(尽管在大多数情况下,它不会在单个应用程序中使其更快,因为API默认参数已经针对通用情况进行了优化比如写)。

如果你真的有Processor Instruction OCD,那么很容易看到它的价值,同时保持FileStream并继续以附加模式写入它(采取预防措施),或者如果你处于Win32 Api级别,创建文件并持有持续写入的句柄并使用异步 IO 工具。

但这是关键..作为用户模式程序员(在大多数情况下) ,这就是用户模式所能做的一切。


#回答第二个问题

如前所述,除此之外,您无能为力;滚动您自己的API 调用;安装快速硬盘(在某些情况下可能使用自己的驱动程序)。但不管怎样,操作系统已经无论如何都缓存和优化了。如果您想对此进行调整,您需要再次访问Windows API和/或使用更高级的异步 IO 功能

最后,不同的服务器操作系统版本在内存和缓存方面具有优势,尽管这一切都有很好的记录


#第三题的答案

在多线程应用程序(内部缓冲区除外)中写入打开文件的进程没有优先处理.Net,而不是我知道的多个进程写入同一个文件(但是有更多经验的人可能会详细说明)。在用户模式下,每个人都可以通过相同的过滤器驱动程序缓存管理器使用相同的 API 。您可以在此处阅读有关缓存管理器和操作系统改进的更多信息。

来自 MSDN

缓存在缓存管理器的指导下进行,该管理器在 Windows 运行时连续运行。系统文件缓存中的文件数据以操作系统确定的时间间隔写入磁盘,并释放该文件数据先前使用的内存——这称为刷新缓存。延迟将数据写入文件并将其保存在缓存中直到缓存刷新的策略称为延迟写入,它由缓存管理器以确定的时间间隔触发。刷新文件数据块的时间部分取决于它已存储在缓存中的时间量以及自上次在读取操作中访问数据以来的时间量。这可确保经常读取的文件数据在系统文件缓存中保持可访问的最长时间。

文件数据缓存提供的 I/O 性能改进量取决于正在读取或写入的文件数据块的大小。当读取和写入大块文件数据时,更有可能需要磁盘读取和写入来完成 I/O 操作。随着越来越多的此类 I/O 操作发生,I/O 性能将越来越受到损害。


#补充

以上所有内容现在纯粹是学术性的。我们可以深入研究Windows API的源代码;我们可以一直跟踪到IO Completion 端口 and Kernel Mode;我们可以剖析驱动程序,但您的问题的答案不会变得更加清晰......除了我们已经知道的。

如果您真的想了解更多关于内部如何在压力下做出反应的知识,您需要采取的下一步是运行您自己的基准以获得实际实施和经验证据。你会这样做; 使用相同和不同的处理器在Windows Api级别测试 .Net 和自定义参数,以确定开销是否相关;使用不同的硬件;使用不同的操作系统(例如设计了对缓存进行不同细粒度控制的服务器);使用不同的驱动程序,对您的物理设备使用不同的物理路径。


#概括

简而言之,除了保持 Stream 活着(我们已经知道创建/打开文件会产生惩罚);使用异步 IO 操作;使用适当的缓冲区大小;直接将扩展参数发送到Win32 Api 中除了在不同的操作系统、配置和硬件上运行您自己的性能测试之外,我真的看不到更多,这将能够为您提供更多答案,让您了解在您的情况下什么更高效、更高效。

我希望这会有所帮助,或者至少给人们一个有趣StreamWriter的 API 之旅(在这两个简单的调用中)。

于 2018-03-13T11:29:09.190 回答
1

.NET 类只是冰山一角,是原生 Win32 函数之上的一个非常薄的层。当您跨越托管层时会发生很多事情。

简化视图:

    .NET layer (managed)
-----------------------------
    Win32 layer (User Mode)
-----------------------------
    Drivers (Kernel Mode)

对于实际的磁盘 I/O,您可以将“Win32 层”视为中厚层。实际的磁盘 I/O、检查文件是否存在、访问权限等不会 发生在这一层。忘记物理磁盘移动。该层与内核(构成设备驱动程序、文件系统驱动程序、IO 管理器和其他系统组件)协调,将检查句柄是否有效,传递的参数是否有效,执行的操作是否良好(如“写" 以“只读”方式打开的文件的操作将被拒绝)。如果您在dotPeek等反编译器或任何其他类似工具中看到 .NET 类的代码,您会看到调用 Win32 API 的托管代码。

实际的磁盘 I/O 由内核模式组件执行。这是Disk I/O(或Network I/O)最厚的一层,核心,触手。访问物理磁盘驱动器、执行安全检查、处理异步 IO、启动 APC/DPC、调用驱动程序堆栈中的其他驱动程序(文件系统驱动程序、微型过滤器驱动程序、监视器等),确保所有文件进程退出时关闭句柄(应用程序未明确关闭)。反病毒组件会在这个级别运行,它们要么记录文件 I/O 操作,要么阻止操作,甚至完全修改操作。裸操作系统上的大多数磁盘 I/O 将由 Microsoft 提供的驱动程序执行。防病毒、特定设备驱动程序(如您最喜欢的硬盘)、其他监控驱动程序(如 Process Monitor 使用的、

大多数驱动程序在启动时或按需加载。进行文件打开/关闭/读取/写入调用时不会加载它们。

Windows 是一个复杂而庞大的操作系统。许多 I/O 元素分散在内核世界(大部分)和 Win32 世界(子系统 DLL)中。您不能说“文件权限”仅由内核或用户执行 - 它是两者的结合。缓存、内存管理器、存储管理器和许多其他“低级”用户/内核组件为用户应用程序执行此操作。

不同版本的 Windows 会做不同的事情。

你不能说内核 IO 是最快的,而 .NET IO 是最慢的。这几乎是一样的。虽然访问内核(从用户(.NET 包括))会花费一些 CPU 周期,因此应用程序应该理想地最小化 IO 调用,例如读取 10K 字节,而不是 10 字节 1000 次。

最后,我想说的是——你不应该在乎!

于 2018-03-06T07:57:46.993 回答
1

我必须首先问为什么这很重要,即使它不在本机 c# 中,你也无能为力来改变它,因为调用在技术上存在于可编辑的代码部分之外,它是系统的一部分。 dll 通过管道由操作系统解释。它在操作系统级别的解释方式无关紧要,只要知道它有效。当您进入 xamarin mono 时,操作系统如何在所有操作系统上处理它,并将基本的读取或写入命令发送到操作系统以在其堆栈上进行处理,因为它可以免费执行(有时这可能需要一段时间,具体取决于事情的繁忙程度是在移动的土地上)。您无法轻松加快或优化它(您基本上是在构建一种新语言,没有人建议使用它。)

正如 ajay 最后所说,你不应该真正关心,因为从长远来看,你不能真正改变它的行为。

您可以在此处阅读一些材料,了解 I/O 的一般工作原理和优化尝试:

https://www.wilderssecurity.com/threads/trying-to-understand-io-nomenclature.286453/

https://docs.microsoft.com/en-us/dotnet/standard/io/

http://enterprisecraftsmanship.com/2014/12/13/io-threads-explained/

希望这可以帮助您了解更多以及您正在寻找什么(我仍然不确定您想知道什么或为什么。您最好联系.net核心团队以获取更多信息)

于 2018-03-08T14:35:41.757 回答