代码之家  ›  专栏  ›  技术社区  ›  Tom Wright

为什么显式管理线程是一件坏事?

  •  45
  • Tom Wright  · 技术社区  · 15 年前

    a previous question 我犯了一个错误。你看,我一直在读关于线的文章,觉得它们是自猕猴桃果冻以来最美味的东西。

    当我读到这样的东西时,想象一下我的困惑:

    hreads是个很坏的东西。或者,至少,对线程的显式管理是一件坏事

    跨线程更新UI通常表示您滥用了线程。

    因为每次有什么事情让我困惑的时候,我都会杀了一只小狗,所以考虑一下这个机会,让你的业力恢复到黑色……

    我应该如何使用线程?

    12 回复  |  直到 14 年前
        1
  •  107
  •   Eric Lippert    15 年前

    学习穿线的热情是很好的,别误会我。热衷于 使用大量线程 相反,它是我所说的“线幸福病”的症状。

    刚刚了解线程功能的开发人员开始问“我可以在一个程序中创建多少线程?”这很像一个英语专业的学生问“我能在一个句子中用多少个词?”对于作家来说,典型的建议是保持句子简短,切中要害,而不是把尽可能多的单词和想法塞进一个句子中。线程是相同的方式;正确的问题不是“我可以从创建中获得多少?”而是“我如何编写这个程序,使线程的数量 最低限度 有必要完成工作吗?”

    线程解决了很多问题,这是事实,但它们也带来了巨大的问题:

    • 多线程程序的性能分析通常非常困难,而且非常违反直觉。我在大量的多线程程序中看到了现实世界中的例子,在这些程序中可以更快地实现一个函数。 不会减慢任何其他功能或使用更多内存 使系统的总吞吐量 更小的 . 为什么?Because threads are often like streets downtown. Imagine taking every street and magically making it shorter 没有重新调整交通灯的时间 . 交通堵塞会好转还是恶化?在多线程程序中编写更快的函数 加快处理器的拥塞速度 .

    你想要的是螺纹像州际公路:没有交通灯,高度平行,交叉在少数非常明确,精心设计的点。 这很难做到。 大多数重多线程程序更像是密集的城市核心,到处都有红绿灯。

    • 编写自己的线程自定义管理是非常困难的。原因是,当您在设计良好的程序中编写常规的单线程程序时,必须考虑的“全局状态”的数量通常很小。理想情况下,您编写的对象具有定义良好的边界,并且不关心调用其成员的控制流。你想在一个循环中调用一个对象,或者一个开关,或者其他什么东西,你就这样做。

    具有自定义线程管理的多线程程序需要 全球的 理解 一切 一根线会这样做的 可能地 影响从其他线程可见的数据。你必须把整个计划都放在脑子里,并且理解 全部的 两个线程相互作用的可能方式,以使其正确并防止死锁或数据损坏。这是一个巨大的成本支付,并极易出现错误。

    • 从本质上讲,线程使您的方法 . 让我举个例子。假设你有:

      如果(!)queue.isEmpty)queue.removeWorkItem().execute();

    代码正确吗?如果它是单线程的,可能。如果它是多线程的,是什么阻止另一个线程删除最后剩余的项目? 之后 是否执行对IsEmpty的调用?没什么,就是这样。这段代码在本地看起来很好,在多线程程序中是一颗等待爆炸的炸弹。基本上,代码实际上是:

     if (queue.WasNotEmptyAtSomePointInThePast) ...
    

    这显然是相当无用的。

    所以假设您决定通过锁定队列来解决这个问题。这是对的吗?

    lock(queue) {if (!queue.IsEmpty) queue.RemoveWorkItem().Execute(); }
    

    当然,这也不对。假设执行导致代码运行,该代码在当前被另一个线程锁定的资源上等待,但该线程在等待队列锁定-会发生什么?两个线程都将永远等待。在一大块代码周围加锁需要知道 一切 代码可以 可能地 任何 共享资源,这样您就可以计算出是否存在死锁。同样,这对于编写应该是非常简单的代码的人来说是一个非常沉重的负担。(这里要做的正确的事情可能是提取锁中的工作项,然后在锁外执行它。但是…what if the items are in a queue because they have to be executed in a particular order? 现在代码也错了,因为其他线程可以先执行后面的作业。)

    • 情况变得更糟了。C语言规范确保单线程程序具有与指定程序完全相同的可观察行为。也就是说,如果您有“if(m(ref x))b=10”;那么您就知道生成的代码的行为就像m访问x一样。 之前 B是写的。现在,编译器、抖动和CPU都可以自由地进行优化。如果其中一个可以确定m是真的,并且我们知道在这个线程上,在调用m之后不会读取b的值,那么可以在访问x之前分配b。所保证的就是单线程程序 像写的那样工作 .

    多线程程序可以 做出保证。如果您在运行此线程时在另一线程上检查b和x,则 可以 如果执行了优化,请参阅b change before x is accessed。 在单线程程序中,读和写可以在逻辑上按时间前后移动,在多线程程序中可以观察到这些移动。

    这意味着,为了编写多线程程序,如果逻辑中有依赖于所观察到的事情发生的顺序与实际编写代码的顺序相同,则必须 详细的 理解语言和运行时的“内存模型”。您必须准确地知道对于访问如何能够及时移动做出了哪些保证。而且,你不能简单地在你的x86设备上测试,并希望得到最好的结果;与其他一些芯片相比,x86芯片有相当保守的优化。

    这只是对编写自己的多线程逻辑时遇到的几个问题的简要概述。There are plenty more. 所以,一些建议:

    • 一定要了解线程。
    • 不要试图在生产代码中编写自己的线程管理。
    • 使用专家编写的高级库来解决线程问题。如果您有大量的工作需要在后台完成,并且希望将其分发给工作线程,请使用线程池,而不是编写自己的线程创建逻辑。如果您有一个问题可以由多个处理器同时解决,请使用任务并行库。如果您想惰性地初始化资源,请使用惰性初始化类,而不是尝试自己编写无锁代码。
    • 避免共享状态。
    • 如果无法避免共享状态,请共享不可变状态。
    • If you have to share mutable state, prefer using locks to lock-free techniques.
        2
  •  11
  •   MusiGenesis    15 年前

    线程的显式管理不是 本质上 这是件坏事,但它充满危险,除非绝对必要,否则不应该做。

    说螺纹绝对是件好事,就像说螺旋桨绝对是件好事:螺旋桨在飞机上工作得很好(当喷气发动机不是更好的选择时),但在汽车上却不是个好主意。

        3
  •  8
  •   Hans Passant    15 年前

    除非您调试了三向死锁,否则您无法理解线程处理会导致什么样的问题。或者花了一个月的时间寻找一个每天只发生一次的比赛条件。所以,继续向前,双脚都跳进去,犯下你需要犯的所有错误,学会害怕野兽,学会怎样做才能避免麻烦。

        4
  •  6
  •   sibidiba    15 年前

    除非您能够编写一个成熟的内核调度程序,否则您将得到显式的线程管理。 总是 错了。

    线程可能是自热巧克力以来最棒的东西,但是并行编程非常复杂。然而,如果你设计的线程是独立的,那么你就不能射自己的脚。

    根据经验法则,如果一个问题被分解成线程,那么它们应该尽可能独立,尽可能少但定义良好的共享资源,具有最简单的管理概念。

        5
  •  6
  •   Dan Tao    14 年前

    我无法提供比这里更好的答案。但我 可以 offer a concrete example of some multithreaded code 在我的工作中 那是灾难性的。

    我的一个同事,和你一样,在他第一次了解这些线的时候,对线非常热情。所以在整个程序中开始有这样的代码:

    Thread t = new Thread(LongRunningMethod);
    t.Start(GetThreadParameters());
    

    基本上,他是创造线程各地。

    所以最终 另一个 同事发现了这一点,并告诉开发负责人: 不要那样做! 创建线程是很昂贵的,您应该使用线程池等。因此,代码中最初看起来像上述代码片段的许多地方开始重写为:

    ThreadPool.QueueUserWorkItem(LongRunningMethod, GetThreadParameters());
    

    大的进步,对吗?一切又恢复正常了?

    好吧,只是有个特别的电话 LongRunningMethod 可能会阻碍-- 很长一段时间 . 突然间,我们开始看到它发生了,我们的软件 应该 立即做出反应…只是没有。事实上,它可能几次都没有反应。 (澄清:我在一家贸易公司工作,所以这是一场彻底的灾难)。

    最终的结果是线程池实际上充满了长阻塞调用,导致了其他代码 想象上的 很快就排好队,直到明显比原来晚了才开始运行。

    当然,这个故事的寓意并不是说创建自己的线程的第一个方法是正确的(它不是)。实际上,使用线程很困难,而且容易出错,正如其他人所说,您应该 非常 使用时要小心。

    在我们的特殊情况下,我们犯了许多错误:

    1. 首先创建新的线程是错误的,因为它比开发人员想象的要昂贵得多。
    2. 将线程池上的所有后台工作排队是错误的,因为它不加选择地处理所有后台任务,并且没有考虑异步调用实际被阻止的可能性。
    3. 有一个长的阻塞方法本身是由于一些粗心和非常懒惰的使用 lock 关键字。
    4. 对确保 在后台线程上运行是线程安全的(不是)。
    5. 对于是否使大量受影响的代码成为多线程的问题,人们没有充分的思考。 一开始就值得做 . 在很多情况下,答案是否定的:多线程只是引入了复杂性和错误,使得代码不那么容易理解, (这里是踢球员):伤害表现。

    我很高兴这么说 今天 我们仍然活着,我们的代码比以前更健康。而我们 在许多我们认为合适的地方使用多线程,并测量了性能提升(例如,在接收市场数据标记和交易所确认传出报价之间减少延迟)。但我们从中吸取了一些非常重要的教训。如果你曾经在一个大的、高度多线程的系统上工作过,那么很可能你也会这样做。

        6
  •  4
  •   Stephen Cleary    15 年前

    我认为第一句话最好这样解释:用 many advanced APIs now available ,几乎没有必要手动编写自己的线程代码。新的API是 许多 易于使用,以及 许多 更难搞砸!.然而,使用老式的线程,您必须非常好地 搞砸。The old-style APIs ( Thread 等)仍然可用,但新的API( Task Parallel Library , Parallel LINQ Reactive Extensions )是未来的道路。

    第二个声明是从设计的角度来看的,IMO。在一个关注点完全分离的设计中,后台任务不应该直接接触到UI来报告更新。应该有一些分离,使用MVVM或MVC这样的模式。

        7
  •  3
  •   Konrad Rudolph    15 年前

    我首先质疑这种看法:

    我一直在读关于线的文章,我觉得它们是自猕猴桃果冻以来最美味的东西。

    不要让我弄错了“线程是一种非常通用的工具”,但这种热情似乎很奇怪。特别是,它表明您可能在很多情况下使用线程,而这些情况下线程根本没有意义(但我可能会再次误解您的热情)。

    正如其他人所指出的,线程处理是另外非常复杂和复杂的。螺纹包装机 存在 只有在极少数情况下,它们才必须得到明确的处理。对于大多数应用程序,可以隐含线程。

    例如,如果您只想在保持GUI响应的同时将计算推送到后台,那么一个更好的解决方案通常是使用回调(这使得计算似乎是在后台完成的,而实际上是在同一线程上执行),或者使用方便的包装器,如 BackgroundWorker 这需要并隐藏所有显式线程处理。

    最后一点,创建线程实际上非常昂贵。使用线程池可以降低这一成本,因为在这里,运行时会创建许多线程,这些线程随后会被重用。当人们说线程的显式管理不好时,这就是他们所指的全部内容。

        8
  •  2
  •   Morfildur    15 年前

    许多高级GUI应用程序通常由两个线程组成,一个用于UI,一个(有时更多)用于处理数据(复制文件、进行大量计算、从数据库加载数据等)。

    处理线程不应该直接更新用户界面,用户界面对它们应该是一个黑框(选中维基百科 包封 )
    他们只说“我完成了处理”或“我完成了9中的任务7”,然后调用事件或其他回调方法。用户界面订阅事件,检查发生了什么变化,并相应地更新用户界面。

    如果您从处理线程更新UI,您将无法重用代码,如果您想更改部分代码,则会遇到更大的问题。

        9
  •  2
  •   Adrian Regan    15 年前

    我想你 应该 尽可能多地体验线程,并了解使用线程的好处和陷阱。只有通过实验和使用,你对它们的理解才会增长。尽可能多地阅读这个主题。

    当涉及到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!";
    });
    
        10
  •  1
  •   FrantiÅ¡ek Žiačik    15 年前

    我觉得穿线是件好事。但是,与他们合作是非常困难的,需要大量的知识和培训。主要问题是,当我们想要从其他两个线程访问共享资源时,这可能会导致不希望的效果。

    考虑经典的例子:您有两个线程,它们从共享列表中获取一些项目,并且在执行某些操作之后,它们会从列表中删除这些项目。

    定期调用的线程方法可能如下所示:

    void Thread()
    {
       if (list.Count > 0)
       {
          /// Do stuff
          list.RemoveAt(0);
       }
    }

    记住,理论上,线程可以在不同步的代码的任何行上切换。因此,如果列表只包含一个项,则一个线程可以通过 list.Count 条件,就在之前 list.Remove 线程切换,另一个线程通过 清单 (列表仍包含一个项目)。现在第一个线程继续 删除列表 在第二条线之后 删除列表 但最后一个项目已经被第一个线程删除,所以第二个线程崩溃。这就是为什么必须使用 lock 语句,这样就不会出现两个线程在 if 语句。

    所以,这就是为什么不同步的UI必须始终在单个线程中运行,而其他线程不应干扰UI的原因。

    在.NET的早期版本中,如果要在其他线程中更新UI,则必须使用 Invoke 方法,但由于很难实现,新版本的.NET BackgroundWorker 类,它通过包装所有内容并允许您在 DoWork 事件和更新用户界面 ProgressChanged 事件。

        11
  •  1
  •   Brian    15 年前

    尽量保持ui线程和处理线程独立的一个重要原因是,如果ui线程冻结,用户会注意到并且不高兴。让UI线程快速燃烧是很重要的。如果您开始将UI内容移出UI线程或将处理内容移到UI线程,则应用程序失去响应的风险更高。

    另外,很多框架代码都是刻意编写的,希望您能将UI和处理分开;当您将两者分开时,程序会工作得更好,而当您不分开时,程序会遇到错误和问题。我不记得我在这方面遇到的任何具体问题,尽管我有模糊的回忆在过去,试图设置用户界面在用户界面之外负责的某些属性,并且让代码拒绝工作;我不记得它是没有编译,还是抛出了异常。

        12
  •  0
  •   supercat    15 年前

    在从非UI线程更新UI时,需要注意以下几点:

    1. 如果经常使用“invoke”,那么如果其他东西使UI线程运行缓慢,那么非UI线程的性能可能会受到严重的负面影响。我宁愿避免使用“invoke”,除非非UI线程需要等待执行UI线程操作才能继续。
    2. 如果您鲁莽地使用“begininvoke”进行控制更新之类的操作,那么大量的调用委托可能会排队,其中一些委托在实际发生时可能非常无用。

    在许多情况下,我的首选样式是将每个控件的状态封装在一个不可变的类中,然后具有一个标志,该标志指示是否不需要、挂起或需要但不挂起更新(如果在完全创建控件之前请求更新该控件,则可能发生后一种情况)。如果需要更新,控件的更新例程应该从清除更新标志、获取状态和绘制控件开始。如果设置了更新标志,它应该重新循环。若要请求另一个线程,例程应使用interlocked.exchange将更新标志设置为“更新挂起”,如果它不是挂起的,则尝试开始更新例程;如果BeginInvoke失败,则将更新标志设置为“需要但不是挂起”。

    如果在控件的更新例程检查并清除其更新标志后尝试控制,则很可能第一次更新将反映新值,但更新标志仍将被设置,从而强制重新绘制一个额外的屏幕。在这种情况下,它将相对无害。重要的是,控件最终将以正确的状态绘制,而不会有多个BeginInvoke挂起。