减小锁定的粒度:C#实现基于关键字(key)的锁定
问题描述
最近需要实现一个API,方法签名(的抽象版本)类似于
void Update(string id)
API将在多线程环境下被调用,需满足:
- 如果多个调用线程传入相同的id,则它们必须被串行化——一个线程工作,其他线程阻塞,前一个线程调用完毕后,后一个线程才开始工作,依此类推。
- 若传入的id不同,则各线程可并行执行。
场景与数据库的行锁定非常相似——锁定对于更新相同的行的多个请求是互斥的,而更新不同的行则可同时进行。
不过这回我们没有数据库的帮忙,同时,程序非常的小(其实是客户端程序),所以我们希望解决方案也非常小巧。
基本思路
说道多线程串行化,立刻想到的就是锁,但是如果简单的 lock (someGlobalObject) 会时所有的线程串行化,这不满足需求。
我们仍然需要锁,不过锁的作用范围更小,于是,需求被转化为小粒度锁定的实现,这个锁范围满足:
- 相同的id共享同一个锁对象。
- 不同的id使用不同的锁对象。
实现
思路确定了,进入实现阶段。
这里为了便于测试,定义一个接口,实际的场景中并不需要这个接口。
1 interface IKeyLockEngine 2 { 3 void Invoke(string key, Action act); 4 }
实现方案1:使用字典记录id与锁对象
“id -> 锁对象”的映射场景很容易让人想到字典(哈希表)——使用一个Dictionary存放正在被使用的id(作为key)和锁对象(作为value),若已经没有线程使用某一id调用API,则从字典中移除该id。
对于每次传入的id做一次检验,将获得以下两种情况:
- id不存在于字典中——没有线程正在使用该id调用API,为该id分配一个锁对象,并将id写入字典;
- id存在于字典中——已有线程正在使用该id调用API,从字典中取出锁对象并使用之;
那么,在有多个线程使用同一个id的情况下,如何知道何时需要从字典中移除该id呢?这里引入一个计数器来解决。
此方案的实现代码如下:
1 public class DictionaryBasedKeyLockEngine : IKeyLockEngine 2 { 3 private static readonly object SyncRoot = new object(); 4 private static readonly Dictionary<string, LockUnit> Locks = new Dictionary<string, LockUnit>(); 5 6 public void Invoke(string key, Action act) 7 { 8 LockUnit lockUnit; 9 10 lock (SyncRoot) 11 { 12 if (Locks.TryGetValue(key, out lockUnit)) 13 { 14 lockUnit.WaitCounter++; 15 } 16 else 17 { 18 lockUnit = new LockUnit(); 19 Locks.Add(key, lockUnit); 20 } 21 } 22 23 try 24 { 25 Monitor.Enter(lockUnit); 26 act(); 27 } 28 finally 29 { 30 lock (SyncRoot) 31 { 32 lockUnit.WaitCounter--; 33 if (lockUnit.WaitCounter == 0) 34 Locks.Remove(key); 35 } 36 Monitor.Exit(lockUnit); 37 } 38 } 39 40 private class LockUnit 41 { 42 public int WaitCounter; 43 } 44 }
上面使用了内部类LockUnit作为锁定对象的类型,并保持一个计数器WaitCounter,其记录了当前使用该锁对象的线程的数量,当计数器归0,就是从字典里移除id的时候了。
因为字典数据是各线程共享的,为了能安全的操作字典,需要一个额外的锁对象SyncRoot,因此实际上有两层的锁定。
实现方案2:互斥体
另一种实现方案使用了.net Framework提供的互斥体 System.Threading.Mutex,利用其可命名的特性,将id作为互斥体的名称,很好的实现了id到锁对象的映射。
1 public class MutexBasedKeyLockEngine : IKeyLockEngine 2 { 3 private static readonly string NameHeader = Guid.NewGuid().ToString("N"); 4 5 public void Invoke(string key, Action act) 6 { 7 var m = new Mutex(false, NameHeader + key); 8 try 9 { 10 m.WaitOne(); 11 act(); 12 } 13 finally 14 { 15 m.ReleaseMutex(); 16 } 17 } 18 }
因为互斥体是在整个操作系统中有效的,作用域非常大,为了避免key与本实现外部所注册的互斥体冲突,定义了一个Guid(几乎不会重复)作为互斥体名称的前缀,以避免此问题。
其他方案:
使用旗语 System.Threading.Semaphore,与互斥体相似,不过还多一个功能,可以控制并发的数量,因为应用场景下没有此要求,在此便不再讨论。
性能分析
我们来比较上述两种方案的性能,重点比较id的重复率对于性能的影响,测试代码如下,代码中使用了老赵的性能计数器CodeTimer。
1 static void Main() 2 { 3 var ran = new Random(); 4 var keyRange = 100; //控制id重复的概率,值越大重复的概率越小 5 var keys = new string[100000]; 6 for (int i = 0; i < keys.Length; i++) 7 { 8 keys[i] = ran.Next(keyRange).ToString(); 9 } 10 11 Action act = () => Thread.Sleep(TimeSpan.FromMilliseconds(0.1)); 12 13 Console.WriteLine("keyRange={0}", keyRange); 14 CodeTimer.Initialize(); 15 CodeTimer.Time("mutex", 1, () => Perform(new MutexBasedKeyLockEngine(), keys, act, 10)); 16 CodeTimer.Time("dictionary", 1, () => Perform(new DictionaryBasedKeyLockEngine(), keys, act, 10)); 17 18 Console.ReadKey(); 19 } 20 21 static void Perform(IKeyLockEngine keyLockEngine, string[] keys, Action act, int threadCount) 22 { 23 var threads = new List<Thread>(); 24 for (int i = 0; i < threadCount; i++) 25 { 26 var tmp = i; 27 var t = new Thread(() => 28 { 29 for (int j = tmp; j < keys.Length; j += threadCount) 30 { 31 keyLockEngine.Invoke(keys[j], act); 32 } 33 }); 34 threads.Add(t); 35 } 36 threads.ForEach(x => x.Start()); 37 threads.ForEach(x => x.Join()); 38 }
测试代码中使用变量keyRange控制随机数的生成范围,keyRange越小,随机数的取值范围就越小,生成的关键字重复的概率就越大,下面是不同的keyRange下的测试结果。
keyRange=10
mutex
Time Elapsed: 1,172ms
CPU Cycles: 31,268,688
dictionary
Time Elapsed: 3,075ms
CPU Cycles: 8,096,584
====================
keyRange=10000
mutex
Time Elapsed: 1,719ms
CPU Cycles: 14,243,352
dictionary
Time Elapsed: 240ms
CPU Cycles: 13,247,904
====================
keyRange=10000000
mutex
Time Elapsed: 3,719ms
CPU Cycles: 50,583,016
dictionary
Time Elapsed: 207ms
CPU Cycles: 21,327,184
从测试结果中,可以看到,关键字的重复率较高时,使用互斥体的方案竟比之使用字典+Moniter的方案的耗时更少;反之,则使用字典的方案耗时更少。
这个结果在使用互斥体的方案上是容易理解的,因为互斥体的创建和销毁开销较大,重复率越高则创建/销毁的互斥体越少,开销也就越少。
那为何使用字典的方案在关键字重复率较高时性能下降了呢?经测试,在多个线程请求锁时,Moniter.Enter方法比单线程请求锁时花费的时间更多,这就需要从Moniter的实现原理上解释了,目前我对此原理尚未了解透彻,待搞清楚后再道来。
结论
最终,我们使用字典的方案,原因如下:
- 使用互斥体的方案在关键字完全重复的情况下应该是性能最好的,不过此时各线程就完全串行化了,这和使用简单的lock效果一样,此时该方案就没有意义了;而实际场景中关键字是“偶然重复”的,这意味着,使用字典的方案更为泛用。
- 互斥体的名称必须是字符串,而字典的key可以是任意类型,在使用字典的方案中,我们容易将string类型的key改为泛型类型,从而扩展该实现的使用范围。互斥体命名则不能做此扩展,因为我们无法确保关键字的类型总是按要求重载了ToString方法。
写在后面:这篇文章的标题怎么取非常让人纠结,一下子找不到一句合适的话描述该问题,各位会怎么做呢?
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构