Performance Improvements in .NET 8 & 7 & 6 -- File I/O【翻译】

Net 8.0

File I/O

.NET 6对如何实现文件I/O进行了重大改革,重写了FileStream类,引入了RandomAccess类以及大量的其他更改。 .NET 8通过进一步改进文件I/O性能而继续提升性能。

对于提高系统性能的一种有趣的方法是取消操作。毕竟,最快的工作是不做,而取消操作是关于停止不必要的额外工作的。在.NET中,异步编程的原始模式基于不可取消的模型(如何使用异步/await真正工作?)进行了深入的历史和讨论),随着时间的推移,所有支持都转向了基于CancellationTokenTask基础模型,越来越多的实现也变得完全可取消。到.NET 7为止,大多数接受CancellationToken的代码路径实际上都遵循了CancellationToken,而不仅仅是进行一个前馈检查,看看是否已经请求了取消,然后在操作过程中没有注意它。大多数遗留的异步代码路径都是非常小心的,但有一个值得注意的是,有没有使用FileOptions.Asynchronous创建的文件流。

FileStream继承了Windows中的异步模型,当时您打开文件句柄时,需要指定它是用于同步还是异步访问("overlapped")。以异步访问打开的文件句柄要求所有操作都是异步的,反之,如果以非异步访问打开的文件句柄,则所有操作都必须是同步的。这导致与FileStream的交互有些影响,因为它同时暴露了同步(如Read)和非同步(如ReadAsync)方法,这意味着需要同时实现这两种行为的同步。如果FileStream以异步访问打开,那么Read需要异步执行并阻塞等待其完成(我们不太喜欢称之为“同步-异步”]),而如果以同步访问打开的文件句柄,那么ReadAsync需要将一个工作项安排为同步执行(我们不太喜欢称之为“异步-同步”])。尽管ReadAsync方法接受了一个CancellationToken,但最终在[ThreadPool工作项中结束的同步Read操作是不可取消的。在.NET 8中,由于dotnet/runtime#87103,在Windows上,至少在Windows上,这是如此。

在.NET 7中,针对相同的问题,PipeStream得到了修复,它依赖于一个内部的AsyncOverSyncWithIoCancellation辅助程序,它会使用Windows中的CancelSynchronousIo来中断正在进行的I/O,同时使用适当的同步来确保只有预期的相关工作被中断,而不是在当前工作线程之前或之后运行的其他工作(Linux已完全支持PipeStream的取消,从.NET 5开始)。与此PR相同,还进一步改进了该辅助程序的实现,以减少分配并进一步优化处理,以便现有对PipeStream的支持变得更好。

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.IO.Pipes;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[MemoryDiagnoser(displayGenColumns: false)]
public class Tests
{
    private readonly CancellationTokenSource _cts = new();
    private readonly byte[] _buffer = new byte[1];
    private AnonymousPipeServerStream _server;
    private AnonymousPipeClientStream _client;

    [GlobalSetup]
    public void Setup()
    {
        _server = new AnonymousPipeServerStream(PipeDirection.Out);
        _client = new AnonymousPipeClientStream(PipeDirection.In, _server.ClientSafePipeHandle);
    }

    [GlobalCleanup]
    public void Cleanup()
    {
        _server.Dispose();
        _client.Dispose();
    }

    [Benchmark(OperationsPerInvoke = 100_000)]
    public async Task ReadWriteAsync()
    {
        for (int i = 0; i < 100_000; i++)
        {
            ValueTask<int> read = _client.ReadAsync(_buffer, _cts.Token);
            await _server.WriteAsync(_buffer, _cts.Token);
            await read;
        }
    }
}
Method Runtime Mean Ratio Allocated Alloc Ratio
ReadWriteAsync .NET 7.0 3.863 us 1.00 181 B 1.00
ReadWriteAsync .NET 8.0 2.941 us 0.76 0.00

通过PathFile与路径进行交互已经取得了改进。 dotnet/runtime#74855 改善了 Windows 上 Path.GetTempFileName() 的功能和性能;在过去,我们曾努力使 .NET 在 Unix 上的行为与在 Windows 上的行为相一致,但这次 PR 非常有趣地采取了一种相反的方向。在 Unix 上,Path.GetTempFileName() 使用 libc mkstemp 函数,该函数接受一个必须以“XXXXXX”结尾的模板,并在创建新文件时填充其中的 X。在 Windows 上,GetTempFileName() 使用类似但只有 4 个 XGetTempFileNameW 函数。通过在 Windows 上填充字符,这使得只有 65,536 个可能的名称,并且随着临时目录的填充,创建重复名称的临时文件可能性越来越高,进而导致创建时间越来越长(这也同时也意味着在 Windows 上 Path.GetTempFileName() 只能创建 65,536 个同时存在的临时文件)。这次 PR 改变了 Windows 的格式以与 Unix 上的格式匹配,避免了使用 GetTempFileNameW,而是进行了随机名称分配和冲突检测。结果是操作系统之间的 consistency 更好,可能的临时文件数量大幅增加(十亿而不是几万),以及更好的性能:

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0
// NOTE: The results for this benchmark will vary wildly based on how full the temp directory is.

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private readonly List<string> _files = new();

    // NOTE: The performance of this benchmark is highly influenced by what's currently in your temp directory.
    [Benchmark]
    public void GetTempFileName()
    {
        for (int i = 0; i < 1000; i++) _files.Add(Path.GetTempFileName());
    }

    [IterationCleanup]
    public void Cleanup()
    {
        foreach (string path in _files) File.Delete(path);
        _files.Clear();
    }
}
Method Runtime Mean Ratio
GetTempFileName .NET 7.0 1,947.8 ms 1.00
GetTempFileName .NET 8.0 276.5 ms 0.34

Path.GetFileName 是另一个通过利用 IndexOf 方法提高性能的方法列表中的方法。在这里,dotnet/runtime#75318 使用 LastIndexOf(在 Unix 上,唯一的目录分隔符是 '/')或 LastIndexOfAny(在 Windows 上,'/''\'' 都可以作为目录分隔符)来查找文件名开始的位置。

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
    private string _path = Path.Join(Path.GetTempPath(), "SomeFileName.cs");

    [Benchmark]
    public ReadOnlySpan<char> GetFileName() => Path.GetFileName(_path.AsSpan());
}
Method Runtime Mean Ratio
GetFileName .NET 7.0 9.465 ns 1.00
GetFileName .NET 8.0 4.733 ns 0.50

FilePath 相关,Environment 中的许多方法也返回路径。Microsoft.Extensions.Hosting.HostingHostBuilderExtensions 曾经使用 Environment.GetSpecialFolder(Environment.SpecialFolder.System) 来获取系统路径,但这会导致启动 ASP.NET 应用程序时出现明显的性能开销。 dotnet/runtime#83564 将此更改为直接使用 Environment.SystemDirectory,在 Windows 上可以更有效地获取路径(从而使代码更简单),但然后 dotnet/runtime#83593 还在 Windows 上修复了 Environment.GetSpecialFolder(Environment.SpecialFolder.System),使其在 Windows 上使用 Environment.SystemDirectory,从而使其性能归因于更高级别的使用。

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[MemoryDiagnoser(displayGenColumns: false)]
public class Tests
{
    [Benchmark]
    public string GetFolderPath() => Environment.GetFolderPath(Environment.SpecialFolder.System);
}
Method Runtime Mean Ratio Allocated Alloc Ratio
GetFolderPath .NET 7.0 1,560.87 ns 1.00 88 B 1.00
GetFolderPath .NET 8.0 45.76 ns 0.03 64 B 0.73

dotnet/runtime#73983 改进了 DirectoryInfoFileInfo,使 FileSystemInfo.Name 属性延迟创建。之前,在构建信息对象时,如果只有完整名称存在(而不仅仅是目录或文件本身),构造函数会立即创建 Name 字符串,即使信息对象从未被使用(通常是像从方法 CreateDirectory 返回时的情况)。现在,在 Name 属性的第一次使用中,该属性将延迟创建 Name 字符串。

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[MemoryDiagnoser(displayGenColumns: false)]
public class Tests
{
    private readonly string _path = Environment.CurrentDirectory;

    [Benchmark]
    public DirectoryInfo Create() => new DirectoryInfo(_path);
}
Method Runtime Mean Ratio Allocated Alloc Ratio
Create .NET 7.0 225.0 ns 1.00 240 B 1.00
Create .NET 8.0 170.1 ns 0.76 200 B 0.83

File.Copy 在 macOS 上已经变得更快了,多亏了 dotnet/runtime#79243@hamarb123 的贡献。现在,File.Copy 使用操作系统中的 clonefile 函数(如果可用)执行复制,如果源文件和目标文件都在同一个卷中,clonefile 会为目标目录创建一个 copy-on-write 克隆文件。这使得在操作系统层面上进行复制,只需要复制数据,而不需要写入数据,从而使复制速度更快,仅在写入数据时才会复制数据的情况下的复制成本占主导地位。

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD", "Min", "Max")]
public class Tests
{
    private string _source;
    private string _dest;

    [GlobalSetup]
    public void Setup()
    {
        _source = Path.GetTempFileName();
        File.WriteAllBytes(_source, Enumerable.Repeat((byte)42, 1_000_000).ToArray());
        _dest = Path.GetRandomFileName();
    }

    [Benchmark]
    public void FileCopy() => File.Copy(_source, _dest, overwrite: true);

    [GlobalCleanup]
    public void Cleanup()
    {
        File.Delete(_source);
        File.Delete(_dest);
    }
}
Method Runtime Mean Ratio
FileCopy .NET 7.0 1,624.8 us 1.00
FileCopy .NET 8.0 366.7 us 0.23

一些更具体的更改已经融入了其中。TextWriter是一个用于将文本写入任意目的地的核心抽象,但有时您希望目的地既不是空闲的,也不是/dev/null这样的地方。为此,TextWriter提供了TextWriter.Null属性,它返回一个在所有成员上都截断的TextWriter实例。或者,至少从可见的行为来看是这样。在实践中,只有其成员中的一部分被覆盖,这意味着尽管不会输出任何内容,但仍然会有一些工作要做,然后将工作的成果扔掉。dotnet/runtime#83293 确保所有写入方法都被覆盖,以消除所有浪费的工作。

更进一步,TextWriter的一个使用场景是在 Console中,Console.SetOut 允许您用您的自定义输出器替换 stdout,此时所有 Console 中的写入方法都会输出到该 TextWriter。为了提供写入的线程安全性,Console 会同步对底层写入器的访问,但是如果写入器本身执行了 nops,则无需进行同步。 dotnet/runtime#83296 在这种情况下解决了这个问题,如果您想暂时阻止 Console 的输出,您可以直接将 Console 的输出设置为 TextWriter.Null,这样 Console 的开销操作将最小化。

// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);

[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[MemoryDiagnoser(displayGenColumns: false)]
public class Tests
{
    private readonly string _value = "42";

    [GlobalSetup]
    public void Setup() => Console.SetOut(TextWriter.Null);

    [Benchmark]
    public void WriteLine() => Console.WriteLine("The value was {0}", _value);
}
Method Runtime Mean Ratio Allocated Alloc Ratio
WriteLine .NET 7.0 80.361 ns 1.00 56 B 1.00
WriteLine .NET 8.0 1.743 ns 0.02 0.00

net7

File I/O

.NET 6带来了一些巨大的文件I/O改进,特别是FileStream的完全重写。虽然.NET 7没有任何单一的改变可以与之相比,但它确实有大量的改进可以显著地“move the needle(移动指针)”,并且方式各异。

一种可靠性改进也可以视为性能改进,那就是提高对取消请求的响应速度。某件事情能被取消得越快,系统就能越早地回收正在使用的宝贵资源,等待该操作完成的事情也能越早地解除阻塞。在.NET 7中,有几处这样的改进。

在某些情况下,它来自添加可取消的重载,其中之前是不可取消的。这正是 dotnet/runtime#61898@bgrainger 添加的新可取消的重载的案例,包括对 TextReader.ReadLineAsyncTextReader.ReadToEndAsync 的方法重写,以及包含对 StreamReaderStringReader 方法的重载;dotnet/runtime#64301@bgrainger 接着重载了这些方法(以及其他缺失的重载),并将其应用于返回自 TextReader.NullStreamReader.NullNullStreamReader 类型(有趣的是,这些被定义为两种不同的类型,不必要的,因此这个 PR 还统一了它们的使用,满足所需类型)。您可以在 dotnet/runtime#66492@lateapexearlyspeed 添加的新 File.ReadLinesAsync 方法中看到这一点。该方法基于对 new StreamReader.ReadLineAsync 的简单环形遍历生成的 IAsyncEnumerable<string>,因此本身也是可取消的。

从我的角度来看,更有趣的情况是,当一个现有的重载形式看似可取消但实际上不可取消。例如,基本 Stream.ReadAsync 方法只是封装了 Stream.BeginRead/EndRead 方法,这些方法是不可取消的,所以如果一个 Stream 类继承 ReadAsync,但不想覆盖它,那么尝试取消调用其 ReadAsync 调用将至少没有效果。它会先提前进行取消检查,以便在请求取消之前调用,它将立即取消,但是在此之后的检查中,提供的 CancellationToken 实际上会被忽略。随着时间的推移,我们试图消除所有剩余的此类情况,但仍有几颗顽固的棋子。一种可疑的情况是关于管道。对于此讨论,有两种相关的管道类型,即匿名和命名管道,它们在 .NET 中表示为一对流:AnonymousPipeClientStream/AnonymousPipeServerStreamNamedPipeClientStream/NamedPipeServerStream。另外,在 Windows 上,操作系统对同步 I/O 和异步 I/O(即并行 I/O)打开的端口和打开的端口之间做出了区分,这在 .NET API 中有所体现:您可以在构建时指定 PipeOptions.Asynchronous 选项来打开一个命名管道用于同步或异步 I/O。在 Unix 上,与它们的命名相反,命名管道实际上是利用 Unix 的 sockets 实现的。现在是一些历史记录:

  • .NET Framework 4.8:不支持取消操作。管道Stream派生类型甚至没有重写ReadAsyncWriteAsync,所以它们只进行了取消检查,然后忽略了token。
  • .NET Core 1.0:在Windows上,对于开启异步I/O的命名管道,完全支持取消操作。实现会向CancellationToken注册,一旦收到取消请求,就会使用CancelIoEx取消与异步操作相关的NativeOverlapped*。在Unix上,命名管道是基于套接字实现的,如果管道是用PipeOptions.Asynchronous打开的,实现会通过轮询来模拟取消:而不是简单地发出Socket.ReceiveAsync/Socket.SendAsync(当时不可取消),它会将一个工作项排队到ThreadPool,这个工作项会运行一个轮询循环,进行带有小超时的Socket.Poll调用,检查token,然后继续循环,直到Poll指示操作会成功或者请求取消。在Windows和Unix上,除了用Asynchronous打开的命名管道,一旦操作开始,取消就是一个空操作。
  • .NET Core 2.1:在Unix上,实现得到了改进,避免了轮询循环,但它仍然缺乏真正可取消的Socket.ReceiveAsync/Socket.SendAsync。相反,此时Socket.ReceiveAsync支持零字节读取,调用者可以传递一个零长度的缓冲区给ReceiveAsync,并使用它作为数据可用于消费的通知,而无需实际消费它。然后,Unix上的异步命名管道流的实现改为发出零字节读取,并将await一个Task.WhenAny,既包括该操作的任务,也包括在请求取消时完成的任务。更好,但仍然远非理想。
  • .NET Core 3.0:在Unix上,Socket获得了真正可取消的ReceiveAsyncSendAsync方法,异步命名管道被更新以使用它们。此时,Windows和Unix的实现在取消方面实际上是相当的;对于异步命名管道都很好,对于其他所有东西只是摆设。
  • .NET 5:在Unix上,暴露了SafeSocketHandle,并且可以为任意提供的SafeSocketHandle创建一个Socket,这使得可以创建一个实际上指向匿名管道的Socket。这反过来使得Unix上的每个PipeStream都可以用Socket来实现,这使得ReceiveAsync/SendAsync对于匿名和命名管道都是完全可取消的,无论它们是如何打开的。

通过.NET 5,问题在Unix上得到了解决,但在Windows上仍然是一个问题。直到现在。在.NET 7中,我们通过dotnet/runtime#72503 (和一个后续的修改dotnet/runtime#72612)使Windows上的所有操作都变得完全可取消。Windows目前不支持非匿名I/O的overlapped I/O,因此对于非匿名I/O和同步I/O打开的命名管道,Windows实现将直接委托到基本Stream实现,这将在一个线程上队列一个工作项到ThreadPool调用同步对应方法,只需在另一个线程上执行。相反,实现现在将工作项队列,但不仅仅是调用同步方法,还在工作项中执行一些预工作和后工作,注册为取消,并传递线程ID,以便在执行I/O时调用。如果要求取消,实现 then使用CancelSynchronousIo来中断它。这里存在一个死锁,即在注册取消之前请求取消,操作可能已经开始了。所以,有一个小的死锁循环,即在注册取消和实际执行同步I/O之间的时间段内请求取消,取消线程将一直旋转,直到I/O开始,但这种情况被期望为非常罕见。在另一侧,CancelSynchronousIo在I/O完成之后被请求;为解决这个死锁,实现依赖于CancellationTokenRegistration.Dispose的保证,该保证承诺与关联的回调永远不会被调用或已经完成执行。不仅这个实现完成了在Windows和Unix上所有非同步读/写操作的取消,而且实际上还提高了正常吞吐量。

private Stream _server;
private Stream _client;
private byte[] _buffer = new byte[1];
private CancellationTokenSource _cts = new CancellationTokenSource();

[Params(false, true)]
public bool Cancelable { get; set; }

[Params(false, true)]
public bool Named { get; set; }

[GlobalSetup]
public void Setup()
{
    if (Named)
    {
        string name = Guid.NewGuid().ToString("N");
        var server = new NamedPipeServerStream(name, PipeDirection.Out);
        var client = new NamedPipeClientStream(".", name, PipeDirection.In);
        Task.WaitAll(server.WaitForConnectionAsync(), client.ConnectAsync());
        _server = server;
        _client = client;
    }
    else
    {
        var server = new AnonymousPipeServerStream(PipeDirection.Out);
        var client = new AnonymousPipeClientStream(PipeDirection.In, server.ClientSafePipeHandle);
        _server = server;
        _client = client;
    }
}

[GlobalCleanup]
public void Cleanup()
{
    _server.Dispose();
    _client.Dispose();
}

[Benchmark(OperationsPerInvoke = 1000)]
public async Task ReadWriteAsync()
{
    CancellationToken ct = Cancelable ? _cts.Token : default;
    for (int i = 0; i < 1000; i++)
    {
        ValueTask<int> read = _client.ReadAsync(_buffer, ct);
        await _server.WriteAsync(_buffer, ct);
        await read;
    }
}
Method Runtime Cancelable Named Mean Ratio Allocated Alloc Ratio
ReadWriteAsync .NET 6.0 False False 22.08 us 1.00 400 B 1.00
ReadWriteAsync .NET 7.0 False False 12.61 us 0.76 192 B 0.48
ReadWriteAsync .NET 6.0 False True 38.45 us 1.00 400 B 1.00
ReadWriteAsync .NET 7.0 False True 32.16 us 0.84 220 B 0.55
ReadWriteAsync .NET 6.0 True False 27.11 us 1.00 400 B 1.00
ReadWriteAsync .NET 7.0 True False 13.29 us 0.52 193 B 0.48
ReadWriteAsync .NET 6.0 True True 38.57 us 1.00 400 B 1.00
ReadWriteAsync .NET 7.0 True True 33.07 us 0.86 214 B 0.54

除了对.NET 7中I/O进行的其他性能相关更改,主要集中在两个问题上:减少系统调用和减少分配。

几个PR将精力投入到了Unix上的系统调用减少上,例如File.CopyFileInfo.CopyTodotnet/runtime#59695@tmds 减少了多种方式的开销。该代码首先执行stat调用以确定源是否实际上是目录,如果是,则操作将出错。相反,PR只是尝试打开源文件,而无论如何,对于复制操作,它都需要这样做,然后只执行stat调用,如果打开文件失败。如果文件打开成功,代码已经执行了fstat来收集有关文件的数据,例如文件是否可读取;通过这个改变,它现在还从单次fstat的结果中提取源文件大小,然后将其传递给核心复制程序,从而避免了由于获取大小而执行的fstat系统调用。节省这些系统调用非常棒,尤其是对于非常小的文件来说,设置复制的开销可能比复制字节还要昂贵。但这个PR最大的好处是它利用了Linux上的IOCTL-FICLONERANGE

一些Linux文件系统,如XFS和Btrfs,支持“写入时复制”,这意味着文件系统不仅将所有数据复制到新文件,而且还会在底层存储中记录有两个指向相同数据的文件。这使得“复制”变得非常快速,因为无需复制数据,文件系统只需要更新一些账目;此外,由于只有一个存储数据,因此磁盘上的空间消耗更少。文件系统只需要复制其中一个文件中已覆盖的数据。

这个PR使用ioctlFICLONE在源和目标文件系统相同且文件系统支持此操作时执行复制。类似地,dotnet/runtime#64264@tmds 进一步改进了File.Copy/FileInfo.CopyTo,如果支持(并且只有新内核足够新来解决之前版本中此函数的一些问题),则在Linux上利用copy_file_range。与典型的读/写循环不同,copy_file_range被设计为完全在内核态,而不需要每读取和写入数据过渡到用户空间。

另一个避免系统调用的方法是在 Unix 上的 File.WriteXxFile.AppendXx 方法。这些方法的实现会直接打开一个 FileStream 或一个 SafeFileHandle,并指定了 FileOptions.SequentialScanSequentialScan 主要与从文件中读取数据相关,它暗示了操作系统缓存,期望数据是从文件按顺序而不是随机地读取。然而,这些写入/追加方法并不会读取,它们只会写入,而且 FileOptions.SequentialScan 在 Unix 上的实现还需要通过 posix_fadvise 发出一个额外的系统调用(传递 POSIX_FADV_SEQUENTIAL)。因此,我们为此支付了系统调用费用,但却没有从中受益。这种情况与著名的 Henny Youngman 笑话类似: “病人说,‘医生,这很疼’;医生说,‘那么别那么做!’”。在这里,答案也是“别那么做”,所以 dotnet/runtime#59247@tmds 停止在不会有所帮助但可能会带来损害的地方 passing SequentialScan

目录处理在目录生命周期内减少了系统调用,尤其是在 Unix 系统上。 dotnet/runtime#58799@tmds 处加速了 Unix 系统上的目录创建。之前,目录创建的实现会首先检查目录是否已存在,这涉及一个系统调用。在预期的小多数情况下,目录已经存在,代码可以提前退出。但是,在预期的大多数情况下,目录不存在,然后它会解析文件路径以查找其中的所有目录,并递归地遍历目录列表,直到找到一个存在的目录,然后尝试创建该目录下的所有子目录。然而,预期的大多数情况是父目录已经存在,而子目录不存在,在这种情况下,我们仍然要为解析付费,而本来可以只创建目标目录。该拉票修复了这个问题,通过将开头的存在检查更改为简单地尝试创建目标目录;如果成功,那么我们就可以直接结束,如果失败,可以使用错误码来判断 mkdir 是否失败,因为 mkdir 没有工作可做。 dotnet/runtime#61777 更进一步,通过使用栈内存为传递给 mkdir 的路径暂时需要的栈内存进行目录创建,避免了在创建目录时使用字符串分配内存。

dotnet/runtime#63675 改进了移动目录的性能,在 Unix 和 Windows 上都减少了几个系统调用。Directory.MoveDirectorInfo.MoveTo 的共享代码在源和目标目录都进行了显式的目录存在检查,但在 Windows 上,Win32 API 调用自身进行移动时不需要进行这些检查,因此它们不需要预先阻止。在 Unix 上,我们也可以类似地避免源目录的存在检查,因为 rename 函数调用时,类似地,如果源目录不存在,它将失败并抛出适当的错误,我们可以通过错误信息得出发生了什么,以便抛出正确的异常。对于目标目录,代码在移动时曾经为目标目录的存在作为文件或目录分别进行单独的检查,但只需要一个 stat 调用就可以满足要求。

private string _path1;
private string _path2;

[GlobalSetup]
public void Setup()
{
    _path1 = Path.GetTempFileName();
    _path2 = Path.GetTempFileName();
    File.Delete(_path1);
    File.Delete(_path2);
    Directory.CreateDirectory(_path1);
}

[Benchmark]
public void Move()
{
    Directory.Move(_path1, _path2);
    Directory.Move(_path2, _path1);
}
Method Runtime Mean Ratio Allocated Alloc Ratio
Move .NET 6.0 31.70 us 1.00 256 B 1.00
Move .NET 7.0 26.31 us 0.83 0.00

在Unix系统上,dotnet/runtime#59520@tmds 引入了目录删除功能,特别是递归删除(删除目录及其所有内容),通过利用文件系统枚举提供的信息来避免进行二次存在检查。

Syscalls减少也是为了支持内存映射文件。 dotnet/runtime#63754 在打开内存映射文件时,利用特殊的装载方式进行操作,同时调用 File.Exists 来确定指定文件是否已存在。这是因为后来在处理错误和异常时,实现需要知道是否要删除可能存在的文件,实现会构造一个 FileStream,而调用 might 将指定文件放入存在状态。但是,只有某些 FileMode 值才支持这种操作,这是通过传递给 CreateFromFile 的参数进行可配置的。FileMode 的常见和默认值是 FileMode.Open,这意味着如果构建 FileStream 出错,将会抛出异常。这意味着我们只有在 FileMode 不是 OpenCreateNew 时才需要调用 File.Exists,从而在大多数情况下,我们实际上只需要调用一次 File.Exists,就可以避免不必要的系统调用。 dotnet/runtime#63790 也有助于解决这个问题,主要有两个方面。首先,在 CreateFromFile 操作过程中,实现可能会多次访问 FileStreamLength,但每次调用都会系统调用读取文件底层长度。我们可以先读取一次并使用该值进行所有检查。其次,.NET 6 引入了 File.OpenHandle 方法,它允许我们直接在 SafeFileHandle 中打开文件句柄/文件描述符,而不必通过 FileStream 进行操作。在 MemoryMappedFile 中使用 FileStream 实际上非常小,因此使用 SafeFileHandle 直接打开文件句柄/文件描述符会更加简单,而不必构建冗余的 FileStream及其相关状态。这有助于减少分配。

最后,有 dotnet/runtime#63794,它认识到一个以只读方式打开的 MemoryMappedViewAccessorMemoryMappedViewStream 无法被写入。这听起来很正常,但这一举措的实际意义在于:如果视图不可写,那么关闭视图不必担心刷新,因为那里的数据在实现中没有变化,而刷新视图可能代价高昂,尤其是对于较大的视图。因此,对不可写视图进行一个简单的修改以避免刷新,可以显著提高 MemoryMappedViewAccessor/MemoryMappedViewStream‘的 Dispose 功能。

private string _path;

[GlobalSetup]
public void Setup()
{
    _path = Path.GetTempFileName();
    File.WriteAllBytes(_path, Enumerable.Range(0, 10_000_000).Select(i => (byte)i).ToArray());
}

[GlobalCleanup]
public void Cleanup()
{
    File.Delete(_path);
}

[Benchmark]
public void MMF()
{
    using var mmf = MemoryMappedFile.CreateFromFile(_path, FileMode.Open, null);
    using var s = mmf.CreateViewStream(0, 10_000_000, MemoryMappedFileAccess.Read);
}
Method Runtime Mean Ratio Allocated Alloc Ratio
MMF .NET 6.0 315.7 us 1.00 488 B 1.00
MMF .NET 7.0 227.1 us 0.68 336 B 0.69

除了系统调用之外,还针对减少分配进行了诸多改进。一个典型的改进是dotnet/runtime#58167,它改进了常用的File.WriteAllText{Async}File.AppendAllText{Async}方法。PR修改了两点:第一,这些操作足够常见,值得避免通过FileStream带来的小但可测量的开销,而直接使用底层的SafeFileHandle,第二,由于方法将整个负载传递给输出,实现可以利用这些已知信息(特别是长度)来优化之前使用的StreamWriter。在这样做的过程中,实现避免了流、写入器和临时缓冲区的开销(主要是分配)。

private string _path;

[GlobalSetup]
public void Setup() => _path = Path.GetRandomFileName();

[GlobalCleanup]
public void Cleanup() => File.Delete(_path);

[Benchmark]
public void WriteAllText() => File.WriteAllText(_path, Sonnet);
Method Runtime Mean Ratio Allocated Alloc Ratio
WriteAllText .NET 6.0 488.5 us 1.00 9944 B 1.00
WriteAllText .NET 7.0 482.9 us 0.99 392 B 0.04

dotnet/runtime#61519 类似地,它将 File.ReadAllBytes{Async} 从通过 FileStream 获取直接使用 SafeFileHandle(并使用 RandomAccess)更改为通过 FileStream 获取。它还像之前一样做了同样的 SequentialScan 更改。虽然这个案例是关于读取(而之前的更改认为附加的系统调用开销没有好处),但 ReadAllBytes{Async} 确实非常常用于读取较小的文件,其中附加的系统调用开销可能达到总成本的 10%(对于较大的文件,现代内核在没有序列化提示的情况下进行缓存表现相当好,因此这里的负面影响可以忽略)。

另一个 such 更改是 dotnet/runtime#68662,它改进了 Path.Join 对空或空路径段的处理。Path.Join 有接受 string 的重载和接受 ReadOnlySpan<char> 的重载,但所有重载都产生 string。基于 string 的重载只是将每个字符串包裹在 span 中,并将其委托给基于 span 的重载。然而,在执行连接操作时是 nop(例如,有两个路径段,第二个是空,所以连接应该只返回第一个),基于 span 的实现仍然需要创建一个新的字符串(ReadOnlySpan<char>-based 的重载无法从 span 中提取字符串)。因此,在其中一个为空或空的情况下,基于 string 的重载可以略微好一点;它们可以做的事情与 Path.Combine 重载相同,即 M 参数重载委托给 M-1 参数重载,排除空或空路径段,在带两个参数的重载的基情况下,如果路径段是空或空,则另一个(或空)可以直接返回。

除此之外,还有许多以分配为基础的 PR,例如 dotnet/runtime#69335 来自 @pedrobsaila,它为我们在 Unix 上使用的任何地方添加了基于堆栈分配的快速路径到内部 ReadLink 辅助函数;或者 dotnet/runtime#68752 更新了 NamedPipeClientStream.ConnectAsync,通过明确通过调用 Task.Factory.StartNew 传递状态,以删除委托分配;或者 dotnet/runtime#69412Assembly.GetManifestResourceStream 返回的 Stream 添加了优化的 Read(Span<byte>) 覆盖。

但是我在这个领域个人最喜欢的改进来自于dotnet/runtime#69272,它为Stream添加了几个辅助方法:

public void ReadExactly(byte[] buffer, int offset, int count);
public void ReadExactly(Span<byte> buffer);

public ValueTask ReadExactlyAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default);
public ValueTask ReadExactlyAsync(Memory<byte> buffer, CancellationToken cancellationToken = default);

public int ReadAtLeast(Span<byte> buffer, int minimumBytes, bool throwOnEndOfStream = true);
public ValueTask<int> ReadAtLeastAsync(Memory<byte> buffer, int minimumBytes, bool throwOnEndOfStream = true, CancellationToken cancellationToken = default);

公平地说,这些更多地是关于可用性而不是性能,但在这方面,两者之间有很强的相关性。通常,在需要功能时,我们会为自己编写这些辅助函数(前述PR删除了许多核心库中的开箱即用的循环),这是非常常见的,而且不幸的是,在这些方面很容易弄错,从而影响性能,比如通过使用需要返回一个Task<int>Stream.ReadAsync重载或者读取的字节数少于允许的这种情况。这些实现是正确的且高效的。

net6

IO

在.NET 6中,投入了大量精力来修复.NET中最古老类型之一FileStream的性能问题。每个应用程序和服务都需要读写文件。不幸的是,FileStream多年来也受到许多与性能相关的问题的困扰,这些问题主要源于其在Windows上的异步I/O实现。例如,调用ReadAsync可能会发出一个 overlapped I/O读操作,但通常情况下,那个读操作都会以同步/异步方式结束,以避免可能导致潜在竞态条件中出现的问题。或者,当缓冲区溢出时,即使是在异步情况下进行缓冲,那些溢出也会以同步写的方式结束。这些问题往往使得使用异步I/O的好处大打折扣,同时还要承担异步I/O所带来的开销(异步I/O往往比同步I/O具有更高的开销,因此也更加可伸缩)。这一切变得更加复杂化,因为FileStream代码是一个错综复杂、难以理清的迷宫,很大程度上是因为它试图将几种不同的功能集成到同一个代码路径中:是否使用 overlapped I/O,是否进行缓冲,目标是磁盘文件还是管道等,每种情况都有不同的逻辑,所有这些都交织在一起。综合来看,这意味着,除了少数例外,FileStream的代码在很大程度上保持不变,直到现在。

.NET 6对FileStream进行了完全重写,在此过程中解决了所有这些问题。得到了一个更易于维护且速度更快(特别是对于异步操作)的实现。为此,进行了大量的PR,我这里列举其中的一些。首先,dotnet/runtime#47128为新的实现奠定了基础,将FileStream重构为一个“策略”(即策略设计模式中的策略)的封装,然后在运行时实现替换和组合(类似于与Random讨论的方法),将现有的实现移动到可以用于.NET 6的策略中(最大兼容性要求时,它是默认的,但可以通过环境变量或AppContext开关启用)。dotnet/runtime#48813dotnet/runtime#49750则引入了新实现的开始,将其拆分为多个策略,用于在Windows上的文件打开方式,一个用于同步I/O打开的文件,一个用于异步I/O打开的文件,以及一个允许在策略上缓冲任何策略。dotnet/runtime#55191则引入了一个Unix优化的策略,用于新的设计。在此期间,还出现了许多其他PR,以优化各种条件。dotnet/runtime#49975dotnet/runtime#56465避免了在Windows上每个操作上跟踪文件长度的昂贵syscall,而dotnet/runtime#44097在Unix上禁用了一个不必要的文件长度设置syscall。dotnet/runtime#50802dotnet/runtime#51363更改了Windows上异步I/O实现的实现,使其不再基于TaskCompletionSource,而是基于可重用的IValueTaskSource实现,这使得(非缓冲)异步读写操作的延迟-分配-免费。dotnet/runtime#55206@tmds使用了在Unix上现有的syscall的知识,从而避免了之后的不必要的statsyscall。dotnet/runtime#56095利用了之前讨论的PoolingAsyncValueTaskMethodBuilder,减少了在使用缓冲时FileStream上异步操作的分配。dotnet/runtime#56387避免了在Windows上进行ReadFile调用,如果我们已经拥有了足够的信息来证明没有内容可以读取。而dotnet/runtime#56682则将Unix上的Read/WriteAsync的优化应用于Windows,并在FileStream以同步I/O打开时应用这些优化。最终,所有这些加起来为FileStream带来了巨大的可维护性改进、巨大的性能改进(特别是对于异步操作)以及更好的可扩展性。这里列举一些微基准测试来突出其影响:

private FileStream _fileStream;
private byte[] _buffer = new byte[1024];

[Params(false, true)]
public bool IsAsync { get; set; }

[Params(1, 4096)]
public int BufferSize { get; set; }

[GlobalSetup]
public void Setup()
{
    byte[] data = new byte[10_000_000];
    new Random(42).NextBytes(data);

    string path = Path.GetTempFileName();
    File.WriteAllBytes(path, data);

    _fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, IsAsync);
}

[GlobalCleanup]
public void Cleanup()
{
    _fileStream.Dispose();
    File.Delete(_fileStream.Name);
}

[Benchmark]
public void Read()
{
    _fileStream.Position = 0;
    while (_fileStream.Read(_buffer
#if !NETCOREAPP2_1_OR_GREATER
            , 0, _buffer.Length
#endif
            ) != 0) ;
}

[Benchmark]
public async Task ReadAsync()
{
    _fileStream.Position = 0;
    while (await _fileStream.ReadAsync(_buffer
#if !NETCOREAPP2_1_OR_GREATER
            , 0, _buffer.Length
#endif
            ) != 0) ;
}
Method Runtime IsAsync BufferSize Mean Ratio Allocated
Read .NET Framework 4.8 False 1 30.717 ms 1.00
Read .NET Core 3.1 False 1 30.745 ms 1.00
Read .NET 5.0 False 1 31.156 ms 1.01
Read .NET 6.0 False 1 30.772 ms 1.00
ReadAsync .NET Framework 4.8 False 1 50.806 ms 1.00 2,125,865 B
ReadAsync .NET Core 3.1 False 1 44.505 ms 0.88 1,953,592 B
ReadAsync .NET 5.0 False 1 39.212 ms 0.77 1,094,096 B
ReadAsync .NET 6.0 False 1 36.018 ms 0.71 247 B
Read .NET Framework 4.8 False 4096 9.593 ms 1.00
Read .NET Core 3.1 False 4096 9.761 ms 1.02
Read .NET 5.0 False 4096 9.446 ms 0.99
Read .NET 6.0 False 4096 9.569 ms 1.00
ReadAsync .NET Framework 4.8 False 4096 30.920 ms 1.00 2,141,481 B
ReadAsync .NET Core 3.1 False 4096 23.758 ms 0.81 1,953,592 B
ReadAsync .NET 5.0 False 4096 25.101 ms 0.82 1,094,096 B
ReadAsync .NET 6.0 False 4096 13.108 ms 0.42 382 B
Read .NET Framework 4.8 True 1 413.228 ms 1.00 2,121,728 B
Read .NET Core 3.1 True 1 217.891 ms 0.53 3,050,056 B
Read .NET 5.0 True 1 219.388 ms 0.53 3,062,741 B
Read .NET 6.0 True 1 83.070 ms 0.20 2,109,867 B
ReadAsync .NET Framework 4.8 True 1 355.670 ms 1.00 3,833,856 B
ReadAsync .NET Core 3.1 True 1 262.625 ms 0.74 3,048,120 B
ReadAsync .NET 5.0 True 1 259.284 ms 0.73 3,047,496 B
ReadAsync .NET 6.0 True 1 119.573 ms 0.34 403 B
Read .NET Framework 4.8 True 4096 106.696 ms 1.00 530,842 B
Read .NET Core 3.1 True 4096 56.785 ms 0.54 353,151 B
Read .NET 5.0 True 4096 54.359 ms 0.51 353,966 B
Read .NET 6.0 True 4096 22.971 ms 0.22 527,930 B
ReadAsync .NET Framework 4.8 True 4096 143.082 ms 1.00 3,026,980 B
ReadAsync .NET Core 3.1 True 4096 55.370 ms 0.38 355,001 B
ReadAsync .NET 5.0 True 4096 54.436 ms 0.38 354,036 B
ReadAsync .NET 6.0 True 4096 32.478 ms 0.23 420 B

一些FileStream的改进包括将其实现中的读/写方面移动到单独的公共类:System.IO.RandomAccess。此类的实现已在dotnet/runtime#53669 dotnet/runtime#54266dotnet/runtime#55490 (来自@teo-tsirpanis)中进行了优化,其中包括使用静态方法提供同步和异步读/写功能,可以同时使用一个或多个缓冲区,并指定在文件中读/写的精确偏移量。所有这些静态方法都接受一个SafeFileHandle,现在可以从新的File.OpenHandle方法中获取。这意味着,如果基于FileStream的接口不理想,代码现在就可以直接访问文件,而无需通过FileStream进行访问。这意味着,如果想要并行处理文件,代码现在就可以并发读/写相同的SafeFileHandle。(后续PR如dotnet/runtime#55150 利用了这些新API,避免了使用FileStream时所需的额外分配和复杂性,当时只需要一个文件句柄和执行单读或写操作的能力。)@adamsitnik正在编写一篇专注于这些FileStream改进的专篇博客,不久后将在.NET博客上发布。

当然,文件操作远不止 FileStream 所能提供的功能。 dotnet/runtime#55210@tmds 消除了一份 stat 系统调用,当目标不存在时,Directory/File.Exists 中的 FileStream 将不会调用 stat 系统调用。 dotnet/runtime#47118@gukoff 消除了一份在 Unix 上移动文件时可能发生的 rename 系统调用。 dotnet/runtime#55644 简化了 File.WriteAllTextAsync 的实现,并使它更快速,且需要更少的分配(当然,这个基准测试也得益于 FileStream 的改进)。

private static string s_contents = string.Concat(Enumerable.Range(0, 100_000).Select(i => (char)('a' + (i % 26))));
private static string s_path = Path.GetRandomFileName();

[Benchmark]
public Task WriteAllTextAsync() => File.WriteAllTextAsync(s_path, s_contents);
Method Runtime Mean Ratio Allocated
WriteAllTextAsync .NET Core 3.1 1.609 ms 1.00 23 KB
WriteAllTextAsync .NET 5.0 1.590 ms 1.00 23 KB
WriteAllTextAsync .NET 6.0 1.143 ms 0.72 15 KB

当然,IO远不止文件。在Windows上,NamedPipeServerStream提供了类似于FileStream的基于覆盖的I/O实现。随着FileStream的实现被重构,dotnet/runtime#52695@manandre也重构了文件的管道实现,以模仿FileStream中使用的相同更新结构,从而产生了许多相同的好处,特别是在由于可重用IValueTaskSource实现而不是TaskCompletionSource实现而导致的分配减少方面。

在压缩方面,除了引入了新的ZlibStreamdotnet/runtime#42717)外,用于BrotliStreamBrotliEncoderBrotliDecoder背后的底层Brotli代码已从v1.0.7升级到v1.0.9。这次升级带来了许多性能改进,包括更好地利用内存路径的代码。并不是所有的压缩/解压缩测量都有显著的益处,但有些确实是的:

private byte[] _toCompress;
private MemoryStream _destination = new MemoryStream();

[GlobalSetup]
public async Task Setup()
{
    using var hc = new HttpClient();
    _toCompress = await hc.GetByteArrayAsync(@"https://raw.githubusercontent.com/dotnet/performance/5584a8b201b8c9c1a805fae4868b30a678107c32/src/benchmarks/micro/corefx/System.IO.Compression/TestData/alice29.txt");
}

[Benchmark]
public void Compress()
{
    _destination.Position = 0;
    using var ds = new BrotliStream(_destination, CompressionLevel.Fastest, leaveOpen: true);
    ds.Write(_toCompress);
}
Method Runtime Mean Ratio
Compress .NET 5.0 1,050.2 us 1.00
Compress .NET 6.0 786.6 us 0.75

dotnet/runtime#47125 是由 @NewellClark 贡献的,他向各种 Stream 类型添加了一些缺失的覆盖,包括 DeflateStream,它具有减少 DeflateStream.WriteAsync 开销的效果。

DeflateStream(以及 GZipStreamBrotliStream)中有一个有趣的性能改进。对于异步读取操作的 Stream 合同规定,只要您请求至少一个字节的数据,操作不会完成,直到至少一个字节被读取为止。然而,合同没有保证该操作将返回您请求的所有数据,事实上,很少有流能做出这样的承诺,并且在很多情况下,当它这样做时会带来问题。不幸的是,作为实现细节,DeflateStream 实际上试图返回尽可能多的数据,通过向底层流发出尽可能多的读取请求来实现,只有在解码足够多的数据以满足请求或在底层流上遇到 EOF(文件末尾)时才停止。这对多个原因都有问题。首先,这阻止了已经收到但需要等待更多数据来处理的数据的并行处理;如果已经准备好了100个字节的数据,但您要求200个字节,那么我被迫等待直到另一个100个字节的数据到达,或者流到达文件末尾。其次,更严重的是,它实际上阻止了 DeflateStream 在任何双向通信场景中使用。想象一下一个 DeflateStream 围绕在一个 NetworkStream 上,流正在用于向远程方发送和接收压缩消息。假设我向 DeflateStream 传递一个1K缓冲区,远程方发送我一个100个字节的消息,我应该读取并响应(远程方将在发送任何进一步消息之前等待我的响应)。DeflateStream 在这里的行為将导致整个系统陷入死锁,因为它将阻止接收等待另一个900个字节或 EOF(文件末尾)的消息。修复此问题通过允许 DeflateStream(以及其他流)在拥有可处理数据时返回,即使不是请求的总字节数。这已经被记录为破坏性更改(https://docs.microsoft.com/dotnet/core/compatibility/core-libraries/6.0/partial-byte-reads-in-streams),不是因为先前的行为得到了保证(它没有),但是我们已经看到太多代码错误地依赖先前的行为,因此这个问题很重要。

PR还修复了一个与性能相关的问题。需要意识到可扩展 Web 服务器的一个问题是内存利用率。如果您有1000个打开的连接,并且您正在等待每个连接的数据到来,您可以使用缓冲区对每个连接进行异步读取,但是如果您使用的缓冲区大小为4K,那么里面有4MB的缓冲区在浪费工作带。相反,您可以使用零字节读取,其中您只是通知有数据可以接收时进行空读,从而避免缓冲区对工作带的影响,只需在知道要放入数据时分配或租赁缓冲区。许多旨在实现双向通信的Stream,如NetworkStreamSslStream,都支持这种零字节读取,不会在空读操作返回之前返回空读。对于.NET 6,DeflateStream也可以用于这种用途,PR 对实现进行了修改,以确保在DeflateStream的输出缓冲区为空时,即使调用者要求零字节,DeflateStream也会向其底层Stream发出读取请求。想要避免这种行为的调用者可以简单地避免进行0字节调用。

继续前进,对于 System.IO.Pipelines,几个PR提高了性能。dotnet/runtime#55086 添加了ReadByteWriteByte的覆盖,分别在缓冲区中已经读取或缓冲区中可写字节时避免了异步代码路径。此外,dotnet/runtime#52159@manandre那里添加了一个CopyToAsync覆盖到用于从Stream中读取的PipeReader,优化了它首先复制已经缓冲的数据,然后将复制委托给StreamCopyToAsync,利用可能提供的任何优化。

除了这些,还有一些小改进。来自@steveberdy(https://github.com/steveberdy)的dotnet/runtime#55373dotnet/runtime#56568删除了不必要的Contains('\0')调用,从@lateapexearlyspeed(https://github.com/lateapexearlyspeed)获得了改进的BufferedStream.Position设置,以避免在将缓冲读数据传给新位置时发生滚动;来自@DavidKarlas(https://github.com/DavidKarlas)的改进避免了在File.GetLastWriteTimeUtc中本地时间上不必要的文件时间轮询;来自@dotnet/runtime#53070(https://github.com/dotnet/runtime/pull/53070)的改进使得在Unix上通过本地时间获取最后写入时间时,避免了不必要的回滚文件时间。最后,来自@dotnet/runtime#43968(https://github.com/dotnet/runtime/pull/43968)将基于派生的Stream类型的参数验证逻辑合并到公有辅助函数(Stream.ValidateBufferArgumentsStream.ValidateCopyToArguments),除了消除重复代码外,还有助于确保行为的一致性,并使用共享且高效的实现来简化验证逻辑。

posted on 2024-03-21 16:32  yahle  阅读(65)  评论(0编辑  收藏  举报