You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

自取消Task子类导致其他实例执行异常问题求助

问题:AutoCancelTask 重复测试仅首次通过的根源分析

问题背景

我实现了Task的子类AutoCancelTask,目标是让它在释放时自动取消自身并等待完成。但单元测试时遇到异常:同一测试多次运行时,只有第一次能通过,后续全部失败,错误提示Assert.AreEqual failed. Expected:<True>. Actual:<(null)>。测试执行时长也异常:第一次约30ms,第二次约300ms,第四至五次<1ms>。

相关代码

AutoCancelTask 实现

using System;
using System.Threading.Tasks;
using System.Threading;

namespace Tests
{
    /// <summary>
    /// 释放时会自动取消并等待完成的Task
    /// </summary>
    public class AutoCancelTask : Task
    {
        private readonly CancellationTokenSource _cts;

        private AutoCancelTask(Action<CancellationToken> action, CancellationTokenSource cts, TaskCreationOptions options)
            : base(() => action(cts.Token), cts.Token, options)
        {
            _cts = cts;
        }

        /// <summary>
        /// 将可取消的Action包装为释放时自动取消的Task
        /// </summary>
        /// <param name="action">要包装的Action</param>
        /// <param name="options">Task创建选项</param>
        /// <returns>已启动的Task</returns>
        public static Task Run(Action<CancellationToken> action, TaskCreationOptions options = TaskCreationOptions.None)
        {
            Task t = new AutoCancelTask(action, new CancellationTokenSource(), options);
            t.Start();
            return t;
        }

        protected override void Dispose(bool disposing)
        {
            if (disposing)
            {
                try
                {
                    _cts.Cancel();
                }
                catch (ObjectDisposedException)
                {
                    // 已释放时无需处理
                }
                try
                {
                    Wait();
                }
                catch (ObjectDisposedException) { }
                catch (AggregateException ex)
                {
                    if (1 < ex.InnerExceptions.Count || !(ex.InnerExceptions[0] is TaskCanceledException))
                    {
                        // 仅当聚合异常不是单个TaskCanceledException时才重新抛出
                        throw ex;
                    }
                }
                _cts.Dispose();
            }

            base.Dispose(disposing);
        }
    }
}

测试代码

using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Threading;

namespace Tests
{
    public class TestDataWrapper
    {
        internal bool? IsCancelled { get; private set; } = null;
        internal void Action(CancellationToken cancel)
        {
            IsCancelled = Task.Delay(100, cancel).ContinueWith(t => t.IsCanceled).Result;
        }
    }

    [TestClass()]
    public class Tests
    {
        public static IEnumerable<object[]> Datas { get; } =
            Enumerable.Range(0, 5).Select(_ => new object[] { new TestDataWrapper() });

        [DataTestMethod]
        [DynamicData(nameof(Datas))]
        public void AutoCancelTask_Cancels(TestDataWrapper data)
        {
            using (AutoCancelTask.Run(data.Action)) { }
            Assert.AreEqual(true, data.IsCancelled);
        }
    }
}

问题根源分析

核心问题出在**Task子类的构造逻辑与Start()调用的时序竞争**,以及Task基类的内部行为特性:

  1. 任务调度的异步性:调用AutoCancelTask.Run时,先创建AutoCancelTask实例,再调用t.Start()。但Task的基类构造函数中传入的委托() => action(cts.Token)不会立即执行,要等到Start()将任务调度到线程池后才会运行。而using语句会在任务创建后立即触发Dispose()流程,此时_cts.Cancel()可能在任务委托被调度执行前就已执行。

  2. Task对已取消令牌的处理逻辑:如果任务启动时(Start()执行后),绑定的CancellationToken已经处于取消状态,Task基类会直接将任务标记为Canceled状态,完全跳过委托的执行。这就导致后续测试中,Action方法根本没运行,IsCancelled始终为null。

  3. 第一次测试通过的原因:首次运行时线程池空闲,任务被立即调度执行,Action已经开始运行,此时Cancel()触发后,Task.Delay会响应取消并将IsCancelled设为true。而后续测试中,线程池调度速度跟不上Dispose()的执行速度,令牌先被取消,任务直接跳过委托执行。

添加1ms延迟能临时解决问题,本质是给了任务足够的调度执行时间,让委托能在令牌取消前开始运行。

修复方案

Task类并非设计用于继承,官方文档明确建议优先使用组合而非继承实现自定义任务逻辑。正确的实现方式是创建一个实现IDisposable的类,内部持有任务和取消令牌源:

public class AutoCancelTask : IDisposable
{
    private readonly Task _task;
    private readonly CancellationTokenSource _cts;

    private AutoCancelTask(Action<CancellationToken> action, CancellationTokenSource cts, TaskCreationOptions options)
    {
        _cts = cts;
        // 直接用Task.Factory.StartNew调度任务,确保委托会被执行(即使令牌后续被取消)
        _task = Task.Factory.StartNew(() => action(cts.Token), cts.Token, options, TaskScheduler.Default);
    }

    public static AutoCancelTask Run(Action<CancellationToken> action, TaskCreationOptions options = TaskCreationOptions.None)
    {
        return new AutoCancelTask(action, new CancellationTokenSource(), options);
    }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (disposing)
        {
            try
            {
                _cts.Cancel();
            }
            catch (ObjectDisposedException)
            {
                // 已释放时无需处理
            }

            try
            {
                _task.Wait();
            }
            catch (ObjectDisposedException)
            {
                // 已释放时无需处理
            }
            catch (AggregateException ex)
            {
                if (!(ex.InnerExceptions.Count == 1 && ex.InnerExceptions[0] is TaskCanceledException))
                {
                    throw;
                }
            }

            _cts.Dispose();
        }
    }
}

对应的测试代码需要调整using语句的使用方式:

[DataTestMethod]
[DynamicData(nameof(Datas))]
public void AutoCancelTask_Cancels(TestDataWrapper data)
{
    using (var autoCancelTask = AutoCancelTask.Run(data.Action)) { }
    Assert.AreEqual(true, data.IsCancelled);
}

这种实现方式避免了Task基类的内部状态竞争问题,即使令牌被快速取消,任务委托也会被执行(直到逻辑内部检查令牌或调用依赖令牌的API),确保IsCancelled会被正确赋值。

内容的提问来源于stack exchange,提问作者GreatBarrier

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.17 03:24:57