
一、线程基础:核心概念与创建方式
(一)线程基础概念与生命周期
线程是程序执行的最小单元,在 C# 中,通过System.Threading命名空间提供了完整的线程管理能力。线程的生命周期包含多个重要阶段,理解这些阶段是有效控制线程行为的基础。
- 新建(New) :当使用
new关键字创建一个Thread对象时,线程处于新建状态。此时,线程仅仅是一个对象,尚未被操作系统调度执行,就像是一辆停在车库里的车,还没有启动引擎。例如:
csharp
Thread thread = new Thread(DoWork);
这里创建了一个线程对象thread,关联了DoWork方法,但线程尚未开始执行。
- 就绪(Ready) :调用
Start方法后,线程进入就绪状态,等待 CPU 调度。此时,线程已经准备好执行,但还需要等待 CPU 分配时间片,就像车已经启动,在等待绿灯放行。
csharp
thread.Start();
调用Start方法后,线程进入就绪队列,等待被 CPU 选中执行。
-
运行(Running) :当 CPU 为线程分配了时间片,线程进入运行状态,开始执行关联的方法中的代码。此时,线程正在执行任务,就像车在道路上行驶。
-
阻塞(Blocked) :在某些情况下,线程可能会进入阻塞状态。例如,线程调用了
Thread.Sleep方法、等待某个事件发生、获取锁失败等。在阻塞状态下,线程暂时停止执行,直到满足特定的条件后才会重新进入就绪状态。比如,线程执行Thread.Sleep(1000);,线程会暂停 1 秒钟,这期间处于阻塞状态。 -
终止(Dead) :当线程执行完其关联的方法,或者调用了
Abort方法终止线程时,线程进入死亡状态,此时线程不再存在,就像车到达了目的地,旅程结束。
除了生命周期,线程还有前台线程和后台线程之分。前台线程会阻止进程退出,只要有前台线程在运行,进程就会继续存活;而后台线程则不会,当所有前台线程结束后,后台线程会被强制终止。在 C# 中,可以通过设置Thread对象的IsBackground属性来指定线程是前台还是后台线程。例如:
csharp
thread.IsBackground = true;
这样就将thread设置为了后台线程。在实际应用中,需要根据任务的类型和需求来选择使用前台线程还是后台线程。比如,对于一些需要长期运行且不影响程序退出的任务,如日志记录、后台数据同步等,可以使用后台线程;而对于一些关键的任务,如用户界面交互、核心业务逻辑处理等,则需要使用前台线程,以确保任务的完整性和稳定性。
(二)线程创建的四种经典方式
1. 无参数线程:ThreadStart 委托
适用于简单无参场景,通过ThreadStart委托指定无返回值方法。ThreadStart委托是一种用于表示线程执行方法的类型,它没有参数且返回值为void。例如:
csharp
class Program
{
static void Main()
{
Thread thread = new Thread(DoWork);
thread.Start();
thread.Join();
Console.WriteLine("主线程执行完毕");
}
static void DoWork()
{
Console.WriteLine("线程运行中...");
}
}
在上述代码中,DoWork方法没有参数,通过ThreadStart委托将其关联到新创建的线程thread,然后调用Start方法启动线程。这种方式适合那些不需要传递参数,独立执行的简单任务,比如在后台定期打印日志,或者监控系统的某些状态等。
2. 单参数线程:ParameterizedThreadStart 委托
当需要传递单个参数给线程执行方法时,可以使用ParameterizedThreadStart委托。这个委托允许传递一个object类型的参数,不过需要注意的是,在方法内部需要手动进行拆箱操作。示例代码如下:
csharp
class Program
{
static void Main()
{
Thread thread = new Thread(DoWorkWithParam);
thread.Start("这是传递给子线程的参数");
thread.Join();
Console.WriteLine("主线程执行完毕");
}
static void DoWorkWithParam(object obj)
{
string param = (string)obj;
Console.WriteLine($"接收到的参数是: {param}");
}
}
这里,DoWorkWithParam方法接收一个object类型的参数,在方法内部将其转换为string类型。这种方式适用于需要传入初始数据的场景,比如根据不同的任务编号执行不同的操作,或者传递一些配置参数给线程等。但要注意,在进行类型转换时,需要进行异常处理,以防止因类型不匹配而导致程序崩溃。
3. 自定义线程类:封装参数与返回值
对于一些复杂的场景,需要传递多个参数或者获取线程执行结果时,可以通过自定义类来封装参数和返回值。通过继承Thread类,并重写Run方法,在类中定义字段来存储参数和结果。示例如下:
csharp
class CustomThread : Thread
{
private int _inputParam;
private int _result;
public CustomThread(int param)
{
_inputParam = param;
}
public int Result
{
get { return _result; }
}
public override void Run()
{
// 模拟一些计算
_result = _inputParam * 2;
}
}
class Program
{
static void Main()
{
CustomThread customThread = new CustomThread(5);
customThread.Start();
customThread.Join();
Console.WriteLine($"计算结果是: {customThread.Result}");
}
}
在这个例子中,CustomThread类封装了输入参数_inputParam和输出结果_result,通过重写Run方法实现了具体的逻辑。这种方式适合需要状态维护或多步骤处理的场景,增强了代码的复用性和可维护性。
4. 匿名方法与 Lambda 表达式:简化代码结构
在 C# 中,还可以使用匿名方法和 Lambda 表达式来创建线程,这种方式可以避免定义独立的方法,直接将线程执行的逻辑嵌入到代码中,使代码结构更加简洁。例如:
csharp
class Program
{
static void Main()
{
Thread thread = new Thread(() =>
{
Console.WriteLine("使用Lambda表达式创建的线程运行中...");
});
thread.Start();
thread.Join();
Console.WriteLine("主线程执行完毕");
}
}
这里使用 Lambda 表达式定义了线程的执行逻辑,直接传递给Thread的构造函数。Lambda 表达式简化了代码量,特别适用于临时性、逻辑简单的线程任务,在快速原型开发或者一些简单的测试场景中非常实用,可以大大提升开发效率。
二、异步编程:委托与线程池进阶
(一)委托异步调用:BeginInvoke/EndInvoke
在 C# 中,委托的异步调用是实现异步编程的一种基础方式,通过BeginInvoke和EndInvoke方法,可以让方法在后台线程中执行,从而避免阻塞主线程。这种方式特别适用于那些需要执行耗时操作,但又不希望影响用户界面响应的场景。
委托异步调用的核心在于BeginInvoke方法,它会立即返回,并在新线程中启动委托所指向的方法。BeginInvoke方法的参数包括委托方法的参数、一个AsyncCallback委托用于在异步操作完成时接收通知,以及一个object类型的状态对象,用于传递额外的信息。例如:
csharp
class Program
{
// 定义一个委托
public delegate int CalculateDelegate(int a, int b);
static void Main()
{
CalculateDelegate calculate = AddNumbers;
// 开始异步调用
IAsyncResult asyncResult = calculate.BeginInvoke(3, 5, CallBack, calculate);
// 主线程可以继续执行其他任务
Console.WriteLine("主线程继续执行...");
// 可以在这里进行一些不需要等待异步结果的操作
// 等待异步操作完成并获取结果(这里也可以在回调中获取)
// int result = calculate.EndInvoke(asyncResult);
// Console.WriteLine($"计算结果: {result}");
Console.ReadKey();
}
static int AddNumbers(int a, int b)
{
// 模拟一些耗时操作
Thread.Sleep(2000);
return a + b;
}
static void CallBack(IAsyncResult asyncResult)
{
CalculateDelegate calculate = (CalculateDelegate)asyncResult.AsyncState;
int result = calculate.EndInvoke(asyncResult);
Console.WriteLine($"回调中获取的计算结果: {result}");
}
}
在上述代码中,AddNumbers方法是一个耗时的计算方法,通过BeginInvoke方法在后台线程中执行。CallBack方法是一个回调函数,当异步操作完成时,会自动调用该函数。在回调函数中,通过EndInvoke方法获取异步操作的结果。
需要注意的是,EndInvoke方法如果在异步操作未完成时调用,会阻塞当前线程,直到异步操作完成。因此,通常建议在回调函数中调用EndInvoke方法,或者使用IAsyncResult的IsCompleted属性或AsyncWaitHandle的WaitOne方法来判断异步操作是否完成,再调用EndInvoke方法。委托异步调用虽然可以实现基本的异步操作,但在处理复杂的异步场景时,代码可能会变得繁琐,维护起来也相对困难。因此,在实际开发中,对于更复杂的异步需求,通常会使用更高级的异步编程模型,如线程池和 Task 并行库 。
(二)线程池与 Task:高效任务管理
1. 线程池:复用线程降低开销
线程池是一种管理线程的机制,它维护着一个线程队列,当有任务需要执行时,线程池会从队列中取出一个空闲线程来执行任务,任务完成后,线程不会被销毁,而是返回线程池等待下一个任务。这样可以避免频繁创建和销毁线程带来的性能开销。在 C# 中,通过ThreadPool类来使用线程池。使用线程池的主要方式是通过QueueUserWorkItem方法,该方法接受一个WaitCallback委托,将任务添加到线程池的任务队列中。例如:
csharp
class Program
{
static void Main()
{
// 将任务添加到线程池
ThreadPool.QueueUserWorkItem(DoWork, "这是传递给线程的参数");
Console.WriteLine("主线程继续执行...");
Console.ReadKey();
}
static void DoWork(object state)
{
string param = (string)state;
Console.WriteLine($"线程池线程执行任务,参数: {param}");
// 模拟任务执行
Thread.Sleep(2000);
Console.WriteLine("任务执行完成");
}
}
在这个例子中,DoWork方法是要执行的任务,通过QueueUserWorkItem方法将其提交到线程池。DoWork方法接受一个object类型的参数,可以在提交任务时传递。线程池适用于处理大量短耗时的任务,比如在一个 Web 应用中,处理大量的 HTTP 请求,每个请求的处理时间较短,使用线程池可以高效地处理这些请求,而不需要为每个请求创建一个新线程。但是,线程池也有一些局限性,例如,它不适合处理长时间运行的任务,因为这会导致线程池中的线程被长时间占用,影响其他任务的执行。而且,通过线程池执行的任务没有返回值,也难以进行复杂的状态管理。
2. Task 并行库:现代异步编程首选
Task并行库是 C# 中更高级的异步编程模型,它基于线程池,提供了更强大的功能和更灵活的编程方式,是现代异步编程的首选。Task可以表示一个异步操作,并且支持返回值、取消令牌和聚合操作等。创建和启动一个Task非常简单,可以使用Task.Run方法,它会在后台线程上执行指定的操作。例如:
csharp
class Program
{
static async Task Main()
{
// 创建并启动一个带返回值的Task
Task<int> task = Task.Run(() =>
{
// 模拟一些计算
Thread.Sleep(2000);
return 42;
});
// 等待任务完成并获取结果
int result = await task;
Console.WriteLine($"任务结果: {result}");
}
}
在这个例子中,Task.Run方法创建并启动了一个异步任务,该任务在后台线程中执行计算操作,完成后返回一个整数值。通过await关键字,可以等待任务完成并获取其结果。Task还支持取消操作,通过CancellationToken来实现。例如:
csharp
class Program
{
static async Task Main()
{
CancellationTokenSource cts = new CancellationTokenSource();
CancellationToken token = cts.Token;
Task task = Task.Run(() => DoWork(token), token);
// 模拟主线程工作
await Task.Delay(1000);
// 取消任务
cts.Cancel();
try
{
await task;
}
catch (OperationCanceledException)
{
Console.WriteLine("任务已取消");
}
}
static void DoWork(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
// 模拟任务执行
Thread.Sleep(500);
Console.WriteLine("任务正在执行...");
}
token.ThrowIfCancellationRequested();
}
}
在这个示例中,CancellationTokenSource用于创建一个取消令牌,通过CancellationToken传递给任务。在任务执行过程中,会不断检查IsCancellationRequested属性来判断是否收到取消请求,如果收到请求,则通过ThrowIfCancellationRequested方法抛出异常来终止任务。除了基本的任务创建和取消,Task还提供了一些聚合操作,比如Task.WhenAll和Task.WhenAny。Task.WhenAll用于等待多个任务都完成,Task.WhenAny用于等待多个任务中的任意一个完成。例如:
csharp
class Program
{
static async Task Main()
{
Task<int> task1 = Task.Run(() => { Thread.Sleep(1000); return 1; });
Task<int> task2 = Task.Run(() => { Thread.Sleep(2000); return 2; });
// 等待所有任务完成
Task<int[]> allTasks = Task.WhenAll(task1, task2);
int[] results = await allTasks;
Console.WriteLine($"任务1结果: {results[0]}, 任务2结果: {results[1]}");
// 等待任意一个任务完成
Task<int> anyTask = Task.WhenAny(task1, task2);
int anyResult = await anyTask;
Console.WriteLine($"最先完成的任务结果: {anyResult}");
}
}
在这个例子中,Task.WhenAll等待task1和task2都完成,并将它们的结果收集到一个数组中返回。Task.WhenAny则等待task1和task2中任意一个完成,并返回最先完成的任务结果。Task并行库极大地简化了异步编程和并行计算的实现,提供了丰富的功能和灵活的编程方式,在实际开发中,无论是处理 I/O 密集型任务还是计算密集型任务,都推荐优先使用Task并行库来实现异步操作 。
三、线程同步:避免资源竞争与死锁
(一)基础同步机制
1. lock 关键字:最简临界区保护
在 C# 中,lock关键字是实现线程同步的最常用且简单的方式之一,它用于保护临界区,确保同一时刻仅有一个线程能够访问共享资源,有效避免了多线程环境下的数据竞争问题。lock关键字实际上是Monitor.Enter和Monitor.Exit方法的语法糖,这意味着使用lock时,编译器会自动在代码块的开始处插入Monitor.Enter,在结束处插入Monitor.Exit,确保锁在代码块执行完毕后能自动释放,从而大大降低了因忘记释放锁而导致死锁的风险。
使用lock关键字时,需要提供一个对象作为锁对象,这个对象就像是一个房间的钥匙,持有这把钥匙的线程才能进入房间(临界区)访问共享资源。例如:
csharp
class Program
{
private static readonly object _lockObject = new object();
private static int sharedResource = 0;
static void Main()
{
Thread thread1 = new Thread(ModifyResource);
Thread thread2 = new Thread(ModifyResource);
thread1.Start();
thread2.Start();
thread1.Join();
thread2.Join();
Console.WriteLine($"最终共享资源的值: {sharedResource}");
}
static void ModifyResource()
{
lock (_lockObject)
{
for (int i = 0; i < 1000; i++)
{
sharedResource++;
}
}
}
}
在上述代码中,_lockObject作为锁对象,ModifyResource方法中的lock语句块保护了对sharedResource的操作。当一个线程进入lock块时,它会获取_lockObject的锁,其他线程在此时尝试进入lock块,就会被阻塞,直到持有锁的线程执行完lock块中的代码并释放锁。需要注意的是,选择合适的锁对象至关重要,锁对象应该是私有的、只读的,并且在整个应用程序中具有唯一性,以避免不同部分的代码意外地使用相同的锁对象,导致不必要的阻塞和性能问题。同时,锁对象也应该是不可变的,避免在锁的生命周期内其状态发生改变,影响锁的正常工作 。
2. Monitor 类:手动控制锁获取与释放
Monitor类提供了更底层、更灵活的线程同步控制,相比于lock关键字,它允许开发者手动管理锁的获取和释放,并且提供了线程间的等待和通知机制,这使得Monitor类在处理复杂的同步场景时具有更大的优势。Monitor类的核心方法包括Enter、Exit、Wait、Pulse和PulseAll。Enter方法用于获取对象的锁,如果锁已经被其他线程持有,调用Enter的线程会被阻塞,直到锁被释放。Exit方法则用于释放锁,允许其他线程获取锁并进入临界区。例如:
csharp
class Program
{
private static readonly object _syncObject = new object();
private static int sharedData = 0;
static void Main()
{
Thread thread1 = new Thread(AccessSharedData);
Thread thread2 = new Thread(AccessSharedData);
thread1.Start();
thread2.Start();
thread1.Join();
thread2.Join();
Console.WriteLine($"最终共享数据的值: {sharedData}");
}
static void AccessSharedData()
{
Monitor.Enter(_syncObject);
try
{
for (int i = 0; i < 1000; i++)
{
sharedData++;
}
}
finally
{
Monitor.Exit(_syncObject);
}
}
}
在这个例子中,Monitor.Enter和Monitor.Exit手动控制了对_syncObject锁的获取和释放,与lock关键字不同,这里需要显式地使用try - finally块来确保无论是否发生异常,锁都能被正确释放。Monitor类的Wait、Pulse和PulseAll方法则用于实现线程间的协作。Wait方法会使当前线程释放锁,并进入等待状态,直到其他线程调用Pulse或PulseAll方法通知它。Pulse方法会唤醒一个等待该锁的线程,而PulseAll方法则会唤醒所有等待该锁的线程。例如,在生产者 - 消费者模型中,可以使用这些方法来协调生产者和消费者线程的工作:
csharp
class ProducerConsumer
{
private static readonly object _syncObject = new object();
private static Queue<int> buffer = new Queue<int>();
private const int MaxBufferSize = 5;
public static void Producer()
{
for (int i = 0; i < 10; i++)
{
Monitor.Enter(_syncObject);
try
{
while (buffer.Count >= MaxBufferSize)
{
Monitor.Wait(_syncObject);
}
buffer.Enqueue(i);
Console.WriteLine($"生产: {i},当前缓冲区大小: {buffer.Count}");
Monitor.PulseAll(_syncObject);
}
finally
{
Monitor.Exit(_syncObject);
}
}
}
public static void Consumer()
{
for (int i = 0; i < 10; i++)
{
Monitor.Enter(_syncObject);
try
{
while (buffer.Count == 0)
{
Monitor.Wait(_syncObject);
}
int item = buffer.Dequeue();
Console.WriteLine($"消费: {item},当前缓冲区大小: {buffer.Count}");
Monitor.PulseAll(_syncObject);
}
finally
{
Monitor.Exit(_syncObject);
}
}
}
}
在这个生产者 - 消费者模型中,生产者线程在缓冲区满时调用Monitor.Wait等待,消费者线程在缓冲区空时也调用Monitor.Wait等待。当生产者生产数据或消费者消费数据后,通过Monitor.PulseAll通知其他等待的线程,从而实现了线程间的高效协作。但需要注意的是,Monitor类的使用相对复杂,需要开发者对锁的获取、释放以及线程间的协作有深入的理解,以避免死锁和其他同步问题的发生 。
(二)跨线程与跨进程同步
1. Mutex 互斥体:跨进程资源保护
Mutex(互斥体)是一种用于线程同步和进程同步的机制,它的主要作用是确保在同一时间只有一个线程或进程能够访问某个共享资源,无论是在同一进程内的多个线程之间,还是在不同进程之间。与lock和Monitor主要用于同一进程内的线程同步不同,Mutex支持跨进程同步,这使得它在多个程序实例需要共享资源的场景中非常有用,比如多个程序需要同时访问同一个文件、数据库连接或其他共享资源时。
创建和使用Mutex的方式与其他同步机制有一些相似之处,但也有其独特的地方。当创建一个Mutex时,可以选择是否初始时就获取锁,以及为Mutex指定一个唯一的名称。例如:
csharp
class Program
{
private static Mutex _mutex = new Mutex(false, "MyUniqueMutexName");
static void Main()
{
bool isOwned;
try
{
// 尝试获取Mutex的所有权,等待时间为0表示立即返回
isOwned = _mutex.WaitOne(TimeSpan.Zero, false);
if (!isOwned)
{
Console.WriteLine("另一个实例已经在运行,无法再次打开!");
return;
}
// 程序运行逻辑
Console.WriteLine("程序正在运行...");
// 模拟程序工作
Thread.Sleep(5000);
}
catch (AbandonedMutexException)
{
// 如果捕获到AbandonedMutexException异常,说明之前的Mutex所有者异常终止了
// 此时当前进程获得了Mutex的所有权,可以继续运行程序
isOwned = true;
Console.WriteLine("捕获到AbandonedMutexException异常,继续运行程序...");
}
finally
{
if (isOwned)
{
_mutex.ReleaseMutex();
}
}
}
}
在这个例子中,通过创建一个命名为MyUniqueMutexName的Mutex,并使用WaitOne方法尝试获取其所有权。如果另一个进程已经持有该Mutex,则当前进程无法获取所有权,从而实现了程序只能有一个实例运行的功能。在使用Mutex时,需要注意命名的唯一性,确保不同进程间能够正确识别和共享同一个Mutex。同时,获取Mutex后一定要记得在合适的时机释放,否则可能会导致其他进程无法访问共享资源,造成死锁或资源浪费。另外,由于Mutex涉及到跨进程通信,其性能开销相对较大,在设计时应充分考虑这一点,避免不必要的性能损失 。
2. Semaphore 信号量:限制并发访问数量
Semaphore(信号量)是一种用于控制同时访问某个资源的线程数量的同步机制,它通过维护一个计数器来实现这一功能。当一个线程请求访问资源时,如果信号量的计数器大于 0,则线程可以继续执行,同时计数器减 1;如果计数器等于 0,则线程被阻塞,直到其他线程释放资源,使计数器增加。Semaphore通常用于限制对共享资源的并发访问,比如数据库连接池,限制同时连接到数据库的线程数量,以避免数据库过载;或者在文件访问中,限制同时读取或写入文件的线程数,确保文件操作的安全性和稳定性。
在 C# 中,创建一个Semaphore对象时,需要指定两个参数:初始计数(initialCount)和最大计数(maximumCount)。初始计数表示信号量创建时的初始可用资源数量,最大计数则表示信号量能达到的最大值。例如,创建一个允许同时有 3 个线程访问的信号量:
csharp
class Program
{
private static Semaphore _semaphore = new Semaphore(3, 3);
static void Main()
{
for (int i = 0; i < 5; i++)
{
Thread thread = new Thread(AccessResource);
thread.Start(i);
}
Console.ReadKey();
}
static void AccessResource(object id)
{
int threadId = (int)id;
Console.WriteLine($"线程 {threadId} 等待获取信号量...");
_semaphore.WaitOne();
try
{
Console.WriteLine($"线程 {threadId} 获取到信号量,开始访问资源...");
// 模拟资源访问
Thread.Sleep(2000);
Console.WriteLine($"线程 {threadId} 访问资源完毕");
}
finally
{
_semaphore.Release();
Console.WriteLine($"线程 {threadId} 释放信号量");
}
}
}
在这个例子中,_semaphore的初始计数和最大计数都为 3,这意味着最多允许 3 个线程同时访问资源。当有 5 个线程尝试访问资源时,前 3 个线程可以立即获取信号量并开始访问,而后面 2 个线程则会被阻塞,直到有线程释放信号量。每个线程在访问资源前后,分别调用WaitOne和Release方法来获取和释放信号量,确保信号量的计数器正确维护,从而实现了对并发访问数量的有效控制。在使用Semaphore时,要确保获取和释放操作的配对,避免因错误的使用导致信号量状态混乱,影响程序的正常运行 。
四、高级技巧:异常处理与最佳实践
(一)线程异常处理策略
1. 子线程异常捕获
在 C# 的多线程编程中,正确处理子线程中的异常是确保程序健壮性的关键。与主线程不同,子线程中的未处理异常不会被自动捕获,若不加以处理,可能会导致线程意外终止,甚至影响整个应用程序的稳定性。为了避免这种情况,应在子线程的执行方法内使用try - catch块来捕获异常。例如:
csharp
class Program
{
static void Main()
{
Thread thread = new Thread(DoWork);
thread.Start();
thread.Join();
Console.WriteLine("主线程执行完毕");
}
static void DoWork()
{
try
{
// 模拟可能抛出异常的操作
int result = 10 / 0;
}
catch (DivideByZeroException ex)
{
Console.WriteLine($"捕获到异常: {ex.Message}");
}
}
}
在上述代码中,DoWork方法是子线程的执行逻辑,通过try - catch块捕获了可能发生的DivideByZeroException异常。这样,即使子线程中发生异常,也不会导致线程突然终止,而是由catch块进行相应的处理,如记录日志、进行错误提示等,从而保证了关键任务的健壮性,避免因单个线程异常导致整个程序崩溃。在实际应用中,可能会有更复杂的业务逻辑和多种类型的异常需要处理,因此需要根据具体情况,合理地组织try - catch块,确保能够捕获并处理所有可能的异常情况。同时,对于一些无法在当前线程中处理的异常,可以考虑将其包装后重新抛出,以便在更高层次的调用栈中进行统一处理 。
2. Task 异常聚合
当使用Task并行库进行多任务处理时,可能会遇到多个任务同时抛出异常的情况。Task提供了强大的异常聚合机制,通过AggregateException类来处理多个任务的异常。例如,当多个Task并行执行时,可以使用Task.WaitAll或await Task.WhenAll方法等待所有任务完成,并捕获可能发生的AggregateException异常。示例代码如下:
csharp
class Program
{
static async Task Main()
{
List<Task> tasks = new List<Task>();
for (int i = 0; i < 3; i++)
{
int taskIndex = i;
tasks.Add(Task.Run(() =>
{
if (taskIndex == 1)
{
throw new Exception("任务1发生异常");
}
else if (taskIndex == 2)
{
throw new Exception("任务2发生异常");
}
return taskIndex;
}));
}
try
{
await Task.WhenAll(tasks);
}
catch (AggregateException ex)
{
Console.WriteLine($"捕获到多个任务的异常: {ex.Message}");
foreach (var innerEx in ex.InnerExceptions)
{
Console.WriteLine($"内部异常: {innerEx.Message}");
}
}
}
}
在这个例子中,创建了 3 个Task,其中任务 1 和任务 2 故意抛出异常。通过await Task.WhenAll(tasks)等待所有任务完成,并在catch块中捕获AggregateException异常。AggregateException包含了所有内部异常,可以通过InnerExceptions属性遍历并处理这些异常。这种方式使得在处理多个并行任务时,能够批量处理异常,并且支持在部分任务失败的情况下,进行相应的恢复逻辑,例如记录详细的错误日志、尝试重新执行失败的任务等,从而提高了程序的容错性和稳定性 。
(二)性能优化与避坑指南
1. 避免过度同步
在多线程编程中,同步机制(如锁)是确保共享资源安全访问的重要手段,但过度使用同步会严重影响程序的性能。同步操作会导致线程阻塞,当一个线程持有锁时,其他线程必须等待锁的释放才能继续执行,这会增加线程上下文切换的开销,降低系统的并发性能。因此,应尽量减少锁的作用范围和持有时间,避免不必要的同步操作。例如,在一些情况下,可以使用原子操作来替代锁。原子操作是指不可被中断的操作,在多线程环境下,它能够保证操作的原子性,即要么完全执行,要么完全不执行,不会出现部分执行的情况。C# 中的System.Threading.Interlocked类提供了一系列原子操作方法,如Increment、Decrement、Add等,可用于对整数类型的变量进行原子操作。以下是一个使用Interlocked.Increment方法替代锁来实现计数器递增的示例:
csharp
class Program
{
private static int sharedCounter = 0;
static void Main()
{
List<Thread> threads = new List<Thread>();
for (int i = 0; i < 10; i++)
{
Thread thread = new Thread(IncrementCounter);
threads.Add(thread);
thread.Start();
}
foreach (var thread in threads)
{
thread.Join();
}
Console.WriteLine($"最终计数器的值: {sharedCounter}");
}
static void IncrementCounter()
{
for (int i = 0; i < 1000; i++)
{
Interlocked.Increment(ref sharedCounter);
}
}
}
在这个例子中,IncrementCounter方法使用Interlocked.Increment对sharedCounter进行递增操作,无需使用锁来保证线程安全。这样可以大大降低线程阻塞的概率,提升并发性能。在实际开发中,应仔细分析共享资源的访问模式,对于简单的、不需要复杂同步逻辑的操作,优先考虑使用原子操作;而对于复杂的共享资源访问,在使用锁时,也要尽量将锁的作用范围缩小到最小,只在真正需要保护共享资源的代码块上使用锁,并且尽快释放锁,以减少线程等待的时间 。
2. 合理选择线程模型
在 C# 多线程编程中,根据任务的特点合理选择线程模型是优化性能的关键。不同的任务类型和场景,适合的线程模型也不同,以下是一些常见的选择建议:
- 短任务 :对于执行时间较短的任务,优先使用线程池或
Task。线程池通过复用线程,避免了频繁创建和销毁线程的开销,非常适合处理大量短耗时的任务。例如,在一个 Web 应用中,处理 HTTP 请求的任务通常是短时间的,使用线程池可以高效地处理这些请求,提高系统的吞吐量。Task并行库基于线程池构建,提供了更灵活的异步编程方式,同样适用于短任务。通过Task.Run方法,可以方便地将短任务提交到线程池执行,并且支持返回值、取消操作等功能。例如:
csharp
class Program
{
static async Task Main()
{
Task<int> task = Task.Run(() =>
{
// 模拟短时间计算任务
Thread.Sleep(100);
return 42;
});
int result = await task;
Console.WriteLine($"任务结果: {result}");
}
}
- 长任务 :对于长时间运行的任务,手动创建线程并设置
IsBackground属性为true是一个较好的选择。设置为后台线程后,当所有前台线程结束时,后台线程会被强制终止,这样可以避免因后台线程未结束而导致进程无法正常退出的问题。同时,手动创建线程可以更好地控制线程的生命周期和状态,例如可以在任务执行过程中进行线程的暂停、恢复和终止操作。例如:
csharp
class Program
{
static void Main()
{
Thread longRunningThread = new Thread(DoLongWork);
longRunningThread.IsBackground = true;
longRunningThread.Start();
// 主线程继续执行其他任务
Console.WriteLine("主线程继续执行...");
Console.ReadKey();
}
static void DoLongWork()
{
// 模拟长时间运行任务
Thread.Sleep(5000);
Console.WriteLine("长时间任务执行完成");
}
}
- UI 场景 :在涉及用户界面(如 WinForms 或 WPF)的应用中,需要确保线程安全地访问 UI 控件。由于 UI 控件不是线程安全的,直接在非 UI 线程中访问 UI 控件会导致异常。此时,通过
SynchronizationContext可以将操作封送到 UI 线程执行。SynchronizationContext提供了一种机制,允许在不同线程之间进行同步操作,确保在 UI 线程上执行 UI 相关的操作。例如,在 WinForms 应用中,可以使用Control.Invoke方法来实现跨线程访问 UI 控件:
csharp
using System;
using System.Windows.Forms;
namespace WinFormsApp
{
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
}
private void button1_Click(object sender, EventArgs e)
{
Thread thread = new Thread(UpdateUI);
thread.Start();
}
private void UpdateUI()
{
if (this.InvokeRequired)
{
this.Invoke(new Action(() =>
{
label1.Text = "UI已更新";
}));
}
else
{
label1.Text = "UI已更新";
}
}
}
}
在这个例子中,当点击按钮时,启动一个新线程执行UpdateUI方法。UpdateUI方法通过InvokeRequired属性判断是否需要封送操作到 UI 线程,如果需要,则使用Invoke方法将更新 UI 的操作封送到 UI 线程执行,从而确保了 UI 操作的线程安全性 。
五、总结
在基础层面,掌握Thread类的使用是入门的关键,通过它可以创建线程并控制其基本行为,了解线程的生命周期各个阶段,以及前台线程和后台线程的区别,为后续的多线程编程打下坚实的基础。在创建线程时,根据不同的需求选择合适的方式,如无参数线程使用ThreadStart委托,单参数线程使用ParameterizedThreadStart委托,复杂场景下自定义线程类,以及利用匿名方法和 Lambda 表达式简化代码结构,这些方法各有优劣,需要开发者根据实际情况灵活运用。
异步编程是现代 C# 开发中不可或缺的部分,委托异步调用通过BeginInvoke和EndInvoke方法实现了基本的异步操作,但在复杂场景下,线程池和Task并行库则展现出更大的优势。线程池通过复用线程降低了开销,适用于处理大量短耗时的任务;而Task并行库则是现代异步编程的首选,它提供了更强大的功能和更灵活的编程方式,支持返回值、取消令牌和聚合操作等,通过await关键字和Task的各种方法,可以轻松实现高效的异步编程。
线程同步是多线程编程中的重点和难点,基础同步机制如lock关键字和Monitor类,为保护共享资源提供了基本的手段。lock关键字简洁易用,是保护临界区的常用方式;Monitor类则提供了更底层、更灵活的控制,允许手动管理锁的获取和释放,以及线程间的等待和通知机制。在跨线程与跨进程同步方面,Mutex互斥体用于跨进程资源保护,确保同一时间只有一个线程或进程能够访问共享资源;Semaphore信号量则用于限制并发访问数量,通过维护一个计数器来控制同时访问资源的线程数量。
在高级技巧方面,正确处理线程异常是保证程序健壮性的关键。子线程异常捕获通过try - catch块来实现,确保子线程中的异常不会导致线程意外终止;Task异常聚合则通过AggregateException类来处理多个任务的异常,提高了程序的容错性。性能优化与避坑指南则提醒开发者要避免过度同步,合理选择线程模型。减少锁的使用范围和时间,优先使用原子操作替代锁,以提高程序的并发性能;根据任务的特点选择合适的线程模型,如短任务使用线程池或Task,长任务手动创建线程并设置为后台线程,UI 场景中通过SynchronizationContext确保线程安全地访问 UI 控件。