C#多线程编程实战:从Thread到async/await的完整指南

📅 2026/8/5 2:55:19
C#多线程编程实战:从Thread到async/await的完整指南
1. 多线程编程从“单车道”到“多车道”的思维跃迁在软件开发的世界里我们写的代码默认是在一条“单车道”上行驶的这条车道就是主线程。想象一下你正在用C#写一个桌面应用用户点击一个按钮程序开始处理一个耗时任务比如从网络下载一个大文件。如果只用主线程那么在整个文件下载完成之前整个界面都会“卡死”用户无法进行任何其他操作体验极差。这就是单线程模型的局限性它一次只能做一件事。多线程技术就是为我们的程序开辟多条“车道”让不同的任务可以并行或并发地执行。比如一条车道负责下载文件另一条车道负责响应用户的点击还有一条车道可以实时更新进度条。这样程序就能同时处理多个任务极大地提升了响应速度和资源利用率。在C#中实现多线程有多种方式从最基础的Thread类到更高级的Task并行库再到异步编程模型每一种都有其适用的场景和背后的设计哲学。理解这些方式不仅仅是记住几个API更是要理解它们如何管理线程的生命周期、如何协调线程间的协作、以及如何避免“翻车”——也就是线程安全问题和性能陷阱。对于任何希望构建高性能、高响应性应用的C#开发者来说多线程都是一项必须掌握的核心技能。2. 线程基础与核心概念解析在深入具体实现方式之前我们必须先打好地基理解几个关键概念。这就像学开车前得先知道油门、刹车和方向盘是干嘛的。2.1 进程、线程与应用程序域很多人容易混淆进程和线程。你可以把一个进程想象成一个独立的“工厂”。这个工厂有自己专属的场地内存空间、资源如打开的文件句柄和一套管理规则。一个C#程序运行起来操作系统就会为它创建一个进程。线程则是这个工厂里的“工人”。一个进程至少有一个工人主线程也可以有多个工人多线程。所有工人在同一个工厂里工作共享工厂的场地和资源即进程的内存空间。他们可以同时进行不同的生产任务。在C#中System.Threading.Thread类就是用来创建和管理这些“工人”的核心对象。应用程序域是.NET引入的一个更轻量级的隔离边界可以把它看作工厂里的一个“独立车间”。一个进程可以包含多个应用程序域它们比进程更轻量提供了某种程度的隔离但又比进程间通信开销小。不过在大多数日常的多线程编程中我们直接与线程和进程打交道更多。2.2 前台线程与后台线程这是.NET线程的一个重要分类直接关系到程序的退出行为。前台线程这种线程就像工厂里的“核心技工”。只要还有一个前台线程在运行整个工厂进程就不会关门即使主线程厂长已经下班了。默认情况下用Thread类创建的线程就是前台线程。后台线程这种线程是“临时工”或“辅助工”。当所有前台线程都结束时无论后台线程的任务是否完成进程都会强制结束所有后台线程也会被立即终止。你可以通过设置Thread.IsBackground true将一个线程设置为后台线程。注意对于执行关键任务如数据持久化、重要计算的线程务必设置为前台线程或确保有完善的生命周期管理否则可能因进程意外退出导致数据丢失或任务中断。2.3 线程状态与生命周期一个线程从诞生到结束会经历一系列状态了解这些状态对调试多线程程序至关重要。主要状态包括Unstarted线程对象已创建但尚未调用Start()方法。Running线程正在执行。WaitSleepJoin线程因为调用Sleep()、Wait()或Join()等方法而被阻塞正在等待某个条件。Stopped/Aborted线程已终止。通过Abort()方法强制终止线程是强烈不推荐的做法因为它可能破坏程序状态在.NET Core/.NET 5中甚至已被移除。Suspended/Resumed已过时不应再使用。理解这些状态能帮助你在程序“卡住”时快速判断是哪个线程在等待什么从而找到问题根源。3. 传统方式使用Thread类这是最原始、最直接的多线程开启方式它给了开发者最大的控制权但也要求开发者承担更多的管理责任。3.1 创建与启动线程使用Thread类你需要提供一个线程执行的方法入口。这个方法可以是一个无参的ThreadStart委托也可以是一个带一个object类型参数的ParameterizedThreadStart委托。// 方式一使用ThreadStart无参数 public static void DoWork() { Console.WriteLine($线程 {Thread.CurrentThread.ManagedThreadId} 正在工作...); Thread.Sleep(2000); // 模拟耗时操作 Console.WriteLine($线程 {Thread.CurrentThread.ManagedThreadId} 工作完成。); } // 在Main方法中 Thread thread1 new Thread(new ThreadStart(DoWork)); thread1.Start(); // 方式二使用ParameterizedThreadStart传递一个对象参数 public static void DoWorkWithParam(object data) { string message data as string; Console.WriteLine($线程收到消息{message}); } Thread thread2 new Thread(new ParameterizedThreadStart(DoWorkWithParam)); thread2.Start(Hello from main thread!);3.2 线程控制与协调创建线程后你经常需要控制它们。等待线程结束 (Join)主线程调用thread1.Join()那么主线程就会在这里阻塞直到thread1这个工人完成它的工作主线程才会继续往下走。这在需要等待子线程计算结果时非常有用。线程休眠 (Sleep)Thread.Sleep(1000)让当前线程暂停执行1秒钟。在此期间线程会让出CPU时间片给其他线程。但要谨慎使用特别是在UI线程中休眠会导致界面无响应。线程中断 (Interrupt)如果目标线程处于WaitSleepJoin状态调用其Interrupt()方法会抛出ThreadInterruptedException从而唤醒它。这是一种协作式的线程中断机制。3.3 Thread方式的优缺点与适用场景优点控制粒度细你可以精确控制线程的优先级(Priority)、是否是后台线程、文化(Culture)等属性。概念直观对于理解多线程的基本模型非常有帮助。缺点资源开销大创建和销毁一个操作系统线程的成本很高。频繁创建短生命周期的线程会导致性能严重下降。管理复杂线程池未出现时需要开发者手动管理线程的创建、回收和数量极易造成资源泄漏或线程爆炸。功能有限不易获取返回值也不方便处理复杂的延续任务和异常传播。适用场景需要长时间运行的、独立的后台任务并且你需要明确控制其前台/后台属性。学习多线程基础原理。在一些非常古老的代码库或对线程有特殊控制的边缘场景中。实操心得在现代C#开发中除非有非常特殊的理由否则不建议直接使用Thread类来开启新的并发任务。线程池(ThreadPool)和Task是更优的选择。4. 线程池管理线程的“智能调度中心”因为直接创建线程开销大.NET提供了线程池(System.Threading.ThreadPool)作为线程的“缓存池”和“调度中心”。线程池维护着一组预先创建好的工作线程当有任务到来时就从池中分配一个空闲线程来执行执行完毕后线程并不销毁而是回到池中等待下一个任务。这避免了频繁创建销毁线程的巨大开销。4.1 使用QueueUserWorkItem这是使用线程池最传统的方式。// 将工作项一个WaitCallback委托排队到线程池 ThreadPool.QueueUserWorkItem(state { // state 就是传入的参数 Console.WriteLine($线程池线程 {Thread.CurrentThread.ManagedThreadId} 正在处理{state}); Thread.Sleep(1000); }, Task Data); // 无参数版本 ThreadPool.QueueUserWorkItem(_ { Console.WriteLine(一个简单的线程池任务。); });这种方式简单快捷非常适合执行那些短小的、不需要返回结果、也不关心执行顺序的后台任务比如日志写入、发送通知等。4.2 线程池的配置与局限性线程池是自动管理的但它也有一些默认行为和限制线程数量线程池会根据系统负载CPU核心数等自动调整工作线程和I/O线程的数量上限。你也可以通过ThreadPool.SetMinThreads和SetMaxThreads进行有限调整但通常不建议。局限性无法获取返回值QueueUserWorkItem方法没有提供直接获取任务执行结果的机制。无法取消或监控状态你无法方便地取消一个已排队的工作项也无法直接查询它是正在运行、已完成还是等待中。不适合长任务如果所有线程池线程都被长时间运行的任务占用新的任务就需要等待可能影响系统响应性。线程池的设计初衷是处理大量短小的任务。正因为这些局限性在.NET Framework 4.0之后微软引入了更强大的Task Parallel Library (TPL)其底层也基于线程池但提供了丰富得多的功能。5. 现代首选Task并行库Task类及其相关的并行库是当今C#多线程和异步编程的基石。它代表一个异步操作比Thread更高级比ThreadPool.QueueUserWorkItem功能更完整。Task本身不直接映射到一个操作系统线程它是一个更抽象的工作单元由任务调度器默认基于线程池来安排执行。5.1 Task的创建与运行创建和启动一个Task有多种方式// 方式1使用Task.Run最常用适用于CPU密集型工作 Task task1 Task.Run(() { Console.WriteLine($Task 在线程 {Thread.CurrentThread.ManagedThreadId} 上运行。); // 模拟CPU计算 for (int i 0; i 1000000; i) { } }); // 默认情况下Task.Run使用的是线程池线程 // 方式2使用Task.Factory.StartNew提供更多配置选项 Task task2 Task.Factory.StartNew(() { Console.WriteLine(通过Factory创建的任务。); }, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); // 方式3直接实例化并启动较少用 Task task3 new Task(() Console.WriteLine(手动创建的任务)); task3.Start();Task.RunvsTask.Factory.StartNewTask.Run是在.NET 4.5引入的简化API它默认使用TaskScheduler.Default线程池调度器并且默认的TaskCreationOptions是DenyChildAttach。在绝大多数场景下Task.Run是首选因为它更简单且行为更合理。Task.Factory.StartNew提供了更精细的控制例如指定不同的任务调度器、任务创建选项等。但在不需要这些特殊配置时使用它可能因为选项配置不当比如忘记指定TaskScheduler而引入难以察觉的bug。5.2 处理任务结果与延续Task的强大之处在于它支持泛型可以返回结果并且可以方便地组织任务链。// 带返回值的Task Taskint calculateTask Task.Run(() { Thread.Sleep(1000); return 42; // 模拟计算结果 }); // 获取结果这会阻塞当前线程直到任务完成 int result calculateTask.Result; // 谨慎使用可能导致死锁 // 更好的方式是使用await见下文异步编程部分 // 任务延续ContinueWith Task continuationTask calculateTask.ContinueWith(previousTask { // previousTask就是前面的calculateTask Console.WriteLine($之前任务的结果是{previousTask.Result}); // 这里可以处理结果或开始下一个任务 }, TaskContinuationOptions.OnlyOnRanToCompletion); // 只有前一个任务成功完成时才执行 continuationTask.Wait(); // 等待延续任务完成ContinueWith允许你定义在一个任务完成后自动执行下一个任务这是构建异步工作流的基础。但它的回调风格基于委托在复杂链式调用时容易产生“回调地狱”因此更推荐使用async/await语法。5.3 任务取消与异常处理取消通过CancellationTokenSource和CancellationToken实现协作式取消。CancellationTokenSource cts new CancellationTokenSource(); CancellationToken token cts.Token; Task longRunningTask Task.Run(() { for (int i 0; i 100; i) { if (token.IsCancellationRequested) { // 清理资源然后退出 Console.WriteLine(任务被取消。); token.ThrowIfCancellationRequested(); // 抛出OperationCanceledException } Thread.Sleep(100); // 模拟工作 Console.WriteLine($进度{i}%); } }, token); // 在某个地方请求取消比如2秒后 Task.Delay(2000).ContinueWith(_ cts.Cancel()); try { await longRunningTask; // 等待任务如果被取消会抛出异常 } catch (OperationCanceledException) { Console.WriteLine(任务已成功取消。); }异常处理Task中的异常会被“包裹”在AggregateException中。当等待任务使用Wait(),Result或await时这个异常会被重新抛出。Task faultedTask Task.Run(() throw new InvalidOperationException(任务内部错误)); try { faultedTask.Wait(); } catch (AggregateException ae) { // 处理所有内部异常 foreach (var e in ae.InnerExceptions) { Console.WriteLine($捕获到异常{e.Message}); } } // 使用await时会直接抛出原始的InvalidOperationException而不是AggregateException这更符合直觉。6. 异步编程模型async与awaitasync和await关键字是C# 5.0引入的语法糖它们并没有创建新的线程机制而是基于Task提供了一种更直观、更类似于同步代码的方式来编写异步程序。其核心目的是释放调用线程特别是UI线程使其在等待I/O操作如文件读写、网络请求、数据库查询时不被阻塞从而提升程序的响应能力和吞吐量。6.1 async/await的工作原理当一个方法被标记为async时它通常返回Task或TaskT。方法内部的await关键字就像一个“暂停点”。执行到await表达式时如果等待的任务尚未完成方法会立即返回一个Task给调用者当前线程不会被阻塞可以去做其他事情比如处理UI事件。内部编译器会为这个async方法生成一个状态机。当await的任务完成后状态机会安排该方法的剩余部分在合适的上下文通常是原来的同步上下文如UI线程中继续执行。public async Taskstring DownloadStringAsync(string url) { using (HttpClient client new HttpClient()) { // 遇到await如果网络请求未完成方法返回Taskstring。 // UI线程被释放不会卡住。 string result await client.GetStringAsync(url); // 当网络请求完成剩余代码这里只是返回会在UI线程上继续执行。 return result; } } // 在UI事件处理程序中调用 private async void Button_Click(object sender, EventArgs e) { try { string data await DownloadStringAsync(https://api.example.com/data); textBox.Text data; // 此处在UI线程安全更新控件 } catch (Exception ex) { MessageBox.Show($下载失败{ex.Message}); } }6.2 与Task.Run的配合CPU密集型工作async/await本身不创建线程它擅长处理I/O密集型操作。对于CPU密集型工作如大量计算为了不阻塞UI线程我们通常结合Task.Run将计算工作卸载到线程池。// 错误的做法在async方法里做CPU密集型工作仍然会阻塞UI线程 public async Taskint CalculateHeavySync() { int sum 0; for (int i 0; i 1000000000; i) { sum i; } // 这会阻塞 return sum; } // 正确的做法使用Task.Run将CPU工作推到后台线程 public async Taskint CalculateHeavyAsync() { // 将CPU密集型计算封装到Task.Run中 return await Task.Run(() { int sum 0; for (int i 0; i 1000000000; i) { sum i; } return sum; }); // await会确保后续代码在原始上下文如UI线程中执行但计算本身在后台线程进行。 }6.3 配置上下文与避免死锁await默认会尝试在原始的“同步上下文”上恢复执行。在UI程序WPF, WinForms中这是UI线程非常方便更新控件。但在控制台程序或无UI的服务器端代码中这可能导致不必要的开销甚至死锁。.ConfigureAwait(false)告诉await你不需要回到原始上下文。这可以提升性能并避免在某些场景下的死锁。public async Taskstring GetDataAsync() { using (var client new HttpClient()) { // 在库代码中通常使用ConfigureAwait(false) string result await client.GetStringAsync(http://example.com).ConfigureAwait(false); // 这里不会回到调用者的同步上下文 return result; } }黄金法则在编写可重用的类库时对每一个await都使用.ConfigureAwait(false)除非你有明确理由需要回到原始上下文。在应用程序的顶层如UI事件处理器则不需要。死锁示例在UI线程上同步等待一个Task使用.Result或.Wait()而这个Task内部又await了需要回到UI线程才能完成的操作就会造成死锁。// 在UI线程上调用此方法会导致死锁 public string GetDataDeadlock() { Taskstring task GetDataAsync(); // 假设这个方法内部有await且没有.ConfigureAwait(false) return task.Result; // UI线程在这里阻塞等待任务完成。但任务完成需要回到UI线程而UI线程正被阻塞...死锁 }解决方案始终使用async/await“一路异步到底”避免混用同步阻塞调用.Result,.Wait()。7. 高级主题与并行循环除了启动独立任务.NET还提供了专门用于数据并行和任务并行的API它们能更高效地利用多核CPU。7.1 Parallel类简化数据并行System.Threading.Parallel类提供了For、ForEach和Invoke方法用于并行执行循环或一组操作。它内部使用任务和分区自动管理线程和负载均衡。// Parallel.For - 并行循环 int[] numbers Enumerable.Range(1, 1000000).ToArray(); long sum 0; Parallel.For(0, numbers.Length, i { // 注意这里需要对共享变量sum进行线程安全操作 Interlocked.Add(ref sum, numbers[i]); // 使用原子操作 }); Console.WriteLine($并行求和结果{sum}); // Parallel.ForEach - 并行遍历集合 var files Directory.GetFiles(C:\SomeFolder, *.txt); Parallel.ForEach(files, file { string content File.ReadAllText(file); Console.WriteLine($处理文件{file}, 长度{content.Length}); }); // Parallel.Invoke - 并行执行多个Action Parallel.Invoke( () { Console.WriteLine(任务A开始); Thread.Sleep(1000); Console.WriteLine(任务A结束); }, () { Console.WriteLine(任务B开始); Thread.Sleep(500); Console.WriteLine(任务B结束); }, () { Console.WriteLine(任务C开始); Thread.Sleep(800); Console.WriteLine(任务C结束); } );注意事项线程安全循环体内部如果访问共享资源如上面的sum变量必须使用锁lock或线程安全操作Interlocked类。顺序不保证Parallel循环的执行顺序是不确定的。开销对于非常小的循环体并行化的开销可能超过收益。通常用于计算密集或I/O密集且迭代次数多的场景。7.2 PLINQ并行LINQ查询PLINQ是LINQ to Objects的并行实现。只需在数据源后加上.AsParallel()后续的查询操作就可能以并行方式执行。int[] source Enumerable.Range(1, 10000000).ToArray(); // 顺序执行 var sequentialQuery from num in source where num % 2 0 select Math.Sqrt(num); // var results sequentialQuery.ToList(); // 顺序执行 // 并行执行 var parallelQuery from num in source.AsParallel() // 关键在这里 where num % 2 0 select Math.Sqrt(num); var results parallelQuery.ToList(); // 并行执行 // 强制并行执行并合并结果顺序 var orderedResults source.AsParallel() .AsOrdered() // 保持顺序 .Where(num num % 2 0) .Select(Math.Sqrt) .ToList(); // 使用ForAll直接处理不聚合结果 source.AsParallel() .Where(num num % 3 0) .ForAll(num Console.WriteLine(num)); // 直接并行输出顺序不确定PLINQ非常适合于对大型集合进行筛选、投影、聚合等操作。它自动处理分区、线程调度和结果合并。你可以通过.WithDegreeOfParallelism()来限制使用的最大核心数通过.WithCancellation()来支持取消。8. 线程安全与同步原语当多个线程访问共享资源变量、文件、集合等时如果不加控制就会发生竞态条件导致数据损坏、程序行为异常等线程安全问题。解决之道就是同步。8.1 锁机制lock/Monitorlock关键字是C#中最常用的同步机制它基于底层的Monitor类。private readonly object _lockObject new object(); // 专用锁对象 private int _sharedCounter 0; public void IncrementSafely() { lock (_lockObject) // 同一时刻只有一个线程能进入这个代码块 { _sharedCounter; // 其他需要同步的操作... } }重要规则使用私有引用类型对象作为锁如private readonly object _lockObj new object();。永远不要锁this、typeof(MyClass)或字符串这可能导致意外的死锁。锁的范围要尽可能小只锁住必须同步的临界区代码以减小性能影响。避免在锁内调用可能阻塞的外部方法或执行耗时操作这会让其他线程长时间等待。8.2 更轻量与灵活的锁SemaphoreSlim信号量用于控制同时访问某个资源的线程数量。比如只允许最多5个线程同时访问数据库连接池。private SemaphoreSlim _semaphore new SemaphoreSlim(5); // 初始5个许可 public async Task AccessResourceAsync() { await _semaphore.WaitAsync(); // 等待许可 try { // 访问受保护的资源 await DoDatabaseWorkAsync(); } finally { _semaphore.Release(); // 释放许可 } }ReaderWriterLockSlim读写锁。允许多个线程同时读但写操作是独占的。适用于读多写少的场景能显著提升性能。private ReaderWriterLockSlim _rwLock new ReaderWriterLockSlim(); private Dictionarystring, string _cache new Dictionarystring, string(); public string GetValue(string key) { _rwLock.EnterReadLock(); // 获取读锁 try { if (_cache.TryGetValue(key, out string value)) return value; return null; } finally { _rwLock.ExitReadLock(); } } public void SetValue(string key, string value) { _rwLock.EnterWriteLock(); // 获取写锁独占 try { _cache[key] value; } finally { _rwLock.ExitWriteLock(); } }8.3 无锁编程与并发集合完全避免锁是性能最高的方案。.NET提供了System.Threading.Interlocked类用于对简单类型int, long等进行原子操作。private int _counter 0; public void IncrementAtomically() { Interlocked.Increment(ref _counter); // 原子递增 // 类似还有Decrement, Add, Exchange, CompareExchange等 }对于更复杂的数据结构可以使用System.Collections.Concurrent命名空间下的线程安全集合它们在内部使用了高效的锁或无锁算法。ConcurrentDictionaryTKey, TValue线程安全的字典。ConcurrentQueueT线程安全的先进先出队列。ConcurrentStackT线程安全的后进先出栈。ConcurrentBagT线程安全的无序集合。ConcurrentBagint numbers new ConcurrentBagint(); Parallel.For(0, 100, i { numbers.Add(i * i); // 无需加锁线程安全 });8.4 常见死锁场景与排查死锁是指两个或更多线程互相等待对方持有的资源导致所有线程都无法继续执行。一个经典场景是“锁顺序死锁”。// 线程1 lock (lockA) { Thread.Sleep(10); lock (lockB) // 等待线程2释放lockB { // ... } } // 线程2 lock (lockB) { Thread.Sleep(10); lock (lockA) // 等待线程1释放lockA { // ... } }避免死锁的策略固定锁的获取顺序在所有线程中都按照相同的顺序如先A后B获取锁。使用超时Monitor.TryEnter(object, TimeSpan)或SemaphoreSlim.Wait(TimeSpan)可以在获取锁失败时超时返回而不是无限等待。减少锁的持有时间。使用更高级的同步构造如Mutex支持跨进程或Channel用于生产者-消费者模型等。排查死锁通常需要借助调试器或分析工具如Visual Studio的并行堆栈窗口来查看所有线程的调用栈和等待链。9. 实战一个简单的多线程下载器让我们综合运用以上知识构建一个简单的控制台多线程下载管理器。这个下载器可以同时下载多个文件并显示每个文件的下载进度。using System; using System.Collections.Concurrent; using System.Diagnostics; using System.Net.Http; using System.Threading.Tasks; public class MultiThreadDownloader { private readonly HttpClient _httpClient; private readonly ConcurrentDictionarystring, double _progress; public MultiThreadDownloader() { _httpClient new HttpClient(); _progress new ConcurrentDictionarystring, double(); } public async Task DownloadFilesAsync(string[] urls, string savePath) { var downloadTasks new Task[urls.Length]; for (int i 0; i urls.Length; i) { string url urls[i]; string fileName GetFileNameFromUrl(url); string filePath Path.Combine(savePath, fileName); // 为每个文件创建一个下载任务 downloadTasks[i] DownloadFileWithProgressAsync(url, filePath); } // 启动一个单独的任务来显示进度 var progressTask Task.Run(() DisplayProgress(_progress)); try { // 等待所有下载任务完成 await Task.WhenAll(downloadTasks); } finally { // 通知进度显示任务结束 _progress.TryAdd(__COMPLETED__, 1); await progressTask; // 等待进度显示任务结束 } Console.WriteLine(\n所有文件下载完成); } private async Task DownloadFileWithProgressAsync(string url, string filePath) { try { // 发起HEAD请求获取文件总大小不是所有服务器都支持 long? totalBytes await GetContentLengthAsync(url); _progress[filePath] 0; using (var response await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead)) using (var fileStream new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true)) using (var httpStream await response.Content.ReadAsStreamAsync()) { var buffer new byte[81920]; // 80KB缓冲区 long totalRead 0; int bytesRead; while ((bytesRead await httpStream.ReadAsync(buffer, 0, buffer.Length)) 0) { await fileStream.WriteAsync(buffer, 0, bytesRead); totalRead bytesRead; // 更新进度 if (totalBytes.HasValue) { double progress (double)totalRead / totalBytes.Value * 100; _progress[filePath] Math.Round(progress, 2); } else { // 无法获取总大小显示已下载字节数 _progress[filePath] totalRead; } } } _progress[filePath] 100; // 标记下载完成 } catch (Exception ex) { Console.WriteLine($下载 {url} 失败: {ex.Message}); _progress.TryRemove(filePath, out _); } } private async Tasklong? GetContentLengthAsync(string url) { try { using (var request new HttpRequestMessage(HttpMethod.Head, url)) using (var response await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead)) { return response.Content.Headers.ContentLength; } } catch { return null; // 如果HEAD请求失败返回null } } private void DisplayProgress(ConcurrentDictionarystring, double progress) { while (true) { Console.Clear(); // 清屏模拟进度更新 Console.WriteLine(下载进度); foreach (var kvp in progress) { if (kvp.Key __COMPLETED__) return; // 收到完成信号 if (kvp.Value is double percent percent 100) { Console.WriteLine($ {Path.GetFileName(kvp.Key)}: {percent}%); } else { Console.WriteLine($ {Path.GetFileName(kvp.Key)}: {kvp.Value} bytes); } } Thread.Sleep(200); // 每200毫秒刷新一次 } } private string GetFileNameFromUrl(string url) { return Path.GetFileName(new Uri(url).LocalPath) ?? downloaded.file; } } // 使用示例 class Program { static async Task Main(string[] args) { var downloader new MultiThreadDownloader(); string[] urls new string[] { https://example.com/largefile1.zip, https://example.com/largefile2.iso, https://example.com/document.pdf }; string saveDirectory C:\Downloads; Directory.CreateDirectory(saveDirectory); await downloader.DownloadFilesAsync(urls, saveDirectory); } }这个示例的关键点并发模型使用Task.WhenAll来并发执行多个下载任务这是典型的基于任务的异步并发。进度共享使用ConcurrentDictionarystring, double来线程安全地存储和更新每个文件的下载进度。多个下载任务生产者并发写入一个进度显示任务消费者读取。异步I/O使用HttpClient.GetAsync和流操作的ReadAsync/WriteAsync这是真正的异步I/O不会阻塞线程池线程能高效处理大量并发网络和文件操作。资源清理正确使用using语句确保HttpResponseMessage、FileStream等资源被及时释放。错误处理每个下载任务有独立的try-catch避免一个文件下载失败影响其他文件。通过这个实战项目你可以看到如何将Task、async/await、并发集合和线程安全等概念结合起来解决一个实际的问题。在实际开发中你还需要考虑更多细节如断点续传、下载速度限制、更友好的进度显示等。