何时需要使用锁?

10

好的,我知道这可能听起来很愚蠢(我害怕确实如此),但我对自己给出的答案并不完全满意,所以认为在这里发问是值得的。 我正在处理一个关于并发性(在Java中)的练习,其内容如下:

给定已解决的数独图表,在同时运行固定数量的线程的情况下,确定图表是否已正确解决,即不会违反规范规则(数字必须仅在其行、列和块中出现一次)。

那么我的问题是:既然线程只需要执行“读取”操作,从图表中收集信息并在其他地方加工,它们是否可以在不担心并发的情况下工作呢?图表的状态始终保持一致,因为没有执行“写入”操作,因此它永远不会改变。

只有当存在资源一致性丢失的风险时,锁/同步块/同步方法才是必需的。换句话说,我是否正确理解了并发性呢?


1
“固定数量的线程”是否排除了使用单个线程的可能性? - Daniel Earwicker
共享的可变状态需要受到保护。 - duffymo
7个回答

9

这是一个相当微妙的问题,一点也不愚蠢。

多个同时读取数据结构的线程可以在没有同步的情况下进行,但前提是数据结构已经被安全地发布。这是内存可见性问题,而不是时间问题或竞态条件。

请参阅Goetz等人的《Java并发实践》第3.5节,进一步讨论安全发布的概念。第3.5.4节“有效不可变对象”似乎适用于此处,因为棋盘在达到解决状态后就不再被写入,从而成为有效不可变对象。

简而言之,编写线程和读取线程必须执行一些内存协调活动,以确保读取线程具有对已编写内容的一致性视图。例如,编写线程可以写入数独棋盘,然后在持有锁时将棋盘的引用存储在静态字段中。读取线程随后可以在持有锁时加载该引用。完成后,它们可以确保所有先前对棋盘的写入都是可见且一致的。之后,读取线程可以自由地访问棋盘结构,无需进一步同步。

还有其他协调内存可见性的方法,例如对易失变量或AtomicReference进行写入/读取。使用更高级别的并发构造,例如闩锁或屏障,或将任务提交到ExecutorService中,也会提供内存可见性保证。

更新

基于与Donal Fellows在评论中的交流,我还应该指出,在从读者线程获取结果时,也适用安全发布要求。也就是说,一旦其中一个读者线程从其计算部分获得结果,它需要将该结果发布到某个地方,以便将其与其他读者线程的结果组合起来。可以像以前一样使用相同的技术,例如锁定/同步共享数据结构、易失变量等。然而,这通常是不必要的,因为可以从ExecutorService.submitinvoke返回的Future中获取结果。这些构造自动处理安全发布要求,因此应用程序无需处理同步。


1
然而,各个读取线程也需要在某个地方投票,以确定它们的任务片段是否对数独检查的整体成功或失败有所贡献。这是一个写入... - Donal Fellows
报告和合并部分结果不一定需要涉及写入共享可变状态。另一种选择,通常更可取的方法是将结果作为函数值返回,例如从“Callable”中返回。这对于通过“Future”协调任务的线程是可用的。请参见“ExecutorService.invokeAll”。这确实涉及在线程之间传递值,但不需要额外的锁定。 - Stuart Marks
这实际上取决于线程间消息传递的实现方式,不是吗?如果您要单向调用内存屏障,那么您需要相应地回去。请注意,由于它实际上只是一个比特 - 或更现实的是根据使用的方法为一个字节或一个词 - 有许多非常高效的方法来完成此操作。 - Donal Fellows
没错,返回值确实需要一个内存屏障。在我上面的评论中,这被封装在CallableFuture中(由FutureTask实现)。如果应用程序使用这些构造,它就不需要进行任何自己的同步。 - Stuart Marks
1
就我个人而言,我也会选择使用 ExecutorService,因为使用线程池(标准实现)来提交任务既易于编写正确的代码,又易于根据可用硬件进行校准。 - Donal Fellows
1
是的!j.u.c库不仅可以自动处理线程,还可以处理安全发布要求。我已经更新了答案并加入了这些信息。感谢您提出这个问题。 - Stuart Marks

2

在我看来,你的理解是正确的。只有当任何一个线程在写入数据时,数据才会出现损坏。

如果你确信没有任何线程在写入,那么可以安全地跳过同步和锁定...

编辑:在这些情况下跳过锁定是最佳实践!:)


5
实际上,这是最佳实践。在只读线程之间共享不可变数据是避免同步问题混乱并提供最佳性能的最佳方式 :-) - JB Nizet
@JBNizet 感谢您指出这一点。我正在编辑最后一行。 - Merlevede
1
但不要忘记 Stuart Marks 在第一个答案中所说的:不可变对象必须“安全发布”。这意味着确保在完全构造它(或所有对象)之前,没有其他线程可以看到不可变数据对象。正如《Java并发实践》中描述的那样,多线程程序在多处理器机器上运行时,有微妙的方法可以让一个线程看到某个对象在另一个线程完成构造之前的状态。 - Solomon Slow

1
如果文件是只读的,则不需要同步文件。基本上,锁被应用于关键部分。关键部分是指不同线程同时访问共享内存的地方。 由于同步会使程序变慢,因为没有多个线程同时访问,所以最好不要在只读文件中使用锁定。

1
假设您有一堆工作要完成(检查9行,9列,9个块)。 如果您希望线程完成这27个工作单位,并且希望在不重复工作的情况下完成工作,则需要同步线程。 另一方面,如果您希望拥有可能执行已由另一个线程完成的工作单位的线程,则不需要同步线程。

实际上,这与您是否想避免重复工作无关,而只与您如何安排工作有关。在没有任何锁定的情况下,静态调度不重复工作是微不足道的。 - Voo
确实。可能存在一种蒙特卡罗算法,它具有重复某些工作的非零概率,但平均而言,在足够多的处理器下,完成所需时间比另一个保证永远不会重复工作的算法更少。 - Solomon Slow
@voo 在这个检查数独棋盘有效性的简单示例中,可以在不需要锁定的情况下将工作静态地分配给不同的线程。但是一般来说,如果每个工作单元完成所需的时间不同,则将工作静态地分配给线程并不是最优的选择。有些线程会超负荷,而有些线程可能没有足够的工作量。jameslarge,你如何推断重复工作的完成时间平均比从不重复工作的算法更短?一般来说,我认为重复工作是绝对不可取的。 - anonymous
@anonymous 在一些问题中,额外的通信和同步开销远比一些线程意外重复工作更糟糕。正如詹姆斯所提到的蒙特卡罗模拟就是一个很好的例子。这只是一个关于你重复了多少以及避免它所需的通信成本有多高的问题。没有硬性规定。 - Voo

0

Thread1写入一些数据,然后一堆线程需要读取这些数据的情况下,如果正确处理,则不需要锁定。 正确处理指的是您的SUDOKU板是不可变对象,而不可变对象指:

  • 状态在构建后无法修改
  • 状态实际上不是通过某些反射黑魔法进行修改的
  • 所有字段都是final的
  • 'this'引用在构建期间不会逃逸(如果在构建期间执行类似于MyClass.instnce = this的操作,则可能会发生这种情况)。

如果将此对象传递给工作线程,则可以正常运行。 如果您的对象不满足所有这些条件,则仍然可能遇到并发问题,在大多数情况下,这是因为JVM可以随意重新排序语句(出于性能原因),并且它可能以这样的方式重新排序这些语句,以便在构建数独板之前启动工作线程。

这里有一篇关于不可变对象的非常好的文章。


0

摘要

为了保证线程能够观察到对主内存的写入效果,必须在读取之前进行写入。如果写入和读取发生在不同的线程中,则需要进行同步操作。规范定义了许多不同类型的同步操作。其中一种操作是执行synchronized语句,但也存在其他选择。

详细信息

Java语言规范writes

两个动作可以通过happens-before关系排序。如果一个动作happens-before另一个动作,则第一个动作对第二个动作可见并且在其之前排序。

以及

更具体地说,如果两个动作共享happens-before关系,则它们不一定按照这个顺序出现,对于它们不共享happens-before关系的任何代码来说,它们可能会以不同的顺序出现。例如,在一个线程中写入与另一个线程中读取竞争的数据可能会以不同的顺序出现。

在您的情况下,您希望阅读线程解决正确的数独。也就是说,数独对象的初始化必须对阅读线程可见,因此初始化必须在阅读线程从数独中读取之前发生。
规范定义了“happens-before”的含义:
如果我们有两个动作x和y,我们写hb(x,y)表示x发生在y之前。
如果x和y是同一线程的动作,并且x在程序顺序中出现在y之前,则hb(x,y)。
从对象的构造函数的结尾到该对象的finalizer(§12.6)的开始有一个happens-before边缘。
如果一个动作x与后续的动作y同步,那么我们也有hb(x,y)。
如果hb(x,y)和hb(y,z),则hb(x,z)。

由于读取操作发生在不同的线程中而不是在终结器中,因此我们需要同步操作来确立写入先于读取。规范给出了以下详尽的同步操作列表:

在监视器m上的解锁操作与随后在m上进行的所有锁定操作同步(其中“随后”根据同步顺序定义)。
对易失性变量v(§8.3.1.4)的写入与任何线程对v的随后读取同步(其中“随后”根据同步顺序定义)。
启动线程的操作与它启动的线程中的第一个操作同步。
将默认值(零、false或null)写入每个变量与每个线程中的第一个操作同步。(虽然在分配包含变量的对象之前向变量写入默认值可能看起来有点奇怪,但从概念上讲,每个对象都是在程序开始时创建的,并具有其默认初始化值。)
线程T1中的最终操作与另一个线程T2中检测到T1已终止的任何操作同步(T2可以通过调用T1.isAlive()或T1.join()来完成此操作)
如果线程T1中断线程T2,则T1的中断与任何其他线程(包括T2)确定T2已被中断的任何点同步(通过抛出InterruptedException或调用Thread.interrupted或Thread.isInterrupted)。

您可以选择任何一种方法来建立happens-before关系。实际上,在数独完全构建完成后启动读取线程可能是最简单的方法。


-2

在我看来,如果你写入数据需要花费很长时间,比如因为网络延迟或巨大的处理开销,那么加锁是必要的。 否则,就可以安全地不使用锁。


即使写操作需要两个时钟周期或1纳秒,写入时仍需要锁定。 - Merlevede
从理论上讲是正确的。问题在于,在99%的情况下,这样的写操作是针对内存存储介质(如CHM)完成的,并且写同步已经提供了,因此您不必自己担心它。对于所有其他情况,您必须手动进行锁定,我认为主题发起人就是指的这个。 - injecteer
@injecteer:那是完全错误的。而且你的规则也不适用:「很长时间」并没有具体的含义。我建议你阅读Brian Goetz所著的《实战Java并发编程》。 - JB Nizet

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