多线程
题目1
请简述下述程序打印结果,以及原因?
输出
- 执行方式1: Select + Task.WhenAll (基于 async/await)
var tasks = numbers.Select(x => DoSomethingAsync(x));Select 是 LINQ 的延迟执行方法。这一行不会立即执行 DoSomethingAsync。它只是创建了一个IEnumerable<Task>,描述了要执行的任务。tasks.ToArray()为了传递给 Task.WhenAll,需要将IEnumerable<Task>转换为数组。在转换过程中,会遍历 Select 的结果,此时才会真正调用 DoSomethingAsync(x) 10 次。DoSomethingAsync(x)这个方法是异步的。它调用await Task.Delay(5000).ConfigureAwait(false);。Task.Delay 返回一个表示延迟的任务,await 会(通常)立即将控制权交还给调用者(即 ToArray() 的迭代逻辑),同时安排 5 秒后继续执行。ConfigureAwait(false)避免了捕获同步上下文,这在这里影响不大,但通常是库代码的好习惯。- 调用 10 次 DoSomethingAsync 会非常快地启动 10 个独立的 5 秒延迟任务。这些任务几乎是同时开始的,并且它们是并发运行的(不阻塞主线程,等待 I/O 完成端口或计时器)。
await Task.WhenAll(tasks.ToArray()).ConfigureAwait(false);Task.WhenAll 会异步地等待所有传递给它的任务完成。由于这 10 个任务是并发运行的,并且每个都需要大约 5 秒,Task.WhenAll 将在大约 5 秒后完成(取决于最慢的那个任务)- 时间预测: 从 00:00:00 开始,启动任务非常快,等待所有并发任务完成大约需要 5 秒。所以时间戳大约是 2024-05-01 00:00:05。
- 执行方式2: Parallel.ForEach (基于 TPL)
Parallel.ForEach(numbers, x => DoSomething(x));Task Parallel Library (TPL) 的 Parallel.ForEach 会尝试在多个线程上并行执行 DoSomething(x)。- Parallel.ForEach 会阻塞调用它的线程(这里是 Action 方法的主流程,在 await Task.WhenAll 之后),直到所有并行操作完成。TPL 会根据可用的 CPU 核心数量来决定同时运行多少个 DoSomething(x)
- 假设系统有 N 个核心可供 TPL 使用。它会同时启动 N 个 DoSomething 调用。每 5 秒钟,这 N 个调用完成,TPL 再启动下一批。总共需要执行 10 次,所以大约需要
Ceiling(10.0 / N) *5 秒。 - 时间戳预测: 从方式 1 结束的 00:00:05 开始,再经过大约 5-10 秒。所以时间戳大约是 2024-05-01 00:00:10 到 00:00:15。(我们取 10 秒的执行时间作为例子,即 00:00:10)。
- 执行方式3: ForEach + new Thread().Start()
numbers.ForEach(x => new Thread(DoSomething).Start());使用List<T>.ForEach遍历列表。对于每个元素:- new Thread(DoSomething): 创建一个新的线程对象,指定它要执行 DoSomething 方法。
- .Start(): 启动这个新线程。Start() 方法立即返回,不会等待新线程执行完毕。
- 这个循环会非常快速地创建并启动 10 个新线程。主线程(执行 Action 方法的线程)在启动完所有线程后不会等待它们完成,而是立即继续执行下一行代码。这 10 个新线程会在后台各自独立地执行 DoSomething(即阻塞自己 5 秒)
- 时间戳预测: 从方式 2 结束的时间(约 00:00:15)开始,创建和启动 10 个线程非常快(可能只需几毫秒)。因此,这个时间戳将非常接近方式 2 的结束时间。大约是 2024-05-01 00:00:10。
总结:
- 方式 1 (async/await): 高效利用异步 I/O(或计时器),任务并发执行不阻塞线程,总时间约等于单个最长任务时间(5s)。
- 方式 2 (Parallel.ForEach): 利用多核并行执行阻塞任务,总时间取决于核心数和任务总数,阻塞调用者直到全部完成(~10-25s,取决于核心)。
- 方式 3 (new Thread): 快速启动多个独立线程(“发射后不管”),不阻塞调用者,打印时间戳时后台线程仍在运行,时间戳只反映启动完成时间(几乎立即)。
题目2
请简述下述程序打印结果,以及原因?
输出结果
- async void 方法在遇到第一个未完成的 await 时,会将控制权立即返回给调用者,导致 Action 方法的 "尾部已执行" 很快被打印出来。
- Task.Run 将耗时操作(包括 Task.Delay().Wait() 阻塞)放到了线程池线程执行,因此不会阻塞主线程。
- Task.Delay().Wait() 是同步阻塞,它会阻塞执行它的线程(在这里是线程池线程),直到延迟结束。这是效率不高的做法(应该用 await Task.Delay())。
- await 后续的代码(在 CallMyJobAsync 中)会在被 await 的任务完成后,通常在线程池线程上恢复执行。
- 因此,主线程的消息先打印,然后线程池开始工作,在延迟和阻塞后打印它们的消息,最后 await 的后续部分打印最终结果。打印的线程 ID 会显示主线程和线程池线程的区别。
题目3
请简述下述程序打印结果,以及原因?
输出
- async void 导致 GetResultAsync 在第一个 await 时就将控制权交还给 Action,使得 Action 的 "完成" 消息很快打印。
- Task.Run 将耗时操作(包含阻塞的 Task.Delay().Wait())放到了线程池。
- Task.Delay().Wait() 同步阻塞了执行它的线程池线程。
- 访问未完成任务的 .Result 属性会同步阻塞调用线程,直到任务完成。
- 整个流程涉及主线程、至少两个后台任务的执行(每个任务阻塞 5 秒),以及由于 .Result 导致的第二次阻塞。因此,总耗时大约是 10 秒。打印的线程 ID 会反映主线程和线程池线程的不同。
- 注意: 在实际应用中,应避免使用 async void(除非是事件处理程序),并用 await Task.Delay() 替换 Task.Delay().Wait(),用 await task 替换 task.Result 以实现完全的异步,避免阻塞线程。
题目4
请简述下述程序打印结果,以及原因?
输出
- async void 使得 RunAsync 在第一个 await 时就把控制权还给 Action,导致 "启动" 和 "完成" 消息几乎同时打印。
- 两次调用 GetResult 是非阻塞的,它们快速地启动了两个后台任务 (Task.Run)。
- 这两个后台任务在线程池中并发执行,每个任务内部都包含一个 1 秒的同步阻塞 (Task.Delay().Wait())。
- await Task.WhenAll 异步地等待这两个并发任务都完成。因为它们是并发执行的,总等待时间约等于最长的那个任务的时间,即大约 1 秒。
- 在 Task.WhenAll 完成后,访问 .Result 是安全的,不会阻塞。
- 因此,整个后台计算过程大约需要 1 秒钟,最终结果在 T0 + 1s 左右打印出来。
题目5
请简述下述程序打印结果,以及原因?
输出
- 同步调用 (HandleError) 一个返回 Task 的异步方法而不 await 它,会导致该方法的 try-catch 无法捕获异步操作中(await 之后)发生的异常。该异常会变成未处理的 Task 异常,通常导致程序崩溃。
- 异步调用 (HandleErrorAsync) 使用 await 等待 Task,可以将异步操作中发生的异常传播回 await 点,从而允许外层的 try-catch 块捕获该异常。
- 主线程的 "启动" 和 "完成" 消息会快速连续打印,因为异步调用不会阻塞主线程。实际的延迟和异常处理发生在后台。
题目6
请简述下述程序打印结果,以及原因?
输出
- SemaphoreSlim(2) 限制了同时能进入 try 块(即执行 2 秒 Sleep)的任务数量为 2 个。
- 4 个任务并发启动,但只有 2 个能立即开始工作,另外 2 个必须等待。
- 任务分成了两批执行,每批执行时间大约 2 秒。
- Task.WaitAll 阻塞了主线程,直到所有 4 个任务(包括等待和执行的时间)都完成。
- 因此,总耗时大约是 2 批 * 2 秒/批 = 4 秒。
题目7
请简述下述程序打印结果,以及原因?
输出
- 在 100,000 次迭代中,这种竞争条件会发生很多次。每次发生,都会导致一次或多次递增操作的丢失。
- 因此,当 Parallel.For 完成时(它会阻塞主线程直到所有迭代完成),a 的最终值将小于 100,000。
- 具体丢失多少次递增取决于线程调度的具体时机、CPU 核心数量等因素,因此结果是非确定性的。
- 使用线程安全方式有两种
Interlocked.Increment(ref a)这是最高效、最推荐的方式,它能原子性地完成递增操作。lock语句 使用锁来保护对 a 的访问,确保同一时间只有一个线程能执行 a++。