SempahoreSlim 作为异步代码中的锁的正确用法是什么?

What is the Correct Usage of SempahoreSlim as a Lock in Async Code?

最近似乎在异步代码中,SemaphoreSlim 是 lock(obj) {} 的推荐替代品。我找到了关于如何使用它的建议: https://blog.cdemi.io/async-waiting-inside-c-sharp-locks/

特别是此人建议此代码:

//Instantiate a Singleton of the Semaphore with a value of 1. This means that only 1 thread can be granted access at a time.
static SemaphoreSlim semaphoreSlim = new SemaphoreSlim(1,1);
//Asynchronously wait to enter the Semaphore. If no-one has been granted access to the Semaphore, code execution will proceed, otherwise this thread waits here until the semaphore is released 
await semaphoreSlim.WaitAsync();
try
{
    await Task.Delay(1000);
}
finally
{
    //When the task is ready, release the semaphore. It is vital to ALWAYS release the semaphore when we are ready, or else we will end up with a Semaphore that is forever locked.
    //This is why it is important to do the Release within a try...finally clause; program execution may crash or take a different path, this way you are guaranteed execution
    semaphoreSlim.Release();
}

对我来说,这段代码似乎违反了我以前看到的有关如何锁定的建议,请记住您的代码可能随时被中断,并为此编写代码。如果在 await sempahoreSlim.WaitAsync() 之后和进入 try 语句之前抛出任何异常,信号量将永远不会被释放。这种问题正是我认为引入 lock 语句和 using 语句会取得如此好的结果的原因。

是否有明确说明此代码有效的参考?也许 try/finally 语句实际上是在代码可以中断之前输入的,这是我以前不知道的事情?或者,是否有一种不同的模式实际上可以正确使用信号量作为锁,或用于异步 .NET 代码的其他一些锁定机制?

是的,理论上 await semaphoreSlim.WaitAsync();try 之间确实会发生某些事情,但实际上:在那种情况下,您的应用程序已经完成,因为它正在处理中内爆的调用堆栈。实际上,虽然这是一个理论上的问题,但 无论如何您都没有多少有用的事情 ,并且您的过程将像病态的东西一样被不雅地搁置。

所以我的建议是:不要太担心 :)

(实际上,更大的风险是线程池死亡螺旋,通常是由线程池线程上的同步异步引起的,这意味着即使您已经从语义上获取了信号量,也没有池线程给你实际做事情,让你回去释放它)

是的,你的假设是正确的。如果在进入 try 之前抛出异常,则 SemaphoreSlim 将永远不会被释放。虽然这是一个非常非常罕见的事件,但我认为在大多数情况下都可以忽略。例如,当代码作为可以取消的任务执行时,不幸的是恰好在 Wait() 完成之后和输入 try{} 之前被取消。有可能,但可能性很小,您不必担心。

您作为示例提供的代码是有效的,甚至在 Microsoft Docs (https://docs.microsoft.com/en-us/dotnet/api/system.threading.semaphoreslim?view=net-6.0#examples)

中也提供了类似的代码

如果您的程序在任何情况下都不得发生此事件,您可以使用 using(){} 实现类似 lock(){} 的模式。这是我在所有项目中使用的 class,而不是手动处理 SemaphoreSlim 或 lock(){} 语句。

public class MySemaphore : IDisposable
{
    private string semaphoreLockKey;
    private static Dictionary<string, SemaphoreSlim> internalSemaphoreSlimDict = new Dictionary<string, SemaphoreSlim>();

    /// <summary>
    /// <para>Creates a <see cref="MySemaphore"/> for the given <paramref name="key"/> and aquires the lock this <see cref="MySemaphore"/> represents.</para>
    /// <para>The task this method returns will await the lock for this <see cref="MySemaphore"/> if the semaphore with the key is already in use.
    /// Once the task aquired the lock, an instance of <see cref="MySemaphore"/> is returned, which will release the lock once <see cref="Dispose"/> is called (preferably via a using() statement)</para>
    /// </summary>
    /// <param name="key"></param>
    /// <returns>Returns a <see cref="MySemaphore"/> that holds the lock of the given <paramref name="key"/>. Dispose the returned instance to release the lock (preferably via a using() statement)</returns>
    /// <remarks>Wrap this into a using() to release the semaphore upon finishing your locked code</remarks>
    public static async Task<MySemaphore> WaitForLockAsync(string key)
    {
        var mySemaphore = new MySemaphore(key);

        await internalSemaphoreSlimDict[key].WaitAsync();
        return mySemaphore;
    }

    /// <summary>
    /// <para>Creates a <see cref="MySemaphore"/> for the given <paramref name="key"/> and aquires the lock this <see cref="MySemaphore"/> represents.</para>
    /// <para>The task this method returns will await the lock for this <see cref="MySemaphore"/> if the semaphore with the key is already in use.
    /// Once the task aquired the lock, an instance of <see cref="MySemaphore"/> is returned, which will release the lock once <see cref="Dispose"/> is called (preferably via a using() statement)</para>
    /// </summary>
    /// <param name="key"></param>
    /// <returns>Returns a <see cref="MySemaphore"/> that holds the lock of the given <paramref name="key"/>. Dispose the returned instance to release the lock (preferably via a using() statement)</returns>
    /// <remarks>Wrap this into a using() to release the semaphore upon finishing your locked code</remarks>
    public static MySemaphore WaitForLock(string key)
    {
        var mySemaphore = new MySemaphore(key);

        internalSemaphoreSlimDict[key].Wait();
        return mySemaphore;
    }

    /// <summary>
    /// Constructor using a key. If a key already exists and is currently used, it will lock the calling thread until the other thread has disposed his MySemaphore
    /// </summary>
    /// <param name="key"></param>
    private MySemaphore(string key)
    {
        this.semaphoreLockKey = key;
        if (!internalSemaphoreSlimDict.ContainsKey(key))
            internalSemaphoreSlimDict[key] = new SemaphoreSlim(1, 1);
    }

    /// <summary>
    /// Releases the Lock that is held by this instance
    /// </summary>
    public void Dispose()
    {
        internalSemaphoreSlimDict[semaphoreLockKey].Release();
    }
}

使用这个 class,我们可以定义一个(字符串)键,将为其创建一个 SemaphoreSlim。两个彼此一无所知的任务可以使用相同的密钥,从而等待另一个任务完成。当我们调用 MySemaphore.WaitForLock 时,将创建一个新的 MySemaphore 实例(表示一个 SemaphoreSlim)并调用 SemaphoreSlim.Wait()。获取锁后,返回 MySemaphore 实例。

由于我们将其包装到 using(){} 语句中,因此每当退出 using 时(通过完成代码或在进入 using 块后抛出任何异常时,MySemaphore.Dispose() 是被调用,这又释放了锁。

用法为:

using (MySemaphore.WaitForLock("someLockKey"))
{
    //do something
}

using (await MySemaphore.WaitForLockAsync("someLockKey"))
{
    await Task.Delay(1000);
}

请注意,通过这种方式,您主要创建了一个类似 lock(){} 的模式,它支持在异步方法中使用并减少代码中的行数。根据 Microsoft 文档,在较旧的 C# 版本中,using() 语句被编译为实际上与您使用 try/finally 相同。这意味着,只有在输入 using 块后,才会释放 MySemaphore - 而不是在仍在执行 WaitForLock() 时。这意味着,你仍然有同样的问题,在 Wait() 获取锁和进入 using 块之间,SemaphoreSlim 不会被释放。

然而,当您使用 using 声明时,这在 C# 8.0 中发生了变化(参见 https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-8.0/using#using-declaration), according to here: https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/using-statement

The newer using statement syntax translates to similar code. The try block opens where the variable is declared. The finally block is added at the close of the enclosing block, typically at the end of a method.

由于我们在 Wait() 调用之前声明了 MySemaphore(当使用 using 声明,而不是 using 语句时), SemaphoreSlim 在任何情况下都会被释放。但是,您必须处理在 Wait() 能够获取锁之前调用 Dispose() 的情况。这可以通过在 MySemaphore class 中添加一个新的布尔字段并在调用 SemaphoreSlim.Wait() 后将其翻转为真来解决,并且仅在该字段为真时调用 Release()。

private bool hasLock;
public void Dispose()
{
    if(hasLock)
        internalSemaphoreSlimDict[semaphoreLockKey].Release();
}

但是您需要更改 MySemaphore 的用法:

using MySemaphore ms = MySemaphore.WaitForLock("someLockKey");
//do something
//MySemaphore "ms" will be disposed when the current scope is exited

using MySemaphore ms = await MySemaphore.WaitForLockAsync("someLockKey");
//do something
//MySemaphore "ms" will be disposed when the current scope is exited

有了这个,一旦声明了“ms”实例,无论发生什么情况,所获得的锁总是会被释放。