1. Task 任务创建:3种核心写法(冷启动/热启动)
核心区分 :冷启动(先实例化后启动)、热启动(创建即执行)StartNew 和Run,工业开发中优先使用热启动 ,冷启动仅在需要「延迟执行任务」时使用。Task.Run 是通用首选,但工业低延迟通信场景禁止使用(基于线程池,有调度延迟,之前讲过的PLC/数控通信需用专用线程)。
csharp
# 1. 冷启动:new Task() + Start(),需手动调用Start才执行
Task task1 = new Task(() => { Console.WriteLine("冷启动任务执行"); });
task1.Start(); // 关键:未调用Start时,任务状态为Created
# 2. 热启动:Task.Factory.StartNew(),创建即执行,支持指定任务选项(工业开发常用)
// 可传入TaskCreationOptions,适合长时间运行/附加子任务等场景
Task task2 = Task.Factory.StartNew(() => { Console.WriteLine("Factory热启动执行"); },
TaskCreationOptions.LongRunning);
# 3. 热启动:Task.Run(),.NET 4.5+新增,简化版Factory.StartNew,推荐通用场景使用
// 底层封装了Factory.StartNew,默认使用线程池,语法更简洁
Task task3 = Task.Run(() => { Console.WriteLine("Run热启动执行"); });
2. TaskCreationOptions 任务创建选项:含义+常用项
创建任务时的可选配置,用于指定任务的调度/执行特性 ,仅Task.Factory.StartNew()/new Task()支持,Task.Run()不支持直接指定(需通过重载间接配置),标注★为常用项。
常用组合示例:长时间运行的任务+强制异步延续
| 选项 | 核心含义 | 适用场景 |
|---|---|---|
★PreferFairness |
指示调度器尽量按「任务创建顺序」执行,避免后创建的任务先执行 | 对执行顺序有轻微要求的批量任务(如MES批量处理生产数据) |
★LongRunning |
标记为长时间运行的任务 | 任务执行时间超过1秒(如大文件解析、远程接口调用),调度器会为其创建新线程(而非复用线程池),避免占用线程池导致其他短任务阻塞 |
★AttachedToParent |
将子任务附加到父任务,父任务需等待子任务完成才会进入完成状态 | 嵌套任务场景(如父任务处理订单,子任务处理订单下的多个商品) |
DenyChildAttach |
禁止当前任务的子任务使用AttachedToParent附加,子任务为独立任务 |
避免嵌套任务导致父任务阻塞,适合独立子任务场景 |
HideScheduler |
阻止环境调度器成为当前任务的调度器,子任务使用默认线程池调度器 | 自定义调度器场景,通用开发几乎不用 |
★RunContinuationsAsynchronously |
强制延续任务异步执行,避免延续任务在触发任务的线程上同步执行导致阻塞 | 所有延续任务场景(避免主线程/任务线程被延续任务阻塞,工业开发必加) |
csharp
Task.Factory.StartNew(() => { Thread.Sleep(2000); Console.WriteLine("长时间任务执行"); },
TaskCreationOptions.LongRunning | TaskCreationOptions.RunContinuationsAsynchronously);
3. Task 的状态有哪些?如何快速判断任务是正常完成/异常/取消?
答案:
- 核心状态:Created、WaitingToRun、Running、RanToCompletion、Canceled、Faulted(共8种,其余为过渡状态);
- 快捷判断:无需直接判断TaskStatus,使用内置属性更简洁:
IsCompletedSuccessfully:正常完成(RanToCompletion);IsCanceled:已取消;IsFaulted:异常终止;IsCompleted:任务已完成(包含以上三种状态)。
通过task.Status获取,标注★为开发中高频关注状态 ,状态流转:Created→WaitingToRun→Running→RanToCompletion/Canceled/Faulted。
状态判断快捷方法(比直接判断Status更简洁):
csharp
task.IsCompleted; // 任务是否完成(RanToCompletion/Canceled/Faulted均返回true)
task.IsCompletedSuccessfully; // 任务是否正常完成(仅RanToCompletion返回true)
task.IsCanceled; // 任务是否取消
task.IsFaulted; // 任务是否异常
| 状态 | 核心含义 | 触发场景 |
|---|---|---|
★Created |
任务已创建,未执行 | new Task()后未调用Start() |
WaitingForActivation |
任务等待激活 | 由系统创建的任务(如延续任务、async-await生成的任务),无需手动Start |
★WaitingToRun |
任务已入队,等待调度器分配线程 | 热启动任务创建后,线程池暂无空闲线程 |
★Running |
任务正在执行中 | 调度器已为任务分配线程,执行业务代码 |
WaitingForChildrenToComplete |
父任务执行完成,等待附加的子任务完成 | 父任务使用AttachedToParent,子任务未完成 |
★RanToCompletion |
任务正常完成 | 业务代码执行完毕,无异常、未取消 |
★Canceled |
任务已取消 | 触发CancellationToken取消信号,且任务检测到取消并正常退出 |
★Faulted |
任务异常终止 | 任务执行过程中抛出未处理异常 |
4. 泛型任务 Task:带返回值的任务
核心 :只有泛型Task才有返回值,通过task.Result获取,非泛型Task无Result属性 ,继承关系:Task<TResult> : Task。
注意 :task.Result是阻塞式 的,与task.Wait()等效,获取结果前会一直阻塞当前线程(主线程/其他任务线程)。
csharp
// 泛型任务创建:指定返回值类型,lambda表达式返回对应类型
Task<int> task = Task.Run(() =>
{
Thread.Sleep(500);
return 10 + 20; // 返回int类型
});
int result = task.Result; // 阻塞当前线程,直到任务完成并获取返回值
Console.WriteLine($"任务返回值:{result}"); // 输出:30
5. Task 任务的等待:单任务+多任务等待
用于阻塞当前线程 ,等待任务完成后再继续执行,工业开发中常用于批量任务执行后统一处理结果(如MES批量采集设备数据后汇总)。单任务等待:Wait() 重载。多任务等待:WaitAll/WaitAny(静态方法)
1. 单任务等待:Wait() 重载
csharp
Task task = Task.Run(() => { Thread.Sleep(1000); });
task.Wait(); // 无参数:无限阻塞,直到任务完成(正常/取消/异常)
bool isCompleted = task.Wait(5000); // 带超时时间(毫秒):返回bool,true=超时前完成,false=超时
// 带取消令牌:等待过程中可被取消
CancellationTokenSource cts = new CancellationTokenSource();
task.Wait(cts.Token);
2. 多任务等待:WaitAll/WaitAny(静态方法)
csharp
Task task1 = Task.Run(() => { Thread.Sleep(500); });
Task task2 = Task.Run(() => { Thread.Sleep(1000); });
Task task3 = Task.Run(() => { Thread.Sleep(1500); });
Task[] tasks = new[] { task1, task2, task3 };
Task.WaitAll(tasks); // 等待**所有任务**完成(耗时=最长任务时间,此处1500ms)
Task.WaitAny(tasks); // 等待**任意一个任务**完成(耗时=最短任务时间,此处500ms)
// 多任务等待也支持超时
bool allCompleted = Task.WaitAll(tasks, 2000); // 2秒内是否所有任务完成
6. task.AsyncState:获取任务的异步状态对象
核心作用 :创建任务时传入自定义状态对象 ,通过AsyncState获取,用于向任务传递额外数据 (如设备ID、工单实体),无需通过lambda闭包(闭包在循环中易出坑)。
工业开发要点 :批量处理设备/工单数据时,用AsyncState传递对象(如设备ID、工单实体),避免闭包导致的变量引用问题。
经典用法:循环创建任务,传递循环变量(避免闭包陷阱)
csharp
class Entity
{
public int Result { get; set; }
public int Number { get; }
public Entity(int number)
{
Number = number;
}
}
internal class Program
{
static void Main(string[] args)
{
Task.Run(() =>
{
});
Console.WriteLine("主线程开始");
Stopwatch stopwatch = Stopwatch.StartNew();
List<Entity> collection = new List<Entity>();
int max = 100;
for (int i = 1; i <= max; i++)
{
collection.Add(new Entity(i));
}
List<Task> tasks = new List<Task>();
foreach (Entity item in collection)
{
Task task = Task.Factory.StartNew(param =>
{
if(param is Entity entity)
{
Thread.Sleep(50);//模拟耗时
entity.Result = entity.Number * entity.Number;
}
},item);
tasks.Add(task);
}
Task.WaitAll(tasks.ToArray());//等待100个任务完成
int sum = 0;
foreach (Task task in tasks)
{
var entity = task.AsyncState as Entity;//获取传入的参数对象
sum += entity.Result;
}
Console.WriteLine($"1-{max}的平方和等于{sum}");
Console.WriteLine($"总耗时:{stopwatch.ElapsedMilliseconds}毫秒");
Console.WriteLine("主线程结束");
Console.ReadKey();
//主线程开始
//1 - 100的平方和等于338350
//总耗时:436毫秒
//主线程结束
}
}
7.Task 任务的取消:CancellationTokenSource(CTS)
核心 :通过「取消令牌」实现任务的优雅取消 (非强制终止线程),工业开发中用于手动终止耗时任务 (如PLC通信超时、远程接口调用中断),禁止使用Abort()终止线程 (会导致资源泄漏)。
CancellationTokenSource, CancelAfter, ThrowIfCancellationRequested, Cancel,
取消回调,取消令牌可多任务共享
完整实现步骤(3步):
csharp
# 1. 创建取消令牌源,可选设置「超时自动取消」
CancellationTokenSource cts = new CancellationTokenSource();
// 可选:设置5秒后自动取消(无需手动调用Cancel)
// cts.CancelAfter(5000);
# 2. 将令牌(cts.Token)传入任务,任务中**主动检测取消信号**
Task task = Task.Run(() =>
{
while (true)
{
// 关键1:检测取消信号,若已取消,抛出OperationCanceledException
cts.Token.ThrowIfCancellationRequested();
// 模拟业务执行
Console.WriteLine("任务执行中...");
Thread.Sleep(200);
}
}, cts.Token); // 关键2:将令牌传入任务
# 3. 手动发送取消信号(外部触发,如按钮点击、超时)
Thread.Sleep(1000);
cts.Cancel(); // 发送取消信号
进阶:取消回调(任务取消时执行自定义逻辑)
csharp
CancellationTokenSource cts = new CancellationTokenSource();
// 注册取消回调:令牌触发取消时,执行指定方法
cts.Token.Register(() =>
{
Console.WriteLine("任务已被取消,执行资源释放逻辑");
// 工业开发中:释放通信连接、关闭文件流、释放硬件资源等
});
Task task = Task.Run(() =>
{
while (!cts.Token.IsCancellationRequested) // 另一种检测取消的方式
{
Console.WriteLine("任务执行中...");
Thread.Sleep(200);
}
// 手动抛出取消异常,让任务状态变为Canceled(否则为RanToCompletion)
cts.Token.ThrowIfCancellationRequested();
}, cts.Token);
Thread.Sleep(1000);
cts.Cancel();
关键要点:
- 任务中必须主动检测取消信号 (
ThrowIfCancellationRequested()/IsCancellationRequested),否则取消信号无效; - 检测到取消后,必须抛出OperationCanceledException ,任务状态才会变为
Canceled,否则为RanToCompletion; - 取消令牌可多任务共享 ,一个CTS的
Cancel()可取消所有传入该令牌的任务。
8. Task 任务的异常捕获:3种核心方式?Task 的异常为什么是AggregateException?如何捕获?
Task的异常为包装异常(AggregateException) ,因为单个任务可能抛出多个异常(如子任务异常),工业开发中必须捕获所有任务异常 ,避免进程崩溃。
方式1:try-catch包裹Wait()/Result,捕获AggregateException并遍历InnerExceptions;
方式2:延续任务ContinueWith结合TaskContinuationOptionsOnlyOnFaulted,通过t.Exception获取;
方法3:async-await会自动解包AggregateException,直接捕获原始异常,无需遍历InnerExceptions,语法和同步代码一致。
方式1:Wait()/Result 捕获(阻塞场景,try-catch包裹)
核心 :非异步场景(如主线程等待任务),用try-catch捕获AggregateException,通过e.InnerExceptions获取所有原始异常。
csharp
Task task = Task.Run(() =>
{
throw new DivideByZeroException("除数不能为0"); // 任务抛出异常
});
try
{
task.Wait(); // 或 task.Result(泛型任务)
}
catch (AggregateException ex)
{
// 遍历所有原始异常(工业开发中需逐个处理/记录)
foreach (var innerEx in ex.InnerExceptions)
{
Console.WriteLine($"任务异常:{innerEx.Message}"); // 输出:除数不能为0
}
}
方式2:延续任务捕获(非阻塞场景,推荐)
通过ContinueWith捕获前驱任务的异常,结合TaskContinuationOptions.OnlyOnFaulted仅在前驱任务异常时执行。
csharp
Task task = Task.Run(() =>
{
throw new Exception("前驱任务抛出异常");
});
// 延续任务:仅当前驱任务异常时执行,t为前驱任务对象
task.ContinueWith(t =>
{
// t.Exception为AggregateException,获取原始异常
foreach (var ex in t.Exception.InnerExceptions)
{
Console.WriteLine($"延续任务捕获异常:{ex.Message}");
}
}, TaskContinuationOptions.OnlyOnFaulted);
方式3:async-await 捕获(.NET 4.5+,最简洁,推荐开发首选)
核心 :async-await会自动解包AggregateException,直接捕获原始异常,无需遍历InnerExceptions,语法和同步代码一致。
csharp
static async Task Main(string[] args) // 主线程标记为async
{
try
{
await Task.Run(() =>
{
throw new DivideByZeroException("除数不能为0");
});
}
catch (DivideByZeroException ex) // 直接捕获原始异常
{
Console.WriteLine($"Await捕获异常:{ex.Message}");
}
}
工业开发要点 :批量任务异常需遍历所有任务的Exception属性,示例:
csharp
Task[] tasks = new[]
{
Task.Run(() => { throw new Exception("任务1异常"); }),
Task.Run(() => { throw new Exception("任务2异常"); })
};
try
{
Task.WaitAll(tasks);
}
catch (AggregateException)
{
// 遍历所有任务,捕获异常的任务
foreach (var task in tasks)
{
if (task.IsFaulted)
{
foreach (var ex in task.Exception.InnerExceptions)
{
Console.WriteLine($"批量任务异常:{ex.Message}");
}
}
}
}
9. Task 延续任务:ContinueWith(任务完成后执行后续逻辑)
核心 :前驱任务完成(正常/异常/取消)后,自动执行延续任务,支持指定执行条件 (仅正常/仅异常/仅取消时执行),工业开发中用于任务完成后的资源释放、结果处理。
1. 基础用法:单个前驱任务的延续
csharp
Task<int> preTask = Task.Run(() => { return 10 + 20; });
// 延续任务:preTask完成后执行,t为前驱任务对象,可获取前驱任务的结果/状态
Task continueTask = preTask.ContinueWith(t =>
{
int result = t.Result; // 获取前驱任务返回值
Console.WriteLine($"前驱任务结果:{result},延续任务执行");
});
2. 关键:TaskContinuationOptions 延续选项(常用★)
用于指定延续任务的执行条件 ,仅当前驱任务满足条件时,延续任务才会执行,避免在延续任务中手动判断状态。默认值是None(这是枚举的默认常量,值为 0)特点是无任何执行条件限制------ 无论前驱任务是正常完成、异常终止还是被取消,延续任务都会被调度执行,是最宽松的延续规则。
OnlyOnRanToCompletion, OnlyOnFaulted, OnlyOnCanceled
| 选项 | 执行条件 | 适用场景 |
|---|---|---|
★OnlyOnRanToCompletion |
前驱任务正常完成(RanToCompletion) | 前驱任务正常执行后,处理结果(如MES数据入库) |
★OnlyOnFaulted |
前驱任务异常(Faulted) | 捕获前驱任务异常,记录日志/执行容错逻辑 |
★OnlyOnCanceled |
前驱任务取消(Canceled) | 任务取消后,释放资源/提示用户 |
NotOnRanToCompletion |
前驱任务未正常完成(异常/取消) | 非成功场景的统一处理 |
NotOnFaulted |
前驱任务未异常(正常/取消) | 无异常场景的处理 |
NotOnCanceled |
前驱任务未取消(正常/异常) | 未取消场景的处理 |
3. 多前驱任务的延续:WhenAll/WhenAny
多个任务完成后执行延续任务,替代Task.WaitAll(非阻塞)。
csharp
Task task1 = Task.Run(() => { Thread.Sleep(500); });
Task task2 = Task.Run(() => { Thread.Sleep(1000); });
// 方式1:所有任务完成后执行延续任务
Task.WhenAll(task1, task2).ContinueWith(t =>
{
Console.WriteLine("所有任务完成,执行延续逻辑");
}, TaskContinuationOptions.OnlyOnRanToCompletion);
// 方式2:任意一个任务完成后执行延续任务
Task.WhenAny(task1, task2).ContinueWith(t =>
{
Console.WriteLine("有任务完成,执行延续逻辑");
});
4. 延续任务的核心注意事项
- 延续任务默认在前驱任务的线程 执行,若需异步执行,创建前驱任务时加
TaskCreationOptions.RunContinuationsAsynchronously; - 延续任务可嵌套,但避免多层嵌套(可读性差,建议用async-await替代);
- 多延续任务:一个前驱任务可绑定多个延续任务(不同条件),彼此独立执行。
10. Task 并行任务创建:循环批量创建(工业开发高频)
批量处理数据时(如MES批量采集多台设备、批量处理工单),循环创建任务并统一等待,重点避免闭包陷阱 (用AsyncState或循环内临时变量解决)。
工业标准写法(用AsyncState传递参数,无闭包陷阱)
csharp
// 模拟工业场景:批量处理10台设备数据
List<Device> devices = Enumerable.Range(1, 10).Select(i => new Device { DeviceId = i, Number = i * 10 }).ToList();
List<Task> tasks = new List<Task>();
foreach (var dev in devices)
{
// 将设备实体作为AsyncState传入,避免闭包陷阱
Task task = Task.Factory.StartNew(state =>
{
Device device = state as Device;
if (device == null) return;
// 模拟耗时操作:PLC/设备数据采集
Thread.Sleep(50);
device.Result = device.Number * 2; // 处理结果
Console.WriteLine($"设备{device.DeviceId}处理完成,结果:{device.Result}");
}, dev);
tasks.Add(task);
}
// 等待所有并行任务完成,再统一处理结果
Task.WaitAll(tasks.ToArray());
// 批量获取处理结果
var completedDevices = devices.Where(d => d.Result != 0).ToList();
Console.WriteLine($"所有设备处理完成,共{completedDevices.Count}台");
// 设备实体类
public class Device
{
public int DeviceId { get; set; } // 设备ID
public int Number { get; set; } // 原始数据
public int Result { get; set; } // 处理结果
}
11. Task 的3种创建方式有什么区别?冷启动和热启动的适用场景?【必背】
答案:
- 方式1:
new Task() + Start():冷启动,任务创建后需手动Start才执行,状态初始为Created,适用于需要延迟执行的场景; - 方式2:
Task.Factory.StartNew():热启动,创建即执行,支持指定TaskCreationOptions,适用于需要自定义任务特性(如长时间运行、附加子任务)的场景; - 方式3:
Task.Run():.NET4.5+新增,热启动,底层封装Factory.StartNew,语法简洁,通用场景首选,但不支持直接指定TaskCreationOptions; - 核心区别:冷启动支持延迟执行,热启动创建即入队等待调度。
12. TaskCreationOptions.LongRunning 的作用是什么?为什么不建议随便用?
答案:
- 作用:标记任务为长时间运行,调度器会为其创建新的独立线程,而非复用线程池线程;
- 原因:线程池的核心是「线程复用」,减少线程创建/销毁的开销,若随便用LongRunning,会创建大量独立线程,导致系统线程切换频繁,性能下降;
- 适用场景:任务执行时间超过1秒(如大文件解析、远程接口调用),避免占用线程池导致短任务阻塞。
13 . Task.WaitAll 和 Task.WhenAll 的区别是什么?
答案:(高频考点,区分阻塞/非阻塞)
Task.WaitAll:阻塞式,静态方法,调用后会阻塞当前线程,直到所有任务完成,返回void;Task.WhenAll:非阻塞式 ,静态方法,调用后返回一个新的Task,当前线程继续执行,新Task在所有任务完成后才完成,支持async-await,推荐开发使用;- 示例:
await Task.WhenAll(t1, t2)(非阻塞),替代Task.WaitAll(t1, t2)(阻塞)。
14. 循环创建Task时,为什么会出现"变量值重复"的问题?如何解决?(闭包陷阱,高频)
答案:
- 原因:C#的for循环变量是按引用传递的,lambda表达式闭包捕获的是变量的引用,而非当前值,循环结束后变量指向最后一个值,导致所有任务获取到相同的变量值;
- 3种解决方法:
- 循环内创建临时变量,将循环变量赋值给临时变量,闭包捕获临时变量;
- 使用
Task.Factory.StartNew的第二个参数,将循环变量作为AsyncState传入,任务内通过state获取(工业开发首选,无闭包); - 使用foreach循环(.NET 5+),foreach的循环变量默认按值传递,无闭包陷阱。
15. Task.Run 和 async-await 的关系是什么?async-await 是开启新线程吗?(高频考点)
答案:
- 关系:
Task.Run用于开启后台线程执行耗时操作 ,async-await是异步编程语法糖,用于简化Task的延续任务,无需手动写ContinueWith; - 核心:async-await 本身不会开启新线程 ,仅当方法内调用了
Task.Run/Task.Factory.StartNew等开启线程的方法时,才会在新线程执行,否则在当前线程执行(如异步IO操作)。
16. 如何优雅取消Task?为什么禁止使用Thread.Abort()?
答案:
- 优雅取消方式:使用
CancellationTokenSource(CTS),步骤为:创建CTS→将cts.Token传入Task→任务内主动检测取消信号(ThrowIfCancellationRequested())→外部调用cts.Cancel()发送取消信号; - 禁止Thread.Abort()的原因:
Abort()会强制终止线程 ,线程执行到任意位置都会被中断,导致资源泄漏 (如未释放的文件流、通信连接、硬件资源),甚至引发进程崩溃,而CTS是优雅取消,任务主动检测并退出,可执行资源释放逻辑。
17. TaskCreationOptions.AttachedToParent 的作用是什么?父任务和子任务的状态关系是什么?
答案:
- 作用:将子任务附加到父任务,形成任务层级,父任务会等待所有附加的子任务完成后,才会进入完成状态;
- 状态关系:父任务执行完毕后,状态变为
WaitingForChildrenToComplete,直到所有子任务完成(正常/异常/取消),父任务才会进入对应的完成状态(RanToCompletion/Canceled/Faulted); - 若子任务未使用AttachedToParent,父任务执行完毕后直接进入完成状态,无需等待子任务。
18. Task 的 Result 属性和 await 有什么区别?
答案:(工业开发易踩坑,区分阻塞/非阻塞)
task.Result:阻塞式,调用后会阻塞当前线程,直到任务完成,若任务异常,抛出AggregateException;await task:非阻塞式,async-await语法,调用后当前线程会释放,继续执行其他逻辑,任务完成后再回到该位置执行,若任务异常,自动解包并抛出原始异常;- 工业开发:主线程/UI线程禁止使用Result(会导致界面卡顿),优先使用await;后台业务线程可根据场景使用,但推荐await。
19. 在C#工业上位机开发中,为什么PLC/数控设备的低延迟通信禁止使用Task?(结合之前的通信知识)
答案:
- 核心原因:Task基于线程池 实现,线程池的核心是「动态调度、线程复用」,存在调度延迟(0~几十毫秒),且线程池线程可能被其他任务占用,导致通信任务等待;
- 工业通信要求1~10ms的稳定低延迟,调度延迟会导致通信延迟突增,无法满足硬指标;
- 补充:Task仅适用于上位机的非实时业务层 (如MES数据上报、日志异步写入、批量数据处理),现场通信层必须使用独占专用线程(无调度延迟、无GC)。
20. 工业开发中,批量采集100台PLC设备数据,如何使用Task实现?需要注意哪些点?
答案:
- 实现方式:循环创建Task,每台设备对应一个Task,使用
Task.WhenAll等待所有任务完成,统一处理采集结果; - 核心注意事项(工业开发必守):
- 避免闭包陷阱:用
AsyncState传递设备ID/IP等参数,而非闭包; - 限制并发数:禁止100个Task同时执行(会导致网络拥塞/PLC连接拒绝),用信号量(SemaphoreSlim) 限制并发数(如同时执行10个任务);
- 异常处理:逐个捕获每个Task的异常,记录异常设备(避免一台设备异常导致批量采集失败);
- 资源释放:每个Task执行完毕后,释放PLC通信连接(避免连接泄漏);
- 禁止使用LongRunning:采集任务为短任务(几十毫秒),复用线程池即可,无需创建独立线程。
- 避免闭包陷阱:用
21. 工业上位机中,Task执行耗时操作(如远程接口调用)时,如何设置超时?
答案:
- 2种常用方式:
- 使用
CancellationTokenSource.CancelAfter:设置超时自动取消,任务内检测取消信号,超时后抛出OperationCanceledException; - 使用
Task.WhenAny:将耗时任务和延迟任务组合,超时后终止等待;
- 使用
- 工业推荐方式(更灵活):
csharp
// 模拟PLC远程调用,设置3秒超时
CancellationTokenSource cts = new CancellationTokenSource(3000); // 3秒后自动取消
try
{
await Task.Run(() =>
{
// 模拟耗时操作:PLC远程通信
while (!cts.Token.IsCancellationRequested)
{
Thread.Sleep(100);
}
cts.Token.ThrowIfCancellationRequested();
}, cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("PLC通信超时"); // 工业开发中:记录超时日志、标记设备离线
}
三、核心要点回顾(笔记+面试题高频考点)
- Task创建分冷启动 (new+Start)和热启动(Factory.StartNew/Run),工业开发优先用热启动,冷启动仅用于延迟执行;
- 常用配置:
LongRunning(长时间任务)、AttachedToParent(附加子任务)、RunContinuationsAsynchronously(异步延续); - 任务状态关注RanToCompletion/Canceled/Faulted,用快捷属性(IsCompletedSuccessfully/IsCanceled/IsFaulted)判断更简洁;
- 多任务等待:
WaitAll(阻塞)、WhenAll(非阻塞,推荐),工业开发优先用WhenAll + await; - 异常捕获:async-await自动解包AggregateException,是开发首选,批量任务需遍历每个Task的Exception;
- 任务取消:用
CancellationTokenSource实现优雅取消,任务内必须主动检测取消信号,禁止使用Thread.Abort(); - 工业开发禁忌:低延迟通信层禁止使用Task (线程池调度延迟),仅用于非实时业务层;循环创建Task避免闭包陷阱(用AsyncState);
- 核心坑点:
Result是阻塞式的,主线程/UI线程禁止使用,优先用await;批量任务需限制并发数(避免资源拥塞)。
async-await 是异步编程语法糖,用于简化 Task 的延续任务,无需手动写 ContinueWith; 怎么理解?
async-await的核心特性,关键抓住两句话:它是简化异步代码的「语法糖」(替代繁琐的ContinueWith),而非「创建线程的工具」(线程由await后面的任务决定)。
async-await的底层是C#编译器自动帮我们生成了ContinueWith的延续任务逻辑,让我们能用同步代码的线性写法 写异步代码,替代ContinueWith的多层嵌套写法(嵌套多了就是「回调地狱」,可读性极差)。
对比案例:实现「先执行任务1→用任务1结果执行任务2→用任务2结果执行任务3」
方式1:手动写ContinueWith(原生Task,嵌套繁琐,可读性差)
多层延续任务会层层嵌套,代码向右缩进,逻辑越复杂越难维护(这就是异步编程的「回调地狱」):
csharp
static void Main(string[] args)
{
// 任务1:计算10+20
Task<int> task1 = Task.Run(() => { Console.WriteLine("执行任务1"); return 10 + 20; });
// 任务1完成后执行任务2:用任务1结果*2
task1.ContinueWith(t1 =>
{
int res1 = t1.Result;
Console.WriteLine($"执行任务2,入参:{res1}");
Task<int> task2 = Task.Run(() => res1 * 2);
// 任务2完成后执行任务3:用任务2结果+5
task2.ContinueWith(t2 =>
{
int res2 = t2.Result;
Console.WriteLine($"执行任务3,入参:{res2}");
int res3 = res2 + 5;
Console.WriteLine($"最终结果:{res3}"); // 输出65
});
});
Console.ReadKey();
}
方式2:用async-await(语法糖,线性写法,和同步代码一样直观)
编译器会自动将这段代码拆解为延续任务,底层还是ContinueWith,但代码完全无嵌套,可读性拉满:
csharp
// 主线程标记为async(.NET 4.5+支持async Main)
static async Task Main(string[] args)
{
// 任务1:计算10+20
Console.WriteLine("执行任务1");
int res1 = await Task.Run(() => 10 + 20);
// 任务2:用任务1结果*2(自动等待任务1完成后执行)
Console.WriteLine($"执行任务2,入参:{res1}");
int res2 = await Task.Run(() => res1 * 2);
// 任务3:用任务2结果+5(自动等待任务2完成后执行)
Console.WriteLine($"执行任务3,入参:{res2}");
int res3 = res2 + 5;
Console.WriteLine($"最终结果:{res3}"); // 输出65
Console.ReadKey();
}
核心结论 :async-await没有改变异步的本质(还是基于Task的延续任务),只是让异步代码的写法和同步代码一致 ,彻底解决了ContinueWith的嵌套问题------这就是「语法糖」的核心价值。
二、核心重点:async-await 本身不开启新线程,线程由await 后面的任务决定
这是最容易误解的点,很多人以为async-await一用就会开新线程,其实**async-await只是「任务调度的管理者」,不是「线程的创建者」**。
场景1:await 后是「由Task.Run/Factory.StartNew创建的任务」→ 新线程执行
原因 :Task.Run/Factory.StartNew会从线程池 获取新线程执行任务,async-await只是等待这个"新线程任务"完成,再继续执行后续代码。
验证代码(工业场景:后台计算设备数据,不阻塞主线程):
场景2:await 后是「无新线程的任务(如已完成任务、异步IO)」→ 原线程执行
核心 :如果await的任务不需要新线程 (比如任务已完成、异步IO操作),那么整个异步方法都会在调用方的原线程 执行,全程无新线程创建。
文件/网络/数据库的异步IO操作 ,底层由操作系统内核 处理,无需.NET托管线程,await时会让出当前线程 (线程去做其他事),IO完成后再回到原线程 执行后续代码,全程无新线程创建 ,且不阻塞线程。
场景3:纯async方法,无await → 同步执行(无异步效果)
如果一个方法标记了async,但内部没有任何await语句 ,那么这个方法会完全同步执行 ,和普通方法没有区别,编译器会给出警告:此异步方法缺少 await 运算符,将以同步方式运行。
三、补充:WinForm/WPF上位机的关键特性------await 后回到UI线程
你做工业上位机开发(WinForm/WPF)时,会遇到UI线程不能阻塞 的问题(阻塞会导致界面卡顿),async-await有一个非常友好的特性:
在UI线程 调用async方法,
await后会自动回到UI线程 执行后续代码,无需手动切换线程(底层由UI同步上下文SynchronizationContext实现)。
- await 的核心作用 :让出当前线程 (而非阻塞),任务完成后回到原线程(WPF) 控制台可能使用新线程(线程池里的也许和task用的同一个)来执行后续代码