使线程安全同步更简单
局部范围的委托和 Lambda 表达式允许一些非常智能且有用的实用函数。
介绍
通常,我们在开始编写项目时不会考虑使其具有线程安全性。在很多情况下,这并不是真正必要的。但随着 PLINQ 和任务并行库 (Task Parallel Library) 的出现,我们发现从一开始就考虑“线程安全”具有明显的性能优势。使用 `ConcurrentDictionary` 等类可以节省大量时间,但我们也可能遇到需要同步但本身不具备线程安全性的集合。
注意事项
当处理重复的、可能耗时的计算时,最好并行运行这些迭代。对于更直接的算术运算,最好保持在单个线程中。
LazyInitializer
令人高兴的是,有一些现有的类可以帮助实现线程安全。在可能的情况下,我使用 `LazyInitializer.EnsureInitialized(ref item, ()=>{})` 来初始化我的访问器属性。但这有一些严重的限制:
- 它不允许返回 null。
- 它本身不允许同步其他属性。
线程安全模式
经过深入研究,我升级了我使用锁和 `Monitor.TryEnter` 的最佳实践。我一直都知道双重检查锁定 (DCL) 线程安全模式,即检查一个条件,如果条件存在,则获取(或尝试获取)锁,然后在执行前再次检查该条件。但在 Lambda 表达式出现之前,我一直没有找到一种方法来封装处理它的实用函数。
锁定类的所有属性
如果一个类可以被多个线程访问,你需要对所有公共属性,甚至可能是某些私有属性应用线程安全。我写得非常厌倦了
readonly object _propA_lock = new Object();
优化线程安全的磁盘 I/O (ReaderWriterLockSlim)
然后我努力最大化文件读/写性能。这比我想象的要困难得多,并且不是一个完美的解决方案,但它有潜力显著提高吞吐量。当文件被应用程序外部的其他进程访问时,你仍然需要捕获和重试 I/O 异常。如果我过于激进地清理 `ReaderWriterLockSlim` 对象,我也会遇到避免 I/O 异常的困难……
我提供了一个 `ReadWriteHelper` 类,它使用 `ReaderWriterLockSlim` 对象作为基于键的锁定机制。这是我优化每个锁对象重用的解决方案。通过让每个锁对象持续一段时间,可以实现重用,并且只在有意义时才将其释放。
`ReadWriteHelper` 继承自 `DeferredCleanupBase`,它使用计时器延迟清理直到需要时才执行。
最后,我提供了一些 `ReaderWriterLockSlim` 的扩展,以帮助防止读/写代码出错。
使用代码
`ThreadSafety` 提供了一组静态方法,它们类似于 `lock` 关键字,但具有一些额外功能,包括带条件的超时锁定。它还允许比 `LazyInitializer` 提供的简单 null 值更复杂的条件。(请参阅“重要说明”了解正确的条件实现。)
object value;
if(!dictionary.TryGetValue(key,out value)) {
lock(dictionary) {
if(!dictionary.TryGetValue(key,out value)) {
dictionary.Add( value = newValue );
}
}
}
简化为:
object value;
ThreadSafety.LockConditional( dictionary,
()=> !dictionary.TryGetValue(key, out value),
()=> dictionary.Add( value = newValue ) );
或者更性能优化的读/写版本:
object value;
ThreadSafety.SynchronizeReadWrite( dictionary, key,
()=> !dictionary.TryGetValue(key, out value),
()=> dictionary.Add( value = newValue ),
5000 /*lock-timeout*/,
false /*throw on error*/);
注意:上面经过优化的版本(需要一个 **键**)经过测试,在应用于 `Dictionary<TKey,TValue>` 时,其性能与 `ConcurrentDictionary<TKey,TValue>` 内置的 `GetOrAdd` 方法相当。
...
`ThreadSafety.Helper` 也可以作为实例类进行初始化,它会自动安全地创建/利用锁。以下是我如何将其作为实例使用的:
// Default keys are strings but you can use whatever key type you like (int for example).
readonly ThreadSafety.Helper SyncHelper = new ThreadSafety.Helper();
void Example() {
// Method A:
SyncHelper.Lock("[keyName]",()=>{ /*some code that needs to be thread safe*/ });
// Method B: // Mirrors what happens internally in Method A.
lock(SyncHelper["[keyName]"]) { /*some code that needs to be thread safe*/ }
// Method C: (Conditional by key)
SyncHelper.LockConditional("[keyName]",()=> {return property==null && !foo},()=>
{ /*some code that needs to be thread safe*/ });
}
只要您不尝试一次对流对象进行多次读写操作,优化文件访问就很简单:
ThreadSafety.File.Read(filePath,()=>{
/* Some code that requires read access of the specified file.
Allows for multiple readers. But blocks if there is a write lock in progress.
*/
});
ThreadSafety.File.Write(filePath,()=>{
/* Some code that requires explicit write access of the specified file.
Blocks all other access to the file until this is complete.
*/
});
我通常使用这些方法来等待文件访问,然后在一个 `Action` 中初始化一个流。
请记住,您需要在局部函数中应用 `while` / `try` / `catch` / `sleep` 重试策略来处理文件访问,以防其他进程在您的应用程序之外访问该文件。我本来可以将异常处理构建进去,但可能的实现方式过于多样,无法在此实用工具中真正做到健壮。在某些情况下,您可能正在写入文件并遇到 `IoException`,此时需要处理该错误并进行复杂的清理工作,然后才能继续。您可能不想重试。我包含了 `ThreadSafety.File.GetFileStreamForRead` 方法,该方法可以协助进行典型用法。
兴趣点
我将 `LockCleanupDelay` 的设置留给您自行试验和调整。延迟清理似乎是避免文件访问冲突和担心过度锁定的理想解决方案。您可以将其设置为零,使其在每次运行后执行清理,但在我的测试中,这很容易导致 `IoException`。
重要的同步说明
无论是否使用 ThreadSafeHelper 工具,在实现条件锁(使用双重检查锁定模式)时,请务必不要在代码块结束之前修改用于条件的变量,否则它会在同步代码完成之前过早地使条件失效。
// Example A
object result;
ThreadSafety.LockConditional(dictionary,()=> !dictionary.TryGetValue(key, ref result),()=>
{
object temp;
/* some potentially complex code that may take some time to finish
and eventually sets/initializes 'temp' */
// Absolute last step:
dictionary.Add(key,result = temp);
});
// Example B
ThreadSafety.LockConditional("[keyName]",()=> _value==null,()=>
{
object result;
/* some potentially complex code that may take some time to finish
and eventually sets/initializes 'result' */
// Absolute last steps:
// System.Threading.Thread.MemoryBarrier(); // memory fence for multiprocessor systems.
_value = result;
});
在上面的示例中,如果您出于任何原因在函数末尾以外的任何地方添加了该值,您可能会在该值准备好之前返回该值。
`Thread.MemoryBarrier()` 被建议用于避免处理器乱序问题。根据 MSDN 的说法:“它同步内存。实际上,它会刷新当前线程所执行处理器的缓存内存到主内存。” 但显然 `lock` 关键字(`Monitor.Enter` / `Exit`)隐式创建了一个完整的内存栅栏,因此不需要 `Thread.MemoryBarrier()`。
欢迎提出任何问题、评论、批评、建议和改进!