简介

Task.Wait 是 Task 的一个实例方法,用于等待 Task 完成,如果 Task 未完成,会阻塞当前线程。

非必要情况下,不建议使用 Task.Wait,而应该使用 await。

本文将基于 .NET 6 的源码来分析 Task.Wait 的实现,其他版本的实现也是类似的。

var task = Task.Run(() =>
{
Thread.Sleep(1000);
return "Hello World";
}); var sw = Stopwatch.StartNew();
Console.WriteLine("Before Wait");
task.Wait();
Console.WriteLine("After Wait: {0}ms", sw.ElapsedMilliseconds); Console.WriteLine("Result: {0}, Elapsed={1}ms", task.Result, sw.ElapsedMilliseconds);

输出:

Before Wait
After Wait: 1002ms
Result: Hello World, Elapsed=1002ms

可以看到,task.Wait 阻塞了当前线程,直到 task 完成。

其效果等效于:

  1. task.Result (仅限于 Task<TResult>)

  2. task.GetAwaiter().GetResult()

task.Wait 共有 5 个重载

public class Task<TResult> : Task
{
} public class Task
{
// 1. 无参数,无返回值,阻塞当前线程至 task 完成
public void Wait()
{
Wait(Timeout.Infinite, default);
} // 2. 无参数,有返回值,阻塞当前线程至 task 完成或 超时
// 如果超时后 task 仍未完成,返回 False,否则返回 True
public bool Wait(TimeSpan timeout)
{
return Wait((int)timeout.TotalMilliseconds, default);
} // 3. 和 2 一样,只是参数类型不同
public bool Wait(int millisecondsTimeout)
{
return Wait(millisecondsTimeout, default);
} // 4. 无参数,无返回值,阻塞当前线程至 task 完成或 cancellationToken 被取消
// cancellationToken 被取消时抛出 OperationCanceledException
public void Wait(CancellationToken cancellationToken)
{
Wait(Timeout.Infinite, cancellationToken);
} // 5. 无参数,有返回值,阻塞当前线程至 task 完成或 超时 或 cancellationToken 被取消
// 如果超时后 task 仍未完成,返回 False,否则返回 True
// cancellationToken 被取消时抛出 OperationCanceledException
public bool Wait(int millisecondsTimeout, CancellationToken cancellationToken)
{
ThrowIfContinuationIsNotNull();
return InternalWaitCore(millisecondsTimeout, cancellationToken);
}
}

下面是一个使用 bool Wait(int millisecondsTimeout) 的例子:

var task = Task.Run(() =>
{
Thread.Sleep(1000);
return "Hello World";
}); var sw = Stopwatch.StartNew();
Console.WriteLine("Before Wait");
bool completed = task.Wait(millisecondsTimeout: 200);
Console.WriteLine("After Wait: completed={0}, Elapsed={1}", completed, sw.ElapsedMilliseconds); Console.WriteLine("Result: {0}, Elapsed={1}", task.Result, sw.ElapsedMilliseconds);

输出:

Before Wait
After Wait: completed=False, Elapsed=230
Result: Hello World, Elapsed=1001

因为指定的 millisecondsTimeout 不足以等待 task 完成,所以 task.Wait 返回 False,继续执行后续代码。

但是,task.Result 仍然会阻塞当前线程,直到 task 完成。

关联的方法还有 Task.WaitAll 和 Task.WaitAny。同样也是非必要情况下,不建议使用。

背后的实现

task.Wait、task.Result、task.GetAwaiter().GetResult() 这三者背后的实现其实是一样的,都是调用了 Task.InternalWaitCore 这个实例方法。

借助 Rider 的类库 debug 功能,来给大家展示一下这三种方法的调用栈。

Task<string> RunTask()
{
return Task.Run(() =>
{
Thread.Sleep(1000);
return "Hello World!";
});
} var task1 = RunTask();
task1.Wait(); var task2 = RunTask();
task2.GetAwaiter().GetResult(); var task3 = RunTask();
_ = task3.Result;

Task.InternalWaitCore 是 Task 的一个私有实例方法。

https://github.com/dotnet/runtime/blob/c76ac565499f3e7c657126d46c00b67a0d74832c/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs#L2883

public class Task
{
internal bool InternalWait(int millisecondsTimeout, CancellationToken cancellationToken) =>
InternalWaitCore(millisecondsTimeout, cancellationToken); private bool InternalWaitCore(int millisecondsTimeout, CancellationToken cancellationToken)
{
// 如果 Task 已经完成,直接返回 true
bool returnValue = IsCompleted;
if (returnValue)
{
return true;
} // 如果调用的是 Task.Wait 的无参重载方法,且Task 已经完成或者在内联执行后完成,直接返回 true,不会阻塞 Task.Wait 的调用线程。
// WrappedTryRunInline 的意思是尝试在捕获的 TaskScheduler 中以内联的方式执行 Task,此处不展开
if (millisecondsTimeout == Timeout.Infinite && !cancellationToken.CanBeCanceled &&
WrappedTryRunInline() && IsCompleted)
{
returnValue = true;
}
else
{
// Task 未完成,调用 SpinThenBlockingWait 方法,阻塞当前线程,直到 Task 完成或超时或 cancellationToken 被取消
returnValue = SpinThenBlockingWait(millisecondsTimeout, cancellationToken);
} return returnValue;
} private bool SpinThenBlockingWait(int millisecondsTimeout, CancellationToken cancellationToken)
{
bool infiniteWait = millisecondsTimeout == Timeout.Infinite;
uint startTimeTicks = infiniteWait ? 0 : (uint)Environment.TickCount;
bool returnValue = SpinWait(millisecondsTimeout);
if (!returnValue)
{
var mres = new SetOnInvokeMres();
try
{
// 将 mres 作为 Task 的 Continuation,当 Task 完成时,会调用 mres.Set() 方法
AddCompletionAction(mres, addBeforeOthers: true);
if (infiniteWait)
{
bool notifyWhenUnblocked = ThreadPool.NotifyThreadBlocked();
try
{
// 没有指定超时时间,阻塞当前线程,直到 Task 完成或 cancellationToken 被取消
returnValue = mres.Wait(Timeout.Infinite, cancellationToken);
}
finally
{
if (notifyWhenUnblocked)
{
ThreadPool.NotifyThreadUnblocked();
}
}
}
else
{
uint elapsedTimeTicks = ((uint)Environment.TickCount) - startTimeTicks;
if (elapsedTimeTicks < millisecondsTimeout)
{
bool notifyWhenUnblocked = ThreadPool.NotifyThreadBlocked();
try
{
// 指定了超时时间,阻塞当前线程,直到 Task 完成或 超时 或 cancellationToken 被取消
returnValue = mres.Wait((int)(millisecondsTimeout - elapsedTimeTicks), cancellationToken);
}
finally
{
if (notifyWhenUnblocked)
{
ThreadPool.NotifyThreadUnblocked();
}
}
}
}
}
finally
{
// 如果因为超时或 cancellationToken 被取消,而导致 Task 未完成,需要将 mres 从 Task 的 Continuation 中移除
if (!IsCompleted) RemoveContinuation(mres);
}
}
return returnValue;
} private bool SpinWait(int millisecondsTimeout)
{
if (IsCompleted) return true; if (millisecondsTimeout == 0)
{
// 如果指定了超时时间为 0,直接返回 false
return false;
} // 自旋至少一次,总次数由 Threading.SpinWait.SpinCountforSpinBeforeWait 决定
// 如果 Task 在自旋期间完成,返回 true
int spinCount = Threading.SpinWait.SpinCountforSpinBeforeWait;
SpinWait spinner = default;
while (spinner.Count < spinCount)
{
// -1 表示自旋期间不休眠,不会让出 CPU 时间片
spinner.SpinOnce(sleep1Threshold: -1); if (IsCompleted)
{
return true;
}
} // 自旋结束后,如果 Task 仍然未完成,返回 false
return false;
} private sealed class SetOnInvokeMres : ManualResetEventSlim, ITaskCompletionAction
{
// 往父类 ManualResetEventSlim 中传入 false,表示 ManualResetEventSlim 的初始状态为 nonsignaled
// 也就是说,在调用 ManualResetEventSlim.Set() 方法之前,ManualResetEventSlim.Wait() 方法会阻塞当前线程
internal SetOnInvokeMres() : base(false, 0) { }
public void Invoke(Task completingTask) { Set(); }
public bool InvokeMayRunArbitraryCode => false;
}
}

Task.Wait 的两个阶段

SpinWait 阶段

用户态锁,不能维持很长时间的等待。线程在等待锁的释放时忙等待,不会进入休眠状态,从而避免了线程切换的开销。它在自旋等待期间会持续占用CPU时间片,如果自旋等待时间过长,会浪费CPU资源。

BlockingWait 阶段

内核态锁,在内核态实现的锁机制。当线程无法获得锁时,会进入内核态并进入休眠状态,将CPU资源让给其他线程。线程在内核态休眠期间不会占用CPU时间片,从而避免了持续的忙等待。当锁可用时,内核会唤醒休眠的线程并将其调度到CPU上执行。

BlockingWait 阶段 主要借助 SetOnInvokeMres 实现, SetOnInvokeMres 继承自 ManualResetEventSlim。

它会阻塞调用线程直到 Task 完成 或 超时 或 cancellationToken 被取消。

当前线程,Task 完成时,SetOnInvokeMres.Set() 方法会被当做 Task 的回调被调用从而解除阻塞。

Task.Wait 可能会导致的问题

到目前为止,我们已经了解到 Task.Wait 阻塞当前线程等待 Task 完成的原理,但是我们还是没有回答最开始的问题:为什么不建议使用 Task.Wait。

可能会导致线程池饥饿

线程池饥饿是指线程池中的可用线程数量不足,无法执行任务的现象。

在 ThreadPool 的设计中,如果已经创建的线程达到了一定数量,就算有新的任务需要执行,也不会立即创建新的线程(每 500ms 才会检查一次是否需要创建新的线程)。

更详细的介绍可以参考我的另一篇文章:https://www.cnblogs.com/eventhorizon/p/15316955.html#3-避免饥饿机制starvation-avoidance

如果我们在一个 ThreadPool 线程中调用 Task.Wait,而 Task.Wait 又阻塞了这个线程,无法执行其他任务,这样就会导致线程池中的可用线程数量不足,从而阻塞了任务的执行。

可能会导致死锁

除此之外 Task.Wait 也可能会导致死锁,这里就不展开了。具体可以参考:https://www.cnblogs.com/eventhorizon/p/15912383.html#同步上下文synchronizationcontext导致的死锁问题与-taskconfigureawaitcontinueoncapturedcontextfalse

.NET 6 对 Task.Wait 的优化

细心的同学会注意到 SpinThenBlockingWait 的 BlockingWait 阶段,会调用 ThreadPool.NotifyThreadBlocked() 方法,这个方法会通知线程池当前线程被阻塞了,新的线程会被立即创建出来。

但这也不代表 Task.Wait 就可以放心使用了,ThreadPool 中的线程被大量阻塞,就算借助 ThreadPool.NotifyThreadBlocked() 能让新任务继续执行,但这会导致线程频繁的创建和销毁,导致性能下降。

总结

  1. Task.Wait 对调用线程的阻塞分为两个阶段:SpinWait 阶段 和 BlockingWait 阶段。如果 Task 完成较快,就可以在性能较好的 SpinWait 阶段完成等待。

  2. 滥用 Task.Wait 会导致线程池饥饿或死锁。

  3. .NET 6 对 Task.Wait 进行了优化,如果 Task.Wait 阻塞了 ThreadPool 中的线程,会立即创建新的线程,避免了线程池中的可用线程数量不足的问题。但是这也会导致线程频繁的创建和销毁,导致性能下降。

揭秘 Task.Wait的更多相关文章

  1. .NET Task揭秘(一)

    Task为.NET提供了基于任务的异步模式,它不是线程,它运行在线程池的线程上.本着开源的精神, 本文以解读基于.NET4.5 Task源码的方式来揭秘Task的实现原理.   Task的创建 Tas ...

  2. .NET Task 揭秘(3)async 与 AsyncMethodBuilder

    目录 前言 AsyncMethodBuilder 介绍 AsyncMethodBuilder 是状态机的重要组成部分 AsyncMethodBuilder 的结构 AsyncMethodBuilder ...

  3. 学习ASP.NET Web API框架揭秘之“HTTP方法重写”

    最近在看老A的<ASP.NET Web API 框架揭秘>,这本书对于本人现阶段来说还是比较合适的(对于调用已经较为熟悉,用其开发过项目,但未深入理解过很多内容为何可以这样“调用”).看到 ...

  4. Spark Tungsten揭秘 Day3 内存分配和管理内幕

    Spark Tungsten揭秘 Day3 内存分配和管理内幕 恭喜Spark2.0发布,今天会看一下2.0的源码. 今天会讲下Tungsten内存分配和管理的内幕.Tungsten想要工作,要有数据 ...

  5. Spark Streaming揭秘 Day30 集群模式下SparkStreaming日志分析

    Spark Streaming揭秘 Day30 集群模式下SparkStreaming日志分析 今天通过集群运行模式观察.研究和透彻的刨析SparkStreaming的日志和web监控台. Day28 ...

  6. Spark Streaming揭秘 Day17 资源动态分配

    Spark Streaming揭秘 Day17 资源动态分配 今天,让我们研究一下一个在Spark中非常重要的特性:资源动态分配. 为什么要动态分配?于Spark不断运行,对资源也有不小的消耗,在默认 ...

  7. Spark Streaming揭秘 Day4-事务一致性(Exactly one)

    Spark Streaming揭秘 Day4 事务一致性Exactly one 引子 对于业务处理系统,事务的一致性非常的关键,事务一致性(Exactly one),简单来说,就是输入数据一定会被处理 ...

  8. ASP.NET Web API框架揭秘:路由系统的几个核心类型

    ASP.NET Web API框架揭秘:路由系统的几个核心类型 虽然ASP.NET Web API框架采用与ASP.NET MVC框架类似的管道式设计,但是ASP.NET Web API管道的核心部分 ...

  9. 第四节:Task的启动的四种方式以及Task、TaskFactory的线程等待和线程延续的解决方案

    一. 背景 揭秘: 在前面的章节介绍过,Task出现之前,微软的多线程处理方式有:Thread→ThreadPool→委托的异步调用,虽然也可以基本业务需要的多线程场景,但它们在多个线程的等待处理方面 ...

  10. 第五节:Task构造函数之TaskCreationOptions枚举处理父子线程之间的关系。

    一. 整体说明 揭秘: 通过F12查看Task类的源码(详见下面的截图),发现Task类的构造函数有有一个参数为:TaskCreationOptions类型,本章节可以算作是一个扩展章节,主要就来研究 ...

随机推荐

  1. Laf v1.0 发布:函数计算只有两种,30s 放弃的和 30s 上线的

    一般情况下,开发一个系统都需要前端和后端,仅靠一个人几乎无法胜任,需要考虑的特性和功能非常多,比如: 需要一个数据库来存放数据: 需要一个文件存储来存放各种文件,比如图片文件: 后端需要提供接口供前端 ...

  2. kubernetes(k8s)常用deploy模板 并验证

    kubernetes常用deploy模板,并验证 编写deploy配置文件 root@hello:~# cat deploy.yaml  apiVersion: apps/v1 kind: Deplo ...

  3. [Java]算法练习:新农村建设

    1 题目描述 from 网友 CASE1 输入 A1 A8 输出 [A1,A2,A3,A4,A5,A6,A7,A8] CASE2 输入 A1 K1 输出 [A1,B1,C1,D1,E1,F1,G1,H ...

  4. 服务器实现端口转发的N种方式

    简介 在一些实际的场景里,我们需要通过利用一些端口转发工具,比如系统自带的命令行工具或第三方小软件,来绕过网络访问限制触及目标系统.下文为大家总结了linux系统和windows系统端口转发常用的一些 ...

  5. Linux(五)用户管理与文件权限

    1 常用的基本命令 Shell可以看作一个命令解释器,为我们提供一个交互式的文本控制台界面,可以通过终端控制台来输入命令,由shell进行解释并最终交给linux内核运行.可以看作用户和硬件的桥梁. ...

  6. Linux(四)软件包管理

    软件包管理 1 RPM 简介 RPM(RedHat Package Manager),是红帽系linux操作系统的软件包管理工具,类似于windows中的setup.exe能够进行软件包的更新.卸载. ...

  7. 从零开始TP6配置ThinkPHP-ApiDoc

    系统:windows11 集成环境:小皮(原phpstudy) composer:2.5 准备工作:安装小皮后,在软件管理中安装composer,2.3安装不上去,只能安装1.8.5,没关系安装后升级 ...

  8. 容易忽视的细节:Log4j 配置导致的零点接口严重超时

    作者:vivo 互联网服务器团队- Jiang Ye 本文详细的记录了一次0点接口严重超时的问题排查经历.本文以作者自身视角极具代入感的描绘了从问题定位到具体的问题排查过程,并通过根因分析并最终解决问 ...

  9. 2023-01-15:销售员。编写一个SQL查询,报告没有任何与名为 “RED” 的公司相关的订单的所有销售人员的姓名。以 任意顺序 返回结果表。 DROP TABLE IF EXISTS `com

    2023-01-15:销售员.编写一个SQL查询,报告没有任何与名为 "RED" 的公司相关的订单的所有销售人员的姓名.以 任意顺序 返回结果表. DROP TABLE IF EX ...

  10. 2022-10-26:以下go语言代码输出什么?A:1 3 2;B:1 2 3;C:3 1 2;D:3 2 1。 package main import “fmt“ type temp struc

    2022-10-26:以下go语言代码输出什么?A:1 3 2:B:1 2 3:C:3 1 2:D:3 2 1. package main import "fmt" type te ...