在多线程编程中,线程同步是确保数据一致性和避免竞争条件的重要手段。本文将深入探讨 lock(object)
和 SemaphoreSlim
这两种常用的同步机制,详细分析它们的区别、适用场景以及如何在实际开发中选择合适的同步工具。
一、lock(object)
(或 Monitor
)
1. 单线程访问: lock
关键字用于确保在同一时间只有一个线程能够访问被保护的代码块。它实现了互斥锁(mutex),适用于单线程临界区的保护。这意味着当一个线程进入锁定区域时,其他试图进入该区域的线程将被阻塞,直到锁被释放。
2. 锁范围: lock
保护的代码块只能在单个线程内运行,进入锁定区域的线程会被阻塞直到锁被释放。它提供了一种简单而有效的方式来防止多个线程同时访问共享资源。
3. 实现方式: lock
是基于 Monitor
类的,它是CLR提供的一种基础同步机制。Monitor
提供了 Enter
和 Exit
方法来显式地进入和离开临界区,lock
关键字在语法上对其进行了简化,使得代码更加易读。
4. 简单易用: lock
语法简洁,易于使用,适用于简单的线程同步需求。典型的用法如下:
private static readonly object _lockObject = new object();
public void SomeMethod()
{
lock (_lockObject)
{
// Critical section.
}
}
5. 不支持异步: lock
不能用于异步代码块,不能与 await
一起使用。这意味着在需要异步处理的场景中,lock
并不适用。
二、SemaphoreSlim
1. 多线程访问: SemaphoreSlim
允许指定同时可以访问资源的线程数。它可以用作计数信号量,允许多个线程并发访问指定数量的资源。例如,初始化为1的 SemaphoreSlim
等价于一个互斥锁,而初始化为大于1的 SemaphoreSlim
则允许指定数量的线程并发访问。
2. 锁范围: SemaphoreSlim
可以控制同时访问资源的多个线程,适用于需要限制并发访问数量的场景。它在资源访问控制方面提供了更大的灵活性。
3. 实现方式: SemaphoreSlim
是一个轻量级的、基于信号量的同步机制。它支持异步操作,使其在需要控制并发访问的异步编程中尤为适用。
4. 异步支持: SemaphoreSlim
提供了异步等待功能,可以与 async
和 await
关键字一起使用。这使得它非常适用于异步编程模型,能够有效避免异步方法中的阻塞问题。
private static readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);
public async Task SomeMethodAsync()
{
await _semaphore.WaitAsync();
try
{
// Critical section.
}
finally
{
_semaphore.Release();
}
}
5. 复杂使用场景: SemaphoreSlim
适用于更复杂的并发控制需求,如限制并发访问数量或需要异步支持的场景。它能够根据具体的并发需求,灵活调整允许并发访问的线程数量。
6.工作原理:
- WaitAsync 方法:
- 当调用
WaitAsync
方法时,如果信号量的计数大于 0,则计数会减 1,并且立即允许调用线程进入临界区。 - 如果信号量的计数为 0,则调用线程会进入等待状态,直到有其他线程调用
Release
方法增加了计数,或者超时。
- 当调用
- Release 方法:
- 调用
Release
方法会增加信号量的计数。如果有线程正在等待进入临界区,则会释放其中一个线程,使其可以进入临界区执行任务。 - 如果没有等待的线程,则信号量的计数会累加,超过
SemaphoreSlim(int initialCount, int maxCount)
其中的maxCount
则会抛出SemaphoreFullException
。
- 调用
三、实际应用:AsyncLoadHelper<TData>
在实际开发中,我们常常需要在异步方法中进行线程同步。下面是一个 AsyncLoadHelper<TData>
类的实现,它通过 SemaphoreSlim
确保数据加载操作的线程安全性,并且支持异步操作。
public class AsyncLoadHelper<TData> : BindableBase, IDisposable
{
private TData _data;
private bool _isLoading;
private Exception _loadingException;
private readonly Lazy<DelegateCommand> _loadCommand;
private readonly Func<CancellationToken, Task<TData>> _dataLoadMethod;
private CancellationTokenSource _cts;
private readonly SemaphoreSlim _asyncLock = new SemaphoreSlim(1, 1);
public TData Data
{
get => _data;
set => SetProperty(ref _data, value);
}
public bool IsLoading
{
get => _isLoading;
set => SetProperty(ref _isLoading, value);
}
public Exception LoadingException
{
get => _loadingException;
set => SetProperty(ref _loadingException, value);
}
public DelegateCommand LoadCommand => _loadCommand.Value;
public AsyncLoadHelper(Func<CancellationToken, Task<TData>> dataLoadMethod)
{
_dataLoadMethod = dataLoadMethod;
_loadCommand = new Lazy<DelegateCommand>(() =>
new DelegateCommand(async () => await ExecuteLoadDataAsync(), () => !IsLoading).ObservesProperty(() => IsLoading));
}
public virtual async Task ExecuteLoadDataAsync()
{
if (IsLoading) return;
await _asyncLock.WaitAsync();
_cts?.Cancel();
_cts = new CancellationTokenSource();
IsLoading = true;
LoadingException = null;
try
{
Data = await _dataLoadMethod(_cts.Token);
}
catch (OperationCanceledException)
{
// Handle if needed
}
catch (Exception e)
{
LoadingException = e;
}
finally
{
IsLoading = false;
_asyncLock.Release();
}
}
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
_asyncLock.Dispose();
}
}
关键点
-
防止重复加载:使用
IsLoading
防止在加载过程中多次调用ExecuteLoadDataAsync
。 -
同步机制:通过
await _asyncLock.WaitAsync()
确保在任何时候只有一个线程能够进入临界区。 -
释放信号量:无论加载操作是否成功,
finally
块中的_asyncLock.Release()
确保了信号量总是被释放,从而不会阻塞后续的加载请求。 -
简单同步:如果需要简单的、单线程的临界区保护,
lock
是更简单和直接的选择。它的语法简洁,易于理解和使用,非常适合基本的线程同步需求。 -
并发控制和异步支持:如果需要控制同时访问资源的线程数量,或者需要在异步代码中使用,
SemaphoreSlim
是更为合适的选择。它不仅支持异步操作,还能灵活地控制并发线程的数量,适用于更复杂的同步场景。