我应该在锁语句中放置多少工作?


27

我是一名初级开发人员,致力于为从第三方解决方案接收数据,将其存储在数据库中,然后将数据整理以供其他第三方解决方案使用的软件编写更新。我们的软件作为Windows服务运行。

查看以前版本中的代码,我看到以下内容:

        static Object _workerLocker = new object();
        static int _runningWorkers = 0;
        int MaxSimultaneousThreads = 5;

        foreach(int SomeObject in ListOfObjects)
        {
            lock (_workerLocker)
            {
                while (_runningWorkers >= MaxSimultaneousThreads)
                {
                    Monitor.Wait(_workerLocker);
                }
            }

            // check to see if the service has been stopped. If yes, then exit
            if (this.IsRunning() == false)
            {
                break;
            }

            lock (_workerLocker)
            {
                _runningWorkers++;
            }

            ThreadPool.QueueUserWorkItem(SomeMethod, SomeObject);

        }

逻辑似乎很清楚:等待线程池中的空间,确保服务尚未停止,然后增加线程计数器并使工作排队。在语句内_runningWorkers递减,然后调用。SomeMethod()lockMonitor.Pulse(_workerLocker)

我的问题是: 将所有代码分组到一个单一的代码中有什么好处lock

        static Object _workerLocker = new object();
        static int _runningWorkers = 0;
        int MaxSimultaneousThreads = 5;

        foreach (int SomeObject in ListOfObjects)
        {
            // Is doing all the work inside a single lock better?
            lock (_workerLocker)
            {
                // wait for room in ThreadPool
                while (_runningWorkers >= MaxSimultaneousThreads) 
                {
                    Monitor.Wait(_workerLocker);
                }
                // check to see if the service has been stopped.
                if (this.IsRunning())
                {
                    ThreadPool.QueueUserWorkItem(SomeMethod, SomeObject);
                    _runningWorkers++;                  
                }
                else
                {
                    break;
                }
            }
        }

看起来,这可能会导致更多地等待其他线程,但是随后,似乎反复将其锁定在单个逻辑块中也将花费一些时间。但是,我是多线程技术的新手,因此我假设这里没有意识到其他问题。

_workerLocker被锁定的唯一其他位置是SomeMethod(),仅出于递减的目的_runningWorkers,然后在外部foreach等待_runningWorkers登录数并返回之前将其变为零。

谢谢你的帮助。

编辑15年4月8日

感谢@delnan推荐使用信号量。代码变为:

        static int MaxSimultaneousThreads = 5;
        static Semaphore WorkerSem = new Semaphore(MaxSimultaneousThreads, MaxSimultaneousThreads);

        foreach (int SomeObject in ListOfObjects)
        {
            // wait for an available thread
            WorkerSem.WaitOne();

            // check if the service has stopped
            if (this.IsRunning())
            {
                ThreadPool.QueueUserWorkItem(SomeMethod, SomeObject);
            }
            else
            {
                break;
            }
        }

WorkerSem.Release()被称为内部SomeMethod()


1
如果整个块都被锁定,SomeMethod将如何获得减少_runningWorkers的锁定?
罗素在ISC 2015年

@RussellatISC:ThreadPool.QueueUserWorkItem SomeMethod异步调用,上面的“锁”部分将在新线程SomeMethod开始运行之前或之后不久保留。
布朗

好点子。据我了解,的目的Monitor.Wait()是释放并重新获取该锁,以便另一个资源(SomeMethod在本例中为)可以使用它。在另一端,SomeMethod获取锁,递减计数器,然后调用Monitor.Pulse(),将锁返回给所讨论的方法。同样,这是我自己的理解。
约瑟夫

@Doc,错过了,但仍然...似乎SomeMethod需要在下一次迭代的foreach锁定之前启动,否则仍将挂在“ while(_runningWorkers> = MaxSimultaneousThreads)”所持有的锁上。
罗素在ISC

@RussellatISC:正如Joseph所说:Monitor.Wait释放锁。我建议看一下文档。
布朗

Answers:


33

这不是性能问题。这首先是正确性问题。如果有两个lock语句,则不能保证在它们之间或部分在lock语句之外进行的操作的原子性。为您的代码的旧版本量身定制,这意味着:

的末端之间while (_runningWorkers >= MaxSimultaneousThreads)_runningWorkers++在所有的事情可能发生,因为代码退保以及两者之间的重新获取锁。例如,线程A可能是第一次获取锁,等到有其他线程退出后,才退出循环和lock。然后它被抢占,线程B进入图片,也等待线程池中的空间。因为说其他线程退出了,所以空间,所以根本不用等待很长时间。现在,线程A和线程B都以某种顺序进行,每个线程都递增_runningWorkers并开始工作。

现在,据我所知,没有数据竞赛,但是从逻辑上讲这是错误的,因为现在MaxSimultaneousThreads运行的工人不止一个。该检查(有时)无效,因为在线程池中占用插槽的任务不是原子的。这应该比围绕锁粒度的小型优化更关心您!(请注意,相反,太早或太长时间锁定很容易导致死锁。)

据我所知,第二个片段解决了这个问题。为解决此问题而进行的侵入性较小的更改可能是将外观++_runningWorkers正确while放置在第一个lock语句内。

现在,撇开准确性,性能如何?这很难说。通常,较长时间的锁定(“粗略地”)会阻止并发,但是正如您所说,这需要与细粒度锁定的额外同步产生的开销进行平衡。通常,唯一的解决方案是基准测试,并意识到除了“将所有内容锁定在所有地方”和“仅锁定最低限度”之外,还有更多选择。有大量可用的模式和并发原语以及线程安全的数据结构。例如,这似乎是发明了应用程序信号量的原因,因此请考虑使用其中之一代替此手动滚动的手动锁定计数器。


11

恕我直言,您问的是错误的问题-您不应该太在乎效率的取舍,而应该更关心准确性。

第一个变体确保_runningWorkers仅在锁定期间访问,但是错过了_runningWorkers可能被另一个线程在第一个锁定与第二个锁定之间的间隙中更改的情况。老实说,如果有人盲目地锁定了所有访问点,_runningWorkers而又不考虑其含义和潜在的错误,那么该代码对我而言就是如此。也许作者对breaklock块中执行该语句有一些迷信的恐惧,但是谁知道呢?

因此,您实际上应该使用第二个变量,不是因为它或多或少有效,而是因为(希望)它比第一个更正确。


另一方面,在执行可能需要获取另一把锁的任务时按住锁会导致死锁,这几乎不能称为“正确”行为。一个人应该确保所有需要作为一个单元完成的代码都被一个公共锁包围,但是一个人应该移到该锁之外,而不需要成为该单元一部分的东西,特别是可能需要获取其他锁的东西。
supercat 2015年

@supercat:这里不是这种情况,请阅读原始问题下方的评论。
布朗

9

其他答案都很好,可以清楚地解决正确性问题。让我解决您更笼统的问题:

我应该在锁语句中放置多少工作?

让我们从标准建议开始,您在接受的答案的最后一段中提到和delnan提到:

  • 锁定特定对象时,请做尽可能少的工作。长时间持有的锁容易争用,争用很慢。注意,这意味着的代码在数量特别锁和的代码量,同样的对象锁定所有锁语句都是相关的。

  • 拥有尽可能少的锁,以降低死锁(或活锁)的可能性。

聪明的读者会注意到,这些是相反的。第一点建议将大锁分解为许多更小,更细的锁,以避免争用。第二种建议将不同的锁合并到同一锁对象中,以避免死锁。

我们可以从最佳标准建议完全矛盾这一事实中得出什么结论?我们得到了很好的建议:

  • 首先不要去那里。如果要在线程之间共享内存,那么您将面临痛苦。

我的建议是,如果需要并发,请使用流程作为并发单元。如果无法使用进程,请使用应用程序域。如果您不能使用应用程序域,则由任务并行库管理线程,并根据高级任务(作业)而不是低级线程(工人)编写代码。

如果您绝对肯定必须使用线程或信号量之类的低级并发原语,则可以使用它们来构建可以捕获您真正需要的高级抽象。您可能会发现更高级别的抽象类似于“异步执行可以被用户取消的任务”,而且TPL已经支持该抽象,因此您无需自己动手。您可能会发现您需要诸如线程安全的延迟初始化之类的东西。不要自己动手使用Lazy<T>,这是由专家编写的。使用专家编写的线程安全集合(不可变或其他形式)。尽可能提高抽象级别。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.