1

我读过关于锁的书,虽然什么都不懂。我的问题是为什么我们要使用未使用object并锁定它,以及这如何使某些东西成为线程安全的,或者这如何有助于多线程?没有其他方法可以制作线程安全的代码。

public class test {
    private object Lock { get; set; }

    ...
    lock (this.Lock) { ... }
    ...
}

对不起,我的问题很愚蠢,但我不明白,虽然我已经用过很多次了。

4

6 回答 6

3

从一个线程访问一段数据而其他线程正在修改它被称为“数据竞争条件”(或只是“数据竞争”),并可能导致数据损坏。(*)

锁只是一种避免数据竞争的机制。如果两个(或更多)并发线程锁定同一个锁对象,则它们不再是并发的,并且在锁期间不再会导致数据竞争。本质上,我们正在序列化对共享数据的访问。

诀窍是保持你的锁尽可能“宽”以避免数据竞争,但尽可能“窄”以通过并发执行获得性能。这是一个很好的平衡,很容易在任一方向上失控,这就是多线程编程很难的原因。

一些指导方针:

  • 只要所有线程都只是读取数据并且没有人会修改它,就不需要锁定。
  • 相反,如果至少有一个线程可能在某个时刻修改数据,那么访问相同数据的所有并发代码路径必须通过锁正确序列化,即使是那些只读取数据的。
    • 在一个代码路径而不是另一个代码路径中使用锁将使数据对竞争条件开放。
    • 此外,在一个代码路径中使用一个锁对象,但在另一个(并发)代码路径中使用不同的锁对象不会序列化这些代码路径,并使您对数据竞争敞开大门。
    • 另一方面,如果两个并发代码路径访问不同的数据,它们可以使用不同的锁对象。但是,只要有多个锁对象,就要注意死锁。死锁通常也是“代码竞争条件”(和 heisenbug,见下文)。
  • 锁定对象不需要(并且通常不是)与您尝试保护的数据相同。不幸的是,没有语言工具可以让您“声明”哪些数据受哪个锁定对象保护,因此您必须非常仔细地为可能维护您的代码的其他人以及您自己记录您的“锁定约定”(因为即使在很短的时间之后,您也会忘记锁定约定的一些角落和缝隙)。
  • 尽可能保护锁对象免受外界影响通常是一个好主意。毕竟,您将它用于非常敏感的锁定任务,并且您不希望它被外部参与者以不可预见的方式锁定。这就是为什么使用this或公共字段作为锁定对象通常是一个坏主意。
  • lock关键字只是Monitor.EnterMonitor.Exit的更方便的语法。
  • 锁对象可以是 .NET 中的任何对象, 值对象将在对 的调用中被装箱Monitor.Enter,这意味着线程不会共享同一个锁对象,从而使数据不受保护。因此,只能使用引用类型作为锁对象。
  • 对于进程间通信,您可以使用全局互斥锁,它可以通过将非空传递nameMutex Constructor来创建。全局互斥锁提供与常规“本地”锁定基本相同的功能,不同之处在于它们可以在单独的进程之间共享。
  • 除了锁之外还有同步机制,例如信号量、条件变量、消息队列或原子操作。混合不同的同步机制时要小心。
  • 锁也表现为内存屏障,这在现代多核、多缓存 CPU 上越来越重要。这就是为什么您需要锁定读取数据而不仅仅是写入的部分原因。

(*)之所以称为“竞赛”,是因为并发线程正在“竞赛”对共享数据执行操作,而赢得该竞赛的人将决定操作的结果。所以结果取决于执行的时间,这在现代抢占式多任务操作系统上基本上是随机的。更糟糕的是,通过调试器等工具观察程序执行的简单行为很容易修改时序,这使它们成为“heisenbugs”(即,被观察的现象仅因观察行为而改变)。

于 2012-04-14T13:56:31.003 回答
1

对于那些刚刚熟悉lockC# 中的关键字的人来说,您的困惑是非常典型的。你是对的,lock语句中使用的对象实际上只不过是一个定义临界区的标记。该对象本身对多线程访问没有任何保护。

其工作方式是 CLR 在称为同步块的对象头(类型句柄)中保留一个 4 字节(32 位系统)部分。同步块只不过是存储实际临界区信息的数组的索引。当您使用lock关键字时,CLR 将相应地修改此同步块值。

这种方案有优点也有缺点。优点是它为定义关键部分提供了一个相当优雅的解决方案。一个明显的缺点是每个对象实例都包含同步块,并且大多数实例从不使用它,因此在大多数情况下这似乎是浪费空间。另一个缺点是可以使用装箱值类型,这几乎总是错误的并且肯定会导致混乱。

我记得早在 .NET 首次发布时,关于lock关键字对语言的好坏有很多讨论。普遍的共识(至少我记得它)是它很糟糕,因为using关键字本来可以很容易地使用。事实上,使用using关键字的解决方案实际上会更有意义,因为它可以在不需要同步块的情况下完成。c# 设计团队甚至公开表示,如果他们有第二次机会,lock关键字永远不会进入语言。1


1我能找到的唯一参考资料是 Jon Skeet 的网站

于 2012-04-15T02:34:20.193 回答
1

lock声明引入了互斥的概念。任何时候只有一个线程可以获取给定对象的锁。这可以防止线程同时访问共享数据结构,从而破坏它们。

如果其他线程已经持有锁,则 lock 语句将阻塞,直到它能够在其参数上获得排他锁,然后才允许其块执行。

请注意,唯一要做的lock就是控制代码块的入口。对类成员的访问与锁完全无关。由类本身来确保必须同步的访问通过使用lock或其他同步原语来协调。另请注意,对部分或所有成员的访问可能不必同步。例如,如果你想维护一个计数器,你可以使用Interlocked类而不加锁。


锁定的替代方案是无锁数据结构,它在存在多个线程的情况下表现正确。必须非常仔细地设计无锁数据结构上的操作,通常借助无锁原语,例如比较和交换 (CAS)。

这种技术的一般主题是尝试以原子方式对数据结构执行操作,并检测何时由于其他线程的并发操作而导致操作失败,然后重试。这适用于不太可能发生故障的轻负载系统,但随着故障率攀升和重试成为主要负载,可能会产生失控行为。这个问题可以通过降低重试率来改善,有效地限制负载。


一个更复杂的替代方案是软件事务内存。与 CAS 不同,STM 将失败和重试的概念推广到任意复杂的内存操作。简单来说,您启动一​​个事务,执行所有操作,最后提交。系统检测是否由于其他线程执行的冲突操作而导致操作无法成功,这些线程击败了当前线程。在这种情况下,STM 要么彻底失败,要求应用程序采取纠正措施,要么在更复杂的实现中,它可以自动回到事务的开始并重试。

于 2012-04-14T09:49:47.257 回答
1

当您有不同的线程同时访问相同的变量/资源时,它们可能会覆盖此变量/资源,并且您可能会得到意想不到的结果。锁定将确保只有一个线程可以按时评估变量,并且保持线程将排队以访问该变量/资源,直到锁定被释放

假设我们有一个账户的余额变量。两个不同的线程读取它的值为 100 假设第一个线程将 50 添加到它,如 100 + 50 并保存它并且余额将有 150 因为第二个线程已经读取了 100 并且平均时间。假设它像 100-50 一样减去 50,但这里要注意的是,第一个线程的余额为 150,所以第二个线程应该为 150-50,这可能会导致严重的问题。

所以 lock 确保当线程想要更​​改某些资源状态时,它会锁定它并在提交更改后离开

于 2012-04-14T09:50:08.697 回答
1

是的,确实还有另一种方式:

using System.Runtime.CompilerServices;

class Test
{
    private object Lock { get; set; }

    [MethodImpl(MethodImplOptions.Synchronized)]
    public void Foo()
    {
        // Now this instance is locked
    }
}

虽然它看起来更“自然”,但它并不经常使用,因为对象以这种方式锁定自身,因此其他代码无法冒险锁定该对象——它可能导致死锁。

因此,您通常会创建一个引用对象的(延迟初始化的)私有字段,并将该对象用作锁。这将保证没有其他人可以锁定与您相同的对象。


关于引擎盖下发生的事情的更多细节:

当您“锁定对象”时,您并没有锁定对象本身。相反,您在整个程序中将该对象用作保证唯一的内存地址。当您“锁定”时,运行时获取对象的地址,使用它在另一个表(对您隐藏)中查找实际锁定,并将对象用作“锁定”(也称为“临界区” ”)。

所以,对你来说,一个对象实际上只是一个代理/符号——它本身并没有做任何事情;它只是充当一个独特的指标,永远不会与同一程序中的另一个有效对象发生冲突。

于 2012-04-14T09:50:17.457 回答
1

Lock对象就像单人间的一扇门,每次只有一位客人可以进入。房间可以是你的数据,客人可以是你的功能

  • 定义数据(房间)
  • 添加门(锁定对象)
  • 邀请客人(功能)
  • 使用lock指令关闭/打开门,每次只允许一名客人进入房间。

为什么我们需要这个?如果您同时在文件中写入数据(只是一个示例,可以是其他 1000 个),您将需要将您的功能(为客人关闭/打开门)的访问同步到写入文件,因此任何功能都将附加到末尾文件的(假设这是本示例的要求)

这自然不仅是同步线程的方式,还有更多:

  • 监视器
  • 等等哈德勒...

查看链接以获取每个链接的完整信息和描述

线程同步

于 2012-04-14T09:51:25.093 回答