C# 面试手册

多线程

题目1

请简述下述程序打印结果,以及原因?

    public void DoSomething(object i)
    {
        Thread.Sleep(5000);
    }
 
    public async Task DoSomethingAsync(object i)
    {
        await Task.Delay(5000).ConfigureAwait(false);
    }
 
    public async Task Action()
    {
        List<int> numbers = Enumerable.Range(1, 10).ToList();
        Console.WriteLine($"初始化-{DateTime.Now}");
        //假设打印的时间是2024-05-01 00:00:00
        //执行方式1
        var tasks = numbers.Select(x => DoSomethingAsync(x));
        await Task.WhenAll(tasks.ToArray()).ConfigureAwait(false);
        Console.WriteLine($"执行方式1-时间大约是?{DateTime.Now}");
        //执行方式2
        Parallel.ForEach(numbers, x => DoSomething(x));
        Console.WriteLine($"执行方式2-时间大约是?{DateTime.Now}");
        //执行方式3
        numbers.ForEach(x => new Thread(DoSomething).Start());
        Console.WriteLine($"执行方式3-时间大约是?{DateTime.Now}");
    }

输出

初始化-2024/05/01 00:00:00
执行方式1-时间大约是?2024/05/01 00:00:05
执行方式2-时间大约是?2024/05/01 00:00:10
执行方式3-时间大约是?2024/05/01 00:00:10
  • 执行方式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

请简述下述程序打印结果,以及原因?

    public static void Action()
    {
        Console.WriteLine($"主方法已执行,当前主线程Id为:{Thread.CurrentThread.ManagedThreadId}");
        CallMyJobAsync("jack");
        Console.WriteLine($"尾部已执行,当前主线程Id为:{Thread.CurrentThread.ManagedThreadId}");
    }
 
    static async void CallMyJobAsync(string name)
    {
        Console.WriteLine($"开始执行任务,当前线程Id为:{Thread.CurrentThread.ManagedThreadId}");
        string result = await MyJobAsync(name);
        Console.WriteLine($"异步完成任务,当前线程Id为:{Thread.CurrentThread.ManagedThreadId}");
        Console.WriteLine(result);
    }
    
    static Task<string> MyJobAsync(string name)
    {
        return Task.Run<string>(() =>
        {
            Task.Delay(1000).Wait();
            Console.WriteLine($"准备SayHi,当前线程Id为:{Thread.CurrentThread.ManagedThreadId}");
            return SayHi(name);
        });
    }
    
    static string SayHi(string name)
    {
        Task.Delay(2000).Wait();//异步等待2s
        Console.WriteLine($"SayHi执行,当前线程Id为:{Thread.CurrentThread.ManagedThreadId}");
        return $"Hi {name}";
    }

输出结果

主方法已执行,当前主线程Id为:1  // (假设主线程 ID 是 1)
开始执行任务,当前线程Id为:1
尾部已执行,当前主线程Id为:1
// --- 大约 1 秒后 ---
准备SayHi,当前线程Id为:[线程池线程 ID, 例如 4] // (ID 不会是 1)
// --- 大约 2 秒后 (总共约 3 秒后) ---
SayHi执行,当前线程Id为:[与上面相同的线程池线程 ID, 例如 4]
异步完成任务,当前线程Id为:[可能是同一个或另一个线程池线程 ID, 例如 4 或 5]
Hi jack
  • async void 方法在遇到第一个未完成的 await 时,会将控制权立即返回给调用者,导致 Action 方法的 "尾部已执行" 很快被打印出来。
  • Task.Run 将耗时操作(包括 Task.Delay().Wait() 阻塞)放到了线程池线程执行,因此不会阻塞主线程。
  • Task.Delay().Wait() 是同步阻塞,它会阻塞执行它的线程(在这里是线程池线程),直到延迟结束。这是效率不高的做法(应该用 await Task.Delay())。
  • await 后续的代码(在 CallMyJobAsync 中)会在被 await 的任务完成后,通常在线程池线程上恢复执行。
  • 因此,主线程的消息先打印,然后线程池开始工作,在延迟和阻塞后打印它们的消息,最后 await 的后续部分打印最终结果。打印的线程 ID 会显示主线程和线程池线程的区别。

题目3

请简述下述程序打印结果,以及原因?

    public void Action()
    {
        Console.WriteLine($"{DateTime.Now}启动.....{Thread.CurrentThread.ManagedThreadId}");
        GetResultAsync();
        Console.WriteLine($"{DateTime.Now}完成.....{Thread.CurrentThread.ManagedThreadId}");
    }
 
    async static void GetResultAsync()
    {
        var number1 = await GetResult(10);
        Console.WriteLine($"{DateTime.Now}结果1完成{Thread.CurrentThread.ManagedThreadId}");
        var number2 = GetResult(number1);
        Console.WriteLine($"{DateTime.Now}结果分别为:{number1}{number2.Result}{Thread.CurrentThread.ManagedThreadId}");
    }
 
    static Task<int> GetResult(int number)
    {
        Console.WriteLine($"{DateTime.Now}启动任务.....{Thread.CurrentThread.ManagedThreadId}");
        return Task.Run<int>(() =>
        {
            Task.Delay(5000).Wait();
            Console.WriteLine($"{DateTime.Now}结束任务.....{Thread.CurrentThread.ManagedThreadId}");
            return number + 10;
        });
    }

输出

[时间 T0] 启动.....1  // (假设主线程 ID 是 1)
[时间 T0 + 极短时间] 启动任务.....1
[时间 T0 + 更短时间] 完成.....1
// --- 大约 5 秒后 ---
[时间 T0 + 5s] 结束任务.....[线程池线程 ID, 例如 4] // (ID 不会是 1)
[时间 T0 + 5s + 极短时间] 结果1完成[线程池线程 ID, 例如 5] // (可能是 4 或 其他线程池 ID)
[时间 T0 + 5s + 更短时间] 启动任务.....[线程池线程 ID, 例如 5]
// --- 再大约 5 秒后 (总共约 10 秒后) ---
[时间 T0 + 10s] 结束任务.....[线程池线程 ID, 例如 6] // (可能是 4, 5 或 其他线程池 ID)
[时间 T0 + 5s + 极短时间] 结果分别为:20和30,[线程池线程 ID, 例如 5] // (阻塞等待的那个线程)
// --- 分隔线,下述实际执行结果
2025/5/4 14:22:09启动.....5
2025/5/4 14:22:09启动任务.....5
2025/5/4 14:22:09完成.....5
2025/5/4 14:22:14结束任务.....12
2025/5/4 14:22:14结果1完成19
2025/5/4 14:22:14启动任务.....19
2025/5/4 14:22:19结束任务.....12
2025/5/4 14:22:14结果分别为:20和30,19
  • 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

请简述下述程序打印结果,以及原因?

public static void Action()
{
    Console.WriteLine($"{DateTime.Now}启动.....{Thread.CurrentThread.ManagedThreadId}");
    RunAsync();
    Console.WriteLine($"{DateTime.Now}完成.....{Thread.CurrentThread.ManagedThreadId}");
}
 
static async void RunAsync()
{
    var result1 = GetResult(10);
    var result2 = GetResult(20);
    await Task.WhenAll(result1, result2);
    Console.WriteLine($"{DateTime.Now}计算结果:{result1.Result}{result2.Result}.....{Thread.CurrentThread.ManagedThreadId}");
}
 
static Task<int> GetResult(int num)
{
    return Task.Run<int>(() =>
    {
        Task.Delay(1000).Wait();
        Console.WriteLine($"{DateTime.Now}计算完成.....{Thread.CurrentThread.ManagedThreadId}");
        return num + 10;
    });
}

输出

[时间 T0] 启动.....1  // (假设主线程 ID 是 1)
[时间 T0 + 极短时间] 完成.....1
// --- 大约 1 秒后 ---
[时间 T0 + 1s] 计算完成.....[线程池线程 ID A, 例如 4]
[时间 T0 + 1s + 极短时间] 计算完成.....[线程池线程 ID B, 例如 5] // (A 和 B 的顺序可能互换)
[时间 T0 + 1s + 稍长极短时间] 计算结果:20和30.....[线程池线程 ID C, 例如 6] // (可能是 4, 5 或其他)
  • async void 使得 RunAsync 在第一个 await 时就把控制权还给 Action,导致 "启动" 和 "完成" 消息几乎同时打印。
  • 两次调用 GetResult 是非阻塞的,它们快速地启动了两个后台任务 (Task.Run)。
  • 这两个后台任务在线程池中并发执行,每个任务内部都包含一个 1 秒的同步阻塞 (Task.Delay().Wait())。
  • await Task.WhenAll 异步地等待这两个并发任务都完成。因为它们是并发执行的,总等待时间约等于最长的那个任务的时间,即大约 1 秒。
  • 在 Task.WhenAll 完成后,访问 .Result 是安全的,不会阻塞。
  • 因此,整个后台计算过程大约需要 1 秒钟,最终结果在 T0 + 1s 左右打印出来。

题目5

请简述下述程序打印结果,以及原因?

public static void Action()
{
    Console.WriteLine($"{DateTime.Now}启动.....{Thread.CurrentThread.ManagedThreadId}");
    HandleError();
    HandleErrorAsync();
    Console.WriteLine($"{DateTime.Now}完成.....{Thread.CurrentThread.ManagedThreadId}");
}
 
static void HandleError()
{
    try
    {
        ThrowExceptionAsync();
    }
    catch (Exception e)
    {
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId}同步错误:{e.Message}");
    }
}
 
static async void HandleErrorAsync()
{
    try
    {
        await ThrowExceptionAsync();
    }
    catch (Exception e)
    {
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId}异步错误:{e.Message}");
    }
}
 
static async Task ThrowExceptionAsync()
{
    await Task.Delay(1000);
    throw new Exception("测试异常");
}

输出

[时间 T0] 启动.....1  // (假设主线程 ID 是 1)
[时间 T0 + 极短时间] 完成.....1
// --- 大约 1 秒后 ---
[ThreadPoolThreadID] 异步错误:测试异常 // (线程 ID 通常不是 1)
  • 同步调用 (HandleError) 一个返回 Task 的异步方法而不 await 它,会导致该方法的 try-catch 无法捕获异步操作中(await 之后)发生的异常。该异常会变成未处理的 Task 异常,通常导致程序崩溃。
  • 异步调用 (HandleErrorAsync) 使用 await 等待 Task,可以将异步操作中发生的异常传播回 await 点,从而允许外层的 try-catch 块捕获该异常。
  • 主线程的 "启动" 和 "完成" 消息会快速连续打印,因为异步调用不会阻塞主线程。实际的延迟和异常处理发生在后台。

题目6

请简述下述程序打印结果,以及原因?

public static void Action()
{
    Console.WriteLine($"{DateTime.Now}开始.....{Thread.CurrentThread.ManagedThreadId}");
    Task t1 = Task.Run(() => DoWork(1));
    Task t2 = Task.Run(() => DoWork(2));
    Task t3 = Task.Run(() => DoWork(3));
    Task t4 = Task.Run(() => DoWork(4));
 
    Task.WaitAll(t1, t2, t3, t4);
    Console.WriteLine($"{DateTime.Now}结束.....{Thread.CurrentThread.ManagedThreadId}");
}
 
static void DoWork(int id)
{
    _semaphore.Wait();
 
    try
    {
        Console.WriteLine($"{DateTime.Now}启动任务{id}.....{Thread.CurrentThread.ManagedThreadId}");
        Thread.Sleep(2000);
    }
    finally
    {
        _semaphore.Release();
    }
}

输出

[时间 T0] 开始.....1 // (假设主线程 ID 是 1)
// --- 几乎同时 ---
[时间 T0 + ε] 启动任务[ID A].....[线程池线程 ID X] // (ID A 是 1, 2, 3, 4 中的一个)
[时间 T0 + ε] 启动任务[ID B].....[线程池线程 ID Y] // (ID B 是 1, 2, 3, 4 中不同于 A 的一个)
// --- 大约 2 秒后 ---
[时间 T0 + 2s] 启动任务[ID C].....[线程池线程 ID Z] // (ID C 是剩余两个 ID 中的一个)
[时间 T0 + 2s] 启动任务[ID D].....[线程池线程 ID W] // (ID D 是最后一个 ID)
// --- 再大约 2 秒后 (总共约 4 秒后) ---
[时间 T0 + 4s] 结束.....1
  • SemaphoreSlim(2) 限制了同时能进入 try 块(即执行 2 秒 Sleep)的任务数量为 2 个。
  • 4 个任务并发启动,但只有 2 个能立即开始工作,另外 2 个必须等待。
  • 任务分成了两批执行,每批执行时间大约 2 秒。
  • Task.WaitAll 阻塞了主线程,直到所有 4 个任务(包括等待和执行的时间)都完成。
  • 因此,总耗时大约是 2 批 * 2 秒/批 = 4 秒。

题目7

请简述下述程序打印结果,以及原因?

public void Action()
{
    int a = 0;
    Parallel.For(0, 100000, (i) =>
    {
        a++;
    });
    Console.WriteLine($"总数{a}.....{Thread.CurrentThread.ManagedThreadId}");
}

输出

总数[一个小于100000的数字].....1 // (假设主线程 ID 是 1)
  • 在 100,000 次迭代中,这种竞争条件会发生很多次。每次发生,都会导致一次或多次递增操作的丢失。
  • 因此,当 Parallel.For 完成时(它会阻塞主线程直到所有迭代完成),a 的最终值将小于 100,000。
  • 具体丢失多少次递增取决于线程调度的具体时机、CPU 核心数量等因素,因此结果是非确定性的
  • 使用线程安全方式有两种
    • Interlocked.Increment(ref a) 这是最高效、最推荐的方式,它能原子性地完成递增操作。
    • lock 语句 使用锁来保护对 a 的访问,确保同一时间只有一个线程能执行 a++。

On this page