当前位置: 首页 > news >正文

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方式的优缺点与适用场景

优点

  1. 控制粒度细:你可以精确控制线程的优先级(Priority)、是否是后台线程、文化(Culture)等属性。
  2. 概念直观:对于理解多线程的基本模型非常有帮助。

缺点

  1. 资源开销大:创建和销毁一个操作系统线程的成本很高。频繁创建短生命周期的线程会导致性能严重下降。
  2. 管理复杂:线程池未出现时,需要开发者手动管理线程的创建、回收和数量,极易造成资源泄漏或线程爆炸。
  3. 功能有限:不易获取返回值,也不方便处理复杂的延续任务和异常传播。

适用场景

  • 需要长时间运行的、独立的后台任务,并且你需要明确控制其前台/后台属性。
  • 学习多线程基础原理。
  • 在一些非常古老的代码库或对线程有特殊控制的边缘场景中。

实操心得:在现代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.SetMinThreadsSetMaxThreads进行有限调整,但通常不建议。
  • 局限性
    1. 无法获取返回值QueueUserWorkItem方法没有提供直接获取任务执行结果的机制。
    2. 无法取消或监控状态:你无法方便地取消一个已排队的工作项,也无法直接查询它是正在运行、已完成还是等待中。
    3. 不适合长任务:如果所有线程池线程都被长时间运行的任务占用,新的任务就需要等待,可能影响系统响应性。线程池的设计初衷是处理大量短小的任务。

正因为这些局限性,在.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.StartNew

  • Task.Run是在.NET 4.5引入的简化API,它默认使用TaskScheduler.Default(线程池调度器),并且默认的TaskCreationOptionsDenyChildAttach。在绝大多数场景下,Task.Run是首选,因为它更简单且行为更合理。
  • Task.Factory.StartNew提供了更精细的控制,例如指定不同的任务调度器、任务创建选项等。但在不需要这些特殊配置时,使用它可能因为选项配置不当(比如忘记指定TaskScheduler)而引入难以察觉的bug。

5.2 处理任务结果与延续

Task的强大之处在于它支持泛型,可以返回结果,并且可以方便地组织任务链。

// 带返回值的Task Task<int> 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 任务取消与异常处理

取消:通过CancellationTokenSourceCancellationToken实现协作式取消。

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(),Resultawait)时,这个异常会被重新抛出。

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与await

asyncawait关键字是C# 5.0引入的语法糖,它们并没有创建新的线程机制,而是基于Task提供了一种更直观、更类似于同步代码的方式来编写异步程序。其核心目的是释放调用线程,特别是UI线程,使其在等待I/O操作(如文件读写、网络请求、数据库查询)时不被阻塞,从而提升程序的响应能力和吞吐量。

6.1 async/await的工作原理

当一个方法被标记为async时,它通常返回TaskTask<T>。方法内部的await关键字就像一个“暂停点”。

  1. 执行到await表达式时,如果等待的任务尚未完成,方法会立即返回一个Task给调用者,当前线程不会被阻塞,可以去做其他事情(比如处理UI事件)。
  2. 内部,编译器会为这个async方法生成一个状态机。当await的任务完成后,状态机会安排该方法的剩余部分在合适的上下文(通常是原来的同步上下文,如UI线程)中继续执行。
public async Task<string> DownloadStringAsync(string url) { using (HttpClient client = new HttpClient()) { // 遇到await,如果网络请求未完成,方法返回Task<string>。 // 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 Task<int> CalculateHeavySync() { int sum = 0; for (int i = 0; i < 1000000000; i++) { sum += i; } // 这会阻塞! return sum; } // 正确的做法:使用Task.Run将CPU工作推到后台线程 public async Task<int> 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 Task<string> 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() { Task<string> 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类提供了ForForEachInvoke方法,用于并行执行循环或一组操作。它内部使用任务和分区,自动管理线程和负载均衡。

// 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结束"); } );

注意事项

  1. 线程安全:循环体内部如果访问共享资源(如上面的sum变量),必须使用锁(lock)或线程安全操作(Interlocked类)。
  2. 顺序不保证Parallel循环的执行顺序是不确定的。
  3. 开销:对于非常小的循环体,并行化的开销可能超过收益。通常用于计算密集或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/Monitor

lock关键字是C#中最常用的同步机制,它基于底层的Monitor类。

private readonly object _lockObject = new object(); // 专用锁对象 private int _sharedCounter = 0; public void IncrementSafely() { lock (_lockObject) // 同一时刻只有一个线程能进入这个代码块 { _sharedCounter++; // 其他需要同步的操作... } }

重要规则

  1. 使用私有引用类型对象作为锁(如private readonly object _lockObj = new object();)。永远不要锁thistypeof(MyClass)或字符串,这可能导致意外的死锁。
  2. 锁的范围要尽可能小,只锁住必须同步的临界区代码,以减小性能影响。
  3. 避免在锁内调用可能阻塞的外部方法或执行耗时操作,这会让其他线程长时间等待。

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 Dictionary<string, string> _cache = new Dictionary<string, 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命名空间下的线程安全集合,它们在内部使用了高效的锁或无锁算法。

  • ConcurrentDictionary<TKey, TValue>:线程安全的字典。
  • ConcurrentQueue<T>:线程安全的先进先出队列。
  • ConcurrentStack<T>:线程安全的后进先出栈。
  • ConcurrentBag<T>:线程安全的无序集合。
ConcurrentBag<int> numbers = new ConcurrentBag<int>(); 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 { // ... } }

避免死锁的策略

  1. 固定锁的获取顺序:在所有线程中,都按照相同的顺序(如先A后B)获取锁。
  2. 使用超时Monitor.TryEnter(object, TimeSpan)SemaphoreSlim.Wait(TimeSpan)可以在获取锁失败时超时返回,而不是无限等待。
  3. 减少锁的持有时间
  4. 使用更高级的同步构造,如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 ConcurrentDictionary<string, double> _progress; public MultiThreadDownloader() { _httpClient = new HttpClient(); _progress = new ConcurrentDictionary<string, 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 Task<long?> 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(ConcurrentDictionary<string, 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); } }

这个示例的关键点

  1. 并发模型:使用Task.WhenAll来并发执行多个下载任务,这是典型的基于任务的异步并发。
  2. 进度共享:使用ConcurrentDictionary<string, double>来线程安全地存储和更新每个文件的下载进度。多个下载任务(生产者)并发写入,一个进度显示任务(消费者)读取。
  3. 异步I/O:使用HttpClient.GetAsync和流操作的ReadAsync/WriteAsync,这是真正的异步I/O,不会阻塞线程池线程,能高效处理大量并发网络和文件操作。
  4. 资源清理:正确使用using语句确保HttpResponseMessageFileStream等资源被及时释放。
  5. 错误处理:每个下载任务有独立的try-catch,避免一个文件下载失败影响其他文件。

通过这个实战项目,你可以看到如何将Taskasync/await、并发集合和线程安全等概念结合起来,解决一个实际的问题。在实际开发中,你还需要考虑更多细节,如断点续传、下载速度限制、更友好的进度显示等。

http://www.jsqmd.com/news/1330428/

相关文章:

  • BarTender数据驱动打印入门:从Excel到批量标签的自动化实战
  • 问浙江路灯杆出口厂家哪家正规,看这三点就够了 - 热点品牌推荐
  • UE5蓝图构建动态监控系统:事件驱动架构与性能优化实战
  • 利用spacedesk实现平板无线副屏:零成本扩展Windows桌面
  • Android HAL硬件抽象层:从架构演进到Camera实战开发指南
  • Python网页数据抓取:从urllib/requests到pandas的实战指南
  • 告别语言障碍:PowerToys中文版让你的Windows效率工具真正说中文
  • 生成式与智能体AI认知能力缺陷分类体系
  • AI艺术收入分成平台实操指南:艺术家如何评估与参与
  • 2026 年现阶段洞头专业的双排链轮品牌哪个好,用它替代单排链轮,一年能省多少维修成本?老机修工看完直呼内行-隆驰机械配件 - 行业推荐官【认证】
  • 中国海警南海执法现场分析:警告用语、法律依据与媒体记录视角
  • Win10全局字体替换:安全修改注册表实现苹方字体美化方案
  • 保研面试准备指南:从学生思维到研究者思维的跨越
  • MATLAB绘图进阶:从静态图表到动态交互的完整指南
  • Zotero文献管理全攻略:从安装配置到Word引用上标问题解决
  • 2026 年 7 月新发布:崇左靠谱的精密冷拔管生产厂家深度剖析,用它做零件的人,居然都没被坑过?-鑫轩钢材 - 企业推荐管【认证】
  • 【2027最新】基于SpringBoot+Vue的学生评奖评优管理系统管理系统源码+MyBatis+MySQL
  • 机械臂运动学原理入门:正逆解、D-H 参数、轨迹规划基础
  • 程序员外包合同模板:从需求定义到付款验收的完整防坑指南
  • 2026 年新发布:大理有实力的428407 H型钢批发厂家联系方式,别再乱买同类物品了,它居然能解决你半年都搞不定的麻烦 - 鉴选官
  • 虾皮店群自动化管理系统:单机管理200+店铺零关联的底层架构
  • 从滚球控制系统解析PID双环串级与激光传感在机电一体化中的实战应用
  • Docker存储卷核心原理与生产环境实践指南
  • Unity技能系统架构实战:行为树与状态机的融合设计与优化
  • 2026 年佛山正规的透明声屏障定制电话,家住马路边的人,居然能把噪音降到没感觉?这玩意儿到底是怎么做的?-永舟丝网 - 企业推荐官-
  • 如何快速掌握Illustrator脚本工具:设计师的终极效率提升指南
  • Windows笔记本睡眠发热耗电快?现代待机问题排查与S3睡眠恢复指南
  • Redis桌面管理终极指南:AnotherRedisDesktopManager完整使用教程
  • 微积分核心概念辨析:驻点、极值点与拐点的本质区别与判断方法
  • Flink反压机制深度解析:从原理到实战调优