多线程概念并锁定 c#

本文关键字:锁定 多线程 | 更新日期: 2023-09-27 18:31:17

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

public class test {
    private object Lock { get; set; }
    ...
    lock (this.Lock) { ... }
    ...
}

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

多线程概念并锁定 c#

从一个线程访问一段数据,而另一个线程正在修改它被称为"数据争用条件"(或简称"数据争用"),并可能导致数据损坏。(*)

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

诀窍是保持锁尽可能"宽"以避免数据争用,同时尽可能"窄",以便通过并发执行获得性能。这是一个很好的平衡,很容易在任何一个方向上失衡,这就是为什么多线程编程很难的原因。

一些准则:

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

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

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

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

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

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

  • 显示器
  • 等待哈德勒斯...

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

线程同步

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

using System.Runtime.CompilerServices;
class Test
{
    private object Lock { get; set; }
    [MethodImpl(MethodImplOptions.Synchronized)]
    public void Foo()
    {
        // Now this instance is locked
    }
}

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

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


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

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

所以真的,对你来说,一个对象只是一个代理/符号——它本身什么都不做;它只是作为一个独特的指标,永远不会与同一程序中的另一个有效对象发生冲突。

当您有不同的线程同时访问同一变量/资源时,它们可能会过度写入此变量/资源,并且您可能会产生意外的结果。Lock 将确保只有一个线程可以按时评估变量,并且保持线程将排队访问此变量/资源,直到释放锁

假设我们有一个帐户的余额变量。两个不同的线程读取其值为 100假设第一个线程像 100 + 50 一样将 50 加到它并保存它,余额将有 150由于第二个线程已经读取 100 并表示 while。假设它像 50 一样减去 100-50,但这里要注意的是,第一个线程使平衡为 150,因此第二个线程应该为 150-50,这可能会导致严重的问题。

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

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

如果其他线程已经持有锁,则 lock 语句将阻塞,直到它能够在允许其块执行之前获取其参数的独占锁。

请注意,lock唯一要做的就是控制代码块的入口。对类成员的访问与锁完全无关。由类本身来确保必须同步的访问通过使用lock或其他同步基元进行协调。另请注意,对部分或所有成员的访问可能不必同步。例如,如果要维护计数器,则可以在不锁定的情况下使用互锁类。


锁定的替代方法是无锁数据结构,它在存在多个线程时行为正确。对无锁数据结构的操作必须非常仔细地设计,通常在无锁基元(如比较交换 (CAS))的帮助下。

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


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

对于那些

刚刚熟悉 C# 中的 lock 关键字的人来说,您的困惑非常典型。你是对的,lock语句中使用的对象实际上只不过是定义关键部分的标记。该对象绝不具有对多线程访问本身的任何保护。

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

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

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


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