19

问题如何确保我的应用程序是线程安全的?他们有什么常见的做法、测试方法、要避免的事情、要寻找的事情吗?

背景我目前正在开发一个服务器应用程序,它在不同的线程中执行许多后台任务,并使用 Indy 与客户端通信(使用另一组自动生成的线程进行通信)。由于应用程序应该是高度可用的,程序崩溃是一件非常糟糕的事情,我想确保应用程序是线程安全的。无论如何,我不时发现一段代码会引发以前从未发生过的异常,并且在大多数情况下,我意识到这是某种同步错误,我忘记正确同步我的对象。因此,我提出了关于最佳实践、线程安全测试等问题的问题。

mghie:谢谢你的回答!我也许应该更精确一点。为了清楚起见,我了解多线程的原理,我在整个程序中使用同步(监视器),并且我知道如何区分线程问题和其他实现问题。但是,尽管如此,我还是时不时忘记添加适当的同步。举个例子,我在代码中使用了 RTL 排序函数。看起来像

FKeyList.Sort (CompareKeysFunc);

事实证明,我必须在排序时同步 FKeyList。最初编写那行简单的代码时,我并没有想到它。我想谈的就是这些。哪些地方容易忘记添加同步代码?您如何确保在所有重要位置添加了同步代码?

4

5 回答 5

17

您无法真正测试线程安全性。您所能做的就是证明您的代码不是线程安全的,但是如果您知道如何做到这一点,那么您已经知道在程序中要做什么来修复该特定错误。问题在于您不知道的错误,您将如何为这些错误编写测试?除此之外,线程问题比其他问题更难发现,因为调试行为已经可以改变程序的行为。从一个程序运行到下一个程序,从一台机器到另一台机器,情况会有所不同。CPU 和 CPU 内核的数量、并行运行的程序的数量和种类、程序中发生的事情的确切顺序和时间——所有这些以及更多都会对程序行为产生影响。[我实际上想将月相之类的东西添加到这个列表中,

我的建议是不要将其视为实现问题,而应开始将其视为程序设计问题。你需要学习和阅读所有你能找到的关于多线程的东西,不管它是不是为 Delphi 编写的。最后,您需要了解基本原理并将其正确应用到您的编程中。诸如临界区、互斥体、条件和线程之类的原语是操作系统提供的,大多数语言只将它们包装在它们的库中(这忽略了诸如 Erlang 提供的绿色线程之类的东西,但这是一个很好的起点)。

我会说从关于线程的维基百科文章开始,然后通过链接的文章开始工作。我从Aaron Cohen 和 Mike Woodring 的“Win32 多线程编程”一书开始——它已经绝版,但也许你可以找到类似的东西。

编辑:让我简要跟进您编辑的问题。所有对非只读数据的访问都需要正确同步才能线程安全,并且对列表进行排序不是只读操作。因此,显然需要围绕对列表的所有访问添加同步。

但是随着系统中的内核越来越多,持续锁定将限制可以完成的工作量,因此寻找一种不同的方式来设计程序是一个好主意。一个想法是在程序中引入尽可能多的只读数据——不再需要锁定,因为所有访问都是只读的。

我发现接口在设计多线程程序时非常有用。接口可以实现为只有对内部数据进行只读访问的方法,如果你坚持使用它们,你可以非常确定很多潜在的编程错误不会发生。您可以在线程之间自由共享它们,并且线程安全的引用计数将确保当对它们的最后一个引用超出范围或被分配另一个值时,它们被正确释放。

您所做的是创建从 TInterfacedObject 派生的对象。它们实现了一个或多个接口,这些接口都只提供对对象内部的只读访问,但它们也可以提供改变对象状态的公共方法。创建对象时,您同时保留对象类型的变量和接口指针变量。这样生命周期管理很容易,因为当异常发生时对象会被自动删除。您使用指向对象的变量来调用正确设置对象所需的所有方法。这会改变内部状态,但由于这只发生在活动线程中,因此没有潜在的冲突。正确设置对象后,您将接口指针返回给调用代码,并且由于除了通过接口指针之外没有其他方法可以访问该对象,因此您可以确定只能执行只读访问。通过使用这种技术,您可以完全消除对象内部的锁定。

如果你需要改变对象的状态怎么办?你没有,你通过从接口复制数据来创建一个新对象,然后改变新对象的内部状态。最后,您将引用指针返回到新对象。

通过使用它,您只需要锁定获取或设置此类接口的位置。通过使用原子交换函数,它甚至可以在没有锁定的情况下完成。有关设置接口指针的类似用例,请参阅Primoz Gabrijelcic 的这篇博客文章。

于 2009-02-19T08:03:25.283 回答
7

很简单:不要使用共享数据。每次访问共享数据时,您都有可能遇到问题(如果您忘记同步访问)。更糟糕的是,每次访问共享数据时,您都有可能阻塞其他线程,这会损害您的并行化。

我知道这个建议并不总是适用的。尽管如此,如果您尝试尽可能多地遵循它并没有什么坏处。

编辑:对 Smasher 评论的更长回应。不适合评论:(

你是完全正确的。这就是为什么我喜欢在只读线程中保留主要数据的卷影副本。我在结构中添加了一个版本控制(一个 4 对齐的 DWORD),并在(受锁保护的)数据写入器中增加了这个版本。数据读取器将比较全局版本和私有版本(可以在没有锁定的情况下完成),并且只有当它们不同时才会锁定结构,将其复制到本地存储,更新本地版本并解锁。然后它将访问结构的本地副本。如果阅读是访问结构的主要方式,则效果很好。

于 2009-02-19T09:30:18.477 回答
2

我会同意 mghie 的建议:设计了线程安全性。您可以在任何地方阅读它。

要深入了解它是如何实现的,请查找有关实时操作系统内核内部结构的书。一个很好的例子是Jean J. Labrosse 的MicroC/OS-II: The Real Time Kernel,它包含工作内核的完整注释源代码,以及关于为什么事情会以这种方式完成的讨论。

编辑:鉴于改进的问题侧重于使用 RTL 功能......

任何可以被多个线程看到的对象都是潜在的同步问题。线程安全对象在每个方法的实现中都会遵循一致的模式,在方法的持续时间内锁定对象状态的“足够”,或者可能会缩小到“足够长”。当然,对对象状态的任何部分的任何读-修改-写序列都必须相对于其他线程以原子方式完成。

艺术在于弄清楚如何完成有用的工作,而不会出现死锁或造成执行瓶颈。

至于发现这样的问题,测试并不能保证。可以修复测试中出现的问题。但是为线程安全编写单元测试或回归测试是极其困难的......所以面对现有的代码体,你可能的求助是不断的代码审查,直到线程安全的实践成为第二天性。

于 2009-02-19T08:20:32.807 回答
2

正如人们所提到的,我想你知道,一般来说,确定你的代码是线程安全的是不可能的(我相信证明是不可能的,但我必须追查这个定理)。自然,您想让事情变得比这更容易。

我尝试做的是:

  • 使用已知的多线程设计模式线程池actor 模型范例命令模式或一些此类方法。这样,同步过程在整个应用程序中以相同的方式以统一的方式发生。
  • 限制和集中同步点。编写您的代码,以便您需要在尽可能少的地方进行同步,并将同步代码保留在代码中的一个或几个地方。
  • 编写同步代码,以便在进入和退出警卫时,值之间的逻辑关系是清晰的。我为此使用了很多断言(您的环境可能会限制这一点)。
  • Don't ever access shared variables without guards/synchronization. Be very clear what your shared data is. (I've heard there are paradigms for guardless multithreaded programming but that would require even more research).
  • Write your code as cleanly, clearly and DRY-ly as possible.
于 2009-02-19T18:01:05.777 回答
1

我的简单答案与这些答案相结合是:

  • 使用线程安全方式创建您的应用程序/程序
  • 避免在所有地方使用公共静态变量

因此,它通常很容易陷入这种习惯/习惯,但需要一些时间来习惯:

用函数式编程语言(例如 F#)甚至使用 Scheme 或 Haskell 对您的逻辑(而不是 UI)进行编程。函数式编程也促进了线程安全实践,同时它也警告我们在函数式编程中始终保持纯洁性。如果您使用 F#,那么使用可变或不可变对象(例如变量)也有明显的区别。


由于方法(或简单的函数)是 F# 和 Haskell 中的一等公民,因此您编写的代码也将更加严格地朝向不那么可变的状态。

同样使用通常可以在这些函数式语言中找到的惰性求值风格,您可以确保您的程序不受副作用影响,并且您还会意识到,如果您的代码需要效果,您必须明确定义它。如果考虑了副作用,那么您的代码将准备好利用代码中组件内的可组合性和多核编程。

于 2009-02-19T09:58:02.090 回答