多线程编程全面解析
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操作。此时切换到其他线程可以提高执行效率,因为处理器不必闲置等待操作完成。然而,线程切换也会带来一定的开销。如果线程数量过多,切换开销会显著影响性能,增加更多线程可能会进一步降低性能,因为处理器会花费大量时间在不同线程之间切换,而不是执行每个线程的工作。
多线程编程的复杂性主要不在于编写多线程程序,而在于如何保证原子性、避免死锁以及消除执行不确定性(如竞态条件)。
- 原子性 :以银行账户转账为例,代码首先会检查账户是否有足够的资金,如果有则进行转账。但如果在检查资金后,另一个线程取走了这些资金,那么当原线程继续执行时,可能会发生无效转账。通过控制账户访问,确保同一时间只有一个线程可以访问账户,就能解决这个问题,使转账操作具有原子性。
一组操作满足以下两个条件之一时,就具有原子性:
- 整个操作集必须在任何操作看起来执行之前完成。
- 系统的表观状态必须返回到任何操作执行之前的状态,就好像没有执行任何步骤一样。
在银行转账的例子中,尽管转账操作由多个步骤组成,但整个操作集必须是一个原子操作。在执行每个步骤的过程中,直到整个操作集完成之前,都不应发生中断(如取款)。如果整个操作集没有完成,那么看起来就好像没有执行任何操作(例如,不能从一个账户扣除资金而不将其存入另一个账户)。识别和实现原子性是多线程编程的主要复杂性之一。
不幸的是,大多数C#语句都不是原子性的。例如,
Count++
在C#中是一个简单的语句,但它会被处理器转换为多个指令:
1. 处理器读取
Count
中的数据。
2. 处理器计算新的值。
3.
Count
被赋予新的值(甚至这一步也可能不是原子性的)。
在数据被访问但新值还未赋值之前,另一个线程可能会修改原始值(可能在修改之前也会检查该值),从而产生竞态条件,因为至少从一个线程的角度来看,
Count
的值意外地发生了变化。
- 死锁 :为了避免竞态条件,编程语言支持将代码块限制为特定数量的线程访问,通常是一个线程。然而,如果线程之间获取锁的顺序不同,可能会发生死锁,导致线程冻结,每个线程都在等待另一个线程释放锁。
例如:
graph LR
classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px;
A(Thread A):::process -->|Acquires a lock on a| B(Requests a lock on b);
C(Thread B):::process -->|Acquires a lock on b| D(Requests a lock on a);
B -->|Deadlocks, waiting for b| E(Blocked);
D -->|Deadlocks, waiting for a| F(Blocked);
在这个例子中,每个线程都在等待另一个线程继续执行,因此每个线程都被阻塞,导致代码执行出现整体死锁。
- 不确定性 :非原子性或导致死锁的代码的问题在于,它依赖于多个线程中处理器指令的执行顺序。这种依赖性会引入程序执行的不确定性。一个指令相对于另一个线程中的指令的执行顺序是未知的。很多时候,代码看起来会表现得很一致,但偶尔会出现不一致的情况,这正是多线程编程的关键问题。由于这种竞态条件很难在实验室中重现,因此多线程代码的质量保证在很大程度上依赖于长时间的压力测试、专门设计的代码分析工具以及大量的代码分析和审查工作。
4. 运行和控制单独的线程
操作系统实现线程并提供各种非托管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
完成。以下示例展示了
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
类型)。泛型版本的
Task
包含一个
Result
属性,用于检索
Task<TResult>
执行的
Func<TResult>
返回的值。
另一个值得注意的特点是,这个示例中没有调用
task.Start()
,而是使用了
Task
的静态
Factory
属性的
StartNew()
方法。这样做的结果与实例化
Task
类似,但
Task.Factory.StartNew<TResult>()
的返回值已经启动。除非需要将
Task
的实例化与调度分开,否则通常使用
StartNew()
就足够了。
除了
Task
的
IsCompleted
属性外,还有几个其他属性值得注意:
| 属性 | 描述 |
| ---- | ---- |
|
Status
| 返回一个
System.Threading.Tasks.TaskStatus
枚举,表示
Task
的状态。值包括
Created
、
WaitingForActivation
、
WaitingToRun
、
Running
、
WaitingForChildrenToComplete
、
RanToCompletion
、
Canceled
和
Faulted
。 |
|
IsCompleted
| 当
Task
完成时(无论是否出错),该属性设置为
true
。当
Status
为
RanToCompletion
、
Canceled
或
Faulted
时,
IsCompleted
为
true
。 |
|
Id
|
Task
的唯一标识符。在调试多线程问题(如竞态条件和死锁)时特别有用。 |
|
AsyncState
|
Id
属性可用于识别
Task
,例如为其命名。此外,
AsyncState
可以跟踪额外的数据。例如,假设有一个
List<T>
,多个
Task
正在计算其中的值。一种将结果放入列表正确位置的方法是将目标列表索引存储在
AsyncState
属性中。这样,当
Task
完成时,代码可以使用
AsyncState
(先将其转换为
int
)来索引列表。(注意,在多个线程中调用
List<T>.Add()
不是一个安全的操作,调用它可能会导致竞态条件,从而可能导致数据丢失。) |
|
Task.CurrentId
|
Task
的静态属性,返回当前正在执行的
Task
的标识符(即执行
Task.CurrentId
调用的
Task
)。由于该属性是静态的,因此在任何地方都可以使用,主要用于调试和诊断活动。 |
5. 任务链与
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
| 指定后续任务应同步执行。指定此选项后,后续任务将在导致前置任务进入最终状态的同一线程上运行。如果在创建后续任务时前置任务已经完成,则后续任务将在创建它的线程上运行。 |
以下示例展示了如何使用
ContinueWith()
注册“通知”:
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) =>
{
//System.Diagnostics.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()
调用,以确保程序在完成输出显示之前不会退出。
需要注意的是,如果不明确指定等待操作,即使任务尚未完成执行,程序也会退出。
Wait()
方法是将任务与调用线程连接起来的一种方式,这样调用
Wait()
的线程将在另一个任务(即调用
Wait()
的实例)完成后才会继续执行。通常,这是必要的,因为一个任务依赖于另一个任务的效果或结果。
另外,不能成功地在
canceledTask
或
faultedTask
上调用
Wait()
,因为这些任务没有完成也不会完成工作,因此没有什么可等待的。示例中的延续选项是互斥的,所以当前置任务成功完成时,只有
completedTask
会执行。
多线程编程是一个复杂但强大的领域,通过合理使用
Task
和相关的API,可以充分发挥计算机多核处理器的性能,提高程序的执行效率。但同时,也需要注意原子性、死锁和执行不确定性等问题,以确保程序的正确性和稳定性。
6. 并行循环与并行LINQ
在多线程编程中,并行循环和并行LINQ是两个重要的概念,它们可以进一步提高程序的执行效率。
6.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}");
});
}
}
这个示例中,
Parallel.ForEach()
方法会并行处理
numbers
列表中的每个元素。
6.2 并行LINQ(PLINQ)
并行LINQ是LINQ的扩展,它允许对查询进行并行执行,从而加快查询速度。以下是一个简单的PLINQ示例:
using System;
using System.Linq;
class Program
{
static void Main()
{
int[] numbers = Enumerable.Range(1, 1000).ToArray();
var result = numbers.AsParallel().Where(n => n % 2 == 0).ToList();
foreach (var num in result)
{
Console.WriteLine(num);
}
}
}
在这个示例中,
AsParallel()
方法将普通的LINQ查询转换为并行查询,
Where()
方法会并行筛选出偶数,最后将结果转换为列表。
7. 多线程编程中的异常处理
在多线程编程中,异常处理是一个重要的问题,因为多个线程可能同时抛出异常,需要正确处理以确保程序的稳定性。
7.1 未处理异常
在多线程程序中,如果某个线程抛出未处理的异常,可能会导致整个程序崩溃。因此,需要在合适的地方捕获和处理这些异常。以下是一个示例:
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 innerEx in ex.InnerExceptions)
{
Console.WriteLine($"Exception: {innerEx.Message}");
}
}
}
}
在这个示例中,
Task
抛出了一个异常,通过
task.Wait()
方法等待任务完成时,会捕获到
AggregateException
,其中包含了所有内部异常。
7.2 并行LINQ中的异常处理
在并行LINQ查询中,也可能会抛出异常。可以使用
WithDegreeOfParallelism()
方法来控制并行度,同时处理异常。示例如下:
using System;
using System.Linq;
class Program
{
static void Main()
{
int[] numbers = Enumerable.Range(1, 10).ToArray();
try
{
var result = numbers.AsParallel()
.WithDegreeOfParallelism(2)
.Select(n =>
{
if (n == 5)
{
throw new Exception("Error at number 5");
}
return n * 2;
})
.ToList();
}
catch (AggregateException ex)
{
foreach (var innerEx in ex.InnerExceptions)
{
Console.WriteLine($"Exception: {innerEx.Message}");
}
}
}
}
在这个示例中,当
n
等于5时会抛出异常,捕获
AggregateException
可以处理这些异常。
8. 任务取消
在多线程编程中,有时需要取消正在执行的任务,以避免不必要的资源浪费。
8.1 取消并行循环
可以使用
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(() =>
{
try
{
Parallel.For(0, 100, new ParallelOptions { CancellationToken = token }, i =>
{
if (token.IsCancellationRequested)
{
token.ThrowIfCancellationRequested();
}
Console.WriteLine($"Processing iteration {i}");
});
}
catch (OperationCanceledException)
{
Console.WriteLine("Parallel loop was canceled.");
}
});
// 模拟一段时间后取消任务
Thread.Sleep(100);
cts.Cancel();
try
{
task.Wait();
}
catch (AggregateException ex)
{
foreach (var innerEx in ex.InnerExceptions)
{
if (innerEx is OperationCanceledException)
{
Console.WriteLine("Task was canceled.");
}
}
}
}
}
在这个示例中,使用
CancellationTokenSource
创建一个取消令牌,在并行循环中检查令牌的状态,如果请求取消则抛出
OperationCanceledException
。
8.2 取消PLINQ查询
同样,可以使用
CancellationToken
来取消PLINQ查询。示例如下:
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
class Program
{
static void Main()
{
CancellationTokenSource cts = new CancellationTokenSource();
CancellationToken token = cts.Token;
int[] numbers = Enumerable.Range(1, 100).ToArray();
try
{
var result = numbers.AsParallel()
.WithCancellation(token)
.Select(n =>
{
if (token.IsCancellationRequested)
{
token.ThrowIfCancellationRequested();
}
return n * 2;
})
.ToList();
}
catch (OperationCanceledException)
{
Console.WriteLine("PLINQ query was canceled.");
}
// 模拟一段时间后取消任务
Thread.Sleep(100);
cts.Cancel();
}
}
在这个示例中,使用
WithCancellation()
方法将取消令牌应用到PLINQ查询中,在查询过程中检查令牌状态并处理取消请求。
9. 多线程编程总结
多线程编程可以充分利用计算机多核处理器的性能,提高程序的执行效率。但同时也带来了原子性、死锁、执行不确定性等复杂问题。以下是多线程编程的一些关键要点总结:
-
线程管理
:使用
Task
和线程池来管理线程,避免频繁创建和销毁线程,提高执行效率。
-
任务链
:通过
ContinueWith()
方法将任务链接起来,实现任务的顺序执行或并行执行。
-
并行循环和并行LINQ
:使用
Parallel.For()
、
Parallel.ForEach<T>()
和并行LINQ来并行执行循环和查询,加快程序运行速度。
-
异常处理
:捕获和处理多线程程序中的异常,避免程序崩溃。
-
任务取消
:使用
CancellationToken
来取消正在执行的任务,避免资源浪费。
通过合理运用这些技术,可以编写出高效、稳定的多线程程序。在实际开发中,需要根据具体的业务需求和场景,选择合适的多线程编程方法,同时注意处理好各种复杂问题,确保程序的正确性和可靠性。
以下是一个简单的多线程编程流程的mermaid流程图:
graph LR
classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px;
A(开始):::process --> B(创建任务):::process
B --> C(启动任务):::process
C --> D{任务是否完成}:::process
D -- 是 --> E(处理结果):::process
D -- 否 --> F{是否取消任务}:::process
F -- 是 --> G(取消任务):::process
F -- 否 --> D
G --> H(结束):::process
E --> H
这个流程图展示了一个基本的多线程任务执行流程,包括任务的创建、启动、完成判断、取消处理和结果处理等步骤。
多线程编程是一个不断发展和演进的领域,随着计算机硬件的不断发展和技术的进步,多线程编程的方法和工具也在不断完善。开发者需要不断学习和实践,才能更好地掌握多线程编程技术,编写出高质量的多线程程序。
超级会员免费看

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



