TLS:并发编程的实践






4.48/5 (11投票s)
关于多线程应用程序以及一个有用的辅助类的演练
目录
- 引言
- 背景
- 架构
- 练习 1:阻塞 UI 线程
- 练习 2:释放 UI 线程
- 练习 3:引入并发
- 练习 4:错误的示例
- 练习 5:使用线程本地存储类
- 附录 A:理解线程本地存储类
- 附录 B:与 ThreadStaticAttribute 的比较
- 附录 C:与 PFX Thread Local Selector 的比较
- 结论
- 参考文献
- 历史
引言
本文有两个目的。首先,有一系列五个练习,详细介绍了如何成功地将顺序算法多线程化,并在 UI 中进行及时进度显示。它还提供了一个线程本地存储类的实现,该类有助于实现多核计算机的性能潜力。
这些练习从初学者级别开始,但即使您已经精通并发编程,我也建议您至少浏览一下它们,因为它们是相互关联的。如果您时间紧迫,可以跳到附录 A 中有关新辅助类的说明。
代码使用了 Microsoft PFX 库 2007 年 12 月的 CTP 来隐藏一些底层细节。
背景
概述
计算机擅长计数,所以我选择了它作为测试负载。实际上,在最后一个示例(正确版本)中,只有 20% 的执行时间用于“工作”。其余时间都花在了基础设施上,但这不成问题,因为所有这些都是 CPU 需要处理的工作。有趣的部分是不同测试的相对比较,因此绝对吞吐量几乎无关紧要。
我说几乎,是因为如今很难完全领会普通计算机的速度。我们谈论的是数量级。时钟速度约为 109 Hz,如果您仔细想想,这确实令人震惊。光速为 3 x 108 m/s,因此在一个时钟周期内,即使光也只能传播大约十分之一米。换句话说,假设您身高 2 米。在光从您的脚传播到眼睛的时间里,一个核心可以执行 20 条指令。它们很快。
然而,即使是这样的强大能力,对于某些任务来说也不够。由于时钟速度不太可能显著提高,当前的硬件趋势是朝着多核和超多核计算机发展。正确高效地使用这些机器以提高性能是本文所探讨的场景。本文面向希望获得现代计算机上并发软件能够实现的规模化性能改进的人群。
必须指出的是,硬件仍处于起步阶段。我使用的八核机器目前被认为是高端的,但它理论上提供的最大性能提升不到一个数量级。如果您的用户拥有双核甚至四核机器,那么潜在的收益显然会更少。我毫不怀疑超多核机器即将到来,但它们不会在明天就出现。我认为,这就是为什么库提供商(尤其是 Microsoft)的支持才刚刚开始出现的原因。
编写正确且高效的并发软件目前并不容易,而且我看不出有任何实质性改变的迹象。这需要知识和智慧,但也需要一丝不苟的方法。不要期望添加 .AsParallel()
就能神奇地实现您的目标。本文将引导您完成利用用户硬件所需的思考过程,我希望它能引发更多问题而非提供答案。
PFX
Microsoft 的 Parallel Extensions 库用于通过移除线程控制代码来简化代码。目前它基本上只做这一点,但它做得相当不错。我对这个库的第一印象是失望,因为它没有做得更多,但如果您不对它抱有过高的期望,它还是有用的。另外,请记住,在撰写本文时,它只是一个早期 CTP。
硬件
我在四台机器上测试了此应用程序
- 2 核:双 Athlon 运行 Server 2003 R2
- 2 核:Athlon 64 X2 运行 Server 2003 R2 x64
- 2 核:Core 2 Duo 运行 Vista Ultimate x64
- 8 核:双四核 Xeon 运行 XP Pro x64
测试的相对性能在所有机器上都是一致的。引用的性能指标来自 8 核机器。
架构
TestAttribute
构成练习的五个测试类都带有此自定义属性。它具有 Index
和 Name
属性,用于标识测试。
TestInfo
此类保存每个测试类的相关信息,并可以实例化它们。它还包含一些静态成员,通过反射搜索程序集,以构建 UI 使用的完整测试列表。它还有几个 DependencyProperties
,用于保存最新的测试结果,同样供 UI 使用。
控制器 (Controller)
此类接收一个 TestInfo
并创建一个测试类的实例,这些类都继承自 TestBase
。它启动测试并记录直到完成所经过的时间。然后,它计算结果并将它们存储在 TestInfo
实例中以供 UI 显示。它还保存当前完成的进度百分比的计算。
TestBase
这是练习中五个测试的基类。它实现了所有通用功能,包括创建线程、运行它们以及在它们完成时触发 Finished
事件。
因素
每个测试类都重写此 virtual
属性以设置要执行的迭代次数。由于测试性能差异高达三个数量级,因此有必要单独控制每个测试,使其在合理的时间内执行。每个测试旨在在几秒钟内完成。
具体测试
五个具体测试类都继承自 TestBase
。它们重写 Work
方法,该方法在测试开始时被调用。这允许它们设置线程(在某些情况下),并提供将在每次迭代中调用的方法。此方法始终称为 Update
,尽管这不是必需的。Update
方法通常设置 TestBase.Done
属性,该属性由 UI 通过 Controller
实例作为百分比读取。最后一个示例是一个例外,它仅在需要时才计算已完成的工作。
用户界面
用户界面是一个简单的 WPF 应用程序。它在左侧列出每个测试,并通过双击来启动。随着测试的执行,它以 100 毫秒的间隔更新进度条。它使用 System.Threading.Timer
来实现。在测试运行期间,您可以通过按 “STOP” 按钮来取消。测试完成后,结果将被存储并显示。
UI 每秒只更新十次,因为这足以向用户提供足够的信息。加快应用程序速度的最佳方法是使其工作量更少!更频繁地计算进度只会减慢应用程序的速度,而对用户没有任何明显的好处。
练习 1:阻塞 UI 线程
本练习提出了问题:在 UI 线程上执行计算密集型任务。
// do not do this
[Test( 1, "Blocking" )]
partial class TLS1Blocking : TestBase
{
public override bool Block { get { return true; } }
protected override void Work()
{
DoWork( Update );
}
void Update( long i )
{
Done = i;
}
}
此示例绝对不推荐。它重写了 Block
属性以返回 true
。这会导致 DoWork
方法在调用线程(即 UI 线程)上执行。这意味着 UI 会一直阻塞直到测试完成。这不好。
Update
方法仅设置 Done
,这是大多数测试中用于向 UI 反馈的基类属性。当然,由于 UI 被阻塞,它无法自行更新,所以这有点多余。除了编译器不知道这一点,因此无法优化掉赋值。
因此,它确实提供了一个良好的基本数据点。此测试在我的机器上执行速度约为 **150 MIPS**(每秒百万次迭代)。这是所有其他测试的比较基准。
练习 2:释放 UI 线程
本练习的目的是使 UI 重新响应。
// recommended sequential pattern
[Test( 2, "Sequential" )]
partial class TLS2Sequential : TestBase
{
protected override void Work()
{
DoWork( Update );
}
void Update( long i )
{
Done = i;
}
}
这与 #1 相同,只是它不重写 Block
属性。默认值为 false
,这会导致基类生成一个线程来执行 Work
方法。
new Thread( new ThreadStart( WorkMaster ) ) { IsBackground = true }.Start();
这基本上意味着 UI 线程仅负责创建新线程,然后返回其自身职责,这是应该的。因此,UI 可以保持最新并正确报告测试进度。此技术也用于所有后续测试。
Work
方法不再创建任何线程,因此迭代在单个主线程上执行。这与 #1 非常相似,只是 UI 已正确更新。因此,此测试的执行速度也约为 **150 MIPS**,这并不令人意外。
练习 3:引入并发
本练习将尝试使用所有可用核心。
// do not do this
[Test( 3, "Wrong" )]
class TLS3Wrong : TestBase
{
public override bool Multithreaded { get { return true; } }
protected override void Work()
{
Parallel.Do( CreateActions( Update ) );
}
void Update( long i )
{
Done = i * Glob.CoreCount;
}
}
我们在本次测试中更改了几项内容
首先,我们重写了 Multithreaded
属性以返回 true。这告诉 TestBase
我们将在机器上的每个核心上执行工作,这使其能够正确报告总进度。同样,这也在所有后续测试中设置。
第二个更改是 Work
方法,它初始化测试。Parallel.Do
是 Microsoft PFX 库提供的一个方法。它为客户端机器上的每个物理核心创建一个线程,并使用它们来运行提供的 Action
。CreateActions
方法在基类中,它创建一个相同的 Action
数组来填充可用核心。因此,此 Work
方法在我的 8 核机器上设置了 8 个线程。
最后一个更改是 Update
方法。Glob.CoreCount
是 System.Environment.ProcessorCount
属性的缓存,后者通常是机器上的核心数。之所以进行缓存,是因为获取它的速度相当慢。由于我们已告知基类此测试是多线程的,因此我们必须乘以核心数,以便在完成时进度百分比报告为 100%。
这有点作弊。我们现在有 8 个线程都在以 8 为步长计数,因此每个线程报告的进度是其实际进度的 8 倍。最终结果将是正确的,但如果您在多核机器上运行此程序,您会看到 UI 中的进度条跳跃,因为不同的线程运行速度不同。因此,此测试不显示正确的行为。
运行多线程时,您不能期望每个线程在相同的时间内完成相同的工作量。内核负责为每个线程分配时间片。算法和物理效应非常复杂,这基本上是一个非确定性过程。例如,核心可能并非始终以相同的速度运行。如果您给机器施加压力,CPU 会发热,当它们过热时,它们会减慢速度以防止永久性物理损坏。此外,您的系统上总是有其他东西在运行,即使只是操作系统本身。这会导致以几乎随机的方式争用资源。所有这些因素加在一起意味着您无法预测特定线程的运行速度。
运行时,此测试最好能达到 **30 MIPSPC**(每核每秒百万次迭代)。但是,由于它使用了所有 8 个核心,总共约等于 **240 MIPS**,大约是顺序算法性能的两倍。
这并不好;每个核心的运行速度是基础速度的五分之一。这是因为线程在写入共享 Done
属性时会相互阻塞。解释有点复杂。请记住,共享状态是性能瓶颈。
此结果与内存模型有关。强内存模型保证 CPU 执行的实际指令更接近于您在顺序查看代码时所期望的那样。弱内存模型允许 CPU 重排读写操作作为一种优化技术。C# 的内存模型实际上很弱,这使得编写正确的并发程序更加困难。对于普通状态,它只需要读写操作在特定线程内不移动即可。然而,x86 的内存模型实际上更强,因此 C# 允许的优化实际上从未发生过。这是一个问题,因为 Itanium 的内存模型较弱,所以如果您在 x86 上进行测试,您可能永远不会遇到用户在 Itanium 系统上运行您的程序时会遇到的问题。
当所有线程都尝试写入 Done
属性时产生的争用被 x86 内存模型加剧了。这基本上将所有写入视为易失性,这意味着每次写入都必须通过所有 CPU 缓存到主内存。这有效地阻塞了每个线程很长时间,导致每个核心的性能损失 80%。
所以,总而言之,尽管此测试使用了所有可用的计算能力,但它既不正确又速度缓慢。
练习 4:错误的示例
本练习将重点放在使程序正确。
// do not do this
[Test( 4, "Worse" )]
partial class TLS4Worse : TestBase
{
long _Count = 0;
protected override void Work()
{
_Count = 0;
Parallel.Do( CreateActions( Update ) );
}
void Update( long i )
{
Done = Interlocked.Increment( ref _Count );
}
}
#3 不正确的原因是每个线程都在写入 Done
属性,而没有与其他线程进行任何同步。在本测试中,将使用 System.Threading.Interlocked
静态类来纠正这一点。这将使程序正确,但正如我们将看到的,这仍然不是一个可接受的解决方案。
Interlocked
类提供了静态方法来作为原子操作来修改变量。这意味着,当方法执行时,它实际上不会被访问同一变量的任何其他线程中断。对于 Increment
方法,这可以防止以下场景。假设两个线程要递增一个变量。它们都读取旧值,在其上加一,然后将更新后的值写回内存。这两个线程独立地在不同核心上同时执行此操作。问题是两个线程都写入相同的值,因此变量只增加了 1,而不是增加了两次。
因此,使用 Interlocked.Increment
应该有效,而且确实有效。使用成员字段 _Count
是因为您不能将属性作为 ref
参数传递。写入 Done
属性又是一个竞态条件,就像 #3 一样,但在此情况下写入的值将足够正确。第一个弄清楚为什么它仍然不完全正确的人将获得一个五星好评。
然而,考虑到性能,我们不应期望比 #3 有任何改进。事实上,由于 Interlocked.Increment
调用引起的额外工作和争用,此测试的性能会差得多。结果大约是 **1 MIPSPC**,在 8 核上总共是 **8 MIPS**。您此时可能会意识到,在紧密循环中使用 Interlocked
方法不是一个好主意。虽然您可以使您的程序正确执行,但性能会受到影响。
这里要吸取的教训是,共享状态是性能瓶颈,即使您可以使其正确执行。虽然此测试足够正确,并且使用了所有可用的核心,但即使在 8 核机器上,其性能也比顺序算法差一个数量级。我们可以做得更好。
练习 5:使用线程本地存储类
本练习将解决性能问题,同时保持代码的正确性。
// this is the recommended strategy
[Test( 5, "Right" )]
partial class TLS5Right : TestBase
{
public override long Done
{
get
{
var tls = TLS;
if ( tls == null ) return 0;
return tls.Results.Sum( s => s.Done );
}
}
protected override void Work()
{
TLS = new Common.TLS<State>();
Parallel.Do( CreateActions( Update ) );
}
void Update( long i, State state )
{
++state.Done;
}
}
partial class TestBase
{
protected class State
{
public long Done = 0;
}
}
在此测试中,我们引入了一个名为 TLS
的类,代表线程本地存储。此类执行两项操作。首先,它为每个线程提供其自己的 State
类泛型参数实例。有其他方法可以做到这一点,但它还提供了第二个功能。它公开了一个类型为 ICollection<State>
的 Results
属性,该属性允许任何线程访问为所有线程创建的 State
的所有实例。
Update
方法接受一个 State
参数,该参数对每个线程都是唯一的。这意味着没有共享状态,每个线程都可以更新提供的 State
实例的成员,而无需担心任何其他线程。这是一个非常强大的技术。它基本上消除了内部工作循环中任何同步的要求。
如果您还记得,UI 线程每秒访问十次当前已完成的工作百分比。从硬件的角度来看,这非常慢,但对用户来说足够快。因此,此测试将计算推迟到需要时进行,方法是重写 Done
属性。它可以在每个线程的每次迭代中进行计算,但这将大大增加工作量,而没有任何收益。再次记住,最快的工作就是不做任何工作。
所以,我们已经消除了内部循环中的阻塞同步,它的性能如何?我们达到了 **160 MIPSPC**,这实际上比顺序算法稍快一些。但我们也运行在 8 核上,总共提供 **1,300 MIPS**。因此,在 8 核机器上,我们实现了大约 8 倍的性能提升。不错。此外,此算法是正确的,并且可以随着您提供的核心数量而扩展。
附录 A:理解线程本地存储类
这是 TLS
类的完整实现
using System;
using System.Collections.Generic;
using System.Threading;
namespace Common
{
public class TLS<DATA> where DATA : class, new()
{
volatile Dictionary<int, DATA> _List =
new Dictionary<int, DATA>();
volatile object _Key = new object();
public DATA Current
{
get
{
int thread = Thread.CurrentThread.ManagedThreadId;
DATA tls = null;
if ( !_List.TryGetValue( thread, out tls ) )
{
lock ( _Key )
{
if ( !_List.TryGetValue( thread, out tls ) )
{
tls = new DATA();
Dictionary<int, DATA> list =
new Dictionary<int, DATA>( _List );
list.Add( thread, tls );
Thread.MemoryBarrier();
_List = list;
}
}
}
return tls;
}
}
public ICollection<DATA> Results
{
get
{
lock ( _Key ) return new List( _List.Values );
}
}
}
}
代码不多,但它做了很多非常有用的事情,而且我尽可能地使其可靠。
状态是泛型类型参数 <DATA>
的集合。每个 ManagedThreadId
会创建此类的实例。集合是名为 _List
的 Dictionary<int, DATA>
。_Key
对象是本地同步对象;
Current
属性创建或检索在其上调用的线程的 DATA
实例。它使用“双重检查锁定”模式来实现。这意味着在为线程创建实例后,访问该实例无需锁定。这使得获取实例相当快,但即使如此,也应尽可能少地使用它。get
方法的锁定部分相当标准,并在网上得到了充分的记录。我所做的唯一更改是添加了一个 Thread.MemoryBarrier
调用。专家们对这是否必要存在分歧,所以我在此包含它以确保万无一失。问题是实例化 DATA
实例所需的写入操作可能会移动到设置 _List
字段的指令之后。这可能意味着另一个线程在使用其 DATA
实例之前就使用了它,而它尚未完全实例化。我认为这种情况不会在此实现中发生,因为 ManagedThreadId
对于线程应该是唯一的。因此,lock
语句(这是一个内存屏障)必须在实例返回到调用线程之前完成。如果调用是冗余的,我们损失很小,因为这条执行路径每个线程只应执行一次。我甚至听说 Thread.MemoryBarrier
在 x86 系统上是 NOP
。
Results
属性以线程安全的方式封装了对为所有线程创建的 DATA
实例的访问。它必须返回一个新集合,因为它不知道调用线程将保留返回值多长时间。如您所见,它每次都进行 lock
,因此不应过于频繁地调用。这通常不是问题,因为结果很可能在 UI 中使用,而 UI 只需要运行得足够快以满足用户需求,这对硬件来说是极其缓慢的速度。
附录 B:与 ThreadStaticAttribute 的比较
当 ThreadStaticAttribute
应用于静态字段时,每个线程都会获得一个新值。从这个意义上说,它似乎与 TLS
类做的事情相同。此外,在压力测试中,它的性能大约是 TLS.Current
的两倍。但是,据我所知,没有办法访问为其他线程创建的实例。这意味着您无法生成跨所有线程的汇总或结果数据。这就是 TLS
类解决的问题。
附录 C:与 PFX Thread Local Selector 的比较
一些 PFX 方法允许您选择线程本地数据,例如
partial class Parallel
{
public static void ForEach<TSource, TLocal>(
IEnumerable<TSource> source,
Func<TLocal> threadLocalSelector,
Action<TSource, int, ParallelState<TLocal>> body,
Action<TLocal> threadLocalCleanup
)
{...}
}
这与上面的 ThreadStaticAttribute
有同样的问题,即您无法访问另一个线程的私有实例。threadLocalCleanup
Action
通过让您有机会在使用结果的最后时刻(线程完成之前)使用它们来稍微软化这一点。但是,您仍然无法在线程执行期间访问它。
您可以组合这两种技术,如下所示
var tls = new TLS<State>();
Parallel.ForEach(
collection,
() => tls.Current,
( data, index, parallelState ) =>
{
State state = parallelState.ThreadLocalState;
...
} );
结论
我希望本文能解答您关于并发编程的一些疑问。为了控制篇幅,我没有深入探讨太多。我写作的目的是真正引起人们对该主题的兴趣,正如我在开头所说,激发更多问题。当然,您可以使用 TLS
类,但须遵守以下许可证。
感谢阅读。
参考文献
MS 并行计算开发中心 [^]历史
2008 年 5 月 11 日 | v1.0 | 首次发布 |