为什么线程的显式pipe理是一件坏事?
在之前的一个问题中 ,我犯了一些失礼。 你看,我一直在读线索,并有一个印象,那就是猕猴桃果冻以来最好吃的东西。
想象一下当我读到这样的东西时,我的困惑:
恐惧是一件非常糟糕的事情。 或者,至less,线程的显式pipe理是一件坏事
和
通过线程更新UI通常表示您正在滥用线程。
由于我每次都会让一个小狗感到困惑,因此我认为这个机会让你的业力重新回到黑色中。
我应该如何使用线程?
热衷于学习线程是伟大的; 不要误解我的意思 相反, 使用大量线程的热情是我所谓的线程幸福症的症状。
刚刚了解线程的力量的开发人员开始提问如“我可以在一个程序中创build多less个线程? 这就像一个英语专业,问“我能在一个句子中使用多less个单词? 对作家的典型build议是保持你的句子简短而重要,而不是试图将尽可能多的词汇和想法塞进一个句子。 线程是相同的方式; 正确的问题不是“我能创造多less?” 而是“如何编写这个程序,使线程数量达到完成工作所需的最低限度 ?
线程解决了很多问题,这是真的,但他们也引入了巨大的问题:
- multithreading程序的性能分析往往是非常困难的,而且很不直观。 我已经看到了在multithreading程序中的真实世界的例子,其中使得function更快而不减慢任何其他function或使用更多的存储器使得系统的总吞吐量更小 。 为什么? 因为线程往往像市中心的街道。 想象一下,把每条街道都缩小一些, 而不用重新计算交通信号灯的时间 。 交通堵塞会变得更好,还是更糟? 在multithreading程序中编写更快的function可以使处理器更快地拥塞 。
你想要的是线程像州际公路一样:没有交通信号灯,高度平行,在less数非常明确的,精心devise的点上交叉。 这很难做到。 大多数multithreading程序更像是密集的城市核心,而且随处可见。
- 编写自己的线程定制pipe理是非常困难的。 原因是因为当你在一个精心devise的程序中编写一个普通的单线程程序时,你需要推理的“全局状态”的数量通常很小。 理想情况下,你可以编写具有明确边界的对象,而不必关心调用其成员的控制stream。 你想要在一个循环或开关中调用一个对象,或者任何你想要的东西。
带有自定义线程pipe理的multithreading程序需要全局理解线程要做的事情,这可能会影响从另一个线程可见的数据。 您几乎必须将整个程序放在脑海中,并了解两个线程可以进行交互的所有可能方式,以便正确地执行并防止死锁或数据损坏。 这是一个巨大的成本支付,并极易发生错误。
-
从本质上讲,线程使你的方法谎言 。 让我举一个例子。 假设你有:
if(!queue.IsEmpty)queue.RemoveWorkItem()。Execute();
代码是否正确? 如果它是单线程的,可能。 如果它是multithreading的,在调用IsEmpty 之后 ,什么是阻止另一个线程移除最后剩下的项目? 没什么,那是什么。 这个代码在本地看起来很好,是一个等待在multithreading程序中运行的炸弹。 基本上这个代码实际上是:
if (queue.WasNotEmptyAtSomePointInThePast) ...
这显然是无用的。
所以假设你决定通过locking队列来解决这个问题。 这是正确的吗?
lock(queue) {if (!queue.IsEmpty) queue.RemoveWorkItem().Execute(); }
这也不对,必然。 假设执行导致代码运行,这将等待当前由另一个线程locking的资源,但该线程正在等待队列锁 – 发生了什么? 两个线程永远等待。 对代码进行locking需要您了解代码可能对任何共享资源执行的所有操作 ,以便确定是否会出现死锁。 再次,这是一个非常沉重的负担,写一个人应该是非常简单的代码。 (在这里做的正确的事情可能是提取锁中的工作项,然后在锁之外执行它,但是……如果这些项是在一个队列中,因为它们必须以特定顺序执行,那该怎么办呢?代码也是错误的,因为其他线程可以先执行更晚的工作。)
- 它变得更糟。 C#语言规范保证单线程程序将具有与程序指定完全相同的可观察行为。 也就是说,如果你有类似“if(M(ref x))b = 10; 那么你知道生成的代码的行为就像在写入b 之前 x被M访问一样。 现在,编译器,抖动和CPU都可以自由优化。 如果其中一个可以确定M将是真的,并且如果我们知道在这个线程上,在调用M之后b的值不被读取,那么在访问x之前b可以被分配。 所有可以保证的是,单线程程序似乎像写入一样工作 。
multithreading程序不能保证。 如果您正在运行另一个线程时正在检查b和x,那么您可以在访问x之前看到b发生了更改(如果执行了此优化)。 在单线程程序中,读写可以在逻辑上相对于彼此在时间上向前和向后移动,并且这些移动可以在multithreading程序中观察到。
这意味着,为了编写multithreading程序,在逻辑上依赖于所观察事物的发生顺序与实际编写代码的顺序相同,必须详细了解“内存模型”语言和运行时间。 你必须确切地知道如何保证访问可以及时移动。 而且你不能简单地在你的x86机器上进行testing,并希望得到最好的结果。 与其他一些芯片相比,x86芯片有着相当保守的优化。
这只是对编写自己的multithreading逻辑时遇到的一些问题的简要概述。 还有更多。 所以,一些build议:
- 了解线程。
- 不要试图在生产代码中编写自己的线程pipe理。
- 使用由专家编写的更高级库来解决线程问题。 如果你有一大堆工作需要在后台完成,并且想把它们分配给工作线程,那么使用一个线程池而不是编写自己的线程创build逻辑。 如果您有一个问题可以同时由多个处理器解决,请使用任务并行库。 如果你想懒惰地初始化一个资源,使用懒惰的初始化类,而不是自己写无锁的代码。
- 避免共享状态。
- 如果你不能避免共享状态,共享不可变的状态。
- 如果您必须共享可变状态,则更愿意使用锁来实现无锁技术。
线程的显式pipe理本质上并不是一件坏事,但它带有危险,除非绝对必要,否则不应该这样做。
说线程绝对是一件好事,就像说螺旋桨是绝对是件好事:螺旋桨在飞机上工作很好(当喷气发动机不是更好的select时),但对汽车来说不是一个好主意。
除非已经debugging了三路死锁,否则你不能理解线程可能导致什么样的问题。 或者花了一个月追逐一天只发生一次的竞赛状况。 所以,继续前进,用双脚跳进去,做出所有需要做的事情来学习恐惧野兽,以及如何避免麻烦。
我无法提供比现在更好的答案。 但是我可以提供一些我们在工作中遇到的灾难性的multithreading代码的具体例子。
我的一个同事,和你一样,当他第一次得知这些线索时,对线索非常热心。 所以在整个程序中开始出现这样的代码:
Thread t = new Thread(LongRunningMethod); t.Start(GetThreadParameters());
基本上,他正在创造各地的线程。
所以最终另一位同事发现了这一点,并告诉开发者负责: 不要这样做! 创build线程是很昂贵的,你应该使用线程池等。所以原来看起来像上面的代码片段中的很多地方开始被重写为:
ThreadPool.QueueUserWorkItem(LongRunningMethod, GetThreadParameters());
大改进吧? 一切又合理了?
那么,除了LongRunningMethod
中有一个特定的调用可能会阻止 – 很长一段时间 。 突然之间,我们开始发现我们的软件应该马上做出反应,但事实并非如此。 事实上,它可能没有反应几秒钟 (澄清:我在一家贸易公司工作,所以这是一个完全的灾难)。
最后发生的事情是线程池实际上已经被填满了长时间阻塞的调用,导致其他代码很快就会被排队等待,直到比预期的要晚。
当然,这个故事的寓意并不是说创build自己的线程的第一种方法是正确的(不是)。 实际上,使用线程很困难,而且容易出错,正如其他人已经说过的那样,在使用线程时应该非常小心。
在我们的特定情况下,犯了许多错误:
- 首先创build新线程是错误的,因为它比开发人员意识到的要昂贵得多。
- 排队线程池上的所有后台工作是错误的,因为它不加区分地处理所有的后台任务,并没有考虑实际阻止asynchronous调用的可能性。
- 有一个长时间阻塞的方法本身是一些不小心,懒惰的使用
lock
关键字的结果。 - 没有给予足够的重视,以确保在后台线程上运行的代码是线程安全的(不是)。
- 没有足够的思想给出一个问题:是否使得大量受影响的multithreading代码甚至值得开始 。 在很多情况下,答案是否定的:multithreading只是引入了复杂性和错误,使得代码不易理解, 以及 (这里是踢球者):伤害性能。
我很高兴地说, 今天 ,我们还活着,我们的代码比以前更健康了。 而且我们确实在很多地方使用multithreading,我们已经确定它是合适的,并且已经测量了性能增益(例如,在接收到市场数据报价并且具有由交易所确认的即将发布的报价之间的延迟)。 但是我们很难学到一些非常重要的教训。 有机会,如果你曾经在一个大型的multithreading系统上工作,你也可以。
除非你能够编写一个完整的内核调度程序,否则你会得到显式的线程pipe理总是错误的。
自热巧克力以来,线程是最棒的,但并行编程非常复杂。 然而,如果你devise你的线程是独立的,那么你不能在脚下自己射击。
根据大拇指的前提,如果一个问题被分解为线程,那么它们应该尽可能地独立,尽可能less的但是定义明确的共享资源,尽可能使用最简约的pipe理概念。
我认为第一个说法最好的解释是: 现在有许多先进的API ,手工编写自己的线程代码几乎是不必要的。 新的API使用起来更容易,而且更难以搞砸! 而老式的线程,你必须相当好, 不要搞乱。 旧式API( Thread
等)仍然可用,但新的API( 任务并行库 , 并行LINQ和反应扩展 )是未来的方式。
第二种说法是从更多的deviseangular度来看,国际海事组织。 在一个关注点清晰分离的devise中,后台任务不应直接进入UI来报告更新。 应该有一些分离,使用像MVVM或MVC模式。
我会开始质疑这种看法:
我一直在阅读关于线程,并有一个印象,他们是自猕猴桃果冻以来最美味的事情。
不要误会我的意思 – 线程是一个非常多function的工具 – 但是这种热情程度似乎很奇怪。 特别是,它表明你可能在很多情况下使用线程,但是这些线程根本没有意义(但是我可能会误以为你的热情)。
正如其他人所指出的,线程处理还相当复杂和复杂。 线程包装存在 ,只有在极less数情况下,他们必须明确处理。 对于大多数应用程序,线程可以隐含。
例如,如果你只是想将一个计算推到后台,同时让GUI响应,一个更好的解决scheme往往是使用callback(这使得看起来好像计算是在后台完成的,而真正在同一时间执行线程),也可以通过使用诸如BackgroundWorker
类的便利包装来获取并隐藏所有显式的线程处理。
最后一件事,创build一个线程实际上是非常昂贵的。 使用线程池减轻了这个成本,因为在这里,运行时创build了一些随后被重用的线程。 当人们说线程的显式pipe理是不好的时候,这就是他们可能提到的。
许多高级GUI应用程序通常由两个线程组成,一个用于UI,一个(或者更多)处理数据(复制文件,进行繁重的计算,从数据库加载数据等)。
处理线程不应该直接更新UI,UI应该是一个黑盒子(查看维基百科的封装 )。
他们只是说“我正在处理”或“我完成了9的任务7”,并调用事件或其他callback方法。 UI订阅该事件,检查已更改的内容并相应地更新UI。
如果您从处理线程更新UI,您将无法重新使用您的代码,如果您想更改部分代码,则会遇到更大的问题。
我认为你应该尽可能多的使用线程,并了解使用它们的好处和缺陷。 只有通过实验和使用,你的理解才会成长。 阅读尽可能多的关于这个问题。
当涉及到C#和用户界面(这是单线程的,你只能修改在UI线程上执行的代码的用户界面元素)。 我使用下面的工具让自己保持清醒,在晚上睡觉。
public static class UIThreadSafe { public static void Perform(Control c, MethodInvoker inv) { if(c == null) return; if(c.InvokeRequired) { c.Invoke(inv, null); } else { inv(); } } }
你可以在任何需要改变UI元素的线程中使用它,就像这样:
UIThreadSafe.Perform(myForm, delegate() { myForm.Title = "I Love Threads!"; });
线程是一件非常好的事情,我想。 但是,和他们一起工作非常困难,需要大量的知识和培训。 主要的问题是当我们想要从另外两个线程访问共享资源,这可能会导致不良影响。
考虑一下经典的例子:你有两个线程从共享列表中获取一些项目,并在做了一些事情后从列表中删除项目。
定期调用的线程方法可能如下所示:
void Thread() { if (list.Count > 0) { /// Do stuff list.RemoveAt(0); } }
请记住,理论上讲,线程可以在不同步的代码行中切换。 因此,如果列表只包含一个项目,则一个线程可以在list.Count
条件之前list.Remove
线程切换,而另一个线程通过list.Count
(列表仍包含一个项目)。 现在第一个线程继续list.Remove
之后,第二个线程继续list.Remove
,但最后一个项目已被第一个线程删除,所以第二个崩溃。 这就是为什么它将不得不使用lock
语句进行同步,以便不存在两个线程在if
语句内部的if
。
所以这就是为什么不同步的UI必须总是在一个线程中运行,而其他线程不应该干扰UI。
在以前的.NET版本中,如果你想在另一个线程中更新UI,你将不得不使用Invoke
方法进行同步,但是由于实现起来很困难,.NET的新版本带有BackgroundWorker
类,它通过包装所有的东西,让你在DoWork
事件中做asynchronous的东西,并更新ProgressChanged
事件中的用户界面。
尝试保持UI线程和处理线程尽可能独立的一个巨大的原因是,如果UI线程冻结,用户会注意到并且不高兴。 让UI线程快速发展非常重要。 如果您开始将UI内容移出UI线程或将处理内容移动到UI线程中,则您的应用程序变得无法响应的风险更高。
而且,很多框架代码是故意编写的,希望将UI和处理分开; 如果你把这两个程序分开,那么程序将会更好地工作,而当你不这样做的时候会遇到错误和问题。 我不记得我遇到过的任何具体问题,尽pipe我在过去试图设置UI负责UI外部的某些属性并且让代码拒绝工作的模糊回忆。 我不记得它是不是编译或者抛出了一个例外。
从非UI线程更新UI时,需要注意以下几点:
- 如果经常使用“Invoke”,如果其他东西使UI线程运行缓慢,则非UI线程的性能可能会受到严重的不利影响。 我宁愿避免使用“Invoke”,除非非UI线程需要等待UI线程操作才能继续执行。
- 如果对控制更新等事情鲁莽地使用“BeginInvoke”,则过多的调用委托可能会排队,其中一些在实际发生时可能是无用的。
在许多情况下,我的首选样式是将每个控件的状态封装在一个不可变的类中,然后有一个标志,指示是否不需要更新,挂起或需要但不挂起(如果发出请求,则可能发生后一种情况在完全创build之前更新控件)。 如果需要更新,则控件的更新例程应该先清除更新标志,抓取状态并绘制控件。 如果更新标志被设置,它应该重新循环。 要请求另一个线程,例程应使用Interlocked.Exchange将更新标志设置为更新挂起,并且 – 如果未挂起 – 请尝试BeginInvoke更新例程; 如果BeginInvoke失败,则将更新标志设置为“需要但不等待”。
如果在控件的更新例程检查并清除其更新标志后立即发生控件尝试,则可能发生第一次更新将反映新值,但更新标志已被设置,从而强制重画一次额外的屏幕。 在发生这种情况的时候,这将是相对无害的。 重要的是,控件最终会以正确的状态绘制,而不会有多个BeginInvoke挂起。