C# 多线程入门指南:拥抱并发,提升性能与响应性
随着现代计算机硬件的发展,特别是多核处理器的普及,单线程应用程序已经越来越难以充分利用计算资源,也无法满足用户对于流畅、无阻塞用户界面的需求。多线程(Multi-threading)技术应运而生,它允许程序同时执行多个代码路径(线程),极大地提升了应用程序的性能、响应性和吞吐量。
对于初学者而言,多线程可能是一个既令人兴奋又充满挑战的领域。理解其核心概念、掌握创建和管理线程的方法、以及解决并发带来的潜在问题(如竞态条件和死锁)是入门的关键。本文将深入浅出地带你走进 C# 多线程的世界,从基础概念讲起,逐步介绍不同的线程实现方式以及常用的同步技术。
第一章:多线程基础概念
在深入代码之前,我们需要先理解一些基本概念。
1.1 什么是进程(Process)?
进程是操作系统分配资源的基本单位。一个进程通常包含一个独立的内存空间、文件句柄、安全凭据等。当我们启动一个应用程序时,操作系统就会为其创建一个进程。例如,你打开一个 Word 文档,就是一个 Word 进程;打开一个浏览器标签页,通常是该浏览器进程下的一个线程(或多个线程,取决于浏览器实现)。进程之间是相互独立的,一个进程的崩溃通常不会影响其他进程。
1.2 什么是线程(Thread)?
线程是进程内的执行单元,是 CPU 调度的基本单位。一个进程可以包含一个或多个线程。与进程不同,同一进程内的所有线程共享该进程的资源,比如内存空间、打开的文件等。正是因为共享资源,线程之间的通信和协作更加方便,但也带来了同步的问题。
想象一下一个公司(进程):公司里有不同的部门(资源),比如销售部、研发部、财务部。每个部门都有自己的工作任务。多线程就像是公司里的多位员工(线程)在同时开展工作。他们共享公司的办公空间、网络、打印机等资源,但每个人执行不同的具体任务。
1.3 并发(Concurrency) vs. 并行(Parallelism)
- 并发 (Concurrency): 指的是系统能够处理多个任务。在单核 CPU 上,并发是通过时间片轮转实现的,CPU 在不同线程之间快速切换,使得每个线程都能向前推进一点,宏观上看就像是同时在运行,但微观上看,在任何一个时刻,只有一个线程真正在 CPU 上执行。
- 并行 (Parallelism): 指的是系统能够同时执行多个任务。这需要多核 CPU 的支持,不同的线程可以在不同的 CPU 核上同时执行。
因此,多线程是实现并发的一种手段,在多核环境下,多线程可以进一步实现并行,从而真正缩短总的执行时间。
1.4 为什么需要多线程?
- 提升应用程序响应性: 将耗时操作(如文件读写、网络请求、复杂计算)放在后台线程中执行,主线程(通常是UI线程)就不会被阻塞,用户界面保持流畅可响应。
- 提升性能和吞吐量: 充分利用多核处理器的计算能力,并行处理多个任务,加快程序整体执行速度。
- 简化程序设计: 某些问题天然适合分解为多个独立的、可以并行或并发执行的子任务。
- 实现后台任务: 执行一些不需要用户直接交互的监控、维护、日志记录等任务。
第二章:使用 System.Threading.Thread
类(传统方式)
System.Threading.Thread
类是 .NET Framework 和 .NET Core 中最基本的线程创建方式。虽然现代 C# 开发中更推荐使用 Task 和 async/await,但理解 Thread
类有助于理解底层机制。
2.1 创建和启动线程
使用 Thread
类创建线程,你需要为其指定一个委托,这个委托包含了线程要执行的代码。这个委托可以是 ThreadStart
或 ParameterizedThreadStart
。
ThreadStart
: 用于线程执行的方法不需要任何参数。ParameterizedThreadStart
: 用于线程执行的方法需要一个参数(类型为object
)。
示例 2.1.1: 使用 ThreadStart
“`csharp
using System;
using System.Threading;
public class BasicThreadExample
{
public static void DoWork()
{
Console.WriteLine($”线程 {Thread.CurrentThread.ManagedThreadId} 正在执行工作…”);
// 模拟耗时操作
Thread.Sleep(2000);
Console.WriteLine($”线程 {Thread.CurrentThread.ManagedThreadId} 工作完成.”);
}
public static void Main(string[] args)
{
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 开始.");
// 1. 创建一个 Thread 对象,传入 ThreadStart 委托
Thread workerThread = new Thread(DoWork);
// 2. 启动线程
workerThread.Start();
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 继续执行...");
// 防止主线程过早结束,可以看到子线程的输出
// 在实际应用中,你可能需要更复杂的同步或等待机制
// Thread.Sleep(3000); // 简单等待
// 或者使用 Join 等待线程结束 (见下一节)
// workerThread.Join();
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 结束.");
}
}
“`
运行上述代码,你会看到主线程启动后立即输出“主线程继续执行…”,而子线程的输出会在稍后出现,证明了多线程是并发执行的。
示例 2.1.2: 使用 ParameterizedThreadStart
“`csharp
using System;
using System.Threading;
public class ParameterizedThreadExample
{
public static void DoWorkWithParameter(object data)
{
string message = data as string; // 注意参数是 object 类型,需要强制转换
Console.WriteLine($”线程 {Thread.CurrentThread.ManagedThreadId} 收到参数: {message}”);
Thread.Sleep(2000);
Console.WriteLine($”线程 {Thread.CurrentThread.ManagedThreadId} 工作完成.”);
}
public static void Main(string[] args)
{
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 开始.");
// 1. 创建 Thread 对象,传入 ParameterizedThreadStart 委托
Thread workerThread = new Thread(DoWorkWithParameter);
// 2. 启动线程并传入参数
string parameter = "Hello from Main!";
workerThread.Start(parameter); // 将参数传递给 Start 方法
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 继续执行...");
// workerThread.Join(); // 等待子线程完成
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 结束.");
}
}
“`
使用 ParameterizedThreadStart
时,传递的参数必须是 object
类型。如果需要传递多个参数或强类型参数,通常会创建一个包含所有参数的类或结构体,然后将该对象作为参数传递。
2.2 等待线程完成:Join()
Thread.Join()
方法可以让当前线程(调用 Join
的线程,比如主线程)等待另一个线程(被调用 Join
方法的线程)执行完毕后再继续执行。
示例 2.2: 使用 Join
“`csharp
using System;
using System.Threading;
public class JoinExample
{
public static void DoWork()
{
Console.WriteLine($”线程 {Thread.CurrentThread.ManagedThreadId} 开始工作…”);
Thread.Sleep(3000); // 模拟耗时操作
Console.WriteLine($”线程 {Thread.CurrentThread.ManagedThreadId} 工作完成.”);
}
public static void Main(string[] args)
{
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 开始.");
Thread workerThread = new Thread(DoWork);
workerThread.Start();
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 正在等待 workerThread 完成...");
// 主线程等待 workerThread 结束
workerThread.Join();
Console.WriteLine($"workerThread 已完成,主线程 {Thread.CurrentThread.ManagedThreadId} 继续执行.");
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 结束.");
}
}
“`
运行此代码,你会看到主线程会在输出“正在等待 workerThread 完成…”后暂停,直到 workerThread 完成并输出其完成消息,主线程才会继续执行并输出最后两条消息。
2.3 线程的状态
一个线程在其生命周期中会经历多种状态,如 Unstarted(未启动)、Running(运行中)、WaitSleepJoin(等待、休眠或加入中)、Stopped(已停止)等。你可以通过 Thread.ThreadState
属性获取线程的当前状态(尽管这在实践中不常用,且不推荐基于状态进行复杂的控制)。
2.4 终止线程(⚠️ 注意)
历史上,Thread
类提供了 Thread.Abort()
方法来强制终止线程。然而,Abort
方法非常危险,它会在线程执行过程中抛出一个特殊的异常 ThreadAbortException
,可能发生在任何时刻,导致线程无法正常清理资源(如释放锁、关闭文件等),从而引发不可预测的错误甚至进程崩溃。Thread.Abort()
在现代 .NET 中已被标记为过时且不应使用。
推荐的协作式终止线程的方式是使用 取消令牌 (Cancellation Token),这在 Task 为中心的异步编程中非常常见和有效,我们将在 Task 部分简要提及。对于传统的 Thread
类,一种简单的协作方式是在线程函数中定期检查一个标志位,如果标志位被设置,则线程自行退出。
示例 2.4: 协作式取消 (简单示例)
“`csharp
using System;
using System.Threading;
public class CooperativeCancellationExample
{
private static bool _cancelRequested = false;
public static void DoWork()
{
Console.WriteLine($"线程 {Thread.CurrentThread.ManagedThreadId} 开始工作...");
while (!_cancelRequested)
{
Console.WriteLine($"线程 {Thread.CurrentThread.ManagedThreadId} 正在进行中...");
Thread.Sleep(500); // 模拟工作,并提供检查取消信号的机会
}
Console.WriteLine($"线程 {Thread.CurrentThread.ManagedThreadId} 收到取消信号,正在退出.");
}
public static void Main(string[] args)
{
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 开始.");
Thread workerThread = new Thread(DoWork);
workerThread.Start();
Console.WriteLine("按任意键请求取消...");
Console.ReadKey();
_cancelRequested = true; // 设置取消标志
workerThread.Join(); // 等待线程退出
Console.WriteLine($"workerThread 已退出,主线程 {Thread.CurrentThread.ManagedThreadId} 结束.");
}
}
“`
这种方式要求工作线程中的代码主动、定期地检查取消标志,并做出响应。
总结 System.Threading.Thread
:
* 是最底层的线程创建方式。
* 提供了细粒度的控制(优先级、后台/前台线程等)。
* 管理复杂(需要手动创建、启动、等待)。
* 终止线程困难且危险(应避免 Abort
)。
* 不具备内置的返回值获取或异常传播机制(需要手动实现)。
由于上述缺点,特别是在处理大量短时任务或需要管理复杂依赖关系时,直接使用 Thread
类会变得非常繁琐。
第三章:使用线程池(Thread Pool)
创建和销毁线程是有开销的。如果应用程序需要频繁地创建和执行许多短小的任务,反复创建和销毁线程会消耗大量系统资源,反而降低效率。线程池应运而生,它维护一个线程的集合,这些线程可以在需要时重复使用,避免了频繁创建和销毁的开销。
.NET 提供了一个内置的线程池,可以通过 System.Threading.ThreadPool
类来访问。
3.1 ThreadPool.QueueUserWorkItem
最常用的线程池方法是 ThreadPool.QueueUserWorkItem
。它将一个工作项(由一个 WaitCallback
委托表示)放入线程池队列中。当线程池中有可用线程时,就会从队列中取出工作项并执行。
WaitCallback
委托接受一个 object
参数。
示例 3.1: 使用 ThreadPool.QueueUserWorkItem
“`csharp
using System;
using System.Threading;
public class ThreadPoolExample
{
// 工作方法,接受一个 object 参数
public static void DoWork(object state)
{
int taskNumber = (int)state; // 将参数转换为实际类型
Console.WriteLine($”线程池线程 {Thread.CurrentThread.ManagedThreadId} 开始执行任务 {taskNumber}…”);
Thread.Sleep(1000); // 模拟工作
Console.WriteLine($”线程池线程 {Thread.CurrentThread.ManagedThreadId} 完成任务 {taskNumber}.”);
}
public static void Main(string[] args)
{
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 开始.");
// 向线程池队列添加 5 个工作项
for (int i = 1; i <= 5; i++)
{
// 将 DoWork 方法和参数 i 加入线程池队列
ThreadPool.QueueUserWorkItem(DoWork, i);
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId}: 已将任务 {i} 加入队列.");
}
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 继续执行.");
// 因为 ThreadPool 的线程是后台线程,主线程结束后它们也会结束
// 为了看到所有输出,需要等待一下
Thread.Sleep(6000); // 简单等待所有任务完成
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 结束.");
}
}
“`
运行此代码,你会发现任务不一定按照加入队列的顺序执行,而且多个任务可能会由同一个线程池线程执行。线程池会根据系统的负载动态调整其线程数量。
线程池的优点:
* 降低了线程创建和销毁的开销。
* 自动管理线程的生命周期和复用。
* 限制了系统中同时运行的线程数量,避免资源耗尽。
线程池的缺点:
* 对线程的控制粒度较粗(不能设置优先级、名称等)。
* 无法直接获取工作项的返回值或捕获异常(需要额外的代码处理)。
* 难以等待特定的工作项完成。
正因为这些限制,尤其是在需要更灵活的任务管理、结果返回、异常处理以及组合多个异步操作的场景下,Task 成为更优的选择。
第四章:现代异步编程:Task 和 Async/Await
从 .NET Framework 4.0 开始引入的 Task Parallel Library (TPL) 和 .NET Framework 4.5 (C# 5.0) 开始引入的 async/await 关键字,彻底改变了 C# 中的异步和并发编程方式。Task 代表了一个异步操作,它可能正在进行、已完成或已取消。async/await 模式则提供了一种更自然、更易读的方式来编写异步代码,使得异步代码看起来像同步代码一样。
4.1 Task 的基本使用
Task
类(在 System.Threading.Tasks
命名空间下)是对异步操作的一种更高级的抽象。它不仅可以用来表示在线程池线程上执行的 CPU 密集型任务,也可以表示 I/O 密集型任务(如文件读写、网络请求,这些操作在等待时不会占用 CPU 线程)。
4.1.1 Task.Run()
Task.Run()
是启动一个 CPU 密集型任务并让其在线程池上执行的最简单、推荐的方式。它返回一个 Task
对象。
示例 4.1.1: 使用 Task.Run
“`csharp
using System;
using System.Threading;
using System.Threading.Tasks;
public class TaskRunExample
{
public static void DoWork()
{
Console.WriteLine($”Task 线程 {Thread.CurrentThread.ManagedThreadId} 开始工作…”);
Thread.Sleep(2000); // 模拟工作
Console.WriteLine($”Task 线程 {Thread.CurrentThread.ManagedThreadId} 完成工作.”);
}
public static void Main(string[] args)
{
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 开始.");
// 使用 Task.Run 启动一个工作
Task task = Task.Run(() => DoWork()); // 使用 Lambda 表达式包裹方法调用
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 继续执行...");
// 可以使用 task.Wait() 等待任务完成 (类似于 Thread.Join())
// task.Wait(); // 阻塞主线程直到任务完成
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 结束.");
// 注意:如果主线程没有等待,并且它结束得太快,可能看不到 Task 的全部输出
// 在控制台应用中,通常需要等待 Task 完成
task.Wait(); // 在示例中确保 Task 完成
}
}
“`
4.1.2 Task<TResult>
获取返回值
如果任务需要返回结果,可以使用 Task<TResult>
。
示例 4.1.2: Task<TResult>
获取返回值
“`csharp
using System;
using System.Threading;
using System.Threading.Tasks;
public class TaskWithResultExample
{
public static int CalculateSum(object limit)
{
int n = (int)limit;
Console.WriteLine($”Task<{typeof(int).Name}> 线程 {Thread.CurrentThread.ManagedThreadId} 开始计算 1 到 {n} 的和…”);
long sum = 0;
for (int i = 1; i <= n; i++)
{
sum += i;
Thread.Sleep(10); // 模拟计算
}
Console.WriteLine($”Task<{typeof(int).Name}> 线程 {Thread.CurrentThread.ManagedThreadId} 完成计算.”);
return (int)sum; // 假设和不会超过 int 范围
}
public static void Main(string[] args)
{
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 开始.");
int calculationLimit = 100;
// 使用 Task.Run 启动一个返回 int 的任务
Task<int> sumTask = Task.Run(() => CalculateSum(calculationLimit));
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 继续执行...");
// 获取任务结果。Value 属性会阻塞当前线程,直到任务完成并返回结果。
int result = sumTask.Result; // sumTask.Result 也会捕获并重新抛出任务中的异常
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId}: 计算结果是 {result}");
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 结束.");
}
}
“`
4.1.3 取消 Task (CancellationTokenSource
)
Task 提供了标准的协作式取消机制,使用 CancellationTokenSource
和 CancellationToken
。
“`csharp
using System;
using System.Threading;
using System.Threading.Tasks;
public class TaskCancellationExample
{
public static void DoCancellableWork(CancellationToken cancellationToken)
{
Console.WriteLine($”可取消 Task 线程 {Thread.CurrentThread.ManagedThreadId} 开始工作…”);
try
{
for (int i = 0; i < 10; i++)
{
// 检查是否已请求取消
cancellationToken.ThrowIfCancellationRequested();
Console.WriteLine($"可取消 Task 线程 {Thread.CurrentThread.ManagedThreadId} 正在进行 {i}...");
Thread.Sleep(500); // 模拟工作
}
Console.WriteLine($"可取消 Task 线程 {Thread.CurrentThread.ManagedThreadId} 工作完成.");
}
catch (OperationCanceledException)
{
Console.WriteLine($"可取消 Task 线程 {Thread.CurrentThread.ManagedThreadId}: 收到取消请求,操作已取消.");
}
catch (Exception ex)
{
Console.WriteLine($"可取消 Task 线程 {Thread.CurrentThread.ManagedThreadId}: 发生异常 - {ex.Message}");
}
}
public static async Task Main(string[] args) // 注意 Main 方法可以使用 async Task
{
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 开始.");
// 创建一个 CancellationTokenSource
using (var cts = new CancellationTokenSource())
{
CancellationToken token = cts.Token;
// 使用 Task.Run 启动一个任务,并传递 CancellationToken
Task workerTask = Task.Run(() => DoCancellableWork(token), token); // 也可以将 token 传给 Task.Run 本身
Console.WriteLine("按任意键请求取消...");
Console.ReadKey();
// 请求取消
cts.Cancel();
try
{
// 使用 await 等待任务完成 (await 不会阻塞主线程)
await workerTask; // 如果 Task 是取消状态,await 会抛出 OperationCanceledException
}
catch (OperationCanceledException)
{
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId}: 任务已取消.");
}
catch (Exception ex)
{
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId}: 任务发生异常 - {ex.Message}");
}
} // cts 会被释放
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 结束.");
}
}
“`
4.1.4 async
和 await
(简介)
async
和 await
关键字并不是用来创建新线程的(Task.Run
或其他方法负责创建或使用线程)。它们是用来简化异步操作完成后延续代码的编写方式。
async
修饰符用于标记一个方法是异步方法,它内部可以使用await
关键字。await
关键字用于等待一个 Task 完成。当遇到await
时,如果被等待的 Task 尚未完成,当前方法会立即返回(释放当前线程),而不会阻塞线程。当 Task 完成后,被await
的方法的剩余部分(称为“延续”)会在合适的上下文中继续执行。
这对于 I/O 密集型操作尤其重要,因为等待 I/O 时线程可以被释放去处理其他任务,而不是空闲等待。对于 CPU 密集型任务,通常结合 Task.Run
使用 await
,如上一个取消示例所示,await workerTask
等待 Task.Run
启动的任务完成,但 await
本身不会阻塞主线程(如果它是在支持异步上下文的UI或ASP.NET环境中)。
示例 4.1.4: 简单 async
/ await
“`csharp
using System;
using System.Threading;
using System.Threading.Tasks;
public class AsyncAwaitExample
{
// 模拟一个异步操作
public static async Task SimulateAsyncTask()
{
Console.WriteLine($”SimulateAsyncTask 开始,线程 ID: {Thread.CurrentThread.ManagedThreadId}”);
await Task.Delay(2000); // await Task.Delay 不会阻塞当前线程
Console.WriteLine($”SimulateAsyncTask 结束,线程 ID: {Thread.CurrentThread.ManagedThreadId}”); // 继续可能在不同线程
}
public static async Task Main(string[] args) // Main 方法现在可以是 async Task
{
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 开始.");
// await 一个异步操作
await SimulateAsyncTask(); // 主线程在此处不会阻塞,而是返回给调用者(操作系统)
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 结束.");
// 由于 Main 方法是 async Task,它会等待内部的 Task 完成
// 如果 Main 是 void 类型,它可能在 SimulateAsyncTask 完成前就退出了
}
}
“`
总结 Task 和 Async/Await:
* 是现代 C# 异步和并发编程的首选方式。
* 提供了更高级的抽象来表示异步操作。
* Task.Run
方便地在线程池上执行 CPU 密集型任务。
* Task<TResult>
支持获取返回值。
* 内置了标准的取消机制 (CancellationTokenSource
/CancellationToken
)。
* async
/await
极大地简化了异步代码的编写和流程控制。
* await
关键字在等待异步操作时不会阻塞线程(对于 I/O 密集型或在支持异步上下文的UI/Web应用中)。
第五章:线程同步与共享数据问题
多线程的核心挑战之一是管理共享数据。由于多个线程可以同时访问和修改同一块内存,如果不加以控制,就可能出现竞态条件(Race Condition),导致程序行为不确定或产生错误结果。
5.1 竞态条件 (Race Condition)
竞态条件发生在多个线程访问和操作共享数据,并且最终结果取决于线程执行的相对顺序时。
示例 5.1: 竞态条件 (无同步)
“`csharp
using System;
using System.Threading;
using System.Threading.Tasks;
public class RaceConditionExample
{
private static int _counter = 0;
private const int Iterations = 100000;
public static void IncrementCounter()
{
for (int i = 0; i < Iterations; i++)
{
// 这里的 _counter++ 实际上不是一个原子操作
// 它可能包含:
// 1. 读取 _counter 的当前值
// 2. 将值加 1
// 3. 将新值写回 _counter
_counter++;
}
Console.WriteLine($"线程 {Thread.CurrentThread.ManagedThreadId} 完成 IncrementCounter.");
}
public static void Main(string[] args)
{
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 开始.");
_counter = 0; // 重置计数器
// 创建两个线程并发递增计数器
Task task1 = Task.Run(() => IncrementCounter());
Task task2 = Task.Run(() => IncrementCounter());
// 等待两个任务都完成
Task.WaitAll(task1, task2);
Console.WriteLine($"所有线程完成.");
// 期望的结果是 Iterations * 2
Console.WriteLine($"最终计数器值: {_counter}"); // 实际值可能小于期望值
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 结束.");
}
}
“`
运行上述代码多次,你会发现最终的 _counter
值几乎总是小于 Iterations * 2
(200000)。这是因为当一个线程读取 _counter
的值后,在它将递增后的值写回之前,另一个线程可能已经读取了相同的旧值并完成了自己的递增。结果是某次递增丢失了。
5.2 同步机制:lock
关键字
为了解决竞态条件,我们需要确保在任何时刻,只有一个线程能够访问和修改共享资源。这称为创建临界区 (Critical Section)。C# 提供了多种同步机制,其中最简单、最常用的是 lock
关键字。
lock
关键字用于获取一个对象的互斥锁。在同一个对象的锁被释放之前,其他试图获取该对象锁的线程会被阻塞。
示例 5.2: 使用 lock
解决竞态条件
“`csharp
using System;
using System.Threading;
using System.Threading.Tasks;
public class LockExample
{
private static int _counter = 0;
private const int Iterations = 100000;
private static readonly object _lockObject = new object(); // 用于 lock 的同步对象
public static void IncrementCounter()
{
for (int i = 0; i < Iterations; i++)
{
// 使用 lock 确保对 _counter 的操作是原子的
lock (_lockObject) // 获取 _lockObject 的锁
{
_counter++; // 临界区:只有持有锁的线程才能执行这里的代码
} // 释放 _lockObject 的锁 (lock 块结束时自动释放)
}
Console.WriteLine($"线程 {Thread.CurrentThread.ManagedThreadId} 完成 IncrementCounter.");
}
public static void Main(string[] args)
{
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 开始.");
_counter = 0; // 重置计数器
Task task1 = Task.Run(() => IncrementCounter());
Task task2 = Task.Run(() => IncrementCounter());
Task.WaitAll(task1, task2);
Console.WriteLine($"所有线程完成.");
// 使用 lock 后,结果应该总是 Iterations * 2
Console.WriteLine($"最终计数器值: {_counter}"); // 实际值应该总是 200000
Console.WriteLine($"主线程 {Thread.CurrentThread.ManagedThreadId} 结束.");
}
}
“`
运行这段代码,你会发现最终的 _counter
值总是正确的 200000。lock
确保了 _counter++
操作的原子性。
使用 lock
的注意事项:
* lock
后面跟着的必须是一个引用类型的对象(不能是值类型或 null)。通常创建一个 private readonly object
作为锁对象。
* 不要锁定公共可访问的对象(如 this
、typeof(MyClass)
或字符串字面量),因为这可能导致外部代码意外地锁定同一个对象,引发死锁或性能问题。
* 锁的粒度很重要。锁定太大的代码块会限制并发性,锁定太小的代码块可能无法覆盖整个临界区。
5.3 其他同步原语 (Synchronization Primitives)
除了 lock
,.NET 还提供了其他更高级或适用于特定场景的同步机制:
Monitor
类:lock
关键字实际上是Monitor
类的语法糖。Monitor
提供了更灵活的功能,如Enter
(获取锁)、Exit
(释放锁)、Wait
(释放锁并等待信号)、Pulse
/PulseAll
(发送信号)。通常在需要等待/通知模式时使用Monitor
或ManualResetEvent/AutoResetEvent
。Mutex
(互斥量): 类似于lock
,但Mutex
可以用于跨多个进程的同步。Semaphore
/SemaphoreSlim
(信号量): 限制同时可以访问某个资源的线程数量。SemaphoreSlim
是Semaphore
的轻量级版本,适用于进程内的同步。ReaderWriterLockSlim
: 允许多个线程同时读取共享资源,但在写入时只允许一个线程访问。适用于读多写少的场景。Interlocked
类: 提供对整数类型变量进行原子操作(如递增、递减、交换、比较并交换)的静态方法。比使用lock
进行简单的原子操作效率更高。在上面的竞态条件示例中,如果只是简单递增/递减,可以使用Interlocked.Increment(ref _counter)
。Concurrent
集合类 (System.Collections.Concurrent
): 提供了线程安全的集合(如ConcurrentBag<T>
,ConcurrentQueue<T>
,ConcurrentDictionary<TKey, TValue>
),在许多情况下可以直接使用它们,而无需手动加锁。
对于初学者,掌握 lock
通常就足以应对很多并发场景。随着深入学习,再逐步了解并使用其他同步原语。
5.4 死锁 (Deadlock)
死锁是多线程编程中一个常见且棘手的问题。当两个或多个线程互相持有对方所需的资源(通常是锁),并且都在等待对方释放资源时,就会发生死锁。
死锁发生的四个必要条件 (Coffman 条件):
1. 互斥 (Mutual Exclusion): 资源不能被多个线程共享,一次只能被一个线程使用。
2. 持有并等待 (Hold and Wait): 线程持有一个或多个资源,同时等待获取其他已经被别的线程持有的资源。
3. 不可剥夺 (No Preemption): 资源不能被强制从持有它的线程那里抢占,只能由持有资源的线程自愿释放。
4. 循环等待 (Circular Wait): 存在一个线程等待链,其中每个线程都在等待链中的下一个线程所持有的资源。
示例:简单的概念死锁
“`csharp
// 线程 1:
lock (lockA)
{
// 执行一些操作
lock (lockB) // 尝试获取 lockB
{
// 执行操作
}
}
// 线程 2:
lock (lockB)
{
// 执行一些操作
lock (lockA) // 尝试获取 lockA
{
// 执行操作
}
}
“`
如果线程 1 获取了 lockA
,同时线程 2 获取了 lockB
,然后线程 1 尝试获取 lockB
(已经被线程 2 持有),线程 2 尝试获取 lockA
(已经被线程 1 持有),两个线程就会互相等待,形成死锁。
避免死锁的策略:
* 避免嵌套锁: 尽量不要在一个锁内部尝试获取另一个锁。如果必须,确保所有线程以相同的顺序获取锁。
* 避免不必要的锁: 只在真正需要保护共享资源的临界区使用锁。
* 设置锁的超时时间: 使用 Monitor.TryEnter
或 Mutex.WaitOne
的带超时参数的版本,如果无法在指定时间内获取锁,则放弃或采取其他恢复措施。
* 检测死锁: 在复杂系统中,可以使用工具或技术来检测死锁的可能性。
死锁通常难以调试,因为它们依赖于线程执行的时序,可能只在特定条件下发生。
第六章:UI 线程与后台线程交互
在桌面应用程序(如 WPF 或 Windows Forms)中,用户界面元素(控件)通常只能由创建它们的线程访问和修改,这个线程就是 UI 线程。如果在后台线程中直接尝试修改 UI 控件,会导致“跨线程操作无效”的异常。
这是因为 UI 控件通常不是线程安全的,直接从多个线程访问会导致不可预测的行为。UI 框架强制要求所有对 UI 的操作都必须在 UI 线程上执行,以保证线程安全。
要从后台线程更新 UI,你需要将更新操作调度 (marshal) 回到 UI 线程。不同的 UI 框架提供不同的机制:
- Windows Forms: 使用
Control.Invoke
或Control.BeginInvoke
。 - WPF: 使用
Dispatcher.Invoke
或Dispatcher.BeginInvoke
。 - UWP: 使用
CoreDispatcher.InvokeAsync
或DispatcherQueue.TryEnqueue
。
现代的 async/await 模式在 UI 应用中非常友好。当你 await
一个 Task(如 Task.Run
启动的任务或 I/O 密集型任务)完成后,紧随 await
后面的代码(延续)会尝试回到 await
发生时的上下文(通常是 UI 线程上下文),从而允许你在延续代码中安全地更新 UI,无需手动使用 Invoke
/BeginInvoke
。
示例(概念性 – WPF):
“`csharp
// 假设这是一个 WPF 窗体类中的方法
private async void StartButton_Click(object sender, RoutedEventArgs e)
{
// 在 UI 线程上启动任务,不会阻塞 UI
// await Task.Run(() =>
// {
// // 模拟耗时操作
// Thread.Sleep(5000);
// // !!! 错误 !!! 不能在这里直接更新 UI
// // myTextBlock.Text = “Done!”;
// }); // Task.Run 会在线程池线程上执行
// 使用 await 后的代码会自动回到 UI 线程(默认行为)
string result = await Task.Run(() =>
{
// 模拟耗时计算并返回结果
Thread.Sleep(5000);
return "计算完成!";
}); // Task.Run 在线程池上
// 任务完成后,await 后的代码在 UI 线程上执行
myTextBlock.Text = result; // 安全更新 UI
}
“`
注意,实际的 UI 编程需要引用相应的 UI 框架库并设置好项目类型。此处仅为概念说明。
第七章:多线程编程的最佳实践
- 优先使用 Task 和 Async/Await: 这是现代 C# 中处理异步和并发的首选方式,它们提供了更强大的功能、更好的可读性和更易于管理的生命周期(包括取消和错误处理)。
- 区分 CPU 密集型和 I/O 密集型任务:
- CPU 密集型任务:使用
Task.Run
将其放到线程池执行,避免阻塞 UI 线程或其他重要线程。 - I/O 密集型任务:使用 .NET 提供的异步方法(如
Stream.ReadAsync
,HttpClient.GetAsync
等),它们在等待 I/O 完成时不会占用线程。
- CPU 密集型任务:使用
- 最小化共享可变状态: 这是避免并发问题(尤其是竞态条件)最有效的策略。尽量设计线程之间通过消息传递或不可变数据结构进行通信,而不是直接共享和修改数据。
- 谨慎使用同步原语: 只在必要时使用
lock
或其他同步机制。过度使用锁会限制并发性,可能导致性能下降甚至死锁。 - 使用协作式取消: 避免使用
Thread.Abort
。利用CancellationTokenSource
和CancellationToken
实现线程或 Task 的友好退出。 - 处理异常: 后台线程中未捕获的异常默认会终止进程(在 .NET Framework 中)或被忽略(在 .NET Core/5+ 中,除非特别配置)。使用 Task 时,异常会被存储在 Task 对象中,并通过
await
或访问Result
属性时重新抛出,这使得异常处理更加方便。 - 注意 UI 更新: 永远不要在后台线程直接更新 UI 控件。使用 UI 框架提供的机制(如
Invoke
/BeginInvoke
或利用 async/await 的上下文切换)将更新操作调度回 UI 线程。 - 小心死锁: 理解死锁的产生原因,并遵循避免死锁的策略,尤其是在使用多个锁时。
总结
多线程是现代软件开发中一项不可或缺的技术,它能够显著提升应用程序的性能和用户体验。从传统的 System.Threading.Thread
类到更高效的线程池,再到现代 C# 中强大易用的 Task 和 async/await 模式,.NET 平台提供了丰富的工具来应对不同的并发场景。
掌握多线程的关键在于理解其基本概念(进程、线程、并发、并行)、学会如何启动和管理线程/任务、以及最重要的——如何安全地处理共享数据和避免常见的陷阱(如竞态条件和死锁)。
对于初学者,建议从理解 Task 和 async/await 开始,因为它们是构建大多数现代异步应用的推荐方式。然后,深入学习同步机制,理解为什么需要它们以及如何正确使用 lock
等原语来保护共享资源。随着经验的积累,你可以逐步探索线程池的更高级用法和其他同步原语。
多线程的世界充满了挑战,但也充满了机遇。通过不断地学习和实践,你将能够编写出更高效、更稳定、响应更流畅的 C# 应用程序。祝你在多线程学习之路上取得成功!