多线程编程全面解析
1. 计算机性能发展与多线程背景
在 2004 年之前,提升计算机性能主要依靠增强单核处理器的能力。但如今,由于硅微芯片技术的物理限制,单核处理器性能提升遭遇瓶颈,计算能力与散热的平衡趋于稳定,甚至出现性能停滞和小幅度下降的情况。
然而,计算机性能仍在持续提升,多核处理器(单个处理器内的多个核心)和多处理器(插入主板的多个微芯片)已成为主流服务器、工作站甚至笔记本电脑的标配。例如,在支持超线程的四核计算机的 Windows 任务管理器中,会显示有八个处理器,这反映了计算机的可用计算能力。
虽然现在普通计算机都配备了多个处理单元或 CPU,但目前讨论的大多数程序仍是单线程的,每次只能使用其中一个 CPU。为了充分发挥计算机多个处理单元的潜力,我们需要编写多线程代码。这涉及到使用
System.Threading
和
System.Threading.Tasks
命名空间中的 API 来操作线程。
2. .NET 中的多线程 API
.NET 4 引入了两组新的多线程编程 API:任务并行库(TPL)和并行 LINQ(PLINQ)。尽管早期版本框架中的线程 API 仍然存在且得到完全支持,但未来的改进将主要围绕新的 API 展开。不过,对于那些针对早期框架的开发者来说,早期的 API 仍然很重要。此外,微软还发布了 .NET 反应式扩展(Rx),它可以为 .NET 3.5 框架添加对 TPL 和 PLINQ 的支持。
3. 线程基础
线程是可以与其他指令序列并发运行的指令序列。允许多个序列同时执行的程序就是多线程程序。例如,在导入大文件时,为了让用户能同时点击“取消”按钮,开发者会创建一个额外的线程来执行导入操作。这样,用户可以请求取消,而不会让用户界面在导入完成前冻结。
操作系统通过时间片轮转机制模拟多个线程的并发运行。即使有多个处理器,通常线程的需求也会超过处理器的数量,因此时间片轮转仍然会发生。时间片轮转是指操作系统快速地将执行从一个线程切换到另一个线程,使得看起来这些线程是同时执行的。处理器在切换到另一个线程之前执行某个线程的时间段称为时间片或量子。
这类似于光纤电话线,光纤线代表处理器,每个通话代表一个线程。单模光纤电话线一次只能传输一个信号,但很多人可以通过它同时进行通话。光纤通道切换通话的速度非常快,使得每个通话看起来都没有中断。同样,多线程进程中的每个线程看起来都能与其他线程连续运行。
由于线程经常需要等待各种事件,如 I/O 操作,切换到不同的线程可以提高执行效率,因为处理器不会在等待操作完成时闲置。然而,线程切换也会带来一些开销。如果线程过多,切换开销会显著影响性能,增加更多线程可能会进一步降低性能,因为处理器会花费更多时间在线程切换上,而不是完成每个线程的工作。
4. 多线程编程的复杂性
多线程编程虽然在 C# 语言和框架设计中已经简化了编程 API,但仍然存在相当大的复杂性,主要体现在以下几个方面:
-
原子性
:以银行账户转账为例,代码首先会验证账户是否有足够的资金,如果有则进行转账。但如果在检查资金后,另一个线程取走了资金,那么当原线程继续执行时,就可能发生无效转账。为了解决这个问题,需要控制账户访问,确保同一时间只有一个线程可以访问账户,使转账操作具有原子性。
一组操作满足以下两个条件之一时就是原子操作:
- 整个操作集必须在任何操作看起来执行之前完成。
- 系统的表观状态必须返回到任何操作执行之前的状态,就好像没有执行任何步骤一样。
在 C# 中,大多数语句都不是原子的。例如,
Count++
这个简单的语句,在处理器层面会转换为多个指令:
1. 处理器读取
Count
中的数据。
2. 处理器计算新的值。
3. 为
Count
分配新的值(这一步可能也不是原子的)。
在数据被访问但新值还未分配时,另一个线程可能会修改原始值,从而导致竞态条件。
-
死锁
:为了避免竞态条件,编程语言支持限制代码块只能由指定数量(通常为一个)的线程访问。但如果线程之间获取锁的顺序不同,就可能会发生死锁。例如:
Thread A Thread B
Acquires a lock on a Acquires a lock on b
Requests a lock on b Requests a lock on a
Deadlocks, waiting for b Deadlocks, waiting for a
在这种情况下,每个线程都在等待另一个线程释放锁,导致线程阻塞,代码执行陷入死锁。
-
不确定性
:非原子或会导致死锁的代码依赖于多个线程之间处理器指令的执行顺序,这会给程序执行带来不确定性。一个指令相对于另一个线程中的指令的执行顺序是未知的。很多时候,代码看起来表现一致,但偶尔会出现异常,这就是多线程编程的关键问题所在。由于竞态条件很难在实验室中重现,多线程代码的质量保证很大程度上依赖于长时间的压力测试、专门设计的代码分析工具以及大量的代码分析和审查工作。
5. 运行和控制单独的线程
操作系统实现了线程并提供了各种非托管 API 来创建和管理线程。CLR 将这些非托管线程进行封装,并通过
System.Threading.Tasks.Task
类在托管代码中公开,该类表示一个异步操作。但
Task
并不直接映射到一个非托管线程,而是对底层的非托管线程构造提供了一定程度的抽象。
创建线程是一个相对昂贵的操作。因此,只要能在两组或多组指令之间重用线程(而不是为每组指令都重新创建线程),整体执行效率可能会更高。在 .NET Framework 4 中,每次创建
Task
时,它不会直接创建一个操作系统线程,而是从线程池请求一个线程。线程池会评估是创建一个全新的线程,还是将一个现有的线程(如之前执行完成的线程)分配给
Task
请求。
通过将线程的概念抽象为
Task
,.NET 多线程 API 降低了高效管理线程的复杂性,即何时创建新的操作系统线程以及何时重用现有线程。同样,
Task
的内部行为(通过
System.Threading.ThreadPool
)会管理何时将线程返回给线程池以供后续重用,以及何时释放线程并释放其可能占用的任何资源。
编程
Task
的工作包括为
Task
分配要执行的指令集,然后启动
Task
。不出所料,分配指令在很大程度上依赖于委托。以下是一个简单的例子:
using System;
using System.Threading.Tasks;
public class Program
{
public static void Main()
{
const int repetitions = 10000;
Task task = new Task(() =>
{
for (int count = 0; count < repetitions; count++)
{
Console.Write('-');
}
});
task.Start();
for (int count = 0; count < repetitions; count++)
{
Console.Write('.');
}
// Wait until the Task completes
task.Wait();
}
}
在这个例子中,要在新线程中运行的代码定义在传递给
Task()
构造函数的委托(这里是
Action
类型)中。这个委托(以 lambda 表达式的形式)在循环的每次迭代中向控制台重复打印
.
。
Task
声明之后的
for
循环几乎相同,只是它显示
-
。程序的输出结果是一系列的破折号,直到线程上下文切换,此时程序会显示点号,直到下一次线程切换,以此类推。这表明两个
for
循环是同时并行运行的。
注意,在
Task
声明之后有一个
Start()
调用。在执行这个调用之前,传递给
Task
的
Action
不会开始执行。此外,
task.Wait()
调用会强制主线程(执行第二个
for
循环的线程)停止并“等待”,直到分配给
task
的所有工作完成执行。
如果
task
中执行的工作返回一个结果,那么任何对结果的请求都会自动阻塞,直到任务完成。以下示例展示了
Task<TResult>
,它通过执行
Func<TResult>
而不是简单的
Action
来返回一个值:
using System;
using System.Threading.Tasks;
public class Program
{
public static void Main()
{
Task<string> task = Task.Factory.StartNew<string>(() => PiCalculator.Calculate(100));
foreach (char busySymbol in Utility.BusySymbols())
{
if (task.IsCompleted)
{
Console.Write('\b');
break;
}
Console.Write(busySymbol);
}
Console.WriteLine();
// Blocks until task completes.
System.Diagnostics.Trace.Assert(task.IsCompleted);
Console.WriteLine(task.Result);
}
}
public class Utility
{
public static IEnumerable<char> BusySymbols()
{
string busySymbols = @"-\|/-\|/";
int next = 0;
while (true)
{
yield return busySymbols[next];
next = (++next) % busySymbols.Length;
yield return '\b';
}
}
}
这个例子中,
task
的数据类型是
Task<TResult>
(这里具体是
string
类型)。任务的泛型版本包含一个
Result
属性,可以从中检索
Task<TResult>
执行的
Func<TResult>
返回的值。
值得注意的是,这个例子中没有调用
task.Start()
,而是使用了
Task
的静态
Factory
属性的
StartNew()
方法。这样做的结果与实例化
Task
类似,但
Task.Factory.StartNew<TResult>()
返回的任务已经开始执行。除非需要将任务的实例化和调度分开,否则通常使用
StartNew()
就足够了。
除了
Task
的
IsCompleted
属性外,还有几个其他属性值得关注:
| 属性 | 描述 |
| ---- | ---- |
|
Status
| 返回一个
System.Threading.Tasks.TaskStatus
枚举,表示任务的状态。值包括
Created
、
WaitingForActivation
、
WaitingToRun
、
Running
、
WaitingForChildrenToComplete
、
RanToCompletion
、
Canceled
和
Faulted
。 |
|
IsCompleted
| 当任务完成时(无论是否出错)设置为
true
。只要
Status
为
RanToCompletion
、
Canceled
或
Faulted
,
IsCompleted
就为
true
。 |
|
Id
| 任务的唯一标识符。在调试多线程问题(如竞态条件和死锁)时特别有用。 |
|
AsyncState
| 可用于跟踪额外的数据。例如,可以将列表索引存储在
AsyncState
属性中,以便在任务完成时将结果放入列表的正确位置。 |
|
Task.CurrentId
|
Task
的静态属性,返回当前正在执行的
Task
的标识符。由于该属性是静态的,因此在任何地方都可以使用,主要用于调试和诊断活动。 |
6. 任务链与
ContinueWith()
方法
Task
包含一个
ContinueWith()
方法,用于将任务链接在一起,使得链中的第一个任务完成后,会触发注册在其后执行的任务。由于
ContinueWith()
方法返回另一个
Task
,因此可以继续添加更多的工作链。
有趣的是,可以使用
ContinueWith()
添加多个任务,并且这些“后续任务”可以在前置任务完成后立即开始执行。此外,当在同一个前置任务实例上多次调用
ContinueWith()
时,所有添加的任务将在前置任务完成时并行运行。
以下是
TaskContinuationOptions
枚举的可用标志及其描述:
| 枚举 | 描述 |
| ---- | ---- |
|
None
| 默认的延续选项,表示异步继续执行,没有特殊的任务选项。指定“后续任务”应在前置任务完成时执行,无论前置任务的最终
System.Threading.Tasks.TaskStatus
如何。 |
|
PreferFairness
| 向
System.Threading.Tasks.TaskScheduler
提示,应尽可能公平地调度任务,即调度较早的任务更有可能较早运行,调度较晚的任务更有可能较晚运行。 |
|
LongRunning
| 指定任务将是一个长时间运行、粗粒度的操作。向
System.Threading.Tasks.TaskScheduler
提示可能需要过度订阅。 |
|
AttachedToParent
| 指定任务在任务层次结构中附加到父任务。 |
|
NotOnRanToCompletion*
| 指定如果前置任务成功完成,则不应调度后续任务。此选项对于多任务延续无效。 |
|
NotOnFaulted*
| 指定如果前置任务抛出未处理的异常,则不应调度后续任务。此选项对于多任务延续无效。 |
|
OnlyOnCanceled*
| 指定只有当前置任务被取消时,才应调度后续任务。此选项对于多任务延续无效。 |
|
NotOnCanceled*
| 指定如果前置任务被取消,则不应调度后续任务。此选项对于多任务延续无效。 |
|
OnlyOnFaulted*
| 指定只有当前置任务抛出未处理的异常时,才应调度后续任务。此选项对于多任务延续无效。 |
|
OnlyOnRanToCompletion*
| 指定只有当前置任务成功完成时,才应调度后续任务。此选项对于多任务延续无效。 |
|
ExecuteSynchronously
| 指定后续任务应同步执行。指定此选项后,后续任务将在导致前置任务进入最终状态的同一线程上运行。如果在创建后续任务时前置任务已经完成,则后续任务将在创建它的线程上运行。 |
带有星号(*)的项对于“注册”前置任务的行为“通知”特别有用。以下是一个示例:
using System;
using System.Threading.Tasks;
public class Program
{
public static void Main()
{
Task<string> task = Task.Factory.StartNew<string>(() => PiCalculator.Calculate(10));
Task faultedTask = task.ContinueWith(
(antecedentTask) =>
{
System.Diagnostics.Trace.Assert(task.IsFaulted);
Console.WriteLine("Task State: Faulted");
},
TaskContinuationOptions.OnlyOnFaulted);
Task canceledTask = task.ContinueWith(
(antecedentTask) =>
{
//Trace.Assert(task.IsCanceled);
Console.WriteLine("Task State: Canceled");
},
TaskContinuationOptions.OnlyOnCanceled);
Task completedTask = task.ContinueWith(
(antecedentTask) =>
{
System.Diagnostics.Trace.Assert(task.IsCompleted);
Console.WriteLine("Task State: Completed");
},
TaskContinuationOptions.OnlyOnRanToCompletion);
completedTask.Wait();
}
}
在这个示例中,我们有效地为前置任务的“事件”进行了注册,这样如果事件发生,相应的“监听”任务就会开始执行。这是一个强大的功能,特别是在对任务采用“即发即弃”的行为时,不需要对任务调用
Wait()
类型的方法。我们可以直接调用
Start()
或
Factory.StartNew()
,注册“通知”,然后丢弃对任务的引用。任务将异步开始执行,无需后续代码来“检查”状态。在这个例子中,我们保留了
completedTask.Wait()
调用,以确保程序在完成输出显示之前不会退出。
需要注意的是,不能成功地对
canceledTask
或
faultedTask
调用
Wait()
,因为这些任务没有也不会完成工作,所以没有什么可等待的。示例中的延续选项是互斥的,因此当前置任务成功完成时,只有
completedTask
会执行。
综上所述,多线程编程虽然复杂,但通过合理使用 .NET 提供的 API,如 TPL 和 PLINQ,以及掌握线程的基本概念和操作方法,我们可以充分发挥计算机多核处理器的性能,提高程序的执行效率。同时,要特别注意处理好原子性、死锁和不确定性等问题,确保多线程程序的正确性和稳定性。
多线程编程全面解析
7. 并行循环与并行 LINQ
在多线程编程中,并行循环和并行 LINQ 是提高效率的重要手段。
7.1 并行循环
在 .NET 中,提供了
Parallel.For()
和
Parallel.ForEach<T>()
方法用于并行执行循环。这两个方法可以自动将循环任务分配到多个线程上执行,从而充分利用多核处理器的性能。
Parallel.For()
方法用于执行固定次数的循环,示例代码如下:
using System;
using System.Threading.Tasks;
class Program
{
static void Main()
{
Parallel.For(0, 10, i =>
{
Console.WriteLine($"Processing iteration {i} on thread {Task.CurrentId}");
});
}
}
在上述代码中,
Parallel.For()
方法会将从 0 到 9 的循环迭代分配到多个线程上并行执行。每个迭代的执行顺序可能不同,但最终都会完成。
Parallel.ForEach<T>()
方法用于对集合中的每个元素进行并行处理,示例代码如下:
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
class Program
{
static void Main()
{
List<int> numbers = new List<int> { 1, 2, 3, 4, 5 };
Parallel.ForEach(numbers, number =>
{
Console.WriteLine($"Processing number {number} on thread {Task.CurrentId}");
});
}
}
这个方法会将集合中的每个元素分配到不同的线程上进行处理,提高处理效率。
7.2 并行 LINQ(PLINQ)
并行 LINQ(PLINQ)是 LINQ 的并行版本,它可以自动将查询操作并行化,从而加快查询的执行速度。PLINQ 通过
AsParallel()
扩展方法将普通的 LINQ 查询转换为并行查询。示例代码如下:
using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
static void Main()
{
List<int> numbers = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
var result = numbers.AsParallel()
.Where(n => n % 2 == 0)
.Select(n => n * n);
foreach (var number in result)
{
Console.WriteLine(number);
}
}
}
在上述代码中,
AsParallel()
方法将
numbers
集合转换为并行查询,
Where
和
Select
操作会在多个线程上并行执行,从而提高查询效率。
8. 多线程编程中的异常处理
在多线程编程中,异常处理是一个重要的问题。由于多个线程可能同时抛出异常,处理不当可能会导致程序崩溃或数据不一致。
在使用
Task
进行多线程编程时,可以通过
try-catch
块来捕获异常。示例代码如下:
using System;
using System.Threading.Tasks;
class Program
{
static void Main()
{
Task task = Task.Run(() =>
{
throw new Exception("An error occurred in the task.");
});
try
{
task.Wait();
}
catch (AggregateException ex)
{
foreach (var innerException in ex.InnerExceptions)
{
Console.WriteLine($"Exception: {innerException.Message}");
}
}
}
}
在上述代码中,
Task.Run()
方法启动一个异步任务,该任务抛出一个异常。在主线程中,使用
task.Wait()
等待任务完成,并使用
try-catch
块捕获可能抛出的
AggregateException
异常。
AggregateException
包含了所有子任务抛出的异常,可以通过
InnerExceptions
属性遍历这些异常。
在并行循环和并行 LINQ 中,异常处理也是类似的。如果在并行操作中抛出异常,会被包装成
AggregateException
,需要通过遍历
InnerExceptions
来处理每个具体的异常。
9. 多线程编程中的取消操作
在多线程编程中,有时需要取消正在执行的任务。.NET 提供了取消机制来实现这一点。
9.1 取消任务
可以使用
CancellationTokenSource
和
CancellationToken
来取消任务。示例代码如下:
using System;
using System.Threading;
using System.Threading.Tasks;
class Program
{
static void Main()
{
CancellationTokenSource cts = new CancellationTokenSource();
CancellationToken token = cts.Token;
Task task = Task.Run(() =>
{
while (!token.IsCancellationRequested)
{
Console.WriteLine("Task is running...");
Thread.Sleep(100);
}
token.ThrowIfCancellationRequested();
}, token);
// 模拟一段时间后取消任务
Thread.Sleep(1000);
cts.Cancel();
try
{
task.Wait();
}
catch (AggregateException ex)
{
foreach (var innerException in ex.InnerExceptions)
{
if (innerException is TaskCanceledException)
{
Console.WriteLine("Task was canceled.");
}
else
{
Console.WriteLine($"Exception: {innerException.Message}");
}
}
}
}
}
在上述代码中,
CancellationTokenSource
用于创建一个取消令牌源,
CancellationToken
用于传递取消信号。在任务中,通过检查
token.IsCancellationRequested
属性来判断是否需要取消任务。当调用
cts.Cancel()
方法时,会触发取消信号,任务会抛出
TaskCanceledException
异常。
9.2 取消并行循环和 PLINQ 查询
在并行循环和 PLINQ 查询中,也可以使用取消令牌来取消操作。示例代码如下:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
class Program
{
static void Main()
{
CancellationTokenSource cts = new CancellationTokenSource();
CancellationToken token = cts.Token;
List<int> numbers = new List<int> { 1, 2, 3, 4, 5 };
try
{
Parallel.ForEach(numbers, new ParallelOptions { CancellationToken = token }, number =>
{
if (token.IsCancellationRequested)
{
token.ThrowIfCancellationRequested();
}
Console.WriteLine($"Processing number {number}");
});
var result = numbers.AsParallel()
.WithCancellation(token)
.Where(n => n % 2 == 0)
.Select(n => n * n);
foreach (var number in result)
{
if (token.IsCancellationRequested)
{
token.ThrowIfCancellationRequested();
}
Console.WriteLine(number);
}
}
catch (OperationCanceledException)
{
Console.WriteLine("Operation was canceled.");
}
// 模拟一段时间后取消操作
Thread.Sleep(500);
cts.Cancel();
}
}
在并行循环中,通过
ParallelOptions
对象传递取消令牌。在 PLINQ 查询中,使用
WithCancellation()
方法传递取消令牌。当取消信号触发时,操作会抛出
OperationCanceledException
异常。
10. 多线程编程的性能优化
多线程编程的目的是提高程序的性能,但如果使用不当,可能会导致性能下降。以下是一些多线程编程的性能优化建议:
-
合理使用线程池
:创建线程是一个相对昂贵的操作,因此应尽量使用线程池来重用线程。在 .NET 中,
Task
会自动从线程池请求线程,避免了频繁创建和销毁线程的开销。
-
避免锁竞争
:锁是用于解决多线程同步问题的重要手段,但如果锁的粒度太大或锁的竞争过于激烈,会导致性能下降。应尽量减少锁的使用,或者使用更细粒度的锁。
-
避免线程饥饿
:线程饥饿是指某些线程由于资源不足而无法执行。在设计多线程程序时,应合理分配资源,避免某些线程长时间等待资源。
-
使用并行算法
:对于可以并行执行的任务,应使用并行算法,如并行循环和并行 LINQ,充分利用多核处理器的性能。
11. 总结
多线程编程是提高程序性能的重要手段,但也带来了复杂性和挑战。通过合理使用 .NET 提供的多线程 API,如 TPL、PLINQ,以及掌握线程的基本概念和操作方法,可以有效地利用多核处理器的性能。同时,要注意处理好多线程编程中的原子性、死锁、不确定性、异常处理和取消操作等问题,确保程序的正确性和稳定性。在实际开发中,还需要根据具体情况进行性能优化,以达到最佳的性能效果。
以下是一个简单的多线程编程流程图,展示了创建任务、执行任务和处理异常的基本流程:
graph TD;
A[创建 CancellationTokenSource 和 CancellationToken] --> B[创建任务];
B --> C{任务是否取消};
C -- 否 --> D[执行任务];
D --> E{任务是否抛出异常};
E -- 是 --> F[捕获 AggregateException 或 OperationCanceledException];
F --> G[处理异常];
E -- 否 --> H[任务完成];
C -- 是 --> I[抛出 TaskCanceledException 或 OperationCanceledException];
I --> G;
通过以上的介绍,希望能帮助开发者更好地理解和掌握多线程编程的相关知识,在实际开发中编写出高效、稳定的多线程程序。
超级会员免费看
747

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



