我们有控制机器的软件.这台机器有一个可以在生产中运行的"作业(job)"概念.这是一个很常见的概念.关于这台机器做什么的更多细节无关紧要.
该软件是用C#编写的,并且已经存在了很长一段时间.类似于callc-await的概念被改编,但是很多遗留的东西没有更新,并且使用lock
个关键字使其成为线程安全的.
当我们想要将"线程安全"代码从同步接口迁移到异步接口时,我们需要用SemaphoreSlim
保护替换lock
条语句.
先介绍一下背景
我最后有一个较一般性的问题,但请容许我先说明问题.
下面是一些"伪"代码,没有使用IDE编写,只是为了使演示成为问题/主题.如果我打错了字,请原谅.
public List<IJob> AllJobs
{
get
{
lock(someLockObject)
{
// Create a new instance of the list so that the caller would not iterate
// over the list while it is being modified by another thread
return _allJobs.ToList();
}
}
}
public void AddJob(IJob job)
{
lock (someLockObject)
{
// Add the job to a list
_allJobs.Add(job);
// Fire an event
AvailableJobsChanged?.Invoke(this, EventArgs.Empty);
}
}
上面的代码可以工作,这主要归功于锁是线程感知的这一事实.因此,当同一线程试图进入底层Monitor
时,它会立即获得访问权限.因此,在我们的例子中,一些线程调用方法AddJob
,该事件触发外部代码(在相同的线程上下文中仍然存在).无论出于何种原因,外部代码都会读取属性AllJobs
,然后我们就完成了.
因此,场景(都在线程1上执行):
- 线程#1呼入
AddJob
- AddJob通过事件触发外部代码
- 外部代码读取AllJobs属性
当我们用SemaphoreSlim
来重写它的时候,它会变得非常错误.
private readonly SemaphoreSlim _sempahore = new();
public List<IJob> AllJobs
{
get
{
try
{
_semaphore.Wait();
// Create a new instance of the list so that the caller would not iterate
// over the list while it is being modified by another thread
return _allJobs.ToList();
}
finally
{
_semaphore.Release();
}
}
}
public async Task AddJob(IJob job)
{
try
{
await _semaphore.WaitAsync().ConfigureAwait(false);
// Add the job to a list
_allJobs.Add(job);
// Fire an event
AvailableJobsChanged?.Invoke(this, EventArgs.Empty);
}
finally
{
_semaphore.Release();
}
}
如果我们重播一下这个场景:
- 线程#1呼入
AddJob
- 信号量被占用
- 事件触发外部代码
- 外部代码读取
AllJobs
个属性 - 线程#1在获取信号量时死锁
因此,我们知道,当从传统的"同步"接口迁移到更现代的异步接口时,我们不能简单地用SemaphoreSlims来取代锁.
通常的解决方案是,确保事件在锁定时被触发.那么这个问题就解决了,而且它仍然是"线程安全的".
上面的例子是我们 case 中最简单的例子.当然,我们也有更长的方法,并且在不同的情况下插入相同的列表.在这些情况下,在整个方法上使用一个大锁看起来更好,这样就没有人需要考虑线程安全的问题了.但我想知道,这是真的吗?
锁定/线程安全的"规则"
那么,在上述所有情况下,这里有没有经验法则?我声称,只有在代码与需要保护的资源交互时才获取和释放信号量,这会更好.但我也明白,锁定整个方法"容易得多".
在SemaphoreSlim
美元的情况下,我也看不到其他方法.但这并不意味着没有这样的机会.但无论如何,我也会尽可能地使用lock
.这是错的吗?
在网上找到一些指南后更新
维基百科可能有一个答案,不幸的是,对我来说,这与我早先尽可能地锁定细粒度的方法几乎是矛盾的.
锁的一个重要属性是它的粒度.粒度是锁保护的数据量的量度.通常,当单个进程访问受保护的数据时, Select 较粗的粒度(少量锁,每个锁保护一大段数据)会导致较少的锁开销,但当多个进程同时运行时,性能会较差.这是因为锁争用增加了.锁越粗糙,锁停止不相关进程继续的可能性就越大.相反,使用细粒度(较大数量的锁,每个锁保护的数据量相当小)会增加锁本身的开销,但会减少锁争用.精细锁定(其中每个进程必须持有来自一组公共锁的多个锁)可能会创建微妙的锁依赖关系.这种细微之处可能会增加程序员在不知不觉中引入死锁的可能性.
来源:https://en.wikipedia.org/wiki/Lock_(computer_science)
因此,这将建议尽可能地从锁定整个方法开始.同样,在上面的示例中,这是一个包含lock
条语句的选项.我不知道,我觉得有点失落.我想我正在寻找一种合适的模式来处理这种情况.
我有点担心改变我们以前"保证"同步行为的方式,当我们转移到使用更细粒度锁定的异步等待时,情况不再是这样.
如果有人还在读到这里,并有更好的方法来处理这种移民,我洗耳恭听