Repository: SixLabors/ImageSharp

Issue:
Async IO methods should use `FileOptions.Asynchronous`
### Prerequisites

- [X] I have written a descriptive issue title
- [X] I have verified that I am running the latest version of ImageSharp
- [X] I have verified if the problem exist in both `DEBUG` and `RELEASE` mode
- [X] I have searched [open](https://github.com/SixLabors/ImageSharp/issues) and [closed](https://github.com/SixLabors/ImageSharp/issues?q=is%3Aissue+is%3Aclosed) issues to ensure it has not already been reported

### ImageSharp version

3.0.1

### Environment (Operating system, version and so on)

Windows 11 Version 22H2

### .NET Framework version

.NET 7.0.304

### Description

Async methods that read/write from the file system, such as `Image.LoadAsync` and `Image.SaveAsync`, use async methods on `FileStream` but don't instantiate the `FileStream` with `FileOptions.Asynchronous`, as should be done when using async operations. This means that the implementation isn't actually truly async and instead uses synchronous IO on a new thread, needlessly using more resources.

There is a simple workaround for this by creating the `FileStream` manually and giving ImageSharp the stream as opposed to the file path, e.g.:
```c#
await using (var fileStream = new FileStream(path, new FileStreamOptions
{
    Mode = FileMode.Open,
    Access = FileAccess.Read,
    Share = FileShare.Read,
    Options = FileOptions.Asynchronous,
}))
using (var image = await Image.LoadAsync(fileStream))
{
// ...
}
```
but I feel like this is a bug and should be fixed in ImageSharp itself. The method is misleading, suggesting that it uses async IO while actually doesn't.


Hints:
This makes sense, here is what BCL is doing for `File.ReadAllTextAsync`:

https://github.com/dotnet/runtime/blob/2f208128f257b2390a8edefa0a19e0eaad6bd419/src/libraries/System.Private.CoreLib/src/System/IO/File.cs#L853-L856

I guess `SequentialScan` would make things worse in our case, because `BufferedReadStream` does `Seek` a lot.

@Neme12 interested creating a PR?

Sure, I can give it a try.

Patch:
diff --git a/src/ImageSharp/IO/IFileSystem.cs b/src/ImageSharp/IO/IFileSystem.cs
index 96a9b5ba01..0f5113eff4 100644
--- a/src/ImageSharp/IO/IFileSystem.cs
+++ b/src/ImageSharp/IO/IFileSystem.cs
@@ -1,4 +1,4 @@
-﻿// Copyright (c) Six Labors.
+// Copyright (c) Six Labors.
 // Licensed under the Six Labors Split License.
 
 namespace SixLabors.ImageSharp.IO;
@@ -9,16 +9,32 @@ namespace SixLabors.ImageSharp.IO;
 internal interface IFileSystem
 {
     /// <summary>
-    /// Returns a readable stream as defined by the path.
+    /// Opens a file as defined by the path and returns it as a readable stream.
     /// </summary>
     /// <param name="path">Path to the file to open.</param>
-    /// <returns>A stream representing the file to open.</returns>
+    /// <returns>A stream representing the opened file.</returns>
     Stream OpenRead(string path);
 
     /// <summary>
-    /// Creates or opens a file and returns it as a writable stream as defined by the path.
+    /// Opens a file as defined by the path and returns it as a readable stream
+    /// that can be used for asynchronous reading.
     /// </summary>
     /// <param name="path">Path to the file to open.</param>
-    /// <returns>A stream representing the file to open.</returns>
+    /// <returns>A stream representing the opened file.</returns>
+    Stream OpenReadAsynchronous(string path);
+
+    /// <summary>
+    /// Creates or opens a file as defined by the path and returns it as a writable stream.
+    /// </summary>
+    /// <param name="path">Path to the file to open.</param>
+    /// <returns>A stream representing the opened file.</returns>
     Stream Create(string path);
+
+    /// <summary>
+    /// Creates or opens a file as defined by the path and returns it as a writable stream
+    /// that can be used for asynchronous reading and writing.
+    /// </summary>
+    /// <param name="path">Path to the file to open.</param>
+    /// <returns>A stream representing the opened file.</returns>
+    Stream CreateAsynchronous(string path);
 }
diff --git a/src/ImageSharp/IO/LocalFileSystem.cs b/src/ImageSharp/IO/LocalFileSystem.cs
index f4dfa2fe14..d1f619f486 100644
--- a/src/ImageSharp/IO/LocalFileSystem.cs
+++ b/src/ImageSharp/IO/LocalFileSystem.cs
@@ -1,4 +1,4 @@
-﻿// Copyright (c) Six Labors.
+// Copyright (c) Six Labors.
 // Licensed under the Six Labors Split License.
 
 namespace SixLabors.ImageSharp.IO;
@@ -11,6 +11,24 @@ internal sealed class LocalFileSystem : IFileSystem
     /// <inheritdoc/>
     public Stream OpenRead(string path) => File.OpenRead(path);
 
+    /// <inheritdoc/>
+    public Stream OpenReadAsynchronous(string path) => File.Open(path, new FileStreamOptions
+    {
+        Mode = FileMode.Open,
+        Access = FileAccess.Read,
+        Share = FileShare.Read,
+        Options = FileOptions.Asynchronous,
+    });
+
     /// <inheritdoc/>
     public Stream Create(string path) => File.Create(path);
+
+    /// <inheritdoc/>
+    public Stream CreateAsynchronous(string path) => File.Open(path, new FileStreamOptions
+    {
+        Mode = FileMode.Create,
+        Access = FileAccess.ReadWrite,
+        Share = FileShare.None,
+        Options = FileOptions.Asynchronous,
+    });
 }
diff --git a/src/ImageSharp/Image.FromFile.cs b/src/ImageSharp/Image.FromFile.cs
index 884acf7a40..a20e5d6c58 100644
--- a/src/ImageSharp/Image.FromFile.cs
+++ b/src/ImageSharp/Image.FromFile.cs
@@ -72,7 +72,7 @@ public static async Task<IImageFormat> DetectFormatAsync(
     {
         Guard.NotNull(options, nameof(options));
 
-        using Stream stream = options.Configuration.FileSystem.OpenRead(path);
+        await using Stream stream = options.Configuration.FileSystem.OpenReadAsynchronous(path);
         return await DetectFormatAsync(options, stream, cancellationToken).ConfigureAwait(false);
     }
 
@@ -144,7 +144,7 @@ public static async Task<ImageInfo> IdentifyAsync(
         CancellationToken cancellationToken = default)
     {
         Guard.NotNull(options, nameof(options));
-        using Stream stream = options.Configuration.FileSystem.OpenRead(path);
+        await using Stream stream = options.Configuration.FileSystem.OpenReadAsynchronous(path);
         return await IdentifyAsync(options, stream, cancellationToken).ConfigureAwait(false);
     }
 
@@ -214,7 +214,7 @@ public static async Task<Image> LoadAsync(
         string path,
         CancellationToken cancellationToken = default)
     {
-        using Stream stream = options.Configuration.FileSystem.OpenRead(path);
+        await using Stream stream = options.Configuration.FileSystem.OpenReadAsynchronous(path);
         return await LoadAsync(options, stream, cancellationToken).ConfigureAwait(false);
     }
 
@@ -291,7 +291,7 @@ public static async Task<Image<TPixel>> LoadAsync<TPixel>(
         Guard.NotNull(options, nameof(options));
         Guard.NotNull(path, nameof(path));
 
-        using Stream stream = options.Configuration.FileSystem.OpenRead(path);
+        await using Stream stream = options.Configuration.FileSystem.OpenReadAsynchronous(path);
         return await LoadAsync<TPixel>(options, stream, cancellationToken).ConfigureAwait(false);
     }
 }
diff --git a/src/ImageSharp/ImageExtensions.cs b/src/ImageSharp/ImageExtensions.cs
index cf970b3166..75e4f13257 100644
--- a/src/ImageSharp/ImageExtensions.cs
+++ b/src/ImageSharp/ImageExtensions.cs
@@ -70,7 +70,7 @@ public static async Task SaveAsync(
         Guard.NotNull(path, nameof(path));
         Guard.NotNull(encoder, nameof(encoder));
 
-        using Stream fs = source.GetConfiguration().FileSystem.Create(path);
+        await using Stream fs = source.GetConfiguration().FileSystem.CreateAsynchronous(path);
         await source.SaveAsync(fs, encoder, cancellationToken).ConfigureAwait(false);
     }
 


Test patch:
diff --git a/tests/ImageSharp.Tests/IO/LocalFileSystemTests.cs b/tests/ImageSharp.Tests/IO/LocalFileSystemTests.cs
index 3d512b7d27..a1eeb25976 100644
--- a/tests/ImageSharp.Tests/IO/LocalFileSystemTests.cs
+++ b/tests/ImageSharp.Tests/IO/LocalFileSystemTests.cs
@@ -1,4 +1,4 @@
-﻿// Copyright (c) Six Labors.
+// Copyright (c) Six Labors.
 // Licensed under the Six Labors Split License.
 
 using SixLabors.ImageSharp.IO;
@@ -11,36 +11,113 @@ public class LocalFileSystemTests
     public void OpenRead()
     {
         string path = Path.GetTempFileName();
-        string testData = Guid.NewGuid().ToString();
-        File.WriteAllText(path, testData);
+        try
+        {
+            string testData = Guid.NewGuid().ToString();
+            File.WriteAllText(path, testData);
 
-        var fs = new LocalFileSystem();
+            LocalFileSystem fs = new();
 
-        using (var r = new StreamReader(fs.OpenRead(path)))
-        {
-            string data = r.ReadToEnd();
+            using (FileStream stream = (FileStream)fs.OpenRead(path))
+            using (StreamReader reader = new(stream))
+            {
+                Assert.False(stream.IsAsync);
+                Assert.True(stream.CanRead);
+                Assert.False(stream.CanWrite);
 
-            Assert.Equal(testData, data);
+                string data = reader.ReadToEnd();
+
+                Assert.Equal(testData, data);
+            }
         }
+        finally
+        {
+            File.Delete(path);
+        }
+    }
 
-        File.Delete(path);
+    [Fact]
+    public async Task OpenReadAsynchronous()
+    {
+        string path = Path.GetTempFileName();
+        try
+        {
+            string testData = Guid.NewGuid().ToString();
+            File.WriteAllText(path, testData);
+
+            LocalFileSystem fs = new();
+
+            await using (FileStream stream = (FileStream)fs.OpenReadAsynchronous(path))
+            using (StreamReader reader = new(stream))
+            {
+                Assert.True(stream.IsAsync);
+                Assert.True(stream.CanRead);
+                Assert.False(stream.CanWrite);
+
+                string data = await reader.ReadToEndAsync();
+
+                Assert.Equal(testData, data);
+            }
+        }
+        finally
+        {
+            File.Delete(path);
+        }
     }
 
     [Fact]
     public void Create()
     {
         string path = Path.GetTempFileName();
-        string testData = Guid.NewGuid().ToString();
-        var fs = new LocalFileSystem();
+        try
+        {
+            string testData = Guid.NewGuid().ToString();
+            LocalFileSystem fs = new();
+
+            using (FileStream stream = (FileStream)fs.Create(path))
+            using (StreamWriter writer = new(stream))
+            {
+                Assert.False(stream.IsAsync);
+                Assert.True(stream.CanRead);
+                Assert.True(stream.CanWrite);
 
-        using (var r = new StreamWriter(fs.Create(path)))
+                writer.Write(testData);
+            }
+
+            string data = File.ReadAllText(path);
+            Assert.Equal(testData, data);
+        }
+        finally
         {
-            r.Write(testData);
+            File.Delete(path);
         }
+    }
 
-        string data = File.ReadAllText(path);
-        Assert.Equal(testData, data);
+    [Fact]
+    public async Task CreateAsynchronous()
+    {
+        string path = Path.GetTempFileName();
+        try
+        {
+            string testData = Guid.NewGuid().ToString();
+            LocalFileSystem fs = new();
+
+            await using (FileStream stream = (FileStream)fs.CreateAsynchronous(path))
+            await using (StreamWriter writer = new(stream))
+            {
+                Assert.True(stream.IsAsync);
+                Assert.True(stream.CanRead);
+                Assert.True(stream.CanWrite);
+
+                await writer.WriteAsync(testData);
+            }
 
-        File.Delete(path);
+            string data = File.ReadAllText(path);
+            Assert.Equal(testData, data);
+        }
+        finally
+        {
+            File.Delete(path);
+        }
     }
 }
diff --git a/tests/ImageSharp.Tests/Image/ImageSaveTests.cs b/tests/ImageSharp.Tests/Image/ImageSaveTests.cs
index a3f03bed5a..f9c01ab564 100644
--- a/tests/ImageSharp.Tests/Image/ImageSaveTests.cs
+++ b/tests/ImageSharp.Tests/Image/ImageSaveTests.cs
@@ -44,7 +44,7 @@ public ImageSaveTests()
     [Fact]
     public void SavePath()
     {
-        var stream = new MemoryStream();
+        using MemoryStream stream = new();
         this.fileSystem.Setup(x => x.Create("path.png")).Returns(stream);
         this.image.Save("path.png");
 
@@ -54,7 +54,7 @@ public void SavePath()
     [Fact]
     public void SavePathWithEncoder()
     {
-        var stream = new MemoryStream();
+        using MemoryStream stream = new();
         this.fileSystem.Setup(x => x.Create("path.jpg")).Returns(stream);
 
         this.image.Save("path.jpg", this.encoderNotInFormat.Object);
@@ -73,7 +73,7 @@ public void ToBase64String()
     [Fact]
     public void SaveStreamWithMime()
     {
-        var stream = new MemoryStream();
+        using MemoryStream stream = new();
         this.image.Save(stream, this.localImageFormat.Object);
 
         this.encoder.Verify(x => x.Encode(this.image, stream));
@@ -82,7 +82,7 @@ public void SaveStreamWithMime()
     [Fact]
     public void SaveStreamWithEncoder()
     {
-        var stream = new MemoryStream();
+        using MemoryStream stream = new();
 
         this.image.Save(stream, this.encoderNotInFormat.Object);
 
diff --git a/tests/ImageSharp.Tests/Image/ImageTests.ImageLoadTestBase.cs b/tests/ImageSharp.Tests/Image/ImageTests.ImageLoadTestBase.cs
index a8f9981b44..996310d8c3 100644
--- a/tests/ImageSharp.Tests/Image/ImageTests.ImageLoadTestBase.cs
+++ b/tests/ImageSharp.Tests/Image/ImageTests.ImageLoadTestBase.cs
@@ -122,6 +122,7 @@ protected ImageLoadTestBase()
             Stream StreamFactory() => this.DataStream;
 
             this.LocalFileSystemMock.Setup(x => x.OpenRead(this.MockFilePath)).Returns(StreamFactory);
+            this.LocalFileSystemMock.Setup(x => x.OpenReadAsynchronous(this.MockFilePath)).Returns(StreamFactory);
             this.topLevelFileSystem.AddFile(this.MockFilePath, StreamFactory);
             this.LocalConfiguration.FileSystem = this.LocalFileSystemMock.Object;
             this.TopLevelConfiguration.FileSystem = this.topLevelFileSystem;
@@ -132,6 +133,11 @@ public void Dispose()
             // Clean up the global object;
             this.localStreamReturnImageRgba32?.Dispose();
             this.localStreamReturnImageAgnostic?.Dispose();
+
+            if (this.dataStreamLazy.IsValueCreated)
+            {
+                this.dataStreamLazy.Value.Dispose();
+            }
         }
 
         protected virtual Stream CreateStream() => this.TestFormat.CreateStream(this.Marker);
diff --git a/tests/ImageSharp.Tests/TestFileSystem.cs b/tests/ImageSharp.Tests/TestFileSystem.cs
index 8aefbe320e..9013d15530 100644
--- a/tests/ImageSharp.Tests/TestFileSystem.cs
+++ b/tests/ImageSharp.Tests/TestFileSystem.cs
@@ -1,6 +1,8 @@
-﻿// Copyright (c) Six Labors.
+// Copyright (c) Six Labors.
 // Licensed under the Six Labors Split License.
 
+#nullable enable
+
 namespace SixLabors.ImageSharp.Tests;
 
 /// <summary>
@@ -8,7 +10,7 @@ namespace SixLabors.ImageSharp.Tests;
 /// </summary>
 public class TestFileSystem : ImageSharp.IO.IFileSystem
 {
-    private readonly Dictionary<string, Func<Stream>> fileSystem = new Dictionary<string, Func<Stream>>(StringComparer.OrdinalIgnoreCase);
+    private readonly Dictionary<string, Func<Stream>> fileSystem = new(StringComparer.OrdinalIgnoreCase);
 
     public void AddFile(string path, Func<Stream> data)
     {
@@ -18,35 +20,39 @@ public void AddFile(string path, Func<Stream> data)
         }
     }
 
-    public Stream Create(string path)
+    public Stream Create(string path) => this.GetStream(path) ?? File.Create(path);
+
+    public Stream CreateAsynchronous(string path) => this.GetStream(path) ?? File.Open(path, new FileStreamOptions
     {
-        // if we have injected a fake file use it instead
-        lock (this.fileSystem)
-        {
-            if (this.fileSystem.ContainsKey(path))
-            {
-                Stream stream = this.fileSystem[path]();
-                stream.Position = 0;
-                return stream;
-            }
-        }
+        Mode = FileMode.Create,
+        Access = FileAccess.ReadWrite,
+        Share = FileShare.None,
+        Options = FileOptions.Asynchronous,
+    });
 
-        return File.Create(path);
-    }
+    public Stream OpenRead(string path) => this.GetStream(path) ?? File.OpenRead(path);
+
+    public Stream OpenReadAsynchronous(string path) => this.GetStream(path) ?? File.Open(path, new FileStreamOptions
+    {
+        Mode = FileMode.Open,
+        Access = FileAccess.Read,
+        Share = FileShare.Read,
+        Options = FileOptions.Asynchronous,
+    });
 
-    public Stream OpenRead(string path)
+    private Stream? GetStream(string path)
     {
         // if we have injected a fake file use it instead
         lock (this.fileSystem)
         {
-            if (this.fileSystem.ContainsKey(path))
+            if (this.fileSystem.TryGetValue(path, out Func<Stream>? streamFactory))
             {
-                Stream stream = this.fileSystem[path]();
+                Stream stream = streamFactory();
                 stream.Position = 0;
                 return stream;
             }
         }
 
-        return File.OpenRead(path);
+        return null;
     }
 }
diff --git a/tests/ImageSharp.Tests/TestUtilities/SingleStreamFileSystem.cs b/tests/ImageSharp.Tests/TestUtilities/SingleStreamFileSystem.cs
index 732948b8e0..7b519531ab 100644
--- a/tests/ImageSharp.Tests/TestUtilities/SingleStreamFileSystem.cs
+++ b/tests/ImageSharp.Tests/TestUtilities/SingleStreamFileSystem.cs
@@ -13,5 +13,9 @@ internal class SingleStreamFileSystem : IFileSystem
 
     Stream IFileSystem.Create(string path) => this.stream;
 
+    Stream IFileSystem.CreateAsynchronous(string path) => this.stream;
+
     Stream IFileSystem.OpenRead(string path) => this.stream;
+
+    Stream IFileSystem.OpenReadAsynchronous(string path) => this.stream;
 }