diff --git a/src/SharpCompress/Archives/AbstractArchive.cs b/src/SharpCompress/Archives/AbstractArchive.cs
index 1e4b353b9..482fdf6ef 100644
--- a/src/SharpCompress/Archives/AbstractArchive.cs
+++ b/src/SharpCompress/Archives/AbstractArchive.cs
@@ -139,6 +139,19 @@ public IReader ExtractAllEntries()
///
public virtual bool IsEncrypted => false;
+ ///
+ /// Returns whether multi-threaded extraction is supported for this archive.
+ /// Multi-threading is supported when:
+ /// 1. The archive is opened from a FileInfo or file path (not a stream)
+ /// 2. Multi-threading is explicitly enabled in ReaderOptions
+ /// 3. The archive is not SOLID (SOLID archives should use sequential extraction)
+ ///
+ public virtual bool SupportsMultiThreadedExtraction =>
+ _sourceStream is not null
+ && _sourceStream.IsFileMode
+ && ReaderOptions.EnableMultiThreadedExtraction
+ && !IsSolid;
+
///
/// The archive can find all the parts of the archive needed to fully extract the archive. This forces the parsing of the entire archive.
///
diff --git a/src/SharpCompress/Archives/IArchive.cs b/src/SharpCompress/Archives/IArchive.cs
index ba0f74a5c..38fbd64dc 100644
--- a/src/SharpCompress/Archives/IArchive.cs
+++ b/src/SharpCompress/Archives/IArchive.cs
@@ -49,4 +49,12 @@ public interface IArchive : IDisposable
/// Returns whether the archive is encrypted.
///
bool IsEncrypted { get; }
+
+ ///
+ /// Returns whether multi-threaded extraction is supported for this archive.
+ /// Multi-threading is supported when the archive is opened from a FileInfo or file path
+ /// (not a stream) and the format supports random access (e.g., Zip, Tar, Rar).
+ /// SOLID archives (some Rar, all 7Zip) should use sequential extraction for best performance.
+ ///
+ bool SupportsMultiThreadedExtraction { get; }
}
diff --git a/src/SharpCompress/Archives/Rar/SeekableFilePart.cs b/src/SharpCompress/Archives/Rar/SeekableFilePart.cs
index 97822d90e..927ba8823 100644
--- a/src/SharpCompress/Archives/Rar/SeekableFilePart.cs
+++ b/src/SharpCompress/Archives/Rar/SeekableFilePart.cs
@@ -1,6 +1,7 @@
using System.IO;
using SharpCompress.Common.Rar;
using SharpCompress.Common.Rar.Headers;
+using SharpCompress.IO;
namespace SharpCompress.Archives.Rar;
@@ -24,6 +25,76 @@ internal SeekableFilePart(
internal override Stream GetCompressedStream()
{
+ Stream streamToUse;
+
+ // If the stream is a SourceStream in file mode with multi-threading enabled,
+ // create an independent stream to support concurrent extraction
+ if (
+ _stream is SourceStream sourceStream
+ && sourceStream.IsFileMode
+ && sourceStream.ReaderOptions.EnableMultiThreadedExtraction
+ )
+ {
+ var independentStream = sourceStream.CreateIndependentStream(0);
+ if (independentStream is not null)
+ {
+ streamToUse = independentStream;
+ streamToUse.Position = FileHeader.DataStartPosition;
+
+ if (FileHeader.R4Salt != null)
+ {
+ var cryptKey = new CryptKey3(_password!);
+ return new RarCryptoWrapper(streamToUse, FileHeader.R4Salt, cryptKey);
+ }
+
+ if (FileHeader.Rar5CryptoInfo != null)
+ {
+ var cryptKey = new CryptKey5(_password!, FileHeader.Rar5CryptoInfo);
+ return new RarCryptoWrapper(
+ streamToUse,
+ FileHeader.Rar5CryptoInfo.Salt,
+ cryptKey
+ );
+ }
+
+ return streamToUse;
+ }
+ }
+
+ // Check if the stream wraps a FileStream
+ Stream? underlyingStream = _stream;
+ if (_stream is IStreamStack streamStack)
+ {
+ underlyingStream = streamStack.BaseStream();
+ }
+
+ if (underlyingStream is FileStream fileStream)
+ {
+ // Create a new independent stream from the file
+ streamToUse = new FileStream(
+ fileStream.Name,
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.Read
+ );
+ streamToUse.Position = FileHeader.DataStartPosition;
+
+ if (FileHeader.R4Salt != null)
+ {
+ var cryptKey = new CryptKey3(_password!);
+ return new RarCryptoWrapper(streamToUse, FileHeader.R4Salt, cryptKey);
+ }
+
+ if (FileHeader.Rar5CryptoInfo != null)
+ {
+ var cryptKey = new CryptKey5(_password!, FileHeader.Rar5CryptoInfo);
+ return new RarCryptoWrapper(streamToUse, FileHeader.Rar5CryptoInfo.Salt, cryptKey);
+ }
+
+ return streamToUse;
+ }
+
+ // Fall back to existing behavior for stream-based sources
_stream.Position = FileHeader.DataStartPosition;
if (FileHeader.R4Salt != null)
diff --git a/src/SharpCompress/Common/Tar/TarFilePart.cs b/src/SharpCompress/Common/Tar/TarFilePart.cs
index 15b76d141..8aa166343 100644
--- a/src/SharpCompress/Common/Tar/TarFilePart.cs
+++ b/src/SharpCompress/Common/Tar/TarFilePart.cs
@@ -2,6 +2,7 @@
using System.Threading;
using System.Threading.Tasks;
using SharpCompress.Common.Tar.Headers;
+using SharpCompress.IO;
namespace SharpCompress.Common.Tar;
@@ -22,10 +23,47 @@ internal TarFilePart(TarHeader header, Stream? seekableStream)
internal override Stream GetCompressedStream()
{
- if (_seekableStream != null)
+ if (_seekableStream is not null)
{
+ // If the seekable stream is a SourceStream in file mode with multi-threading enabled,
+ // create an independent stream to support concurrent extraction
+ if (
+ _seekableStream is SourceStream sourceStream
+ && sourceStream.IsFileMode
+ && sourceStream.ReaderOptions.EnableMultiThreadedExtraction
+ )
+ {
+ var independentStream = sourceStream.CreateIndependentStream(0);
+ if (independentStream is not null)
+ {
+ independentStream.Position = Header.DataStartPosition ?? 0;
+ return new TarReadOnlySubStream(independentStream, Header.Size);
+ }
+ }
+
+ // Check if the seekable stream wraps a FileStream
+ Stream? underlyingStream = _seekableStream;
+ if (_seekableStream is IStreamStack streamStack)
+ {
+ underlyingStream = streamStack.BaseStream();
+ }
+
+ if (underlyingStream is FileStream fileStream)
+ {
+ // Create a new independent stream from the file
+ var independentStream = new FileStream(
+ fileStream.Name,
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.Read
+ );
+ independentStream.Position = Header.DataStartPosition ?? 0;
+ return new TarReadOnlySubStream(independentStream, Header.Size);
+ }
+
+ // Fall back to existing behavior for stream-based sources
_seekableStream.Position = Header.DataStartPosition ?? 0;
- return new TarReadOnlySubStream(_seekableStream, Header.Size, false);
+ return new TarReadOnlySubStream(_seekableStream, Header.Size);
}
return Header.PackedStream.NotNull();
}
@@ -36,14 +74,8 @@ internal override Stream GetCompressedStream()
{
if (_seekableStream != null)
{
- var useSyncOverAsync = false;
-#if LEGACY_DOTNET
- useSyncOverAsync = true;
-#endif
_seekableStream.Position = Header.DataStartPosition ?? 0;
- return new ValueTask(
- new TarReadOnlySubStream(_seekableStream, Header.Size, useSyncOverAsync)
- );
+ return new ValueTask(new TarReadOnlySubStream(_seekableStream, Header.Size));
}
return new ValueTask(Header.PackedStream.NotNull());
}
diff --git a/src/SharpCompress/Common/Tar/TarHeaderFactory.Async.cs b/src/SharpCompress/Common/Tar/TarHeaderFactory.Async.cs
index 772a56651..c011abd25 100644
--- a/src/SharpCompress/Common/Tar/TarHeaderFactory.Async.cs
+++ b/src/SharpCompress/Common/Tar/TarHeaderFactory.Async.cs
@@ -44,15 +44,7 @@ IArchiveEncoding archiveEncoding
break;
case StreamingMode.Streaming:
{
- var useSyncOverAsync = false;
-#if LEGACY_DOTNET
- useSyncOverAsync = true;
-#endif
- header.PackedStream = new TarReadOnlySubStream(
- stream,
- header.Size,
- useSyncOverAsync
- );
+ header.PackedStream = new TarReadOnlySubStream(stream, header.Size);
}
break;
default:
diff --git a/src/SharpCompress/Common/Tar/TarHeaderFactory.cs b/src/SharpCompress/Common/Tar/TarHeaderFactory.cs
index df71351e9..c95efaef5 100644
--- a/src/SharpCompress/Common/Tar/TarHeaderFactory.cs
+++ b/src/SharpCompress/Common/Tar/TarHeaderFactory.cs
@@ -38,11 +38,7 @@ IArchiveEncoding archiveEncoding
break;
case StreamingMode.Streaming:
{
- header.PackedStream = new TarReadOnlySubStream(
- stream,
- header.Size,
- false
- );
+ header.PackedStream = new TarReadOnlySubStream(stream, header.Size);
}
break;
default:
diff --git a/src/SharpCompress/Common/Tar/TarReadOnlySubStream.cs b/src/SharpCompress/Common/Tar/TarReadOnlySubStream.cs
index 987751a34..79a4c4a4e 100644
--- a/src/SharpCompress/Common/Tar/TarReadOnlySubStream.cs
+++ b/src/SharpCompress/Common/Tar/TarReadOnlySubStream.cs
@@ -10,7 +10,7 @@ internal class TarReadOnlySubStream : Stream
private bool _isDisposed;
private long _amountRead;
- public TarReadOnlySubStream(Stream stream, long bytesToRead, bool useSyncOverAsyncDispose)
+ public TarReadOnlySubStream(Stream stream, long bytesToRead)
{
_stream = stream;
BytesLeftToRead = bytesToRead;
diff --git a/src/SharpCompress/Common/Zip/SeekableZipFilePart.Async.cs b/src/SharpCompress/Common/Zip/SeekableZipFilePart.Async.cs
index 8fff84365..2c17e5134 100644
--- a/src/SharpCompress/Common/Zip/SeekableZipFilePart.Async.cs
+++ b/src/SharpCompress/Common/Zip/SeekableZipFilePart.Async.cs
@@ -1,26 +1,102 @@
+using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using SharpCompress.Common.Zip.Headers;
+using SharpCompress.IO;
namespace SharpCompress.Common.Zip;
-internal partial class SeekableZipFilePart
+internal partial class SeekableZipFilePart : IDisposable
{
+ private readonly SemaphoreSlim _asyncHeaderSemaphore = new(1, 1);
+
internal override async ValueTask GetCompressedStreamAsync(
CancellationToken cancellationToken = default
)
{
if (!_isLocalHeaderLoaded)
{
- await LoadLocalHeaderAsync(cancellationToken).ConfigureAwait(false);
- _isLocalHeaderLoaded = true;
+ await _asyncHeaderSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ if (!_isLocalHeaderLoaded)
+ {
+ await LoadLocalHeaderAsync(cancellationToken).ConfigureAwait(false);
+ _isLocalHeaderLoaded = true;
+ }
+ }
+ finally
+ {
+ _asyncHeaderSemaphore.Release();
+ }
}
return await base.GetCompressedStreamAsync(cancellationToken).ConfigureAwait(false);
}
- private async ValueTask LoadLocalHeaderAsync(CancellationToken cancellationToken = default) =>
- Header = await _headerFactory
- .GetLocalHeaderAsync(BaseStream, (DirectoryEntryHeader)Header)
- .ConfigureAwait(false);
+ private async ValueTask LoadLocalHeaderAsync(CancellationToken cancellationToken = default)
+ {
+ // Use an independent stream for loading the header if multi-threading is enabled
+ Stream streamToUse = BaseStream;
+ bool disposeStream = false;
+
+ if (
+ BaseStream is SourceStream sourceStream
+ && sourceStream.IsFileMode
+ && sourceStream.ReaderOptions.EnableMultiThreadedExtraction
+ )
+ {
+ var independentStream = sourceStream.CreateIndependentStream(0);
+ if (independentStream is not null)
+ {
+ streamToUse = independentStream;
+ disposeStream = true;
+ }
+ }
+ else
+ {
+ // Check if BaseStream wraps a FileStream
+ Stream? underlyingStream = BaseStream;
+ if (BaseStream is IStreamStack streamStack)
+ {
+ underlyingStream = streamStack.BaseStream();
+ }
+
+ if (underlyingStream is FileStream fileStream)
+ {
+ streamToUse = new FileStream(
+ fileStream.Name,
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.Read
+ );
+ disposeStream = true;
+ }
+ }
+
+ try
+ {
+ Header = await _headerFactory
+ .GetLocalHeaderAsync(streamToUse, (DirectoryEntryHeader)Header)
+ .ConfigureAwait(false);
+ }
+ finally
+ {
+ if (disposeStream)
+ {
+ if (streamToUse is IAsyncDisposable asyncDisposable)
+ {
+ await asyncDisposable.DisposeAsync().ConfigureAwait(false);
+ }
+ else
+ {
+#pragma warning disable VSTHRD103
+ streamToUse.Dispose();
+#pragma warning restore VSTHRD103
+ }
+ }
+ }
+ }
+
+ public void Dispose() => _asyncHeaderSemaphore.Dispose();
}
diff --git a/src/SharpCompress/Common/Zip/SeekableZipFilePart.cs b/src/SharpCompress/Common/Zip/SeekableZipFilePart.cs
index f9c59dfdc..d783d2f22 100644
--- a/src/SharpCompress/Common/Zip/SeekableZipFilePart.cs
+++ b/src/SharpCompress/Common/Zip/SeekableZipFilePart.cs
@@ -1,14 +1,15 @@
using System.IO;
using SharpCompress.Common.Zip.Headers;
-using SharpCompress.Compressors;
+using SharpCompress.IO;
using SharpCompress.Providers;
namespace SharpCompress.Common.Zip;
internal partial class SeekableZipFilePart : ZipFilePart
{
- private bool _isLocalHeaderLoaded;
+ private volatile bool _isLocalHeaderLoaded;
private readonly SeekableZipHeaderFactory _headerFactory;
+ private readonly object _headerLock = new();
internal SeekableZipFilePart(
SeekableZipHeaderFactory headerFactory,
@@ -22,19 +23,112 @@ internal override Stream GetCompressedStream()
{
if (!_isLocalHeaderLoaded)
{
- LoadLocalHeader();
- _isLocalHeaderLoaded = true;
+ lock (_headerLock)
+ {
+ if (!_isLocalHeaderLoaded)
+ {
+ LoadLocalHeader();
+ _isLocalHeaderLoaded = true;
+ }
+ }
}
return base.GetCompressedStream();
}
- private void LoadLocalHeader() =>
- Header = _headerFactory.GetLocalHeader(BaseStream, (DirectoryEntryHeader)Header);
+ private void LoadLocalHeader()
+ {
+ // Use an independent stream for loading the header if multi-threading is enabled
+ Stream streamToUse = BaseStream;
+ bool disposeStream = false;
+
+ if (
+ BaseStream is SourceStream sourceStream
+ && sourceStream.IsFileMode
+ && sourceStream.ReaderOptions.EnableMultiThreadedExtraction
+ )
+ {
+ var independentStream = sourceStream.CreateIndependentStream(0);
+ if (independentStream is not null)
+ {
+ streamToUse = independentStream;
+ disposeStream = true;
+ }
+ }
+ else
+ {
+ // Check if BaseStream wraps a FileStream
+ Stream? underlyingStream = BaseStream;
+ if (BaseStream is IStreamStack streamStack)
+ {
+ underlyingStream = streamStack.BaseStream();
+ }
+
+ if (underlyingStream is FileStream fileStream)
+ {
+ streamToUse = new FileStream(
+ fileStream.Name,
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.Read
+ );
+ disposeStream = true;
+ }
+ }
+
+ try
+ {
+ Header = _headerFactory.GetLocalHeader(streamToUse, (DirectoryEntryHeader)Header);
+ }
+ finally
+ {
+ if (disposeStream)
+ {
+ streamToUse.Dispose();
+ }
+ }
+ }
protected override Stream CreateBaseStream()
{
- BaseStream.Position = Header.DataStartPosition.NotNull();
+ // If BaseStream is a SourceStream in file mode with multi-threading enabled,
+ // create an independent stream to support concurrent extraction
+ if (
+ BaseStream is SourceStream sourceStream
+ && sourceStream.IsFileMode
+ && sourceStream.ReaderOptions.EnableMultiThreadedExtraction
+ )
+ {
+ // Create a new independent stream for this entry
+ var independentStream = sourceStream.CreateIndependentStream(0);
+ if (independentStream is not null)
+ {
+ independentStream.Position = Header.DataStartPosition.NotNull();
+ return independentStream;
+ }
+ }
+ // Check if BaseStream wraps a FileStream (for multi-volume archives)
+ Stream? underlyingStream = BaseStream;
+ if (BaseStream is IStreamStack streamStack)
+ {
+ underlyingStream = streamStack.BaseStream();
+ }
+
+ if (underlyingStream is FileStream fileStream)
+ {
+ // Create a new independent stream from the file
+ var independentStream = new FileStream(
+ fileStream.Name,
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.Read
+ );
+ independentStream.Position = Header.DataStartPosition.NotNull();
+ return independentStream;
+ }
+
+ // Fall back to existing behavior for stream-based sources
+ BaseStream.Position = Header.DataStartPosition.NotNull();
return BaseStream;
}
}
diff --git a/src/SharpCompress/IO/SourceStream.cs b/src/SharpCompress/IO/SourceStream.cs
index 4a8377139..fee77d5ad 100644
--- a/src/SharpCompress/IO/SourceStream.cs
+++ b/src/SharpCompress/IO/SourceStream.cs
@@ -77,6 +77,30 @@ public void LoadAllParts()
private Stream Current => _streams[_stream];
+ ///
+ /// Creates an independent stream for the specified volume index.
+ /// This allows multiple threads to read from different positions concurrently.
+ /// Only works when IsFileMode is true.
+ ///
+ /// The volume index to create a stream for
+ /// A new independent FileStream, or null if not in file mode or volume doesn't exist
+ public Stream? CreateIndependentStream(int volumeIndex)
+ {
+ if (!IsFileMode)
+ {
+ return null;
+ }
+
+ // Ensure the volume is loaded
+ if (!LoadStream(volumeIndex))
+ {
+ return null;
+ }
+
+ // Create a new independent stream from the FileInfo
+ return _files[volumeIndex].OpenRead();
+ }
+
public bool LoadStream(int index) //ensure all parts to id are loaded
{
while (_streams.Count <= index)
diff --git a/src/SharpCompress/Readers/ReaderOptions.cs b/src/SharpCompress/Readers/ReaderOptions.cs
index aced7169e..0aefb5274 100644
--- a/src/SharpCompress/Readers/ReaderOptions.cs
+++ b/src/SharpCompress/Readers/ReaderOptions.cs
@@ -185,4 +185,12 @@ public static ReaderOptions ForSelfExtractingArchive(string? password = null) =>
// new ReaderOptions().WithPassword("secret").WithLookForHeader(true)
// or
// new ReaderOptions { Password = "secret", LookForHeader = true }
+
+ ///
+ /// Enable multi-threaded extraction support when the archive is opened from a FileInfo or file path.
+ /// When enabled, multiple threads can extract different entries concurrently by creating
+ /// independent file streams. This is only effective for archives opened from files, not streams.
+ /// Default is false for backward compatibility.
+ ///
+ public bool EnableMultiThreadedExtraction { get; set; }
}
diff --git a/src/SharpCompress/packages.lock.json b/src/SharpCompress/packages.lock.json
index e1e7afa51..738df5991 100644
--- a/src/SharpCompress/packages.lock.json
+++ b/src/SharpCompress/packages.lock.json
@@ -286,9 +286,9 @@
"net10.0": {
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
- "requested": "[10.0.8, )",
- "resolved": "10.0.8",
- "contentHash": "dVbSXGIFNR5nZcv2tOLoWI+a9T4jtFd77IYjuND+QVe360qWgAF7H0WtoopYhRw/+SgpGUTyrkrh+65+ClNnfw=="
+ "requested": "[10.0.6, )",
+ "resolved": "10.0.6",
+ "contentHash": "QKuvS0LWX4fjFqeDkyM7Kqt8P3wYTiPD4nwU+9y59n0sCiG714fxDgbbN82vDnzq89AF/PiHl92TP2C4aFDUQA=="
},
"Microsoft.NETFramework.ReferenceAssemblies": {
"type": "Direct",
@@ -388,9 +388,9 @@
"net8.0": {
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
- "requested": "[8.0.27, )",
- "resolved": "8.0.27",
- "contentHash": "rQi9TxifHRnXP7lVRZH05DxD2/XGbJp12q0ozcbrlBlBnyyzssFTH/2vLhtKWUp2CT1qVscTrcYTFiwTyKPKRg=="
+ "requested": "[8.0.26, )",
+ "resolved": "8.0.26",
+ "contentHash": "o7/yVssM2r9Wyln2s9edBd5ANZXqdSdBI+g7JqXkyJmXrhs2WsJp25K5yPnYrTgdKBCjKB8bg+O2oew4sgzFaA=="
},
"Microsoft.NETFramework.ReferenceAssemblies": {
"type": "Direct",
diff --git a/tests/SharpCompress.Test/Tar/TarMultiThreadTests.cs b/tests/SharpCompress.Test/Tar/TarMultiThreadTests.cs
new file mode 100644
index 000000000..f7cbcc30f
--- /dev/null
+++ b/tests/SharpCompress.Test/Tar/TarMultiThreadTests.cs
@@ -0,0 +1,115 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using SharpCompress.Archives.Tar;
+using SharpCompress.Common;
+using Xunit;
+
+namespace SharpCompress.Test.Tar;
+
+public class TarMultiThreadTests : TestBase
+{
+ [Fact]
+ public void Tar_Archive_Concurrent_Extraction_From_FileInfo()
+ {
+ // Test concurrent extraction of multiple entries from a Tar archive opened from FileInfo
+ var testArchive = Path.Combine(TEST_ARCHIVES_PATH, "Tar.tar");
+ var fileInfo = new FileInfo(testArchive);
+
+ var options = new SharpCompress.Readers.ReaderOptions
+ {
+ EnableMultiThreadedExtraction = true,
+ };
+
+ using var archive = TarArchive.OpenArchive(fileInfo, options);
+
+ // Verify multi-threading is supported
+ Assert.True(archive.SupportsMultiThreadedExtraction);
+
+ var entries = archive.Entries.Where(e => !e.IsDirectory).Take(5).ToList();
+
+ // Extract multiple entries concurrently
+ var tasks = new List();
+ var outputFiles = new List();
+
+ foreach (var entry in entries)
+ {
+ var outputFile = Path.Combine(SCRATCH_FILES_PATH, entry.Key!);
+ outputFiles.Add(outputFile);
+
+ tasks.Add(
+ Task.Run(() =>
+ {
+ var dir = Path.GetDirectoryName(outputFile);
+ if (dir != null)
+ {
+ Directory.CreateDirectory(dir);
+ }
+ using var entryStream = entry.OpenEntryStream();
+ using var fileStream = File.Create(outputFile);
+ entryStream.CopyTo(fileStream);
+ })
+ );
+ }
+
+ Task.WaitAll(tasks.ToArray());
+
+ // Verify all files were extracted
+ Assert.Equal(entries.Count, outputFiles.Count);
+ foreach (var outputFile in outputFiles)
+ {
+ Assert.True(File.Exists(outputFile), $"File {outputFile} should exist");
+ }
+ }
+
+ [Fact]
+ public async Task Tar_Archive_Concurrent_Extraction_From_FileInfo_Async()
+ {
+ // Test concurrent async extraction of multiple entries from a Tar archive opened from FileInfo
+ var testArchive = Path.Combine(TEST_ARCHIVES_PATH, "Tar.tar");
+ var fileInfo = new FileInfo(testArchive);
+
+ var options = new SharpCompress.Readers.ReaderOptions
+ {
+ EnableMultiThreadedExtraction = true,
+ };
+
+ using var archive = TarArchive.OpenArchive(fileInfo, options);
+ var entries = archive.Entries.Where(e => !e.IsDirectory).Take(5).ToList();
+
+ // Extract multiple entries concurrently
+ var tasks = new List();
+ var outputFiles = new List();
+
+ foreach (var entry in entries)
+ {
+ var outputFile = Path.Combine(SCRATCH_FILES_PATH, entry.Key!);
+ outputFiles.Add(outputFile);
+
+ tasks.Add(
+ Task.Run(async () =>
+ {
+ var dir = Path.GetDirectoryName(outputFile);
+ if (dir != null)
+ {
+ Directory.CreateDirectory(dir);
+ }
+ using var entryStream = await entry.OpenEntryStreamAsync();
+ using var fileStream = File.Create(outputFile);
+ await entryStream.CopyToAsync(fileStream);
+ })
+ );
+ }
+
+ await Task.WhenAll(tasks);
+
+ // Verify all files were extracted
+ Assert.Equal(entries.Count, outputFiles.Count);
+ foreach (var outputFile in outputFiles)
+ {
+ Assert.True(File.Exists(outputFile), $"File {outputFile} should exist");
+ }
+ }
+}
diff --git a/tests/SharpCompress.Test/Zip/ZipMultiThreadTests.cs b/tests/SharpCompress.Test/Zip/ZipMultiThreadTests.cs
new file mode 100644
index 000000000..4100f5fa7
--- /dev/null
+++ b/tests/SharpCompress.Test/Zip/ZipMultiThreadTests.cs
@@ -0,0 +1,192 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using SharpCompress.Archives.Zip;
+using SharpCompress.Common;
+using Xunit;
+
+namespace SharpCompress.Test.Zip;
+
+public class ZipMultiThreadTests : TestBase
+{
+ [Fact]
+ public void Zip_Archive_Without_MultiThreading_Enabled()
+ {
+ // Test that extraction still works when multi-threading is NOT enabled
+ var testArchive = Path.Combine(TEST_ARCHIVES_PATH, "Zip.none.zip");
+ var fileInfo = new FileInfo(testArchive);
+
+ // Default options - multi-threading disabled
+ using var archive = ZipArchive.OpenArchive(fileInfo);
+
+ // Verify multi-threading is NOT supported
+ Assert.False(archive.SupportsMultiThreadedExtraction);
+
+ var entry = archive.Entries.First(e => !e.IsDirectory);
+ var outputFile = Path.Combine(SCRATCH_FILES_PATH, entry.Key!);
+
+ var dir = Path.GetDirectoryName(outputFile);
+ if (dir != null)
+ {
+ Directory.CreateDirectory(dir);
+ }
+
+ using var entryStream = entry.OpenEntryStream();
+ using var fileStream = File.Create(outputFile);
+ entryStream.CopyTo(fileStream);
+
+ Assert.True(File.Exists(outputFile));
+ }
+
+ [Fact]
+ public void Zip_Archive_Concurrent_Extraction_From_FileInfo()
+ {
+ // Test concurrent extraction of multiple entries from a Zip archive opened from FileInfo
+ var testArchive = Path.Combine(TEST_ARCHIVES_PATH, "Zip.none.zip");
+ var fileInfo = new FileInfo(testArchive);
+
+ var options = new SharpCompress.Readers.ReaderOptions
+ {
+ EnableMultiThreadedExtraction = true,
+ };
+
+ using var archive = ZipArchive.OpenArchive(fileInfo, options);
+
+ // Verify multi-threading is supported
+ Assert.True(archive.SupportsMultiThreadedExtraction);
+
+ var entries = archive.Entries.Where(e => !e.IsDirectory).Take(5).ToList();
+
+ // Extract multiple entries concurrently
+ var tasks = new List();
+ var outputFiles = new List();
+
+ foreach (var entry in entries)
+ {
+ var outputFile = Path.Combine(SCRATCH_FILES_PATH, entry.Key!);
+ outputFiles.Add(outputFile);
+
+ tasks.Add(
+ Task.Run(() =>
+ {
+ var dir = Path.GetDirectoryName(outputFile);
+ if (dir != null)
+ {
+ Directory.CreateDirectory(dir);
+ }
+ using var entryStream = entry.OpenEntryStream();
+ using var fileStream = File.Create(outputFile);
+ entryStream.CopyTo(fileStream);
+ })
+ );
+ }
+
+ Task.WaitAll(tasks.ToArray());
+
+ // Verify all files were extracted
+ Assert.Equal(entries.Count, outputFiles.Count);
+ foreach (var outputFile in outputFiles)
+ {
+ Assert.True(File.Exists(outputFile), $"File {outputFile} should exist");
+ }
+ }
+
+ [Fact]
+ public async Task Zip_Archive_Concurrent_Extraction_From_FileInfo_Async()
+ {
+ // Test concurrent async extraction of multiple entries from a Zip archive opened from FileInfo
+ var testArchive = Path.Combine(TEST_ARCHIVES_PATH, "Zip.none.zip");
+ var fileInfo = new FileInfo(testArchive);
+
+ var options = new SharpCompress.Readers.ReaderOptions
+ {
+ EnableMultiThreadedExtraction = true,
+ };
+
+ using var archive = ZipArchive.OpenArchive(fileInfo, options);
+ var entries = archive.Entries.Where(e => !e.IsDirectory).Take(5).ToList();
+
+ // Extract multiple entries concurrently
+ var tasks = new List();
+ var outputFiles = new List();
+
+ foreach (var entry in entries)
+ {
+ var outputFile = Path.Combine(SCRATCH_FILES_PATH, entry.Key!);
+ outputFiles.Add(outputFile);
+
+ tasks.Add(
+ Task.Run(async () =>
+ {
+ var dir = Path.GetDirectoryName(outputFile);
+ if (dir != null)
+ {
+ Directory.CreateDirectory(dir);
+ }
+ using var entryStream = await entry.OpenEntryStreamAsync();
+ using var fileStream = File.Create(outputFile);
+ await entryStream.CopyToAsync(fileStream);
+ })
+ );
+ }
+
+ await Task.WhenAll(tasks);
+
+ // Verify all files were extracted
+ Assert.Equal(entries.Count, outputFiles.Count);
+ foreach (var outputFile in outputFiles)
+ {
+ Assert.True(File.Exists(outputFile), $"File {outputFile} should exist");
+ }
+ }
+
+ [Fact]
+ public void Zip_Archive_Concurrent_Extraction_From_Path()
+ {
+ // Test concurrent extraction when opening from path (should use FileInfo internally)
+ var testArchive = Path.Combine(TEST_ARCHIVES_PATH, "Zip.none.zip");
+
+ var options = new SharpCompress.Readers.ReaderOptions
+ {
+ EnableMultiThreadedExtraction = true,
+ };
+
+ using var archive = ZipArchive.OpenArchive(testArchive, options);
+ var entries = archive.Entries.Where(e => !e.IsDirectory).Take(5).ToList();
+
+ // Extract multiple entries concurrently
+ var tasks = new List();
+ var outputFiles = new List();
+
+ foreach (var entry in entries)
+ {
+ var outputFile = Path.Combine(SCRATCH_FILES_PATH, entry.Key!);
+ outputFiles.Add(outputFile);
+
+ tasks.Add(
+ Task.Run(() =>
+ {
+ var dir = Path.GetDirectoryName(outputFile);
+ if (dir != null)
+ {
+ Directory.CreateDirectory(dir);
+ }
+ using var entryStream = entry.OpenEntryStream();
+ using var fileStream = File.Create(outputFile);
+ entryStream.CopyTo(fileStream);
+ })
+ );
+ }
+
+ Task.WaitAll(tasks.ToArray());
+
+ // Verify all files were extracted
+ Assert.Equal(entries.Count, outputFiles.Count);
+ foreach (var outputFile in outputFiles)
+ {
+ Assert.True(File.Exists(outputFile), $"File {outputFile} should exist");
+ }
+ }
+}