线程相关问题及其调试

6
这是我对之前关于内存管理问题的跟进。以下是我所知道的问题。
1)数据竞争(原子性违规和数据损坏)
2)排序问题
3)误用锁导致死锁
4)海森堡Bug
还有其他多线程问题吗?如何解决它们?
7个回答

2

Eric列出的四个问题基本上是准确的。但调试这些问题很困难。

对于死锁,我一直倾向于使用“分级锁”。基本上,您为每种类型的锁分配一个级别号。然后要求线程获取单调的锁。

要使用分级锁,您可以声明以下结构:

typedef struct {
   os_mutex actual_lock;
   int level;
   my_lock *prev_lock_in_thread;
} my_lock_struct;

static __tls my_lock_struct *last_lock_in_thread;

void my_lock_aquire(int level, *my_lock_struct lock) {
    if (last_lock_in_thread != NULL) assert(last_lock_in_thread->level < level)
    os_lock_acquire(lock->actual_lock)
    lock->level = level
    lock->prev_lock_in_thread = last_lock_in_thread
    last_lock_in_thread = lock
}

升级锁的酷炫之处在于死锁会引发一个断言。通过一些额外的神奇操作,例如 FUNC LINE ,您可以准确地知道线程出了什么问题。
对于数据竞争和缺乏同步的情况,目前的情况相当糟糕。有些静态工具试图识别这些问题,但误报率很高。
我所在的公司( http://www.corensic.com )推出了一个新产品Jinx,可以积极寻找可以暴露竞争条件的情况。这是通过使用虚拟化技术来控制各个CPU上线程的交错,并聚焦于CPU之间的通信来完成的。
快去看看吧!您可能还有几天可以免费下载测试版。
Jinx在查找无锁数据结构中的错误方面尤为出色。它也非常擅长查找其他竞争条件。酷的是没有误报。如果您的代码测试接近竞争条件,Jinx会帮助代码进入错误的路径。但是,如果存在坏路径,您将不会收到虚假警报。

2

很不幸地,没有什么好的方法可以自动解决大多数或所有线程问题。即使单线程代码都能完美运行的单元测试可能永远也无法检测到非常微妙的竞态条件。

有一件事情可以帮助解决这个问题,那就是将线程交互数据封装在对象中。对象接口/作用域越小,就越容易在审核中检测出错误(并可能在测试中检测到,但竞态条件可能会让测试用例变得复杂)。通过保持一个简单的可用于使用的接口,使用该接口的客户端也将默认正确。通过从许多较小的部分构建更大的系统(其中只有少数几个实际上进行线程交互),您可以在第一时间避免线程错误。


1

线程编程中最常见的四个问题是:

1-死锁
2-活锁
3-竞态条件
4-饥饿


所有这些问题都可以通过信号量(锁)来解决。首先,您需要仔细了解自己在做什么。尽量不要过度使用线程,它们是帮助程序执行任务的工具,而不是您需要随处放置的魔法技巧。 - Eric

1
如何解决[多线程问题]?
通过记录日志是“调试”MT应用程序的好方法。具有广泛过滤选项的良好日志记录库使其更加容易。当然,记录日志本身会影响时间,因此仍然可能出现“海森巴格”,但比实际进入调试器时要少得多。
为此做好准备和计划。从一开始就将良好的日志记录设施纳入应用程序中。

1

让你的线程尽可能简单。

尽量不要使用全局变量。全局常量(实际上永远不会改变的常量)是可以的。当你需要使用全局或共享变量时,你需要用某种互斥锁/锁(信号量、监视器等)来保护它们。

确保你真正理解你的互斥锁是如何工作的。有几种不同的实现方式,它们的工作方式也可能不同。

尽量组织你的代码,使得关键部分(在这些部分中,你持有某种类型的锁)尽可能快速。请注意,一些函数可能会阻塞(睡眠或等待某些东西,并且阻止操作系统允许该线程继续运行一段时间)。不要在持有任何锁时使用这些函数(除非绝对必要或在调试期间,因为它有时可以显示其他错误)。

尝试了解更多线程对你实际上意味着什么。盲目地向问题投入更多线程往往会使情况变得更糟。不同的线程会竞争CPU和锁。

避免死锁需要计划。尽量避免同时获取多个锁。如果这是不可避免的,请决定你将用于获取和释放所有线程锁的顺序。确保你知道死锁的真正含义。

调试多线程或分布式应用程序很困难。如果您可以在单线程环境下完成大部分调试(甚至只是强制其他线程休眠),那么您可以尝试在进入多线程调试之前消除非线程中心错误。

始终考虑其他线程可能正在进行的操作。在代码中加上注释。如果您之所以按照某种方式执行某些操作,是因为您知道此时没有其他线程应该访问某个资源,请加上大型注释。

您可能需要将对互斥锁的调用包装在其他函数中,例如:

int my_lock_get(lock_type lock, const char * file, unsigned line, const char * msg) {

 thread_id_type me = this_thread();

 logf("%u\t%s (%u)\t%s:%u\t%s\t%s\n", time_now(), thread_name(me), me, "get", msg);

 lock_get(lock);

 logf("%u\t%s (%u)\t%s:%u\t%s\t%s\n", time_now(), thread_name(me), me, "in", msg);

}

解锁的类似版本。请注意,此处使用的函数和类型都是虚构的,并不基于任何一个API。

使用类似这样的东西,如果出现错误,您可以返回并使用Perl脚本或类似的工具在日志上运行查询,以查看哪里出了问题(例如匹配锁定和解锁)。

请注意,您的打印或记录功能可能也需要加锁。许多库已经内置了此功能,但并非所有库都有。这些锁不能使用lock_[get|release]函数的打印版本,否则会导致无限递归。


1
  1. 即使是const的全局变量也要小心,特别是在C++中。只有像C一样静态初始化的POD才能在这里使用。一旦运行时构造函数开始发挥作用,就要非常小心。我记得静态链接的变量的初始化顺序在不同的编译单元中被称为未定义的顺序。也许现在初始化所有成员并具有空函数体的C++类可以使用,但我曾经也有过不好的经历。

    这是为什么在POSIX方面,pthread_mutex_tsem_t更容易编程的原因之一:它有一个静态初始化器PTHREAD_MUTEX_INITIALIZER

  2. 关键部分应该尽可能短,有两个原因:最后可能更有效率,但更重要的是更容易维护和调试。

    关键部分的长度不应超过一个屏幕,包括所需的锁定和解锁以保护它,以及帮助读者理解正在发生的事情的注释和断言。

    开始实施非常严格的关键部分,也许为它们所有人都提供一个全局锁,并放松约束条件。

  3. 如果许多线程开始同时写入,记录可能很困难。如果每个线程都做了合理数量的工作,请尝试让它们各自编写自己的文件,以便它们不会相互锁定。

    但要注意,记录会改变代码行为。当错误消失时,这可能是不好的,或者当出现您否则不会注意到的错误时,这可能是有益的。

    要对这样的混乱进行事后分析,您必须在每行上具有准确的时间戳,以便可以合并所有文件并为您提供一致的执行视图。


1

-> 把优先级反转加入到列表中。

正如另一位发帖者所暗示的那样,日志文件是非常好的工具。对于死锁,使用LogLock而不是Lock可以帮助确定何时实体停止工作。也就是说,一旦你知道你有一个死锁,日志将告诉你锁何时何地被实例化和释放。这在追踪这些问题方面非常有帮助。

我发现,在使用Actor模型时,遵循相同的消息->确认->确认接收样式时,竞争条件似乎会消失。话虽如此,你的情况可能会有所不同。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接