1.托管线程概念

.NET 线程基础知识

是时候开始学习 C# 和 .NET 中的线程基础知识了。 我们将介绍 .NET 6 中可用的托管线程概念,但其中许多功能从一开始就是 .NET 的一部分。System.Threading 命名空间从 .NET Framework 1.0 开始可用。 在随后的 20 年中,为开发人员添加了许多有用的功能。

为了在应用程序中负责任地使用线程,您应该确切了解线程是什么是线程以及应用程序过程中使用线程的方式。

线程和进程

我们将从应用程序处理,线程和流程的基本单位开始我们的旅程。 一个过程封装了应用程序的所有执行。 对于所有平台和框架。

线程代表一个过程中的单个执行单元。 默认情况下,.NET应用程序将在单个线程(即主线程或主线程)上执行所有逻辑。 开发人员可以利用托管线程和其他.NET构造从单线读取到多线程世界,但是您怎么知道何时采取此步骤?

我们什么时候应该在.NET中使用多线程?

在决定是否将螺纹引入应用程序时,有许多因素需要考虑。这些因素既是应用程序的内部和外部因素。 外部因素包括在将应用程序部署的位置,处理器运行的位置以及在这些系统上运行哪些其他类型的流程的功能?

如果您的应用程序将争夺系统有限的资源,那么使用多个线程是明智的选择。 如果用户的印象是您的应用程序正在影响其系统的性能,则您需要重新缩小过程中的线程数量。 关键任务应用程序将拥有更多的资源,以便在需要时保持响应。

引入线程的其他常见原因与应用程序本身有关。桌面和移动应用程序需要保持用户界面(UI)对用户输入的响应。 如果应用程序需要处理大量数据或从数据库,文件或网络资源中加载它,则在主线程上执行会导致UI冻结或滞后。 同样,在多个线程上并行执行长期运行的任务可以减少任务的整体执行时间。

后台线程

前台线程和后台线程的区别可能并不是你想象的那样。创建为前台线程的托管线程不是 UI 线程或主线程。前台线程是在托管进程正在运行时阻止其终止的线程。如果应用程序终止,任何正在运行的后台线程都将停止,以便进程可以关闭。

默认情况下,新创建的线程是前台线程。要创建新的后台线程,请在启动线程之前将 Thread.IsBackground 属性设置为 true。 此外,您可以使用 IsBackground 属性来确定现有线程的后台状态。 让我们看一个示例,您可能希望在应用程序中使用后台线程。

在此示例中,我们将在 Visual Studio 中创建一个控制台应用程序,它将持续检查后台线程上的网络连接状态。 创建一个新的.NET 6控制台应用程序项目,将其命名为BackgroundPingConsoleApp,并在Program.cs中输入以下代码:

Console.WriteLine("Hello, World!");

var bgThread = new Thread(() =>
    {
        while (true)
        {
            bool isNetworkUp = System.Net.NetworkInformation.NetworkInterface.GetIsNetworkAvailable();
            Console.WriteLine($"Is network available? Answer: {isNetworkUp}");
            Thread.Sleep(100);
        }
    });

bgThread.IsBackground = true;  //设置为后台线程
bgThread.Start();             // 开始线程

for (int i = 0; i < 10; i++)
{
    Console.WriteLine("Main thread working...");
    Task.Delay(500); //等待了500毫秒
}

Console.WriteLine("Done");
Console.ReadKey();

在运行前面的代码并检查输出之前,让我们讨论一下它的每个部分:

  1. 第一个 Console.WriteLine 语句是由项目模板创建的。 我们将其保留在这里以帮助说明控制台中的订单输出。
  2. 接下来,我们创建一个名为 bgThread 的新线程类型。 在线程体内,有一个while循环,它会不断执行,直到线程终止。 在循环内,我们调用 GetIsNetworkAvailable 方法并将调用结果输出到控制台。 在重新开始之前,我们使用 Thread.Sleep 注入 100 毫秒的延迟。
  3. 创建线程后的下一行是本课的重点部分:
bgThread.IsBackground = true;

IsBackground 属性设置为 true 可以使我们的新线程成为后台线程。 这告诉我们的应用程序,线程内执行的代码对应用程序并不重要,并且进程可以终止,而无需等待线程完成其工作。 这是一件好事,因为我们创建的 while 循环永远不会完成。

  1. 在下一行中,我们使用 Start 方法启动线程。
  2. 接下来,应用程序在应用程序的主线程内开始一些工作。 for 循环将执行 10 次并向控制台输出“主线程正在工作…”。 在循环的每次迭代结束时,Task.Delay 用于等待 500 毫秒,希望为后台线程提供一些时间来执行某些工作。
  3. for 循环结束后,应用程序将向控制台输出“Done”,并等待用户输入,使用 Console.ReadKey 方法终止应用程序。

现在,运行应用程序并检查控制台输出。 当您觉得应用程序运行了足够长的时间后,您可以按任意键来停止应用程序:

图 1.1 – 查看线程控制台应用程序输出

在这里插入图片描述

结果可能不是你所期望的。 您可以看到程序在开始任何后台线程工作之前在主线程上执行了所有逻辑。 稍后我们将看到如何更改
线程的优先级来操纵首先处理哪些工作。

在此示例中,需要理解的重要一点是,我们能够通过按某个键来执行 Console.ReadKey 命令来停止控制台应用程序。 即使后台线程仍在运行,进程也不认为该线程对应用程序至关重要。 如果注释掉以下行,应用程序将不再通过按键终止:

bgThread.IsBackground = true;

必须通过关闭命令窗口或使用“调试|”来停止应用程序。 Visual Studio 中的“停止调试”菜单项。 稍后,在计划和取消工作部分,我们将
了解如何取消托管线程中的工作。

什么是托管线程?

在 .NET 中,托管线程是由我们在前面的示例中使用的 System.Threading.Thread 类实现的。 当前进程的托管执行环境监视作为进程一部分运行的所有线程。 非托管线程是使用本机 Win32 线程元素在 C++ 中进行编程时管理线程的方式。 非托管线程可以通过 COM 互操作或通过 .NET 代码的平台调用 (PInvoke) 调用进入托管进程。 如果该线程是第一次进入托管环境,.NET 将创建一个新的 Thread 对象来由执行环境管理。

可以使用 Thread 对象的 ManagedThreadId 属性来唯一标识托管线程。 该属性是一个整数,保证在所有线程中都是唯一的,并且不会随时间而改变。

ThreadState 属性是一个只读属性,提供 Thread 对象的当前执行状态。 在 .NET 线程基础知识部分的示例中,如果我们在调用 bgThread.Start() 之前检查了 ThreadState 属性,则它将处于 Unstarted 状态。 调用Start后,状态将更改为Background。 如果它不是后台线程,调用 Start 会将 ThreadState 属性更改为 Running

以下是 ThreadState 枚举值的完整列表:

Aborted:线程已中止。

AbortRequested:已请求中止,但尚未完成。

Background:线程在后台运行(IsBackground 已设置为true)。

Running: 线程当前正在运行。

Stopped:线程已停止。

StopRequested:已请求停止,但尚未完成。

Suspended:线程已挂起。

SuspendRequested:已请求线程挂起,但尚未完成。

Unstarted:线程已创建但尚未启动。

WaitSleepJoin:线程当前被阻塞。

Thread.IsAlive 属性是一个不太具体的属性,可以告诉您线程当前是否正在运行。 它是一个布尔属性,如果线程已启动并且尚未以某种方式停止或中止,则该属性将返回 true

线程还有一个 Name 属性,如果从未设置过该属性,则该属性默认为 null。 一旦在线程上设置了 Name 属性,就无法更改。 如果尝试将线程的 Name 属性设置为非 null,则会抛出 InvalidOperationException

创建和销毁线程

创建和销毁线程是 .NET 中托管线程的基本概念。 我们已经看到了一个创建线程的代码示例,但是应该首先讨论 Thread 类的一些附加构造函数。 此外,我们还将了解一些暂停或中断线程执行的方法。 最后,我们将介绍一些销毁或终止线程执行的方法。

创建托管线程

在 .NET 中创建托管线程是通过实例化新的 Thread 对象来完成的。 Thread 类有四个构造函数重载:

  • Thread(ParameterizedThreadStart):这将创建一个新的 Thread 对象。 它通过传递带有构造函数的委托来实现此目的,该构造函数将一个对象作为其参数,该参数可以在调用 Thread.Start() 时传递。
  • Thread(ThreadStart):这将创建一个新的 Thread 对象,该对象将执行要调用的方法,该方法作为 ThreadStart 属性提供。
  • Thread(ParameterizedThreadStart, Int32):这会添加 maxStackSize 参数。 避免使用此重载,因为最好允许 .NET 管理堆栈大小。
  • Thread(ThreadStart, Int32):这会添加 maxStackSize 参数。 避免使用此重载,因为最好允许 .NET 管理堆栈大小。

我们的第一个示例使用 Thread(ThreadStart) 构造函数。 让我们看一下该代码的一个版本,它使用 ParameterizedThreadStart 通过限制 while 循环的迭代次数来传递值:

Console.WriteLine("Hello, World!");

//创建新线程
var bgThread = new Thread((object? data) =>
{
    if (data is null) return;
    
    int counter = 0;
    var result = int.TryParse(data.ToString(), out int maxCount);
    if (!result) return;
    
    while (counter < maxCount)
    {
        bool isNetworkUp = System.Net
            .NetworkInformation
            .NetworkInterface
            .GetIsNetworkAvailable();
        
        Console.WriteLine($"Is network available? Answer: {isNetworkUp}");
        Thread.Sleep(100);
        
        counter++;
    }
});

// 设置为后台线程
bgThread.IsBackground = true;

// 开启线程并传入参数 12  <-- data
bgThread.Start(12);

for (int i = 0; i < 10; i++)
{
    Console.WriteLine("Main thread working...");
    Task.Delay(500);
}


Console.WriteLine("Done");
Console.ReadKey();

如果运行该应用程序,它将像上一个示例一样运行,但后台线程应该只向控制台输出 12 行。 您可以尝试将不同的整数值传递到 Start 方法中,看看这对控制台输出有何影响。

如果要获取对正在执行当前代码的线程的引用,可以使用 Thread.CurrentThread 静态属性:

var currentThread = System.Threading.Thread.CurrentThread;

如果您的代码需要检查当前线程的 ManagedThreadId、优先级或它是否在后台运行,这会很有用。

暂停线程执行

有时,需要暂停线程的执行。 现实生活中一个常见的例子是后台线程上的重试机制。 如果您有一个方法将日志数据发送到网络资源,但网络不可用,您可以调用 Thread.Sleep 等待特定的时间间隔,然后再重试。 Thread.Sleep 是一个静态方法,它将阻止当前线程指定的毫秒数。 无法在当前线程以外的线程上调用 Thread.Sleep

我们已经在本章的示例中使用了 Thread.Sleep,但让我们稍微更改一下代码,看看它如何影响事件的顺序。 将线程内的 Thread.Sleep 间隔更改为 10,删除使其成为后台线程的代码,并将 Task.Delay() 调用更改为 Thread.Sleep(100)

Console.WriteLine("Hello, World!");

var bgThread = new Thread((object? data) =>
{
    if (data is null) return;
    
    int counter = 0;
    var result = int.TryParse(data.ToString(), out int maxCount);
    if (!result) return;
    
    while (counter < maxCount)
    {
        bool isNetworkUp = System.Net.NetworkInformation
            .NetworkInterface
            .GetIsNetworkAvailable();
        
        Console.WriteLine($"Is network available? Answer: {isNetworkUp}");
        Thread.Sleep(10);
        counter++;
    }
});

bgThread.Start(12);

for (int i = 0; i < 12; i++)
{
    Console.WriteLine("Main thread working...");
    Thread.Sleep(100);
}

Console.WriteLine("Done");
Console.ReadKey();

再次运行应用程序时,您可以看到,在主线程上设置更大的延迟可以让 bgThread 内的进程在主线程完成其工作之前开始执行:

图 1.2 – 使用 Thread.Sleep 更改事件顺序

在这里插入图片描述

可以调整两个 Thread.Sleep 间隔以查看它们如何影响控制台输出。 试一试!

此外,还可以将 Timeout.Infinite 传递给 Thread.Sleep。 这将导致线程暂停,直到被另一个线程或托管环境中断或中止。中断被阻止或暂停的线程是通过调用 Thread.Interrupt 来完成的。 当一个线程被中断时,它会收到一个ThreadInterruptedException异常。

异常处理程序应该允许线程继续工作或清理任何剩余的工作。如果异常未处理,运行时将捕获异常并停止线程。 在正在运行的线程上调用 Thread.Interrupt 在该线程被阻塞之前不会产生任何效果。

销毁托管线程

通常,销毁托管线程被认为是不安全的做法。 这就是 .NET 6 不再支持 Thread.Abort 方法的原因。 在 .NET Framework 中,在线程上调用 Thread.Abort 将引发 ThreadAbortedException 异常并停止线程运行。.NET Core 或任何较新版本的 .NET 中不支持中止线程。 如果某些代码需要强制停止,建议您在与其他代码不同的进程中运行它,并使用 Process.Kill 终止其他进程。

任何其他线程终止都应该使用取消来协作处理。 我们将在计划和取消工作部分了解如何执行此操作。 接下来,我们来讨论一些例外情况
使用托管线程时进行处理。

处理线程异常

有几种特定于托管线程的异常类型,包括我们在上一节中介绍的 ThreadInterruptedException 异常。 另一种特定于线程的异常类型是 ThreadAbortException。 但是,正如我们在上一节中讨论的那样,.NET 6 中不支持 Thread.Abort,因此,虽然 .NET 6 中存在这种异常类型,但没有必要处理它,因为这种类型的异常仅在 .NET Framework 应用程序中可能出现。

另外两个异常是 ThreadStartException 异常和 ThreadStateException 异常。 如果在执行线程中的任何用户代码之前启动托管线程时出现问题,则会引发 ThreadStartException 异常。 当线程上调用的方法在线程处于其当前 ThreadState 属性中时不可用时,将引发 ThreadStateException 异常。 例如,在已经启动的线程上调用 Thread.Start 是无效的,并且会导致 ThreadStateException 异常。 通常可以通过在对线程执行操作之前检查 ThreadState 属性来避免这些类型的异常。

在多线程应用程序中实现全面的异常处理非常重要。 如果托管线程中的代码开始无提示地失败,没有任何日志记录或导致进程终止,
应用程序可能会陷入无效状态。 这还可能导致性能下降和无响应。 虽然许多应用程序可能会很快注意到这种降级,但某些服务和其他非基于 GUI 的应用程序可能会持续一段时间而不会注意到任何问题。 将日志记录添加到异常处理程序以及在日志报告故障时提醒用户的过程将有助于防止未检测到的故障线程出现问题。

跨线程同步数据

在本节中,我们将了解 .NET 中可用于跨多个线程同步数据的一些方法。 如果处理不当,跨线程共享数据可能成为多线程开发的主要痛点之一。 .NET 中具有线程保护的类被认为是线程安全的。
多线程应用程序中的数据可以通过多种不同的方式同步:

• 同步代码区域(Synchronized code regions):仅使用Monitor 类或在.NET 编译器的帮助下同步所需的代码块。
• 手动同步(Manual synchronization):.NET 中有多种同步原语可用于手动同步数据。
• 同步上下文(Synchronized context):仅在.NET Framework 和Xamarin 应用程序中可用。
System.Collections.Concurrent 类:有专门的.NET 集合来处理并发性。 我们将在第 9 章中研究这些内容。

同步代码区域(Synchronized code regions)

您可以使用多种技术来同步代码区域。 我们要讨论的第一个是 Monitor 类。 您可以通过调用 Monitor.EnterMonitor.Exit 包围可由多个线程访问的代码块:

...
Monitor.Enter(order);
order.AddDetails(orderDetail);
Monitor.Exit(order);
...

在此示例中,假设您有一个由多个线程并行更新的订单对象。 当当前线程向订单对象添加 orderDetail 项时,Monitor 类将锁定其他线程的访问。 最大限度地减少给其他线程引入等待时间的机会的关键是仅锁定需要同步的代码行。

Interlocked 类提供了多种方法,用于对跨多个线程共享的对象执行原子操作。 以下方法列表是 Interlocked 类的一部分:

Add: 这将两个整数相加,用两个整数之和替换第一个整数

And:这是两个整数的按位与运算

CompareExchange:比较两个对象是否相等,如果相等则替换第一个对象

Decrement: 递减一个整数

Exchange:这将变量设置为新值

Increment:递增一个整数

Or:这是两个整数的按位或运算

这些互锁操作将仅在该操作期间锁定对目标对象的访问。
此外,C# 中的 lock 语句可用于将对代码块的访问锁定为仅允许单个线程。 lock 语句是使用 .NET Monitor.Enter 实现的语言构造
Monitor.Exit 操作。
有一些内置编译器支持锁定和监视器块。 如果这些块之一内抛出异常,锁就会自动释放。 C# 编译器围绕同步代码生成一个 try/finally 块,并在 finally 块中调用 Monitor.Exit

手动同步

跨多个线程同步数据时,通常使用手动同步。
某些类型的数据无法通过其他方式保护,例如:
• 全局字段(Global fields):这些是可以在应用程序中全局访问的变量。
• 静态字段(Static fields):这些是类中的静态变量。
• 实例字段(Instance fields):这些是类中的实例变量。

这些字段没有方法体,因此无法在它们周围放置同步代码区域。 通过手动同步,您可以保护使用这些对象的所有区域。 这些区域可以使用 C# 中的锁定语句进行保护,但其他一些同步原语提供对共享数据的访问,并且可以在更细粒度的级别上协调线程之间的交互。 我们要检查的第一个构造是 System.Threading.Mutex 类。

Mutex 类与 Monitor 类类似,它阻止对代码区域的访问,但它也可以提供向其他进程授予访问权限的能力。 使用 Mutex 类时,使用 WaitOne()ReleaseMutex() 方法来获取和释放锁。 让我们看一下相同的订单/订单详细信息示例。 这次,我们将使用在类级别声明的 Mutex 类:

private static Mutex orderMutex = new Mutex();
...
orderMutex.WaitOne();

order.AddDetails(orderDetail);


orderMutex.ReleaseMutex();
...

如果要在 Mutex 类上强制执行超时期限,可以使用超时值调用 WaitOne 重载:

orderMutex.WaitOne(500);

需要注意的是,Mutex 是一次性类型。 使用完对象后,您应该始终对对象调用 Dispose()。 此外,您还可以将一次性类型包含在 using 块中以间接处置它。
在本节中,我们要检查的最后一个 .NET 手动锁定构造是 ReaderWriterLockSlim 类。 如果您有一个跨多个线程使用的对象,则可以使用此类型,但大多数代码都是从该对象读取数据。 您不想在正在读取数据的代码块中锁定对对象的访问,但您确实希望在更新或同时写入对象时阻止读取。 这称为“多个读取,单独写入”。

ContactListManager 类包含可以添加到电话号码或通过电话号码检索的联系人列表。 该类假设这些操作可以从多个线程调用并使用
ReaderWriterLockSlim 类在 GetContactByPhoneNumber 方法中应用读锁,并在 AddContact 方法中应用写锁。 锁在finally块中释放,以确保即使遇到异常,它们也始终被释放:

public class ContactListManager
{
    private readonly List<Contact> contacts;
    
    private readonly ReaderWriterLockSlim contactLock =  new ReaderWriterLockSlim();
    
    public ContactListManager( List<Contact> initialContacts)
    {
   		contacts = initialContacts;
    }
    
    public void AddContact(Contact newContact)
    {
        try
        {
            contactLock.EnterWriteLock();
            contacts.Add(newContact);
        }
        finally
        {
        	contactLock.ExitWriteLock();
        }
    }
    
    public Contact GetContactByPhoneNumber(string phoneNumber)
    {
        try
        {
            contactLock.EnterReadLock();
            return contacts.FirstOrDefault(x =>x.PhoneNumber == phoneNumber);
        }
        finally
        {
            contactLock.ExitReadLock();
        }
    }
}

如果要将 DeleteContact 方法添加到 ContactListManager 类,则可以利用相同的 EnterWriteLock 方法来防止与类中的其他操作发生任何冲突。 如果在一次使用联系人时忘记锁定,则可能会导致任何其他操作失败。 此外,还可以对 ReaderWriterLockSlim 锁应用超时:

contacts.EnterWriteLock(1000);

调度与取消工作线程

调度托管线程

当涉及托管线程时,调度并不像听起来那么明确。 没有机制告诉操作系统在特定时间开始工作或在特定时间间隔内执行。 虽然您可以编写这种逻辑,但可能没有必要。 调度托管线程的过程只需通过设置线程的优先级来管理。 为此,请将 Thread.Priority 属性设置为可用的 ThreadPriority 值之一:HighestAboveNormalNormal(默认)、BelowNormalLowest

通常,较高优先级的线程将先于较低优先级的线程执行。 通常,在所有较高优先级线程完成之前,最低优先级的线程不会执行。 如果最低优先级线程已启动并且正常线程启动,则最低优先级线程将被挂起,以便正常线程可以运行。 这些规则不是绝对的,但您可以将它们用作指南。 大多数时候,您将保留线程的默认值:Normal
当存在多个具有相同优先级的线程时,操作系统将循环遍历它们,在挂起工作并继续处理下一个相同优先级的线程之前,为每个线程提供最大分配的时间。 逻辑会因操作系统而异,并且进程的优先级可能会根据应用程序是否位于 UI 的前台而变化。

让我们使用网络检查代码来测试线程优先级:

  1. 首先在 Visual Studio 中创建一个新的控制台应用程序
  2. 向项目添加一个名为 NetworkingWork 的新类,并添加一个名为 CheckNetworkStatus 的方法,其实现如下:
public void CheckNetworkStatus(object data)
{
    for (int i = 0; i < 12; i++)
    {
        bool isNetworkUp = System.Net.NetworkInformation.
            NetworkInterface
            .GetIsNetworkAvailable();
        
        Console.WriteLine($"Thread priority {(string)data}; Is network available?  Answer: {isNetworkUp}");
        i++;
    }
}

调用代码将传递一个带有当前正在执行消息的线程的优先级的参数。 这将作为 for 循环内控制台输出的一部分添加,以便用户可以看到哪些优先级线程首先运行。
3.接下来,将Program.cs的内容替换为以下代码:

using BackgroundPingConsoleApp_sched;

Console.WriteLine("Hello, World!");
var networkingWork = new NetworkingWork();

var bgThread1 = new Thread(networkingWork.CheckNetworkStatus);
var bgThread2 = new Thread(networkingWork.CheckNetworkStatus);
var bgThread3 = new Thread(networkingWork.CheckNetworkStatus);
var bgThread4 = new Thread(networkingWork.CheckNetworkStatus);
var bgThread5 = new Thread(networkingWork.CheckNetworkStatus);

bgThread1.Priority = ThreadPriority.Lowest;
bgThread2.Priority = ThreadPriority.BelowNormal;
bgThread3.Priority = ThreadPriority.Normal;
bgThread4.Priority = ThreadPriority.AboveNormal;
bgThread5.Priority = ThreadPriority.Highest;

bgThread1.Start("Lowest");
bgThread2.Start("BelowNormal");
bgThread3.Start("Normal");
bgThread4.Start("AboveNormal");
bgThread5.Start("Highest");

for (int i = 0; i < 10; i++)
{
	Console.WriteLine("Main thread working...");
}

Console.WriteLine("Done");
Console.ReadKey();

该代码创建了五个 Thread 对象,每个对象都有不同的 Thread.Priority 值。为了让事情变得更有趣,线程按照其优先级的相反顺序启动。 您可以尝试自行更改此设置,看看执行顺序如何受到影响。

  1. 现在运行应用程序并检查输出:

图 1.3 – 五个不同线程的控制台输出

在这里插入图片描述

您可以看到,操作系统(在我的例子中是 Windows 11)有时会在所有较高优先级线程完成其工作之前执行较低优先级线程。 选择下一个要运行的线程的算法有点神秘。 您还应该记住,这是多线程。多个线程同时运行。 可同时运行的线程的确切数量将因处理器或虚拟机配置而异。

取消托管线程

取消托管线程是了解托管线程的更重要的概念之一。 如果您有在前台线程上运行的长时间运行的操作,它们应该支持取消。 有时,您可能希望允许用户通过应用程序的 UI 取消进程,或者取消可能是应用程序关闭时清理进程的一部分。

要取消托管线程中的操作,您将使用 CancellationToken 参数。Thread 对象本身没有像某些现代线程构造 .NET 那样内置对取消标记的支持。 因此,我们必须将令牌传递给在新创建的线程中运行的方法。 在下一个练习中,我们将修改前面的示例以支持取消:

  1. 首先更新 NetworkingWork.cs,以便传递给 CheckNetworkStatus 的参数是 CancellationToken 参数:
public void CheckNetworkStatus(object data)
{
    var cancelToken = (CancellationToken)data;
    
    while (!cancelToken.IsCancellationRequested)
    {
        bool isNetworkUp = System.Net
            .NetworkInformation.NetworkInterface
            .GetIsNetworkAvailable();
        
        Console.WriteLine($"Is network available?Answer: {isNetworkUp}");
    }
}

该代码将在 while 循环内不断检查网络状态,直到 IsCancellationRequested 变为 true

  1. Program.cs 中,我们将返回仅使用一个 Thread 对象。 删除或注释掉所有以前的后台线程。 要将 CancellationToken 参数传递给 Thread.Start 方法,请创建一个新的 CancellationTokenSource 对象,并将其命名为 ctSource。 取消令牌可在 Token 属性中找到:
var pingThread = new Thread(networkingWork.CheckNetworkStatus);

//取消线程
var ctSource = new CancellationTokenSource();
pingThread.Start(ctSource.Token);
...
  1. 接下来,在 for 循环内添加 Thread.Sleep(100) 语句,以允许 pingThread 在主线程挂起时执行:
for (int i = 0; i < 10; i++)
{
    Console.WriteLine("Main thread working...");
    Thread.Sleep(100);
}
  1. for 循环完成后,调用 Cancel() 方法,将线程连接回主线程,并释放 ctSource 对象。 Join 方法将阻塞当前线程并使用该线程等待 pingThread 完成:
...
ctSource.Cancel();
pingThread.Join();
ctSource.Dispose();
  1. 现在,当您运行应用程序时,您将看到网络检查在主线程上的最终 Thread.Sleep 语句执行后不久停止:

图 1.4 – 取消控制台应用程序中的线程

在这里插入图片描述

现在,网络检查器应用程序在侦听击键以关闭应用程序之前会优雅地取消线程工作。

当托管线程上有一个长时间运行的进程时,您应该在代码迭代循环、开始进程中的新步骤以及在其他逻辑检查点处检查取消情况。
的过程。 如果操作使用计时器定期执行工作,则每次计时器执行时都应检查令牌。

监听取消的另一种方法是注册一个委托,以便在请求取消时调用。 将委托传递给托管线程内的 Token.Register 方法
接收取消回调。 以下 CheckNetworkStatus2 方法的工作方式与前面的示例完全相同:

public void CheckNetworkStatus2(object data)
{
    bool finish = false;
    var cancelToken = (CancellationToken)data;
    
    cancelToken.Register(() => {
        // Clean up and end pending work
        finish = true;
    });
    
    while (!finish)
    {
        bool isNetworkUp = System.Net.NetworkInformation
        	.NetworkInterface
            .GetIsNetworkAvailable();
        
        Console.WriteLine($"Is network available? Answer:{isNetworkUp}");
    }
}

如果您的代码有多个部分需要侦听取消请求,那么使用这样的委托会更有用。 回调方法可以调用多个清理方法或设置另一个标志
在整个线程中受到监视。 它很好地封装了清理操作。

  • 29
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

0neKing2017

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值