显示标签为“线程”的博文。显示所有博文
显示标签为“线程”的博文。显示所有博文

2009年4月24日星期五

再说 lock-free 编程

转帖自:http://www.cnblogs.com/lucifer1982/archive/2009/04/08/1431992.html

lock-free 编程实在让人又爱又恨。博主以前曾经写过几篇关于 lock-free 编程的文章。比如关于无锁编程、并发数据结构:迷人的原子。如果想更加深入的了解和实践 lock-free 编程,可以参考CLR 2.0 Memory Model、并发数据结构:Stack。这篇文章并不打算继续阐述如何使用 lock-free 技术,而是谈一下它的负面影响。从而让大家对 lock-free 有个更加全面的认识。

说到 lock-free 编程,现实中经常使用 CAS 原语。CAS 是英文 Compare and Swap 的简写。在 Windows 和 .NET 平台,由于历史原因,它被写做 Interlocked API。原子操作在 x86 架构 CPU 对应的汇编指令有 XCHG、CMPXCHG、INC 等,当然还得加上 LOCK 作为前缀(更多信息请看 并发数据结构:迷人的原子)。

CAS 原语在轻度和中度争用情况下确实可以大幅度提高程序性能。但凡事有利必有弊,CAS 原语极度扼杀了程序的可伸缩性(其他缺点请看关于无锁编程)。各位看官可能觉得这种观点有点偏激,但事实如此。请容博主细细道来:

  • CAS 的原子性完全取决于硬件实现。大多数 Intel 和 AMD 的 CPU 采用了一种叫做 MOSEI 缓存一致性协议来管理缓存。这种架构下,处理器缓存内 CAS 操作相对成本低廉。但一旦资源争用,就会引起缓存失效和总线占用。缓存越失效,总线越被占用,完成 CAS 操作也越被延迟。缓存争用是程序可伸缩性杀手。当然对于非 CAS 内存操作来说也是如此,但 CAS 情况更加槽糕。
  • CAS 操作要比普通内存操作花费更多 CPU 周期。这归功于缓存分级的额外负担、刷新写缓冲区与穿越内存栅栏限制和需求以及编译器对 CAS 操作优化的能力。
  • CAS 经常被用在优化并行操作上。这意味着 CAS 操作失败将导致重新尝试某些指令(典型的回滚操作)。即便没有任何争用,它也会做一些无用功。不论成功或失败都会增加争用的风险。

大多数 CAS 操作发生在锁进入和退出时。尽管锁可由单一 CAS 操作构建,但 .NET CLR Monitor 类却使用了两个(一个在 Enter 方法,另一个在 Exit 方法)。lock-free 算法也经常使用 CAS 原语来代替使用锁机制。但是由于内存重组,这样的算法也常常需要显式的栅栏,即便使用了 CAS 指令。锁机制非常邪恶,但大多数合格的开发人员都知道让锁持有尽量少的时间。因此,虽然锁机制让人非常讨厌,且影响性能。但相对于大量,频繁的 CAS 操作而言,它却并不影响程序的可伸缩性。

举个很简单的例子,增加计数 100,000,000 次。要做到这样,有几种方式。如果仅运行在单核单处理器上,我们可以使用普通的内存操作:

static volatile int counter = 0;
static void BaselineCounter()
{
for (int i = 0; i < Count; i++)
{
counter++;
}
}

很明显,上述代码示例不是线程安全的,但给计数器提供了一个很好的时间基准。下面我们使用 LOCK INC 来作为线程安全的第一种方式:

static volatile int counter = 0;
static void LockIncCounter()
{
for (int i = 0; i < Count; i++)
{
Interlocked.Increment(ref counter);
}
}

现在代码示例线程安全了。我们还可以采取另外一种方式来保证线程安全。如果需要执行一些验证(比如内存溢出保护),我们通常会使用这种方式。就是使用 CMPXCHG(即 CAS):

static volatile int counter = 0;
static void CASCounter()
{
for (int i = 0; i < Count; i++)
{
int oldValue;
do
{
oldValue = counter;
}
while (Interlocked.CompareExchange(ref counter, oldValue + 1, oldValue) != oldValue);
}
}

现在问一个有意思的问题:当缓存争用时,哪一个方法更慢?结果可能会让你大吃一惊哦。

在 Intel 4 核处理器下测试结果如下:

F1

图中,当 CPU 使用 2 个核时,BaselineCounter 方法是单核单路情况的 2.11 倍。其他情况类似。通过结果比对,我们可以得知:更多的并发性导致结果更加槽糕。这很大部分原因由内存争用所致。

当 CAS 操作失败,通过旋转等待可以改善 CASCounter 方法的在多核处理器上的性能(具体技巧可以参考夏天是个好季节兄的自己动手实现一个轻量级的信号量(一)、(二))。这可以大大减少活锁和关联内联阻碍锁耗费的时间。

当然,这个示例非常极端。它频繁反复修改同一个内存地址。通过期间插入特定的函数调用,延迟访问共享内存可以极大缓解压力。

比如插入 2 个函数调用,我们得到了如下数据:

F2插入 64 个函数调用之后,数据又变成了如下所示:

F3 这个时候,我们看到多核所花费的时间少于单核了。这就是我们使用并行所带来的加速。看到这里,我们可能会想,既然从 2 到 64 个函数调用使得结果越来越好,那么超过 64 个函数调用岂不是会变得更好?实际上,在插入 128 个函数调用之后,加速已经达到极限。结果如下所示:

F4 如何计算加速比,请参考并行思维 [II]。

天下没有免费的午餐,CAS 也不例外。我们应当慎之又慎的将 lock-free CAS 代码放到我们的代码中,且必须清楚的知道线程执行它们的频繁程度。我们可以用下面这句话来作为总结:共享是魔鬼。它从根本上限制应用程序可伸缩性,最好尽 量避免。共享内存需要并发控制,而并发控制需要 CAS。CAS 又非常昂贵,因此共享内存也非常昂贵。有很多人提出 lock-free 技术,事务内存,读写锁等可以改善程序可伸缩性。但很遗憾,这种情况很少出现。CAS 往往比正确实现锁机制的解决方案更加糟糕。很大原因要归结于共享内存、乐观失败尝试、缓存失效等。

Update 于 2009 年 4 月 8 日 21 : 10

overred 兄在 review 这篇文章的时候,提了一个很好的问题:在使用 Interlocked API 的时候,共享变量不用 volatile 修饰。

为了更方便说明这个问题,俺写个简单点的代码示例,如下所示:

using System;

namespace Lucifer.CSharp.Sample
{
class Program
{
static volatile int x;

static void Main(string[] args)
{
Foo(ref x);
}

static void Foo(ref int y)
{
while (y == 0) ;
}
}
}

当我们在 Visual Studio 中编译这段代码时,IDE 会给出编译警告,如下所示:

F5通 常来说,我们对于这样的编译警告应该给予足够重视。比如在上面的例子中,JIT 编译器会认为 y 一直未变,从而引起死循环。在 IA64 平台上,这会被认为普通内存访问代替了特殊的 load-acquire 访问,这就可能导致 CPU 指令重组方面的一些 Bug。但是有一种情况例外,就是使用 Interlocked API 和 Thread.VolatileXXX 方法以及锁。因为这些 API 内部都会显式要求内存栅栏和硬件原子指令,而不管外部共享变量是否采用 volatile 修饰。因此,文中采用的测试方法还是很安全嘀。

如果你觉得这个编译警告很烦人,可以使用 #pragma 指令禁掉这种警告,如下所示:

static volatile int x;

static void Foo()
{
#pragma warning disable 0420
Interlocked.Exchange(ref x, 1);
#pragma warning restore 0420
}

当然,也可以完全不用 volatile 修饰符。CLR 内存模型保证了这一点。

如何正确使用 volatile ,请参考并发数据结构:谈谈volatile变量。

并发数据结构 : .NET Framework 中提供的读写锁

转帖自:http://www.cnblogs.com/lucifer1982/archive/2008/12/07/1349437.html

在多线程编程时,开发人员经常会遭遇多个线程读写某个资源的情况。这就需要进行线程同步来保证线程安全。一般情况下,我们的同步措施是使用锁机 制。但是,假如线程只对资源进行读取操作,那么根本不需要使用锁;反之,假如线程只对资源进行写入操作,则应当使用互斥锁(比如使用 Monitor 类等)。还有一种情况,就是存在多个线程对资源进行读取操作,同时每次只有一个线程对资源进行独占写入操作。这正是本文主题--读写锁的用武之地。

ReaderWriterLock 类

.NET Framework BCL 在 1.1 版本时,给我们提供了一个 ReaderWriterLock 类来面对此种情景。但是很遗憾,Microsoft 官方不推荐使用该类。Jeffrey Richter 也在他的《CLR via C#》一书中对它进行了严厉的批判。下面是该类不受欢迎的主要原因:

  • 性能。这个类实在是太慢了。比如它的 AcquireReaderLock 方法比 Monitor 类的 Enter 方法要慢 5 倍左右,而等待争夺写锁甚至比 Monitor 类慢 6 倍。
  • 策略。假如某个线程完成写入操作后,同时面临读线程和写线程等待处理。ReaderWriterLock 会优先释放读线程,而让写线程继续等待。但我们使用读写锁是因为存在大量的读线程和非常少的写线程,这样写线程很可能必须长时间地等待,造成写线程饥饿, 不能及时更新数据。更槽糕的情况是,假如写线程一直等待,就会造成活锁。反之,我们让 ReaderWriterLock 采取写线程优先的策略。如果存在多个写线程,而读线程数量稀少,也会造成读线程饥饿。幸运的是,现实实践中,这种情况很少出现。一旦发生这种情况,我们可 以采取互斥锁的办法。
  • 递归。ReaderWriterLock 类支持锁递归。这就意味着该锁清楚的知道目前哪个线程拥有它。假如拥有该锁的线程递归尝试获得该读写锁,递归算法允许该线程获得该读写锁,并且增加获得该 锁的计数。然而该线程必须释放该锁相同的次数以便线程不再拥有该锁。尽管这看起来是个很好的特性,但是实现这个“特性”代价太高。首先,因为多个读线程可 以同时拥有该读写锁,这必须让该锁为每个线程保持计数。此外,还需要额外的内存空间和时间来更新计数。这个特性对 ReaderWriterLock 类可怜的性能贡献极大。其次,有些良好的设计需要一个线程在此处获得该锁,然后在别处释放该锁(比如 .NET 的异步编程架构)。因为这个递归特性,ReaderWriterLock 不支持这种编程架构。
  • 资源泄漏。在 .NET 2.0 之前的版本中, ReaderWriterLock 类会造成内核对象泄露。这些对象只有在进程终止后才能再次回收。幸运的是,.NET 2.0 修正了这个 Bug 。

此外,ReaderWriterLock 还有个令人担心的危险的非原子性操作。它就是 UpgradeToWriteLock 方法。这个方法实际上在更新到写锁前先释放了读锁。这就让其他线程有机会在此期间乘虚而入,从而获得读写锁且改变状态。如果先更新到写锁,然后释放读锁。 假如两个线程同时更新将会导致另外一个线程死锁。

所以 Microsoft 决定构建一个新类来一次性解决上述所有问题,这就是 ReaderWriterLockSlim 类。本来可以在原有的 ReaderWriterLock 类上修正错误,但是考虑到兼容性和已存在的 API ,Microsoft 放弃了这种做法。当然也可以标记 ReaderWriterLock 类为 Obsolete,但是由于某些原因,这个类还有存在的必要。

ReaderWriterLockSlim 类

新的 ReaderWriterLockSlim 类支持三种锁定模式:Read,Write,UpgradeableRead。这三种模式对应的方法分别是 EnterReadLock,EnterWriteLock,EnterUpgradeableReadLock 。再就是与此对应的 TryEnterReadLock,TryEnterWriteLock,TryEnterUpgradeableReadLock,ExitReadLock,ExitWriteLock,ExitUpgradeableReadLock。 Read 和 Writer 锁定模式比较简单易懂:Read 模式是典型的共享锁定模式,任意数量的线程都可以在该模式下同时获得锁;Writer 模式则是互斥模式,在该模式下只允许一个线程进入该锁。UpgradeableRead 锁定模式可能对于大多数人来说比较新鲜,但是在数据库领域却众所周知。

这个新的读写锁类性能跟 Monitor 类大致相当,大概在 Monitor 类的 2 倍之内。而且新锁优先让写线程获得锁,因为写操作的频率远小于读操作。通常这会导致更好的可伸缩性。起初,ReaderWriterLockSlim 类在设计时考虑到相当多的情况。比如在早期 CTP 的代码还提供了PrefersReaders, PrefersWritersAndUpgrades 和 Fifo 等竞争策略。但是这些策略虽然添加起来非常简单,但是会导致情况非常的复杂。所以 Microsoft 最后决定提供一个能够在大多数情况下良好工作的简单模型。

ReaderWriterLockSlim 的更新锁

现在让我们更加深入的讨论一下更新模型。UpgradeableRead 锁定模式允许安全的从 Read 或 Write 模式下更新。还记得先前 ReaderWriterLock 的更新是非原子性,危险的操作吗(尤其是大多数人根本没有意识到这点)?现在提供的新读写锁既不会破坏原子性,也不会导致死锁。新锁一次只允许一个线程处 在 UpgradeableRead 模式下。

一旦该读写锁处在 UpgradeableRead 模式下,线程就能读取某些状态值来决定是否降级到 Read 模式或升级到 Write 模式。注意应当尽可能快的作出这个决定:持有 UpgradeableRead 锁会强制任何新的读请求等待,尽管已存在的读取操作仍然活跃。遗憾的是,CLR 团队移除了 DowngradeToRead 和 UpgradeToWrite 两个方法。如果要降级到读锁,只要简单的在 ExitUpgradeableReadLock 方法后紧跟着调用 EnterReadLock 方法即可:这可以让其他的 Read 和 UpgradeableRead 获得完成先前应当持有却被 UpgradeableRead 锁持有的操作。如果要升级到写锁,只要简单调用 EnterWriteLock 方法即可:这可能要等待,直到不再有任何线程在 Read 模式下持有锁。不像降级到读锁,必须调用 ExitUpgradeableReadLock。在 Write 模式下不必非得调用 ExitUpgradeableReadLock。但是为了形式统一,最好还是调用它。比如下面的代码:

using System;
using System.Linq;
using System.Threading;

namespace Lucifer.CSharp.Sample
{
class Program
{
private ReaderWriterLockSlim rwLock = new ReaderWriterLockSlim();

void Sample()
{
bool isUpdated = true;
rwLock.EnterUpgradeableReadLock();
try
{
if (/* … 读取状态值来决定是否更新 … */)
{
rwLock.EnterWriteLock();
try
{
//… 写入状态值 …
}
finally
{
rwLock.ExitWriteLock();
}
}
else
{
rwLock.EnterReadLock();
rwLock.ExitUpgradeableReadLock();
isUpdated = false;
try
{
//… 读取状态值 …
}
finally
{
rwLock.ExitReadLock();
}
}
}
finally
{
if (isUpdated)
rwLock.ExitUpgradeableReadLock();
}
}
}
}

ReaderWriterLockSlim 的递归策略

新的读写锁还有一个有意思的特性就是它的递归策略。默认情况下,除已提及的降级到读锁和升级到写锁之外,所有的递归请求都不允许。这意味着你不能连 续两次调用 EnterReadLock,其他模式下也类似。如果你这么做,CLR 将会抛出 LockRecursionException 异常。当然,你可以使用 LockRecursionPolicy.SupportsRecursion 的构造函数参数让该读写锁支持递归锁定。但不建议对新的开发使用递归,因为递归会带来不必要的复杂情况,从而使你的代码更容易出现死锁现象。

有一种特殊的情况永远也不被允许,无论你采取什么样的递归策略。这就是当线程持有读锁时请求写锁。Microsoft 曾经考虑提供这样的支持,但是这种情况太容易导致死锁。所以 Microsoft 最终放弃了这个方案。

此外,这个新的读写锁还提供了很多对应的属性来确定线程是否在指定模型下持有该锁。比如 IsReadLockHeld, IsWriteLockHeld 和 IsUpgradeableReadLockHeld 。你也可以通过 WaitingReadCount,WaitingWriteCount 和 WaitingUpgradeCount 等属性来查看有多少线程正在等待持有特定模式下的锁。CurrentReadCount 属性则告知目前有多少并发读线程。RecursiveReadCount, RecursiveWriteCount 和 RecursiveUpgradeCount 则告知目前线程进入特定模式锁定状态下的次数。

小结

这篇文章分析了 .NET 中提供的两个读写锁类。然而 .NET 3.5 提供的新读写锁 ReaderWriterLockSlim 类消除了 ReaderWriterLock 类存在的主要问题。与 ReaderWriterLock 相比,性能有了极大提高。更新具有原子性,也可以极大避免死锁。更有清晰的递归策略。在任何情况下,我们都应该使用 ReaderWriterLockSlim 来代替 ReaderWriterLock 类。

Update 于 2008-12-07 0:06

Windows Vista 及其以后的版本新增了一个 SRWLock 原语。它以 Windows 内核事件机制为基础而构建。它的设计比较有意思。

SRW 锁不支持递归。Windows Kernel 团队认为支持递归会造成额外系统开销,原因是为了维持准确性需进行逐线程的计数。SRW 锁也不支持从共享访问升级到独占访问,同时也不支持从独占访问降级到共享访问。支持升级能力可能会造成难以接受的复杂性和额外系统开销,这种开销甚至会影 响锁内共享和独占获得代码的常见情况。它还要求定义关于如何选择等待中的读取器、等待中的写入器和等待升级的读取器的策略,这又将与无偏向的基本设计目标 相抵触。我对其进行了 .NET 封装。代码如下:

using System;
using System.Threading;
using System.Runtime.InteropServices;

namespace Lucifer.Threading.Lock
{
///
/// Windows NT 6.0 才支持的读写锁。
///

///
请注意,这个类只能在 NT 6.0 及以后的版本中才能使用。
public sealed class SRWLock
{
private IntPtr rwLock;

///
/// 该锁不支持递归。
///

public SRWLock()
{
InitializeSRWLock(out rwLock);
}

///
/// 获得读锁。
///

public void EnterReadLock()
{
AcquireSRWLockShared(ref rwLock);
}

///
/// 获得写锁。
///

public void EnterWriteLock()
{
AcquireSRWLockExclusive(ref rwLock);
}

///
/// 释放读锁。
///

public void ExitReadLock()
{
ReleaseSRWLockShared(ref rwLock);
}

///
/// 释放写锁。
///

public void ExitWriteLock()
{
ReleaseSRWLockExclusive(ref rwLock);
}

[DllImport("Kernel32", CallingConvention = CallingConvention.Winapi, ExactSpelling = true)]
private static extern void InitializeSRWLock(out IntPtr rwLock);

[DllImport("Kernel32", CallingConvention = CallingConvention.Winapi, ExactSpelling = true)]
private static extern void AcquireSRWLockExclusive(ref IntPtr rwLock);

[DllImport("Kernel32", CallingConvention = CallingConvention.Winapi, ExactSpelling = true)]
private static extern void AcquireSRWLockShared(ref IntPtr rwLock);

[DllImport("Kernel32", CallingConvention = CallingConvention.Winapi, ExactSpelling = true)]
private static extern void ReleaseSRWLockExclusive(ref IntPtr rwLock);

[DllImport("Kernel32", CallingConvention = CallingConvention.Winapi, ExactSpelling = true)]
private static extern void ReleaseSRWLockShared(ref IntPtr rwLock);
}
}

此外,在其他平台也有一些有意思的读写锁。比如 Linux 内核中的读写锁和 Java 中的读写锁。感兴趣的同学可以自己研究一番。

读写锁有个很常用的场景就是在缓存设计中。因为缓存中经常有些很稳定,不太长更新的内容。MSDN 的代码示例就很经典,我原版拷贝一下,呵呵。代码示例如下:

using System;
using System.Threading;

namespace Lucifer.CSharp.Sample
{
public class SynchronizedCache
{
private ReaderWriterLockSlim cacheLock = new ReaderWriterLockSlim();
private Dictionary<int, string> innerCache = new Dictionary<int, string>();

public string Read(int key)
{
cacheLock.EnterReadLock();
try
{
return innerCache[key];
}
finally
{
cacheLock.ExitReadLock();
}
}

public void Add(int key, string value)
{
cacheLock.EnterWriteLock();
try
{
innerCache.Add(key, value);
}
finally
{
cacheLock.ExitWriteLock();
}
}

public bool AddWithTimeout(int key, string value, int timeout)
{
if (cacheLock.TryEnterWriteLock(timeout))
{
try
{
innerCache.Add(key, value);
}
finally
{
cacheLock.ExitWriteLock();
}
return true;
}
else
{
return false;
}
}

public AddOrUpdateStatus AddOrUpdate(int key, string value)
{
cacheLock.EnterUpgradeableReadLock();
try
{
string result = null;
if (innerCache.TryGetValue(key, out result))
{
if (result == value)
{
return AddOrUpdateStatus.Unchanged;
}
else
{
cacheLock.EnterWriteLock();
try
{
innerCache[key] = value;
}
finally
{
cacheLock.ExitWriteLock();
}
return AddOrUpdateStatus.Updated;
}
}
else
{
cacheLock.EnterWriteLock();
try
{
innerCache.Add(key, value);
}
finally
{
cacheLock.ExitWriteLock();
}
return AddOrUpdateStatus.Added;
}
}
finally
{
cacheLock.ExitUpgradeableReadLock();
}
}

public void Delete(int key)
{
cacheLock.EnterWriteLock();
try
{
innerCache.Remove(key);
}
finally
{
cacheLock.ExitWriteLock();
}
}

public enum AddOrUpdateStatus
{
Added,
Updated,
Unchanged
};
}
}

再次 Update 于 2008-12-07 0:47

如果应用场景要求性能十分苛刻,可以考虑采用 lock-free 方案。但是 lock-free 有着固有缺陷:极难编码,极难证明其正确性。读写锁方案的应用范围更加广泛一些。