别再乱用 StartNew:C#.NET TaskFactory 任务调度实战详解

bash 复制代码
        ### 简介

TaskFactory.NET 里专门用来创建和调度 Task 的工厂类。

最常见的入口是:

csharp 复制代码
Task.Factory.StartNew(...)

很多代码里会把它当成 Task.Run 的高级版,甚至直接用它替代 Task.Run

这种理解只对了一半。

一句话概括:

text 复制代码
TaskFactory 适合需要精细控制任务创建、调度器、取消令牌、创建选项和延续任务的场景。

普通后台任务,直接用 Task.Run 通常更清楚。

只有需要这些能力时,TaskFactory 才更合适:

  • 指定 TaskScheduler
  • 指定 TaskCreationOptions
  • 传入 CancellationToken
  • 使用 LongRunning
  • 使用 AttachedToParent
  • 使用 ContinueWhenAll
  • 使用 ContinueWhenAny
  • 把旧式 Begin/End 异步模型转成 Task

Task.Factory 是什么?

Task.FactoryTask 类型上的静态属性,返回一个默认的 TaskFactory

常见写法:

csharp 复制代码
Task task = Task.Factory.StartNew(() =>
{
    Console.WriteLine("任务执行中");
});

也可以自己创建一个 TaskFactory

csharp 复制代码
var factory = new TaskFactory(
    CancellationToken.None,
    TaskCreationOptions.None,
    TaskContinuationOptions.None,
    TaskScheduler.Default);

手动创建 TaskFactory 的意义是统一默认配置。

例如一批任务都要使用同一个调度器、同一个取消令牌,就可以放进同一个工厂里。

TaskFactory 能做什么?

常用方法如下:

方法 作用
StartNew 创建并启动一个任务
ContinueWhenAll 多个任务全部完成后执行延续任务
ContinueWhenAny 任意一个任务完成后执行延续任务
FromAsync 把旧式 APM 异步模型包装成 Task

其中最常见的是 StartNew

StartNewTask.Run 参数更多,因此也更容易写错。

Task.Run 和 Task.Factory.StartNew 的关系

Task.Run 可以理解成一种更安全、更固定配置的快捷写法。

这段代码:

csharp 复制代码
Task task = Task.Run(() => DoWork());

大致接近:

csharp 复制代码
Task task = Task.Factory.StartNew(
    () => DoWork(),
    CancellationToken.None,
    TaskCreationOptions.DenyChildAttach,
    TaskScheduler.Default);

重点有两个:

  • Task.Run 使用 TaskScheduler.Default
  • Task.Run 默认带 DenyChildAttach

而这个写法:

csharp 复制代码
Task task = Task.Factory.StartNew(() => DoWork());

使用的是当前默认调度器,不一定永远等于线程池调度器。

在普通控制台程序里,差异可能不明显。

但在 UI 程序、自定义调度器、延续任务链里,这个差异就可能影响执行线程。

两者怎么选?

场景 推荐
普通后台计算 Task.Run
ASP.NET Core 里包装同步代码 通常不推荐额外包一层
需要指定 LongRunning Task.Factory.StartNew
需要指定自定义 TaskScheduler TaskFactory
需要 ContinueWhenAll / ContinueWhenAny TaskFactoryTask.WhenAll / Task.WhenAny
需要从 Begin/End 老 API 转成 Task Task.Factory.FromAsync

日常开发里,可以先记住这个规则:

text 复制代码
能用 Task.Run 说清楚的任务,不要强行换成 StartNew。
需要调度细节时,再使用 TaskFactory。

Demo 目标

下面写一个控制台 Demo,模拟订单报表生成任务。

场景如下:

text 复制代码
读取多个门店订单数据
  |
  v
并行计算每个门店销售额
  |
  v
汇总所有门店结果
  |
  v
取最快返回的门店结果
  |
  v
演示取消、异常、LongRunning、async 委托坑

这个 Demo 不依赖数据库,复制到控制台项目即可运行。

创建项目:

bash 复制代码
mkdir TaskFactoryDemo
cd TaskFactoryDemo

dotnet new console

准备模型

修改 Program.cs

csharp 复制代码
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

var stores = new[]
{
    new StoreOrder("上海店", new[] { 120m, 88m, 300m }),
    new StoreOrder("杭州店", new[] { 66m, 180m, 210m }),
    new StoreOrder("深圳店", new[] { 520m, 45m, 99m }),
};

Console.WriteLine($"主线程:{Environment.CurrentManagedThreadId}");

record StoreOrder(string StoreName, decimal[] Amounts);

record StoreReport(string StoreName, decimal TotalAmount, int ThreadId);

后面示例都基于这两个类型。

基础 StartNew

StartNew 会创建并启动一个任务。

csharp 复制代码
Task task = Task.Factory.StartNew(() =>
{
    Console.WriteLine($"任务线程:{Environment.CurrentManagedThreadId}");
    Console.WriteLine("开始生成订单报表");
});

await task;

有返回值时,返回 Task<TResult>

csharp 复制代码
Task<StoreReport> reportTask = Task.Factory.StartNew(() =>
{
    var store = stores[0];

    decimal total = store.Amounts.Sum();

    return new StoreReport(
        store.StoreName,
        total,
        Environment.CurrentManagedThreadId);
});

StoreReport report = await reportTask;

Console.WriteLine($"{report.StoreName} 销售额:{report.TotalAmount},线程:{report.ThreadId}");

这类简单场景用 Task.Run 也可以,而且更推荐:

csharp 复制代码
Task<StoreReport> reportTask = Task.Run(() =>
{
    var store = stores[0];
    return new StoreReport(store.StoreName, store.Amounts.Sum(), Environment.CurrentManagedThreadId);
});

传递 state,减少闭包

StartNew 支持传入 object state

csharp 复制代码
Task<StoreReport> stateTask = Task.Factory.StartNew(static state =>
{
    var store = (StoreOrder)state!;

    return new StoreReport(
        store.StoreName,
        store.Amounts.Sum(),
        Environment.CurrentManagedThreadId);
}, stores[1]);

StoreReport stateReport = await stateTask;

Console.WriteLine($"{stateReport.StoreName} 销售额:{stateReport.TotalAmount}");

这类写法的好处是可以减少闭包捕获。

闭包写法是这样:

csharp 复制代码
var store = stores[1];

Task<StoreReport> taskWithClosure = Task.Factory.StartNew(() =>
{
    return new StoreReport(store.StoreName, store.Amounts.Sum(), Environment.CurrentManagedThreadId);
});

大多数业务代码不用纠结这点。

但在高频创建大量任务的场景里,state 参数可以少一些额外分配。

CancellationToken 取消任务

CancellationToken 不会强制杀死线程。

它只是一个取消信号,任务内部要主动检查。

csharp 复制代码
using var cts = new CancellationTokenSource();

Task cancelTask = Task.Factory.StartNew(() =>
{
    for (int i = 1; i <= 10; i++)
    {
        cts.Token.ThrowIfCancellationRequested();

        Console.WriteLine($"处理第 {i} 批订单");
        Thread.Sleep(300);
    }
}, cts.Token);

await Task.Delay(900);
cts.Cancel();

try
{
    await cancelTask;
}
catch (OperationCanceledException)
{
    Console.WriteLine("任务已取消");
}

这里有两个细节:

  • cts.Token 传给 StartNew
  • 任务内部调用 ThrowIfCancellationRequested()

如果只传 Token,但任务内部从不检查,正在执行的代码不会自动停下来。

LongRunning 长任务

TaskCreationOptions.LongRunning 表示这是一个长时间运行的粗粒度任务。

它不是强制命令,而是给调度器的提示。

默认调度器通常会为它创建独立线程,避免长期占用线程池工作线程。

csharp 复制代码
Task longRunningTask = Task.Factory.StartNew(() =>
{
    Console.WriteLine($"LongRunning 线程:{Environment.CurrentManagedThreadId}");

    for (int i = 1; i <= 3; i++)
    {
        Console.WriteLine($"后台队列第 {i} 次轮询");
        Thread.Sleep(500);
    }
},
CancellationToken.None,
TaskCreationOptions.LongRunning,
TaskScheduler.Default);

await longRunningTask;

适合:

  • 长时间阻塞任务
  • 独立后台循环
  • 持续消费队列
  • 无法改造成真正异步的老代码

不适合:

  • 普通接口请求
  • 普通数据库查询
  • 短小计算任务
  • 已经有异步 API 的 I/O 操作

例如这类写法通常没有意义:

csharp 复制代码
await Task.Factory.StartNew(async () =>
{
    await httpClient.GetStringAsync(url);
}, TaskCreationOptions.LongRunning);

网络 I/O 本身就有异步 API,不需要用 LongRunning 占一个线程。

ContinueWhenAll:全部完成后汇总

ContinueWhenAll 用来等一组任务全部完成后,再执行一个延续任务。

csharp 复制代码
Task<StoreReport>[] reportTasks = stores
    .Select(store => Task.Factory.StartNew(static state =>
    {
        var item = (StoreOrder)state!;

        Thread.Sleep(300);

        return new StoreReport(
            item.StoreName,
            item.Amounts.Sum(),
            Environment.CurrentManagedThreadId);
    }, store))
    .ToArray();

Task<decimal> totalTask = Task.Factory.ContinueWhenAll(reportTasks, completedTasks =>
{
    decimal total = completedTasks.Sum(t => t.Result.TotalAmount);

    Console.WriteLine("所有门店计算完成");

    return total;
});

decimal totalAmount = await totalTask;

Console.WriteLine($"总销售额:{totalAmount}");

这段代码的流程是:

text 复制代码
多个门店任务并行执行
  |
  v
全部完成
  |
  v
汇总销售额

现代代码里也可以写成:

csharp 复制代码
StoreReport[] reports = await Task.WhenAll(reportTasks);
decimal total = reports.Sum(x => x.TotalAmount);

Task.WhenAll 通常更适合配合 async/await 使用。

ContinueWhenAll 更像早期 TPL 风格,适合需要设置延续选项或调度器的场景。

ContinueWhenAny:谁先完成先处理

ContinueWhenAny 用来处理最快完成的任务。

csharp 复制代码
Task<StoreReport>[] raceTasks = stores
    .Select((store, index) => Task.Factory.StartNew(static state =>
    {
        var data = ((StoreOrder Store, int Index))state!;

        Thread.Sleep((data.Index + 1) * 300);

        return new StoreReport(
            data.Store.StoreName,
            data.Store.Amounts.Sum(),
            Environment.CurrentManagedThreadId);
    }, (store, index)))
    .ToArray();

Task<StoreReport> fastestTask = Task.Factory.ContinueWhenAny(raceTasks, completedTask =>
{
    return completedTask.Result;
});

StoreReport fastest = await fastestTask;

Console.WriteLine($"最快返回:{fastest.StoreName},销售额:{fastest.TotalAmount}");

典型场景:

  • 多个缓存源谁先返回用谁
  • 多个服务节点谁先响应用谁
  • 多个计算方案先拿到一个可用结果

现代写法也可以使用:

csharp 复制代码
Task<StoreReport> first = await Task.WhenAny(raceTasks);
StoreReport fastestReport = await first;

FromAsync:包装旧式 Begin/End API

早期 .NET 有一类异步 API 使用 BeginXxx / EndXxx 模式,也叫 APM

TaskFactory.FromAsync 可以把它们包装成 Task

示例:

csharp 复制代码
byte[] buffer = new byte[1024];

await File.WriteAllTextAsync("orders.txt", "hello task factory");

await using FileStream stream = new(
    "orders.txt",
    FileMode.Open,
    FileAccess.Read,
    FileShare.Read,
    bufferSize: 4096,
    useAsync: true);

Task<int> readTask = Task.Factory.FromAsync(
    stream.BeginRead,
    stream.EndRead,
    buffer,
    0,
    buffer.Length,
    state: null);

int bytesRead = await readTask;

Console.WriteLine($"读取字节数:{bytesRead}");

现在大多数新 API 已经直接提供 ReadAsyncWriteAsyncSendAsync

所以 FromAsync 更多用于兼容老接口。

async 委托的大坑

Task.Factory.StartNew 遇到 async 委托时,很容易写出嵌套任务。

看这段代码:

csharp 复制代码
Task<Task<string>> nestedTask = Task.Factory.StartNew(async () =>
{
    await Task.Delay(500);
    return "异步结果";
});

返回类型不是:

csharp 复制代码
Task<string>

而是:

csharp 复制代码
Task<Task<string>>

原因是:

text 复制代码
StartNew 只负责启动委托。
async 委托本身又会返回一个 Task。

如果只等待外层任务:

csharp 复制代码
Task<string> innerTask = await nestedTask;

只表示 async 委托已经返回了内部任务,不代表内部异步操作已经完成。

正确处理方式有两个。

第一种,使用 Unwrap()

csharp 复制代码
Task<string> unwrappedTask = Task.Factory.StartNew(async () =>
{
    await Task.Delay(500);
    return "异步结果";
}).Unwrap();

string result = await unwrappedTask;

第二种,直接用 Task.Run

csharp 复制代码
Task<string> runTask = Task.Run(async () =>
{
    await Task.Delay(500);
    return "异步结果";
});

string result = await runTask;

这也是日常代码更推荐 Task.Run 的原因之一。

Task.RunFunc<Task>Func<Task<TResult>> 有专门重载,使用起来更自然。

异常处理

TaskFactory 创建的任务如果抛异常,异常会存到 Task.Exception 里。

使用 await 时,异常会按原始异常抛出:

csharp 复制代码
Task errorTask = Task.Factory.StartNew(() =>
{
    throw new InvalidOperationException("报表生成失败");
});

try
{
    await errorTask;
}
catch (InvalidOperationException ex)
{
    Console.WriteLine(ex.Message);
}

使用 Wait()Result 时,异常会被包进 AggregateException

csharp 复制代码
Task<int> errorResultTask = Task.Factory.StartNew<int>(() =>
{
    throw new InvalidOperationException("计算失败");
});

try
{
    int result = errorResultTask.Result;
}
catch (AggregateException ex)
{
    Console.WriteLine(ex.InnerException?.Message);
}

所以异步代码里更推荐:

csharp 复制代码
await task;

而不是:

csharp 复制代码
task.Wait();
task.Result;

Wait()Result 会阻塞当前线程,在 UI 程序和某些同步上下文里还可能引发死锁。

父子任务 AttachedToParent

AttachedToParent 可以让子任务附加到父任务。

父任务会等附加的子任务完成后才算完成。

csharp 复制代码
Task parent = Task.Factory.StartNew(() =>
{
    Console.WriteLine("父任务开始");

    Task.Factory.StartNew(() =>
    {
        Thread.Sleep(500);
        Console.WriteLine("子任务完成");
    }, TaskCreationOptions.AttachedToParent);

    Console.WriteLine("父任务委托结束");
});

await parent;

Console.WriteLine("父任务真正完成");

输出顺序通常类似:

text 复制代码
父任务开始
父任务委托结束
子任务完成
父任务真正完成

但要注意,Task.Run 默认带 DenyChildAttach

也就是说,用 Task.Run 创建的父任务,子任务不能随便附加进去。

这能避免一些隐式父子关系带来的等待问题。

自定义 TaskFactory

如果一批任务都要使用相同配置,可以创建自己的 TaskFactory

下面示例统一使用一个 CancellationTokenTaskScheduler.Default

csharp 复制代码
using var source = new CancellationTokenSource();

var factory = new TaskFactory(
    source.Token,
    TaskCreationOptions.None,
    TaskContinuationOptions.None,
    TaskScheduler.Default);

Task<StoreReport>[] tasks = stores
    .Select(store => factory.StartNew(static state =>
    {
        var item = (StoreOrder)state!;

        return new StoreReport(
            item.StoreName,
            item.Amounts.Sum(),
            Environment.CurrentManagedThreadId);
    }, store))
    .ToArray();

StoreReport[] reports = await Task.WhenAll(tasks);

foreach (StoreReport item in reports)
{
    Console.WriteLine($"{item.StoreName}: {item.TotalAmount}");
}

这种写法的价值不是"少写几行代码",而是把一批任务的默认策略固定下来。

常见错误

1. 用 StartNew 包异步 I/O

不推荐:

csharp 复制代码
await Task.Factory.StartNew(async () =>
{
    await File.ReadAllTextAsync("orders.txt");
});

推荐:

csharp 复制代码
string text = await File.ReadAllTextAsync("orders.txt");

异步 I/O 本身不需要额外占用线程池线程。

2. 忘记指定 TaskScheduler.Default

在某些上下文里:

csharp 复制代码
Task.Factory.StartNew(() => DoWork());

可能会使用当前调度器。

如果明确希望在线程池执行,可以写完整:

csharp 复制代码
Task.Factory.StartNew(
    () => DoWork(),
    CancellationToken.None,
    TaskCreationOptions.None,
    TaskScheduler.Default);

普通场景更简单:

csharp 复制代码
Task.Run(() => DoWork());

3. async 委托忘记 Unwrap

错误:

csharp 复制代码
Task<Task<string>> task = Task.Factory.StartNew(async () =>
{
    await Task.Delay(1000);
    return "ok";
});

正确:

csharp 复制代码
Task<string> task = Task.Factory.StartNew(async () =>
{
    await Task.Delay(1000);
    return "ok";
}).Unwrap();

更简单:

csharp 复制代码
Task<string> task = Task.Run(async () =>
{
    await Task.Delay(1000);
    return "ok";
});

4. 把 LongRunning 当成性能优化开关

LongRunning 不是"更快"开关。

它更适合长时间阻塞的粗粒度任务。

大量短任务都加 LongRunning,反而可能创建过多线程,增加上下文切换和内存开销。

5. 用 Result / Wait 阻塞异步代码

不推荐:

csharp 复制代码
var result = task.Result;
task.Wait();

推荐:

csharp 复制代码
var result = await task;

await 不会阻塞当前线程,异常处理也更自然。

TaskFactory 适合哪些场景?

适合:

  • 需要指定 TaskCreationOptions
  • 需要指定 TaskScheduler
  • 需要把旧式 Begin/End API 转成 Task
  • 需要父子任务关系
  • 需要统一创建一批任务的默认策略
  • 维护早期 TPL 风格代码

不适合:

  • 普通异步 I/O
  • 普通后台计算
  • 只是为了"让方法变异步"
  • ASP.NET Core 请求里随手包同步代码
  • 大量短小任务无脑 StartNew

总结

TaskFactory 不是过时 API,但它也不是日常异步编程的首选入口。

更合理的分工是:

text 复制代码
普通后台任务:Task.Run
等待多个任务:Task.WhenAll / Task.WhenAny
真正异步 I/O:直接 await 原生异步 API
需要精细控制调度:TaskFactory

Task.Factory.StartNew 的强大之处在于可配置。

但可配置也意味着更容易踩坑:

  • 调度器可能不是预期的
  • async 委托会产生嵌套 Task
  • LongRunning 可能被滥用
  • Wait / Result 可能阻塞线程
  • 取消不会自动杀死正在执行的代码

掌握这些边界后,TaskFactory 更适合放在工具箱里处理特殊任务调度,而不是替代 Task.Run 成为默认写法。

相关推荐
Artech10 小时前
[MAF预定义的AIContextProvider-03]ChatHistoryMemoryProvider——赋予Agent从经验中学习的能力
ai·c#·agent·memory·maf
Scout-leaf2 天前
C#摸鱼实录——IoC与DI案例详解
c#
咕白m6252 天前
使用 C# 在 Excel 中应用多种字体样式
后端·c#
Artech2 天前
[MAF预定义的AIContextProvider-02]AgentSkillsProvider——将Agent Skills引入MAF
ai·c#·agent·agent skills·maf
2601_962072553 天前
李梦娇常识4600问|题库|打印版
sql·华为od·华为·c#·华为云·.net·harmonyos
m0_547486663 天前
《C#语言程序设计与实践》 全套PPT课件
c语言·c#·c语言程序设计
叶帆3 天前
【YFIOs】用C#开发硬件之设备上云
开发语言·unity·c#
IT方大同3 天前
(嵌入式操作系统)信号量
嵌入式硬件·c#
z落落3 天前
C# FileStream文件流读取文件
开发语言·c#