我读过关于锁的书,虽然什么都不懂。我的问题是为什么我们要使用未使用object
并锁定它,以及这如何使某些东西成为线程安全的,或者这如何有助于多线程?没有其他方法可以制作线程安全的代码。
public class test {
private object Lock { get; set; }
...
lock (this.Lock) { ... }
...
}
对不起,我的问题很愚蠢,但我不明白,虽然我已经用过很多次了。
我读过关于锁的书,虽然什么都不懂。我的问题是为什么我们要使用未使用object
并锁定它,以及这如何使某些东西成为线程安全的,或者这如何有助于多线程?没有其他方法可以制作线程安全的代码。
public class test {
private object Lock { get; set; }
...
lock (this.Lock) { ... }
...
}
对不起,我的问题很愚蠢,但我不明白,虽然我已经用过很多次了。
从一个线程访问一段数据而其他线程正在修改它被称为“数据竞争条件”(或只是“数据竞争”),并可能导致数据损坏。(*)
锁只是一种避免数据竞争的机制。如果两个(或更多)并发线程锁定同一个锁对象,则它们不再是并发的,并且在锁期间不再会导致数据竞争。本质上,我们正在序列化对共享数据的访问。
诀窍是保持你的锁尽可能“宽”以避免数据竞争,但尽可能“窄”以通过并发执行获得性能。这是一个很好的平衡,很容易在任一方向上失控,这就是多线程编程很难的原因。
一些指导方针:
this
或公共字段作为锁定对象通常是一个坏主意。lock
关键字只是Monitor.Enter和Monitor.Exit的更方便的语法。Monitor.Enter
,这意味着线程不会共享同一个锁对象,从而使数据不受保护。因此,只能使用引用类型作为锁对象。name
给Mutex Constructor来创建。全局互斥锁提供与常规“本地”锁定基本相同的功能,不同之处在于它们可以在单独的进程之间共享。(*)之所以称为“竞赛”,是因为并发线程正在“竞赛”对共享数据执行操作,而赢得该竞赛的人将决定操作的结果。所以结果取决于执行的时间,这在现代抢占式多任务操作系统上基本上是随机的。更糟糕的是,通过调试器等工具观察程序执行的简单行为很容易修改时序,这使它们成为“heisenbugs”(即,被观察的现象仅因观察行为而改变)。
对于那些刚刚熟悉lock
C# 中的关键字的人来说,您的困惑是非常典型的。你是对的,lock
语句中使用的对象实际上只不过是一个定义临界区的标记。该对象本身对多线程访问没有任何保护。
其工作方式是 CLR 在称为同步块的对象头(类型句柄)中保留一个 4 字节(32 位系统)部分。同步块只不过是存储实际临界区信息的数组的索引。当您使用lock
关键字时,CLR 将相应地修改此同步块值。
这种方案有优点也有缺点。优点是它为定义关键部分提供了一个相当优雅的解决方案。一个明显的缺点是每个对象实例都包含同步块,并且大多数实例从不使用它,因此在大多数情况下这似乎是浪费空间。另一个缺点是可以使用装箱值类型,这几乎总是错误的并且肯定会导致混乱。
我记得早在 .NET 首次发布时,关于lock
关键字对语言的好坏有很多讨论。普遍的共识(至少我记得它)是它很糟糕,因为using
关键字本来可以很容易地使用。事实上,使用using
关键字的解决方案实际上会更有意义,因为它可以在不需要同步块的情况下完成。c# 设计团队甚至公开表示,如果他们有第二次机会,lock
关键字永远不会进入语言。1
1我能找到的唯一参考资料是 Jon Skeet 的网站。
该lock
声明引入了互斥的概念。任何时候只有一个线程可以获取给定对象的锁。这可以防止线程同时访问共享数据结构,从而破坏它们。
如果其他线程已经持有锁,则 lock 语句将阻塞,直到它能够在其参数上获得排他锁,然后才允许其块执行。
请注意,唯一要做的lock
就是控制代码块的入口。对类成员的访问与锁完全无关。由类本身来确保必须同步的访问通过使用lock
或其他同步原语来协调。另请注意,对部分或所有成员的访问可能不必同步。例如,如果你想维护一个计数器,你可以使用Interlocked类而不加锁。
锁定的替代方案是无锁数据结构,它在存在多个线程的情况下表现正确。必须非常仔细地设计无锁数据结构上的操作,通常借助无锁原语,例如比较和交换 (CAS)。
这种技术的一般主题是尝试以原子方式对数据结构执行操作,并检测何时由于其他线程的并发操作而导致操作失败,然后重试。这适用于不太可能发生故障的轻负载系统,但随着故障率攀升和重试成为主要负载,可能会产生失控行为。这个问题可以通过降低重试率来改善,有效地限制负载。
一个更复杂的替代方案是软件事务内存。与 CAS 不同,STM 将失败和重试的概念推广到任意复杂的内存操作。简单来说,您启动一个事务,执行所有操作,最后提交。系统检测是否由于其他线程执行的冲突操作而导致操作无法成功,这些线程击败了当前线程。在这种情况下,STM 要么彻底失败,要求应用程序采取纠正措施,要么在更复杂的实现中,它可以自动回到事务的开始并重试。
当您有不同的线程同时访问相同的变量/资源时,它们可能会覆盖此变量/资源,并且您可能会得到意想不到的结果。锁定将确保只有一个线程可以按时评估变量,并且保持线程将排队以访问该变量/资源,直到锁定被释放
假设我们有一个账户的余额变量。两个不同的线程读取它的值为 100 假设第一个线程将 50 添加到它,如 100 + 50 并保存它并且余额将有 150 因为第二个线程已经读取了 100 并且平均时间。假设它像 100-50 一样减去 50,但这里要注意的是,第一个线程的余额为 150,所以第二个线程应该为 150-50,这可能会导致严重的问题。
所以 lock 确保当线程想要更改某些资源状态时,它会锁定它并在提交更改后离开
是的,确实还有另一种方式:
using System.Runtime.CompilerServices;
class Test
{
private object Lock { get; set; }
[MethodImpl(MethodImplOptions.Synchronized)]
public void Foo()
{
// Now this instance is locked
}
}
虽然它看起来更“自然”,但它并不经常使用,因为对象以这种方式锁定自身,因此其他代码无法冒险锁定该对象——它可能导致死锁。
因此,您通常会创建一个引用对象的(延迟初始化的)私有字段,并将该对象用作锁。这将保证没有其他人可以锁定与您相同的对象。
关于引擎盖下发生的事情的更多细节:
当您“锁定对象”时,您并没有锁定对象本身。相反,您在整个程序中将该对象用作保证唯一的内存地址。当您“锁定”时,运行时获取对象的地址,使用它在另一个表(对您隐藏)中查找实际锁定,并将该对象用作“锁定”(也称为“临界区” ”)。
所以,对你来说,一个对象实际上只是一个代理/符号——它本身并没有做任何事情;它只是充当一个独特的指标,永远不会与同一程序中的另一个有效对象发生冲突。
Lock
对象就像单人间的一扇门,每次只有一位客人可以进入。房间可以是你的数据,客人可以是你的功能。
lock
指令关闭/打开门,每次只允许一名客人进入房间。为什么我们需要这个?如果您同时在文件中写入数据(只是一个示例,可以是其他 1000 个),您将需要将您的功能(为客人关闭/打开门)的访问同步到写入文件,因此任何功能都将附加到末尾文件的(假设这是本示例的要求)
这自然不仅是同步线程的方式,还有更多:
查看链接以获取每个链接的完整信息和描述