并行编程-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();
在运行前面的代码并检查输出之前,让我们讨论一下它的每个部分:
- 第一个
Console.WriteLine
语句是由项目模板创建的。 我们将其保留在这里以帮助说明控制台中的订单输出。 - 接下来,我们创建一个名为
bgThread
的新线程类型。 在线程体内,有一个while
循环,它会不断执行,直到线程终止。 在循环内,我们调用GetIsNetworkAvailable
方法并将调用结果输出到控制台。 在重新开始之前,我们使用Thread.Sleep
注入 100 毫秒的延迟。 - 创建线程后的下一行是本课的重点部分:
bgThread.IsBackground = true;
将 IsBackground
属性设置为 true
可以使我们的新线程成为后台线程。 这告诉我们的应用程序,线程内执行的代码对应用程序并不重要,并且进程可以终止,而无需等待线程完成其工作。 这是一件好事,因为我们创建的 while
循环永远不会完成。
- 在下一行中,我们使用
Start
方法启动线程。 - 接下来,应用程序在应用程序的主线程内开始一些工作。 for 循环将执行 10 次并向控制台输出“主线程正在工作...”。 在循环的每次迭代结束时,
Task.Delay
用于等待 500 毫秒,希望为后台线程提供一些时间来执行某些工作。 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.Enter
和 Monitor.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
值之一:Highest
、AboveNormal
、Normal
(默认)、BelowNormal
或 Lowest
。
通常,较高优先级的线程将先于较低优先级的线程执行。 通常,在所有较高优先级线程完成之前,最低优先级的线程不会执行。 如果最低优先级线程已启动并且正常线程启动,则最低优先级线程将被挂起,以便正常线程可以运行。 这些规则不是绝对的,但您可以将它们用作指南。 大多数时候,您将保留线程的默认值:Normal
。
当存在多个具有相同优先级的线程时,操作系统将循环遍历它们,在挂起工作并继续处理下一个相同优先级的线程之前,为每个线程提供最大分配的时间。 逻辑会因操作系统而异,并且进程的优先级可能会根据应用程序是否位于 UI 的前台而变化。
让我们使用网络检查代码来测试线程优先级:
- 首先在 Visual Studio 中创建一个新的控制台应用程序
- 向项目添加一个名为
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
值。为了让事情变得更有趣,线程按照其优先级的相反顺序启动。 您可以尝试自行更改此设置,看看执行顺序如何受到影响。
4. 现在运行应用程序并检查输出:
图 1.3 – 五个不同线程的控制台输出
您可以看到,操作系统(在我的例子中是 Windows 11)有时会在所有较高优先级线程完成其工作之前执行较低优先级线程。 选择下一个要运行的线程的算法有点神秘。 您还应该记住,这是多线程。多个线程同时运行。 可同时运行的线程的确切数量将因处理器或虚拟机配置而异。
取消托管线程
取消托管线程是了解托管线程的更重要的概念之一。 如果您有在前台线程上运行的长时间运行的操作,它们应该支持取消。 有时,您可能希望允许用户通过应用程序的 UI 取消进程,或者取消可能是应用程序关闭时清理进程的一部分。
要取消托管线程中的操作,您将使用 CancellationToken
参数。Thread
对象本身没有像某些现代线程构造 .NET 那样内置对取消标记的支持。 因此,我们必须将令牌传递给在新创建的线程中运行的方法。 在下一个练习中,我们将修改前面的示例以支持取消:
- 首先更新
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
。
- 在
Program.cs
中,我们将返回仅使用一个Thread
对象。 删除或注释掉所有以前的后台线程。 要将CancellationToken
参数传递给Thread.Start
方法,请创建一个新的CancellationTokenSource
对象,并将其命名为ctSource
。 取消令牌可在Token
属性中找到:
var pingThread = new Thread(networkingWork.CheckNetworkStatus);
//取消线程
var ctSource = new CancellationTokenSource();
pingThread.Start(ctSource.Token);
...
- 接下来,在
for
循环内添加Thread.Sleep(100)
语句,以允许pingThread
在主线程挂起时执行:
for (int i = 0; i < 10; i++)
{
Console.WriteLine("Main thread working...");
Thread.Sleep(100);
}
for
循环完成后,调用Cancel()
方法,将线程连接回主线程,并释放ctSource
对象。Join
方法将阻塞当前线程并使用该线程等待pingThread
完成:
...
ctSource.Cancel();
pingThread.Join();
ctSource.Dispose();
- 现在,当您运行应用程序时,您将看到网络检查在主线程上的最终
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}");
}
}
如果您的代码有多个部分需要侦听取消请求,那么使用这样的委托会更有用。 回调方法可以调用多个清理方法或设置另一个标志
在整个线程中受到监视。 它很好地封装了清理操作。