查阅 MSDN 异步编程以了解更多
异步编程
在 C# 中,异步编程是一种编程范式,它允许程序异步执行任务,而不会阻塞主线程或其他任务的执行。在异步程序中,程序可以继续执行其他任务,而无需等待一个任务完成
优势
异步编程可以为需要执行 I/O 操作或其他耗时任务的应用程序提供显著的优势,例如访问数据库、下载文件或执行网络通信。通过使用异步编程,应用程序可以提高性能、响应性、可扩展性和资源效率,同时简化代码并提高容错性
误解
异步编程总是更快:虽然异步编程可以提高性能,但它并不能保证在所有情况下都能提供更快的执行速度,如果一个应用程序没有大量的 I/O 操作或耗时任务,使用异步编程可能不会提供任何性能优势,甚至可能引入不必要的复杂性
异步编程总是优于同步编程:虽然异步编程可以提高应用程序的可扩展性和响应性,但它可能并不总是最好的解决方案。异步编程可能会引入额外的复杂性,而且对于所有的用例可能都不是必需需要的
异步编程可以完全消除对线程的需求:虽然异步编程可以减少显式线程的需求,但它并不能完全消除它。异步编程仍然依赖于底层线程和线程池在后台执行任务
还有并不能完全消除锁和同步的需求、或认为异步容易调试等等
关键字
async
和 await
目标
支持读起来像一连串语句的代码,但会根据外部资源分配和任务完成时间以更复杂的顺序执行
内容
async
用以标记函数的关键字,例如:
public async Task TestAsync(){ }
但仅这还不是真正的异步函数
await
必须在 async
标记下的函数内使用的关键字,例如:
public async Task TestAsync()
{
await Task.delay(1000);
Console.WriteLine("TestDone");
}
async
和 await
一起使用才构成了异步函数
如此例所示,await
关键字将暂停 TestAsync
方法的执行,并返回不完整的 Task
,在此期间,线程将返回到线程池,以便自己可用于另一个请求,await
完成后将恢复继续向下执行
但是,如何知道 await
已经完成呢?这里就需要用到 Task
,可先了解一下此站另一个 blog - 线程
总结
该操作不会在线程池线程运行
如果仅使用
async
关键字,那么函数内容仍会以同步方式进行一般异步函数命名的最后结尾都是 Async
帮助我们从异步操作中提取结果
验证操作成功
提供在异步方法中执行其余代码的延续
Task
Task
抽象类是在 C# 5.0 (.NET Framwork 4.0) 引出的概念
大多数情况下,都是返回 Task
或者 Task<T>
,极少数情况会返回 void
因为 Task
返回类型可提供更加丰富的监视内容和可等待内容结果,而 void
没有这些,仅在异步事件处理程序使用 void
返回类型
优势
抽象类允许我们轻松的使用线程池和本地线程,且绝大多数
Task
都在属于线程池的后台线程中执行异步操作的状态管理:
Task
对象跟踪异步操作的状态,包括是否已经完成、是否已经取消、是否已经出错等等,开发人员可以通过访问Task
的属性和方法来获取和操作异步操作的状态信息异步操作的结果返回:
Task
对象代表异步操作的最终结果,当异步操作完成时,它可以返回异步操作的结果或者抛出异常。异步方法的返回值通常是一个Task
对象或者Task<T>
对象,开发人员可以通过访问Task
对象的Result
属性或者使用await
关键字来获取异步操作的结果异步操作的协调和组合:
Task
对象可以通过多种方式来协调和组合异步操作,例如使用Task.WhenAll
或Task.WhenAny
方法来等待多个异步操作的完成或者组合多个异步操作的结果并行和异步编程的支持:
Task
对象可以帮助开发人员实现并行和异步编程模式,使用异步方法和异步操作可以提高应用程序的性能和响应性,避免阻塞 UI 线程或者阻塞 CPU
Task
对象不是线程,也不会创建线程,它只是代表了异步操作的状态和行为。异步操作的实际执行可能在线程池线程、IO 线程、UI 线程或者其它线程中执行,具体取决于异步操作的实现方式和上下文
比较
例一
public static void Main()
{
double i = 0;
var mres = new ManualResetEventSlim(false);
ThreadPool.QueueUserWorkItem<ManualResetEventSlim>((_msg) =>
{
i = Math.Exp(1);
mres.Set();
}, mres, false);
mres.Wait();
Console.WriteLine(i);
// output:2.71828182845
}
上面这个例子首先创建了 ManualResetEventSlim
API 与其中的 Set
和 Wait
方法来控制线程池运行,再创建线程池执行内容
现在,我们可以用如下 Task
来简化操作
public static void Main()
{
var task = Task.Run(() => Math.Exp(1));
Console.WriteLine(task.Result);
}
// output:2.71828182845
与用前一个 ThredPool
类方法相比,Task
省去了创建变量和控制线程发生的过程,且用前个方法不能返回类型,而此方法在这个例子中,返回了 Task<double>
类型(具有 Result 属性)
例二
in BaseClass.cs
public class CarBuilding
{
private static Body BuildBody(int weight, int length, int width) => new Body(weight, length, width);
private static Engine BuildEngine(int horsePower) => new Engine(horsePower);
private static Suspension BuildSuspension(int supportedKg) => new Suspension(supportedKg);
private static Painting Paint(string color, int bodyArea) => new Painting(color, bodyArea);
private static void Test(Body body, IEnumerable<Suspension> suspensions, Engine engine)
{
if (suspensions.Sum(s => s.SupportedKg) <= body.Weight || engine.Horsepower * 4 <= body.Weight)
throw new ArgumentException("The car weighs too much");
}
public static void Main()
{
}
}
public class Body
{
public Body(int weight, int length, int width)
{
Weight = weight;
Length = length;
Width = weight;
Console.WriteLine($"{Weight} and {Length} and {Width}");
}
public int Weight { get; set; }
public int Length { get; set; }
public int Width { get; set; }
}
public class Engine
{
public Engine(int horsePower)
{
Horsepower = horsePower;
}
public int Horsepower { get; set; }
}
public class Suspension
{
public Suspension(int supportedKg)
{
SupportedKg = supportedKg;
Console.WriteLine(SupportedKg);
}
public int SupportedKg { get; set; }
}
public class Painting
{
public Painting(string color, int bodyArea)
{
Color = color;
BodyArea = bodyArea;
Console.WriteLine($"{Color} and {BodyArea}");
}
public string Color { get; set; }
public int BodyArea { get; set; }
}
在 Main
函数中添加以下内容:
public static void Main()
{
Body body = null!;
var bodyThread = new Thread(() =>
{
body = BuildBody(100, 5, 2);
});
bodyThread.Start(); // Thread 方法
var bodyTask = Task.Run(() => BuildBody(50,1,1)); // Task 方法
// output:
// 100 and 5 and 100
// 50 and 1 and 50
}
这里两个方法有着区别,Task
在这更具优势,上文也有提到(简洁、具有返回值)
继续往 Main
函数添加内容
public static void Main()
{
Body body = null!;
var bodyThread = new Thread(() =>
{
body = BuildBody(100, 5, 2);
});
bodyThread.Start();
bodyThread.Join();
Painting painting;
var paintingThread = new Thread(() =>
{
painting = new Painting("red", body.Width * body.Length);
});}
paintingThread.Start();
paintingThread.Join();
// output:
// 100 and 5 and 100
// red and 500
上面内容用的 Thread
写法写出两个子线程运行的方法,下面写一个 Task
写法
public static void Main()
{
var bodyTask = Task.Run(() => BuildBody(50, 5, 2));
var paintingTask = bodyTask.ContinueWith(
task => Paint("red", task.Result.Width * task.Result.Length)
);
}
// output:
// 50 and 5 and 50
// red and 250
比较一下,用 Task
方法显而易见的简洁了,其中采用了链式编程,使用了 ContinueWith
方法,意思是执行 bodyTask
线程池,并开始函数内部委托的执行,使任务之间的依赖性清晰且定义明确
而且 ContinueWith
方法也提供第二个参数,如写法:task => Paint("red", task.Result.Width * task.Result.Length),TaskContinuationOptions.OnlyOnRanToCompletion
,第二个参数就表明前一个线程池必须完成后才执行此委托
在 Main 函数中添加以下内容:
Thread
多个线程创建并执行写法
using System.Collections.Concurrent;
------------------------------------------------
public static void Main()
{
var suspensions = new ConcurrentBag<Suspension>();
var suspensionThreads = Enumerable
.Range(0, 10)
.Select(i =>
{
var t = new Thread(() =>
{
suspensions.Add(BuildSuspension(40));
});
t.Start();
return t;
});
foreach (var suspThread in suspensionThreads)
suspThread.Join();
// output:40 40 40 ... 40 40 (10 个 40)
}
ConcurrentBag
:表示线程安全、无序的对象集合
这个 API 就是为了解决多个线程池的性能问题
而用 Task
创建则无需此步骤,如下
public static void Main()
{
var suspensionTasks = Enumerable
.Range(0, 10)
.Select(i => Task.Run(() => BuildSuspension(40)));
var suspensionsTask = Task.WhenAll(suspensionTasks)
.ContinueWith(task => task.Result.ToList());
// output:40 40 40 ... 40 40 (10 个 40)
}
同样省略了很多步骤,在里面使用了 WhenAll
方法,表明执行完所有内容并返回一个 Task<T>
类型
也可进一步优化为:Task.Factory.ContinueWhenAll(suspensionTasks, tasks => tasks.ToList());
在 Main
函数添加以下内容:
用 Thread
表示线程抛出异常
public static void Main()
{
Exception? thrownException = null;
var body = new Body(1,1,1);
IEnumerable<Suspension> suspensions = new Suspension[1];
var engine = new Engine(1);
var testing = new Thread(() =>
{
try
{
Test(body, suspensions, engine);
}
catch (Exception exc)
{
thrownException = exc;
}
});
testing.Start();
testing.Join();
if (thrownException is not null)
throw thrownException;
}
只有当线程抛出异常,才会使 thrownException
有值,没有异常则是 null
Tasks
方法同样有内置工具检测异常,如下
public static void Main()
{
var body = new Body(1,1,1);
IEnumerable<Suspension> suspensions = new Suspension[1];
var engine = new Engine(1);
try
{
var testingTask = Task.Run(
() => Test(body, suspensions,engine)
);
}
catch (AggregateException exc)
{
throw exc.InnerExceptions[0];
}
}
在这种情况下,我们只重新抛出第一个内部异常,但我们也可以使用AgggregateException.Handle()
方法处理它
TAP
Task-based Asynchronous Pattern 基于 Task
的异步编程模型
了解任务代表异步方法的执行而不是结果非常重要,Task
有几个属性,指示操作是否成功完成(状态、已完成、取消、错误)
死锁
一般出现在 UI 或 ASP.NET 程序中
内容
static object lock1 = new object();
static object lock2 = new object();
public static void Main()
{
Thread t1 = new Thread(DoWork1);
Thread t2 = new Thread(DoWork2);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine("Done.");
}
static void DoWork1()
{
lock (lock1)
{
Console.WriteLine("Thread 1 acquired lock1.");
Thread.Sleep(1000);
lock (lock2)
{
Console.WriteLine("Thread 1 acquired lock2.");
}
}
}
static void DoWork2()
{
lock (lock2)
{
Console.WriteLine("Thread 2 acquired lock2.");
Thread.Sleep(1000);
lock (lock1)
{
Console.WriteLine("Thread 2 acquired lock1.");
}
}
}
在上面的代码中,定义了两个锁对象 lock1
和 lock2
,然后创建了两个线程 t1
和 t2
,分别在 DoWork1
和 DoWork2
方法中获取锁。然后在这两个方法中互相等待对方释放锁,导致程序陷入死锁状态。运行程序后,程序将无法继续执行下去,需要手动终止程序。为了避免死锁,我们需要使用合适的同步和互斥机制,避免线程之间的互相等待
lock
代码块是 C# 中用于实现线程同步的机制。lock
关键字可以将一段代码块标记为“临界区”,确保同一时刻只有一个线程可以进入这个代码块,从而避免多个线程同时访问共享资源,导致数据竞争和不确定性的问题,在上述示例中进入lock
代码块之前,线程会尝试获取lock1
的锁,如果这个锁当前没有被其他线程占用,那么线程将获取到锁,并进入临界区代码。在临界区代码执行完成之后,线程会释放锁,以便其他线程可以获取锁进入临界区
也尽量减少使用 Result
或 GetResult
或 Wait
等方法(在某些地方适用,如控制台程序)
Result
属性为阻止属性。 如果你在其任务完成之前尝试访问它,当前处于活动状态的线程将被阻止,直到任务完成且值为可用。 在大多数情况下,应通过使用await
访问此值,而不是直接访问属性
该函数内容需要阻止主线程而等待新开的线程先完成,这可能会导致死锁,这正是我们试图避免使用异步和等待关键字的事情
解决措施
代码同步化,不争抢线程资源
不使用
await
关键字使用
ConfigureAwait()
上下文
上下文(Context)是一种用于在不同线程之间共享数据的机制。上下文对象可以在一个线程中创建,并在另一个线程中使用,它可以是线程上下文、进程上下文、同步上下文、应用程序域上下文、执行上下文等等
指程序中当前执行代码的环境或状态。可以将上下文视为一个程序运行时的快照,其中包含了程序的当前状态、已定义的变量、对象以及正在执行的代码的位置等信息
当前上下文
通常也指异步上下文,它与线程池线程一起管理异步操作的执行。异步上下文是一种保存与异步操作相关的上下文信息的机制,包括线程 ID、SynchronizationContext 对象等。异步操作在等待其他操作完成时可能会保存当前上下文,以便可以在异步操作完成时恢复它。当前上下文与异步操作的执行是密切相关的,异步操作必须在正确的上下文中执行才能正常工作
理解
如果使用的是 UI 线程,那么就是 UI 上下文
如果使用的是 ASP.NET 请求响应,那么这就是 ASP.NET 请求上下文
其余情况都是线程池上下文
当我们等待 Task
时,当等待决定暂停方法执行时,会捕获请求当前上下文。一旦方法准备好恢复执行,应用程序将从线程池中获取线程,将其分配给上下文,并恢复执行
在 ASP.NET Core 应用程序中没有 SynchronizationContext
。ASP.NET Core 避免捕获上下文并排队,它所做的只是从线程池中获取线程并将其分配给请求,因此,应用程序要做的后台工作要少得多
示例
// WinForms 示例程序(同样原理用于 WPF 程序).
private async void DownloadFileButton_Click(object sender, EventArgs e)
{
// 因为我们使用了异步关键字 awiat,这个 UI 线程不会被文件下载阻止
await DownloadFileAsync(fileNameTextBox.Text);
// 由于我们恢复了 UI 上下文,可以直接调用 UI 元素
resultTextBox.Text = "File downloaded!";
}
private static void button()
{
Console.WriteLine($"main1 thread {Thread.CurrentThread.ManagedThreadId}");
TestConfigureAwait().Wait();
Console.WriteLine($"main2 thread: {Thread.CurrentThread.ManagedThreadId}");
}
private static async Task TestConfigureAwait()
{
await Task.Run(() =>
{
Thread.Sleep(6000);
Console.WriteLine($"task thread: {Thread.CurrentThread.ManagedThreadId}");
}).ConfigureAwait(false);
Console.WriteLine($"testConfigureAwait thread: {Thread.CurrentThread.ManagedThreadId}");
}
在这个示例中,是一个 UI 上下文内容,和使用了 ConfigureAwait(false)
来解决死锁问题
异常捕获
public static void Test()
{
try
{
var result = SomeAsyncCode().Result;
}
catch (ArgumentException aex)
{
Console.WriteLine($"Caught ArgumentException: {aex.Message}");
}
}
private static async Task<string> SomeAsyncCode()
{
await Task.Delay(10);
throw new ArgumentException("Oh noes!");
}
在这个示例中我们尝试使用 try-catch
来捕捉异常,我们执行 SomeAsyncCode()
,获取到它的 Result
属性。可以发现我们并没有进入到 catch
块。
这是因为,如果没有等待关键字,任务会吞噬异常。此外,不再有继续,操作也没有经过验证。如果继续执行此操作,将获得一个异常,但这将是映射异常,而不是我们从异步方法中抛出的异常
--- EXCEPTION #1/2 [ArgumentException]
在发生 ArgumentException 时,使用其他关键字尝试解决
在此示例,我们不能用 await
关键字来等待结果,我们使用 SomeAsyncCode().GetAwaiter().GetResult()
来捕捉,运行后结果正确,成功进入 catch
块
也可以使用 Global Error Handling 技术来解决或跟踪异常
控制台程序
在 C# 7.1 之前其实是不能对控制台程序对主入口 Main
函数标记为 async
原因
如果 await
看到可等待项尚未完成,那么它会异步发挥作用。它告诉 awaitable 在方法完成后运行剩余部分,然后从异步方法返回。当将方法的剩余部分传递给 await
时,awaiter 还将捕获当前上下文
异步方法将在完成之前返回给其调用者。这在 UI 应用程序(方法仅返回 UI 事件循环)和 ASP.NET 应用程序(该方法从线程返回,但使请求保持保留状态)中完美工作
对于控制台程序来说效果不太好:Main
函数返回到操作系统,因此程序退出
解决
控制台程序没有很大的必要使用异步,但为简单的一些操作演示,也是能解决的
让主线程强制等待异步完成,如以下示例:
static void Main(string[] args)
{
MainAsync(args).GetAwaiter().GetResult();
// or MainAsync(args).Wait()
}
static async Task MainAsync(string[] args)
{
Bootstrapper bs = new Bootstrapper();
var list = await bs.GetList();
}
主要就是 GetAwaiter
方法和 GetResult
方法,前者是获取到有 Task
标记的异步程序,后者是等待此有 Task
标记的异步程序结束
也可以直接使用 MainAsync(args).Wait()
,Wait
方法与上面类似,但上面的两个方法会避免一些 AggregateException
包装错误
总结
以上内容为搜罗实验整理,如有错误欢迎留言指正交流,如有内容变动会及时更新
更多参考:
Old | New | Description |
---|---|---|
task.Wait | await task | Wait/await for a task to complete |
task.Result | await task | Get the result of a completed task |
Task.WaitAny | await Task.WhenAny | Wait/await for one of a collection of tasks to complete |
Task.WaitAll | await Task.WhenAll | Wait/await for every one of a collection of tasks to complete |
Thread.Sleep | await Task.Delay | Wait/await for a period of time |
Task constructor | Task.Run or TaskFactory.StartNew | Create a code-based task |