为什么明确管理线程是件坏事?

Tom*_*ght 45 c# multithreading

在上一个问题中,我做了一个失礼.你看,我一直在阅读有关线程的文章,并且给人的印象是,自从奇异果jello以来,它们是最美味的东西.

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

[T] hreads是一件非常糟糕的事情.或者,至少,线程的显式管理是一件坏事

跨线程更新UI通常表明您正在滥用线程.

因为每当有什么东西让我感到困惑时我就会杀了一只小狗,考虑一下这个机会让你的业力重新回到黑色......

我该如何使用线程?

Eri*_*ert 109

学习线程的Enthusiam很棒; 别误会我的意思.相比之下,使用大量线程的热情是我称之为线程幸福病的症状.

刚刚了解了线程强大功能的开发人员开始提出诸如"我可以在一个程序中创建多少个线程?"之类的问题.这就像一位英语专业的学生,​​"我可以在句子中使用多少个单词?" 对作者的典型建议是保持你的句子简短而重要,而不是试图将尽可能多的单词和想法塞进一个句子中.线程是一样的; 正确的问题不是"创造多少可以逃脱?" 而是"如何编写这个程序,以便线程数是完成工作所需的最小数量?"

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

  • 多线程程序的性能分析通常非常困难且非常违反直觉.我已经在大量多线程程序中看到了真实的例子,其中在不减慢任何其他功能或使用更多内存的情况下更快地创建函数使得系统的总吞吐量更小.为什么?因为线程往往像市中心的街道.想象一下,如果没有重新计时红绿灯,那么就可以拍摄每条街道并神奇地缩短街道.交通堵塞会变得更好,还是更糟?在多线程程序中编写更快的函数可以使处理器更快地实现拥塞.

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

  • 编写自己的自定义线程管理非常难以正确完成.原因是当你在精心设计的程序中编写常规的单线程程序时,你必须推理的"全局状态"的数量通常很小.理想情况下,您编写的对象具有明确定义的边界,并且不关心调用其成员的控制流.你想要在一个循环中调用一个对象,或者一个开关,或者其他任何东西.

具有自定义线程管理的多线程程序需要全局了解线程将要执行的所有操作,这些操作可能影响从另一个线程可见的数据.您几乎必须掌握整个程序,并了解两个线程可以进行交互的所有可能方式,以使其正确并防止死锁或数据损坏.这是一个很大的支付成本,并且非常容易出错.

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

    if(!queue.IsEmpty)queue.RemoveWorkItem().Execute();

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

 if (queue.WasNotEmptyAtSomePointInThePast) ...
Run Code Online (Sandbox Code Playgroud)

这显然是没用的.

因此,假设您决定通过锁定队列来解决问题.这是正确的吗?

lock(queue) {if (!queue.IsEmpty) queue.RemoveWorkItem().Execute(); }
Run Code Online (Sandbox Code Playgroud)

That's not right either, necessarily. Suppose the execution causes code to run which waits on a resource currently locked by another thread, but that thread is waiting on the lock for queue - what happens? Both threads wait forever. Putting a lock around a hunk of code requires you to know everything that code could possibly do with any shared resource, so that you can work out whether there will be any deadlocks. Again, that is an extremely heavy burden to put on someone writing what ought to be very simple code. (The right thing to do here is probably to extract the work item in the lock and then execute it outside the lock. But... what if the items are in a queue because they have to be executed in a particular order? Now that code is wrong too because other threads can then execute later jobs first.)

  • It gets worse. The C# language spec guarantees that a single-threaded program will have observable behaviour that is exactly as the program is specified. That is, if you have something like "if (M(ref x)) b = 10;" then you know that the code generated will behave as though x is accessed by M before b is written. Now, the compiler, jitter and CPU are all free to optimize that. If one of them can determine that M is going to be true and if we know that on this thread, the value of b is not read after the call to M, then b can be assigned before x is accessed. All that is guaranteed is that the single-threaded program seems to work like it was written.

Multi-threaded programs do not make that guarantee. If you are examining b and x on a different thread while this one is running then you can see b change before x is accessed, if that optimization is performed. Reads and writes can logically be moved forwards and backwards in time with respect to each other in single threaded programs, and those moves can be observed in multi-threaded programs.

This means that in order to write multi-threaded programs where there is a dependency in the logic on things being observed to happen in the same order as the code is actually written, you have to have a detailed understanding of the "memory model" of the language and the runtime. You have to know precisely what guarantees are made about how accesses can move around in time. And you cannot simply test on your x86 box and hope for the best; the x86 chips have pretty conservative optimizations compared to some other chips out there.

That's just a brief overview of just a few of the problems you run into when writing your own multithreaded logic. There are plenty more. So, some advice:

  • Do learn about threading.
  • Do not attempt to write your own thread management in production code.
  • 使用专家编写的高级库来解决线程问题.如果您需要在后台完成大量工作并希望将其移植到工作线程,请使用线程池而不是编写自己的线程创建逻辑.如果您有一个可以同时由多个处理器解决的问题,请使用任务并行库.如果您想懒惰地初始化资源,请使用延迟初始化类,而不是尝试自己编写无锁代码.
  • 避免共享状态.
  • 如果无法避免共享状态,则共享不可变状态.
  • 如果你必须共享可变状态,则更喜欢使用锁定来实现无锁技术.

  • @Konrad Rudolph - 只要没有其他人观察到由此优化引起的临时不一致状态,在回答读取之前移动upvotes是可以的. (34认同)
  • Eric,优秀的概述!+1 (12认同)
  • 很好的比喻(穿线:街道和交通灯). (5认同)
  • @stakx:你是对的.如果您知道如何正确实现它们,它们会很棒.所以,如果你的名字是Joe Duffy,那就去吧.如果没有,**避免无锁技术**.锁定免费代码和安全代码有很多共同之处:两者都非常难以正确*,*即使在出现微妙错误的情况下*也会在大多数情况下正常工作*. (3认同)
  • 一旦我读到"线程幸福病"这个词,我就知道是你了:) (2认同)

Mus*_*sis 11

线程的显式管理本质上并不是一件坏事,但它会带来危险,除非绝对必要,否则不应该做.

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

  • 对于它的价值,我认为汽车上的螺旋桨会很棒. (23认同)

Han*_*ant 8

除非你调试了三方死锁,否则你无法理解线程可能导致什么样的问题.或者花了一个月的时间来追逐每天只发生一次的竞赛条件.所以,继续双脚跳进去,做出所有的错误,你需要学会害怕野兽以及如何避免麻烦.


sib*_*iba 6

除非您处于能够编写完全成熟的内核调度程序的水平,否则您将得到显式线程管理总是错误的.

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

作为大拇指的先行规则,如果问题被分解为线程,它们应尽可能独立,尽可能少但定义明确的共享资源,具有最简约的管理概念.


Dan*_*Tao 6

我无法提供比已经存在的更好的答案.但我可以提供一些具体的例子,说明我们在工作实际遇到的一些多线程代码是灾难性的.

我的一个同事,像你一样,在第一次了解它们时非常热衷于线程.所以在整个程序中开始有这样的代码:

Thread t = new Thread(LongRunningMethod);
t.Start(GetThreadParameters());
Run Code Online (Sandbox Code Playgroud)

基本上,他正在创造各地的线程.

所以最终另一位同事发现了这一点并告诉负责的开发商:不要这样做!创建线程是昂贵的,你应该使用线程池等等.所以代码中最初看起来像上面代码片段的很多地方开始被重写为:

ThreadPool.QueueUserWorkItem(LongRunningMethod, GetThreadParameters());
Run Code Online (Sandbox Code Playgroud)

大改进吧?一切都恢复了理智?

好吧,除了有一个特别的呼吁LongRunningMethod,可能会阻止 - 很长一段时间.突然间,我们偶尔会看到它发生了我们的软件应该立即做出反应的事情......它只是没有.事实上,它可能没有反应几秒钟(澄清:我为一家贸易公司工作,所以这是一场彻底的灾难).

最终发生的事情是线程池实际上填满了长时间阻塞的调用,导致其他代码应该很快就会排队并且直到明显晚于它应该具有的速度才会运行.

当然,这个故事的寓意并不是创建自己的线程的第一种方法是正确的做法(事实并非如此).这真的只是使用线程很难并且容易出错,并且正如其他人已经说过的那样,使用它们时应该非常小心.

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

  1. 首先创建新线程是错误的,因为它比开发人员实现的成本高得多.
  2. 排队线程池上的所有后台工作是错误的,因为它不加选择地处理所有后台任务,并没有考虑异步调用实际被阻止的可能性.
  3. 有一个长时间阻塞的方法本身是一些粗心和非常懒惰的lock关键字使用的结果.
  4. 没有足够的注意了确保该代码正在对后台线程运行的是线程安全的(这不是).
  5. 关于是否让许多受影响的代码多线程甚至值得开始做的问题没有给予足够的思考.在很多情况下,答案是否定的:多线程只引入了复杂性和错误,使代码不易理解,并且(这里是踢球者):伤害性能.

我很高兴地说,今天,我们还活着,我们的代码处于比以前更健康的状态.我们确实在很多地方使用多线程,我们认为它是合适的并且已经测量了性能提升(例如减少了接收市场数据滴答和交换确认的传出报价之间的延迟).但是我们很难学到一些非常重要的课程.如果你曾经在一个大型的高度多线程系统上工作,你也会有机会.