并发编程-3.托管线程的最佳实践

发布时间 2023-10-13 21:26:56作者: F(x)_King

处理静态对象

在 .NET 中处理静态数据时,涉及托管线程时需要了解一些重要的事情。

静态数据和构造函数

关于从托管线程访问静态数据需要了解的一项重要内容与构造函数有关。 在访问任何类的静态成员之前,必须先完成其静态构造函数的运行。 运行时将阻止线程执行,直到静态构造函数运行为止,以确保所有必需的初始化已完成。

如果您在自己的代码库中使用静态对象,您将知道哪些类具有静态构造函数,并且可以控制它们内部逻辑的复杂性。 当静态数据超出您的控制范围、位于第三方库或 .NET 本身内部时,事情可能就不那么清晰了。
让我们尝试一个简单的示例来说明在这种情况下可能遇到的潜在延迟。

  1. 首先在 Visual Studio 中创建一个名为 ThreadingStaticDataExample 的新 .NET 控制台应用程序。
  2. 向名为 WorkstationState 的项目添加一个具有以下静态成员的新类:
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;

namespace ThreadingStaticDataExample
{
    internal class WorkstationState
    {
        internal static string Name { get; set; }
        internal static string IpAddress { get; set;}
        internal static bool IsNetworkAvailable { get; set; }
        
        [ThreadStatic]
        internal static DateTime? NetworkConnectivityLastUpdated;
        
        static WorkstationState()
        {
            Name = Dns.GetHostName();
            IpAddress = GetLocalIPAddress(Name);
            IsNetworkAvailable = NetworkInterface.GetIsNetworkAvailable();
            NetworkConnectivityLastUpdated = DateTime.UtcNow;
            Thread.Sleep(2000);
        }
        
        private static string GetLocalIPAddress(string hostName)
        {
            var hostEntry = Dns.GetHostEntry(hostName);
            foreach (var address in hostEntry.AddressList
                                    .Where(a => a.AddressFamily == AddressFamily.InterNetwork))
            {
                return address.ToString();
            }
            return string.Empty;
        }
    }
}

该类将保存有关当前工作站的一些信息,包括主机名、本地IP地址以及网络当前是否可用。 私有 GetLocalIpAddress 方法根据提供的主机名获取本地 IP。WorkstationState 有一个静态构造函数,用于设置初始属性数据并通过 Thread.Sleep 调用注入两秒的延迟。 这将帮助我们模拟应用程序获取一些其他网络信息,这些信息需要一些时间才能在慢速网络连接上检索。

  1. 接下来,添加一个名为 WorkstationHelper 的类。 此类将包含一个异步方法来更新 WorkstationState 中的静态 IsNetworkAvailable 和 NetworkConnectivityLastUpdated 属性,并将 IsNetworkAvailable 的值返回给调用者:
internal async Task<bool> GetNetworkAvailability()
{
    await Task.Delay(100);
    
    WorkstationState.IsNetworkAvailable = NetworkInterface.GetIsNetworkAvailable();
    WorkstationState.NetworkConnectivityLastUpdated = DateTime.UtcNow;
    
    return WorkstationState.IsNetworkAvailable;
}

如果您想在循环中调用 Task.Delay 并通过改变注入的延迟进行实验,那么还有一个正在等待的 Task.Delay 调用。
4. 最后,更新 Program.cs 以调用 GetNetworkAvailability 并使用连接、主机名和 IP 地址更新控制台输出:

using ThreadingStaticDataExample;


Console.WriteLine("Hello, World!");
Console.WriteLine($"Current datetime:{DateTime.UtcNow}");

var helper = new WorkstationHelper();
await helper.GetNetworkAvailability();

Console.WriteLine($"Network availability last updated{WorkstationState.NetworkConnectivityLastUpdated} for computer {WorkstationState.Name} at IP {WorkstationState.IpAddress}");
  1. 运行程序并检查输出。 您可以看到静态构造函数注入的两个 Console.WriteLine 调用之间有两秒的延迟:
Hello, World!
Current datetime: 2/12/2022 4:07:13 PM
Network availability last updated 2/12/2022 4:07:15 PM for
computer ALVINASHCRABC3A at IP 10.211.55.3

静态构造函数是使用托管线程时要记住的静态数据的一方面。一个更常见的问题是控制跨线程对静态对象的并发读/写访问。

控制对静态对象的共享访问

对于静态数据,最佳实践是尽可能避免使用它。 一般来说,它会降低您的代码的可测试性、可扩展性,并且在处理并发时更容易出现意外行为。 然而,有时静态数据是无法避免的。 您可能正在使用遗留代码库,其中重构代码以删除静态数据可能存在风险,或者需要付出巨大的努力。 当数据很少更改或类是无状态时,静态类也很有用。
对于不可避免的静态物体的情况,可以采取一些预防措施。 让我们回顾其中的一些并讨论它们的优点,从锁定机制开始。

Locks

在第一章中,我们讨论了一些锁定对象以供共享使用的策略。 在使用静态变量时,锁甚至更加重要,因为并发访问的机会随着对象范围的增加而增加。
防止多个线程并发访问对象的最简单方法是用锁封闭任何访问该对象的代码。 让我们修改 WorkstationHelper 中的代码,以防止对 GetNetworkActivity 的多个调用同时写入 WorkstationState 属性:

internal class WorkstationHelper
{
    private static object _workstationLock = new object();
    
    internal async Task<bool> GetNetworkAvailability()
    {
        await Task.Delay(100);
        
        lock( _workstationLock)
        {
            WorkstationState.IsNetworkAvailable = NetworkInterface.GetIsNetworkAvailable();
            WorkstationState.NetworkConnectivityLastUpdated = DateTime.UtcNow;
        }
        
        return WorkstationState.IsNetworkAvailable;
    }
}

我们添加了一个私有静态 _workstationLock 对象,并将其用作包含对 WorkstationState 属性的写入的锁定块的一部分。 如果现在在 Parallel.ForEach 或其他并发操作中使用 GetNetworkAvailability,则一次只有一个线程可以进入该锁定块。
您可以使用第 1 章中讨论的任何锁定机制。选择最适合您的场景的功能。 您可以利用静态成员的另一个 .NET 功能是 ThreadStatic 属性。

标记为 ThreadStatic 的字段不应在构造函数中初始化其数据,因为初始化仅适用于当前线程。 所有其他线程上的值将为 null 或该类型的默认值。
如果将 ThreadStatic 属性应用于 WorkstationState 的 NetworkConnectivityLastUpdated 属性,并在 Parallel.For 循环中调用 WorkstationHelper.GetNetworkAvailability 三十次,则最后在 Program.cs 中读取的值可能是也可能不是写入静态实例之一的最后一个值。 Program.cs 中的变量将包含从 Parallel.For 循环内的主线程写入的最后一个值。

  1. 要亲自尝试,请将 ThreadStatic 属性添加到 NetworkConnectivityLastUpdated 并使其成为内部字段而不是属性。 该属性不能应用于属性:
[ThreadStatic]
internal static DateTime? NetworkConnectivityLastUpdated;
  1. 然后更新 Program.cs 以使用 Parallel.For 循环:
using ThreadingStaticDataExample;

Console.WriteLine("Hello, World!");
Console.WriteLine($"Current datetime:{ DateTime.UtcNow}");

var helper = new WorkstationHelper();
Parallel.For(1, 30, async (x) =>
{
    await helper.GetNetworkAvailability();
});

Console.WriteLine($"Network availability last updated { WorkstationState.NetworkConnectivityLastUpdated} for computer { WorkstationState.Name} at IP{ WorkstationState.IpAddress }");

现在,每次运行程序时,输出中的日期/时间值之间的时间都会有所不同,因为写入控制台的最终值可能不是所有线程的最终值。

虽然 ThreadStatic 应该仅应用于每个线程都需要实例的情况,但应用中与静态类似的另一种模式是单例。 我们来讨论一下单例的使用
多线程应用程序。

工作中的单例模式

单例模式是一种对象设计模式,只允许创建其自身的单个实例。 这种设计模式是最常见的设计模式之一,为大多数 .NET 开发人员所熟知。每个主流依赖注入 (DI) 框架都允许将注册类型注册为单例。 容器只会为每种类型创建一个实例,每次请求该类型时都会提供相同的实例。

我们可以使用锁和一些额外的代码为 WorkstationState 手动创建一个单例。 这是 WorkstationStateSingleton:

public class WorkstationStateSingleton
{
    private static WorkstationStateSingleton? _singleton = null;
    private static readonly object _lock = new();

    WorkstationStateSingleton()
    {
        Name = Dns.GetHostName();
        IpAddress = GetLocalIPAddress(Name);
        IsNetworkAvailable = NetworkInterface.GetIsNetworkAvailable();
        NetworkConnectivityLastUpdated = DateTime.UtcNow;
    }

    public static WorkstationStateSingleton Instance
    {
        get
        {
            lock (_lock)
            {
                if (_singleton == null)
                {
                    _singleton = new WorkstationStateSingleton();
                }
                return _singleton;
            }
        }
    }

    public string Name { get; set; }
    public string IpAddress { get; set; }
    public bool IsNetworkAvailable { get; set; }
    public DateTime? NetworkConnectivityLastUpdated { get; set; }

    private string GetLocalIPAddress(string hostName)
    {
        var hostEntry = Dns.GetHostEntry(hostName);
        foreach (var address in hostEntry.AddressList
                                .Where(a => a.AddressFamily == AddressFamily.InterNetwork))
        {
            return address.ToString();
        }
        return string.Empty;
    }
}

要使其成为单例,需要执行两个步骤。 首先,构造函数是私有的,因此只有 WorkstationStateSingleton 可以创建其自身的实例。 其次,创建一个静态实例方法。 如果它不为 null,则返回其自身的 _singleton 实例。 否则,它会创建要返回的实例。 用 _lock 包围此代码可确保不会在不同的并发线程上创建两次实例。

单例面临与静态类相同的挑战。 如果所有共享数据可以由托管线程同时访问,则应使用锁来保护它们。 在 DI 容器中注册的单例所面临的额外挑战是,必须在与容器相同的范围内声明锁对象、互斥体或其他机制。 这将确保所有可能使用单例的数据也可以强制执行相同的锁。

请注意,如今使用单例通常不被认为是一个好的做法。 因此,许多开发人员认为它们是反模式。 但是,了解它们以及代码中现有的单例可能如何受到多线程代码的影响非常重要。

死锁是积极锁定的陷阱之一。 积极锁定是指在可以并行执行的代码的许多部分中锁定对象的使用。 在下一节中,我们将讨论托管线程中的死锁和竞争条件。

管理死锁和竞争条件

与开发人员可以使用的许多工具一样,滥用托管线程的功能可能会对运行时的应用程序产生不利影响。 死锁和竞争条件是多线程编程可能产生的两种情况:

• 当多个线程试图锁定同一资源并导致无法继续执行时,就会发生死锁。
• 当多个线程继续更新特定例程时,会发生竞争条件,并且正确的结果取决于它们执行该例程的顺序。

图 3.2 – 两个线程争用相同的资源,导致死锁

image

避免死锁

避免应用程序中出现死锁至关重要。 如果死锁涉及的线程之一是应用程序的 UI 线程,则会导致应用程序冻结。 当只有非 UI 线程发生死锁时,诊断问题可能会更加困难。 死锁的线程池线程将阻止应用程序关闭,但死锁的后台线程不会。

当生产环境中出现问题时,经过良好检测的代码对于调试问题至关重要。 如果可以在您自己的开发环境中重现该问题,请单步执行
使用 Visual Studio 调试器调试代码是查找死锁根源的最快方法。

创建死锁的最简单方法之一是通过尝试获取同一资源上的锁的递归或嵌套方法。 看下面的代码:

private object _lock = new object();
private List<string> _data;
public DeadlockSample()
{
	_data = new List<string> { "First", "Second","Third" };
}

public async Task ProcessData()
{
    lock (_lock)
    {
        foreach(var item in _data)
        {
        Console.WriteLine(item);
        }
        await AddData();
    }
}

private async Task AddData()
{
    lock (_lock)
    {
        _data.AddRange(GetMoreData());
        await Task.Delay(100);
    }
}

ProcessData方法正在锁定_lock对象并使用_data进行处理。 但是,它正在调用 AddData,它也尝试获取相同的锁。 该锁永远不会变得可用,并且进程将陷入死锁。 在这种情况下,问题就很明显了。 如果从多个位置调用 AddData 或父代码中涉及某些 Parallel.ForEach 循环会怎样? 某些父代码使用 _data 并获取锁,但有些则不这样做。 在这种情况下,ReaderWriterLockSlim 中的非阻塞读锁可以帮助防止死锁。

防止死锁的另一种方法是使用 Monitor.TryEnter 向锁定尝试添加超时。 在此示例中,如果在一秒内无法获取锁,代码将超时:

private void AddDataWithMonitor()
{
    if (Monitor.TryEnter(_lock, 1000))
    {
        try
        {
        	_data.AddRange(GetMoreData());
        }
        finally
        {
        	Monitor.Exit(_lock);
        }
    }
    else
    {
        Console.WriteLine($"AddData: Unable to acquire lock. Stack trace: {Environment.StackTrace}");
    }
}

记录获取锁的任何失败有助于查明代码中可能的死锁来源,以便您可以重新编写代码以避免死锁。

避免竞争条件

当多个线程同时读取和写入相同的变量时,就会出现竞争条件。如果没有任何锁定,结果可能会非常难以预测。 某些操作可能会被其他并行线程的结果覆盖。 即使锁定到位,两个线程操作的顺序也可能会改变结果。 这是一个没有锁的简单示例,执行一些加法和并行乘法:

private int _runningTotal;

public void PerformCalculationsRace()
{
    _runningTotal = 3;
    Parallel.Invoke(() => {
    	AddValue().Wait();
    }, () => {
    	MultiplyValue().Wait();
    });
    Console.WriteLine($"Running total is {_runningTotal}");
}

private async Task AddValue()
{
    await Task.Delay(100);
    _runningTotal += 15;
}

private async Task MultiplyValue()
{
    await Task.Delay(100);
    _runningTotal = _runningTotal * 10;
}

我们都知道,在组合加法和乘法时,运算顺序很重要。如果两个运算按顺序处理,则两个结果可能是 180 或 45,但如果 AddValue 和 MultiplyValue 在执行各自运算之前都读取初始值 3,则最后完成的方法将写入 18 或 30 作为 _runningTotal 的最终值。
如果要确保乘法发生在加法之前,可以重写 PerformCalculations 方法以对从 MultiplyValue 返回的任务使用ContinueWith 方法:

public async Task PerformCalculations()
{
    _runningTotal = 3;
    
    await MultiplyValue().ContinueWith(async (Task) => {
        await AddValue();
    });
    
    Console.WriteLine($"Running total is {_runningTotal}");
}

此代码始终会在相加之前先相乘,并且始终以 _runningTotal 等于 45 结束。 在整个代码中使用 async 和 wait 可确保 UI 或服务进程在根据需要使用线程池中的线程时保持响应。
上一章讨论的 Interlocked 类也可用于对共享资源执行数学运算。 Interlocked.Add 和 Interlocked.Exchange 可以对 _runningTotal 变量并行执行线程安全操作。 以下是原始 Parallel.Invoke 示例,修改为使用带有 _runningTotal 的 Interlocked 方法:

public class InterlockedSample
{
    private long _runningTotal;
    
    public void PerformCalculations()
    {
        _runningTotal = 3;
        Parallel.Invoke(() => {
        	AddValue().Wait();
        }, () => {
        	MultiplyValue().Wait();
        });
        Console.WriteLine($"Running total is {_runningTotal}");
    }
    
    private async Task AddValue()
    {
        await Task.Delay(100);
        Interlocked.Add(ref _runningTotal, 15);
    }
    
    private async Task MultiplyValue()
    {
        await Task.Delay(100);
        var currentTotal = Interlocked.Read(ref _runningTotal);
        Interlocked.Exchange(ref _runningTotal, currentTotal * 10);
    }
}

这两个操作仍然可以按不同的顺序执行,但 _runningTotal 的使用现在已锁定且线程安全。 Interlocked 类比使用锁定语句更有效,并且对于此类简单更改将产生更高的性能。
在代码中执行并发操作时,保护所有共享资源非常重要。通过创建精心设计的锁定策略,您将获得最佳性能,同时保持应用程序中的线程安全。 让我们以一些有关线程限制的指导来结束本章。

线程限制和其他建议

因此,听起来使用多线程确实可以提高应用程序的性能。 您可能应该开始用 Parallel.ForEach 循环替换所有 foreach 循环,并开始在线程池线程上调用所有服务和辅助方法,对吧? 有什么限制吗?限制是什么? 嗯,说到线程,绝对是有限制的。

可以同时执行的线程数量受到系统上处理器和处理器核心数量的限制。 没有办法绕过硬件限制,因为 CPU(或在虚拟机上运行时的虚拟 CPU)只能运行这么多线程。 此外,您的应用程序必须与系统上运行的其他进程共享这些 CPU。 如果您的 CPU 有四个核心,它正在积极运行其他五个应用程序,并且您的程序正在尝试执行具有多个线程的进程,则系统不太可能一次接受多个线程。

.NET 线程池经过优化,可以根据可用线程的数量处理不同的场景,但您可以采取一些措施来防止系统负担过重。 某些并行操作(例如 Parallel.ForEach)可以限制循环尝试使用的线程数。 您可以为操作提供 ParallelOptions 对象并设置 MaxDegreeOfParallelism 选项。默认情况下,循环将使用调度程序提供的线程数。

您可以通过以下实现确保最大数量不超过系统上可用核心数量的一半:

public void ProcessParallelForEachWithLimits(List<string> items)
{
    int max = Environment.ProcessorCount > 1 ? Environment.ProcessorCount / 2 : 1;
    
    var options = new ParallelOptions
    {
    	MaxDegreeOfParallelism = max
    };
    
    Parallel.ForEach(items, options, y => {
    // Process items
    });
}

PLINQ 操作还可以使用 WithDegreeOfParallelism 扩展方法来限制最大并行度:

public bool ProcessPlinqWithLimits(List<string> items)
{
    int max = Environment.ProcessorCount > 1 ? Environment.ProcessorCount / 2 : 1;
    
    return items.AsParallel()
        .WithDegreeOfParallelism(max)
        .Any(i => CheckString(i));
}

private bool CheckString(string item)
{
	return !string.IsNullOrWhiteSpace(item);
}

如有必要,应用程序还可以调整线程池最大值。 通过调用ThreadPool.SetMaxThreads,您可以更改workerThreads 和completionPortThreads 的最大值。 completionPortThreads 是线程池上异步 I/O 线程的数量。 通常不需要更改这些值,并且可以设置的值有一些限制。 最大值不能设置为小于系统上的核心数或小于线程池上的当前最小值。 您可以使用 ThreadPool.GetMinThreads 查询当前最小值。 以下示例说明了如何安全地将最大线程值设置为大于当前最小值的值:

private void UpdateThreadPoolMax()
{
    ThreadPool.GetMinThreads(out int workerMin, out int completionMin);
    int workerMax = GetProcessingMax(workerMin);
    int completionMax = GetProcessingMax(completionMin);
    ThreadPool.SetMaxThreads(workerMax, completionMax);
}

private int GetProcessingMax(int min)
{
	return min < Environment.ProcessorCount ? Environment.ProcessorCount * 2 : min * 2;
}

关于分配给应用程序中的操作的线程数,还有一些其他通用准则需要遵循。 尽量避免将多个线程分配给共享资源的操作。 例如,如果您有一项将活动记录到文件的服务,则不应分配多个后台工作人员来执行日志记录。 阻塞文件 I/O 操作将阻止第二个线程写入,直到第一个线程完成。 在这种情况下,你不会获得任何效率。

如果您发现自己向应用程序中的对象添加了广泛的锁定,则说明您使用了太多线程,或者需要更改任务分配以减少资源争用。 尝试根据所使用的数据类型来划分线程任务责任。 您可能有许多并行任务调用服务来获取数据,但只需要一两个线程来处理返回的数据。

您可能听说过“线程饥饿”这个词。 当太多线程阻塞或等待资源可用时,通常会发生这种情况。 有一些常见的情况会发生这种情况:

  • 锁(Locks): 有太多线程竞争相同的锁定资源。 分析您的代码以确定如何减少争用。
  • No async/await: 使用 ASP.NET Core 时,所有控制器方法都应标记为异步。 这允许网络服务器在您的请求等待操作完成时处理其他请求。
  • 太多线程(Too much threading):创建太多线程池线程将导致更多空闲线程等待处理。 它还增加了线程争用和饥饿的可能性。

避免这些做法,.NET 将尽最大努力管理线程池,为您的应用程序和系统上的其他应用程序提供服务。
最后,不要使用 Thread.Suspend 和 Thread.Resume 尝试控制跨多个线程的操作顺序。 相反,请利用本章讨论的其他技术,包括锁定机制和 Task.ContinueWith。