为何readAllEXIFdates任务在producerCompleted更新后无法退出循环?
生产者消费者循环无法退出的原因分析
相关代码
方法 readAllFilesAtRootFolder
public static Dictionary<string, List<mediaFile>> readAllFilesAtRootFolder( string rootFolder, string[] extensions, bool subFolders, ConcurrentQueue<mediaFile> fileQueue, CancellationToken cancellationToken) { var allFiles = new Dictionary<string, List<mediaFile>>(); IEnumerable<string> files; try { if (subFolders) { files = extensions.SelectMany(ext => System.IO.Directory.GetFiles( rootFolder, ext, SearchOption.AllDirectories)); } else { files = extensions.SelectMany(ext => System.IO.Directory.GetFiles( rootFolder, ext, SearchOption.TopDirectoryOnly)); } int i = 1; foreach (var file in files) { if (cancellationToken.IsCancellationRequested) break; mediaFile mediaFile = new mediaFile(); string extension = Path.GetExtension(file).Replace(".", "").ToUpper(); // 如果扩展名不为空,按扩展名分组文件 if (!string.IsNullOrEmpty(extension)) { if (!allFiles.ContainsKey(extension)) { allFiles[extension] = new List<mediaFile>(); } mediaFile.fileName = file; //mediaFile.exif_date = getImageExifDate(file.ToString()); fileQueue.Enqueue(mediaFile); allFiles[extension].Add(mediaFile); } } return allFiles; } catch { } return allFiles; }
方法 readAllEXIFdates
public static void readAllEXIFdates(ConcurrentQueue<mediaFile> fileQueue, ConcurrentDictionary<string, List<mediaFile>> resultDictionary, CancellationToken cancellationToken, bool producerCompleted) { var allEXIFFiles = new Dictionary<string, List<mediaFile>>(); { // 给主线程留出空闲时间 //Thread.Sleep(100); while (!cancellationToken.IsCancellationRequested && (!producerCompleted || !fileQueue.IsEmpty)) { if (fileQueue.TryDequeue(out var mediaFile)) { // 处理EXIF日期 mediaFile.exif_date = getImageExifDate(mediaFile.fileName); var extension = Path.GetExtension(mediaFile.fileName) .ToUpperInvariant().TrimStart('.'); if (!string.IsNullOrEmpty(extension)) { resultDictionary.AddOrUpdate(extension, new List<mediaFile> { mediaFile }, (key, existingList) => { existingList.Add(mediaFile); return existingList; }); } } else { // Thread.Sleep(100); // 队列无文件时短暂等待 } } } }
按钮点击事件处理器
private async void button4_Click_1(object sender, EventArgs e) { string[] extenions = getSelectExtensions(chkExtensions); label5.Text = "读取文件中,请稍候...."; _cancellationTokenSource = new CancellationTokenSource(); string[] extensions = getSelectExtensions(chkExtensions); label5.Text = "读取文件中,请稍候..."; // 定义共享资源 var fileQueue = new ConcurrentQueue<mediaFile>(); var resultDictionary = new ConcurrentDictionary<string, List<mediaFile>>(); bool producerCompleted = false; // 生产者任务 var producerTask = Task.Run(() => { try { readAllFilesAtRootFolder( "H:/My Photos/IMages/2015 - Dhaham's Choir Concert", extensions, chkSubFolders.Checked, fileQueue, _cancellationTokenSource.Token); } finally { producerCompleted = true; } }); var consumerTask = Task.Run(() => readAllEXIFdates(fileQueue, resultDictionary, _cancellationTokenSource.Token, producerCompleted)); // 等待两个任务完成 await Task.WhenAll(producerTask, consumerTask); label8.Text = $"处理文件总数: {resultDictionary.Values.Count}"; }
问题描述
预期readAllEXIFdates()方法会在producerCompleted被更新为true时退出循环,但实际该循环始终无法感知到producerCompleted的更新——即便将producerCompleted改为类的公共字段也无效。循环代码如下:
while (!cancellationToken.IsCancellationRequested && (!producerCompleted || !fileQueue.IsEmpty))
原因分析
1. 值类型按值传递导致副本独立
producerCompleted是值类型(bool),在传递给readAllEXIFdates方法时会创建独立副本。生产者任务修改的是外部的producerCompleted变量,而消费者方法内部使用的是初始化时传入的false副本,二者完全独立,因此消费者永远看不到外部变量的更新,!producerCompleted始终为true。
2. 类字段的线程可见性问题
即便把producerCompleted改成类的公共字段,依然存在线程可见性问题:普通字段没有内存屏障,CPU会将字段值缓存到线程本地缓存中,消费者线程可能一直读取缓存里的旧值(false),无法感知生产者线程对字段的修改(true)。
可行解决方案
- 使用引用类型包装:用
bool[]或自定义类包装状态,比如class Flag { public bool Completed; },传递引用后修改能被消费者感知。 - 添加内存屏障:给类字段加上
volatile修饰符,强制线程每次从主内存读取最新值,而非本地缓存。 - 使用同步原语:比如
ManualResetEventSlim,生产者任务完成时触发事件,消费者通过事件状态判断是否退出。 - 利用Task的完成状态:不用单独的bool变量,消费者直接通过
producerTask.IsCompleted判断生产者是否完成(需确保线程安全)。
内容的提问来源于stack exchange,提问作者PCG
相关产品推荐
相关产品推荐

