基于yield的批量文件搜索并发任务启动异常问题排查
文件搜索器并发处理问题排查与解决
问题1:并发任务无法按SemaphoreSlim设置并行启动
你实现了批量yield返回文件的搜索器,期望通过SemaphoreSlim控制8个并发任务,但实际必须等待前一个任务完成才会启动下一个,即便使用Task.Run也无法解决。
核心原因
- 同步枚举阻塞主线程:
EnumerateFilesRecursively是同步IEnumerable,foreach迭代时,每次获取下一批文件会同步阻塞当前线程——必须等当前批次的文件扫描完成,才会进入下一次循环启动新任务,无法同时进行文件扫描和任务处理。 - 任务启动方式导致同步执行阻塞:原代码直接调用
StartTasksAsync,该方法内部的foreach (var item in items)是同步执行的,这部分代码会在当前线程运行到第一个await(Task.Delay)前阻塞线程,导致下一次循环无法及时启动。
解决方法
步骤1:将文件枚举改为异步IAsyncEnumerable
把同步文件扫描改成异步,避免阻塞主线程,让文件扫描和任务处理并行进行:
internal static async IAsyncEnumerable<IEnumerable<string>> EnumerateFilesRecursively( string directory, int batchSize, [EnumeratorCancellation] CancellationToken cancellationToken) { Stack<string> stack = new Stack<string>(); List<string> currentFoundFiles = new List<string>(); stack.Push(directory); while (stack.Count > 0) { cancellationToken.ThrowIfCancellationRequested(); string currentDir = stack.Pop(); try { // 异步获取子目录,避免同步阻塞 foreach (string subDir in await Task.Run(() => Directory.GetDirectories(currentDir), cancellationToken)) stack.Push(subDir); } catch (UnauthorizedAccessException) { Log.Error("File Searcher: UnauthorizedAccessException"); } catch (DirectoryNotFoundException) { Log.Error("File Searcher: DirectoryNotFoundException"); } try { // 异步获取文件,避免同步阻塞 var files = await Task.Run(() => Directory.GetFiles(currentDir, "*") .Where(file => allowedExtensions.Contains(Path.GetExtension(file))) .ToList(), cancellationToken); currentFoundFiles.AddRange(files); } catch (UnauthorizedAccessException) { Log.Error("File Searcher 2: UnauthorizedAccessException"); continue; } catch (DirectoryNotFoundException) { Log.Error("File Searcher 2: DirectoryNotFoundException"); continue; } if (currentFoundFiles.Count < batchSize) continue; // 返回副本,避免后续修改影响已返回的批次 yield return currentFoundFiles.ToList(); currentFoundFiles.Clear(); } if(currentFoundFiles.Count > 0) yield return currentFoundFiles.ToList(); }
步骤2:修改RunAsync方法,异步枚举并正确启动任务
使用await foreach遍历异步枚举器,同时用Task.Run包装任务逻辑,确保任务完全在后台线程执行,不阻塞枚举过程:
public async Task RunAsync(CancellationToken cancellationToken) { var tasks = new List<Task>(); var index = 0; using var semaphore = new SemaphoreSlim(8, 16); // 异步枚举文件批次 await foreach (var items in FileSearcher.EnumerateFilesRecursively(@"E:/nestedAAAA", batchSize, cancellationToken)) { await semaphore.WaitAsync(cancellationToken); try { var localIndex = index; var localItems = items; // 捕获当前批次的副本 // 用Task.Run包装任务,确保任务在后台线程执行 var task = Task.Run(async () => { try { await StartTasksAsync(localItems, localIndex, cancellationToken); } finally { semaphore.Release(); } }, cancellationToken); tasks.Add(task); index++; } catch(Exception ex) { _logger.LogError(ex, "Error in HarvestEngine RunAsync"); semaphore.Release(); } } await Task.WhenAll(tasks); }
问题2:Task.Run中仅最后一个任务打印所有项
简化代码后,发现只有最后一个任务能打印批次的所有文件,之前的任务仅打印第一个项。
核心原因
EnumerateFilesRecursively中,所有yield返回的IEnumerable<string>都是同一个currentFoundFiles列表的引用。当后续迭代调用currentFoundFiles.Clear()并添加新文件时,之前任务中引用的列表已经被修改,导致遍历到的内容不是当初yield的批次数据。
解决方法
在yield返回批次时,返回列表的副本,而不是原列表的引用:
// 原代码 // yield return currentFoundFiles; // 修改为: yield return currentFoundFiles.ToList();
这样每个批次都是独立的列表,后续修改原列表不会影响已返回的批次数据。
内容的提问来源于stack exchange,提问作者uk3ndt
相关产品推荐
相关产品推荐

