减小锁定的粒度:C#实现基于关键字(key)的锁定

问题描述

最近需要实现一个API,方法签名(的抽象版本)类似于

void Update(string id)

API将在多线程环境下被调用,需满足:

  1. 如果多个调用线程传入相同的id,则它们必须被串行化——一个线程工作,其他线程阻塞,前一个线程调用完毕后,后一个线程才开始工作,依此类推。
  2. 若传入的id不同,则各线程可并行执行。

场景与数据库的行锁定非常相似——锁定对于更新相同的行的多个请求是互斥的,而更新不同的行则可同时进行。
不过这回我们没有数据库的帮忙,同时,程序非常的小(其实是客户端程序),所以我们希望解决方案也非常小巧。

 

基本思路

说道多线程串行化,立刻想到的就是锁,但是如果简单的 lock (someGlobalObject) 会时所有的线程串行化,这不满足需求。
我们仍然需要锁,不过锁的作用范围更小,于是,需求被转化为小粒度锁定的实现,这个锁范围满足:

  1. 相同的id共享同一个锁对象。
  2. 不同的id使用不同的锁对象。

 

实现

思路确定了,进入实现阶段。
这里为了便于测试,定义一个接口,实际的场景中并不需要这个接口。

interface IKeyLockEngine
{
    void Invoke(string key, Action act);
}

实现方案1:使用字典记录id与锁对象

“id -> 锁对象”的映射场景很容易让人想到字典(哈希表)——使用一个Dictionary存放正在被使用的id(作为key)和锁对象(作为value),若已经没有线程使用某一id调用API,则从字典中移除该id。
对于每次传入的id做一次检验,将获得以下两种情况:

  1. id不存在于字典中——没有线程正在使用该id调用API,为该id分配一个锁对象,并将id写入字典;
  2. id存在于字典中——已有线程正在使用该id调用API,从字典中取出锁对象并使用之;


那么,在有多个线程使用同一个id的情况下,如何知道何时需要从字典中移除该id呢?这里引入一个计数器来解决。
此方案的实现代码如下:

public class DictionaryBasedKeyLockEngine : IKeyLockEngine
{
    private static readonly object SyncRoot = new object();
    private static readonly Dictionary<string, LockUnit> Locks = new Dictionary<string, LockUnit>();

    public void Invoke(string key, Action act)
    {
        LockUnit lockUnit;

        lock (SyncRoot)
        {
            if (Locks.TryGetValue(key, out lockUnit))
            {
                lockUnit.WaitCounter++;
            }
            else
            {
                lockUnit = new LockUnit();
                Locks.Add(key, lockUnit);
            }
        }

        try
        {
            Monitor.Enter(lockUnit);
            act();
        }
        finally
        {
            lock (SyncRoot)
            {
                lockUnit.WaitCounter--;
                if (lockUnit.WaitCounter == 0)
                    Locks.Remove(key);
            }
            Monitor.Exit(lockUnit);
        }
    }

    private class LockUnit
    {
        public int WaitCounter;
    }
}

上面使用了内部类LockUnit作为锁定对象的类型,并保持一个计数器WaitCounter,其记录了当前使用该锁对象的线程的数量,当计数器归0,就是从字典里移除id的时候了。
因为字典数据是各线程共享的,为了能安全的操作字典,需要一个额外的锁对象SyncRoot,因此实际上有两层的锁定。

实现方案2:互斥体

另一种实现方案使用了.net Framework提供的互斥体 System.Threading.Mutex,利用其可命名的特性,将id作为互斥体的名称,很好的实现了id到锁对象的映射。

public class MutexBasedKeyLockEngine : IKeyLockEngine
{
    private static readonly string NameHeader = Guid.NewGuid().ToString("N");

    public void Invoke(string key, Action act)
    {
        var m = new Mutex(false, NameHeader + key);
        try
        {
            m.WaitOne();
            act();
        }
        finally
        {
            m.ReleaseMutex();
        }
    }
}

因为互斥体是在整个操作系统中有效的,作用域非常大,为了避免key与本实现外部所注册的互斥体冲突,定义了一个Guid(几乎不会重复)作为互斥体名称的前缀,以避免此问题。

此方案还有一个问题:互斥体的名称必须是字符串,而前面使用字典的方案中,key可以是任意类型,容易将string类型的key改为泛型类型,从而扩展该实现的使用范围。互斥体命名则不能做此扩展,因为我们无法确保关键字的类型总是按要求重载了ToString方法。

实现方案3:自旋锁

此方案由边城浪补充。利用高性能的CAS操作将锁的粒度变小,收录如下:

public class SpinLockEngine : IKeyLockEngine
{
    private const int LockeCount = 0x12fd; //素数,减少hash冲突,值越大冲突概率越小,但占用内存越大
    private static readonly int[] Locks = new int[LockeCount];

    public void Invoke(string key, Action act)
    {
        int index = (key.GetHashCode() & 0x7fffffff) % LockeCount;

        // 尝试0变1,进入对应index的临界状态;
        while (Interlocked.CompareExchange(ref Locks[index], 1, 0) == 1)
        {
            Thread.Sleep(1);
            ////可也以计数方式.每X次尝试失败则睡眠1,否则睡眠0
            //Thread.Sleep(((++count) | 0x000f) == 0 ? 1 : 0);
        }

        try
        {
            act();
        }
        finally
        {
            Thread.VolatileWrite(ref Locks[index], 0);
        }
    }
}

该方案下,若两个的key的GetHashCode结果与素数的模数相同,则两个key互斥,即使两个key不想等——目前的应用场景下这是可以接受的。

其他方案:

使用旗语 System.Threading.Semaphore,与互斥体相似,不过还多一个功能,可以控制并发的数量,因为应用场景下没有此要求,在此便不再讨论。

 

性能分析

我们来比较上述两种方案的性能,重点比较id的重复率对于性能的影响,测试代码如下,代码中使用了老赵的性能计数器CodeTimer

static void Main()
{
    var ran = new Random();
    var keyRange = 100; //控制id重复的概率,值越大重复的概率越小
    var keys = new string[100000];
    for (int i = 0; i < keys.Length; i++)
    {
        keys[i] = ran.Next(keyRange).ToString();
    }

    Action act = () => Thread.Sleep(TimeSpan.FromMilliseconds(0.1));

    Console.WriteLine("keyRange={0}", keyRange);
    CodeTimer.Initialize();
    CodeTimer.Time("mutex", 1, () => Perform(new MutexBasedKeyLockEngine(), keys, act, 10));
    CodeTimer.Time("dictionary", 1, () => Perform(new DictionaryBasedKeyLockEngine(), keys, act, 10));
    CodeTimer.Time("spinlock", 1, () => Perform(new SpinLockEngine(), keys, act, 10));

    Console.ReadKey();
}

static void Perform(IKeyLockEngine keyLockEngine, string[] keys, Action act, int threadCount)
{
    var threads = new List<Thread>();
    for (int i = 0; i < threadCount; i++)
    {
        var tmp = i;
        var t = new Thread(() =>
        {
            for (int j = tmp; j < keys.Length; j += threadCount)
            {
                keyLockEngine.Invoke(keys[j], act);
            }
        });
        threads.Add(t);
    }
    threads.ForEach(x => x.Start());
    threads.ForEach(x => x.Join());
}

测试代码中使用变量keyRange控制随机数的生成范围,keyRange越小,随机数的取值范围就越小,生成的关键字重复的概率就越大,下面是不同的keyRange下的测试结果。

keyRange=10
mutex
        Time Elapsed:   1,170ms
dictionary
        Time Elapsed:   3,305ms
spinlock
        Time Elapsed:   65ms
====================
keyRange=10000
mutex
        Time Elapsed:   1,277ms
dictionary
        Time Elapsed:   179ms
spinlock
        Time Elapsed:   45ms
====================
keyRange=10000000
mutex
        Time Elapsed:   4,900ms
dictionary
        Time Elapsed:   189ms
spinlock
        Time Elapsed:   56ms

从测试结果中,可以看到,关键字的重复率较高时,使用互斥体的方案竟比之使用字典+Moniter的方案的耗时更少;反之,则使用字典的方案耗时更少。
这个结果在使用互斥体的方案上是容易理解的,因为互斥体的创建和销毁开销较大,重复率越高则创建/销毁的互斥体越少,开销也就越少。
那为何使用字典的方案在关键字重复率较高时性能下降了呢?经测试,在多个线程请求锁时,Moniter.Enter方法比单线程请求锁时花费的时间更多,这就需要从Moniter的实现原理上去理解了。
自旋锁的性能表现极好——它的操作更接近底层。

 

结论

若严格要求具有不同的key的操作可并行执行,使用字典的方案;在允许不同的key有小概率互斥的情况下,自旋锁的方案具有最佳的表现。

 

写在后面:这篇文章的标题怎么取非常让人纠结,一下子找不到一句合适的话描述该问题,各位会怎么做呢?

posted on 2012-11-17 20:10  codeyeast  阅读(2721)  评论(9编辑  收藏  举报

导航