《c#10 in a nutshell》--- 读书随记(12)

Chapter 21. Advanced Threading

内容来自书籍《C# 10 in a Nutshell》
Author:Joseph Albahari
需要该电子书的小伙伴,可以留下邮箱,有空看到就会发送的

Synchronization Overview

同步是为可预测的结果协调并发操作的行为。当多个线程访问相同的数据时,同步特别重要

最简单和最有用的同步工具可以说是第14章中描述的延续和任务组合器。通过将并发程序表述为与延续符和组合符串联在一起的异步操作,可以减少锁定和信令的需要。然而,仍然有时候低层次的结构会发挥作用。

同步的结构分为三类:

  • 排他锁
    排他锁只允许一条线程同时执行某一段代码。主要的目的是为了防止和其他线程同时访问一个变量。排他锁有:lockMutexSpinLock
  • 非排他锁
    允许你限制并发,非排他锁有:SemaphoreReaderWriterLock
  • 发信号
    允许一个线程保持阻塞,直到受到一个或多个线程的唤醒。有 ManualResetEventAutoResetEventCountdownEventBarrier

在共享状态上执行某些并发操作,而不使用非阻塞同步构造进行锁定,这也是可能的(并且是棘手的)。比如 Thread.MemoryBarrierThread.VolatileReadThread.VolatileWritevolatile关键字 、Interlocked

Exclusive Locking

Lock 的使用是最方便和广泛的,其余两种有专用场景:

  • Mutex 允许跨多个进程(计算机范围的锁)
  • SpinLock 实现了一个微型优化,可以减少高并发性场景中的上下文切换

The lock Statement

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

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

如果 Go 方法同时被两个或更多的线程调用,那么就很有可能会出现division-by-zero error。因为可能一条线程在修改了val2的值的时候,另一条线程正在作if后面的语句,我们可以使用lock解决这个问题

class ThreadUnsafe
{
    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;
        }
    }
}

加了lock之后,同一时刻只有一条线程可以持有这个锁对象,其他线程会一直阻塞直到锁被释放,如果等待的线程有多个,那么等待线程会放在队列中,根据先到先得的原则获取锁

独占锁有时被说成是强制对受该锁保护的任何内容进行序列化访问,因为一个线程的访问不能与另一个线程的访问重叠。

Monitor.Enter and Monitor.Exit

lock语句其实是一个带着try/finally块的Monitor.Enter 和 Monitor.Exit方法的快捷方式

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

The lockTaken overloads

我们刚才演示的代码有一个微妙的漏洞。考虑在对 Monitor 的调用之间引发异常的(不太可能的)事件。输入和 try 块(可能是由于 OutOfMemory 异常或。NET 框架,如果线程中止)。在这种情况下,锁可能被使用,也可能不被使用。如果获取了锁,就不会释放它ーー因为我们永远不会进入 try/finally 块。这将导致泄漏锁。为了避免这个,Monitor定义了方法

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

如果(并且仅当) Enter 方法引发异常且没有使用锁,则此方法之后的 lockTaken 为 false

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

TryEnter

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

Choosing the Synchronization Object

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

锁定的对象也可以是this或者typeof (Widget)类型类。这种锁定方式的缺点是没有封装锁定逻辑,因此更难防止死锁和过度阻塞。

而且,对于这个同步对象来说,没有锁是不会限制访问的,锁限制的是那个lock块

没有锁会出现两种问题:

  • 诸如递增变量(或者甚至在特定条件下读/写变量)之类的操作不是原子操作。

  • 编译器、 CLR 和处理器有权在 CPU 寄存器中重新排序指令和缓存变量,以提高性能ーー只要这种优化不改变单线程程序的行为

lock 减轻了第二个问题,因为它在锁定之前和之后创建了一个内存屏障。内存屏障是一道“栅栏”,重新排序和缓存的效果无法穿过它。

Locking and Atomicity

如果一组变量总是在同一个锁中读写,那么可以说这些变量是原子读写

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

我们可以说 x 和 y 是原子级的访问的,因为代码块不能被另一个线程的操作分割或抢占,这样它不就会改变 x 或 y 并使其结果无效。如果总是在同一个独占锁中访问 x 和 y,则永远不会出现除以零的错误。

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

如果在锁块中引发异常(不管是否涉及多线程) ,那么锁所提供的原子性就会被破坏。

Nested Locking

一个线程可以重复对同一个同步对象锁定

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

在这些场景中,只有当最外层的 lock 语句已退出ーー或相应数量的 Monitor 已退出时,才会解锁对象。已执行退出语句

Performance

锁是快速的: 如果锁是无竞争的,那么在一台2020年代的计算机上,您可以在不到20纳秒的时间内获得并释放一个锁。如果它是争用的,那么相应的上下文切换将开销移动到更接近微秒区域的位置,尽管在线程实际重新调度之前可能需要更长的时间。

Mutex

和lock很相似,但是它可以跨进程工作。换句话说,Mutex 可以是计算机范围的,也可以是应用范围的。获取和释放一个无竞争对手的 Mutex 需要大约半微秒的时间,比lock慢了20多倍。

对于 Mutex 类,您可以调用 WaitOne 方法来锁定,调用 ReleaseMutex 来解锁。与 lock 语句一样,Mutex 只能从获得它的同一个线程释放。

跨进程互斥对象的一个常见用途是确保一次只能运行一个程序实例。接下来的步骤如下:

// 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();
}

Locking and Thread Safety

如果程序或方法能够在任何多线程场景中正确工作,那么它就是线程安全的。线程安全主要是通过锁定和减少线程交互的可能性来实现的。
通用类型很少是完全线程安全的,原因如下:

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

因此,线程安全通常只在处理特定多线程场景所需的位置实现。

但是,有几种方法可以“欺骗”大型复杂的类并使其在多线程环境中安全地运行。一种方法是牺牲粒度,将大部分代码(甚至是对整个对象的访问)封装在一个独占锁中,从而实现高级别的序列化访问。实际上,如果您想在多线程上下文中使用线程不安全的第三方代码(或者大多数 .NET 类型) ,这种策略是必不可少的。诀窍就是使用相同的独占锁来保护对线程不安全对象上的所有属性、方法和字段的访问。如果对象的所有方法都快速执行,那么解决方案就能很好地工作(否则,将会出现大量阻塞)。

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

Thread Safety in Application Servers

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

例如,我们有一个接口是使用RetrieveUser方法来查询数据库:
internal User RetrieveUser (int id) { ... }

如果这个方法的调用非常频繁,那么为了提高性能,我们可以使用缓存查询的结果,将它保存在一个 static的字典中,那么对于这种场景,我们就需要是线程安全的访问了:

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);
        lock (_users) _users[id] = u;
        return u;
    }
}

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

对于这种情况,其实可以使用之前见过的一种策略来解决这种问题,我们可以使用Task

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不直接通过 await 调用,而是先获取Task,那么这个Task所代表的方法只会执行一次,以后每次都只是在Task内部获取结果,因为Task已经完成了

注意,我们现在有一个单一的锁,它覆盖了整个方法的逻辑。我们可以在不损害并发性的情况下做到这一点,因为我们在锁中所做的一切就是访问字典和(潜在地)启动异步操作(通过调用 Task.Run)。如果两个线程同时以相同的 ID 调用这个方法,它们最终都会等待相同的任务,这正是我们想要的结果。

Immutable Objects

不可变对象的状态不能改变ーー无论是外在的还是内在的。不可变对象中的字段通常声明为只读,并在构造过程中完全初始化。不可变性是函数式编程的一个标志ーー在函数式编程中,不需要对对象进行修改,而是创建一个具有不同属性的新对象。LINQ 遵循这种模式。不变性在多线程中也很有价值,因为它避免了共享可写状态的问题ーー通过消除(或最小化)可写状态。

Nonexclusive Locking

Semaphore

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

容量为1的信号量类似于 Mutex 或锁,只不过信号量没有“所有者”ーー它是线程不可知的。任何线程都可以在Semaphore上调用 Releaseon,而对于 Mutex 和 lock,只有获得锁的线程才能释放它。

这个类有两个功能相似的版本: Semaphore 和 SemaphoreSlim。后者已经进行了优化,以满足并行编程的低延迟要求。它在传统的多线程中也很有用,因为它允许您在等待时指定cancellation token,并且它为异步编程公开了 WaitAsync 方法。但是,您不能将其用于进程间signaling。

在调用 WaitOne 和 Release 时,Semaphore 需要花费大约一微秒的时间; 而 SemaphoreSlim 只需要花费大约十分之一的时间。

信号量可以用来限制并发性ーー防止太多线程同时执行某段代码。下面的例子中,俱乐部只允许同时有三个人进入

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!");
        Thread.Sleep(1000 * (int)id);
        Console.WriteLine(id + " is leaving");
        _sem.Release();
    }
}

如果给Semaphore命名,它也可以和Mutex一样跨进程使用,但是只能在Windwos平台使用,而Mutex可以在Unix平台使用

Asynchronous semaphores and locks

普通的lock方法内部不允许出现 await

这样做毫无意义,因为锁是由线程持有的,而线程在从 await 返回时通常会发生变化。锁定也会阻塞,并且阻塞时间可能会很长,这正是异步函数所不希望实现的。

然而,有时候还是需要让异步操作按顺序执行ーー或者限制并行性,以便一次执行的操作不超过 n 个。

例如,考虑一个 web 浏览器: 它需要并行地执行异步下载,但是它可能希望设置一个限制,以便一次最多下载10次。可以用SemaphoreSlim实现

SemaphoreSlim _semaphore = new SemaphoreSlim(10);

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

Writing an EnterAsync extension method

还可以利用扩展方法和Disposable来简化上面的代码

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

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

Parallel.ForEachAsync

在.Net 6中,另一种类似的方法有Parallel.ForEachAsync,它也可以限制线程数量

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

Reader/Writer Locks

通常,对于并发读取操作,类型的实例是线程安全的,但对于并发更新(对于并发读取和更新)则不是这样。对于诸如文件之类的资源,也可以这样做。尽管使用针对所有访问模式的简单独占锁来保护这种类型的实例通常可以做到这一点,但是如果有很多读并且只是偶尔有更新,那么它可能会不合理地限制并发性。可能发生这种情况的一个示例是在业务应用程序服务器中,对于该服务器,通常使用的数据被缓存,以便在静态字段中进行快速检索。ReaderWriterLockSlim 类的设计就是为了在这种情况下提供最大可用性锁定。

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

与普通锁相比较时(监视器。输入/退出) ,ReaderWriterLockSlim 仍然慢了一倍。当有很多read和很少write的时候,这种差距减少了

ReaderWriterLockSlim 有两个基础类型的锁,一个读锁,一个写锁

  • 写锁是排他锁
  • 读锁可以兼容其他读锁
public void EnterReadLock();
public void ExitReadLock();

public void EnterWriteLock();
public void ExitWriteLock();

Upgradeable locks

有时候,在单个原子操作中将读锁替换为写锁是很有用的。例如,假设只有在项目不存在的情况下才想向列表中添加项目。理想情况下,您希望最小化持有(独占)写锁所花费的时间,因此您可以按照以下方式进行操作

  1. 获取读锁
  2. 检查列表中是否有某个特定元素,如果有就释放锁,返回
  3. 释放读锁
  4. 获取写锁
  5. 添加元素

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

  1. 调用EnterUpgradeableReadLock
  2. 执行只读的行为(检查元素是否存在,没有就继续下面步骤)
  3. 调用EnterWriteLock(这样会让锁升级为写锁)
  4. 添加元素
  5. 调用ExitWriteLock(把写锁还回去了)
  6. 继续只读行为
  7. 调用ExitUpgradeableReadLock

从调用者的角度来看,它更像是嵌套锁定或递归锁定。可升级锁和读锁之间还有一个重要的区别。虽然可升级锁可以与任意数量的读锁共存,但一次只能取出一个可升级锁。这通过序列化竞争转换来防止转换死锁

Signaling with Event Wait Handles

最简单的signaling结构称为event wait handles(与 C # 事件无关)。event wait handles有三种: AutoResetEvent、 ManualResetEvent (Slim)和 CountdownEvent。前两者基于公共的 EventWaitHandle 类,它们的所有功能都是从这个类派生出来的。

AutoResetEvent

AutoResetEvent 就像是一个识别门,每次只允许一个人通过,也就是调用 WaitOne 方法的线程,会阻塞在门前,直到有别的线程允许(Set方法唤醒)这个线程通过,然后“autoReset”的意思是在一个线程通过之后会自动关门等下一个线程过来,如果同时有多个线程 WaitOne ,那就会有个队列

两种方式创建: var auto = new AutoResetEvent (false);,‘
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);
        _waitHandle.Set();
    }

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

如果在没有线程在等待时调用 Set,则句柄将一直保持打开状态,直到某个线程调用 WaitOne。这种行为有助于防止如果有线程提前Set了,然后另一个线程还没有调用WaitOne,它错过了,然后就一直阻塞了。然而,在没有人等候的十字转门上重复呼叫 Set 并不能让整个队伍在他们到达时通过: 只有下一个人可以通过,额外的票被“浪费”了

完成wait handle后,可以调用它的 Close 方法来释放操作系统资源。或者,您可以简单地删除对wait handle的所有引用,并允许垃圾回收器稍后为您完成这项工作(wait handle实现销毁模式,Finalizer 调用 Close)。这是少数几个可以接受(可以说)依赖这种备份的场景之一,因为wait handle的操作系统负担很轻。进程退出时自动释放wait handle。

在 AutoResetEvent 上调用 Reset 会关闭旋转栅门(如果它是开着的) ,而不会等待或阻塞。

Two-way signaling

假设我们希望主线程在一行中向工作线程发出三次信号。如果主线程只是在一个wait handle快速连续多次调用 Set,那么第二个或第三个信号可能会丢失,因为工作者可能需要时间来处理每个信号。

那么我们可以等待这个worker准备好之后再发信号

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();
        lock (_locker) _message = "ooo";
        _go.Set();
        _ready.WaitOne();
        lock (_locker) _message = "ahhh";
        _go.Set(); // Give the worker another message
        _ready.WaitOne();
        lock (_locker) _message = null;
        _go.Set();
    }

    static void Work()
    {
        while (true)
        {
            _ready.Set();
            _go.WaitOne();
            lock (_locker)
            {
                if (_message == null) return;
                Console.WriteLine(_message);
            }
        }
    }
}

ManualResetEvent

ManualResetEvent 就像是一个门,调用Set方法就会开门,允许复数线程通过,只要调用WaitOne,线程就会阻塞在门前,如果门是关的。调用Reset会关门。除了这些不同之外,ManualResetEvent 的功能类似于 AutoResetEvent

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

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

等待或发送 AutoResetEvent 或 ManualResetEvent 信号大约需要1微秒(假设没有阻塞)。ManualResetEventSlim 和 CountdownEvent 在短等待情况下可以快50倍,因为它们不依赖于操作系统,并且明智地使用了旋转构造。然而,在大多数情况下,signaling类本身的开销不会造成瓶颈; 因此,很少考虑这个问题。

CountdownEvent

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

var countdown = new CountdownEvent (3);

调用 Signal方法代表计数一次,调用Wait代表阻塞,直到倒数完毕

var countdown = new CountdownEvent (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");

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

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

可以使用TryAddCount方法,增加CountdownEvent倒数个数

要解除倒计时事件的信号,请调用 Reset: 这将解除构造的信号,并将其计数重置为原始值

The Barrier Class

Barrier 类实现了一个线程执行 Barrier,允许许多线程在一个时间点进行交会(不要与 Thread.MemoryBarrier 混淆)。这个类是非常快速和有效的,并且是建立在Wait、Pulse和spinlocks之上的。

使用步骤:

  1. 实例化它,指定应该有多少个线程参与会合
  2. 让每个线程在需要会合时调用 SignalAndwait

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

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

Barrier 的一个非常有用的特性是,在构建它的时候,你还可以指定一个后期操作。这是一个在 SignalAndwait 被调用 n 次之后,但在线程被解除阻塞之前运行的委托

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

那么上面例子的输出就会有所不同

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

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

Lazy Initialization

线程处理中的一个常见问题是如何以线程安全的方式惰性地初始化共享字段。当您有一个构造成本很高的类型的字段时,就会出现这种需求

class Foo
{
    public readonly Expensive Expensive = new Expensive();
    ...
}

这段代码的问题在于,实例化 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;
        }
    }
}

Lazy< T >

如果使用 true 参数实例化,它将实现刚才描述的线程安全初始化模式。

Lazy < T > 实际上实现了这种模式的微优化版本,称为double-checked locking模式。 double-checked locking 模式执行额外的volatile read,以避免在对象已经初始化的情况下获取锁的成本。

实例化传递一个委托,告诉它如何初始化所需的实例,true是需要线程安全的版本

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

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

LazyInitializer

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

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

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

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

还可以传入另一个参数,以请求竞争线程竞相进行初始化。这听起来与我们最初的线程不安全示例类似,只不过第一个完成的线程总是获胜ーー因此最终只有一个实例。这种技术的优势在于它甚至比double-checked locking模式更快(在多核上) ,因为它可以使用我们在“非阻塞同步”和“惰性初始模式”中描述的高级技术,在完全没有锁的情况下实现。这是一种极端的(很少需要的)优化,它是有代价的:

  • 当更多的线程争先恐后地进行初始化时,速度会更慢。
  • 执行冗余初始化可能会浪费 CPU 资源。
  • 初始化逻辑必须是线程安全的(例如,在这种情况下,如果 Expensive 的构造函数写入静态字段,那么它就是线程不安全的)。
  • 如果初始化程序实例化一个需要对象的销毁,没有额外的逻辑,“浪费”的对象就不会被处理掉

Thread-Local Storage

[ ThreadStatic ]

thread-local storage最简单的方法是使用 ThreadStatic 属性标记静态字段:
[ThreadStatic] static int _x;

然后,每个线程看到 _ x 的一个单独副本

不幸的是,[ ThreadStatic ]不能处理实例字段(它什么也不做) ; 也不能处理字段初始化器ーー它们只能在执行静态构造函数时运行的线程上执行一次。如果需要使用实例字段ーー或者以非默认值开始ーー ThreadLocal < T > 提供了一个更好的选项。

ThreadLocal< T >

ThreadLocal < T > 为静态字段和实例字段提供thread-local storage,并允许指定默认值。

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

使用 ThreadLocal 的一个好处是可以对值进行延迟计算: Factory 函数在第一次调用时(对于每个线程)进行计算。

ThreadLocal< T > and instance fields

ThreadLocal < T > 对于实例字段和捕获的本地变量也很有用。例如,考虑在多线程环境中生成随机数的问题。Random 类不是线程安全的,所以我们要么使用 Lock (限制并发性) ,要么为每个线程生成一个单独的 Random 对象。

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

GetData and 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); }
    }
    ...
}

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 > 有一个有趣而独特的细微差别: 如果一个 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

但是,新线程将获得该值的一个副本,因此它所做的任何更改都不会影响原始线程

Timers

如果需要定期重复执行某个方法,最简单的方法是使用计时器。计时器在使用内存和资源方面是方便和有效的

.NET 提供了五种Timer,下面两种是通用多线程计时器

  • System.Threading.Timer
  • System.Timers.Timer

多线程计时器更加强大、准确和灵活

最后,从 .NET 6开始,我们将首先介绍 PeriodicTimer。

PeriodicTimer

PeriodicTimer 实际上不是一个计时器; 它是一个帮助异步循环的类。考虑到 async 和 await 的出现,传统的计时器通常是不必要的,这一点很重要。相反,下面的模式可以很好地工作

StartPeriodicOperation();

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

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
}

可以通过disposing这个实例,就可以停止计时器

Multithreaded Timers

System.Threading.Timer 是最简单的多线程计时器,它只有一个构造器和两个方法。

例子是一开始等待5000ms后执行,然后每隔1000ms执行一次

// 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);
}

在计时器启动之后,还可以通过Change方法修改执行间隔,如果计时器是一次性的,可以指定
Timeout.Infinitezai 给构造器

还有一个计时器是在System.Timers 命名空间,它简单地包装了 System.Threading.Timer。它在相同的基础上提供了一些便利:

  • 一个 IComponent 实现,允许它位于 VisualStudio 设计器的组件栏中
  • Interval 属性而不是 Change 方法
  • 使用 Elapsed 事件而不是回调委托
  • 用于启动和停止计时器的已启用属性(其默认值为false)
  • 启动和停止方法,以防您被“启用”混淆
  • 用于指示重复事件的 AutoReset 标志(默认值为 true)
  • 具有 Invoke 和 BeginInvoke 方法的 SynchronizingObject 属性,用于安全调用 WPF 元素和 Windows 窗体控件上的方法
using System;
using System.Timers;

void tmr_Elapsed (object sender, EventArgs e) => Console.WriteLine ("Tick");
var tmr = new Timer();
tmr.Interval = 500;
tmr.Elapsed += tmr_Elapsed;
tmr.Start();
Console.ReadLine();
tmr.Stop();
Console.ReadLine();
tmr.Start();
Console.ReadLine();
tmr.Dispose();

多线程计时器使用线程池允许一些线程为多个计时器服务。这意味着每次调用回调方法或 Elapsed 事件时,它都可以在不同的线程上激发。此外,Elapsed 事件总是按时激发(大约)ーー不管前一个 Elapsed 事件是否完成执行。因此,回调或事件处理程序必须是线程安全的。

多线程计时器的精度取决于操作系统,通常在10-20毫秒区域。如果需要更高的精度,可以使用本机互操作并调用 Windows 多媒体计时器。它的精度可以精确到1毫秒,并且是在 winmm.dll 中定义的。

posted @ 2022-07-02 22:06  huang1993  阅读(265)  评论(0)    收藏  举报