C# ThreadPool.QueueUserWorkItem返回true但回调未执行问题
现有如下代码:
static void Main(string[] args) { runStartedJobs(); } static void runStartedJobs() { List<Job> runningJobs = Job.getQueue(1); for (int j = 0; j < runningJobs.Count; j++) { if (runningJobs[j].InputFile.Trim().Length == 0 || runningJobs[j].SubscriberColumn > 0 || runningJobs[j].NpsGroupColumn > 0) { string queueFolder = @"c:\temp\"; string searchString = "cde_" + runningJobs[j].JobID.ToString("0") + "_*.json"; string[] jsonFiles = Directory.GetFiles(queueFolder, searchString, SearchOption.TopDirectoryOnly); for (int f = 0; f < jsonFiles.Length; f++) { StreamReader jsonReader = new StreamReader(jsonFiles[f]); string jsonThread = jsonReader.ReadToEnd(); jsonReader.Close(); List<NPS_Package> packages = JsonConvert.DeserializeObject<List<NPS_Package>>(jsonThread); bool threadQueued = ThreadPool.QueueUserWorkItem(new WaitCallback(GetData), packages); } //Console.ReadLine(); } }
运行时现象:
- 共触发2次
QueueUserWorkItem调用,返回值均为true,但程序启动后立即退出,GetData方法入口设置的断点始终无法命中,方法内逻辑完全没有执行 - 如果在for循环后取消
Console.ReadLine()的注释,阻塞主线程,GetData方法可以正常执行,断点可命中 - 需求:不希望通过阻塞主线程的方式等待单个任务执行完成,需要支持在当前作业执行完毕前并发启动其他作业,同时保证线程池任务可以正常执行。
.NET线程池创建的所有工作线程都属于后台线程,运行时的进程退出规则非常明确:只要进程内所有前台线程执行完毕,无论后台线程是否启动、是否执行完成,都会直接强制终止所有后台线程并退出进程。
你的代码中主线程是唯一的前台线程,执行完runStartedJobs()方法后会立刻从Main方法返回,此时两个工作项虽然已经成功排入线程池队列(这也是QueueUserWorkItem返回true的原因——该返回值仅代表入队成功,不代表任务已经开始执行),但线程池还没来得及调度工作线程执行GetData,进程就已经触发退出流程,所有排队的后台工作项直接被终止,自然无法命中断点。
添加Console.ReadLine()之所以能解决问题,本质是它把主线程(前台线程)阻塞住了,进程不会触发退出逻辑,线程池才有足够时间调度工作线程执行任务。
核心思路是:提交工作项的逻辑不要加任何阻塞,保证作业可以持续并发启动;同时不要让主线程在还有未完成工作项的时候直接退出,避免进程提前终止后台任务。
推荐用Task替代原始的ThreadPool.QueueUserWorkItemAPI,二者底层都是基于线程池调度,没有额外性能开销,但Task内置了状态跟踪、异常传播的能力,代码可维护性更高。
改造后的参考代码如下:
using System.Collections.Concurrent; // 全局维护线程安全的运行中任务集合 private static readonly ConcurrentBag<Task> _runningTasks = new ConcurrentBag<Task>(); static void Main(string[] args) { runStartedJobs(); // 等待所有已提交的任务执行完成,阻止进程提前退出 // 如果需要长期运行持续拉取新作业,可以在这里加循环逻辑定期扫描新任务 Task.WhenAll(_runningTasks).Wait(); } static void runStartedJobs() { List<Job> runningJobs = Job.getQueue(1); for (int j = 0; j < runningJobs.Count; j++) { if (runningJobs[j].InputFile.Trim().Length == 0 || runningJobs[j].SubscriberColumn > 0 || runningJobs[j].NpsGroupColumn > 0) { string queueFolder = @"c:\temp\"; string searchString = "cde_" + runningJobs[j].JobID.ToString("0") + "_*.json"; string[] jsonFiles = Directory.GetFiles(queueFolder, searchString, SearchOption.TopDirectoryOnly); for (int f = 0; f < jsonFiles.Length; f++) { // using块保证StreamReader无论正常结束还是抛异常都能正确释放,避免文件句柄泄漏 using (StreamReader jsonReader = new StreamReader(jsonFiles[f])) { string jsonThread = jsonReader.ReadToEnd(); List<NPS_Package> packages = JsonConvert.DeserializeObject<List<NPS_Package>>(jsonThread); // 提交线程池任务,提交过程瞬间完成,不会阻塞后续作业处理 Task workTask = Task.Run(() => GetData(packages)); _runningTasks.Add(workTask); // 任务执行完成后自动从集合中移除,避免内存泄漏 workTask.ContinueWith(_ => _runningTasks.TryTake(out _)); } } // 此处无需任何等待,提交完当前作业的所有任务后会立刻进入下一轮循环处理其他作业 // 完全满足并发启动作业的要求 } } }
方案说明:
- 无阻塞提交:任务提交是瞬时操作,循环不会等待当前任务执行完成,会持续遍历处理后续作业、后续文件,所有任务在线程池内并发执行,和原代码的调度逻辑完全一致,没有串行等待的问题
- 不会提前退出:主线程会等待所有已提交的任务全部执行完成后再结束,不会出现进程强制终止后台任务的问题。如果要做长期运行的作业调度服务,只需要把Main方法改成循环,定期扫描新的作业提交任务即可,只要集合里还有未完成的任务,进程就不会退出
- 异常安全:用
using块替换了原代码手动调用Close()的逻辑,即使读取文件、反序列化过程中抛出异常,文件句柄也能正确释放,不会造成资源泄漏。
如果坚持使用原始的ThreadPoolAPI,也可以通过线程安全的计数器实现相同逻辑:提交任务时计数器原子+1,GetData方法执行完成(包括异常分支)时计数器原子-1,主线程在计数器归0前保持运行即可,但这种方式需要自行处理异常捕获、线程同步逻辑,维护成本更高。
内容的提问来源于stack exchange,提问作者Mark Ainsworth

