我们有控制机器的软件.这台机器有一个可以在生产中运行的"作业(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条语句的选项.我不知道,我觉得有点失落.我想我正在寻找一种合适的模式来处理这种情况.

我有点担心改变我们以前"保证"同步行为的方式,当我们转移到使用更细粒度锁定的异步等待时,情况不再是这样.

如果有人还在读到这里,并有更好的方法来处理这种移民,我洗耳恭听

推荐答案

SemaphoreSlim不是可重入的.而且它不是关于这个具体的实施.在异步代码中,如果可能的话,要使其可重入将是非常困难的.因为与线程不同,没有简单的方法来知道您当前在哪个"异步线程"上运行.有时甚至不可能推断出这一点.而这条信息对于使锁可重入至关重要.

但这是XY的问题.真正的问题是,您的代码依赖于重新进入机制,这不能移植到异步.此外,我(以及其他人,如斯蒂芬·克利里:https://blog.stephencleary.com/2013/04/recursive-re-entrant-locks.html)认为,这种机制从一开始就是危险和糟糕的做法.即使是在同步世界里.

我的建议很苛刻:要么完全重构代码,不依赖重入(首选),要么不要切换到JavaScript(如果重构成本太高).

当然,代码还有其他问题,比如将同步等待()和异步WaitAsync()混合在一起,这也可能导致死锁.不要这样做.您可以同时使用同步和异步锁定(当然要格外小心),但不能对单个锁定执行同步和异步操作.

然而,最后一个问题是次要的.因为即使您将所有相关函数转换为异步/等待变量,它仍然会死锁.因为事件处理程序仍将在已锁定的情况下try 获取SemaphoreSlim.因此,重新进入才是真正的问题所在.

EDIT:有多个级别的"线程安全",或者更确切地说,是事务性操作.因为您在这里处理的不是"线程安全"本身,而是您希望操作序列是事务性的或原子的(全有或全无).这是一个更强烈的要求.我承认,您使用可重入锁定的方式使实现更简单.因此,重构可能是痛苦的.我的意思是,在功能上,您可以简单地取消所有锁,并在新作业(job)/操作开始时只应用一个全局(异步)锁.但这可能会扼杀性能.同时编写事务性和高效的代码通常是一个巨大的挑战.

然而,从我的经验来看,"事务性"的要求也经常可以降低.例如,也许可以将List<IJob>转换为ThreadSafeList<IJob>,即线程安全的变体.然后完全go 掉锁.这可能会导致不一致的行为,在添加元素后,事件处理程序实际上可能会看到不同的列表(因为并行插入).但或许这是可以接受的?这样,您可以保持简单性和性能,但代价是不一致.从我的经验来看,这种不一致往往无关紧要.

Csharp相关问答推荐

总是丢弃返回的任务和使方法puc无效之间有区别吗?

实体核心框架--HasColumnType和HasPrecision有什么不同?

ASP.NET Core 8.0 JWT验证问题:尽管令牌有效,但SecurityTokenNoExpirationError异常

返回TyedResults.BadRequest<;字符串>;时问题详细信息不起作用

S能够用DATETIME来计算,这有什么错呢?

只有第一个LINQ.Count()语句有效

TeamsBot SendActivityActivityTypes与ActivityTypes同步.键入不再起作用

Docker Container中的HttpRequest后地址不可用

在C#中,将两个哈希集连接在一起的时间复杂度是多少?

BlockingCollection T引发意外InvalidOperationException

C#Null判断处理失败

EF核心区分大小写的主键

为什么@rendermode Interactive Auto不能在.NET 8.0 Blazor中运行?

等待一个等待函数

使用Blazor WebAssembly提高初始页面加载时间的性能

为什么当我try 为玩家角色设置动画时,没有从文件夹中拉出正确的图像?

如何设置WinForms按钮焦点,使其看起来像是被Tab键插入其中?

.NET文档对继承的困惑

我可以阻止类型上的Object.ToString()吗?

根据运行时值获取泛型类型的字典