被多线程错误困扰


26

在我管理的新团队中,我们的大部分代码是平台,TCP套接字和http网络代码。所有C ++。其中大多数来自离开团队的其他开发人员。团队中目前的开发人员非常聪明,但从经验来看大多是初级的。

我们最大的问题:多线程并发错误。我们的大多数类库都通过使用某些线程池类而被编写为异步的。类库中的方法通常将长时间运行的任务从一个线程排队到线程池中,然后在另一个线程上调用该类的回调方法。结果,我们有很多涉及错误线程假设的边缘错误。这导致了一些细微的错误,这些错误不仅仅具有关键部分和锁以防止并发问题。

使这些问题更难解决的是,修复尝试通常是不正确的。我发现团队在尝试(或在遗留代码本身中)尝试犯的一些错误包括以下内容:

常见错误#1-通过仅对共享数据进行锁定来解决并发问题,但是忘记了当方法未按预期顺序调用时会发生什么情况。这是一个非常简单的示例:

void Foo::OnHttpRequestComplete(statuscode status)
{
    m_pBar->DoSomethingImportant(status);
}

void Foo::Shutdown()
{
    m_pBar->Cleanup();
    delete m_pBar;
    m_pBar=nullptr;
}

因此,现在有了一个错误,其中在OnHttpNetworkRequestComplete发生时可以调用Shutdown。测试人员找到错误,捕获故障转储,并将错误分配给开发人员。他反过来修复了这样的错误。

void Foo::OnHttpRequestComplete(statuscode status)
{
    AutoLock lock(m_cs);
    m_pBar->DoSomethingImportant(status);
}

void Foo::Shutdown()
{
    AutoLock lock(m_cs);
    m_pBar->Cleanup();
    delete m_pBar;
    m_pBar=nullptr;
}

除非您意识到还有一个更微妙的边缘情况,否则上面的修补程序看起来不错。如果在调用OnHttpRequestComplete 之前调用Shutdown,会发生什么情况?我的团队拥有的真实示例更加复杂,并且在代码审查过程中很难发现边缘情况。

常见错误#2-通过盲目退出锁来解决死锁问题,等待另一个线程完成,然后重新进入锁-但是没有处理对象只是被另一个线程更新的情况!

常见错误#3-即使对象是引用计数,关闭序列也会“释放”它的指针。但是忘记等待仍在运行的线程释放它的实例。这样,组件将完全关闭,然后在不希望再有任何调用的状态下在对象上调用伪造或较晚的回调。

还有其他边缘情况,但最重要的是:

即使对于聪明人,多线程编程也很难。

当我发现这些错误时,我会花时间与每个开发人员讨论这些错误,以开发更合适的修复程序。但是我怀疑,由于“正确的”解决方案涉及到大量遗留代码,因此他们常常对如何解决每个问题感到困惑。

我们将很快发货,并且我确定我们正在应用的补丁将在即将发布的版本中保留。之后,我们将有一些时间来改善代码库并在需要时进行重构。我们将没有时间重新编写所有内容。而且大多数代码还不错。但是我希望重构代码,以便可以完全避免线程问题。

我正在考虑的一种方法是这种方法。对于每个重要的平台功能,请使用专用的单线程,将所有事件和网络回调整理到其中。与Windows中使用消息循环的COM公寓线程相似。长阻塞操作仍然可以分派到工作池线程,但是完成回调在组件的线程上调用。组件甚至可能共享同一线程。然后,可以在单个线程世界的假设下编写线程中运行的所有类库。

在走这条路之前,如果还有其他标准技术或设计模式来处理多线程问题,我也非常感兴趣。我必须强调-除了描述互斥量和信号量基础知识的书以外的内容。你怎么看?

我也对其他用于重构过程的方法感兴趣。包括以下任何一项:

  1. 有关线程周围设计模式的文献或论文。除了互斥量和信号量介绍之外,还有其他内容。我们也不需要大规模的并行性,只需设计对象模型以正确处理来自其他线程的异步事件的方法即可。

  2. 绘制各种组件的线程的方法,以便轻松地研究和发展解决方案。(也就是说,相当于一个UML,用于讨论跨对象和类的线程)

  3. 对您的开发团队进行有关多线程代码问题的教育。

  4. 你会怎么做?


23
有些人遇到问题时认为我将使用多线程。现在他们有了Twolm探测器
Tom Squires

20
对我而言,最有效的方法是尽可能消除变异性。当我看到可变对象更改状态以传递新值时,我尝试将其重构为传递一个新的不可变对象,该对象持有更改后的值。如果安全地完成了对象初始化,则可以保证不存在数据争用-颇为缓解
2012年

3
欢迎来到多线程地狱。我使用Ada,Occam,C ++编写多线程/并行程序已经20多年了。这从来都不是容易的,所有事情都需要非常仔细的考虑,任何说“轻松做X”的人都是傻瓜,他并不真正了解发生了什么。祝好运。
quick_now 2012年

2
如果您希望并发性很好,请使用Erlang!实际上,您想要的是某种形式的无共享参与者模型,其中将消除怪异的极端情况。
Zachary K

3
@DeadMG我认为共享状态并发本来就容易出现奇怪的极端情况,应该避免。但是,嘿,我写了一本关于Erlang的书
Zachary K,

Answers:


27

除了这些代码之外,您的代码还有其他重大问题。手动删除指针?调用cleanup函数?哎呀 而且,正如在问题注释中准确指出的那样,您不使用RAII进行锁定,这是另一个相当史诗般的失败,它保证在DoSomethingImportant引发异常时发生可怕的事情。

发生此多线程错误的事实只是核心问题的征兆-您的代码在任何线程情况下的语义都非常糟糕,并且您使用的是完全不可靠的工具和惯用语。如果我是你,我会惊讶于它只有一个线程,更不用说更多了。

常见错误#3-即使对象被引用计数,关闭序列也会“释放”它的指针。但是忘记等待仍在运行的线程释放它的实例。这样,组件将完全关闭,然后在不希望再有任何调用的状态下在对象上调用伪造或较晚的回调。

引用计数的全部要点是线程已经释放了它的instance。因为如果没有,则由于线程仍然具有引用,因此无法销毁它。

使用std::shared_ptr。当所有线程都释放后(因此,没有人可以调用该函数,因为它们没有指向该函数的指针),那么将调用析构函数。保证安全。

其次,使用真正的线程库,例如英特尔的线程构建模块或微软的并行模式库。编写自己的代码既耗时又不可靠,并且代码中充满了不需要的线程细节。自己做锁与自己进行内存管理一样糟糕。他们已经实现了许多通用的,非常有用的线程习惯用法,可以正确使用。


这是一个好的答案,但不是我一直在寻找的方向,因为它花费了太多时间来评估一段为简单起见而编写的示例代码(而不反映我们产品中的真实代码)。但是,我对您发表的一则评论“好奇的工具”感到好奇。什么是不可靠的工具?您推荐什么工具?
koncurrency 2012年

5
@koncurrency:一个不可靠的工具是诸如手动内存管理或编写自己的同步之类的东西,理论上它可以解决问题X,但实际上如此糟糕,以至于您几乎可以保证存在巨大的错误,并且它是解决问题的唯一方法开发人员时间的大量和不成比例的投入,才是合理的规模,这就是您现在所拥有的。
DeadMG

9

其他张贴者对解决核心问题应采取的措施给予了很好的评价。这篇文章关注的是更直接的问题,即修补足够好的旧代码来为您腾出时间以正确的方式重做所有事情。换句话说,这不是做事的正确方法,只是暂时li行的一种方法。

您整合关键事件的想法是一个好的开始。我会尽可能地使用单个调度线程来处理所有关键同步事件,无论那里有订单依赖性。在当前执行并发敏感操作(分配,清理,回调等)的任何地方设置线程安全消息队列,而不是向该线程发送消息并让其执行或触发该操作。这个想法是,这个线程控制着所有工作单元的启动,停止,分配和清除。

调度线程并不会解决你所描述的问题,它只是巩固了他们在一个地方。您仍然必须担心事件/消息以意外的顺序发生。运行时间较长的事件仍将需要发送给其他线程,因此共享数据的并发性仍然存在问题。缓解这种情况的一种方法是避免通过引用传递数据。只要有可能,派发消息中的数据应为副本,副本应归接收者所有。(这与其他人提到的使数据不可变一样。)

这种分派方法的优点是,在分派线程中,您具有某种避风港,您至少知道某些操作是按顺序进行的。缺点是它会造成瓶颈和额外的CPU开销。我建议一开始不要担心这两个问题:首先要集中精力通过尽可能多地移入调度线程来获得某种正确操作的度量。然后进行一些性能分析,以查看占用了最多CPU时间的时间,并开始使用正确的多线程技术将其移出调度线程。

再说一次,我所描述的不是正确的做事方式,而是一个可以使您以正确的方式朝着正确的方向前进的过程,增量要小到足以满足商业期限。


+1是克服当前挑战的合理,中间建议。

是的,这是我正在研究的方法。您提出了有关性能的要点。
koncurrency

更改事物以使其通过单个调度线程听起来并不像是快速修补,而是对我来说是一个巨大的重构。
塞巴斯蒂安·雷德尔

8

根据显示的代码,您有一堆WTF。如果不是不可能的话,以增量方式修复编写不良的多线程应用程序将非常困难。告诉所有者,如果不进行大量返工,该应用程序将永远不会可靠。根据检查和重新处理与共享库交互的代码的每一位,给他们一个估计。首先给他们一个估计的检查。然后,您可以对返工进行估算。

当您对代码进行重做时,您应该计划编写代码,以确保其正确无误。如果您不知道该怎么做,请找一个做的人,否则您将落在同一个地方。


我的答案被接受后,请立即阅读。只是想说我喜欢介绍性的句子:)
back2dos

7

如果您有时间专用于重构应用程序,我建议您看一下actor模型(例如,对于C ++实现,请参见TheronCasablancalibcppaCAF)。

Actor是同时运行并仅使用异步消息交换相互通信的对象。因此,所有线程管理,互斥锁,死锁等问题都由actor实现库处理,您可以集中精力实现对象(actor)的行为,归结为重复循环

  1. 接收讯息
  2. 执行计算
  3. 发送消息/创建/杀死其他演员。

一种方法可能是先阅读该主题,然后再看一两个库,以了解actor模型是否可以集成到您的代码中。

我已经在我的项目中使用此模型(简化版本)了几个月,而它的强大功能令我惊讶。


1
Scala的Akka库是一个很好的实现,它考虑了很多有关如何在孩子死后杀死父母演员,反之亦然的想法。我知道它不是C ++,但值得一看:akka.io
GlenPeterson,2012年

1
@GlenPeterson:谢谢,我了解akka(我认为这是目前最有趣的解决方案,并且可以在Java和Scala上使用),但是这个问题专门针对C ++。否则,可以考虑使用Erlang。我猜在Erlang中,多线程编程的所有麻烦都已经一去不复返了。但是也许像akka这样的框架非常接近。
乔治(Giorgio)2012年

“我猜在Erlang中,多线程编程的所有麻烦都已经消失了。” 我认为这可能有些夸张。或者,如果为true,则可能缺乏性能。我知道Akka不能与C ++一起使用,只是说它看起来像管理多个线程的最新技术。但是,它不是线程安全的。您仍然可以在演员之间传递可变的状态,然后射杀自己。
GlenPeterson,2012年

我不是Erlang专家,但是AFAIK每个actor都是独立执行的,并且交换不可变消息。因此,您实际上根本不必处理线程和共享的可变状态。性能可能低于C ++,但是这通常在您提高抽象级别时才会发生(增加执行时间但减少开发时间)。
乔治

下选民可以发表评论并提出我如何改善此答案的建议吗?
乔治

6

常见错误#1-通过仅对共享数据进行锁定来解决并发问题,但是忘记了当方法未按预期顺序调用时会发生什么情况。这是一个非常简单的示例:

这里的错误不是“忘记”,而是“没有解决”。如果事情发生的顺序出乎意料,那就有问题了。您应该解决它而不是尝试解决它(通常将锁锁在某个东西上)。

您应该在一定程度上尝试调整参与者模型/消息并分离关注点。Foo显然,它的作用是处理某种HTTP通信。如果要设计系统以并行方式执行此操作,则必须在上面的层上处理对象生命周期并相应地访问同步。

试图让多个线程对相同的可变数据进行操作很难。但这也很少需要。所有需要这样做的常见情况,都已经被抽象为更易于管理的概念,并且对于任何主要命令式语言都多次实施。您只需要使用它们。


2

您的问题非常糟糕,但是使用C ++的情况却很典型。代码审查将解决其中的一些问题。30分钟,一组眼球收率达90%。

#1问题您需要确保有严格的锁定等级,以防止锁定死锁。

如果将自动锁定替换为包装器和宏,则可以执行此操作。

保留在包装器背面创建的静态全局锁映射。您使用宏将名称和行号信息插入到Autolock包装器构造函数中。

您还需要一个静态支配图。

现在,在锁内,您必须更新支配者图,并且如果获得顺序更改,则声明错误并中止。

经过大量测试后,您可能会摆脱大多数潜在的僵局。

该代码留给学生作为练习。

问题#2然后会消失(大部分情况下)

您的归档解决方案将起作用。我以前在任务和生活关键系统中使用过它。我的看法是这样

  • 传递不可变的对象或在传递之前制作它们的副本。
  • 不要通过公共变量或获取器共享数据。

  • 外部事件通过多线程分派进入由一个线程提供服务的队列。现在,您可以整理有关事件处理的原因。

  • 跨线程的数据更改进入线程安全队列,由一个线程处理。进行订阅。现在,您可以对有关数据流的原因进行分类。

  • 如果您的数据需要跨市区访问,请将其发布到数据队列中。这将复制它,并将其异步传递给订阅者。还会破坏程序中的所有数据依赖关系。

这几乎是便宜的演员模型。Giorgio的链接会有所帮助。

最后,关闭对象的问题。

当您进行引用计数时,您已经解决了50%。另外50%是引用计数回调。向回调持有者传递引用。然后,关机调用必须等待refcount的计数为零。不解决复杂的对象图;进入真正的垃圾收集。(这是ins Java的动机,因为它不保证何时或是否将调用finalize();使您摆脱这种编程的习惯。)


2

对于将来的探索者:为了补充有关参与者模型的答案,我想添加CSP(通信顺序过程),并向它所涉及的更大的过程计算系列致敬。CSP与参与者模型相似,但划分方式有所不同。您仍然有一堆线程,但是它们通过特定的通道进行通信,而不是通过特定的通道进行通信,并且两个进程必须准备就绪才能分别发送和接收。还有一种用于证明CSP代码正确的形式化语言。我仍在过渡到大量使用CSP,但是现在我已经在几个项目中使用了几个月,现在它已经大大简化了。

肯特大学拥有C ++实现(https://www.cs.kent.ac.uk/projects/ofa/c++csp/,克隆于https://github.com/themasterchef/cppcsp2)。


1

有关线程周围设计模式的文献或论文。除了互斥量和信号量介绍之外,还有其他内容。我们也不需要大规模的并行性,只需设计对象模型以正确处理来自其他线程的异步事件的方法即可。

我目前正在阅读此书,它解释了在C ++中可能遇到的所有问题以及如何避免它们(使用新的线程库,但我认为全局解释适用于您的情况): http://www.amazon。 com / C-Concurrency-Action-Practical-Multithreading / dp / 1933988770 / ref = sr_1_1?ie = UTF8&qid = 1337934534&sr = 8-1

绘制各种组件的线程的方法,以便轻松地研究和发展解决方案。(也就是说,相当于一个UML,用于讨论跨对象和类的线程)

我个人使用简化的UML,只是假设消息是异步完成的。同样,“模块”之间也是如此,但是在模块内部我不想知道。

对您的开发团队进行有关多线程代码问题的教育。

这本书会有所帮助,但是我认为实践/原型设计和经验丰富的导师会更好。

你会怎么做?

我将完全避免让人们不了解项目的并发问题。但是我想您无法做到这一点,因此,在您的特定情况下,除了尝试确保团队受过更好的教育外,我不知道。


谢谢你的建议。我可能会捡起来。
koncurrency

线程化真的很难。并非每个程序员都能应对挑战。在商业世界中,每当我看到使用的线程时,它们就会被锁包围,以至于两个线程无法同时运行。您可以遵循一些规则来简化操作,但这仍然很困难。
GlenPeterson,2012年

@GlenPeterson Agreed,现在,我有了更多的经验(因为有了这个答案),我发现我们需要更好的抽象来使其易于管理并阻止共享数据。幸运的是,语言设计师似乎为此付出了很多努力。
克莱姆(Klaim)2012年

Scala给我留下了深刻的印象,特别是为J​​ava带来了不变性的功能编程优势,最小的副作用,Java是C ++的直接后代。它在Java虚拟机上运行,​​因此可能没有所需的性能。约书亚·布洛赫(Joshua Bloch)的书《有效的Java》(Effective Java)旨在最大程度地减少可变性,制作气密接口和线程安全性。即使它是基于Java的,我敢打赌您可以将其中的80-90%应用于C ++。在代码审查中质疑可变性和共享状态(或共享状态的可变性)可能是您的第一步。
GlenPeterson,2012年

1

您已经通过确认问题并积极寻找解决方案来解决问题。这就是我要做的:

  • 坐下来为您的应用程序设计一个线程模型。这是一个回答诸如以下问题的文档:您拥有哪种类型的线程?在哪个线程中应该做什么?您应该使用哪种不同的同步模式?换句话说,在应对多线程问题时,它应该描述“参与规则”。
  • 使用线程分析工具检查您的代码库是否有错误。Valgrind有一个叫做Helgrind的线程检查器,它擅长发现诸如共享状态被操纵而没有正确同步之类的事情。当然,还有其他一些好的工具,请寻找它们。
  • 考虑从C ++迁移。C ++是编写并发程序的噩梦。我个人选择的是Erlang,但这是一个品味问题。

8
最后一位绝对为-1。看来OP的代码使用的是最原始的工具,而不是实际的C ++工具。
DeadMG

2
我不同意 即使使用正确的C ++机制和工具,C ++中的并发也是一个噩梦。并且请注意,我选择了“ 考虑 ”一词。我完全理解这可能不是现实的选择,但是不考虑其他选择而使用C ++只是愚蠢的。
JesperE 2012年

4
@JesperE-对不起,但是没有。如果您将C ++的并发级别设置得太低,那么它只会是一场噩梦。使用适当的线程抽象,它并不比任何其他语言或运行时都糟糕。有了适当的应用程序结构,它实际上和我见过的其他任何东西一样简单。
迈克尔·科恩

2
在我工作的地方,我相信我们确实拥有合适的应用程序结构,使用正确的线程抽象等等。尽管如此,这些年来,我们花费了无数小时来调试错误,这些错误根本不会以为并发设计的语言出现。但是我有一种感觉,我们必须同意不同意。
JesperE 2012年

1
@JesperE:我同意你的看法。与直接使用线程编码相比,Erlang模型(存在针对Scala / Java,Ruby和C ++的实现)更加健壮。
乔治

1

查看您的示例:Foo :: Shutdown一旦开始执行,就不可能再调用OnHttpRequestComplete来运行。这与任何实现都没有关系,只是行不通。

您也可能会争辩说,在对OnHttpRequestComplete的调用正在运行时(绝对正确),不应调用Foo :: Shutdown;如果仍然未完成对OnHttpRequestComplete的调用,则可能不应该进行调用。

首先要做的不是锁定等,而是允许或禁止的逻辑。一个简单的模型是您的类可能有零个或多个不完整的请求,零个或多个尚未被调用的完成,零个或多个正在运行的完成以及您的对象是否要关闭。

Foo :: Shutdown有望完成运行中的完成,将不完整的请求运行到可能的程度,以便可以将它们关闭,从而不允许启动更多的完成,不允许启动更多的请求。

您需要执行的操作:在函数中添加规范,以确切说明其功能。(例如,在调用Shutdown之后,启动http请求可能会失败)。然后编写您的函数,使其符合规格。

锁最好仅在最短的时间内用于控制共享变量的修改。因此,您可能有一个受锁定保护的变量“ performingShutDown”。


0

你会怎么做?

老实说; 我很快就逃走了。

并发问题是NASTY。某些东西可以正常工作几个月,然后(由于几件事的具体时间安排)突然在客户的脸上炸毁,无法弄清楚发生了什么,没有希望看到不错的(可再现的)错误报告,也没有办法甚至可以确定这不是与软件无关的硬件故障。

避免并发问题需要在设计阶段开始,首先要从您要如何做开始(“全局锁定顺序”,参与者模型等)开始。您不是想尽一切办法来解决该问题,而是希望在即将发布的版本之后一切都不会自毁。

请注意,我在这里不是在开玩笑。用你自己的话说(“ 大部分来自离开团队的其他开发人员。团队中当前的开发人员非常聪明,但从经验上来说大多是初级的。 ”)表明人们已经完成了我的所有工作在暗示。

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.