什么是非线程安全?

16
有很多文章和讨论解释为什么构建线程安全的类是好的。据说如果多个线程同时访问一个字段,可能会产生一些不良后果。那么保持非线程安全代码的意义何在?我主要关注.NET,但我相信主要原因并不依赖于语言。
例如,.NET静态字段不是线程安全的,如果它们默认情况下是线程安全的,结果会怎样呢?(无需执行“手动”锁定)。使用(实际上默认为)非线程安全有什么好处?
我想到的一件事是性能(尽管更多的是猜测)。当函数或字段不需要线程安全时,直觉告诉我们不应该使用线程安全。然而,问题是:为什么?线程安全只是你始终需要实现的额外代码吗?在哪些场景中可以100%确定例如一个字段不会被两个线程同时使用?
9个回答

16

编写线程安全的代码:

  1. 需要更有技能的开发人员
  2. 更难且需要更多的编码工作量
  3. 更难测试和调试
  4. 通常有更大的性能成本

但是!并不总是需要编写线程安全的代码。如果你可以确定某个代码片段只会被一个线程访问,上述列表就变得很冗余且不必要。这就像两个人不携带太多行李时去邻近城市,租一辆货车一样。


但是你什么时候可以确定一个DLL绝对不会使用线程呢?那些想要尝试你的库的客户怎么办?是否应该使所有公共方法都支持多线程?关于那辆车:有些人喜欢吹嘘。 :) - pbalaga
@rook:记住,如果这是一个好的库,你不必关心它如何实现功能:除非非常小心地为您标记,否则它永远不应该让您访问不安全的线程。 - Chris Pfohl
3
关于客户端对他们的库进行试验,除非明确记录一个方法是线程安全的,否则可以假设它不是线程安全的。当客户使用您的类时,他们应该承担维护线程安全的责任。 - Juliet
@rook 对的。如果没有明确声明线程安全性,则假定不存在线程安全性。问题在于有不同级别的线程安全性:方法可以是线程/非安全的,而类则可以是安全/非安全的。库可以是安全/非安全的(您可以创建多个实例),有时整个库都不是线程安全的。但您还有其他选项,例如创建多个应用程序域。 - Andrey
1
@rook:库代码理想情况下应该永远不要使用任何形式的全局变量/全局状态(包括委婉语如“单例”)。如果需要使用,您应该始终使用锁来保护它们,以防调用者是线程化的。但通常这还不够,因为调用者可能有两个不同的组件(可能是库本身),每个组件都假定它们独占您的库的内部状态。所以在库中不要使用全局变量。句号。 - R.. GitHub STOP HELPING ICE

12

线程安全性是有代价的 - 如果同时访问可能引起问题的字段,您需要进行锁定。

在没有使用线程但需要高性能且每个 CPU 循环都非常重要的应用程序中,无需使用安全线程类。


这是什么类型的问题?如果两个线程仅对一个字段执行读操作会发生什么? - pbalaga
1
@rook:如果两个线程对一个字段执行读操作,它们将读取该字段的值。现在,如果这两个线程同时修改该字段,可能会出现问题。 - Szymon Rozga

5

那么,保留非线程安全的代码的意义是什么呢?

成本。就像你所想象的那样,通常会降低性能。

此外,编写线程安全的代码更加困难和耗时。


5

线程安全不是一个“是”或“否”的问题。 “线程安全”的含义取决于上下文; 它是否意味着“并发读取安全,但并发写入不安全”? 它是否意味着应用程序可能返回陈旧的数据而不是崩溃? 它可以有很多含义。

不将类设为“线程安全”的主要原因是成本。 如果该类型不会被多个线程访问,则没有将其设置为线程安全的优势,并且会增加维护成本。


3

写线程安全的代码有时非常痛苦。例如,简单的延迟加载需要两次检查“==null”和一个锁。很容易出错。

[编辑]

我并不是想表达多线程延迟加载特别困难,而是在你认为已经完成了锁定之后,“哦,我忘记锁定那个了!”这些瞬间会迅速而且频繁地出现,这才是真正的挑战。


它也很慢。必须将变量定义为volatile,这样编译器不会将访问优化掉。volatile意味着需要清除缓存。基本上,CPU的处理速度会变得非常慢。 - TomTom
@TomTom:当然,我只是给出了我不总是并行处理的主要原因。你平均的消费者并不太关心速度,除非他们注意到它,但对于大多数应用程序来说,这是相当不可能的。 - Chris Pfohl
啊,不错;我希望我也能有这种奢侈——处理大量数据或非常时间紧迫的数据会改变我的观点。 - TomTom
是的,在我在研究机构时,我们确实关心速度,因为多出来的几个 CPU 周期意味着多几天的处理时间。现在我构建应用程序,只有当它中断 UI 绘制时,处理时间才真正重要。 :) - Chris Pfohl

2

有些情况下,“线程安全”并不适用。这个考虑因素除了需要更高的开发者技能和增加的时间(开发、测试和运行时都会受到影响)。

例如,List<T> 是一个常用的非线程安全类。如果我们要创建一个线程安全的等效类,我们该如何实现 GetEnumerator?提示:没有好的解决方案。


然而,仍然存在一些问题。这样的实现有什么问题? - pbalaga
我不确定你所说的“仍然存在一些”的意思。拥有真正线程安全的数据结构的唯一方法是使它们不可变。 - Stephen Cleary
事实上,“still”是被划掉的。我的意思是,有一些解决方案存在,似乎可以有效地处理线程安全问题。也许它们并不像你所说的那样“好”。 - pbalaga
@rook:我的意思是有些类(例如List<T>)实际上无法变成线程安全的。你可以创建其他线程安全的集合(例如ConcurrentQueue<T>),或者选择使其不可变,但是你不能拥有一个与非线程安全的List<T>具有相同语义的线程安全的List<T> - Stephen Cleary
@StephenCleary:如果有可能在确定要操作的项的索引和实际操作该索引之间删除项目,则对List<T>进行索引操作将无法有意义地保证线程安全。但是,可以拥有有用的线程安全子集List<T>的功能,包括例如所有不删除项目的成员,或包括AddRemoveRemoveAt(0)Item(0).GetGetEnumerator,但没有其他索引操作(缺少读取项0的Try方法会让人感到恼火,但并非不可克服)。 - supercat

2
那么,保留非线程安全代码的意义是什么?
允许不是线程安全的代码存在,这样就可以让程序员决定正确的隔离级别
正如其他人所提到的,这可以减少复杂性并提高性能。
Rico Mariani撰写了两篇文章,题为“将同步放在正确的级别上”和“将同步放在正确的级别上--解决方案”,其中有一个很好的例子。
在这篇文章中,他有一个名为DoWork()的方法。他在其中调用其他类Read两次,Write两次,然后是LogToSteam
“Read”,“Write”和“LogToSteam”共享一个锁,并且是线程安全的。这很好,但事实上,“DoWork”也是线程安全的,因此每个“Read”,“Write”和“LogToSteam”的同步工作都是完全浪费时间的。
这一切都与命令式编程的本质有关。它的副作用导致了这种需要。
然而,如果您拥有一个开发平台,可以将应用程序表示为纯函数,在其中没有依赖项或副作用,则可以创建不需要开发人员干预即可管理线程的应用程序。

2
把这个问题颠倒过来。
在编程的早期,没有线程概念,因此也就不存在线程安全的代码。一个程序从开始到结束按照步骤执行,事件是什么?线程又是什么?
随着硬件变得更加强大,软件能够解决的问题类型变得更加丰富和有想象力,开发人员变得更加雄心勃勃,软件基础设施也变得更加复杂。它也变得更加顶重。今天,我们拥有了一个复杂、强大而在某些情况下不必要地顶重的软件生态系统,其中包括线程和“线程安全”。
我意识到这个问题更多地针对应用程序开发人员,而不是固件开发人员,但看整个森林确实可以深入了解那棵树的进化过程。

0

那么,保留非线程安全代码的意义是什么?

原则上,尽可能避免使用锁。理想情况下,代码应该是可重入的和线程安全的,不需要任何锁定。但这只是空想。

回归现实,一个的程序员尽其所能采用局部锁定而不是锁住整个上下文。例如,在各种例程中每次锁定几行代码,而不是在函数中锁定所有东西。

因此,还必须重构代码,以设计最小化锁定、甚至消除锁定的方案。

例如,考虑一个foobar()函数,它在每次调用时获取新数据,并在树中更改节点的数据类型上使用switch() case。可以大多数情况下避免锁定(如果不是完全),因为每个case语句将触摸树中的不同节点。尽管这是一个更具体的例子,但我认为它阐明了我的观点。


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