深入分析C#中的异步和多线程
许多开发人员对异步代码和多线程以及它们的工作原理和使用方法都有错误的认识。在这里,你将了解这两个概念之间的区别,并使用c#实现它们。
我:“服务员,这是我第一次来这家餐厅。通常需要4个小时才能拿到食物吗?”
服务员:“哦,是的,先生。这家餐厅的厨房里只有一个厨师。”
我:“……只有一个厨师吗?”
服务员:“是的,先生,我们有好几个厨师,但每次只有一个在厨房工作。”
我:“所以其他10个穿着厨师服站在厨房里的人……什么都不做吗?厨房太小了吗?”
服务员:“哦,我们的厨房很大,先生。”
我:“那为什么他们不同时工作呢?”
服务员:“先生,这倒是个好主意,但我们还没想好怎么做。”
我:“好了,奇怪。但是…嘿…现在的主厨在哪里?我现在没看见有人在厨房里。”
服务员:“是的,先生。有一份订单的厨房用品已经用完了,所以厨师已经停止烹饪,站在外面等着送货了。”
我:“看起来他可以一边等一边做饭,也许送货员可以直接告诉他们什么时候到了?”
服务员:“又是一个绝妙的主意,先生。我们在后面有送货门铃,但厨师喜欢等。我去给你再拿点水来。”
多糟糕的餐厅,对吧?不幸的是,很多程序都是这样工作的。
有两种不同的方法可以让这家餐厅做得更好。
首先,很明显,每个单独的晚餐订单可以由不同的厨师来处理。每一种都是一个必须按特定顺序发生的事情列表(准备原料,然后混合它们,然后烹饪,等等)。因此,如果每个厨师都致力于处理这一清单上的东西,几份晚餐订单可以同时做出。
这是一个真实世界中的多线程示例。计算机有能力让多个不同的线程同时运行,每个线程负责按特定顺序执行一系列活动。
然后还有异步行为。需要明确的是,异步不是多线程的。还记得那个一直在等外卖的厨师吗?真是浪费时间!在等待的过程中,他没有做任何有意义的事情,比如做饭。而且,等待也不会让送货更快。一旦他打电话订购供应品,发货就会随时发生,所以为什么要等呢?相反,送货员只需按门铃,说一句:“嘿,这是你的供应品!”
有很多I/O活动是由代码之外的东西处理的。例如,向远程服务器发送一个网络请求。这就像给餐厅点餐一样。你的代码所做的唯一事情就是进行调用并接收结果。如果选择等待结果,在这两者之间完全不做任何事情,那么这就是“同步”行为。
然而,如果你更喜欢在结果返回时被打断/通知(就像送货员到达时按门铃),同时可以处理其他事情,那么这就是“异步”行为。
只要工作是由不受当前代码直接控制的对象完成的,就可以使用异步代码。例如,当你向硬盘驱动器写入一堆数据时,你的代码并没有执行实际的写入操作。它只是请求硬件执行该任务。因此,你可以使用异步编码开始编写,然后在编写完成时得到通知,同时继续处理其他事情。
异步的优点在于不需要额外的线程,因此非常高效。
“等等!”你说。“如果没有额外的线程,那么谁或什么在等待结果?代码如何知道返回的结果?”
还记得那个门铃吗?你的电脑里有一个系统叫做“中断”系统,它的工作原理有点像那个门铃。当你的代码开始一个异步活动时,它基本上会安装一个虚拟的门铃。当其他任务(写入硬盘驱动器,等待网络响应等)完成时,中断系统“中断”当前运行的代码并按下门铃,让你的应用程序知道有一个任务在等待!不需要线程坐在那里等待!
让我们快速回顾一下我们的两种工具:
多线程:使用一个额外的线程来执行一系列活动/任务。
异步:使用同一个线程和中断系统,让线程外的其他组件完成一些活动,并在活动结束时得到通知。
UI线程
还有一件重要的事情需要知道的是为什么使用这些工具是好的。在.net中,有一个主线程叫做UI线程,它负责更新屏幕的所有可视部分。默认情况下,这是一切运行的地方。当你点击一个按钮,你想看到按钮被短暂地按下,然后返回,这是UI线程的责任。你的应用中只有一个UI线程,这意味着如果你的UI线程忙着做繁重的计算或等待网络请求之类的事情,那么它不能更新你在屏幕上看到的东西,直到它完成。结果是,你的应用程序看起来像“冻结”——你可以点击一个按钮,但似乎什么都不会发生,因为UI线程正在忙着做其他事情。
理想情况下,你希望UI线程尽可能地空闲,这样你的应用程序似乎总是在响应用户的操作。这就是异步和多线程的由来。通过使用这些工具,可以确保在其他地方完成繁重的工作,UI线程保持良好和响应性。
现在让我们看看如何在c#中使用这些工具。
C#的异步操作
执行异步操作的代码非常简单。你应该知道两个主要的关键字:“async”和“await”,所以人们通常将其称为async/await。假设你现在有这样的代码:
public void Loopy() { var hugeFiles = new string[] { "Gr8Gonzos_Home_Movie_In_8k_Res.mkv", // 1 GB "War_And_Peace_In_150_Languages.rtf", // 1.2 GB "Cats_On_Catnip.mpg" // 0.9 GB }; foreach (var hugeFile in hugeFiles) { ReadAHugeFile(hugeFile); } MessageBox.Show("All done!"); } public byte[] ReadAHugeFile(string bigFile) { var fileSize = new FileInfo(bigFile).Length; // Get the file size var allData = new byte[fileSize]; // Allocate a byte array as large as our file using (var fs = new System.IO.FileStream(bigFile, FileMode.Open)) { fs.Read(allData, 0, (int)fileSize); // Read the entire file... } return allData; // ...and return those bytes! }
在当前的形式中,这些都是同步运行的。如果你点击一个按钮从UI线程运行Loopy(),那么应用程序将似乎冻结,直到所有三大文件阅读,因为每个“ReadAHugeFile”是要花很长时间在UI线程上运行,并将同步阅读。这可不好!让我们看看能否将ReadAHugeFile变为异步的这样UI线程就能继续处理其他东西。
无论何时,只要有支持异步的命令,微软通常会给我们同步和异步版本的这些命令。在上面的代码中,System.IO.FileStream对象同时具有"Read"和"ReadAsync"方法。所以第一步就是将“fs.Read”修改成“fs.ReadAsync”。
public byte[] ReadAHugeFile(string bigFile) { var fileSize = new FileInfo(bigFile).Length; // Get the file size var allData = new byte[fileSize]; // Allocate a byte array as large as our file using (var fs = new System.IO.FileStream(bigFile, FileMode.Open)) { fs.ReadAsync(allData, 0, (int)fileSize); // Read the entire file asynchronously... } return allData; // ...and return those bytes! }
如果现在运行它,它会立即返回,并且“allData”字节数组中不会有任何数据。为什么?
这是因为ReadAsync是开始读取并返回一个任务对象,这有点像一个书签。这是.net的一个“Promise”,一旦异步活动完成(例如从硬盘读取数据),它将返回结果,任务对象可以用来访问结果。但如果我们对这个任务不做任何事情,那么系统就会立即继续到下一行代码,也就是我们的"return allData"行,它会返回一个尚未填满数据的数组。
因此,告诉代码等待结果是很有用的(但这样一来,原始线程可以在此期间继续做其他事情)。为了做到这一点,我们使用了一个"awaiter",它就像在async调用之前添加单词"await"一样简单:
public byte[] ReadAHugeFile(string bigFile) { var fileSize = new FileInfo(bigFile).Length; // Get the file size var allData = new byte[fileSize]; // Allocate a byte array as large as our file using (var fs = new System.IO.FileStream(bigFile, FileMode.Open)) { await fs.ReadAsync(allData, 0, (int)fileSize); // Read the entire file asynchronously... } return allData; // ...and return those bytes! }
如果现在运行它,它会立即返回,并且“allData”字节数组中不会有任何数据。为什么?
这是因为ReadAsync是开始读取并返回一个任务对象,这有点像一个书签。这是.net的一个“Promise”,一旦异步活动完成(例如从硬盘读取数据),它将返回结果,任务对象可以用来访问结果。但如果我们对这个任务不做任何事情,那么系统就会立即继续到下一行代码,也就是我们的"return allData"行,它会返回一个尚未填满数据的数组。
因此,告诉代码等待结果是很有用的(但这样一来,原始线程可以在此期间继续做其他事情)。为了做到这一点,我们使用了一个"awaiter",它就像在async调用之前添加单词"await"一样简单:
public byte[] ReadAHugeFile(string bigFile) { var fileSize = new FileInfo(bigFile).Length; // Get the file size var allData = new byte[fileSize]; // Allocate a byte array as large as our file using (var fs = new System.IO.FileStream(bigFile, FileMode.Open)) { await fs.ReadAsync(allData, 0, (int)fileSize); // Read the entire file asynchronously... } return allData; // ...and return those bytes! }
哦。如果你试过,你会发现有一个错误。这是因为.net需要知道这个方法是异步的,它最终会返回一个字节数组。因此,我们做的第一件事是在返回类型之前添加单词“async”,然后用Task<…>,是这样的:
public async Task<byte[]> ReadAHugeFile(string bigFile) { var fileSize = new FileInfo(bigFile).Length; // Get the file size var allData = new byte[fileSize]; // Allocate a byte array as large as our file using (var fs = new System.IO.FileStream(bigFile, FileMode.Open)) { await fs.ReadAsync(allData, 0, (int)fileSize); // Read the entire file asynchronously... } return allData; // ...and return those bytes! }
好吧!现在我们烹饪!如果我们现在运行我们的代码,它将继续在UI线程上运行,直到我们到达ReadAsync方法的await。此时,. net知道这是一个将由硬盘执行的活动,因此“await”将一个小书签放在当前位置,然后UI线程返回到它的正常处理(所有的视觉更新等)。
随后,一旦硬盘驱动器读取了所有数据,ReadAsync方法将其全部复制到allData字节数组中,任务现在就完成了,因此系统按门铃,让原始线程知道结果已经准备好了。原始线程说:“太棒了!让我回到离开的地方!”一有机会,它就会回到“await fs.ReadSync”,然后继续下一步,返回allData数组,这个数组现在已经填充了我们的数据。
如果你在一个接一个地看一个例子,并且使用的是最近的Visual Studio版本,你会注意到这一行:
ReadAHugeFile(hugeFile);
…现在,它用绿色下划线表示,如果将鼠标悬停在它上面,它会说,“因为这个调用没有被等待,所以在调用完成之前,当前方法的执行将继续。”考虑对调用的结果应用'await'操作符。"
这是Visual Studio让你知道它承认ReadAHugeFile()是一个异步的方法,而不是返回一个结果,这也是返回任务,所以如果你想等待结果,然后你就可以添加一个“await”:
await ReadAHugeFile(hugeFile);
…但如果我们这样做了,那么你还必须更新方法签名:
public async void Loopy()
注意,如果我们在一个不返回任何东西的方法上(void返回类型),那么我们不需要将返回类型包装在Task<…>中。
但是,我们不要这样做。相反,让我们来了解一下我们可以用异步做些什么。
如果你不想等待ReadAHugeFile(hugeFile)的结果,因为你可能不关心最终的结果,但你不喜欢绿色下划线/警告,你可以使用一个特殊的技巧来告诉.net。只需将结果赋给_字符,就像这样:
_ = ReadAHugeFile(hugeFile);
这就是.net的语法,表示“我不在乎结果,但我不希望用它的警告来打扰我。”
好吧,我们试试别的。如果我们在这一行上使用了await,那么它将等待第一个文件被异步读取,然后等待第二个文件被异步读取,最后等待第三个文件被异步读取。但是…如果我们想要同时异步地读取所有3个文件,然后在所有3个文件都完成之后,我们允许代码继续到下一行,该怎么办?
有一个叫做Task.WhenAll()的方法,它本身是一个你可以await的异步方法。传入其他任务对象的列表,然后等待它,一旦所有任务都完成,它就会完成。所以最简单的方法就是创建一个List<Task>对象:
List<Task> readingTasks = new List<Task>();
…然后,当我们将每个ReadAHugeFile()调用中的Task添加到列表中时:
foreach (var hugeFile in hugeFiles) { readingTasks.Add(ReadAHugeFile(hugeFile)); }
…最后我们 await Task.WhenAll():
await Task.WhenAll(readingTasks);
最终的方法是这样的:
public async void Loopy() { var hugeFiles = new string[] { "Gr8Gonzos_Home_Movie_In_8k_Res.mkv", // 1 GB "War_And_Peace_In_150_Languages.rtf", // 1.2 GB "Cats_On_Catnip.mpg" // 0.9 GB }; List<Task> readingTasks = new List<Task>(); foreach (var hugeFile in hugeFiles) { readingTasks.Add(ReadAHugeFile(hugeFile)); } await Task.WhenAll(readingTasks); MessageBox.Show(sb.ToString()); }
当涉及到并行活动时,一些I/O机制比其他机制工作得更好(例如,网络请求通常比硬盘读取工作得更好,但这取决于硬件),但原理是相同的。
现在,“await”操作符还要做的最后一件事是提取最终结果。所以在上面的例子中,ReadAHugeFile返回一个任务<byte[]>。await的神奇功能会在完成后自动抛出Task<>包装器,并返回byte[]数组,所以如果你想访问Loopy()中的字节,你可以这样做:
byte[] data = await ReadAHugeFile(hugeFile);
再次强调,await是一个神奇的小命令,它使异步编程变得非常简单,并为你处理各种各样的小事情。
现在让我们转向多线程。
C#中的多线程
微软有时会给你10种不同的方法来做同样的事情,这就是它如何使用多线程。你有BackgroundWorker类、Thread和Task(它们有几个变体)。最终,它们都做着相同的事情,只是有不同的功能。现在,大多数人都使用Task,因为它们的设置和使用都很简单,而且如果你想这样做的话(我们稍后会讲到),它们也可以很好地与异步代码交互。如果你好奇的话,关于这些具体区别有很多文章,但是我们在这里使用任务。
要让任何方法在单独的线程中运行,只需使用Task.Run()方法来执行它。例如,假设你有这样一个方法:
public void DoRandomCalculations(int howMany) { var rng = new Random(); for (int i = 0; i < howMany; i++) { int a = rng.Next(1, 1000); int b = rng.Next(1, 1000); int sum = 0; sum = a + b; } }
我们可以像这样在当前线程中调用它:
DoRandomCalculations(1000000);
或者我们可以让另一个线程来做这个工作:
Task.Run(() => DoRandomCalculations(1000000));
当然,有一些不同的版本,但这是总体思路。
Task. run()的一个优点是它返回一个我们可以等待的任务对象。因此,如果想在一个单独的线程中运行一堆代码,然后在进入下一步之前等待它完成,你可以使用await,就像你在前面一节看到的那样:
var finalData = await Task.Run(() => {});
请记住,本文讨论的是如何开始,以及这些概念是如何工作的,但它并不是全面的。但是也许有了这些知识,你将能够理解其他人关于多线程和异步编码更高级种类的更复杂的文章。
以上就是深入分析C#中的异步和多线程的详细内容,更多关于C#中的异步和多线程的资料请关注我们其它相关文章!