【问题标题】:Efficient locking on a resource, identified by a string对资源的有效锁定,由字符串标识
【发布时间】:2020-02-01 17:54:38
【问题描述】:

编辑:我更新了我的示例以使用 https://github.com/StephenCleary/AsyncEx 库。仍在等待可用的提示。

有一些资源,由字符串标识(例如文件、URL 等)。我正在寻找资源的锁定机制。我找到了 2 种不同的解决方案,但每种都有其问题:

第一个是使用ConcurrentDictionary类和AsyncLock

using Nito.AsyncEx;
using System.Collections.Concurrent;

internal static class Locking {
    private static ConcurrentDictionary<string, AsyncLock> mutexes
        = new ConcurrentDictionary<string, AsyncLock>();

    internal static AsyncLock GetMutex(string resourceLocator) {
        return mutexes.GetOrAdd(
            resourceLocator,
            key => new AsyncLock()
        );
    }
}

异步使用:

using (await Locking.GetMutex("resource_string").LockAsync()) {
    ...
}

同步使用:

using (Locking.GetMutex("resource_string").Lock()) {
    ...
}

这很安全,但问题是字典越来越大,当没有人等待锁定时,我没有看到一种线程安全的方法来从字典中删除项目。 (我也想避免全局锁。)

我的第二个解决方案将字符串散列为0N - 1 之间的数字,并锁定这些:

using Nito.AsyncEx;
using System.Collections.Concurrent;

internal static class Locking {
    private const UInt32 BUCKET_COUNT = 4096;

    private static ConcurrentDictionary<UInt32, AsyncLock> mutexes
        = new ConcurrentDictionary<UInt32, AsyncLock>();

    private static UInt32 HashStringToInt(string text) {
        return ((UInt32)text.GetHashCode()) % BUCKET_COUNT;
    }

    internal static AsyncLock GetMutex(string resourceLocator) {
        return mutexes.GetOrAdd(
            HashStringToInt(resourceLocator),
            key => new AsyncLock()
        );
    }
}

如您所见,第二种解决方案仅降低了冲突的概率,但并没有避免它们。我最大的担心是它会导致死锁:避免死锁的主要策略是始终以特定顺序锁定项目。但是使用这种方法,不同的项目可以以不同的顺序映射到相同的桶,例如:(A->X,B->Y),(C->Y,D->X)。因此,使用此解决方案无法安全地锁定多个资源。

有没有更好的解决方案? (我也欢迎对上述两种解决方案提出批评。)

【问题讨论】:

  • Lock/Unlock API 看起来有点笨拙且容易出错。您不喜欢利用方便的lock 语句的API 吗?例如:lock (mutex.Get("some_string")) {/*protected region*/}
  • @TheodorZoulias 谢谢,我在想这个。优点是,如果出现异常,锁会自动删除。但作为下一步,我将使用 NuGet 库中的 AsyncAutoResetEvent 将其扩展到异步情况。而且我看不到使用lock 语句实现的类似东西。
  • @TheodorZoulias 虽然我可以使用这个:github.com/StephenCleary/AsyncEx#asynclock 所以我将进入lock 声明方向,谢谢。
  • 实现基于SemaphoreSlim 的异步一次性储物柜非常简单,但另一方面,使用 Stephen Cleary 经过良好测试的库不会出错!

标签: c# multithreading asynchronous .net-core locking


【解决方案1】:

您可以通过在字典停止使用时从字典中删除锁来改进第一个解决方案。然后可以将移除的锁添加到一个小池中,这样下次您需要一把锁时,您只需从池中获取一个,而不是创建一个新的。


更新:这是这个想法的一个实现。它基于 SemaphoreSlims 而不是 Stephen Cleary 的 AsyncLocks,因为需要自定义一次性用品才能从字典中删除未使用的信号量。

public class MultiLock<TKey>
{
    private object Locker { get; } = new object();
    private Dictionary<TKey, LockItem> Dictionary { get; }
    private Queue<LockItem> Pool { get; }
    private int PoolSize { get; }

    public MultiLock(int poolSize = 10)
    {
        Dictionary = new Dictionary<TKey, LockItem>();
        Pool = new Queue<LockItem>(poolSize);
        PoolSize = poolSize;
    }

    public WaitResult Wait(TKey key,
        int millisecondsTimeout = Timeout.Infinite,
        CancellationToken cancellationToken = default)
    {
        var lockItem = GetLockItem(key);
        bool acquired;
        try
        {
            acquired = lockItem.Semaphore.Wait(millisecondsTimeout,
                cancellationToken);
        }
        catch
        {
            ReleaseLockItem(lockItem, key);
            throw;
        }
        return new WaitResult(this, lockItem, key, acquired);
    }

    public async Task<WaitResult> WaitAsync(TKey key,
        int millisecondsTimeout = Timeout.Infinite,
        CancellationToken cancellationToken = default)
    {
        var lockItem = GetLockItem(key);
        bool acquired;
        try
        {
            acquired = await lockItem.Semaphore.WaitAsync(millisecondsTimeout,
                cancellationToken).ConfigureAwait(false);
        }
        catch
        {
            ReleaseLockItem(lockItem, key);
            throw;
        }
        return new WaitResult(this, lockItem, key, acquired);
    }

    private LockItem GetLockItem(TKey key)
    {
        LockItem lockItem;
        lock (Locker)
        {
            if (!Dictionary.TryGetValue(key, out lockItem))
            {
                if (Pool.Count > 0)
                {
                    lockItem = Pool.Dequeue();
                }
                else
                {
                    lockItem = new LockItem();
                }
                Dictionary.Add(key, lockItem);
            }
            lockItem.UsedCount += 1;
        }
        return lockItem;
    }

    private void ReleaseLockItem(LockItem lockItem, TKey key)
    {
        lock (Locker)
        {
            lockItem.UsedCount -= 1;
            if (lockItem.UsedCount == 0)
            {
                if (Dictionary.TryGetValue(key, out var stored))
                {
                    if (stored == lockItem) // Sanity check
                    {
                        Dictionary.Remove(key);
                        if (Pool.Count < PoolSize)
                        {
                            Pool.Enqueue(lockItem);
                        }
                    }
                }
            }
        }
    }

    internal class LockItem
    {
        public SemaphoreSlim Semaphore { get; } = new SemaphoreSlim(1);
        public int UsedCount { get; set; }
    }

    public struct WaitResult : IDisposable
    {
        private MultiLock<TKey> MultiLock { get; }
        private LockItem LockItem { get; }
        private TKey Key { get; }

        public bool LockAcquired { get; }

        internal WaitResult(MultiLock<TKey> multiLock, LockItem lockItem, TKey key,
            bool acquired)
        {
            MultiLock = multiLock;
            LockItem = lockItem;
            Key = key;
            LockAcquired = acquired;
        }

        void IDisposable.Dispose()
        {
            MultiLock.ReleaseLockItem(LockItem, Key);
            LockItem.Semaphore.Release();
        }
    }
}

使用示例:

var multiLock = new MultiLock<string>();
using (await multiLock.WaitAsync("SomeKey"))
{
    //...
}

未使用信号量的默认池大小为 10。最佳值应该是使用 MultiLock 实例的并发工作人员的数量。

我在我的 PC 上进行了性能测试,10 个工作人员每秒总共能够异步获取锁 500,000 次(使用了 20 个不同的字符串标识符)。

【讨论】:

  • WaitOne 返回的那一刻,另一个线程可以在我从字典中删除之前“锁定”事件。然后下一个线程会创建一个新事件,并且 2 会同时使用相同的资源。
  • 是的,这个解决方案应该在考虑线程安全的情况下稳健地实现,并且基于ConcurrentDictionary 可能不会削减它,因为这个类缺少条件TryRemove 方法。显而易见的替代方案是使用lock 保护的普通Dictionary,但问题是如果所有字典操作都需要对单个共享锁的独占访问,将会产生多少争用。这取决于调用 Lock 方法的路径的 hot 程度。对于每秒少于 500,000 次调用,争用应该是微不足道的。
  • @CrouchingKitten 我用提议的解决方案的实施更新了我的答案。
  • 谢谢,+1。我得再检查一下。那么池的唯一作用就是避免垃圾回收带来的性能问题?嗯,它为字典使用了全局锁,但我想这可以通过锁定键的哈希然后修改 ConcurrentDictionary 来解决,就像它的键属于分区一样。
  • @CrouchingKitten 是的,池中只有少数SemaphoreSlims 在应用程序的生命周期内被创建。我认为用无锁ConcurrentDictionary 替换Dictionary+lock 是不可能的,因为在请求锁和释放锁时,必须以原子方式完成多个操作。不过这些操作非常便宜,所以我不认为这会成为问题。
猜你喜欢
  • 2012-06-09
  • 2012-03-16
  • 2012-04-18
  • 1970-01-01
  • 2015-05-22
  • 1970-01-01
  • 2018-11-29
  • 2017-11-08
  • 1970-01-01
相关资源
最近更新 更多