一、文件构成
1、界面:一个textbox,四个button。
2、程序:前面(15)的book类与data类
private void AppendLine(string s)
{
txtInfo.AppendText(string.IsNullOrEmpty(txtInfo.Text) ? s : $"{Environment.NewLine}{s}");
txtInfo.ScrollToCaret();
txtInfo.Refresh();
}
private void InvworkAppendLine(string text)
{ BeginInvoke(new Action(() => AppendLine(text))); }
private void BtnWait_Click(object sender, EventArgs e)//单一任务同步等待完成
{
Stopwatch sw = Stopwatch.StartNew();
txtInfo.Clear();
AppendLine("Wait开始...");
for (int i = 0; i < Data.Books.Count; i++)
{
Task<string> t = Task.Run(() =>
{
return Data.Books[i].Search();
});
//Task.wait()方法,调用线程阻塞在wait处,出现两种情况结束等待:
//1.线程执行完毕;
//2.任务本身已经取消或引发异常
t.Wait();//当前调用线程上同步阻塞等待
AppendLine($"{i}.{t.Result}");
}
sw.Stop();
AppendLine($"Wait完成:{Convert.ToSingle(sw.ElapsedMilliseconds) / 1000}秒");
}
private void BtnWaitAll_Click(object sender, EventArgs e)//同步等待全部完成
{
List<Task<string>> ts = new List<Task<string>>();
Stopwatch sw = Stopwatch.StartNew();
txtInfo.Clear();
AppendLine("WaitAll开始...");
for (int i = 0; i < Data.Books.Count; i++)
{
var book = Data.Books[i];
ts.Add(Task.Run(() => { return book.Search(); }));
}
AppendLine($"等待前{DateTime.Now.TimeOfDay}");
Task.WaitAll(ts.ToArray());
AppendLine($"等待前{DateTime.Now.TimeOfDay}");
foreach (var t in ts)
{ AppendLine($"{t.Result}.{t.Id}.{t.Status}"); }
sw.Stop();
AppendLine($"WaitAll完成:{Convert.ToSingle(sw.ElapsedMilliseconds) / 1000}秒");
}
private void BtnWaitAny_Click(object sender, EventArgs e)//同步等待任一完成
{
List<Task<string>> ts = new List<Task<string>>();
Stopwatch sw = Stopwatch.StartNew();
txtInfo.Clear();
AppendLine("WaitAny开始...");
for (int i = 0; i < Data.Books.Count; i++)
{
var book = Data.Books[i];
ts.Add(Task.Run(() =>
{
return book.Search();
}));
}
Task.WaitAny(ts.ToArray());//ts任务集合中任一任务完成,调用线程就继续向后执行。
foreach (var t in ts)
{
AppendLine($"{t.Id}.{t.Status}");
}
sw.Stop();
AppendLine($"WaitAll完成:{Convert.ToSingle(sw.ElapsedMilliseconds) / 1000}秒");
}
private void BtnDeadLock_Click(object sender, EventArgs e)//死锁
{
Stopwatch sw = Stopwatch.StartNew();
txtInfo.Clear();
AppendLine("Wait开始...");
for (int i = 0; i < Data.Books.Count; i++)
{
var book = Data.Books[i];
var idx = i + 1;
var task = book.SearchAsync();
task.Wait();
AppendLine($"{idx}.{task.Result}");
}
sw.Stop();
AppendLine($"Wait完成:{Convert.ToSingle(sw.ElapsedMilliseconds) / 1000}秒");
}
二、Wait
private void BtnWait_Click(object sender, EventArgs e)//单一任务同步等待完成
{
Stopwatch sw = Stopwatch.StartNew();
txtInfo.Clear();
AppendLine("Wait开始...");
for (int i = 0; i < Data.Books.Count; i++)
{
Task<string> t = Task.Run(() =>
{
return Data.Books[i].Search();
});
//Task.wait()方法,调用线程阻塞在wait处,出现两种情况结束等待:
//1.线程执行完毕;
//2.任务本身已经取消或引发异常
t.Wait();//a 当前调用线程上同步阻塞等待
AppendLine($"{i}.{t.Result}");//b
}
sw.Stop();
AppendLine($"Wait完成:{Convert.ToSingle(sw.ElapsedMilliseconds) / 1000}秒");
}
1、t.Wait();
Task.Wait方法用于阻塞当前线程,并等待异步操作的完成。它是一个实例方法,针对具体的单一任务使用,用法:
task.Wait(); // 阻塞当前线程,直到任务完成
当调用Task.Wait方法时,当前线程将被阻塞,直到对应的任务(task)完成。如果任务已经完成,Wait方法会立即返回。如果任务尚未完成,Wait方法会一直阻塞当前线程直到任务完成。
注意:task.Wait方法可能会导致线程阻塞,可能影响程序的响应性。因此,在使用Wait方法时,需要权衡其对程序性能和响应性的影响。
另外,从.NET Framework 4.5开始,推荐使用await关键字结合异步方法进行异步操作的等待,而不是直接使用Task.Wait方法。使用await可以让异步代码在等待异步操作完成时释放当前线程并进行其他工作,从而提高程序的性能和响应性。
扩展:它有几个重载,可以设置取消或运行时间,方便进行控制。
2、t.Result
task.Result 是一个同步方法,它会阻塞当前线程,直到任务完成并返回结果。如果任务已经完成,它会立即返回结果;如果任务尚未完成,它会阻塞当前线程直到任务完成。这意味着如果在主线程中使用 task.Result,它会阻塞主线程,导致应用程序无响应,看似"死了"。
而使用 await task 或 task.Wait() 方法时,当前线程会被暂停并释放,允许其他代码在此期间执行。这被称为异步等待。一旦任务完成,当前线程会重新获得控制。
总结:task.Result 是同步等待任务结果,会阻塞当前线程;而使用 await task 或 task.Wait() 是异步等待任务完成,允许其他代码执行。
因此上面的a处t.Wait是可以省略的,因为它也是同步阻塞等待,省略后,t.Result的结果如果没出来也会同步阻塞等待,直到结果返回。所以上面的结果是逐个出来,前面一个任务完成后才开始循环中的第二个任务。
三、WaitAll
private void BtnWaitAll_Click(object sender, EventArgs e)//同步等待全部完成
{
List<Task<string>> ts = new List<Task<string>>();
Stopwatch sw = Stopwatch.StartNew();
txtInfo.Clear();
AppendLine("WaitAll开始...");
for (int i = 0; i < Data.Books.Count; i++)
{
var book = Data.Books[i];
ts.Add(Task.Run(() => { return book.Search(); }));
}
AppendLine($"等待前{DateTime.Now.TimeOfDay}");
Task.WaitAll(ts.ToArray());
AppendLine($"等待前{DateTime.Now.TimeOfDay}");
foreach (var t in ts)
{ AppendLine($"{t.Result}.{t.Id}.{t.Status}"); }
sw.Stop();
AppendLine($"WaitAll完成:{Convert.ToSingle(sw.ElapsedMilliseconds) / 1000}秒");
}
1、Task.WaitAll 方法是一个静态方法,用于等待多个任务完成。它接受一个 Task 数组作为参数,并阻塞当前线程,直到所有任务都完成。
使用 Task.WaitAll 方法可以方便地等待多个任务并发地执行,而不需要手动跟踪每个任务的状态。它可以在需要等待多个任务完成后再继续执行的场景中非常有用,例如在并行处理数据或者执行批量任务时。
使用 Task.WaitAll 方法等待所有任务完成。一旦所有任务都完成,就会继续执行后续的代码。
注意,在等待多个任务时,如果其中一个任务发生异常,Task.WaitAll 方法会将所有任务的异常都聚合到一个 AggregateException 对象中,并将其抛出。因此,在使用 Task.WaitAll 方法时,最好使用 try-catch 块来处理可能的异常。
总结:Task.WaitAll 方法用于等待多个任务完成,它阻塞当前线程直到所有任务都完成。它适用于需要等待多个任务并发执行的场景。
警告:WaitAll()如果不带参数,将不会等待任何任务,起不到等待所有任务的作用。
常见参数是params Task[]数组形式,也可以设置超时等待和可取消。
2、ts是一个List<Task<string>>类型的列表,用于存储并发执行的任务。执行结果:
可以看到结束时间很快,当waitall结速时,所有结果,状态都是出来了。它们的ID各不同,即由不同的异步线程执行,
3、上面ID有7,那么线程池里有多少线程呢?
对于 C# 线程池的线程数量,线程池有一个默认的最小线程数和最大线程数。默认情况下,最小线程数等于处理器的核心数,而最大线程数则是根据系统的配置和资源动态调整的。
线程池的最小线程数可以通过 ThreadPool.GetMinThreads 方法来获取。而最大线程数可以通过 ThreadPool.GetMaxThreads 方法来获取。这些值是根据应用程序的需求和系统资源的限制来动态调整的,所以可能会有所不同。
至于显示的线程 ID 为 7,这是因为系统上的线程池正在处理并行任务,同时分配了多个线程来执行这些任务。线程池会为每个线程分配一个唯一的 ID,因此每次显示的线程 ID 可能会有所不同。
注意,具体的线程池行为会受到操作系统和运行时环境的影响,因此在不同的环境中结果可能会有所不同。如果有特定的线程池需求,可以通过 C# 的相关方法和属性来进行自定义配置。
四、WaitAny
private void BtnWaitAny_Click(object sender, EventArgs e)//同步等待任一完成
{
List<Task<string>> ts = new List<Task<string>>();
Stopwatch sw = Stopwatch.StartNew();
txtInfo.Clear();
AppendLine("WaitAny开始...");
for (int i = 0; i < Data.Books.Count; i++)
{
var book = Data.Books[i];
ts.Add(Task.Run(() =>
{
return book.Search();
}));
}
Task.WaitAny(ts.ToArray());//ts任务集合中任一任务完成,调用线程就继续向后执行。
foreach (var t in ts)
{
AppendLine($"{t.Id}.{t.Status}");//a
}
sw.Stop();
AppendLine($"WaitAll完成:{Convert.ToSingle(sw.ElapsedMilliseconds) / 1000}秒");
}
1、Task.WaitAny用于等待多个异步任务中的任意一个完成。这对于同时执行多个任务,但只需要其中一个首先完成时非常有用。
Task.WaitAny方法接受一个Task对象的数组作为参数,并等待数组中任意一个任务完成。当一个任务完成时,该方法立即返回,并返回完成的任务的索引。
Task.WaitAny不会阻塞整个程序,只会阻塞当前的线程,直到数组中的任意一个任务完成。
当你调用Task.WaitAny方法时,它不会等待所有任务都完成。相反,它只等待第一个完成的任务,然后立即返回。
如果数组中没有任务完成,Task.WaitAny方法将阻塞当前线程,直到有任务完成。
返回的索引是基于零的索引,表示完成的任务在任务数组中的位置。如果没有任务完成,该方法将返回-1。
2、a处task.status是RanToCompletion表示什么意思?
在C#的Task Parallel Library (TPL)中,Task.Status是一个属性,它提供了关于任务当前状态的信息。Task.Status可能枚举值如下:
TaskStatus.Created: 表示任务刚刚被创建,但还没有开始执行。
TaskStatus.Running: 表示任务正在执行。
TaskStatus.RanToCompletion: 表示任务已经成功完成。
TaskStatus.Faulted: 表示任务由于异常而失败。
TaskStatus.Canceled: 表示任务被取消。
所以,如果 Task.Status 是 RanToCompletion,这意味着任务已经成功完成并且没有出现任何错误。
3、结果:
因为是waitany是同步等待第一个完成的,就向下继续执行。所以a处不能显示结果(t.Result)。一旦用了t.Result它又会同步阻塞并等待当前t任务返回结果。因此这里用来显示各个任务的执行状态,可以看到有些在运行,有些已经完成。
4、按完成时间的先后顺序显示信息:
private async void BtnWaitAny_Click(object sender, EventArgs e)//同步等待任一完成
{
List<Task<string>> ts = new List<Task<string>>();
Stopwatch sw = Stopwatch.StartNew();
txtInfo.Clear();
AppendLine("WaitAny开始...");
for (int i = 0; i < Data.Books.Count; i++)
{
var book = Data.Books[i];
ts.Add(Task.Run(() =>
{
return book.Search();
}));
}
int idx = 0;
while (ts.Count > 0)
{
Task<string> com = await Task.WhenAny(ts);
AppendLine($"{++idx}.{com.Result}===={DateTime.Now.TimeOfDay}");
ts.Remove(com);
}
sw.Stop();
AppendLine($"WaitAll完成:{Convert.ToSingle(sw.ElapsedMilliseconds) / 1000}秒");
}
结果:
上面使用异步等待任一先完成whenany就执行显示信息,同时删除这个任务,直到这些任务全部删除,也即所有任务全部完成。
如果写成:
private async void BtnWaitAny_Click(object sender, EventArgs e)//同步等待任一完成
{
List<Task<string>> ts = new List<Task<string>>();
Stopwatch sw = Stopwatch.StartNew();
txtInfo.Clear();
AppendLine("WaitAny开始...");
for (int i = 0; i < Data.Books.Count; i++)
{
var book = Data.Books[i];
ts.Add(Task.Run(() =>
{
return book.Search();
}));
}
int idx = 0;
await Task.WhenAll(ts.ToArray()).ContinueWith((t) =>
{
InvworkAppendLine($"{++idx}.{t.Result}===={DateTime.Now.TimeOfDay}");//a
});
sw.Stop();
AppendLine($"WaitAll完成:{Convert.ToSingle(sw.ElapsedMilliseconds) / 1000}秒");
}
因为WhenAll是异步线程执行,所以不能用AppendLine来操作UI线程的控件,这里用InvworkAppendLine委托操作UI线程的控件。
上面a处的t只能是最后完成的任务,所以结果为:
WaitAny开始...
1.System.String[]====14:02:02.0183909
WaitAll完成:2.08秒
只有一条信息,也就是最后一个任务完成的信息。因此,需要在contiuewith进行循环:
await Task.WhenAll(ts.ToArray()).ContinueWith((t) =>
{
foreach (var item in ts)
{
InvworkAppendLine($"{++idx}.{item.Result}===={DateTime.Now.TimeOfDay}");//a
}
});
但结果却是这样的:
WaitAny开始...
1.封神演义--------用时:1.001====14:03:23.5000157
2.三国演义--------用时:2.012====14:03:23.5030043
3.水浒传---------用时:1.001====14:03:23.5030043
4.西游记---------用时:1.001====14:03:23.5030043
5.聊斋志异--------用时:1.001====14:03:23.5030043
6.儒林外史--------用时:2.012====14:03:23.5030043
7.隋唐演义--------用时:1.001====14:03:23.5030043
WaitAll完成:2.1秒
明显的,它是所有任务完成后,再去取每个的耗时,最后的时间记录是循环时的时间并不是真正完成的时间。所以这个时间几乎紧贴。无论如何,只要用了whenall它是所有完成后才进行记录,就错过了每个任务完成就记录的时间,所以用whenall就是错误的。
五、死锁
private void BtnDeadLock_Click(object sender, EventArgs e)//死锁
{
Stopwatch sw = Stopwatch.StartNew();
txtInfo.Clear();
AppendLine("Wait开始...");
for (int i = 0; i < Data.Books.Count; i++)
{
var book = Data.Books[i];
var idx = i + 1;
var task = book.SearchAsync();
AppendLine($"{idx}.{task.Result}");//a
}
sw.Stop();
AppendLine($"Wait完成:{Convert.ToSingle(sw.ElapsedMilliseconds) / 1000}秒");
}
//book类中方法
public async Task<string> SearchAsync()
{
Stopwatch sw = Stopwatch.StartNew();
await Task.Delay(Duration * 1000);//b
sw.Stop();
return Result(sw.ElapsedMilliseconds);//c
}
1、为什么async的返回类型都不能使用void?(上面用的Task<string>)
因为有返回类型时,以便对当前任务进行判断,或对该任务的后续再次加工操作。
使用void作为异步方法的返回类型会导致以下问题:
(1)无法使用await等待异步方法完成:使用await关键字可以等待一个异步方法完成并继续执行后续代码。但是,如果异步方法的返回类型是void,则无法使用await等待其完成,因为不能将void类型传递给await。
public async void DoSomethingAsync()//c 这里使用了async void
{
await Task.Delay(1000); // 模拟异步操作,等待1秒钟
Console.WriteLine("Async operation completed.");
}
// 调用异步方法
DoSomethingAsync();//a
Console.WriteLine("Method called.");//b
// 输出结果:
// Method called.
// Async operation completed.
上面a处调用了c处的async void,它是异步执行,无法知道它什么时间完成,犹如一个脱了缰绳的狗,你无法预知它什么回家(输出结果),导致输出的顺序不是预期的。 (2)无法捕获异步方法中的异常:当异步方法的返回类型是Task或Task<T>时,可以在调用异步方法时使用try-catch块捕获方法中发生的异常。但是,如果返回类型是void,则无法通过异常处理方式捕获异步方法中的异常。
public async void DoSomethingAsync()
{
await Task.Delay(1000);
throw new Exception("Something went wrong.");//假定出错
}
// 调用异步方法
try
{
DoSomethingAsync();
}
catch (Exception ex)
{
Console.WriteLine("Error: " + ex.Message);
}
// 输出结果:
// Unhandled exception: System.Exception: Something went wrong.
由于异步方法的返回类型是void,无法使用try-catch块捕获异步方法中的异常。因此,发生的异常会成为未处理的异常,导致程序崩溃或无法正常处理异常情况。
本质上:
异步方法的异常不会直接返回给调用线程,而是在Task对象中捕获。当使用await等待异步方法完成时,await会检查Task对象的状态,如果其中包含了异常,await会抛出该异常,然后可以在调用代码中使用try-catch块捕获它。也即:当异步方法抛出异常时,异常会被捕获并封装在Task对象中。然后,Task对象会在等待它的代码中传播异常。
public async Task<int> DoSomethingAsync()
{
await Task.Delay(1000);
throw new Exception("Something went wrong.");
}
try
{
int result = await DoSomethingAsync();
Console.WriteLine("Result: " + result);
}
catch (Exception ex)
{
Console.WriteLine("Error: " + ex.Message);
}
上面可以直接捕获到异步,而不会导致程序崩溃。 (3)无法获取异步方法的执行结果:异步方法的返回类型是用于包装异步操作的Task或Task<T>,其中包含了异步操作的执行结果。如果返回类型是void,则无法直接获取异步方法的执行结果。
public async void DoSomethingAsync()
{
await Task.Delay(1000);
Console.WriteLine("Async operation completed.");
}
// 调用异步方法
DoSomethingAsync();
Console.WriteLine("Method called.");
// 输出结果:
// Method called.
// Async operation completed.
修改一下,通过返回的task就能得到最后异步执行的结果、状态等。
public async Task<int> DoSomethingAsync()
{
await Task.Delay(1000);
Console.WriteLine("Async operation completed.");
return 42;
}
// 调用异步方法
Task<int> task = DoSomethingAsync();
Console.WriteLine("Method called.");
// 等待异步方法完成并获取结果
int result = await task;
Console.WriteLine("Result: " + result);
// 输出结果:
// Method called.
// Async operation completed.
// Result: 42
使用Task或Task<T>作为返回类型可以解决上述问题,使得异步方法可以更好地集成到异步编程模型中,并允许你在调用异步方法时等待其完成、捕获异常和获取执行结果。因此,推荐在异步方法中使用Task或Task<T>作为返回类型,而不是void。
2、为什么在UI中各控件是用的async void呢?
在UI编程中,很多事件处理程序或回调方法需要使用async void作为返回类型。这是因为在许多UI框架中,例如WPF、Windows Forms和ASP.NET,它们提供了一种异步编程模型,允许在UI线程上执行异步操作。
在这些UI框架中,UI元素的事件处理程序通常需要返回void类型,因为它们的返回值没有特定的用途。同时,这些事件处理程序通常需要异步执行,以避免在UI线程上进行耗时的操作,从而保持UI的响应性。
然而,async void方法有一些潜在的问题需要注意。由于async void方法无法等待其完成,也无法捕获其中发生的异常,因此在使用时需要格外小心。如果async void方法出现异常,它可能会导致应用程序崩溃或产生意外的行为。
为了解决这个问题,UI框架通常提供了一些错误处理机制,例如提供UnhandledException事件来捕获async void方法中的异常。开发者可以在这些错误处理机制中进行适当的异常处理,以确保应用程序的稳定性和可靠性。
尽管在UI编程中可以使用async void方法来简化异步逻辑的书写,但在其他场景中,建议使用async Task或async Task<T>作为异步方法的返回类型,以便能够更好地处理异步操作,等待其完成,并捕获其中发生的异常。
3、经典的死锁模型:
在C#中,典型的死锁情况可以发生在主程序等待子程序的结果,而子程序又在等待主程序的空闲状态。这种情况通常称为"上下文死锁"或"异步死锁",发生在使用await和Task.Wait(或Task.Result)组合时。
上面a处用task.Result是一个同步等待子程序的结果操作,它会阻塞主线程,直到子程序SearchSync返回c处结果为止。
而此时子程序SearchAsync在b处完成异步等待后,因为默认的ConfigureAwait为true(Task不加此参数就是默认true),所以在b处异步线程并不会向下执行,而是试图将控制切换到UI线程或调用线程上去。但是,调用线程是主程序,主程序又在等子方法向下执行到c处,以便返回结果。
这样主程序等待子程序c处的结果,子程序在b处期待主程序空闲下来,两个相互等,相互累,形成死锁。
4、死锁的解决办法
(1)使用ConfigureAwait(false): 在主程序调用MainMethod时,可以在await语句中使用ConfigureAwait(false)来禁用上下文切换。这样可以避免造成上下文死锁,因为等待期间不会回到主线程上下文。
唯一要注意的是,如果在子程序中异步线程涉及要操作UI,请用委托。
public async Task<string> SearchAsync()
{
Stopwatch sw = Stopwatch.StartNew();
await Task.Delay(Duration * 1000).ConfigureAwait(false);
sw.Stop();
return Result(sw.ElapsedMilliseconds);
}
(2)使用ContiueWith():让异步线程继续执行下去。在主程序中修改:
for (int i = 0; i < Data.Books.Count; i++)
{
var book = Data.Books[i];
var idx = i + 1;
Task task = book.SearchAsync().ContinueWith((t) =>
{
InvworkAppendLine($"{idx}.{t.Result}");//a
});
}
ContinueWith执行线程将选定默认的TaskContinuationOptions,即如果没有指定TaskScheduler,在没有完成状态的线程上调用的ContinueWith处理程序将在相同的线程上执行。因此,ContinueWith中的语句会在异步操作所在的后台线程上执行。
因此,SearchAsync中task的configureawait是true或false都是没有影响的。book.SearchAsync().ContinueWith中的代码会在异步操作所在的后台线程上执行,而不会回到UI线程。
注意,如果想在异步操作完成后在UI线程上更新界面,可能需要确保使用BeginInvoke或使用await关键字在UI线程上执行更新操作,以确保正确的线程同步和避免潜在的跨线程问题。因此上面的InvworkAppendLine不能更改为AppendLine(要操作UI),而使用委托。
5、控制台一般不会发生死锁?
控制台应用程序中通常不会发生死锁的情况。这是因为在控制台环境中,没有涉及到 UI 线程和界面更新等需要特殊注意的操作。
死锁通常发生在多线程环境中,当两个或多个线程相互等待对方释放某个资源时,导致它们都无法继续执行。这种情况通常涉及到线程间的同步和互斥操作,如共享资源的访问、锁定等。
在控制台应用程序中,由于通常只有单个主线程(也可以有其他子线程,但通常没有复杂的线程同步需求),线程间的竞争和同步问题相对较少。控制台程序一般不涉及复杂的多线程操作,因此死锁的风险较低。
然而,如果在控制台应用程序中存在多个线程并涉及到资源竞争,可能会发生死锁情况。为了避免死锁,需要合理设计和管理线程间的同步操作,例如使用适当的同步方法、避免过多的资源竞争、正确处理锁定和竞争条件等。
如果是控制台程序,或者一个普通的非 UI 线程,其 SynchronizationContext 为 null,那么异步任务执行完后不需要回到原有线程,也不会造成死锁。
当控制台的SynchronizationContext为null时,无法自动切换回调的执行线程,即使ConfigureAwait(true)被使用。在这种情况下,异步操作完成后,执行的后续代码会继续在异步操作所在的线程上执行,而不会自动切换到调用线程。
ConfigureAwait(true) 的作用是告诉编译器在异步操作完成后,尽可能地切换回调的执行上下文(例如UI线程),以便便利地进行界面更新等操作。但是,当当前上下文(如控制台)的SynchronizationContext为null时,切换上下文的机制不起作用,后续代码仍然会在原始调用线程上执行。
如果想确保后续代码在调用线程上执行,可以使用ConfigureAwait(false),显式地指示不切换上下文。这样,无论当前的SynchronizationContext是否为null,后续代码都会在异步操作所在的线程上执行,而不进行自动切换。
注意,控制台环境通常是单线程的,因此即使使用ConfigureAwait(true)也不会发生线程切换。在异步操作完成后,后续代码仍然会继续在同一个控制台线程上执行,而不会自动切换到其他线程。