多线程编程:任务管理与并行迭代执行
1. 任务异常处理
在多线程编程中,任务的异常处理至关重要。与单线程的异常处理不同,我们不能简单地通过包裹
Task.Start()
来捕获传递给任务委托内的异常,因为异常显然会在任务启动后发生。
-
未处理异常冒泡
:从 CLR 2.0 开始,终结器线程、线程池线程和用户创建线程上的未处理异常通常会冒泡,触发 Windows 错误报告对话框并导致应用程序退出。所有已知处理机制的异常都需要显式的
catch块,否则会导致程序关闭。 -
任务执行中的异常抑制
:任务执行期间的未处理异常会被抑制,直到调用任务完成成员(如
Wait()、Result、Task.WaitAll()或Task.WaitAny())。这些成员会抛出任务执行期间发生的任何未处理异常。
以下是处理任务未处理异常的示例代码:
using System;
using System.Threading.Tasks;
public class Program
{
public static void Main()
{
Task task = Task.Factory.StartNew(() =>
{
throw new ApplicationException();
});
try
{
task.Wait();
}
catch (AggregateException exception)
{
foreach (Exception item in
exception.InnerExceptions)
{
Console.WriteLine(
"ERROR: {0}", item.Message);
}
}
}
}
上述代码中,异常的数据类型是
System.AggregateException
,它是与根任务相关的(潜在)任务层次结构可能抛出的异常集合。
另外,在应用程序关闭期间,与任务相关的终结异常会被抑制。这是因为处理此类异常的努力往往过于复杂,而异常在应用程序退出时可能是良性的。
除了使用
try/catch
块,还可以使用
ContinueWith()
任务来处理未处理异常。以下是示例代码:
using System;
using System.Diagnostics;
using System.Threading.Tasks;
public class Program
{
public static void Main()
{
bool parentTaskFaulted = false;
Task task = new Task(() =>
{
throw new ApplicationException();
});
Task faultedTask = task.ContinueWith(
(parentTask) =>
{
parentTaskFaulted = parentTask.IsFaulted;
}, TaskContinuationOptions.OnlyOnFaulted);
task.Start();
Trace.Assert(parentTaskFaulted);
if (!task.IsFaulted)
{
task.Wait();
}
else
{
Console.WriteLine(
"ERROR: {0}", task.Exception.Message);
}
}
}
通过
ContinueWith()
任务的委托参数,可以评估前一个任务的
Exception
属性来检查异常。
2. 取消任务
.NET 3.5 及更早版本的 API 与 .NET Framework 4 的 API 在任务取消支持上存在差异。早期的 API 对取消请求支持较少,采用“粗暴”的中断方式,如调用线程中止、卸载
AppDomain
或终止进程。这种方式可能会在关键代码块执行期间意外中断线程,威胁数据完整性,还会引入线程行为的不确定性。
而 .NET Framework 4 的 PLINQ 和 TPL 基于的 API 支持取消请求方式,即合作取消。通过检查
System.Threading.CancellationToken
,目标任务可以对取消请求做出适当响应。
以下是使用
CancellationToken
取消任务的示例代码:
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
public class Program
{
public static void Main()
{
string stars = "*".PadRight(Console.WindowWidth-1, '*');
Console.WriteLine("Push ENTER to exit.");
// Wait for the user's input
Console.ReadLine();
Console.WriteLine(stars);
Console.WriteLine();
}
private static void WritePi(CancellationToken cancellationToken)
{
const int batchSize = 1;
string piSection = string.Empty;
int i = 0;
while (!cancellationToken.IsCancellationRequested
|| i == int.MaxValue)
{
piSection = PiCalculator.Calculate(
batchSize, (i++) * batchSize);
Console.Write(piSection);
}
}
}
在上述代码中,启动任务后,
Console.Read()
会阻塞主线程,同时任务继续执行。用户按下
Enter
键后,会调用
CancellationTokenSource.Cancel()
。
Cancel()
调用会设置从
CancellationTokenSource.Token
复制的所有取消令牌的
IsCancellationRequested
属性。
需要注意的是:
-
取消令牌
:在异步任务中评估的是
CancellationToken
,而不是
CancellationTokenSource
。
CancellationToken
用于监控和响应取消请求,而
CancellationTokenSource
用于取消任务本身。
-
复制
:
CancellationToken
是一个结构体,调用
CancellationTokenSource.Token
会创建令牌的副本,因此所有取消令牌实例都是线程安全的。
如果取消操作会干扰任务返回有效结果,可以抛出
TaskCanceledException
来报告取消情况。
CancellationToken
包含
ThrowIfCancellationRequested()
方法,可更方便地报告异常。
3. 长时间运行的任务
任务是操作系统线程的抽象,线程池可以高效地管理线程的分配和释放。通常情况下,任务应该合作并及时返回线程,以便其他请求可以使用相同的共享资源。
但如果开发者知道某个任务将长时间运行并长时间占用底层线程资源,需要通知线程池该任务不太可能很快返回共享线程。这样线程池更有可能为该任务创建专用线程,而不是使用共享线程。可以在调用
StartNew()
时使用
TaskCreationOptions.LongRunning
选项来实现,示例代码如下:
using System.Threading.Tasks;
// ...
Task task = Task.Factory.StartNew(
() =>
WritePi(cancellationTokenSource.Token),
TaskCreationOptions.LongRunning);
4. 任务的释放
在依赖任务的代码中,通常会调用任务的
Wait()
方法,以确保程序在任务完成执行之前不会退出。这符合 TPL 内置的合作取消方法。
如果程序在任务完成之前退出,任务所依赖的底层线程将被 CLR 中止,可能会导致不良影响。更好的方法是采用合作取消,即任务支持取消,应用程序调用取消并等待任务完成。
任务还支持
IDisposable
接口,因为
Wait()
依赖于
WaitHandle
,而
WaitHandle
支持
IDisposable
。虽然在前面的代码示例中没有包含
Dispose()
调用,但技术上应该调用它。不过,除非有大量任务和相应的
WaitHandle
,否则不立即调用
Dispose()
不会消耗大量资源,在某些情况下允许终结器负责资源清理是合理的。
5. 并行执行迭代
在计算圆周率(pi)的示例中,传统的
for
循环是同步顺序执行的。但由于圆周率计算算法可以将计算拆分为独立的部分,因此可以让迭代同时运行,从而根据处理器数量减少执行时间。
以下是同步计算圆周率分段的
for
循环示例代码:
using System;
const int TotalDigits = 100;
const int BatchSize = 10;
class Program
{
void Main()
{
string pi = null;
int iterations = TotalDigits / BatchSize;
for (int i = 0; i < iterations; i++)
{
pi += PiCalculator.Calculate(
BatchSize, i * BatchSize);
}
Console.WriteLine(pi);
}
}
using System;
class PiCalculator
{
public static string Calculate(int digits, int startingAt)
{
// ...
}
// ...
}
而 .NET 4 提供了并行
for
循环的能力,通过
System.Threading.Tasks.Parallel
的
Parallel.For()
方法实现。以下是并行计算圆周率分段的示例代码:
using System;
const int TotalDigits = 100;
const int BatchSize = 10;
class Program
{
void Main()
{
string pi = null;
int iterations = TotalDigits / BatchSize;
string[] sections = new string[iterations];
Parallel.For(0, iterations, (i) =>
{
sections[i] += PiCalculator.Calculate(
BatchSize, i * BatchSize);
});
pi = string.Join("", sections);
Console.WriteLine(pi);
}
}
Parallel.For()
的输出与同步
for
循环相同,但执行时间会显著减少(假设多个 CPU)。需要注意的是,合并圆周率各部分的代码不再在迭代内部进行,而是在
Parallel.For()
循环完成后使用
string.Join()
进行合并,以避免顺序问题和潜在的竞争条件。
并行执行循环不仅限于
for
循环,
Parallel.ForEach()
为
foreach
循环提供了类似的功能。以下是并行执行
foreach
循环的示例代码:
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
class Program
{
static void EncryptFiles(
string directoryPath, string searchPattern)
{
IEnumerable<string> files = Directory.GetFiles(
directoryPath, searchPattern,
SearchOption.AllDirectories);
Parallel.ForEach(files, (fileName) =>
{
Encrypt(fileName);
});
}
}
综上所述,多线程编程中的任务管理和并行迭代执行可以提高程序的性能和响应能力,但需要注意异常处理、任务取消、资源管理等问题。通过合理使用相关的 API 和技术,可以更好地实现多线程编程的目标。
以下是一个简单的流程图,展示了任务异常处理的流程:
graph TD;
A[任务启动] --> B{是否发生异常};
B -- 是 --> C[异常被抑制];
C --> D{调用任务完成成员};
D -- 是 --> E[抛出异常];
E --> F[try/catch 处理];
B -- 否 --> G[任务正常完成];
通过以上内容,我们对多线程编程中的任务管理和并行迭代执行有了更深入的了解。在实际开发中,可以根据具体需求选择合适的方法和技术,以提高程序的性能和稳定性。
多线程编程:任务管理与并行迭代执行
6. 不同任务操作的对比分析
为了更清晰地理解多线程编程中不同任务操作的特点,下面通过表格对任务异常处理、取消任务、长时间运行任务和任务释放等操作进行对比分析。
| 操作类型 | 主要特点 | 适用场景 | 代码示例 |
|---|---|---|---|
| 任务异常处理 |
使用
try/catch
块或
ContinueWith()
任务捕获和处理异常,异常类型为
AggregateException
| 当任务执行可能抛出异常,需要捕获并处理时 |
csharp<br>using System;<br>using System.Threading.Tasks;<br>public class Program <br>{<br> public static void Main()<br> {<br> Task task = Task.Factory.StartNew(() =><br> {<br> throw new ApplicationException();<br> });<br> try<br> {<br> task.Wait();<br> }<br> catch (AggregateException exception)<br> {<br> foreach (Exception item in <br> exception.InnerExceptions)<br> {<br> Console.WriteLine(<br> "ERROR: {0}", item.Message);<br> }<br> }<br> } <br>}<br>
|
| 取消任务 |
通过
CancellationToken
实现合作取消,避免“粗暴”中断带来的问题
| 当需要在任务执行过程中取消任务时 |
csharp<br>using System;<br>using System.Diagnostics;<br>using System.Threading;<br>using System.Threading.Tasks;<br>public class Program <br>{<br> public static void Main()<br> {<br> string stars = "*".PadRight(Console.WindowWidth - 1, '*');<br> Console.WriteLine("Push ENTER to exit.");<br> Console.ReadLine();<br> Console.WriteLine(stars);<br> Console.WriteLine();<br> }<br> private static void WritePi(CancellationToken cancellationToken)<br> {<br> const int batchSize = 1;<br> string piSection = string.Empty;<br> int i = 0;<br> while (!cancellationToken.IsCancellationRequested <br> || i == int.MaxValue)<br> {<br> piSection = PiCalculator.Calculate(<br> batchSize, (i++) * batchSize);<br> Console.Write(piSection);<br> }<br> } <br>}<br>
|
| 长时间运行任务 |
使用
TaskCreationOptions.LongRunning
选项通知线程池创建专用线程
| 当任务需要长时间运行,占用线程资源时 |
csharp<br>using System.Threading.Tasks;<br>Task task = Task.Factory.StartNew(<br> () => <br> WritePi(cancellationTokenSource.Token),<br> TaskCreationOptions.LongRunning);<br>
|
| 任务释放 |
任务支持
IDisposable
接口,可调用
Dispose()
方法释放资源
| 当需要确保资源及时释放时 |
虽然代码示例中未体现,但可在合适位置调用
task.Dispose()
|
7. 并行迭代执行的深入理解
并行迭代执行是提高程序性能的重要手段,下面进一步分析
Parallel.For()
和
Parallel.ForEach()
的执行流程。
7.1
Parallel.For()
执行流程
graph TD;
A[开始 `Parallel.For()`] --> B[确定迭代范围];
B --> C{是否有可用处理器};
C -- 是 --> D[分配迭代给处理器];
D --> E[各处理器并行执行迭代];
E --> F{所有迭代是否完成};
F -- 否 --> E;
F -- 是 --> G[合并结果];
C -- 否 --> H[顺序执行迭代];
H --> G;
从流程图可以看出,
Parallel.For()
首先确定迭代范围,然后根据可用处理器情况进行分配。如果有可用处理器,各处理器会并行执行迭代;如果没有可用处理器,则顺序执行迭代。最后,所有迭代完成后合并结果。
7.2
Parallel.ForEach()
执行流程
Parallel.ForEach()
的执行流程与
Parallel.For()
类似,只是迭代对象不同。其主要步骤如下:
1. 获取可枚举对象。
2. 根据可用处理器分配元素给处理器并行处理。
3. 各处理器并行处理元素。
4. 等待所有元素处理完成。
以下是一个简单的示例,展示如何使用
Parallel.ForEach()
处理文件列表:
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
class Program
{
static void Main()
{
string directoryPath = @"C:\YourDirectory";
string searchPattern = "*.txt";
EncryptFiles(directoryPath, searchPattern);
}
static void EncryptFiles(
string directoryPath, string searchPattern)
{
IEnumerable<string> files = Directory.GetFiles(
directoryPath, searchPattern,
SearchOption.AllDirectories);
Parallel.ForEach(files, (fileName) =>
{
Encrypt(fileName);
});
}
static void Encrypt(string fileName)
{
// 加密文件的具体实现
Console.WriteLine($"Encrypting {fileName}");
}
}
8. 多线程编程的注意事项
在进行多线程编程时,为了确保程序的正确性和稳定性,需要注意以下几点:
1.
数据同步
:多个线程同时访问共享资源时,可能会出现数据竞争问题。可以使用锁机制(如
lock
语句)或其他同步原语来保证数据的一致性。
2.
异常处理
:如前面所述,要正确处理任务执行过程中可能抛出的异常,避免程序崩溃。
3.
资源管理
:合理使用线程资源,对于长时间运行的任务,使用
TaskCreationOptions.LongRunning
选项;对于任务完成后不再使用的资源,及时调用
Dispose()
方法释放。
4.
避免死锁
:在使用锁机制时,要注意避免死锁的发生。死锁是指两个或多个线程相互等待对方释放锁,导致程序无法继续执行。
通过遵循这些注意事项,可以更好地进行多线程编程,提高程序的性能和可靠性。
在实际开发中,多线程编程是一个复杂而又强大的技术。通过合理运用任务管理和并行迭代执行的方法,可以充分发挥多核处理器的优势,提高程序的运行效率。同时,要注意处理好异常、资源管理等问题,确保程序的稳定性和正确性。希望本文的内容能帮助你更好地理解和应用多线程编程技术。
超级会员免费看
10万+

被折叠的 条评论
为什么被折叠?



