优秀的编程知识分享平台

网站首页 > 技术文章 正文

c# 10 教程:21 高级线程编程(c#线程用法)

nanyue 2024-10-25 13:19:53 技术文章 2 ℃


我们从线程的基础知识开始第 ,作为任务和异步的前身。具体来说,我们展示了如何启动和配置线程,并介绍了线程池、阻塞、旋转和同步上下文等基本概念。我们还介绍了锁定和线程安全,并演示了最简单的信令结构 ManualResetEvent 。

本章继续第 关于线程的主题。在前三节中,我们将更详细地充实同步、锁定和线程安全。然后,我们将介绍:

  • 非独占锁定(信号灯和读/写器锁定)
  • 所有信令结构(自动复位事件、手动复位事件、倒计时事件和屏障)
  • Lazy initialization ( Lazy<T> and LazyInitializer )
  • 线程本地存储(ThreadStaticAttribute 、ThreadLocal<T> 和 GetData / SetData )
  • 定时器

线程是一个如此广泛的话题,我们已经将其他材料放在网上来完成图片。访问 ,讨论以下更晦涩难懂的主题:

  • Monitor.Wait and Monitor.Pulse 用于专用信令场景
  • 用于微优化的非阻塞同步技术(互锁,内存屏障,易失性)
  • SpinLock 和 SpinWait 适用于高并发场景

同步概述

是协调并发操作以获得可预测结果的行为。当多个线程访问相同的数据时,同步尤为重要;在这个地区搁浅非常容易。

最简单和最有用的同步工具可以说是中描述的延续和任务组合器。通过将并发程序制定为与延续器和组合器串在一起的异步操作,您可以减少对锁定和信令的需求。但是,有时较低级别的构造仍然会发挥作用。

同步构造可分为三类:

独家锁定

独占锁定构造一次只允许一个线程执行某些活动或执行一段代码。它们的主要目的是让线程访问共享写入状态,而不会相互干扰。独占锁定结构是锁、互斥锁和旋转锁。

非排他性锁定

非独占锁定允许您并发性。非独占锁定结构是信号量(Slim)和ReaderWriterLock(Slim)。

信号

这些允许线程阻塞,直到收到来自其他线程的一个或多个通知。信令结构包括手动重置事件(Slim)、自动重置事件、倒计时事件和屏障。前三个称为。

也可以(并且很棘手)通过使用在不锁定的情况下对共享状态执行某些并发操作。这些是 Thread.MemoryBarrier 、Thread.volatileRead 、Thread.VolatileWrite、volatile 关键字和 Interlock 类。我们,以及监视器的等待/脉冲方法,您可以使用它们来编写自定义信令逻辑。

独家锁定

有三种独占锁定结构:lock 语句、Mutex 和 SpinLock。锁结构是最方便和广泛使用的,而其他两个目标利基场景:

  • 互斥允许您跨越多个进程(计算机范围的锁)。
  • SpinLock 实现了微优化,可以减少高并发方案中的上下文切换(请参阅 )。

锁定声明

为了说明锁定的必要性,请考虑以下类:

class ThreadUnsafe
{
  static int _val1 = 1, _val2 = 1;

  static void Go()
  {
    if (_val2 != 0) Console.WriteLine (_val1 / _val2);
    _val2 = 0;
  }
}

这个类不是线程安全的:如果两个线程同时调用 Go,则可能会得到被零除错误,因为_val2可以在一个线程中设置为零,因为另一个线程介于执行 if 语句和 Console.WriteLine 之间。以下是锁定解决问题的方法:

class ThreadSafe
{
  static readonly object _locker = new object();
  static int _val1 = 1, _val2 = 1;

  static void Go()
  {
    lock (_locker)
    {
      if (_val2 != 0) Console.WriteLine (_val1 / _val2);
      _val2 = 0;
    }
  }
}

一次只有一个线程可以锁定同步对象(在本例中为 _locker),并且在释放锁之前,任何争用线程都将被阻止。如果多个线程争用锁,它们将在“就绪队列”上排队,并以先到先得的方式授予锁。1有时说独占锁强制对受锁保护的任何内容进行访问,因为一个线程的访问不能与另一个线程的访问重叠。在本例中,我们将保护 Go 方法内部的逻辑以及字段_val1和_val2。

监视。进入和监视。退出

C# 的 lock 语句实际上是调用方法 Monitor.Enter 和 Monitor.Exit 的语法快捷方式,带有 try / finally 块。下面是前面示例的 Go 方法中实际发生的情况(简化版本):

Monitor.Enter (_locker);
try
{
  if (_val2 != 0) Console.WriteLine (_val1 / _val2);
  _val2 = 0;
}
finally { Monitor.Exit (_locker); }

在同一对象上调用 Monitor。退出而不先调用 Monitor.Enter 会引发异常。

锁已取重载

我们刚刚演示的代码有一个微妙的漏洞。考虑在调用 Monitor.Enter 和 try 块之间引发异常的(不太可能的)事件(可能是由于 OutOfMemoryException,或者在 .NET Framework 中,如果线程中止)。在这种情况下,可能会也可能不会使用锁定。如果锁定占用,它不会被释放 - 因为我们永远不会进入 阻止。这将导致锁泄漏。为了避免这种危险, 定义了以下重载:

public static void Enter (object obj, ref bool lockTaken);

lockTaken 在此方法之后为 false,如果(且仅当)Enter 方法引发异常并且未采用锁定。

下面是更可靠的使用模式(这正是 C# 翻译锁定语句的方式):

bool lockTaken = false;
try
{
  Monitor.Enter (_locker, ref lockTaken);
  // Do your stuff...
}
finally { if (lockTaken) Monitor.Exit (_locker); }

尝试输入

监视器还提供了 TryEnter 方法,该方法允许以毫秒为单位或以 TimeSpan 为单位指定超时。然后,如果获取了锁,则该方法返回 true;如果由于方法超时而未获得锁,则返回 false。也可以在没有参数的情况下调用 TryEnter,这会“测试”锁,如果无法立即获取锁,则会立即超时。与 Enter 方法一样,TryEnter 被重载以接受 lockTaken 参数。

选择同步对象

您可以将每个参与线程可见的任何对象用作同步对象,但要遵守一条硬性规则:它必须是引用类型。同步对象通常是私有的(因为这有助于封装锁定逻辑),并且通常是实例或静态字段。同步对象可以兼作它所保护的对象,如以下示例中的_list字段所示:

class ThreadSafe
{
  List <string> _list = new List <string>();

  void Test()
  {
    lock (_list)
    {
      _list.Add ("Item 1");
      ...

专用于锁定的字段(例如前面示例中的 _locker)允许精确控制锁定的范围和粒度。您也可以使用包含对象 ( this ) 作为同步对象:

lock (this) { ... }

甚至它的类型:

lock (typeof (Widget)) { ... }    // For protecting access to statics

以这种方式锁定的缺点是你没有封装锁定逻辑,因此防止死锁和过度阻塞变得更加困难。

您还可以锁定由 lambda 表达式或匿名方法捕获的局部变量。

注意

锁定不会以任何方式限制对同步对象本身的访问。换句话说,x.ToString() 不会阻塞,因为另一个线程调用了 lock(x) ;两个线程都必须调用 lock(x) 才能发生阻塞。

何时锁定

作为基本规则,您需要锁定访问。即使在最简单的情况下(对单个字段执行赋值操作),也必须考虑同步。在下面的类中,增量和赋值方法都不是线程安全的:

class ThreadUnsafe
{
  static int _x;
  static void Increment() { _x++; }
  static void Assign()    { _x = 123; }
}

以下是增量和分配的线程安全版本:

static readonly object _locker = new object();
static int _x;

static void Increment() { lock (_locker) _x++; }
static void Assign()    { lock (_locker) _x = 123; }

如果没有锁,可能会出现两个问题:

  • 诸如递增变量(甚至在某些情况下读取/写入变量)之类的操作不是原子操作。
  • 编译器、CLR 和处理器有权对 CPU 寄存器中的指令和进行重新排序以提高性能,只要此类优化不会更改线程程序(或使用锁的多线程程序)的行为。

锁定可以缓解第二个问题,因为它会在锁定之前和之后创建。内存屏障是一个“围栏”,重新排序和缓存的效果无法穿透它。

注意

这不仅适用于锁,而且适用于所有同步构造。因此,例如,如果您使用构造,确保一次只有一个线程读取/写入变量,则无需锁定。因此,以下代码是线程安全的,无需锁定 x :

var signal = new ManualResetEvent (false);
int x = 0;
new Thread (() => { x++; signal.Set(); }).Start();
signal.WaitOne();
Console.WriteLine (x);    // 1 (always)

在中,我们解释了这种需求是如何产生的,以及内存屏障和互锁类如何在这些情况下提供锁定的替代方案。

锁定和原子性

如果一组变量总是在同一锁中读取和写入,则可以说变量是以和写入的。假设字段 x 和 y 总是在对象锁上的锁中读取和分配:

lock (locker) { if (x != 0) y /= x; }

我们可以说 x 和 y 是原子访问的,因为代码块不能被另一个线程的操作分割或抢占,以至于它会改变 x 或 y 并。您永远不会收到除以零的错误,前提是 x 和 y 始终在同一独占锁中访问。

注意

如果在锁块中引发异常(无论是否涉及多线程),则会违反锁提供的原子性。例如,请考虑以下事项:

decimal _savingsBalance, _checkBalance;

void Transfer (decimal amount)
{
  lock (_locker)
  {
    _savingsBalance += amount;
    _checkBalance -= amount + GetBankFee();
  }
}

如果 GetBankFee() 抛出异常,银行将亏损。在这种情况下,我们可以通过更早地调用GetBankFee来避免这个问题。对于更复杂的情况,解决方案是在捕获或最终块中实现“回滚”逻辑。

指令原子性是一个不同的概念,尽管类似:如果指令在底层处理器上不可分割地执行,则该是原子的。

嵌套锁定

线程可以以嵌套()方式重复锁定同一对象:

lock (locker)
  lock (locker)
    lock (locker)
    {
       // Do something...
    }

或者:

Monitor.Enter (locker); Monitor.Enter (locker);  Monitor.Enter (locker); 
// Do something...
Monitor.Exit (locker);  Monitor.Exit (locker);   Monitor.Exit (locker);

在这些情况下,仅当最外层的锁定语句退出或执行了匹配数量的 Monitor.Exit 语句时,才会解锁对象。

当一种方法从锁内调用另一个方法时,嵌套锁定很有用:

object locker = new object();

lock (locker)
{
  AnotherMethod();
  // We still have the lock - because locks are reentrant.
}

void AnotherMethod()
{
  lock (locker) { Console.WriteLine ("Another method"); }
}

线程只能在第一个(最外层)锁上阻塞。

僵局

当两个线程各自等待另一个线程持有的资源时,就会发生死锁,因此两者都无法继续。说明这一点的最简单方法是使用两个锁:

object locker1 = new object();
object locker2 = new object();

new Thread (() => {
                    lock (locker1)
                    {
                      Thread.Sleep (1000);
                      lock (locker2);      // Deadlock
                    }
                  }).Start();
lock (locker2)
{
  Thread.Sleep (1000);
  lock (locker1);                          // Deadlock
}

您可以使用三个或更多线程创建更复杂的死锁链。

注意

在标准宿主环境中,CLR 与 SQL Server 不同,不会通过终止其中一个违规者来自动检测和解决死锁。线程死锁会导致参与线程无限期阻塞,除非您指定了锁定超时。(但是,在 SQL CLR 集成主机下,检测到死锁,并在其中一个线程上引发 [可捕获] 异常。

死锁是多线程处理中最困难的问题之一,尤其是当存在许多相互关联的对象时。从根本上说,困难的问题是您无法确定取出了哪些锁。

因此,您可能会在类 x 中锁定私人字段 a,而不知道您的调用方(或调用方的调用方)已经锁定了类 y 中的字段 b。与此同时,另一个线程正在做相反的事情 - 创建死锁。具有讽刺意味的是,(好的)面向对象设计模式加剧了这个问题,因为此类模式创建的调用链直到运行时才确定。

流行的建议“以一致的顺序锁定对象以防止死锁”虽然在我们的初始示例中很有帮助,但很难应用于刚才描述的方案。更好的策略是警惕锁定对象中对方法的调用,这些方法可能引用回您自己的对象。此外,考虑是否真的需要锁定对其他类中方法的调用(通常你会这样做——正如中看到的那样——但有时还有其他选项)。更多地依赖更高级别的同步选项,如任务延续/组合器、数据并行性和不可变类型(本章后面)可以减少对锁定的需求。

注意

这是感知问题的另一种方法:当您在握住锁时调用其他代码时,该锁的封装会微妙地。这不是 CLR 中的错误;一般来说,这是锁定的基本限制。锁定问题正在各种研究项目中得到解决,包括。

在拥有锁的情况下调用 Dispatcher.Invoke(在 WPF 应用程序中)或 Control.Invoke(在 Windows 窗体应用程序中)时,会出现另一种死锁情况。如果用户界面碰巧正在运行另一个正在等待同一锁的方法,则死锁将在那里发生。通常,只需调用 BeginInvoke 而不是 Invoke(或依赖于在存在同步上下文时隐式执行此操作的异步函数)即可解决此问题。或者,您可以在调用 Invoke 之前释放锁,尽管如果您的取出锁,这将不起作用。

性能

锁定速度很快:如果锁没有争用,您可以在 20 年代的计算机上在不到 2020 纳秒的时间内获取和释放锁。如果发生争用,则相应的上下文切换会将开销移近微秒区域,尽管在实际重新调度线程之前可能会更长。

互斥体

互斥锁类似于C#锁,但它可以跨多个进程工作。换句话说,互斥可以是范围的,也可以是。获取和释放无争议的互斥体大约需要半微秒,比锁慢 20 多倍。

使用互斥类,可以调用 WaitOne 方法进行锁定,并调用 ReleaseMutex 进行解锁。与 lock 语句一样,互斥体只能从获取它的同一线程中释放。

注意

如果你忘记调用 ReleaseMutex 而只是调用 Close 或 Dispose,一个废弃的互斥例外将被抛给等待该互斥锁的其他人。

跨进程互斥体的常见用途是确保一次只能运行一个程序实例。这是它是如何完成的:

// Naming a Mutex makes it available computer-wide. Use a name that's
// unique to your company and application (e.g., include your URL).

using var mutex = new Mutex (true, @"Global\oreilly.com OneAtATimeDemo");
// Wait a few seconds if contended, in case another instance
// of the program is still in the process of shutting down.

if (!mutex.WaitOne (TimeSpan.FromSeconds (3), false))
{
  Console.WriteLine ("Another instance of the app is running. Bye!");
  return;
}
try { RunProgram(); }
finally { mutex.ReleaseMutex (); }

void RunProgram()
{
  Console.WriteLine ("Running. Press Enter to exit");
  Console.ReadLine();
}

注意

如果在终端服务下或在单独的 Unix 控制台中运行,则计算机范围的互斥体通常仅对同一会话中的应用程序可见。若要使其对所有终端服务器会话可见,请在其名称前面加上 ,如示例中所示。

锁定和螺纹安全

如果程序或方法可以在任何多线程方案中正常工作,则它是线程安全的。线程安全主要通过锁定和减少线程交互的可能性来实现。

常规用途类型很少完全是线程安全的,

  • 全线程安全中的开发负担可能很大,特别是如果类型具有许多字段(每个字段在任意多线程上下文中都有交互的可能性)。
  • 线程安全可能会带来性能成本(无论该类型是否实际由多个线程使用,都应支付部分费用)。
  • 线程安全类型不一定使使用它的程序成为线程安全的,后者所涉及的工作通常使前者变得多余。

因此,线程安全通常只在需要的地方实现,以便处理特定的多线程方案。

但是,有几种方法可以“作弊”,并使大型复杂类在多线程环境中安全运行。一种是通过将大部分代码(甚至访问整个对象)包装在单个独占锁中来牺牲粒度,从而在高级别强制执行序列化访问。事实上,如果要在多线程上下文中使用线程不安全的第三方代码(或大多数 .NET 类型),则此策略是必不可少的。诀窍只是使用相同的独占锁来保护对线程不安全对象上所有属性、方法和字段的访问。如果对象的方法都快速执行,则解决方案效果很好(否则,会有很多阻塞)。

注意

撇开基元类型不谈,很少有 .NET 类型在实例化时,除了并发只读访问之外,对线程安全。开发人员有责任叠加线程安全性,通常使用独占锁。(我们在中介绍的 System.Collections.Concurrent 中的集合是一个例外。

作弊的另一种方法是通过最小化共享数据来最小化线程交互。这是一个很好的方法,隐式用于“无状态”中间层应用程序和网页服务器。由于多个客户端请求可以同时到达,因此它们调用的服务器方法必须是线程安全的。无状态设计(由于可伸缩性的原因而流行)本质上限制了交互的可能性,因为类不会在请求之间保存数据。然后,线程交互仅限于您可能选择创建的静态字段,用于在内存中缓存常用数据以及提供基础结构服务(如身份验证和审核)等目的。

另一种解决方案(在胖客户端应用程序中)是在 UI 线程上运行访问共享状态的代码。正如我们在第 中看到的,异步函数使这变得容易。

线程安全和 .NET 类型

可以使用锁定将线程不安全代码转换为线程安全代码。.NET 是一个很好的应用:几乎所有的非基元类型在实例化时都不是线程安全的(除了只读访问之外的任何内容),但如果对任何给定对象的所有访问都通过锁进行保护,则可以在多线程代码中使用它们。下面是两个线程同时将项添加到同一 List 集合,然后枚举该列表的示例:

class ThreadSafe
{
  static List <string> _list = new List <string>();

  static void Main()
  {
    new Thread (AddItem).Start();
    new Thread (AddItem).Start();
  }

  static void AddItem()
  {
    lock (_list) _list.Add ("Item " + _list.Count);

    string[] items;
    lock (_list) items = _list.ToArray();
    foreach (string s in items) Console.WriteLine (s);
  }
}

在本例中,我们将锁定_list对象本身。如果我们有两个相互关联的列表,我们需要选择一个要锁定的公共对象(我们可以指定其中一个列表,或者更好的是:使用一个独立的字段)。

枚举 .NET 集合也是线程不安全的,因为如果在枚举期间修改列表,则会引发异常。在此示例中,我们首先将项目复制到数组中,而不是在枚举期间锁定。这样可以避免在枚举期间执行的操作可能非常耗时时过度持有锁。(另一种解决方案是使用读取器/写入器锁;请参阅

锁定线程安全对象

有时,您还需要锁定访问线程安全对象。为了说明这一点,想象一下.NET 的 List 类确实是线程安全的,我们希望将一个项目添加到列表中:

if (!_list.Contains (newItem)) _list.Add (newItem);

无论列表是否线程安全,此声明肯定不是!整个 if 语句需要包装在锁中,以防止在测试集装箱船和添加新项目之间抢占。然后,在我们修改该列表的任何地方都需要使用相同的锁。例如,以下语句也需要包装在相同的锁中,以确保它不会抢占前一个语句:

_list.Clear();

换句话说,我们需要完全像线程不安全的集合类一样锁定(使 List 类的假设线程安全冗余)。

注意

在高并发环境中,锁定访问集合可能会导致过度阻塞。为此,.NET 提供了一个线程安全的队列、堆栈和字典,我们将在第 中讨论。

静态成员

仅当所有并发线程都知道并使用锁时,才可以围绕自定义锁包装对对象的访问。如果对象的作用域较广,则情况可能并非如此。最坏的情况是公共类型中的静态成员。例如,假设 DateTime 结构 DateTime.Now 上的静态属性不是线程安全的,并且两个并发调用可能会导致乱码输出或异常。使用外部锁定解决此问题的唯一方法可能是在调用 DateTime.Now 之前锁定类型本身 — lock(typeof(DateTime)) 。这只有在所有程序员都同意这样做的情况下才会起作用(这不太可能)。此外,锁定类型会产生其自身的问题。

因此,DateTime 结构上的静态成员已经过精心编程,使其是线程安全的。这是整个 .NET 中的常见模式:静态成员在编写供公众使用的类型时,遵循此模式也是有意义的,以免创建不可能的线程安全难题。换句话说,通过使静态方法成为线程安全的,您正在编程,以免该类型使用者的线程安全。

注意

静态方法中的线程安全是您必须显式编码的东西:由于方法是静态的,它不会自动发生!

只读线程安全

使类型对并发只读访问(如果可能)是线程安全的,因为这意味着使用者可以避免过度锁定。许多 .NET 类型遵循此原则:例如,集合对于并发读取器是线程安全的。

遵循此原则很简单:如果将某个类型记录为线程安全的并发只读访问,则不要写入使用者期望为只读的方法中的字段(或锁定这样做)。例如,在集合中实现 ToArray() 方法时,可以从压缩集合的内部结构开始。但是,对于希望这是只读的使用者来说,这将使线程不安全。

只读线程安全是枚举器与“可枚举项”分开的原因之一:两个线程可以同时枚举一个集合,因为每个线程都获取一个单独的枚举器对象。

注意

在没有文档的情况下,在假设方法本质上是否是只读时要谨慎。一个很好的例子是 Random 类:当你调用 Random.Next() 时,它的内部实现要求它更新私有种子值。因此,必须使用 Random 类进行锁定,或者为每个线程维护一个单独的实例。

应用程序服务器中的线程安全

应用程序服务器需要多线程来处理并发的客户端请求。ASP.NET 核心和 Web API 应用程序都是隐式多线程的。这意味着,在服务器端编写代码时,如果处理客户端请求的线程之间可能存在交互,则必须考虑线程安全性。幸运的是,这种可能性很少见;典型的服务器类要么是无状态的(无字段),要么具有为每个客户端或每个请求创建单独对象实例的激活模型。交互通常仅通过静态字段产生,有时用于在数据库的内存部分中缓存以提高性能。

例如,假设您有一个查询数据库的 RetrieveUser 方法:

// User is a custom class with fields for user data
internal User RetrieveUser (int id) { ... }

如果经常调用此方法,则可以通过在静态字典中缓存结果来提高性能。下面是一个概念上简单的解决方案,它考虑了线程安全性:

static class UserCache
{
  static Dictionary <int, User> _users = new Dictionary <int, User>();

  internal static User GetUser (int id)
  {
    User u = null;

    lock (_users)
      if (_users.TryGetValue (id, out u))
        return u;

    u = RetrieveUser (id);           // Method to retrieve from database;
    lock (_users) _users [id] = u;
    return u;
  }
}

我们至少必须锁定阅读和更新字典以确保线程安全。在此示例中,我们在锁定的简单性和性能之间选择了实际折衷方案。我们的设计造成了效率低下的小可能性:如果两个线程同时使用相同的以前未检索的 id 调用此方法,则 RetrieveUser 方法将被调用两次,并且字典将不必要地更新。在整个方法中锁定一次可以防止这种情况,但它会造成更糟糕的低效率:整个缓存将在调用 RetrieveUser 期间被锁定,在此期间,其他线程将在检索用户时被阻止。

对于理想的解决方案,我们需要使用我们在中描述的策略。我们不是缓存 User ,而是缓存 Task<User> ,然后调用者等待:

static class UserCache
{
  static Dictionary <int, Task<User>> _userTasks = 
     new Dictionary <int, Task<User>>();
  
  internal static Task<User> GetUserAsync (int id)
  {
    lock (_userTasks)
      if (_userTasks.TryGetValue (id, out var userTask))
        return userTask;
      else
        return _userTasks [id] = Task.Run (() => RetrieveUser (id));
  }
}

请注意,我们现在有一个涵盖整个方法逻辑的锁。我们可以在不损害并发性的情况下做到这一点,因为我们在锁内所做的只是访问字典并(可能)异步操作(通过调用 Task.Run)。如果两个线程使用相同的 ID 同时调用此方法,它们最终都会等待,这正是我们想要的结果。

不可变对象

不可变对象是其状态无法更改的对象 - 外部或内部。不可变对象中的字段通常声明为只读,并在构造期间完全初始化。

不变性是函数式编程的标志,在函数式编程中,您不是对象,而是创建具有不同属性的新对象。LINQ 遵循此范例。不变性在多线程中也很有价值,因为它通过消除(或最小化)可写状态来避免共享可写状态的问题。

一种模式是使用不可变对象来封装一组相关字段,以最大程度地减少锁定持续时间。举一个非常简单的例子,假设我们有两个字段,如下所示:

int _percentComplete;
string _statusMessage;

现在让我们假设我们想以原子方式读取和写入它们。与其锁定这些字段,我们可以定义以下不可变类:

class ProgressStatus    // Represents progress of some activity
{
  public readonly int PercentComplete;
  public readonly string StatusMessage;

  // This class might have many more fields...

  public ProgressStatus (int percentComplete, string statusMessage)
  {
    PercentComplete = percentComplete;
    StatusMessage = statusMessage;
  }
}

然后我们可以定义该类型的单个字段,以及一个锁定对象:

readonly object _statusLocker = new object();
ProgressStatus _status;

现在,我们可以读取和写入该类型的值,而无需为多个赋值保留锁:

var status = new ProgressStatus (50, "Working on it");
// Imagine we were assigning many more fields...
// ...
lock (_statusLocker) _status = status;    // Very brief lock

要读取对象,我们首先获取对象引用的副本(在锁内)。然后,我们可以读取其值而无需按住锁:

ProgressStatus status;
lock (_statusLocker) status = _status;   // Again, a brief lock
int pc = status.PercentComplete;
string msg = status.StatusMessage;
...

非排他性锁定

非独占锁定构造用于并发性。在本节中,我们将介绍信号量和读/写器锁,并说明 SemaphoreSlim 类如何限制异步操作的并发性。

信号

信号灯就像一个夜总会:它有一定的容量,由保镖强制执行。当俱乐部满员时,没有更多的人可以进入,外面排起了长队。然后,对于每个离开的人,有一个人进入。构造函数至少需要两个参数:夜总会当前可用的位置数和俱乐部的总容量。

容量为 1 的信号量类似于互斥锁或锁,只是信号量没有“所有者”——它与。任何线程都可以在信号量上调用释放,而对于互斥锁和锁,只有获得锁的线程才能释放它。

注意

此类有两个功能相似的版本:信号量和信号量Slim。后者已经过优化,可满足并行编程的低延迟需求。它在传统的多线程中也很有用,因为它允许您在等待时指定取消令牌(请参阅中的),并且它公开了用于异步编程的 WaitAsync 方法。但是,不能将其用于进程间信号。

信号量在调用 WaitOne 和 Release 时会产生大约一微秒;信号量Slim约占其中的十分之一。

信号量可用于限制并发性,防止太多线程同时执行特定代码段。在以下示例中,五个线程尝试进入一次只允许三个线程进入的夜总会:

class TheClub      // No door lists!
{
  static SemaphoreSlim _sem = new SemaphoreSlim (3);    // Capacity of 3
 
  static void Main()
  {
    for (int i = 1; i <= 5; i++) new Thread (Enter).Start (i);
  }

  static void Enter (object id)
  {
    Console.WriteLine (id + " wants to enter");
    _sem.Wait();
    Console.WriteLine (id + " is in!");           // Only three threads
    Thread.Sleep (1000 * (int) id);               // can be here at
    Console.WriteLine (id + " is leaving");       // a time.
    _sem.Release();
  }
}

1 wants to enter
1 is in!
2 wants to enter
2 is in!
3 wants to enter
3 is in!
4 wants to enter
5 wants to enter
1 is leaving
4 is in!
2 is leaving
5 is in!

信号量,如果命名,可以像互斥体一样跨越进程(名为信号量仅在Windows上可用,而命名的互斥体也适用于Unix平台)。

异步信号量和锁

锁定 await 语句是非法的:

lock (_locker)
{
  await Task.Delay (1000);    // Compilation error
  ...
}

这样做是没有意义的,因为锁由线程持有,线程通常在从等待返回时发生变化。锁定也会,而阻塞可能很长的时间正是你用异步函数实现的。

但是,有时仍然需要使异步操作按顺序执行,或者限制并行性,以便一次执行的操作不超过 个。例如,考虑一个 Web 浏览器:它需要并行执行异步下载,但它可能希望施加一个限制,以便一次最多进行 10 次下载。我们可以通过使用信号量苗来实现这一点:

SemaphoreSlim _semaphore = new SemaphoreSlim (10);

async Task<byte[]> DownloadWithSemaphoreAsync (string uri)
{
    await _semaphore.WaitAsync();
    try { return await new WebClient().DownloadDataTaskAsync (uri); }
    finally { _semaphore.Release(); }
}

将信号灯的 initialCount 减少到 1 会将最大并行度降低到 1,从而将其转换为异步锁。

编写输入异步扩展方法

以下扩展方法通过使用我们在中编写的一次性类简化了 SemaphoreSlim 的异步使用:

public static async Task<IDisposable> EnterAsync (this SemaphoreSlim ss)
{
  await ss.WaitAsync().ConfigureAwait (false);
  return Disposable.Create (() => ss.Release());
}

使用此方法,我们可以重写我们的 DownloadWithSemaphoreAsync :

async Task<byte[]> DownloadWithSemaphoreAsync (string uri)
{
  using (await _semaphore.EnterAsync())
    return await new WebClient().DownloadDataTaskAsync (uri);
}

Parallel.ForEachAsync

从 .NET 6 开始,限制异步并发的另一种方法是使用 Parallel.ForEachAsync 方法。假设 uri 位于要下载的 URI 数组中,下面介绍了如何并行下载它们,同时将并发性限制为最多 10 次并行下载:

await Parallel.ForEachAsync (uris,
  new ParallelOptions { MaxDegreeOfParallelism = 10 },
  async (uri, cancelToken) =>
   {
    var download = await new HttpClient().GetByteArrayAsync (uri);
    Console.WriteLine (#34;Downloaded {download.Length} bytes");
  });

并行类中的其他方法适用于(计算绑定的)并行编程方案,我们将在第 中介绍。

读/写器锁

通常,某种类型的实例对于并发读取操作是线程安全的,但对于并发更新(或对于并发读取和更新)则不是线程安全的。对于文件等资源也是如此。尽管使用适用于所有访问模式的简单独占锁保护此类类型的实例通常可以解决问题,但如果有许多读取器和偶尔更新,则可能会不合理地限制并发性。在业务应用程序服务器中,可能会发生这种情况的一个示例,该服务器缓存常用数据以便在静态字段中快速检索。类旨在在此提供最大可用性锁定。

注意

ReaderWriterLockSlim是旧的“胖”ReaderWriterLock类的替代品。后者在功能上相似,但它的速度要慢几倍,并且在其处理锁升级的机制中存在固有的设计缺陷。

与普通锁(监视器.进入/退出)相比,ReaderWriterLockSlim仍然慢两倍。权衡是减少争用(当有大量阅读和最少的写作时)。

对于这两个类,有两种基本的锁类型:读锁定和写锁定:

  • 写锁定是通用独占的。
  • 读锁定与其他读锁定兼容。

因此,持有写锁的线程会阻止所有其他尝试获取读写锁的线程(反之亦然)。但是,如果没有线程持有写锁定,则任意数量的线程可以同时获得读锁定。

ReaderWriterLockSlim定义了以下获取和释放读/写锁的方法:

public void EnterReadLock();
public void ExitReadLock();
public void EnterWriteLock();
public void ExitWriteLock();

此外,所有方法都有“Try”版本,它们接受 Monitor.TryEnter 样式的超时参数(如果资源严重争用,则很容易发生超时)。ReaderWriterLock 提供了类似的方法,命名为 和 。如果发生超时,它们会抛出应用程序异常,而不是返回 false 。EnterXXXAcquireXXXReleaseXXX

以下程序演示了 ReaderWriterLockSlim 。三个线程连续枚举一个列表,而另外两个线程每 100 毫秒向列表追加一个随机数。读锁定保护列表读取器,写锁定保护列表编写器:

class SlimDemo
{
  static ReaderWriterLockSlim _rw = new ReaderWriterLockSlim();
  static List<int> _items = new List<int>();
  static Random _rand = new Random();

  static void Main()
  {
    new Thread (Read).Start();
    new Thread (Read).Start();
    new Thread (Read).Start();

    new Thread (Write).Start ("A");
    new Thread (Write).Start ("B");
  }

  static void Read()
  {
    while (true)
    {
      _rw.EnterReadLock();
      foreach (int i in _items) Thread.Sleep (10);
      _rw.ExitReadLock();
    }
  }

  static void Write (object threadID)
  {
    while (true)
    {
      int newNumber = GetRandNum (100);
      _rw.EnterWriteLock();
      _items.Add (newNumber);
      _rw.ExitWriteLock();
      Console.WriteLine ("Thread " + threadID + " added " + newNumber);
      Thread.Sleep (100);
    }
  }

  static int GetRandNum (int max) { lock (_rand) return _rand.Next(max); }
}

注意

在生产代码中,通常会添加 try / finally 块,以确保在引发异常时释放锁。

结果如下:

Thread B added 61
Thread A added 83
Thread B added 55
Thread A added 33
...

ReaderWriterLockSlim 允许比简单锁更多的并发读取活动。我们可以通过在 while 循环的开头的 Write 方法中插入以下行来说明这一点:

Console.WriteLine (_rw.CurrentReadCount + " concurrent readers");

这几乎总是打印“3 个并发读取器”(Read 方法将大部分时间花在 foreach 循环中)。除了 CurrentReadCount 之外,ReaderWriterLockSlim 还提供了以下属性来监视锁:

public bool IsReadLockHeld            { get; }
public bool IsUpgradeableReadLockHeld { get; }
public bool IsWriteLockHeld           { get; }

public int  WaitingReadCount          { get; }
public int  WaitingUpgradeCount       { get; }
public int  WaitingWriteCount         { get; }

public int  RecursiveReadCount        { get; }
public int  RecursiveUpgradeCount     { get; }
public int  RecursiveWriteCount       { get; }

可升级锁

有时,在单个原子操作中将读锁定交换为写锁定很有用。例如,假设您希望仅在某个项目尚不存在时才将该项目添加到列表中。理想情况下,您希望最大程度地减少持有(独占)写锁定所花费的时间,因此您可以执行以下操作:

  1. 获取读锁定。
  2. 测试该项目是否已存在于列表中;如果是这样,请松开锁并返回 。
  3. 释放读锁定。
  4. 获取写锁定。
  5. 添加项目。

问题是另一个线程可能会潜入并在步骤 3 和 4 之间修改列表(例如,添加相同的项目)。ReaderWriterLockSlim通过第三种锁(称为)来解决这个问题。可升级锁类似于读锁,只是它以后可以在原子操作中提升为写锁。以下是您的使用方法:

  1. 调用 EnterUpgradeableReadLock 。
  2. 执行基于读取的活动(例如,测试项目是否已存在于列表中)。
  3. 调用 EnterWriteLock(这会将可升级锁转换为写锁)。
  4. 执行基于写入的活动(例如,将项目添加到列表中)。
  5. 调用 ExitWriteLock(这会将写锁定转换回可升级的锁)。
  6. 执行任何其他基于读取的活动。
  7. 调用退出可升级读取锁定。

从调用者的角度来看,这更像是嵌套或递归锁定。不过,从功能上讲,在步骤 3 中,ReaderWriterLockSlim 会释放您的读锁定,并以原子方式获得新的写锁定。

可升级锁和读锁之间还有另一个重要区别。尽管可升级锁可以与任意数量的锁共存,但一次只能取出一个可升级锁本身。这可以通过竞争转换来防止转换死锁,就像 SQL Server 中的更新锁一样:

SQL Server

读卡器作家锁苗条

共享锁定

读锁定

专属锁

写锁定

更新锁

可升级锁

我们可以通过更改前面示例中的 Write 方法来演示可升级锁,以便仅在数字不存在时才向列表中添加一个数字:

while (true)
{
  int newNumber = GetRandNum (100);
  _rw.EnterUpgradeableReadLock();
  if (!_items.Contains (newNumber))
  {
    _rw.EnterWriteLock();
    _items.Add (newNumber);
    _rw.ExitWriteLock();
    Console.WriteLine ("Thread " + threadID + " added " + newNumber);
  }
  _rw.ExitUpgradeableReadLock();
  Thread.Sleep (100);
}

注意

ReaderWriterLock 也可以进行锁转换,但不可靠,因为它不支持可升级锁的概念。这就是为什么ReaderWriterLockSlim的设计者必须从一个新的类重新开始。

锁递归

通常,嵌套或递归锁定是禁止使用ReaderWriterLockSlim的。因此,以下内容会引发异常:

var rw = new ReaderWriterLockSlim();
rw.EnterReadLock();
rw.EnterReadLock();
rw.ExitReadLock();
rw.ExitReadLock();

但是,如果您按如下方式构造 ReaderWriterLockSlim,它就会运行而不会出错:

var rw = new ReaderWriterLockSlim (LockRecursionPolicy.SupportsRecursion);

这可确保递归锁定只有在您计划的情况下才能发生。递归锁定可能会产生不希望的复杂性,因为可能会获取多种锁:

rw.EnterWriteLock();
rw.EnterReadLock();
Console.WriteLine (rw.IsReadLockHeld);     // True
Console.WriteLine (rw.IsWriteLockHeld);    // True
rw.ExitReadLock();
rw.ExitWriteLock();

基本规则是,在获得锁后,后续递归锁可以更少,但不能更大,在以下规模上:

读锁→可升级锁→写锁

但是,将可升级锁提升为写锁的请求始终是合法的。

使用事件等待句柄发出信号

最简单的信令构造称为(与 C# 事件无关)。事件等待句柄有三种形式:自动重置事件、手动重置事件(苗条)和倒计时事件。前两者基于公共 EventWaitHandle 类,它们从该类派生所有功能。

自动重置事件

自动重置事件就像一个票务旋转门:插入一张票证只能让一个人通过。类名称中的“auto”是指打开的十字转门在有人通过后自动关闭或“重置”的事实。线程通过调用 WaitOne 在十字转门处等待或阻塞(在此“一”十字转门处等待,直到它打开),并通过调用 Set 方法插入票证。如果多个线程调用 WaitOne ,则队列2在十字转门后面堆积。票证可以来自任何线程;换句话说,任何有权访问 AutoResetEvent 对象的(未阻止的)线程都可以在其上调用 Set 以释放一个阻塞的线程。

您可以通过两种方式创建自动重置事件。第一个是通过其构造函数:

var auto = new AutoResetEvent (false);

(将 true 传递给构造函数等效于立即调用 Set。创建自动重置事件的第二种方法如下:

var auto = new EventWaitHandle (false, EventResetMode.AutoReset);

在以下示例中,启动了一个线程,其工作只是等待另一个线程发出信号(参见):

class BasicWaitHandle
{
  static EventWaitHandle _waitHandle = new AutoResetEvent (false);

  static void Main()
  {
    new Thread (Waiter).Start();
    Thread.Sleep (1000);                  // Pause for a second...
    _waitHandle.Set();                    // Wake up the Waiter.
  }

  static void Waiter()
  {
    Console.WriteLine ("Waiting...");
    _waitHandle.WaitOne();                // Wait for notification
    Console.WriteLine ("Notified");
  }
}

// Output:
Waiting... (pause) Notified.



使用 EventWaitHandle 发出信号

如果在没有线程等待时调用 Set,则句柄将保持打开状态,直到某个线程调用 WaitOne。此行为有助于防止标题为十字转门的线程与插入票证的线程之间的争用(“哎呀,插入票证太早了微秒;现在你将不得不无限期地等待!然而,在没有人等待的旋转栅门上反复打电话给Set并不允许整个派对在到达时通过:只有下一个人被允许通过,额外的门票被“浪费”。

释放等待句柄

完成等待句柄后,可以调用其 Close 方法来释放 OS 资源。或者,您可以简单地删除对等待句柄的所有引用,并允许垃圾回收器稍后为您完成作业(等待句柄实现终结器调用 Close 的处置模式)。这是依赖此备份(可以说)可接受的少数方案之一,因为等待句柄的操作系统负担很轻。

等待句柄在进程退出时自动释放。

在自动重置事件上调用 Reset 将关闭旋转栅门(如果它已打开),而无需等待或阻止。

WaitOne 接受可选的超时参数,如果等待因超时而不是获取信号而结束,则返回 false。

注意

在超时为 0 的情况下调用 WaitOne 将测试等待句柄是否“打开”,而不会阻止调用方。但请记住,这样做会重置自动重置事件(如果它处于打开状态)。

双向信令

假设我们希望主线程连续三次向工作线程发出信号。如果主线程只是快速连续多次调用等待句柄上的 Set ,则第二个或第三个信号可能会丢失,因为工作线程可能需要一些时间来处理每个信号。

解决方案是让主线程等到工作线程准备就绪后再发出信号。我们可以通过使用另一个 自动重置事件 ,如下所示:

class TwoWaySignaling
{
  static EventWaitHandle _ready = new AutoResetEvent (false);
  static EventWaitHandle _go = new AutoResetEvent (false);
  static readonly object _locker = new object();
  static string _message;

  static void Main()
  {
    new Thread (Work).Start();

    _ready.WaitOne();                  // First wait until worker is ready
    lock (_locker) _message = "ooo";
    _go.Set();                         // Tell worker to go

    _ready.WaitOne();
    lock (_locker) _message = "ahhh";  // Give the worker another message
    _go.Set();

    _ready.WaitOne();
    lock (_locker) _message = null;    // Signal the worker to exit
    _go.Set();
  }

  static void Work()
  {
    while (true)
    {
      _ready.Set();                          // Indicate that we're ready
      _go.WaitOne();                         // Wait to be kicked off...
      lock (_locker)
      {
        if (_message == null) return;        // Gracefully exit
        Console.WriteLine (_message);
      }
    }
  }
}

// Output:
ooo
ahhh

直观地显示了此过程。



双向信令

在这里,我们使用空消息来指示工作线程应结束。对于无限期运行的线程,制定退出策略非常重要!

手动重置事件

正如我们中所描述的,ManualResetEvent的功能类似于一个简单的门。调用集打开门数量的线程调用 WaitOne。呼叫重置将关闭门。在关闭的门上调用 WaitOne 的线程将阻塞;当大门下一次打开时,它们将立即全部释放。除了这些差异之外,手动重置事件的功能类似于自动重置事件。

与 AutoResetEvent 一样,您可以通过两种方式构造 ManualResetEvent:

var manual1 = new ManualResetEvent (false);
var manual2 = new EventWaitHandle (false, EventResetMode.ManualReset);

注意

还有另一个版本的ManualResetEvent称为ManualResetEventSlim。后者针对较短的等待时间进行了优化 - 能够选择旋转以进行一定次数的迭代。它还具有更有效的托管实现,并允许通过CancelToken取消等待。ManualResetEventSlim 不子类 WaitHandle ;但是,它公开了一个 WaitHandle 属性,该属性在调用时返回基于 WaitHandle 的对象(具有传统等待句柄的性能配置文件)。

信令结构和性能

等待或发出自动重置事件或手动重置事件的信号大约需要一微秒(假设没有阻塞)。

ManualResetEventSlim 和 CountdownEvent 在短等待场景中可以提高 50 倍的速度,因为它们不依赖操作系统并明智地使用旋转结构。但是,在大多数情况下,信令类本身的开销不会造成瓶颈;因此,它很少是一个考虑因素。

手动重置事件在允许一个线程取消阻止许多其他线程方面很有用。倒计时事件涵盖了相反的情况。

倒计时事件

倒计时事件允许您等待多个线程。该类具有高效、完全托管的实现。若要使用该类,请使用要等待的线程数或“计数”来实例化它:

var countdown = new CountdownEvent (3);  // Initialize with "count" of 3.

呼叫信号递减“计数”;调用 等待块,直到计数降至零:

new Thread (SaySomething).Start ("I am thread 1");
new Thread (SaySomething).Start ("I am thread 2");
new Thread (SaySomething).Start ("I am thread 3");

countdown.Wait();   // Blocks until Signal has been called 3 times
Console.WriteLine ("All threads have finished speaking!");

void SaySomething (object thing)
{
  Thread.Sleep (1000);
  Console.WriteLine (thing);
  countdown.Signal();
}

注意

有时,通过使用我们在(PLINQ 和并行类)中描述的构造,可以更轻松地解决 CountdownEvent 有效的问题。

您可以通过调用 AddCount 来重新递增倒计时事件的计数。但是,如果它已经达到零,则会引发异常:您无法通过调用 AddCount 来“取消信号”倒计时事件。为了防止抛出异常的可能性,您可以改为调用 TryAddCount ,如果倒计时为零,则返回 false。

要取消向倒计时事件发出信号,请调用 Reset :这既取消了构造的信号,又将其计数重置为原始值。

与 ManualResetEventSlim 一样,CountdownEvent 公开了一个 WaitHandle 属性,用于其他类或方法需要基于 的对象的情况。

创建跨进程事件等待句柄

EventWaitHandle的构造函数允许创建一个“命名的”EventWaitHandle,能够跨多个进程运行。名称只是一个字符串,它可以是任何不会无意中与其他人的值冲突的值!如果该名称已在计算机上使用,则会获得对同一基础 EventWaitHandle 的引用;否则,操作系统会创建一个新操作系统。下面是一个示例:

EventWaitHandle wh = new EventWaitHandle (false, EventResetMode.AutoReset,
                                      @"Global\MyCompany.MyApp.SomeName");

如果两个应用程序分别运行此代码,它们将能够相互发出信号:等待句柄将跨两个进程中的所有线程工作。

命名事件等待句柄仅在 Windows 上可用。

等待句柄和延续

与其等待等待句柄(并阻止线程),不如通过调用 ThreadPool.RegisterWaitForSingleObject 将“延续”附加到它。此方法接受在发出等待句柄信号时执行的委托:

var starter = new ManualResetEvent (false);

RegisteredWaitHandle reg = ThreadPool.RegisterWaitForSingleObject
 (starter, Go, "Some Data", -1, true);

Thread.Sleep (5000);
Console.WriteLine ("Signaling worker...");
starter.Set();
Console.ReadLine();
reg.Unregister (starter);    // Clean up when we’re done.

void Go (object data, bool timedOut)
{
  Console.WriteLine ("Started - " + data);
  // Perform task...
}

// Output:
(5 second delay)
Signaling worker...
Started - Some Data

当等待句柄发出信号(或超时已过)时,委托将在池线程上运行。然后,您应该调用 Unregister 以释放回调的非托管句柄。

除了等待句柄和委托之外,RegisterWaitForSingleObject 还接受一个“黑盒”对象,它传递给委托方法(很像 ParameterizedThreadStart ),以及以毫秒为单位的超时(-1 表示没有超时)和一个布尔标志,指示请求是一次性的还是重复的。

注意

每个等待句柄只能可靠地调用一次 RegisterWaitForSingleObject。在同一等待句柄上再次调用此方法会导致间歇性故障,即未发出信号的等待句柄会触发回调,就像发出一样。

此限制使得(非苗条)等待句柄不适合异步编程。

WaitAny、WaitAll和SignalAndWait

除了 Set、WaitOne 和 Reset 方法之外,WaitHandle 类上还有静态方法来破解更复杂的同步螺母。WaitAny 、 方法对多个句柄执行信令和等待操作。等待句柄可以是不同的类型(包括互斥句柄和信号量,因为它们也派生自抽象的 WaitHandle 类)。ManualResetEventSlim 和 CountdownEvent 也可以通过其 WaitHandle 属性参与这些方法。

注意

WaitAll和SignalAndWait与传统的COM体系结构有着奇怪的联系:这些方法要求调用方位于多线程单元中,这是最不适合互操作性的模型。例如,在此模式下,WPF 或 Windows 窗体应用程序的主线程无法与剪贴板交互。我们稍后将讨论替代方案。

WaitHandle.WaitAny 等待等待句柄数组中的任何一个;WaitHandle.WaitAll 以原子方式等待所有给定的句柄。这意味着,如果您等待两个自动重置事件:

  • WaitAny永远不会最终“锁定”这两个事件。
  • WaitAll永远不会只“锁定”一个事件。

SignalAndWait 调用 Set 在一个 WaitHandle 上,然后在另一个 上调用 WaitOne。发出第一个句柄的信号后,它会跳到队列的头部等待第二个句柄;这有助于它成功(尽管该操作不是真正的原子操作)。您可以将此方法视为将一个信号“交换”为另一个信号,并在一对 EventWaitHandle 上使用它来设置两个线程以在同一时间点会合或“相遇”。自动重置事件或手动重置事件都可以解决问题。第一个线程执行以下命令:

WaitHandle.SignalAndWait (wh1, wh2);

第二个线程执行相反的操作:

WaitHandle.SignalAndWait (wh2, wh1);

WaitAll 和 SignalAndWait 的替代品

WaitAll 和 SignalAndWait 不会在单线程单元中运行。幸运的是,还有其他选择。在 SignalAndWait 的情况下,您很少需要它的队列跳转语义:例如,在我们的会合示例中,如果等待句柄仅用于该会合,则只需在第一个等待句柄上调用 Set,然后在另一个上调用 WaitOne。在下一节中,我们将探讨实现线程交合的另一种选择。

在 WaitAny 和 WaitAll 的情况下,如果你不需要原子性,你可以使用我们在上一节中编写的代码将等待句柄转换为任务,然后使用 Task.WhenAny 和 Task.WhenAll()。

如果你需要原子性,你可以采用最低级别的信令方法,并使用监视器的等待和脉冲方法自己编写逻辑。我们将 详细描述等待和脉搏。

屏障类

Barrier 类实现了线程,允许许多线程在一个时间点会合(不要与 Thread.MemoryBarrier 混淆)。该类非常快速和高效,并且建立在等待,脉冲和旋转锁之上。

使用此类:

  1. 实例化它,指定应该参与会合的线程数(您可以稍后通过调用 AddTParticipants / RemoveParticipants 来更改此设置)。
  2. 让每个线程在想要会合时调用 SignalAndWait。

实例化值为 3 的屏障会导致 SignalAndWait 阻塞,直到该方法被调用三次。然后它重新开始:再次调用 SignalAndWait 会阻止,直到再调用三次。这使每个线程与其他每个线程“同步”。

在下面的示例中,三个线程中的每一个都写入数字 0 到 4,同时与其他线程保持同步:

var barrier = new Barrier (3);

new Thread (Speak).Start();
new Thread (Speak).Start();
new Thread (Speak).Start();

void Speak()
{
  for (int i = 0; i < 5; i++)
  {
    Console.Write (i + " ");
    barrier.SignalAndWait();
  }
}

OUTPUT:  0 0 0 1 1 1 2 2 2 3 3 3 4 4 4

屏障的一个非常有用的功能是,您还可以在构造它时指定后期。这是一个委托,在 SignalAndWait 被调用 次之后运行,但在线程被解锁运行(如图 中的阴影区域所示)。在我们的示例中,如果我们按如下方式实例化我们的屏障

static Barrier _barrier = new Barrier (3, barrier => Console.WriteLine());

输出是这样的:

0 0 0 
1 1 1 
2 2 2 
3 3 3 
4 4 4



障碍

后期操作对于合并来自每个工作线程的数据非常有用。它不需要担心抢占,因为在它做它的事情时,所有工作线程都会被阻止。

延迟初始化

线程处理中常见的问题是如何以线程安全的方式延迟初始化共享字段。当您有一个构造成本很高的字段时,就会出现

class Foo
{
  public readonly Expensive Expensive = new Expensive();
  ...
}
class Expensive {  /* Suppose this is expensive to construct */  }

此代码的问题在于,实例化 Foo 会产生实例化 Expensive 的性能成本 — 无论是否访问过 Expensive 字段。显而易见的答案是构建实例:

class Foo
{
  Expensive _expensive;
  public Expensive Expensive         // Lazily instantiate Expensive
  {
    get
    {
      if (_expensive == null) _expensive = new Expensive();
      return _expensive;
    }
  }
  ...
}

那么问题来了,这是线程安全的吗?除了我们在没有内存屏障的情况下访问锁之外的_expensive之外,请考虑如果两个线程同时访问此属性会发生什么。它们都可以满足 if 语句的谓词,并且每个线程最终都会得到 Expensive 实例。因为这可能会导致细微的错误,所以一般来说,我们会说这段代码不是线程安全的。

该问题的解决方案是锁定检查和初始化对象:

Expensive _expensive;
readonly object _expenseLock = new object();

public Expensive Expensive
{
  get
  {
    lock (_expenseLock)
    {
      if (_expensive == null) _expensive = new Expensive();
      return _expensive;
    }
  }
}

懒惰<T>

Lazy<T> 类可用于帮助延迟初始化。如果使用参数 true 实例化,它实现了刚才描述的线程安全初始化模式。

注意

Lazy<T>实际上实现了这种模式的微优化版本,称为。仔细检查锁定执行额外的易失性读取,以避免在对象已初始化时获取锁定的成本。

要使用 Lazy<T> ,使用值工厂委托实例化类,该委托告诉它如何初始化新值,并且参数为 true 。然后,通过 Value 属性访问其值:

Lazy<Expensive> _expensive = new Lazy<Expensive>
  (() => new Expensive(), true);

public Expensive Expensive { get { return _expensive.Value; } }

如果将 false 传递到 Lazy<T> 的构造函数中,它将实现我们在本节开头描述的线程不安全的延迟初始化模式 — 当您想在单线程上下文中使用 Lazy<T> 时,这是有意义的。

延迟初始值设定项

LazyInitializer 是一个静态类,其工作原理与 Lazy<T 完全相同>除了:

  • 其功能通过静态方法公开,该方法直接在你自己类型的字段上运行。这可以防止一定程度的间接性,从而在需要极端优化的情况下提高性能。
  • 它提供了另一种初始化模式,其中多个线程可以竞相初始化。

要使用 LazyInitializer ,请在访问字段之前调用 EnsureInitialized,传递对字段和工厂委托的引用:

Expensive _expensive;
public Expensive Expensive
{ 
  get          // Implement double-checked locking
  { 
    LazyInitializer.EnsureInitialized (ref _expensive,
                                      () => new Expensive());
    return _expensive;
  }
}

您还可以传入另一个参数来请求竞争线程初始化。这听起来类似于我们最初的线程不安全示例,只是第一个完成的线程总是获胜,因此您最终只有一个实例。这种技术的优点是它比双重检查锁定更快(在多核上),因为它可以使用我们在 的“非阻塞同步”和“延迟初始化”中描述的高级技术完全在没有锁的情况下实现。这是一个极端的(很少需要的)优化,需要付出代价:

  • 当更多的线程竞相初始化时,它会比你拥有的内核更慢。
  • 它可能会浪费执行冗余初始化的 CPU 资源。
  • 初始化逻辑必须是线程安全的(在这种情况下,例如,如果 Expensive 的构造函数写入静态字段,这将是线程不安全的)。
  • 如果初始值设定项实例化需要处置的对象,则如果没有其他逻辑,将不会释放“浪费”的对象。

线程本地存储

本章的大部分内容都集中在同步构造以及线程同时访问相同数据所产生的问题。但是,有时您希望保持数据隔离,确保每个线程都有单独的副本。局部变量正是实现这一点的,但它们仅对瞬态数据有用。

解决方案是。您可能很难想到一个要求:您希望与线程隔离的数据本质上往往是暂时的。它的主要应用是存储“带外”数据,即支持执行路径的基础结构(如消息传递、事务和安全令牌)的数据。在方法参数中传递此类数据可能很笨拙,并且可能会疏远除您自己的方法之外的所有方法;将此类信息存储在普通静态字段中意味着在所有线程之间共享它。

线程本地存储也可用于优化并行代码。它允许每个线程以独占方式访问其自己的线程不安全对象版本,而无需锁,也无需在方法调用之间重建该对象。

有四种方法可以实现线程本地存储。我们将在以下小节中查看它们。

[线程静态]

线程本地存储的最简单方法是使用 ThreadStatic 属性标记静态字段:

[ThreadStatic] static int _x;

然后,每个线程都会看到 _x 的单独副本。

不幸的是,[ThreadStatic] 不适用于实例字段(它什么都不做);它也不能很好地与字段初始值设定项配合使用 — 它们仅在静态构造函数执行时正在运行的线程上执行。如果您需要使用实例字段(或从非默认值开始),ThreadLocal<T> 提供了更好的选择。

ThreadLocal<T>

ThreadLocal<T> 为静态字段和实例字段提供线程本地存储,并允许您指定默认值。

下面介绍如何创建 ThreadLocal<int>,每个线程的默认值为 3:

static ThreadLocal<int> _x = new ThreadLocal<int> (() => 3);

然后_x 的 Value 属性获取或设置其线程本地值。使用 ThreadLocal 的一个好处是可以延迟计算值:工厂函数在第一次调用时(针对每个线程)进行评估。

线程本地<T> 和实例字段

ThreadLocal<T> 对于实例字段和捕获的局部变量也很有用。例如,考虑在多线程环境中生成随机数的问题。Random 类不是线程安全的,因此我们必须使用 Random 锁定(限制并发性)或为每个线程生成一个单独的 Random 对象。ThreadLocal<T>使后者变得容易:

var localRandom = new ThreadLocal<Random>(() => new Random());
Console.WriteLine (localRandom.Value.Next());

我们用于创建 Random 对象的工厂函数有点简单,因为 Random 的无参数构造函数依赖于随机数种子的系统时钟。对于在 ~10 毫秒内创建的两个随机对象,这可能相同。这是修复它的一种方法:

var localRandom = new ThreadLocal<Random>
 ( () => new Random (Guid.NewGuid().GetHashCode()) );

我们在中使用它(参见中的并行拼写检查示例)。

GetData 和 SetData

第三种方法是在 Thread 类中使用两个方法:GetData 和 SetData。它们将数据存储在特定于线程的“插槽”中。Thread.GetData 从线程的独立数据存储中读取;Thread.SetData 写入它。这两种方法都需要一个 LocalDataStoreSlot 对象来标识槽。您可以在所有线程中使用相同的槽,它们仍将获得单独的值。下面是一个示例:

class Test
{
  // The same LocalDataStoreSlot object can be used across all threads.
  LocalDataStoreSlot _secSlot = Thread.GetNamedDataSlot ("securityLevel");

  // This property has a separate value on each thread.
  int SecurityLevel
  {
    get
    {
      object data = Thread.GetData (_secSlot);
      return data == null ? 0 : (int) data;    // null == uninitialized
    }
    set { Thread.SetData (_secSlot, value); }
  }
  ...

在本例中,我们称为 Thread.GetNamedDataSlot ,它创建一个命名槽 — 这允许在应用程序中共享该槽。或者,您可以使用未命名的插槽自己控制插槽的作用域,通过调用 Thread.AllocateDataSlot 获得:

class Test
{
  LocalDataStoreSlot _secSlot = Thread.AllocateDataSlot();
  ...

Thread.FreeNamedDataSlot 将在所有线程中释放命名数据槽,但只有在对该 LocalDataStoreSlot 的所有引用都退出范围并被垃圾回收后。这可确保线程不会从其脚下拉出数据槽,只要它们在需要槽时保留对相应 LocalDataStoreSlot 对象的引用即可。

AsyncLocal<T>

到目前为止,我们讨论的线程本地存储方法与异步函数不兼容,因为在 await 之后,可以在不同的线程上恢复执行。AsyncLocal<T> 类通过在 await 中保留其值来解决此问题:

static AsyncLocal<string> _asyncLocalTest = new AsyncLocal<string>();

async void Main()
{
  _asyncLocalTest.Value = "test";  
  await Task.Delay (1000);  
  // The following works even if we come back on another thread:
  Console.WriteLine (_asyncLocalTest.Value);   // test
}

AsyncLocal<T> 仍然能够将操作分别在单独的线程上启动,无论是由 Thread 发起的。启动或任务 。跑。下面写“一一”和“二二”:

static AsyncLocal<string> _asyncLocalTest = new AsyncLocal<string>();

void Main()
{
  // Call Test twice on two concurrent threads:
  new Thread (() => Test ("one")).Start();
  new Thread (() => Test ("two")).Start();
}

async void Test (string value)
{
  _asyncLocalTest.Value = value;
  await Task.Delay (1000);
  Console.WriteLine (value + " " + _asyncLocalTest.Value);
}

AsyncLocal<T> 有一个有趣且独特的细微差别:如果 AsyncLocal<T> 对象在启动线程时已经有一个值,则新线程将“继承”该值:

static AsyncLocal<string> _asyncLocalTest = new AsyncLocal<string>();

void Main()
{
  _asyncLocalTest.Value = "test";
  new Thread (AnotherMethod).Start();
}

void AnotherMethod() => Console.WriteLine (_asyncLocalTest.Value);  // test

但是,新线程会获取该因此它所做的任何更改都不会影响原始值:

static AsyncLocal<string> _asyncLocalTest = new AsyncLocal<string>();

void Main()
{
  _asyncLocalTest.Value = "test";
  var t = new Thread (AnotherMethod);
  t.Start(); t.Join();
  Console.WriteLine (_asyncLocalTest.Value);   // test  (not ha-ha!)
}

void AnotherMethod() => _asyncLocalTest.Value = "ha-ha!";

请记住,新线程将获取该值的拷贝。因此,如果要将 Async<string> 替换为 Async<StringBuilder> 或 Async<List<string>> ,新线程可以清除 StringBuilder 或向 List<string 添加/删除项目> ,这将影响原始线程。

定时器

如果您需要定期重复执行某个方法,最简单的方法是使用。与以下技术相比,计时器在使用内存和资源方面既方便又高效:

new Thread (delegate() {
                         while (enabled)
                         {
                           DoSomeAction();
                           Thread.Sleep (TimeSpan.FromHours (24));
                         }
                       }).Start();

这不仅会永久占用线程资源,而且无需额外的编码,DoSomeAction 将在每天晚些时候发生。计时器解决了。

.NET 提供五个计时器。其中两个是通用多线程计时器:

  • 系统线程.计时器
  • 系统.定时器.定时器

另外两个是专用单线程计时器:

  • System.Windows.Forms.Timer (Windows Forms timer)
  • System.Windows.Threading.DispatcherTimer (WPF timer)

多线程定时器功能更强大、更准确、更灵活;单线程计时器更安全、更方便地运行更新 Windows 窗体控件或 WPF 元素的简单任务。

最后,从 .NET 6 开始,有 周期计时器 ,我们将首先介绍它。

周期计时器

周期计时器并不是真正的计时器;这是一个帮助异步循环的类。重要的是要考虑到,自从异步和等待的出现以来,传统的计时器通常不是必需的。相反,以下模式效果很好:

StartPeriodicOperation();

async void StartPeriodicOperation()
{
  while (true)
  {
    await Task.Delay (1000);
    Console.WriteLine ("Tick");   // Do some action
  }
 }

注意

如果从 UI 线程调用 StartPeriodicOperation,它将表现为单线程计时器,因为 await 将始终返回相同的同步上下文。

您只需添加 .ConfigureAwait(false) to the await.

PeriodicTimer 是一个简化此模式的类:

var timer = new PeriodicTimer (TimeSpan.FromSeconds (1));
StartPeriodicOperation();
// Optionally dispose timer when you want to stop looping.

async void StartPeriodicOperation()
{
  while (await timer.WaitForNextTickAsync())
    Console.WriteLine ("Tick");    // Do some action
}

PeriodicTimer 还允许您通过释放计时器实例来停止计时器。这会导致 WaitForNextTickAsync 返回 false,从而允许循环结束。

多线程计时器

System.Threading.Timer 是最简单的多线程计时器:它只有一个构造函数和两个方法(极简主义者和书籍作者都很高兴!在下面的示例中,计时器调用 Tick 方法,该方法写入“tick...”五秒钟后,然后每隔一秒,直到用户按 Enter :

using System;
using System.Threading;

// First interval = 5000ms; subsequent intervals = 1000ms
Timer tmr = new Timer (Tick, "tick...", 5000, 1000);
Console.ReadLine();
tmr.Dispose();         // This both stops the timer and cleans up.

void Tick (object data)
{
  // This runs on a pooled thread
  Console.WriteLine (data);          // Writes "tick..."
}

注意

有关释放多线程计时器的讨论,请参阅中的

稍后可以通过调用计时器的 Change 方法更改计时器的间隔。如果希望计时器只触发一次,请在构造函数的最后一个参数中指定 Timeout.Infinite 。

.NET 在 System.Timers 命名空间中提供了另一个同名的计时器类。这简单地包装了System.Threading.Timer ,在使用相同的底层引擎时提供了额外的便利。以下是其附加功能的摘要:

  • 一个 IComponent 实现,允许它位于 Visual Studio 的设计器组件托盘中
  • Interval 属性而不是 Change 方法
  • 已用而不是回调委托
  • 一个 Enabled 属性,用于启动和停止计时器(其默认值为 false)
  • 启动和停止方法,以防您对已启用感到困惑
  • 用于指示定期事件的自动重置标志(默认值为 true)
  • 具有 Invoke 和 BeginInvoke 方法的 SynchronizingObject 属性,用于安全地调用 WPF 元素和 Windows 窗体控件上的方法

下面是一个示例:

using System;
using System.Timers;          // Timers namespace rather than Threading

var tmr = new Timer();        // Doesn't require any args
tmr.Interval = 500;
tmr.Elapsed += tmr_Elapsed;   // Uses an event instead of a delegate
tmr.Start();                  // Start the timer
Console.ReadLine();
tmr.Stop();                   // Stop the timer
Console.ReadLine();
tmr.Start();                  // Restart the timer
Console.ReadLine();
tmr.Dispose();                // Permanently stop the timer

void tmr_Elapsed (object sender, EventArgs e)
  => Console.WriteLine ("Tick");

多线程计时器使用线程池允许几个线程为多个计时器提供服务。这意味着每次调用回调方法或 Elapsed 事件时,它可以在不同的线程上触发。此外,“已用”事件始终(大约)按时触发,而不管上一个“已用”事件是否完成执行。因此,回调或事件处理程序必须是线程安全的。

多线程计时器的精度取决于操作系统,通常在 10 到 20 毫秒的区域内。如果需要更高的精度,可以使用本机互操作并调用 Windows 多媒体计时器。这具有低至一毫秒的精度,并以 定义。首先调用timeBeginPeriod通知操作系统你需要高定时精度,然后调用timeSetEvent启动多媒体定时器。完成后,调用 timeKillEvent 停止计时器,调用 timeEndPeriod 通知操作系统不再需要高计时精度。演示了使用P/Invoke调用外部方法。您可以通过搜索关键字在互联网上找到使用多媒体计时器的完整示例。

单线程定时器

.NET 提供了旨在消除 WPF 和 Windows 窗体应用程序的线程安全问题的计时器:

  • System.Windows.Threading.DispatcherTimer (WPF)
  • System.Windows.Forms.Timer (Windows Forms)

注意

单线程计时器不是为在各自的环境之外工作而设计的。例如,如果在 Windows 服务应用程序中使用 Windows 窗体计时器,则计时器事件将不会触发!

两者都类似于它们公开的成员中的 System.Timers.Timer — 间隔 、 开始 和 停止 (以及 Tick,相当于 已过 )— 并且以类似的方式使用。但是,它们在内部的工作方式上有所不同。它们不是在池线程上触发计时器事件,而是将事件发布到 WPF 或 Windows 窗体消息循环。这意味着 Tick 事件始终在最初创建计时器的同一线程上触发,在普通应用程序中,该线程是用于管理所有用户界面元素和控件的同一线程。这有很多:

  • 您可以忘记线程安全。
  • 在上一个即时报价完成处理之前,新的即时报价永远不会触发。
  • 您可以直接从 Tick 事件处理代码更新用户界面元素和控件,而无需调用 Control.BeginInvoke 或 Dispatcher.BeginInvoke 。

因此,使用这些计时器的程序并不是真正的多线程:你最终会得到与中描述的相同类型的伪并发,即在UI线程上执行的异步函数。一个线程为所有计时器以及处理 UI 事件提供服务。这意味着 Tick 事件处理程序必须快速执行,否则 UI 将无响应。

这使得 WPF 和 Windows 窗体计时器适用于小型作业,通常更新 UI 的某些方面(例如,时钟或倒计时显示)。

在精度方面,单线程计时器类似于多线程计时器(数十毫秒),尽管它们通常不太,因为它们可能会在处理其他 UI 请求(或其他计时器事件)时延迟。

Tags:

最近发表
标签列表