SemaphoreSlim.WaitAsync before/after try block
本文关键字:try block after WaitAsync before SemaphoreSlim | 更新日期: 2023-09-27 18:07:50
我知道在同步世界中第一个片段是正确的,但是WaitAsync和async/await魔法是怎么回事?请给我一些 .net 内部。
await _semaphore.WaitAsync();
try
{
// todo
}
finally
{
_semaphore.Release();
}
或
try
{
await _semaphore.WaitAsync();
// todo
}
finally
{
_semaphore.Release();
}
根据 MSDN,SemaphoreSlim.WaitAsync
可能会抛出:
-
ObjectDisposedException
- 如果信号量已处置 -
ArgumentOutOfRangeException
- 如果您选择接受int
的重载并且它是负数(不包括 -1(
在这两种情况下,SemaphoreSlim
都不会获得锁,这使得将其释放到finally
块中变得不合时宜。
需要注意的一点是,如果在第二个示例中对象被释放或为 null,则 finally 块将执行并触发另一个异常或调用可能没有首先获得任何要释放的锁的Release
。
总而言之,我会选择前者,以便与非异步锁保持一致并避免finally
块中的异常
如果我们考虑ThreadAbortException
,这两个选项都是危险的,这是在较旧的.NET Framework代码中可能发生的异常,尽管重要的是要注意它不会出现在较新的.NET Core代码中,正如Microsoft所说:"即使这种类型存在于.NET Core和.NET 5+中,由于不支持Abort,公共语言运行时也不会抛出ThreadAbortException。
- 考虑选项 1 和
ThreadAbortException
发生在WaitAsync
和try
之间。在这种情况下,将获取信号量锁,但永远不会释放。最终这将导致僵局。
await _semaphore.WaitAsync();
// ThreadAbortException happens here
try
{
// todo
}
finally
{
_semaphore.Release();
}
- 现在在选项 2 中,如果在获取锁之前发生
ThreadAbortException
,我们仍然会尝试释放其他人的锁,或者如果信号量未锁定,我们会得到SemaphoreFullException
。
try
{
// ThreadAbortException happens here
await _semaphore.WaitAsync();
// todo
}
finally
{
_semaphore.Release();
}
从理论上讲,我们可以使用选项 2 并跟踪是否实际获取了锁。为此,我们将把锁获取和跟踪逻辑放入finally
块中的另一个(内部(try-finally
语句中。原因是ThreadAbortException
不会中断finally
块执行。所以我们会有这样的东西:
var isTaken = false;
try
{
try
{
}
finally
{
await _semaphore.WaitAsync();
isTaken = true;
}
// todo
}
finally
{
if (isTaken)
{
_semaphore.Release();
}
}
不幸的是,我们仍然不安全。问题是Thread.Abort
锁定调用线程,直到中止线程离开受保护区域(在我们的场景中是内部finally
块(。这可能导致僵局。为了避免无限或长时间运行的信号量等待,我们可以定期中断它,并给ThreadAbortException
一个中断执行的机会。现在逻辑感觉很安全。
var isTaken = false;
try
{
do
{
try
{
}
finally
{
isTaken = await _semaphore.WaitAsync(TimeSpan.FromSeconds(1));
}
}
while(!isTaken);
// todo
}
finally
{
if (isTaken)
{
_semaphore.Release();
}
}
内部有异常WaitAsync
则未获取信号量,因此Release
是不必要的,应避免使用。您应该使用第一个代码段。
如果您担心实际获取信号量时会出现异常(除了NullReferenceException
之外,这不太可能(,您可以尝试独立捕获它:
try
{
await _semaphore.WaitAsync();
}
catch
{
// handle
}
try
{
// todo
}
finally
{
_semaphore.Release();
}
对 Bill Tarbell 的 SemaphoreSlim
类的LockSync
扩展方法的尝试改进。通过使用值类型的IDisposable
包装器和ValueTask
返回类型,可以显著减少超出SemaphoreSlim
类自身分配的额外分配。
public static ReleaseToken Lock(this SemaphoreSlim semaphore,
CancellationToken cancellationToken = default)
{
semaphore.Wait(cancellationToken);
return new ReleaseToken(semaphore);
}
public static async ValueTask<ReleaseToken> LockAsync(this SemaphoreSlim semaphore,
CancellationToken cancellationToken = default)
{
await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
return new ReleaseToken(semaphore);
}
public readonly struct ReleaseToken : IDisposable
{
private readonly SemaphoreSlim _semaphore;
public ReleaseToken(SemaphoreSlim semaphore) => _semaphore = semaphore;
public void Dispose() => _semaphore?.Release();
}
使用示例(同步/异步(:
using (semaphore.Lock())
{
DoStuff();
}
using (await semaphore.LockAsync())
{
await DoStuffAsync();
}
同步Lock
始终是无分配的,无论信号量是立即获取还是在阻塞等待后获取。异步LockAsync
也是无分配的,但仅当同步获取信号量时(当它CurrentCount
恰好是正数时(。当存在争用并且LockAsync
必须异步完成时,144 字节将额外分配给标准SemaphoreSlim.WaitAsync
分配(截至 64 位计算机上的 .NET 5,其中 88 个字节(不带CancellationToken
的字节和 497 个字节,具有可取消CancellationToken
(。
从文档中:
从 C# 7.0 开始支持使用
ValueTask<TResult>
类型,任何版本的 Visual Basic 都不支持使用。
从 C# 7.2 开始,readonly
结构可用。
这里还解释了为什么 IDisposable
ReleaseToken
结构没有被 using
语句框住。
首选第一个选项,以避免在引发 Wait 调用时调用释放。 不过,使用 c#8.0,我们可以编写一些东西,这样我们就不会在每个需要使用信号量的方法上都有太多丑陋的嵌套。
用法:
public async Task YourMethod()
{
using await _semaphore.LockAsync();
// todo
} //the using statement will auto-release the semaphore
下面是扩展方法:
using System;
using System.Threading;
using System.Threading.Tasks;
namespace YourNamespace
{
public static class SemaphorSlimExtensions
{
public static IDisposable LockSync(this SemaphoreSlim semaphore)
{
if (semaphore == null)
throw new ArgumentNullException(nameof(semaphore));
var wrapper = new AutoReleaseSemaphoreWrapper(semaphore);
semaphore.Wait();
return wrapper;
}
public static async Task<IDisposable> LockAsync(this SemaphoreSlim semaphore)
{
if (semaphore == null)
throw new ArgumentNullException(nameof(semaphore));
var wrapper = new AutoReleaseSemaphoreWrapper(semaphore);
await semaphore.WaitAsync();
return wrapper;
}
}
}
和 IDisposable 包装器:
using System;
using System.Threading;
namespace YourNamespace
{
public class AutoReleaseSemaphoreWrapper : IDisposable
{
private readonly SemaphoreSlim _semaphore;
public AutoReleaseSemaphoreWrapper(SemaphoreSlim semaphore )
{
_semaphore = semaphore;
}
public void Dispose()
{
try
{
_semaphore.Release();
}
catch { }
}
}
}
这是答案和问题的混合。
来自一篇关于lock(){}
实现的文章:
这里的问题是,如果编译器在监视器进入和 try 保护区域之间生成无操作指令,则运行时可能会在监视器进入之后但在 try 之前引发线程中止异常。在这种情况下,最终永远不会运行,因此锁泄漏,可能最终使程序死锁。如果在未优化和优化的版本中这是不可能的,那就太好了。 (https://blogs.msdn.microsoft.com/ericlippert/2009/03/06/locks-and-exceptions-do-not-mix/(
当然,lock
是不一样的,但从这个注释中我们可以得出结论,如果它还提供了一种确定锁是否成功获取的方法(如本文中所述Monitor.Enter
(,那么将SemaphoreSlim.WaitAsync()
放在try
块内也可能更好。但是,SemaphoreSlim
没有提供这样的机制。
这篇关于using
实现的文章说:
using (Font font1 = new Font("Arial", 10.0f))
{
byte charset = font1.GdiCharSet;
}
转换为:
{
Font font1 = new Font("Arial", 10.0f);
try
{
byte charset = font1.GdiCharSet;
}
finally
{
if (font1 != null)
((IDisposable)font1).Dispose();
}
}
如果可以在Monitor.Enter()
及其紧随其后的try
之间生成 noop,那么同样的问题是否也适用于转换后的using
代码?
也许这种实现AsyncSemaphore
https://github.com/Microsoft/vs-threading/blob/81db9bbc559e641c2b2baf2b811d959f0c0adf24/src/Microsoft.VisualStudio.Threading/AsyncSemaphore.cs
和SemaphoreSlim
扩展https://github.com/StephenCleary/AsyncEx/blob/02341dbaf3df62e97c4bbaeb6d6606d345f9cda5/src/Nito.AsyncEx.Coordination/SemaphoreSlimExtensions.cs
也很有趣。