原文:zh.annas-archive.org/md5/56ec4473fdab4d33463dddef4fa20d6b

译者:飞龙

协议:CC BY-NC-SA 4.0

第四章:线程缠结的问题

并发 和线程

线程并发是大多数开发者认为他们已经全部了解的东西。理论听起来很简单,但在实践中,线程是许多错误发生的地方,也是所有那些令人沮丧的 bug 的起源。线程可能相当复杂,但 BCL 和 CLR 团队的人们已经尽他们所能帮助我们,使事情尽可能简单。

一旦你掌握了它,线程就是你技能的一个很好的补充,并且可以在你的系统中产生重大影响。

在本章中,我们将探讨以下主题:

  • 并发和线程是什么?

  • 线程在.NET 和 Windows 内部是如何工作的?

  • CLR 是如何帮助我们的?

  • async/await 是什么?

  • 我们如何同步线程并使它们协同工作?

  • 我如何确保我的代码在处理线程时表现良好?

  • 我如何使用线程上的集合?

让我们深入了解这个迷人的主题!

技术要求

本章中所有的源代码和示例都可以从本书的 GitHub 仓库下载,网址为github.com/PacktPublishing/Systems-Programming-with-C-Sharp-and-.NET/tree/main/SystemsProgrammingWithCSharpAndNet/Chapter04

并发和线程——基础知识

今天早上,我像往常一样醒来。我起床,洗了个澡,然后穿好衣服。然后我遛狗 30 分钟(今天是星期天)。我回到家,泡了些咖啡,然后坐下来写这篇文档。

我敢肯定,你的日常活动在总体上看起来是一样的。你做一件事,然后做下一件事。事情按顺序完成。有时,当我遛狗时,我会给其他时区的人打电话,但大多数时候,我一次只做一件事。这样更有效率。如果我坐下来写这一章,但五分钟后停下来遛狗,然后让我跑回家写五分钟,再跑回去接狗再走 500 米,事情永远也完成不了。我会因为来回跑而得到锻炼,但这会很低效。

这是一种愚蠢的生活方式(没有评判;如果你这样做,我对此表示理解,但这对我来说不起作用)。

然而,在计算机的情况下,我们倾向于认为这种工作方式可以使工作更快地完成。我们为什么这么想?

计算机不能同时做两件事。不,等等。让我换个说法。CPU 核心不能同时做两件事。在 2005 年 AMD 发布了Athlon 64 X2 处理器之前,以及同一年 Intel 发布了 Pentium D 之前,普通的计算机都是单核的。这意味着在 2005 年之前,计算机通常一次只能做一件事。

现在,大多数设备都有多个核心。你的电脑、笔记本电脑和手机都有多核处理器。然而,作为系统程序员,你可能会遇到只有单个核心的设备。想想物联网设备:它们需要便宜且功耗非常低。这些系统通常只有一个核心。系统程序员遇到单核设备比编写其他软件的人更常见。

然而,最终,这并不重要。我的主要开发机器有 16 个核心。这听起来很多。然而,如果我看我的任务管理器,我可以看到许多事情同时运行,远超过这 16 个核心可以处理的。所以,即使在多核环境中,机器也必须做些事情来使所有这些任务得以运行。作为系统程序员,我们必须意识到如何编写我们的软件,以从这些核心中获得最大利益。

因此,我们在这里处理两个独立的话题。一个是并发;另一个是线程。

并发是一种概念,即系统在重叠的时段内执行多个操作序列。这并不是真正的同时执行;那被称为并行。它完全是关于任务在看似相同的时间运行,而不需要等待其他任务。这是一个概念,而不是一种编程技术。

另一方面,线程是程序员的结构。线程是实现并发的一种方式。

值得了解

线程可以是硬件线程或软件线程。CPU 处理第一种类型;第二种类型在我们的软件中处理。操作系统OS)可以将线程分配给实际的硬件线程,但作为开发者,你几乎总是要处理软件线程。在这里,我主要会谈论软件线程,但当我指的是硬件线程时,我会指出这一点。

并发的起源——中断请求(IRQ)

目前,让我们忽略这样一个事实:除了将负载分散到 CPU 可能拥有的物理核心之外,计算机无法进行多任务处理。为了简化问题,我们将假设计算机可以同时做两件事。

这并不总是如此。在早期,计算机一次只做一件事。这意味着如果你为计算机编写了一些软件,你就完全控制了所有可用的硬件。一切都是你的,而且是你的独有。

嗯,当我说那是你的,我的意思是那主要是你的。有时,会发生一些需要 CPU 注意的事情。在那些日子里,我们有一种叫做中断请求IRQ)的东西。中断请求是一种通常与硬件相关的硬件功能。一个外部设备,如软盘驱动器或调制解调器,可以通过在 CPU 的特定连接上施加电压来向 CPU 发出信号。当发生这种情况时,CPU 会完成它正在执行的指令,将所有状态存储在内存中,查找属于那个中断请求的地址(可能有多个),然后在该地址处启动代码。当该功能完成时,整个过程会逆转:CPU 将加载之前存储的状态,并继续执行原始代码,就像什么都没发生一样。

这种机制工作得相当不错,但存在许多潜在问题。例如,只有少数几个中断请求线路可用。如果你的代码覆盖了附加到某些硬件的另一段代码的注册,那么该硬件将无法正常工作。

更糟糕的是,如果你犯了一个愚蠢的错误,你的代码从未从中断请求中返回,你可能会使整个机器停止运行。它将简单地永远不会从你的代码中返回,并且正在运行的程序将无限期地挂起。因此,你必须非常小心,确保你的代码中没有这样的错误!

中断请求(IRQs)今天仍在使用,尤其是在像 Raspberry Pi 这样的低功耗设备中。我们将在本书的后面遇到它们。

协作式和抢占式多任务处理

中断请求(IRQs)工作得还可以,但它们应该由硬件设备使用。由于中断请求的数量并不多,并且它们有杀死正在运行进程的潜在能力,所以我们已经不再在常规软件中使用它们。

然而,拥有计算机却只能用它做一件事情,这似乎是一种资源的浪费。计算机变得越来越强大。它们很快就能做比我们要求它们做的事情更多的事情。那时,多任务操作系统就出现了。

例如,Windows 95 之前的 Windows 版本,如 Windows 3.1,使用了一种叫做协作式多任务处理的东西。原理相当简单。一段代码会做某事,当它认为可以休息一下时,它就会告诉操作系统:“嘿,我在休息;如果你需要我做些什么,请告诉我。”然后它会停止执行。这意味着操作系统可以将 CPU 时间分配给另一个进程。

我们称之为协作式多任务处理,因为我们期望软件能够合作并公平地共享资源。

当然,如果一个程序行为不当,它仍然可以声称所有的 CPU 时间,从而阻止其他软件按预期运行。

需要一种更好的方法。Windows NT 3.1 以及后来的 Windows 95 做得更好:它们引入了抢占式多任务处理

这个想法很简单:为进程分配一些运行时间,当时间到了,就存储该进程的状态,将其停放某处,然后继续下一个进程。当原始进程再次需要做某事时,操作系统将程序重新加载到内存中,并恢复状态,然后进程可以继续。除非进程跟踪时钟,否则进程对它休眠的时间一无所知。

进程不能再声称所有可用的 CPU 时间。如果其时间已用完,操作系统将暂停该进程。

预先多任务处理仍然是现代操作系统今天的工作方式。

然而,所有这些都涉及在计算机上同时运行的多个进程。我们如何让一个进程同时做多件事情呢?好吧,一个解决方案就是使用线程。

C#中的线程

线程是一个概念,它允许计算机在您的程序中同时做更多的事情。就像操作系统允许多个程序同时运行一样,线程允许您的程序在应用程序中并发运行多个流程。线程不过是您程序中的一个执行流程。您始终至少有一个线程:当程序开始执行时启动的那个线程。我们称之为主线程。运行时管理这个线程,您对其控制很少。然而,所有其他的线程都是您的,您可以随意对它们进行操作。

线程并不是什么神奇的东西。基本原理很简单:

  1. 创建一个您想要运行的方法、函数或任何其他代码片段。

  2. 创建一个线程,给它传递方法的地址。

  3. 启动线程。

  4. 操作系统或运行时在运行主线程的同时执行那个方法或函数。

  5. 您可以监控那个线程的进度。您可以等待它结束,或者您可以使用一种“发射后不管”的策略,只需让它完成其工作即可。

您如何执行这些步骤取决于您想使用哪个版本。您是选择.NET 方式还是选择我们熟知的 Win32 API 的兔子洞?

在.NET 中,线程由一个实际的类(或者更准确地说,是一个类的实例)表示。在 Win32 中,它们只是由 Win32 API 创建的东西。

Win32 线程

在 Win32 中,您使用CreateThread API 创建线程。我想向您展示它是如何工作的,但我要坦白:您可能永远不会在您的代码中这样做。使用 Win32 API 创建线程的方法有很多更好的选择。尽管如此,在某些情况下,完全控制 Win32 线程可能是必要的。

让我向您展示如何在 Win32 API 中完成这个操作。

我们将首先声明一个delegate。这个delegate是包含线程执行的工作的函数的形式:

public delegate uint ThreadProc(IntPtr lpParameter);

由于我们正在调用 Win32 API,我们需要导入它们:

[DllImport("kernel32.dll", SetLastError = true)]
public static extern IntPtr CreateThread(
    IntPtr lpThreadAttributes,
    uint dwStackSize,
    ThreadProc lpStartAddress,
    IntPtr lpParameter,
    uint dwCreationFlags,
    out uint lpThreadId
);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool CloseHandle(IntPtr hObject);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern uint WaitForSingleObject(IntPtr
hHandle, uint dwMilliseconds);

我们将导入三个 API:CreateThreadCloseHandleWaitForSingleObject

在我们可以使用这些 API 之前,我们必须编写执行有用操作的代码。在这种情况下,这并不是真正有用的代码,但这是将在线程中执行的代码:

public uint MyThreadFunction(IntPtr lpParameter)
{
    for (int i = 0; i < 1000; i++)
        Console.WriteLine("Unmanaged thread");
    return 0;
}

这个 MyThreadFunction 函数与之前定义的委托相匹配。

在清除所有这些之后,我们可以创建线程,并让我们的程序执行某些操作。或者更确切地说,它可以同时执行许多操作。下面是操作步骤:

public void DoWork()
{
    uint threadId;
    var threadHandle = CreateThread(
        IntPtr.Zero,
        0,
        MyThreadFunction,
        IntPtr.Zero,
        0,
        out threadId
    );
    // Wait for the thread to be finished
    WaitForSingleObject(threadHandle, 1000);
    // Clean up
    CloseHandle(threadHandle);
}

DoWork() 方法通过调用 CreateThread Win32 API 创建线程。此 API 有一些参数。让我借助 表 4.1 来解释它们的作用:

参数 描述
IntPtr lpThreadAttributes 安全属性结构的指针
uint dwStackSize 此线程所需的堆栈大小
ThreadProc lpStartAddress 线程运行的函数的指针
IntPtr lpParameter 指向传递给线程的变量的指针
uint dwCreationFlags 确定线程创建方式的附加标志
out uint lpThreadId 一个输出参数,包含线程的 ID

表 4.1:CreateThread Win32 API 的参数

安全属性定义了谁或什么可以访问线程以及此线程可以使用什么。安全属性相当复杂。在这里,我们不会深入探讨它们,主要是因为在处理线程时它们并不常用。在这里,我们将安全属性设置为 IntPtr.Zero

dwStackSize 参数定义了线程使用的堆栈大小。如前所述,每个线程都有自己的堆栈,可以在其中存储其值类型。当线程完成后,此堆栈将被回收。

然后,我们获取线程启动时将执行的函数指针。在 C#中,我们可以传递方法的名称,让编译器完成找出内存地址的繁琐工作。

在提供方法启动地址后,我们得到一些更有趣的东西:我们可以将数据传递到线程方法中。lpParameter 参数是指向数据所在内存的指针。除非你想使用简单的 Int32,否则将数据放入线程中相当繁琐。毕竟,IntPtr 是一个 32 位值,所以你可以将一个 int 转换回转换以获取线程函数中的数据。这里我没有传递任何东西,但稍后在本章中我会向你展示如何做到这一点。

接下来是定义系统如何创建线程的标志。除了默认的 0,表示“不执行特殊操作”之外,我们可以使用两个标志。这些标志在 表 4.2 中解释。

标志 含义
0 0x00000000 不执行特殊操作
CREATE_SUSPENDED 0x00000004 创建线程,但立即挂起而不是启动。
STACK_SIZE_PARAM_IS_A_RESERVATION 0x00010000 如果设置此标志,则堆栈大小是预留的。如果没有设置,则堆栈大小是已提交的。

表 4.2:线程创建选项

CREATE_SUSPENDED创建线程,但在创建时将其置于挂起状态。默认行为是立即运行lpStartAddress指向的代码。

STACK_SIZE_PARAM_IS_A_RESERVATION是一个有趣的标志。这个标志是您可能想要使用 Win32 线程创建版本而不是.NET 版本的主要原因之一。每个线程都有自己的栈。您可以指定该栈应该有多大,但当你这样做时,发生的所有事情只是系统保留该内存。这种保留是一个快速操作。保留只是告诉系统您希望在某个时候使用这么多内存。如果系统没有足够的内存来满足您的请求,您将收到错误。

然而,内存尚未提交。提交意味着操作系统为您请求的内存保留,并将其标记为被进程使用。保留只是告诉它您希望在以后使用该内存。

页面错误

当您的应用程序请求内存或尝试从系统访问内存时,可能会发生某些事情。

第一种情况发生在内存已在您的栈或堆中可用时。您获得了该内存的指针;现在它完全属于您了。

如果内存尚未在您的栈或堆中,但系统上可用,接下来会发生什么。这会导致软页面错误。系统会将新内存添加到当前的栈或堆中。

接下来,可能您想要访问的内存不在您计算机的内存芯片中。在这种情况下,它可能已经被交换到磁盘上。这是一个硬页面错误。操作系统将从磁盘加载内存并将其添加到您的工作集。

页面错误对于增加系统的灵活性非常有用。然而,它们会带来很大的性能损失。

当您保留内存并想要访问它时,可能会发生页面错误。当这种情况发生时,您的应用程序性能将下降。

如果您提交内存,则它在需要时保证可用。这使得您的内存占用更大、速度更快,因为您不会遇到页面错误。

您必须在这里做出选择:您更喜欢两种场景中的哪一种?您可以通过STACK_SIZE_PARAM_IS_A_RESERVATION标志来控制栈。

代码示例以两个语句结束:WaitForSingleObject()CloseHandle()。本章中的同步线程部分更详细地解释了WaitForSingleObject()。不过,简短的描述如下:在主线程继续之前等待线程完成。

CloseHandle清理所有已使用的资源。是的,这是一个未管理资源。这是一个使用IDisposable模式的好地方。

.NET 线程

.NET BCL 中的线程使用起来要简单得多。当然,当某件事被简化时,您通常会因此牺牲灵活性。

以下示例显示了如何使用.NET 结构执行与 Win32 线程相同的工作。

我们将从线程函数开始,它在新的线程上运行。它几乎与 Win32 示例相同。以下代码片段显示了我们要在线程内运行的代码:

void MyThreadFunction()
{
    for (var i = 0; i < 1000; i++)
        Console.WriteLine("Managed thread");
}

在我们的代码的主体中,我们创建线程,给它运行的功能,然后启动它:

var myManagedThread = new Thread(MyThreadFunction);
myManagedThread.Start();
myManagedThread.Join();

我们创建Thread类的新实例,并在构造函数中传递我们想要使用的方法。然后我们启动它。然后,我们使用Join()等待它,实际上暂停了主线程,直到我们的新线程完成它正在做的事情。

就这样。如果你与 Win32 版本进行比较,我确信你会欣赏这种简单性。

然而,不要被这种简单性欺骗:这并不意味着你不能控制你的线程。你可以控制它们,并且你可以做比我所展示的更多的事情。例如,你也可以指定你想要为你的线程使用的堆栈大小:

var myHugeStackSize = 8 * 1024 * 1024; // 8 MB
var myManagedThread = new Thread(MyThreadFunction, myHugeStackSize);

在这里,我们为新线程分配了 8 MB 的堆栈。

很高兴了解

32 位应用程序的默认堆栈大小为 1 MB;对于 64 位应用程序,为 4 MB。你很少需要超过这个大小。只有在测试了你的应用程序并发现你确实需要它时,才应该请求大堆栈。

在 Win32 示例中,我们必须明确指出我们想要创建一个处于挂起状态的线程。如果我们没有这样做,它将立即启动。在.NET 中,情况不同。在.NET 中创建的新线程被认为是未启动。这意味着它不会立即启动。它也还没有挂起;在行为上存在相当大的差异。

一个挂起的线程已经完全形成,并被放置在操作系统的调度器列表中。它的堆栈已分配,所有资源都存在。

一个Thread类。堆栈尚未分配,它还没有被分配给操作系统,因此它还没有在调度器上,等等。

当我们在.NET 线程上调用Start()时,运行时会做所有这些工作。创建线程比 Win32 中的CreateThread()调用要快得多,但当你启动线程时,这种性能提升就丢失了。把它想象成懒加载初始化。

CLR 的设计者利用了这一点。如果创建线程相对便宜,而只有在使用它们时才变得昂贵,为什么不将创建的负担移到程序开始时呢?启动应用程序需要时间;如果我们稍微延长这一点,那就没关系了。然而,这意味着当它在使用时,我们有一个更快的系统。当我们需要一两个线程时,我们可以有一个线程池可用。这正是他们所做的事情。

一个例子可能会使这更清晰。然而,在我能向你展示那之前,我们必须做一些修改。我们想要创建许多同时运行的线程。为了区分每个线程的输出,我们需要向那个线程传递一些数据,以便它可以显示它。因此,我们需要有存储数据的地方。

我们需要不可变数据,原因将在本章后面讨论线程安全性时变得清晰。C# 9 中添加的record是一个实现这一点的绝佳方式:

internal record ThreadData(int LoopCounter);

我们现在可以开始编写在特定线程中执行的方法:

void MyThreadFunction(object? myObjectData)
{
    // Verify that we have a ThreadData object
    if (myObjectData is not ThreadData myData)
        throw new ArgumentException("Parameter is not a                     ThreadData object");
    // Get the thread ID
    var currentThreadId = Thread.CurrentThread.ManagedThreadId;
    // Write the data to the Console
    Console.WriteLine(
        $"Managed thread in Thread {currentThreadId} " +
        $"with loop counter {myData.LoopCounter}");
}

线程获取一个Nullable<object>类型的参数。我们不能将其声明为其他类型,因为这是运行时所期望的。

要使用这些数据,我们需要将其转换为正确的类型。

然后,我们将获取当前线程的 ID。每个线程都有一个唯一的 ID,因此我们可以与之交互,尽管我们在这里只会显示它。

让我们创建一些线程:

for (int i = 0; i < 100; i++)
{
    ThreadData threadData = new(i);
    var newThread = new Thread(MyThreadFunction);
    newThread.Start(threadData);
}
Console.ReadKey();

我们将创建一百个线程,并在创建后立即启动它们。我们将给它们一些数据,以查看我们在循环中的位置。

在循环之后,我添加了Console.ReadKey(),以确保在所有线程完成之前程序不会退出。当你运行程序时启动的主线程是特殊的:如果它结束,CLR 将结束整个程序并卸载所有内存。所以,保持你的主线程活跃直到你确信所有工作都完成是至关重要的。在实际场景中,你不会使用Console.ReadLine()来做这件事,但在这个演示中,它工作得很好。

如果你运行这个程序,你可能会看到线程 ID 随着循环计数器的增加而增加。它们并不相等。CLR 在你运行循环之前已经创建了一打或更多的线程。

如果你将循环增加到执行更多的迭代次数,你最终会看到线程 ID 偶尔相同。CLR 会重用线程以避免线程饥饿。

然而,我承诺要向你展示线程池。将代码中我们原本的 for 循环部分替换为以下代码:

for (int i = 0; i < 100; i++)
{
    ThreadData threadData = new(i);
    ThreadPool.QueueUserWorkItem(MyThreadFunction, threadData);
}
Console.ReadKey();

我们将在这里使用线程池,在需要时从池中取出线程。如果你运行这个程序,你会反复看到相同的线程 ID。线程从池中被取出并使用正确的数据启动。当线程完成时,它会关闭,其资源会被释放,然后被放回池中,以便在需要时再次使用。

负载很小,优势巨大。使用这种系统的系统效率更高。

ThreadPool隐藏了许多你可以使用的秘密和技巧,但它的使用在很大程度上已经被任务并行库TPL)所取代,它为你处理了大部分工作。让我们看看。

任务和并行库 – TPL

TPL 已经存在了一段时间。它是在 2010 年随着.NET 4.0 的发布而引入的。

TPL 简化了我们过去用线程做的许多事情。线程仍然有其位置,尤其是在处理第三方库时。然而,在大多数情况下,我们可以让 TPL 来处理这些事情。

在 TPL 中,Task类是主要的操作类。Task是一个在需要时处理线程实例化的类。它做得多得多,但我们稍后再讨论。

我说“当需要时”,因为它是足够智能的,能够确定何时需要一个新的线程。

让我们从简单的例子开始,然后逐步深入:

Task myTask = Task.Run(() => { Console.WriteLine("Hello from the task."); });
Console.WriteLine("Main thread is done.");
Console.ReadKey();

Task只是另一个 C#类,它为我们处理了大部分并发。在这种情况下,我们调用static method Run(),它接受一个委托来执行。

我们可以将其重写如下:

Task myTask = Task.Run(DoWork);
Console.WriteLine("Main thread is done.");
Console.ReadKey();
return 0;
void DoWork()
{
    Console.WriteLine("Hello from the task.");
}

这个代码片段做的是同样的事情,但我们调用方法而不是使用 lambda 表达式。

我们可以用稍微不同的方式做到同样的事情:

Task myTask = new Task(DoWork);
myTask.Start();

我省略了Console相关的内容和实际的方法;它们将保持不变(直到我说我已经改变了它们)。

这段代码基本上与上一个示例做的是同样的事情。区别在于Task不会启动,除非我们明确调用Start()

第二个示例为你提供了对任务更多的控制。你可以在启动任务之前设置属性和改变任务的行为。Task.Run()主要设计用于“发射后不管”的场景。Start()更加灵活;它允许我们改变调度,例如,指定它在一个特定的线程上运行。你也可以这样指定Task的优先级。

这个例子并不非常吸引人。让我们尝试让它变得更有趣。我们可以将我们的方法改为以下内容:

void DoWork(int id)
{
    Console.WriteLine($"call Id {id}.");
}

我们将在我们的方法中添加一个参数来识别调用者。由于我们现在有一个参数,我们必须也改变如何将这个参数传递给Task构造函数。让我们不要止步于此。想象一下,我们想要链式调用方法。在Task完成使用Id 1DoWork之后,我们希望它再次调用那个方法,但这次使用Id 2。在现实生活中,你可能会链式调用两个完全不同的方法,但工作方式是相同的。

代码看起来是这样的:

Task myTask = new Task(() => DoWork(1));
myTask.ContinueWith((prevTask) => DoWork(2));
myTask.Start();

我们已经更改了构造函数中的参数,以便我们可以将那个1整数传递给方法。下一行更有趣。它说:“当你完成第一步后,再次调用DoWork,但这次使用Id 2。”prevTask参数是已经完成工作的前一个Task。这触发了第二个Task的开始。

如果你运行这个程序,你会看到控制台按正确顺序打印的行。

让我们重写一次被调用的方法:

void DoWork(int id)
{
    Console.WriteLine($"call Id {id}, " +
                      $"running on thread " +
                      $"{Thread.CurrentThread.ManagedThreadId}.");
}

我们将这个方法运行的线程的id添加到输出中。我还想在开始任务之前看到这个id线程。我们的调用代码现在看起来是这样的:

Console.WriteLine($"Our main thread id =
{Thread.CurrentThread.ManagedThreadId}.");
Task myTask = new Task(() => DoWork(1));
myTask.ContinueWith((prevTask) => DoWork(2));
myTask.Start();

如果你运行这个程序,你可能会看到任务在不同的线程上运行,而不是主线程。如果你重复几次,甚至可能发生第二个任务在第一个任务不同的线程上运行的情况。这种情况何时发生是不可预测的;调度器会根据当前条件选择最佳方案。我们不必担心这个问题。它只是正常工作。这不是很酷吗?

TPL 中另一个很棒的类是Parallel类。它允许我们并行执行操作。让我们看看这个:

Console.WriteLine($"Our main thread id =
{Thread.CurrentThread.ManagedThreadId}.");
int[] myIds = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
Parallel.ForEach(myIds, (i) => DoWork(i));

首先,我们将打印当前线程的 id。然后,我们将创建一个从 110 的整数数组;这里没有什么特别的。之后,我们将调用 Parallel 类的静态 ForEach 方法,并给它提供数组和要调用的 lambda 表达式。该方法并行地遍历数组,并使用正确的参数调用 lambda 表达式。它这样做的方式不是顺序的,而是与标准的 ForEach 循环不同。

当你运行这个程序时,你会看到一些令人兴奋的结果。程序打印 ID 的顺序完全是随机的。你会看到运行时使用了多个线程,但有时它会重用其中的一些线程。

再次强调,TPL 确定最佳做法,并为你处理所有线程的创建和调度。

TPL 非常强大。它也是 async/await 模式的支柱。这是一个简化并发工作的模式,以至于大多数用户都没有意识到幕后发生了什么。凭借你新获得的知识,你应该没有问题跟踪正在发生的事情。那么,让我们来看看 async/await。

Async/await

软件几乎从不孤立运行。大多数软件需要在某个时刻超出其边界并访问代码块之外的东西。例如,读取和写入文件、从网络读取数据、向打印机发送数据等等。假设一台典型的机器可以在大约 10 纳秒内访问内存中的一个字节。从 SSD 读取相同的字节大约需要 1,000,000 纳秒或更长。从外部设备读取数据通常比从内存中读取本地数据慢 100,000 到 1,000,000 倍。当你尝试优化代码时,如果你知道你的软件在将数据传输到外部硬件和从外部硬件传输数据时,请考虑这一点。

让我们更进一步。让我们假设你有一台处理数据速度相当快的机器。你需要从外部网站读取数据。你的程序必须等待很长时间,数据才可用。数据到达我们这里可能需要毫秒。对我们这些普通人来说,这已经很快了,但计算机在这段时间内本可以做一百万件其他任务。这似乎是对我们昂贵的资源的巨大浪费,对吧?

线程当然可以帮助我们。你可以创建一个线程来调用外部网站,并等待它完成,同时做其他事情。然而,正如我们所看到的,线程可能会相当繁琐。TPL 有所帮助,但事情仍然可能变得复杂。从外部源读取数据或将数据写入外部目标如此常见,以至于 CLR 设计者决定通过引入 async/await 来帮助我们。

自顶向下的方法很简单:任何耗时超过简单操作的事情都应该异步执行。然而,我们不想直接处理线程本身。Async/await,它内部使用 TPL,是一种可以帮助我们的模式。

它所做的是这样:一旦你有需要异步运行的代码,编译器就会注入代码,将我们的代码包装到一个状态机中。这个状态机跟踪线程和我们的代码的进度,并在需要关注的代码块之间来回切换。

这听起来很复杂吗?嗯,确实是。然而,使用方法是直接的。然而,在我向你展示它之前,我想先介绍一个小小的辅助代码,我经常在讨论 async/await 时使用。这段代码只是对 string 类的一个扩展方法,并将一个 string 输出到控制台,并添加 ManagedThreadId。它甚至允许对输出进行着色,使得区分不同的线程更容易。如果你想使用这个,请随意。如果你宁愿在所有地方都使用 Console.WriteLine(),那也请随意。然而,使用这个可以使代码的关键部分更容易阅读。以下是我的扩展方法:

using static System.Threading.Thread;
namespace ExtensionLibrary;
public static class StringExtensions
{
    public static string Dump(this string message,         ConsoleColor printColor = ConsoleColor.Cyan)
    {
        var oldColor = Console.ForegroundColor;
        Console.ForegroundColor = printColor;
      Console.WriteLine($"({CurrentThread.ManagedThreadId})\t :         {message}");
        Console.ForegroundColor = oldColor;
        return message;
    }
}

你也可以在 GitHub 仓库中找到这段代码。

首先,我想给你展示一个最简单的例子:

using ExtensionLibrary;
DoWork();
// The program is paused until DoWork is finished.
// This is a waste of CPU!
"Just before calling the long-running DoWork()"
    .Dump(ConsoleColor.DarkBlue);
"Program has finished".Dump(ConsoleColor.DarkBlue);
Console.ReadKey();
void DoWork()
{
    "We are doing important stuff!".Dump(ConsoleColor.DarkYellow);
    // Do something useful, then wait a bit.
    Thread.Sleep(1000);
}

想象一下,我们想在 DoWork() 方法中做一件需要很长时间的事情,比如从存储中读取文件。我在这里通过暂停当前线程一秒钟来模拟这一点。当我们在这个主方法中调用它时,整个程序都会暂停。我们昂贵的强大 CPU 被闲置(至少不是为我们程序)。这似乎很浪费!我们已经看到我们可以使用线程或 TPL 来改进这一点。然而,这段代码也被包装在 async/await 模式之中,所以为什么不使用这个呢?

要做到这一点,我将 Thread.Sleep() 替换为对 Task.Delay() 的调用。这基本上做了同样的事情,但允许我们改进我们的代码。记住:这个 Thread.Sleep() 和新的 Task.Delay() 方法只是我们应用程序应该做的实际工作的替代品。在代码中有一个 Sleep()Delay() 方法通常是一个坏主意。

如果你必须调用一个异步方法,你必须等待它。所以,我们在调用 Task.Delay() 之前添加了 await 关键字。

一旦我们完成了替换,我还会在方法前加上 async 关键字。这个关键字告诉编译器应该将这个方法包装在我之前提到的状态机中。然而,任何异步方法都不应该返回 void,原因将在稍后变得清楚。我们需要返回一个 TaskTask<>,如果你实际上返回了某些内容。所以,我们将我们的 void 改为 Task。同样,任何异步方法都需要用 await 关键字来调用。所以,结果看起来像这样:

using ExtensionLibrary;
"Just before calling the long-running DoWork()"
    .Dump(ConsoleColor.DarkBlue);
await DoWork();
// The program is no longer paused until DoWork is finished.
// This allows the CPU to keep working!
"Program has finished".Dump(ConsoleColor.DarkBlue);
Console.ReadKey();
async Task DoWork()
{
    "We are doing important stuff!".Dump(ConsoleColor.DarkYellow);
    // Do something useful, then wait a bit.
    await Task.Delay(1000);
}

运行这个看看会发生什么。

你可能会看到程序从一个线程开始,然后在同一个线程上执行 DoWork() 方法,但完成之后会切换到新的线程。这是因为编译器看到了我们的 Task.Delay() 等待操作,并决定释放 CPU 去做其他事情。运行时将我们的当前线程挂起,并将其状态存储在内存中,这样我们的主代码就可以自由地做其他事情。只有当 Task.Delay() 完成后,我们的主线程才会被恢复。然而,由于主线程不再与我们的代码相关联,我们需要一个新的线程。这个线程是从 ThreadPool 中拉取的(记住:那里很快,因为线程是在启动时创建的),并填充了我们之前的状态。然后系统可以继续在这个线程上运行。程序也是在那个新线程上结束的!

我提到所有异步方法都需要 async 修饰符,并应该返回一个 Task 而不是 void。这样做有一个简单的理由。如果你不这样做,你的代码会工作,但不会像预期的那样。异步一直到底规则很简单,但非常重要。

异步一直到底!

如果你有一个包含 await 关键字的方法,那么这个方法必须是异步的,并返回一个 Task。然而,由于你可能会在某个地方自己调用这个方法,所以调用代码也必须是异步的,并返回某种形式的 TaskTask<>。因为这个方法也会被调用……嗯,你明白这个意思。规则是:异步一直到底!链中的每个方法都需要有异步!

另一条规则,它不像“异步一直到底”规则那样严格,是所有异步方法都应该这样命名。我们的 DoWork() 方法应该重命名为 DoWorkAsync()

然而,在我们这样做之前,让我们看看如果我们粗心大意,没有返回一个 Task 会发生什么。试试看:将 Task 返回类型替换为 void,并在 DoWork() 之前移除 await(你不能等待 void,所以如果你不移除它,你会得到一个错误)。

运行它。它运行得很好,对吧?好吧,没有创建新的线程,但谁在乎呢?软件做了它需要做的事情。

现在,让我们稍微修改一下我们的 DoWork() 方法:

using ExtensionLibrary;
"Just before calling the long-running DoWork()"
    .Dump(ConsoleColor.DarkBlue);
DoWork();
"Program has finished".Dump(ConsoleColor.DarkBlue);
//Console.ReadKey();
async void DoWork()
{
    "We are doing important stuff!"
        .Dump(ConsoleColor.DarkYellow);
    await Task.Delay(1000);
    throw new Exception(
        "Something went terribly wrong."
    );
    "We're done with the hard work."
        .Dump(ConsoleColor.DarkYellow);
}

我也暂时移除了 ReadLine(),使程序更贴近现实。主线程在一切完成后结束。

运行它。看到我们没有得到 “我们已经完成了艰苦的工作” 的消息。这是有道理的;它前面有一个异常。然而,请注意,我们也没有看到那个异常。

为什么会这样?这很复杂,但简化的解释是,由于DoWork仍然是一个异步方法,状态机仍然被创建。异常是在不同的线程上抛出的(在Task.Delay()等待之后)。然而,由于状态机没有配置为等待所有结果(因为我们省略了await关键字),它只是忽略了那个线程。如果你将那个“我们已经完成了艰苦的工作”的Dump()行移动到异常之前的行,你会看到它没有被调用。实际上,它确实被调用了;你只是没有看到它。这个线程已经变成了一个“发射并遗忘”的线程。你失去了对它的所有控制。

你能想象一个复杂的软件,其中在代码深处出了问题吗?你能想象没有获取到异常吗?你能想象调试那个的恐怖吗?

如果你一路使用 async/await,你会得到那个异常。

哦,在我忘记之前:我移除Console.ReadKey()行的原因是我这样做迫使主线程尽快退出,从而卸载应用程序从内存中。如果你恢复那行代码,你会看到异常,因为主线程在那里暂停。现在其他事情将被允许发生。

然而,这并不是我们问题的真正解决方案。你不想在获取异常之前等待主线程空闲。这可能需要很长时间才能发生。

请恢复 async/await 关键字,将DoWork()中的 void 替换为Task,然后运行它。异常正是在你预期的地方抛出的。

这真的很重要,所以我喜欢重复一遍:从上到下都是异步的!

Task.Wait()和 Task.Result

关于为什么不应该使用Task.Wait()Task.Result有很多博客文章和文章。这个原因很简单:这些调用会阻塞当前线程。使用它们会移除调度器在Task完成时恢复调用线程工作并返回执行流程的能力。如果你这样做,为什么还要使用 async/await 呢?Async/await 还允许线程同步,因此不需要使用Wait()Result

等一下。有些情况下,你可能会决定仍然使用它们:

  • 如果你正在对正在现代化的遗留代码进行工作,你可能想使用它们。规则是“从上到下都是异步的”,这可能需要大量的代码重构。这并不总是可行的。在这些情况下,你可能使用Wait()Result代替。

  • 在单元测试中,你可以模拟或存根异步方法。然而,有时单元测试使用Wait()Result可能更好。

  • 在系统编程中,你可能不关心主线程保持响应。毕竟,没有用户界面。所以,阻塞主线程可能不是一个大问题。我仍然认为不使用 async/await 是不好的做法,但在这种情况下,你可以用Wait()Result来解决问题。

就像软件开发中的所有规则一样,对这些规则保持警惕,尽可能多地应用它们,只有在你有充分的理由并且深思熟虑之后才打破这些规则。此外,请为你未来的自己行个方便,在源代码中记录你选择偏离常规工作方式的原因。

因此,现在你知道如何使用 async/await。尽管它们并不总是导致多线程,但它们是平衡应用程序负载的绝佳方式。它们在保持代码组织方面大有裨益。你无需再承担在线程之间进行所有同步的负担。然而,这并不意味着你永远不需要关心同步。这是不可避免的,所以我认为我们现在应该讨论一下。

线程同步

async/await 模式让我们的开发者生活变得更加容易。如果你有一个长时间运行的任务(记住:任何使用 CPU 之外的设备的事情都是长时间运行的),你可以异步调用该方法。然后你可以坐下来等待它完成,而不会阻塞应用程序其他地方的执行。TPL 负责线程管理。

然而,有时你可能想要有更多的控制权。你可能遇到这样的情况,你必须等待一个方法完成才能继续。想象一下,你有一个主线程并调用 A() 方法。该方法运行时间较长,因此你将其改为异步(将其重命名为以“async”结尾的名称)并更改返回类型为 TaskTask<>。现在你可以等待它。然而,另一个线程可能必须等待你的 Aasync() 方法完成。你如何做到这一点?

欢迎来到线程同步的奇妙世界。

同步——我们如何做到这一点?

在过去,当我们仍然使用线程和 ThreadPool 时,同步可能会很麻烦。然而,随着 Task 和 async/await 的出现,事情变得容易多了,而且没有真正的缺点。在我向你展示这一点之前,我想先展示如何同步线程而不是任务。

让我从基础程序开始:

using ExtensionLibrary;
"In the main part of the app.".Dump(ConsoleColor.White);
ThreadPool.QueueUserWorkItem(DoSomethingForTwoSeconds);
ThreadPool.QueueUserWorkItem(DoSomethingForOneSecond);
"Main app is done.\nPress any key to
stop.".Dump(ConsoleColor.White);
Console.ReadKey();
return 0;
void DoSomethingForOneSecond(object? notUsed)
{
    $"Doing something for one second.".Dump(ConsoleColor.Yellow);
    Thread.Sleep(1000);
    $"Finished something for one second".Dump(ConsoleColor.Yellow);
}
void DoSomethingForTwoSeconds(object? notUsed)
{
    "Doing something for two
        seconds.".Dump(ConsoleColor.DarkYellow);
    Thread.Sleep(2900);
    "Done doing something for two
        seconds.".Dump(ConsoleColor.DarkYellow)
}

这个示例现在应该很清楚。我有两个方法,它们执行一些需要很长时间才能完成的事情。我从 ThreadPool 中拉出一些线程,并使所有这些同时运行。

如果你运行这个,在 Console.ReadKey() 位置。如果我们想在继续之前等待两个方法完成,我们能做什么?

答案是使用同步机制。这意味着我们有一个对象,我们可以用它来标记某些状态。我们可以自己编写它,但我们必须注意很多同步和线程安全问题。幸运的是,我们不必这样做。Win32 API 提供了一些工具,它们被巧妙地封装在 BCL 类中。

其中之一是 CountdownEvent 类。正如其名所示,它允许我们进行事件倒计时。

将你的主方法修改如下:

"In the main part of the app.".Dump(ConsoleColor.White);
// Tell the system we want to wait for 2 threads to finish.
CountdownEvent countdown = new(2);
ThreadPool.QueueUserWorkItem(DoSomethingForOneSecond);
ThreadPool.QueueUserWorkItem(DoSomethingForTwoSeconds);
// Do the actual waiting.
countdown.Wait();
"Main app is done.\nPress any key to stop.".Dump(ConsoleColor.White);
Console.ReadKey();
return 0;

我们将创建一个 CountdownEvent 类的新实例并将其初始化为 2

然后,我们将获取线程并允许它们完成工作。

在方法中的代码,我添加了一行:

void DoSomethingForOneSecond(object? notUsed)
{
    $"Doing something for one second.".Dump(ConsoleColor.Yellow);
    Thread.Sleep(1000);
    $"Finished something for one second".Dump(ConsoleColor.Yellow);
    countdown.Signal();
}

在方法底部,你会看到 .Signal() 倒计时。由于这个实例在这个方法中是可访问的,我可以使用它。Signal() 告诉倒计时减少等待事件的数量。

我对 DoSomethingForTwoSeconds() 方法也做了同样的处理。

这意味着当两个方法都完成后,它们会在倒计时上调用 Signal()。在主方法中,我在 ThreadPool 代码之后添加了 countdown.Wait(),告诉主线程暂停,直到倒计时达到零。

如果你运行这个程序,你会看到它运行得非常出色,并且主线程的其他部分与线程完美同步。

然而,如果我想在 DoSomethingForOneSecond 完成后启动 DoSomethingForTwoSeconds 方法呢?

这几乎同样简单。我们可以使用其他同步类之一来帮助我们。让我展示如何使用 ManualResetEvent 来完成这个操作。这个类或多或少与 CountdownEvent 类似。区别在于 ManualResetEvent 类不计数,它只是等待信号。

在主方法中,在调用 ThreadPool 之前,我添加了这一行:

ManualResetEvent mre = new(false);

我将其设置为初始的 False 状态。这样做会导致任何线程等待事件被设置。

DoSomethingForOneSecond() 中,我在最后添加了一行:

// Tell the second thread it can start
mre.Set();

Set 的调用告诉 ManualResetEvent 任何等待的线程都可以继续。

DoSomethingForTwoSeconds() 中,我在方法的开头添加了以下内容:

// Wait for the first thread to finish.
mre.WaitOne();

WaitOne() 告诉代码暂停线程,直到 mre 收到信号(这发生在 DoSomethingForOneSecond() 的末尾)。

如果你现在运行你的程序,你会注意到一切都很完美地同步,等待其他任务完成。

当然,你可能通过不使用线程就达到了完全相同的结果。我们基本上从我们的应用程序中移除了所有多任务。如果你需要同步线程,现在你知道如何做了。然而,要小心:如果你出错,可能会引入奇怪的错误。相信我:调试多线程应用程序可不是件轻松的事。

使用 async/await 进行同步

到现在为止,你可能已经猜到了,使用 async/await 可以显著降低处理线程和它们之间同步的复杂性。

让我们回到 DoSomethingForOneSecondDoSomethingForTwoSeconds 方法的例子。这次,我们将重新编写它们以使用 async/await。

你的 DoSomethingForOneSecond 应该是这样的:

async Task DoSomethingForOneSecondAsync()
{
    $"Doing something for one second.".Dump(ConsoleColor.Yellow);
    await Task.Delay(1000);
    $"Finished something for one second".Dump(ConsoleColor.Yellow);
}

我将函数重命名,以异步结尾,因为我应该早就这样做。

DoSomethingForTwoSecondsAsync() 应该得到同样的处理。

在主方法中调用这些方法现在看起来是这样的:

"In the main part of the app.".Dump(ConsoleColor.White);
await DoSomethingForOneSecondAsync();
await DoSomethingForTwoSecondsAsync();
"Main app is done.\nPress any key to stop.".Dump(ConsoleColor.White);
Console.ReadKey();
return 0;

结果与我们在自己进行所有同步的示例中得到的相同,唯一的区别是我们不再有阻塞的线程。所以这不仅更容易做,而且也更好。

然而,如果我们不想按顺序执行这些方法,而是想让它们同时运行会怎样?如果我们想让它们同时运行会怎样?

好吧,这很简单。由于我们的方法返回一个 Task,我们可以处理它。我们不是逐个等待它们,而是可以同时等待它们。让我给你展示一下:

var task1 = DoSomethingForOneSecondAsync();
var task2 = DoSomethingForTwoSecondsAsync();
// Wait for all tasks to be finished
Task.WaitAll(task1, task2);
"Main app is done.\nPress any key to stop.".Dump(ConsoleColor.White);
Console.ReadKey();
return 0;

我们将利用我们获取任务的事实。Task 类有一个名为 WaitAll() 的静态方法,它只有在所有任务都完成时才返回。

还有其他方法,例如 WaitAny(只有当任何任务完成时才继续),WhenAll(当它们都完成时执行某些操作),以及 WhenAny(你可以自己弄清楚)。

WaitAllWaitAnyWhenAllWhenAny 之间的区别在于 WaitXXX 是一个阻塞调用。它会阻塞当前线程,直到条件满足。WhenXXX 返回一个 Task 本身,你可以 await 它,因此不会阻塞线程。

然而,这里有一个更大的区别:WhenAll 允许你捕获返回的结果。如果你想要等待完成的任何任务返回一个结果,你可以通过 WhenAll 获取它。WhenAll 将结果以数组的形式返回给你。你可以访问它们,这是 WaitAllWaitAny 无法做到的。

如果你有所怀疑,WhenAny 返回一个 Task<T>。这个 Task<T> 有一个名为 Result 的属性;你可以读取这个属性来获取对那个 Task 结果的访问。这是一个使用 Result 实际上是一件好事的例子!

取消线程

有时候,你可能想要停止一个线程的运行。这样做可能有几个很好的理由,但无论你的理由是什么,一定要清理自己的“战场”。线程使用起来成本很高,将它们留在未知状态是一种糟糕的做法:你有一天会自食其果。

在 .NET Framework 的日子里,Thread 类有一个名为 Abort() 的方法。然而,结果证明这个方法弊大于利,因此 BCL 和 CLR 的人决定废除它。如果你尝试中止一个线程,你会得到一个 PlatformNotSupportedException。我想他们真的不希望我们再使用它了。

停止正在运行的线程的最佳方式与停止正在运行的 Task 的方式相同:使用我们称之为协作取消的东西。调用线程可以请求另一个线程停止。这取决于第二个线程是否遵守那个请求——或者不遵守。没有保证。

做这件事的标准方式是使用一个 CancellationTokenCancellationToken 是我们用来表示我们想要取消某事的信号的对象。

当然,你可以自己编写这个类。除了线程安全之外,没有太多的事情发生。然而,在你的线程或任务中包含一个 CancellationToken 可以清楚地让用户知道它可以被取消。

我将稍微重写我们的 DoSomethingForOneSecondAsyncMethod()

async Task DoSomethingForOneSecondAsync()
{
    $"Doing something for one second.".Dump(ConsoleColor.Yellow);
    for(int i=0;i<1000;i++)
        await Task.Delay(1);
    $"Finished something for one second".Dump(ConsoleColor.Yellow);
}

代替使用Task.Delay(1000)调用,我使用1000 await Task.Delay1)。从理论上讲,这将导致一秒的延迟。然而,当你运行这个时,它实际上会花费更长的时间。await 调用本身也会占用一些时间。

我可以测量它花费的时间然后重新计算迭代次数,或者我可以简单地将方法重命名为DoSomethingForSomeUnderterminedAmountOfTimeAsync()。我将把这个决定留给你。

假设我们在主方法中等待了 500 毫秒后感到无聊,并决定停止这个线程。我们该如何实现这一点?

这就是CancellationToken发挥作用的地方。再次强调,CancellationToken是一个简单的类。如果你想创建一个,当然可以,但最好使用一个专门的类。这个CancellationTokenSource类就是为此而创建的,并且能在各种奇怪的情况下工作。它是线程安全的。

让我们在主方法的开始处创建一个:

using ExtensionLibrary;
"In the main part of the app.".Dump(ConsoleColor.White);
using var cts = new CancellationTokenSource();
var task1 = DoSomethingForOneSecondAsync();
Task.WaitAny(task1);
"Main app is done.\nPress any key to stop.".Dump(ConsoleColor.White);
Console.ReadKey();
return 0;

我在这里使用WaitAny是因为我们想在创建任务后和它完成前取消该任务。同时,注意using语句。CancellationTokenSource实现了IDisposable接口,因此我们必须遵守这一点。

取消很简单。在var task1Task.WaitAny()行之间,添加以下内容:

await Task.Delay(500);
"We got bored. Let's cancel.".Dump(ConsoleColor.White);
cts.Cancel();

我们会等待一会儿,然后感到无聊并调用cts.Cancel()

然而,如果你运行这个,什么都不会发生。这并不完全正确;会有很多事情发生。更准确地说,DoSomethingForOneSecondAsync中的整个循环都会发生。

CancellationToken并不是取消正在运行的任务的魔法方法。你必须自己检查这个令牌。

我们必须给我们的方法添加一个CancellationToken类型的参数。现在方法签名将看起来像这样:

async Task DoSomethingForOneSecondAsync(CancellationToken cancellationToken)

我们在调用它时必须传入这个令牌。在我们的主方法中,将调用此方法的行更改为以下内容:

var task1 = DoSomethingForOneSecondAsync(cts.Token);

我们将获取我们的CancellationTokenSource并获取它的令牌。这就是我们将传递给我们的方法的。

在我们的方法内部,我们必须检查是否需要取消。是的,这就是为什么我在Delay周围添加了循环。现在完整的方法将看起来像这样:

async Task DoSomethingForOneSecondAsync(CancellationToken cancellationToken)
{
    $"Doing something for one second.".Dump(ConsoleColor.Yellow);
    bool hasBeenCancelled = false;
    int i = 0;
    for (i = 0; i < 1000; i++)
    {
        if (cancellationToken.IsCancellationRequested)
        {
            hasBeenCancelled = true;
            break;
        }
        await Task.Delay(1);
    }
    if(hasBeenCancelled)
    {
        $"We got interrupted after {i} iterations.".Dump(ConsoleColor.            Yellow);
    }
    else
    {
        $"Finished something for one second".Dump(ConsoleColor.            Yellow);
    }
}

如果有人调用CancellationTokenSourceCancel,令牌上的IsCancellationRequested标志将被设置为True。我们必须尊重这一点。我通过跳出for循环来实现这一点。我还设置了一个hasBeenCancelled变量,这样我就可以通知我们的用户我们已经取消了循环,并告诉他们是在多少次迭代后取消的。

我们本可以跳过这个布尔值并再次使用IsCancellationRequested。然而,可能存在这样的风险:请求是在循环完成后、打印消息之前到达的。在这种情况下,循环没有被中断。但我们还是说它被中断了,这是不正确的。这样我们就可以避免打印错误的消息。

运行它并看看会发生什么。在我的机器上,它大约迭代了 40 次后就会取消。

这段代码中有一个错误。将CancellationToken传递给任何接受它的方法是一种良好的实践。在我们的例子中,那就是Task.Delay()。它有一个接受CancellationToken的重载。

我故意在这里省略了它。因为代码几乎 100%的时间都会在那个行上,等待 Delay,我们会取消它,永远不会看到任何打印的消息。然而,现在让我们添加它:

await Task.Delay(1, cancellationToken);

重新运行它并看看会发生什么。

你可能会注意到我们缺少了很多屏幕输出。原因很简单。Task.Delay()在取消时抛出OperationCancelledException。然而,我们在主方法中没有在Task上使用await,所以我们会错过这个异常。记得我之前说过,当一切没有做对的时候,错过异常是多么容易吗?

同步有助于防止错误发生。然而,有许多技术可以确保我们的代码是线程安全的。现在让我们深入探讨这些技术。

线程安全编程技术

看看这段代码。运行它并看看会发生什么:

using ExtensionLibrary;
int iterationCount = 100;
ThreadPool.QueueUserWorkItem(async (state) =>
{
    await Task.Delay(500);
    iterationCount = 0;
    $"We are stopping it...".Dump(ConsoleColor.Red);
});
await WaitAWhile();
$"In the main part of the app.".Dump(ConsoleColor.White);
"Main app is done.\nPress any key to stop.".Dump(ConsoleColor.White);
Console.ReadKey();
return 0;
async Task WaitAWhile()
{
    do
    {
        $"In the loop at iterations {iterationCount}".            Dump(ConsoleColor.Yellow);
        await Task.Delay(1);
    }while (--iterationCount > 0) ;
}

我们有一个从 100 倒数到 0 的Task。由于我们在这里await它,代码的主要部分会优雅地等待这个操作完成后再继续。然而,我们还有一个等待 500 毫秒后设置计数器为0的第二个线程。结果是循环提前结束。

我们看到的是容易调试的。每一行代码都在一个屏幕上,所以我猜想你将能够很容易地找到这个错误。

然而,如果这里使用的整数是类的一个成员呢?正如你所知,类的实例是引用类型。引用类型是通过引用传递的,而不是通过值。所以如果 Task 可以访问那个实例,它可以改变那个实例的成员。然而,其他任何任务、线程或代码都会看到这些变化的效果。

线程安全就是避免这些情况。

值类型本质上是安全的。如果你将一个整数值类型等值类型传递给Task,你将不会有任何问题。整数的值被复制,你并没有改变原始值。

然而,如果你需要访问更复杂的数据类型,你需要考虑这一点。好消息是运行时为我们提供了几个工具来减轻这个问题。

你得到的一个工具是Lock()关键字。

Lock()

保护你的数据最简单的方法是在它周围使用锁。锁是一个对象,它在一定程度上就像是一段代码的护城河。锁确保只有一个线程可以同时进入那个代码块。语法很简单:

lock (new object())
{
    iterationCount--;
}

锁接受一个参数。它使用这个参数来识别要锁定的区域。它对这个对象不做任何事情;这只是为了将锁钩到某个地方。所以,有一个新的object()就足够了。

代码块中的任何代码都是安全的,这意味着只有一个线程可以同时递减iterationCount。当另一个线程尝试同时执行相同的操作时,它会在到达锁语句时立即阻塞。那个线程会一直阻塞,直到之前的线程退出代码块。

是的,这意味着如果其他线程在那个代码中崩溃(在这个例子中不太可能:在--运算符上崩溃非常罕见),整个系统永远无法进入那个代码块。

Lock()是围绕监视器对象的一种语法糖。编译器实际上使用Monitors。所以,以下代码会产生相同的中间语言(IL):

var lockObject = new object();
Monitor.Enter(lockObject);
try
{
    iterationCount--;
}
finally
{
    Monitor.Exit(lockObject);
    lockObject = null;
}

我不知道你,但对我来说,lock()语句看起来要轻松得多。

记录

确保数据不会意外覆盖的最佳方式是确保数据不能被更改。不可变类型正是为此而设计的。

让我先创建一个记录:

record Counter(int InitialValue);

记录是一种引用类型,所以它的内存是在堆上分配的。然而,记录旨在是不可变的。你可以创建不可变的记录,但这在这里没有帮助。

目前,我有一个只有一个成员InitialValue的记录。在构建Counter时,我必须设置该值,但之后我永远不能更改它。所以,没有线程可以再来修改这个值了。

然而,由于我无法在任何地方更改它,我也必须在Task中的代码进行更改。现在它看起来是这样的:

async Task WaitAWhile()
{
    var actualCounter = myCounter.InitialValue;
    do
    {
        $"In the loop at iterations {actualCounter}".            Dump(ConsoleColor.Yellow);
        await Task.Delay(1);
    } while (--actualCounter > 0);
}

我已经复制了值并在循环中递减它。如果你和我有点相似,你可能会说,“等等。为什么我没有只是将原始的iterationCount复制到一个局部变量中,并使用它而不是这个记录?”

我看到很多人这样做。然而,这并不保证能行得通。如果你在复制之前另一个线程改变了iterationCount的值怎么办?你将从一个错误的初始值开始。

不可变记录保证其内部值永远不会改变,永远如此。你很安全。

避免使用静态成员和类

我知道创建类的实例可能会很麻烦。有时,似乎更容易创建一个静态类,其中包含静态成员,并使用它们代替。它们确实有一些用例。然而,请记住这一点:静态类默认不是线程安全的。静态成员在多个线程之间共享,因此任何人都可以更改它们。

使用volatile关键字

有时,代码看起来很简单,但可能并非如此。看看这一行:

int a=42;

我们知道这是如何工作的。这个整数位于栈上。如果我们更改其值,该内存地址的值也会改变。简单,对吧?错了。编译器会使用各种技巧来优化我们的代码,尤其是在你以发布模式构建时。以发布模式构建意味着编译器可能会缓存简单整数的值以加快速度。它甚至可能决定将这一行移动到代码的另一个位置,如果它认为这不会影响执行。

这不是问题,直到多个线程或任务处理这段代码。编译器可能会出错。它无法确定哪些任务可以同时访问该变量。

是的,即使在多线程系统中简单地写入一个整数也可能出错。

如果我们使用lock(),我们可以保证只有一个线程可以访问那个代码块,但这仍然不能减轻编译器优化的影响。

为了解决这个问题,我们可以使用volatile关键字。它看起来是这样的:

private static volatile int _initialValue = 100;

与使用缓存值不同,编译器确保我们始终直接访问内存地址和存储的值。这意味着所有线程都将前往同一位置并使用相同的整数,从而消除了在旧、过时或缓存数据上工作的风险。

你可能会想将volatile关键字添加到每个地方,但我建议你避免这样做。它会干扰编译器的优化技术。你应该只在怀疑特定代码片段可能存在问题时使用它。

因此,现在你知道如何在与线程打交道时更加安全。这非常重要:如果你搞砸了,你可能会在代码中遇到可怕且难以调试的错误。这尤其适用于你在多个线程中处理集合的情况。你是如何保持它们同步的?幸运的是,BCL 已经为我们解决了这个问题。让我们来谈谈并发集合。

.NET 中的并发集合

集合是许多程序的核心。数组、列表、字典——我们经常使用它们。然而,它们是线程安全的吗?让我们来了解一下:

using ExtensionLibrary;
var allLines = new List<string>();
for(int i = 0; i < 1000; i++)
{
    allLines.Add($"Line {i:000}");
}
ThreadPool.QueueUserWorkItem((_) =>
{
    Thread.Sleep(1000);
    allLines.Clear();
});
await DumpArray(allLines);
"Main app is done.\nPress any key to stop.".Dump(ConsoleColor.White);
Console.ReadKey();
return 0;
async Task DumpArray(List<string> someData)
{
    foreach(var data in someData)
    {
        data.Dump(ConsoleColor.Yellow);
        await Task.Delay(100);
    }
}

我们有一个List<string>。然后我们向该列表中添加了 1000 个字符串。我们有一个任务会遍历它们,将它们显示在屏幕上,并稍作等待。

我们还有一个单独的线程在等待一秒后清除列表。

如果你已经阅读了本章前面的部分,你可能会预期任务中的循环会提前终止。它不应该打印所有项目,因为列表突然为空,因此ForEach()停止。

然而,如果你运行它,你会看到不同的结果。你会得到一个友好的InvalidOperationException,告诉你集合已被修改,这破坏了ForEach代码。

BCL 中的集合不是线程安全的。如果一个线程正在处理它们,而另一个线程决定需要处理那个集合,事情就会出错。

以下集合不是线程安全的,在处理任务时应避免使用:

  • List<T>

  • Dictionary<TKeyTValue>

  • Queue<T>

  • Stack<T>

  • HashSet<T>

  • ArrayList

  • HashTable

  • SortedList<TKey, TValue>, 和 TSortedList

不要在多个线程或任务中同时使用这些。

一些集合是线程安全的。以下是它们的用途:

集合名称 描述
ConcurrentDictionary<TKey, TValue> 一个线程安全的键值对集合。它允许并发添加、更新和删除。
ConcurrentQueue<T> 一个线程安全的先进先出(FIFO)集合版本。
ConcurrentStack<T> 一个线程安全的后进先出(LIFO)集合版本。
ConcurrentBag<T> 一个线程安全、无序的对象集合。适用于顺序不重要的场景。
BlockingCollection<T> 表示一个可以限制大小的线程安全集合。它提供阻塞和非阻塞的addtake操作。

表 4.3:线程安全集合

前四个集合只是我们已知的集合的线程安全版本。然而,大多数人可能不会认出最后一个:BlockingCollection<T>集合。

这个集合首先是一个线程安全的集合。它还允许阻塞。让我给你举个例子:

using ExtensionLibrary;
using System.Collections.Concurrent;
// We have a collection that blocks as soon as
// 5 items have been added. Before this thread
// can continue, one has to be taken away first.
var allLines = new BlockingCollection<string>(boundedCapacity:5);
ThreadPool.QueueUserWorkItem((_) => {
    for (int i = 0; i < 10; i++)
    {
        allLines.Add($"Line {i:000}");
        Thread.Sleep(1000);
    }
    allLines.CompleteAdding();
});
// Give the first thread some time to add items before
// we take them away again.
Thread.Sleep(6000);
// Read all items by taking them away
ThreadPool.QueueUserWorkItem((_) => {
    while (!allLines.IsCompleted)
    {
        try
        {
            var item = allLines.Take();
            item.Dump(ConsoleColor.Yellow);
            Thread.Sleep(10);
        }
        catch (InvalidOperationException)
        {
            // This can happen if
            // CompleteAdding has been called
            // but the collection is already empty
            // in our case: this thread finished before the
            // first one
        }
    }
});
"Main app is done.\nPress any key to stop.".Dump(ConsoleColor.White);
Console.ReadKey();
return 0;

这里发生了很多事情,让我带你了解一下。

首先,我们创建了一个BlockingCollection的实例。这个类有一个很好的重载构造函数,它只允许添加这个数量的项目。如果有更多,则阻塞线程。我这里不需要这个功能,但我发现添加它很有趣。

然后我们启动了一个新线程,向这个集合添加项目。我们可以尝试添加 10 个,但同样,它只允许五个项目。所以,当第五个项目被添加时,这个线程会阻塞,直到我们移除其中一个项目。

在循环结束时,我们告诉集合我们没有剩下要添加的内容。我们通过调用CompleteAdding()来完成这个操作。

在我们读取第二个线程中的数据之前,我们等待了几秒钟,以便第一个线程有时间填充集合。

第二个线程(如果你也计算主线程的话是第三个)从这个集合中取出了一个项目。它是一个先进先出(FIFO)的集合,所以我们可以取出的第一个项目是列表中第一个添加的项目。我们展示了我们取出的内容,并等待了一段时间。我们需要捕获InvalidOperationException。如果在我们已经从集合中取出所有项目之后,由于时间原因调用了CompleteAdding,将会发生异常。我们需要捕获这个异常。

由于我们的时间安排和Thread.Sleep()调用,我们将看到一个迷人的效果。第一个线程用五个项目填充集合。然后它等待。这个操作总共需要五秒钟。程序开始后的六秒钟,我们将开始取项目。由于项目很多(确切地说有五个),程序将快速将这些项目打印到屏幕上。当我们取一个项目时,第一个线程获得添加新项目的权限。然而,由于添加一个项目需要一秒钟,第二个线程必须等待直到项目被添加。如果还没有可取的项目,Take()也会阻塞。

只有当第一个线程调用CompleteAdding()方法时,第二个线程才知道它已经完成(因为我们检查了IsCompleted属性)。然后,我们可以退出线程。

在幕后有许多同步操作,但它工作得非常出色。这无疑是您工具箱中的一个优秀补充!

下一步

这真是一次相当刺激的经历。线程编程可能很复杂,但我们成功地完成了所有这些。

在本章中,我们探讨了众多不同的事情。我们描述了多任务是什么,从老式的中断请求(IRQs)开始,经过协作多任务,最终到达现代的抢占式多任务。

然后,我们研究了 Win32 线程及其.NET 对应物。我们看到了如何创建线程,但很快发现Threadpool在大多数情况下提供了更好的方法。然而,我们了解到大多数这些都是多余的,因为 TPL 为我们处理了很多细节。

特别是,我们了解到 async/await 隐藏了很多复杂性,使得编写多线程代码变得轻而易举。就像所有工具一样,我们也了解到 async/await 伴随着风险。您必须知道会发生什么,以及坏事情可能发生在哪里。幸运的是,我们也涵盖了那些情况。

我们探讨了集合以及如何使您的代码线程安全。我们还学习了一些关于 async/await 的基本知识:从底层到顶层的 async!

异步编程在处理 CPU 外部的设备时是必不可少的。我们需要广泛使用这些技术的领域之一是文件系统。然而,文件系统还有很多其他您需要了解的事情。所以,下一章处理这个主题真是太好了!

第五章:文件系统编年史

文件系统 和 IO

计算机是令人难以置信的机器,但它们有一个缺点。如果电源关闭,它们会忘记一切。如果我们不希望丢失我们的工作,我们必须将其存储在其他地方。我们可以打印数据,将其放在网络上,或者存储在永久存储中。这是最常见的选项。当然,我们需要一种方法将数据输入 CPU。我们可以从文件或网络中读取数据。我们甚至可以使用键盘输入数据。这是我们(程序员)和我(作者)都非常熟悉的事情。

当我们编写软件时,我们提到 的概念。流表示随时间提供的一系列数据元素。这个序列可以存储在磁盘上,可以是通过网络线缆流动的数据,也可以是内存芯片的状态。无论我们使用什么物理介质,数据都必须来回流动。这一章处理这个主题,涵盖流、文件以及其他 输入和输出IO)的方式。

在本章中,我们将不会深入探讨的主题是网络。网络是一个如此不同的概念,以至于将有一个单独的章节来处理这个主题。您可以在 第八章 中找到所有低级网络细节。然而,通过网络处理数据的概念对于文件和其他媒体是相同的。因此,这里阐述的原则仍然适用。

在本章中,我们将涵盖以下主题:

  • 如何使用 .NET 处理文件

  • 如何使用 Win32 API 与文件系统交互

  • 如何处理目录和路径

  • 为什么以及如何使用异步 IO

  • 如何使用加密和压缩

我们有很多内容要覆盖,所以让我们深入探讨吧!

技术要求

要查看本章中的所有代码,您可以访问以下链接:github.com/PacktPublishing/Systems-Programming-with-C-Sharp-and-.NET/tree/main/SystemsProgrammingWithCSharpAndNet/Chapter05

文件写入基础

没有什么比写入文件更直接的了,对吧?这就是为什么我认为这是一个好的起点。以下是实现这一点的代码:

var path = System.IO.Path.GetTempPath();
var fileName = "WriteLines.txt";
var fullPath = Path.Combine(path, fileName);
File.WriteAllText(fullPath, "Hello, System Programmers");

第一行获取系统 temp 路径。然后我们指定文件名,将其添加到 temp 路径中,并向该文件写入一行文本。

这个例子足够简单,但它已经展示了某些有用的内容。首先,我们可以快速访问 temp 文件夹;我们不需要在我们的代码中指定它的位置。其次,我们可以组合文件名和路径,而不用担心路径分隔符。在 Windows 上,路径的部分由反斜杠分隔,而在 Linux 上,这是一个正斜杠。CLR 会确定应该使用什么,并使用正确的一个。

File.WriteAllText 然后使用这些数据创建一个文件,打开它,写入字符串,然后关闭文件。如果文件已经存在,系统将覆盖它。

如果我们想要使用临时文件名而不是 WriteLines.Text,代码可以更加简单:

var path = System.IO.Path.GetTempFileName();
File.WriteAllText(path, "Hello, System Programmers");

系统会查找 temp 文件的路径,生成一个具有唯一文件名的新的文件,并使用该文件写入字符串。缺点是现在我们不知道它是哪个文件。我们必须将其记录在某个地方;否则,我们的 temp 文件夹会很快填满未使用的文件(尽管大多数操作系统都会清理 temp 文件夹,所以这里没有真正的担忧)。

您当然可以使用任何您想要的文件夹。然而,如果您想使用一些特殊文件夹,例如 Windows 上的 Documents 文件夹,系统也可以帮助您访问这些文件夹。看看以下代码片段:

var path = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
var fileName = "WriteLines.txt";
var fullPath = Path.Combine(path, fileName);
File.WriteAllText(fullPath, "Hello, System Programmers");

这段代码查找我的机器上 My Documents 的位置,并返回该位置,以便我可以将文件写入该位置。您可以从一个很长的特殊位置列表中进行选择,所有这些位置都是 SpecialFolder 枚举的一部分。我不会列出所有这些位置;您可以在以下链接中找到它们:learn.microsoft.com/en-us/dotnet/api/system.environment.specialfolder?view=net-8.0

这种写文件的方式毫不费力。然而,正如我们之前多次看到的,方便往往伴随着控制力的减少。作为系统程序员,我们希望获得尽可能多的控制权。让我们夺回一些控制权。

FileStream

静态的 File 类易于使用,如果您想快速向文件写入或从文件读取内容,它非常方便。然而,它并不是最快的方式。至少,如果我们谈论执行时间,它不是最快的。作为系统程序员,我们非常关注速度,即使这意味着放弃编码的便利性。

以下示例比之前的示例快约 20%,但它执行的是相同的事情。它只需要几行额外的代码:

var fileName = Path.GetTempFileName();
var info = new UTF8Encoding(true).GetBytes("Hello, System Developers!");
using FileStream? fs = File.Create(fileName, info.Length);
try
{
    fs.Write(info, 0, info.Length);
}
finally
{
    fs.Close();
}

这个示例使用了 File.Create() 返回的 FileStream。我们当然可以自己创建一个。将创建 FileStream 的行替换为以下内容:

using var fs = new FileStream(
    path: fileName,
    mode: FileMode.Create,
    access: FileAccess.Write,
    share: FileShare.None,
    bufferSize:0x1000,
    options: FileOptions.Asynchronous);

我在这里使用了最全面的重载来向您展示您可以使用的部分选项。大多数选项都是不言自明的,但我想要强调两个参数:shareoptions

Share 是一个标志,用于告诉操作系统在使用文件时如何共享文件。它有以下选项:

标志 描述
None 0 不允许共享。任何尝试访问文件的进程都将失败。
Read 1 当我们仍在使用文件时,其他进程可以读取文件。
Write 2 其他进程可能同时向文件写入。
ReadWrite 3 这结合了 ReadWrite 标志。
Delete 4 这允许我们在使用文件的同时请求删除文件。
Inheritable 16 文件句柄可以被子进程继承。然而,这在 Win32 应用程序中不起作用。

表 5.1:文件共享选项

虽然指定这个列表中的标志可能表明其他进程在我们使用文件时可以对我们文件进行操作,但并不能保证这些其他进程实际上可以这样做。通常,它们还需要其他权限。

Delete是一个很好的标志。它允许我们在使用文件的同时进行删除。这可能会导致奇怪的情况。如果我们创建一个文件并指定我们允许删除,我们可能在另一个进程已经删除文件的情况下向文件写入。系统不会抱怨并继续运行。然而,你最终会失去那个文件,这意味着永远失去了你的数据。让我给你展示一下我的意思:

using System.Text;
var fileName = Path.GetTempFileName();
var info = new UTF8Encoding(true).GetBytes("Hello fellow System Developers!");
using (var fs = new FileStream(
    path: fileName,
    mode: FileMode.Create,
    access: FileAccess.Write,
    share: FileShare.Delete, // We allow other processes to delete the                               //file.
    bufferSize: 0x1000,
    options: FileOptions.Asynchronous))
{
    try
    {
        fs.Write(info, 0, info.Length);
        Console.WriteLine($"Wrote to the file. Now try to delete it.             You can find it here:\n{fileName}");
        Console.ReadKey();
        fs.Write(info);
        Console.WriteLine("Done with all the writing");
        Console.ReadKey();
    }
    finally
    {
        fs.Close();
    }
}
Console.WriteLine("Done.");
Console.ReadKey();

这个例子很简单。我们首先获取一个临时文件名。然后,我们获取构成有效载荷的字节。之后,我们将创建一个FileStream实例,在创建过程中设置一些属性。

其中之一是Share选项。我们将其设置为FileShare.Delete

我们将向文件写入一些数据,然后暂停程序。如果你运行它,这就是你获取输出的时候,它会告诉你文件的名称和位置,然后将其删除。你应该注意到你可以这样做。然后继续程序。正如你所看到的,下一行再次将相同的数据写入我们刚刚删除的文件。什么也没有发生。真的,什么也没有发生。没有错误,也没有任何数据被写入任何地方。

在大多数情况下,这是你想要避免的行为。然而,也许你的用例需要的就是这种行为。在这种情况下,现在你知道如何做到这一点。

更快的是 – Win32

存在一种更快的方式来写入文件。如果我们移除了 CLR 的开销,我们可以将文件写入速度提高大约 20%。速度提高 20%可能意味着一个运行缓慢的应用程序和一个看起来闪电般快速的应用程序之间的区别。通常,这会带来一定的代价。CLR 为我们提供的一切现在都掌握在我们自己的手中。我们必须做更多的工作。然而,如果你在寻找将数据写入文件的最快方式,Win32 方法再次是做这件事的最佳方式。

我们将首先声明一些常量:

private const uint GENERIC_WRITE = 0x40000000;
private const uint CREATE_ALWAYS = 0x00000002;
private const uint FILE_APPEND_DATA = 0x00000004;

GENERIC_WRITE告诉系统我们想要写入文件。CREATE_ALWAYS指定每次调用此方法时都想要创建一个新文件。FILE_APPEND_DATA意味着我们想要向当前文件添加内容(这没有太多意义,因为我们刚刚创建了文件)。

是时候导入 Win32 API 了:

[DllImport("kernel32.dll", SetLastError = true)]
private static extern SafeFileHandle CreateFile(
    string lpFileName,
    uint dwDesiredAccess,
    uint dwShareMode,
    IntPtr lpSecurityAttributes,
    uint dwCreationDisposition,
    uint dwFlagsAndAttributes,
    IntPtr hTemplateFile);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool WriteFile(
    SafeFileHandle hFile,
    byte[] lpBuffer,
    uint nNumberOfBytesToWrite,
    out uint lpNumberOfBytesWritten,
    IntPtr lpOverlapped);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseHandle(SafeFileHandle hObject);

我们将从kernel32.dll导入三个方法。CreateFile创建一个文件,WriteFile向该文件写入,而CloseHandle关闭句柄,在我们的例子中,是文件的句柄。

这就是我们需要的所有内容。让我给你展示它是如何工作的:

public void WriteToFile(string fileName, string textToWrite)
{
    var fileHandle = CreateFile(
        fileName,
        GENERIC_WRITE,
        0,
        IntPtr.Zero,
        CREATE_ALWAYS,
        FILE_APPEND_DATA,
        IntPtr.Zero);
    if (!fileHandle.IsInvalid)
        try
        {
            var bytes = Encoding.ASCII.GetBytes(textToWrite);
            var writeResult = WriteFile(
                fileHandle,
                bytes,
                (uint)bytes.Length,
                out var bytesWritten,
                IntPtr.Zero);
        }
        finally
        {
            // Always close the handle once you are done
            CloseHandle(fileHandle);
        }
    else
        Console.WriteLine("Failed to open file.");
}

根据你现在的知识,你应该能够跟上。我们首先将创建一个具有正确参数的文件。如果这成功了,我们将得到我们想要写入的字节,然后使用WriteFile进行实际的写入。之后,我们将关闭句柄。我们在finally块中这样做;句柄很昂贵,并且锁定对文件的访问。我们希望关闭它,以便其他进程可以访问文件。

如果你认为这看起来还不错,你部分是正确的。这非常简单。然而,我省略了很多东西,比如错误检查。你还记得我之前关于性能的讨论吗?我在前面的章节中说,与正常的 CPU 操作相比,文件 I/O 要慢得多。因此,我们必须尽可能多地使用异步方法。你可以用 Win32 做到这一点,但这相当复杂。在这里,我不会向你展示如何做,但如果你在 Win32 API、CreateFileFILE_FLAG_OVERLAPPED上快速搜索,你可以了解它是如何工作的。简而言之,你将不得不自己检查一切。我的建议是坚持使用 CLR 函数。我们将在本章后面讨论异步 I/O。

我们已经学习了如何写入文件以及与之相关的所有内容。然而,这仅仅是故事的一部分。让我们转向等式的另一半:读取文件。

文件读取基础

太棒了。我们已经写了一个文件。现在我们应该能够读取它,对吧?好的,让我们深入探讨一下。我们将从一个简单的例子开始:一个包含一些文本行的文件,我们希望将其读取到字符串中:

public string ReadFromFile(string fileName)
{
    var text = File.ReadAllText(fileName);
    return text;
}

我无法使它比这更简单了。我们有一个静态的ReadAllText方法,它接受一个文件名并将所有文本读取到字符串中。然后我们返回它。请记住,并非所有文件都包含文本。我甚至敢说大多数文件都不包含文本。它们是二进制的。现在,从技术上讲,一个text文件也是一个binary文件。所以,让我们再次读取文件,但现在是通过读取实际的字节。这次我使用FileStream,这样我们就可以对发生的事情有更多的控制:

public string ReadWithStream(string fileName)
{
    byte[] fileContent;
    using (FileStream fs = File.OpenRead(fileName))
    {
        fileContent = new byte[fs.Length];
        fs.Read(fileContent, 0, (int)fs.Length);
        fs.Close();
    }
    return Encoding.ASCII.GetString(fileContent);
}

FileStream的好处是它知道流的长度。这意味着我们可以为我们的数组分配足够的空间来包含所有数据。

我们将通过一次调用fs.Read()来读取所有数据,给它一个字节数组,起始位置0,以及要读取的总字节数。再次强调,当我们完成时,我们将关闭流。

最后,我们将假设内容是 ASCII 字符,将文件转换为字符串。

如果文件相对较小,这种方式读取是可行的。在这种情况下,你可以一次性读取所有内容。然而,如果文件太大,你必须分块读取。

为了做到这一点,Read()方法通过告诉你它读取了多少数据来帮助你。你可以创建一个循环并遍历整个文件。

我们可以像这样重写读取文件的部分:

fileContent = new byte[fs.Length];
int i = 0;
int bytesRead=0;
do
{
    var myBuffer = new byte[1];
    bytesRead = fs.Read(myBuffer, 0, 1);
    if(bytesRead > 0)
        fileContent[i++] = myBuffer[0];
}while(bytesRead > 0);
fs.Close();

这是一个愚蠢的方法,但它说明了我的观点。我们将继续读取文件,直到我们有了所有数据,在这种情况下 fs.Read() 返回 0

读取二进制数据

如果你有一个你知道结构的 binary 文件,你可以使用 BinaryReader 来帮助。

二进制数据通常比文本数据更节省内存。由于我们作为系统程序员总是在寻找更高效的代码,这值得一看。

假设我有一个以下这样的类。这并没有什么特殊的意义;它只是一个数据集合:

class MyData
{
    public int Id { get; set; }
    public double SomeMagicNumber { get; set; }
    public bool IsThisAGoodDataSet { get; set; }
    public MyFlags SomeFlags { get; set; }
    public string? SomeText { get; set; }
}
[Flags]
public enum MyFlags
{
    FlagOne,
    FlagTwo,
    FlagThree
}

假设我已经创建了一个具有属性 423.1415TrueMyFlags.One | MyFlags.ThreeHello, Systems Programmers 的该类实例。我可以使用 JSON 序列化将其写入文件。这会产生一个 114 字节的文件。如果使用二进制格式,我可以将其缩小到 44 字节。这是一个相当大的节省,尤其是在将数据放在网络上时。

使用 BinaryReader 类读取该文件是直接的。让我给你展示一下:

public MyData Read(string fileName)
{
    var myData = new MyData();
    using var fs = File.OpenRead(fileName);
    try
    {
        using BinaryReader br = new(fs);
        myData.Id = br.ReadInt32();
        myData.IsThisAGoodDataSet = br.ReadBoolean();
        myData.SomeMagicNumber = br.ReadDouble();
        myData.SomeFlags = (MyFlags)br.ReadInt32();
        myData.SomeText = br.ReadString();
    }
    finally
    {
        fs.Close();
    }
    return myData;
}

这样做意味着你必须非常小心。你必须精确地知道文件的结构。你必须负责以正确的顺序获取所有数据,并确切地知道每个字段的类型。然而,这样做可以确保效率,并且可以节省你许多 CPU 周期。

我们现在已经了解了如何读取和写入文件。然而,文件系统中的不仅仅是文件。我们需要一种方法来组织所有这些文件。这把我们带到了 IO 的下一个主题:目录!

目录操作

想象一下有一个只有一个根文件夹的文件系统。你的驱动器上的所有文件都存储在那里。你将很难找到所有文件。幸运的是,操作系统都支持文件夹或目录的概念。CLR 通过给我们提供两个类来帮助我们处理路径、文件夹和目录:PathDirectory

路径类

Path 是一个具有处理路径的辅助方法的类。我指的是表示目录名称的字符串。当处理实际的目录和文件时,你应该使用 Directory 类。

我们已经在之前的示例中看到了 Path 类。我使用它来获取临时文件名和 Documents 文件夹的名称。我还用它来合并路径和文件名,以避免自己处理路径分隔符。

Path 类中有许多实用的方法和属性。你可以在以下表中看到一些最常用的。

方法 描述
Path.Combine 将两个或多个字符串合并为一个路径
Path.GetFileName 返回指定路径字符串的文件名和扩展名
Path.GetFileNameWithoutExtension 返回指定路径字符串的文件名,不带扩展名
Path.GetExtension 获取指定路径字符串的扩展名(包括点)
Path.GetDirectoryName 获取指定路径字符串的目录信息
Path.GetFullPath 将相对路径转换为绝对路径
Path.GetTempPath 返回系统临时文件夹的路径
Path.GetRandomFileName 返回一个尚未被使用的随机文件名
Path.GetInvalidFileNameChars 返回当前平台上不允许在文件名中使用的字符数组
Path.GetInvalidPathChars 返回当前平台上不允许在路径字符串中使用的字符数组
Path.ChangeExtension 改变文件路径的扩展名
Path.HasExtension 确定路径是否包含文件名扩展名
Path.IsPathRooted 获取一个值,指示指定的路径字符串是否包含根
Path.DirectorySeparatorChar 用于路径字符串的平台特定分隔符

表 5.2:Path 类及其方法和属性

如您所见,Path 类有一组非常好用且方便的辅助工具。当我们调查其他平台时,我们还会遇到它们,但现在,请记住尽可能多地使用它们。

目录类

Directory 类处理您文件系统中的实际目录。这个类与 Path 类紧密合作。如果您需要指定目录的名称(以及其位置),您将使用 Path 类。

假设我们想在 Windows 机器上的 Pictures 文件夹中列出所有图像。您会这样做:

var imagesPath =
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures);
string[] allFiles =
    Directory.GetFiles(
        path: imagesPath,
        searchPattern: "*.jPg",
        searchOption: SearchOption.AllDirectories);
foreach (string file in allFiles)
{
    Console.WriteLine(file);
}

我在这里使用 Environment.SpecialFolder.MyPictures 来标识包含所有图片的文件夹。实际的路径取决于您的操作系统、用户名以及您如何设置您的机器。这意味着有很多可能的变体,但我们不必过于担心这一点。让操作系统去处理这个问题,只要我们得到正确的文件夹即可。

我使用了 Directory.GetFiles() 方法来遍历该文件夹。我想获取所有子文件夹中收集的所有 JPEG 图像。注意我在 searchPattern 变量中如何拼写扩展名:*.jPg。在 Windows 上,文件名不区分大小写。在 Linux 上,它们是区分大小写的。因此,在基于 Linux 的机器上,这不会起作用。好吧,它将起作用,但它不会返回您可能期望得到的所有文件。不幸的是,GetFiles() 无法设置不区分大小写的过滤器。如果您想获取所有 JPG 图像,无论它们的扩展名看起来如何,您必须以另一种方式来做:

var regex = new Regex(@"\.jpe?g$", RegexOptions.IgnoreCase);
var allFiles =
    Directory.EnumerateFiles(imagesPath)
        .Where(file => regex.IsMatch(file));

我在这里创建了一个正则表达式,表示我想过滤以 .jpgjpeg 结尾的字符串,并忽略大小写。然后我使用 Directory.EnumerateFiles() 并应用 Where() LINQ 操作符来应用 regex 过滤器。

此方法在所有平台上都工作得很好。您本可以通过以下代码避免使用 regex 过滤器,该代码更冗长,但我假设对许多人来说更易读:

var files = Directory.EnumerateFiles(imagesPath)
    .Where(file => file.EndsWith(".jpg",
StringComparison.OrdinalIgnoreCase) ||
                   file.EndsWith(".jpeg",
StringComparison.OrdinalIgnoreCase));

我在下面的表中为您收集了Directory类最常用的方法和属性:

方法 或属性 描述
Directory.CreateDirectory 在指定的路径中创建所有目录和子目录,除非它们已经存在
Directory.Delete 删除指定的目录,以及可选地删除目录中的任何子目录和文件
Directory.Exists 确定给定的路径是否指向磁盘上的现有目录
Directory.GetCurrentDirectory 获取应用程序的当前工作目录
Directory.GetDirectories 获取指定目录中子目录的名称(包括它们的路径)
Directory.GetFiles 返回指定目录中文件的名称(包括它们的路径)
Directory.GetFileSystemEntries 返回指定目录中所有文件和子目录的名称
Directory.GetLastAccessTime 返回指定文件或目录最后访问的日期和时间
Directory.GetLastWriteTime 返回指定文件或目录最后写入的日期和时间
Directory.GetParent 获取指定路径的父目录,包括绝对路径和相对路径
Directory.Move 将文件或目录及其内容移动到新位置
Directory.SetCreationTime 设置指定文件或目录的创建日期和时间
Directory.SetCurrentDirectory 将应用程序的当前工作目录设置为指定的目录
Directory.SetLastAccessTime 设置指定文件或目录最后访问的日期和时间
Directory.SetLastWriteTime 设置指定文件或目录最后写入的日期和时间

表 5.3:目录类的属性和方法

Directory有一些不错的辅助方法和属性。你可以自己找出所有这些属性,但为什么麻烦自己去做,如果 CLR 足够友好地帮助你呢?当我们以后转移到其他平台时,这些属性也将是有益的。

DirectoryInfo

我还想讨论另一个类:DirectoryInfo类。DirectoryDirectoryInfo之间的区别在于前者使用静态方法,而后者用作实例。Directory返回关于目录的字符串信息。DirectoryInfo返回包含更多信息的对象。让我给你举一个例子:

var imagesPath = Environment.GetFolderPath(
    Environment.SpecialFolder.MyPictures);
var directoryInfo = new DirectoryInfo(imagesPath);
Console.WriteLine(directoryInfo.FullName);
Console.WriteLine(directoryInfo.CreationTime);
Console.WriteLine(directoryInfo.Attributes);

我创建了一个DirectoryInfo类的实例,并给它提供了我们images文件夹的路径。这个实例有很多有价值的属性,例如完整名称、创建时间、属性等等。我在下表中列出了最常用的属性和方法。

方法 或属性 描述
DirectoryInfo.Create 创建一个目录
DirectoryInfo.Delete 删除此 DirectoryInfo 实例,指定是否删除子目录和文件
DirectoryInfo.Exists 获取一个值,指示目录是否存在
DirectoryInfo.Extension 获取表示目录扩展部分的字符串
DirectoryInfo.FullName 获取目录或文件的完整路径
DirectoryInfo.Name 获取此 DirectoryInfo 实例的名称
DirectoryInfo.Parent 获取指定子目录的父目录
DirectoryInfo.Root 获取路径的根部分
DirectoryInfo.GetFiles 从当前目录返回文件列表
DirectoryInfo.GetDirectories 返回当前目录的子目录
DirectoryInfo.GetFileSystemInfos 获取表示当前目录中的文件和子目录的 FileSystemInfo 对象数组
DirectoryInfo.MoveTo DirectoryInfo 实例及其内容移动到新路径
DirectoryInfo.Refresh 刷新对象的状态
DirectoryInfo.EnumerateFiles 返回当前目录中文件信息的可枚举集合
DirectoryInfo.EnumerateDirectories 返回当前目录中目录信息的可枚举集合
DirectoryInfo.Enumerate FileSystemInfos 返回当前目录中文件系统信息的可枚举集合

表 5.4:DirectoryInfo 属性和方法

如您所见,PathDirectoryDirectoryInfo 在处理文件时可以提供很大帮助。

文件系统监控

作为系统程序员,我们必须找到与我们的应用程序通信的方法。毕竟,没有用户界面让用户可以表明他们的期望操作。

那个类别的多数应用程序都监听网络端口或以其他方式让系统与之通信。其中一种方式是等待文件或目录的变化。

监视文件或文件夹是一个相当常见的场景。例如,我们可以构建一个系统来处理通过电子邮件系统获取的文件。一旦文件作为附件发送,邮件客户端就会将其放置在一个目录中,我们的系统就会去获取它。

这意味着我们需要有一种方法来监视那个文件夹。幸运的是,这并不太难实现。这确实需要一些解释,所以让我带你了解一下。

我们将从其他类与之交互的类开始:

internal class MyFolderWatcher : Idisposable
{
    protected virtual void Dispose(bool disposing)
    {
        if (disposing)
        {
            // Dispose managed state (managed objects).
        }
    }
     ~MyFolderWatcher()
    {
        Dispose(false);
    }
    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }
}

我们稍后需要清理一些资源,所以我在这里实现了 IDisposable 接口。我们需要清理的类是 FileSystemWatcher 类型的实例。这个类在实例化时监视一个文件夹,并且可选地监视文件名过滤器。如果那里发生了一些有趣的事情,FileSystemWatcher 会通知我们。定义“有趣的事情”是什么取决于我们。

让我们将它设置为我们类的一个私有成员:

private FileSystemWatcher? _watcher;

我们可以将我们的 Dispose(bool disposing) 方法改为清理这些内容,但我会暂时保留这个。我们需要做的不仅仅是销毁 FileSystemWatcher

FileSystemWatcher 是资源密集型的。监视一个文件夹可能会导致大量的 CPU 压力。因此,我们必须确保只有在需要时才启用它。

然后,我们将添加一个启用监视器并设置一些设置的方法:

public void SetupWatcher(string pathToWatch)
{
    if(_watcher != null)
        throw new InvalidOperationException(
            "The watcher has already been set up");
    if(!Path.Exists(pathToWatch))
        throw new ArgumentOutOfRangeException(
            nameof(pathToWatch),
            "The path does not exist");
    // Set the folder to keep an eye on
    _watcher = new FileSystemWatcher(pathToWatch);
    // We only want notifications when a file is created or
    // when it has changed.
    _watcher.NotifyFilter =
        NotifyFilters.FileName |
        NotifyFilters.LastWrite;
    // Set the callbacks
    _watcher.Created += WatcherCallback;
    _watcher.Changed += WatcherCallback;
    // Start watching
    _watcher.EnableRaisingEvents = true;
}

我们将开始进行两项检查。首先,我们将查看监视器是否已经被创建。如果已经创建,我们将抛出一个错误。第二是检查提供的路径是否存在。

如果这两个检查都通过,我们将创建一个 FileSystemWatcher 类的实例,并给它我们想要监视的路径。

您可以指定您想要监视的内容。这由 NotifyFilter 属性控制。这个属性接受一个枚举或 NotifyFilter 枚举的组合。您可以在以下表中查看您的选项。

NotifyFilters 枚举 描述
Attributes 监视文件或文件夹属性的变化
CreationTime 监视文件和目录的创建时间的变化
DirectoryName 监视目录名称的变化
FileName 监视文件名称的变化
LastAccess 监视文件和目录的最后访问时间的变化
LastWrite 监视文件和目录的最后写入时间的变化
Security 监视文件和目录的安全设置的变化
Size 监视文件和目录的大小变化

表 5.5:NotifyFilters 选项

我只对文件夹中的新文件或更改的文件感兴趣。因此,我给它设置了 NotifyFilters.FileName | NotifyFilters.LastWrite 的值。当然,当您第一次创建文件时,文件的 FileName 会发生变化。我也可以选择 CreationTime,它几乎不会变化。我还会监视 LastWrite,它告诉我文件何时发生变化。

在此之后,我将给 _watcher 一个回调,当两个我关心的任意一个事件被触发时调用。由于所有事件共享相同的签名,我可以用一个方法来完成。这个方法就是我们接下来要看的。然而,在我们这样做之前,我们需要通过将 _watcher.EnableRaisingEvents 设置为 True 来启动监视器。下一段代码包含了 eventhandler 的主体:

private void WatcherCallback(object sender, FileSystemEventArgs e)
{
    switch (e.ChangeType)
    {
        case WatcherChangeTypes.Created:
            FileAdded?.Invoke(this, new FileCreatedEventArgs                 (e.FullPath));
            break;
        case WatcherChangeTypes.Changed:
            FileChanged?.Invoke(this, new
                FileChangedEventArgs(e.FullPath));
            break;
    }
}

当监视器调用这个回调时,我们得到一个 FileSystemEventArgs 类的实例。这个类包含一个名为 ChangeType 的字段,它指示触发了这个调用的变化类型。它还包含受影响的文件的完整路径和名称,在 FullPath 属性中。

我们将切换到 ChangeType 字段,并调用我们类中的一个事件处理器。我们类中的两个事件处理器如下所示:

public event EventHandler<FileCreatedEventArgs>? FileAdded;
public event EventHandler<FileChangedEventArgs>? FileChanged;

FileCreatedEventArgsFileChangedEventArgs类型对于EventHandler来说也很直接。我本可以使用一个类型。然而,为了未来的使用,我决定为它们提供不同的类,我可能在某个时候通过更多信息扩展它们。它们看起来像这样:

public class FileCreatedEventArgs : EventArgs
{
    public FileCreatedEventArgs(string filePath)
    {
        FilePath = filePath;
    }
    public string FilePath { get; }
}
public class FileChangedEventArgs : EventArgs
{
    public FileChangedEventArgs(string filePath)
    {
        FilePath = filePath;
    }
    public string FilePath { get; }
}

FileSystemWatcher实现了IDisposable。因此,当我们不再使用它时,我们必须将其释放。我们需要重写自己的Dispose(bool disposing)方法,使其看起来像这样:

protected virtual void Dispose(bool disposing)
{
    if (!disposing) return;
    if (_watcher == null)
        return;
    // Stop raising events
    _watcher.EnableRaisingEvents = false;
    // Clean whoever has subscribed to us
    // to prevent memory leaks
    FileAdded = null;
    FileChanged = null;
    _watcher.Dispose();
    _watcher = null;
}

在进行了一些检查后,我们将停止系统接收任何事件。然后我们将清除事件。如果我们不这样做,其他对象可能会持有对我们类的引用,从而阻止此类从内存中释放。

当那件事完成时,我们将释放_watcher并将其设置为 null。

就这样。如果你从你的程序中运行它,给它一个文件夹,并附加一些eventhandlers,你将能够看到当你向该文件夹添加或更改文件时会发生什么。

几乎完美。几乎——但并不完全。

如果你添加一个文件,你会得到多个事件。如果你这么想,这是有道理的。毕竟,文件是在文件系统中创建的,然后立即被更改。如果你愿意,你可以更改我们的类来考虑这一点。这并不难做,所以我会把它留给你。

异步 I/O

我之前已经说过,但这一点非常重要,所以我必须在这里重复一遍:I/O 很慢。任何与 I/O 一起工作的代码都应该异步执行。幸运的是,System.IO命名空间中的大多数类都有我们可以使用的异步成员,我们可以与 async/await 一起使用。

如果微软决定将System.IO中所有非异步方法标记为过时,我会很高兴。

天真的方法

你在System.IO中知道的大部分方法都有一个异步版本。所以,只需在方法名后添加async后缀并等待它即可。很简单!

仔细想想,不。这并不简单。

让我给你举一个例子:

public async Task CreateBigFileNaively(string fileName)
{
    var stream = File.CreateText(fileName);
    for (int i = 0; i < Int32.MaxValue; i++)
    {
            var value = $"This is line {i}";
            Console.Writeline(value);
            await stream.WriteLineAsync(value);
                await Task.Delay(10);
    }
    Console.WriteLine("Closing the stream");
    stream.Close();
    await stream.DisposeAsync();
}

此方法创建一个文件,然后向其中写入一行字符串。完成后,它关闭文件并很好地释放它。它是异步执行的。所以这就是事情应该这样做的方式,对吧?

让我们使用这个方法:

var asyncSample = new AsyncSample();
await asyncSample.CreateBigFileNaively(@"c:\temp\bigFile.txt");

将这两行代码添加到你的主控制台应用程序中。运行它并让它运行几秒钟。注意屏幕上写入的行(它应该说的是类似于“这是第 n 行”,其中n是行的编号)。然后按Ctrl + C取消操作。程序将停止。现在,请打开文件并看看它走了多远。有很大可能性你会看到文件上最后写入的行不是你在屏幕上看到的数字。

你可能会想知道为什么?CLR 确保我们的代码的性能尽可能高。所以,所有写入文件系统的数据都会在发送到 SSD 或其他媒体之前缓冲到缓存中。毕竟,写入存储是慢的。然而,由于我们终止了进程,CLR 没有时间刷新缓存。

使用取消令牌

当然,在现实世界中这种情况不会经常发生。然而,你可能想要取消一个长时间运行的 IO 进程,然后你可能会遇到这种情况。

这有一个解决方案。还记得我们讨论线程的那一章吗?还记得我说过有一个叫做CancellationToken的东西吗?那就是我们需要的东西。

让我们重写写入文件的代码。让我们从方法名称中移除naïve;我们现在知道得更多了:

public async Task CreateBigFile(string fileName, CancellationToken cancellationToken)
{
    var stream = File.CreateText(fileName);
    for (int i = 0; i < Int32.MaxValue; i++)
    {
        if (cancellationToken.IsCancellationRequested)
        {
            Console.WriteLine("We are being cancelled");
            break;
        }
        else
        {
            var value = $"This is line {i}";
            Console.WriteLine(value);
            await stream.WriteLineAsync(value);
            try
            {
                await Task.Delay(10, cancellationToken);
            }
            catch (TaskCanceledException)
            {
                Console.WriteLine("We are being cancelled");
                break;
            }
        }
    }
    Console.WriteLine("Closing the stream");
    stream.Close();
    await stream.DisposeAsync();
}

我们在这里添加了很多代码。让我带你看看。首先,我们向方法添加了一个CancellationToken类型的参数。我们将在循环中不断检查是否请求了Cancel。如果是这样,我们将在屏幕上打印消息并优雅地退出循环。

Task.Delay()中,我们也传入了CancellationToken。毕竟,当系统等待这个延迟时,也可以请求取消。然而,当这发生在Task.Delay()期间时,CLR 将抛出一个TaskCanceledException类型的异常。我们必须捕获它以防止我们的程序崩溃并停止。这就是为什么我们在这里有try..catch块。我们需要这个try..catch块来防止异常向上冒泡到调用堆栈。

我们必须模拟从外部中断循环。将调用此方法的代码更改为以下内容:

var cancellationTokenSource = new
CancellationTokenSource();
ThreadPool.QueueUserWorkItem((_) =>
{
    Thread.Sleep(10000);
    Console.WriteLine("About to cancel the operation");
    cancellationTokenSource.Cancel();
});
var asyncSample = new AsyncSample();
await asyncSample.CreateBigFile(
    @"c:\temp\bigFile.txt",
    cancellationTokenSource.Token);

首先,我们将创建一个新的CancellationtokenSource。然后,我们将从ThreadPool中拉取一个线程并给它一些事情做。等待 10 秒后,它将请求取消。

现在调用CreateBigFile时有一个CancellationTokenSource

运行它并看到它在 10 秒后停止。注意它停止在哪一行,并检查实际的文件以查看是否是最后写入的一行。在我的机器上,这工作得很好。

记住:在处理异步文件处理时,无论你做什么,尽量使用CancellationSourceToken。同时,确保你处理任何副作用。确保在请求取消后进行清理,以便 CLR 可以正确刷新缓存并清理其资源。

BufferedStream

CLR 在最大化 I/O 操作性能方面做得相当不错。正如我们所见,它可以在写入外部设备之前缓存数据。这种缓存加快了我们的代码,因为我们不再需要等待缓慢的写入操作完成。然而,CLR 对那些缓存做出了明智的猜测。有时它会出错。如果我们知道我们想要写入的数据的大小,我们可以利用这个知识从我们的应用程序中获得更高的性能。

假设我们有一个系统,它会将以下记录写入 I/O:

internal readonly record struct DataRecord
{
    public int Id { get; init; }
    public DateTime LogDate { get; init; }
    public double Price { get; init; }
}

这个块长 24 字节。我们可以通过将intDateTimedouble的大小相加来快速确定这一点。

如果我们将这些内容写入文件,CLR 会将其缓存,直到系统找到一个合适的时机将实际的数据写入存储。然而,我们可以改进这一点。我们可以使用BufferedStream类首先将数据写入缓冲区。然后,当 CLR 认为这是最佳时机时,可以将该缓冲区刷新到底层存储。这里的优势在于我们可以控制该缓冲区或缓存的大小。如果我们指定的大小恰到好处,我们就不会浪费内存。然而,我们也不会将其设置得太小,以免刷新过于频繁。这对我们来说刚刚好。

实现这一功能的代码看起来像这样:

public async Task WriteBufferedData(string fileName)
{
    var data = new DataRecord
    {
        Id = 42,
        LogDate = DateTime.UtcNow,
        Price = 12.34
    };
    await using FileStream stream = new(fileName, FileMode.CreateNew,     FileAccess.Write);
    await using BufferedStream bufferedStream = new(stream,
    Marshal.SizeOf<DataRecord>());
    await using BinaryWriter writer = new(bufferedStream);
    writer.Write(data.Id);
    writer.Write(data.LogDate.ToBinary());
    writer.Write(data.Price);
}

首先,我们将创建一个FileStream。这个FileStream是我们写入的实际文件的句柄。然后,我们将创建一个BufferedStream,并给它提供FileStream以及我们想要写入的记录的大小。之后,我们将创建一个BinaryWriter,以便尽可能高效地将我们的数据写入缓冲区。

一切都设置好之后,我们再进行写作。

一个警告

如果你不确定数据的大小,使用BufferedStream可能会对你不利。BufferedStream在执行大量已知大小的较小、频繁的数据写入时表现最佳。否则,缓存管理最好留给 CLR。

文件系统安全

文件是我们存储东西的地方。那些东西可能不是每个人都想看到的。有时,我们必须隐藏数据或确保只有我们信任的程序可以访问它。操作系统可以提供帮助。每个操作系统都有处理文件和目录访问的方式。你通常可以允许或拒绝对这些文件的读取或写入访问。

然而,当你想要分享文件时会发生什么呢?让我们假设你想要通过电线传输数据或者将其存储在另一个驱动器上,比如可移动的 USB 驱动器。在这种情况下,确保那样的安全级别是非常具有挑战性的。这意味着你可能需要加密数据以防止其被滥用。

安全性——一个独立的话题

我在这里只介绍安全性和加密的基础知识。这并不是这个复杂且广泛主题的完整指南。仅关于这个主题就有数百本书籍被撰写。我想让你知道你可以进行安全和加密。然而,如果你想要认真对待这个问题,我建议你出去寻找一些关于这些主题的优质资源,并从那里学习。

加密基础

基本上,我们有两种加密类型:对称非对称算法。尽管它们之间有很多相似之处,但一个很大的区别在于它们处理密钥的方式。

让我们讨论一个基本的例子。假设你有一条信息,想要将它传输给其他人。由于信息内容敏感,你不想让其他人能够阅读它,所以你决定对其进行加密。这意味着你需要改变信息的内文,使得没有人能够理解它。接收者随后解密它,将你的文本转换成可理解的内容。我们称人们可以实际阅读并理解的内容为明文。相反,加密的、不可读的文本是我们所说的密文。人们阅读明文;密文需要解密。

这种保护信息的方式并不新颖。2000 多年前,凯撒就做过这样的事情。他使用了一种简单的替换算法。他所做的就是取一段他想发送给战场指挥官的文字,然后将所有字符向左或向右移动一定的位置。这里的数字就是我们所说的他的密钥。

因此,如果凯撒选择了3这个密钥,他信息中的所有 A 都会变成 D。字符 B 会变成 E,以此类推。

如果你知道了密钥,你就可以用他的密文进行逆向操作,恢复成明文。

这里的问题是传输实际要使用的数字。双方都需要知道密钥,否则事情永远不会成功。你需要一种安全的方式告诉对方使用哪个密钥,这样他们才能将你的密文解密成明文。

如果你认识对方,共享这个密钥并不难。你可以走上前去,将密钥写在一张密封的信封里递给他们,并告诉他们只有在收到加密信息时才能打开。然而,如今这样做要困难得多。计算机不知道它们想要与之通信的其他计算机。安全地交换密钥很困难。

解决这个问题的可能方法是使用非对称加密和解密。这个解决方案很复杂,但其基础是这样的:你有两个密钥。一个密钥用于加密数据,另一个用于解密数据。其中一个密钥是私有的,另一个是公开的。私钥只属于你一个人。你用它来加密文件。任何拥有公钥的人都可以解密它。当然,如果你想信息只被另一个特定方阅读,你可以反过来操作。你可以要求对方与你共享他们的公钥。然后,你用那个密钥加密信息。现在,只有对方才能用他们的私钥再次解密。

对称算法比非对称算法快得多。然而,它们面临密钥共享的问题。这就是为什么大多数算法结合两种方法的原因。它们使用非对称算法加密一个密钥,这个密钥可以用于对称加密。这个密钥相对较小,因此加密和解密可以相对快速地进行。然后,使用这个对称密钥来加密整个消息。这样,对称密钥就可以成为消息的一部分。它本身被加密,所以只有预期的接收者才能解密密钥,从而解密消息的其余部分。

如果这听起来很复杂,我有好消息:CLR 有许多类可以帮助我们完成这项工作。它们的使用也很简单。

对称加密和解密

让我们看看我们是否可以在 C#代码中加密和解密一个简单的消息:

public static void EncryptFileSymmetric(string inputFile, string outputFile, string key)
{
    using (FileStream inputFileStream = new
    FileStream(inputFile, FileMode.Open, FileAccess.Read))
    using (FileStream outputFileStream = new FileStream(outputFile,     FileMode.Create, FileAccess.Write))
    {
        byte[] keyBytes = Encoding.UTF8.GetBytes(key);
        using (Aes aesAlg = Aes.Create())
        {
            aesAlg.Key = keyBytes;
            aesAlg.GenerateIV();
            byte[] ivBytes = aesAlg.IV;
            outputFileStream.Write(ivBytes, 0, ivBytes.Length);
            using (CryptoStream csEncrypt = new
               CryptoStream(outputFileStream,                aesAlg.CreateEncryptor(),
                       CryptoStreamMode.Write))
            {
                byte[] buffer = new byte[4096];
                int bytesRead;
                while ((bytesRead =
                   inputFileStream.Read(buffer,                    0, buffer.Length)) > 0)
                {
                    csEncrypt.Write(buffer, 0, bytesRead);
                }
            }
        }
    }
}

这种方法接受输入文件名、输出文件名和一个密钥。然后,它打开输入文件,读取其内容,加密它,并将密文写入输出文件。

这种工作方式非常直接。

首先,我们将创建两个流。然后,我们将获取密钥并生成其字节数组。密钥必须是 128 位、192 位或 256 位数组。换句话说,它必须是 16、24 或 32 字节长。密钥越长,破解就越困难。然而,长密钥也会减慢加密和解密过程。选择权在你。

我们将创建一个Aes类的实例。高级加密标准AES)被广泛认为是一个好且安全的加密算法。为了使事情更加安全,我们将使用初始化向量IV)增强我们的密钥。你可以将其视为我们添加到密钥中以使其更难以阅读的东西。我们将把这个 IV 作为文件中的第一件事写入。

然后,我们将创建一个CryptoStream类的实例。这个类帮助我们写入加密数据,正如你将在接下来的代码块中看到的那样。我们将字节数组传递给CryptoStream类。由于我们使用 AES 类(更确切地说,是该类CreateEncryptor调用的结果)初始化了CryptoStream类,因此它使用我们的密钥来加密数据。

解密也很简单。它遵循相同的原理:从密钥获取文件,从文件中读取 IV,然后解密其余部分并将其存储在新文件中。这看起来是这样的:

public static void DecryptFileSymmetric(string inputFile, string outputFile, string key)
{
    using (FileStream inputFileStream = new FileStream(inputFile,     FileMode.Open, FileAccess.Read))
    using (FileStream outputFileStream = new FileStream(outputFile,     FileMode.Create, FileAccess.Write))
    {
        byte[] keyBytes = Encoding.UTF8.GetBytes(key);
        using (Aes aesAlg = Aes.Create())
        {
            byte[] ivBytes = new byte[aesAlg.BlockSize / 8];
            inputFileStream.Read(ivBytes, 0,
               ivBytes.Length);
            aesAlg.Key = keyBytes;
            aesAlg.IV = ivBytes;
            using (CryptoStream csDecrypt =
                   new CryptoStream(outputFileStream,
                   aesAlg.CreateDecryptor(), CryptoStreamMode.Write))
            {
                byte[] buffer = new byte[4096];
                int bytesRead;
                while ((bytesRead =
                inputFileStream.Read(buffer, 0, buffer.Length)) > 0)
                {
                    csDecrypt.Write(buffer, 0, bytesRead);
                }
            }
        }
    }
}

我们现在不是从CryptoStream获取Encryptor,而是获取Decryptor。其余的现在应该很容易理解了。

非对称加密和解密

在前面的例子中,我们生成了一个简单的 128 位、192 位或 256 位密钥。例如,你可以传递一个字符串,如SystemSoftware42,并获取字节。相同的密钥用于加密和解密。

对于非对称加密,获取密钥要困难一些。然而,有辅助类可以帮助完成这项工作,所以在实践中并不困难。以下是代码:

public static (string, string) GenerateKeyPair()
{
    using RSA rsa = RSA.Create();
    byte[] publicKeyBytes = rsa.ExportRSAPublicKey();
    byte[] privateKeyBytes = rsa.ExportRSAPrivateKey();
    string publicKeyBase64 = Convert.ToBase64String(publicKeyBytes);
    string privateKeyBase64 = Convert.ToBase64String(privateKeyBytes);
    return (publicKeyBase64, privateKeyBase64);
}

我使用了RSA类来生成密钥对。Rivest, Shamir, and AdlemanRSA)类是以发明这个算法的三位密码学家命名的。

我们将通过调用Create()来创建一个RSA实例。然后,我们将调用ExportRSAPublicKey()ExportRSAPrivateKey()来从其中获取生成的密钥。

由于密钥是字节数组,我们将使用ToBase64String()来使它们更易于阅读。这使得共享密钥变得更加容易。

现在我们有了密钥对,我们可以用它来加密一条消息。当然,我们也可以再次解密它。这段代码看起来是这样的:

public static byte[] EncryptWithPublicKey(
    byte[] data,
    byte[] publicKeyBytes)
{
    using RSA rsa = RSA.Create();
    rsa.ImportRSAPublicKey(publicKeyBytes, out _);
    return rsa.Encrypt(data, RSAEncryptionPadding.OaepSHA256);
}
public static byte[] DecryptWithPrivateKey(
    byte[] encryptedData,
    byte[] privateKeyBytes)
{
    using RSA rsa = RSA.Create();
    rsa.ImportRSAPrivateKey(privateKeyBytes, out _);
    return rsa.Decrypt(encryptedData, RSAEncryptionPadding.    OaepSHA256);
}

这段代码足够简单。我只想指出rsa.Encrypt()rsa.Decrypt()方法中的最后一个参数。在这里我们将使用填充来向结果中添加额外的数据(并且在解密时我们会将其移除)。这种填充使得攻击者尝试破解我们的消息变得更加困难。

你可以将这三个方法结合起来使用,如下所示:

(string, string) keyPair = Encryption.GenerateKeyPair();
keyPair.Item1.Dump();
keyPair.Item2.Dump();
var publicKey = Convert.FromBase64String(keyPair.Item1);
var privateKey = Convert.FromBase64String(keyPair.Item2);
string message = "This is the text that we, as System Programmers,     want to secure.";
byte[] messageBytes = Encoding.UTF8.GetBytes(message);
byte[] encryptedBytes = Encryption.EncryptWithPublicKey(messageBytes,     publicKey);
string encrypted = Encoding.UTF8.GetString(encryptedBytes);
encrypted.Dump(ConsoleColor.DarkYellow);
byte[] decryptedBytes = Encryption.    DecryptWithPrivateKey(encryptedBytes, privateKey);
string decrypted = Encoding.UTF8.GetString(decryptedBytes);
decrypted.Dump(ConsoleColor.DarkYellow);

首先,我们将创建一个密钥对。我们的方法将这个对作为字符串返回,这样我们就可以打印它们(我再次使用我们方便的Dump()扩展方法)。然而,密钥需要以二进制格式存在,所以我将它们转换成字节数组。

我将定义我想要加密的消息,获取该消息的字节,并对其进行加密。然后,我将打印加密后的消息。如果你这样做,我想你会同意这很难看到实际的消息。它是一堆字符的混乱。

然后,我们将通过调用DecryptWithPrivateKey()来反转它。这个方法返回我们的字符串。

如果我们将我们的公钥的Base64版本发送给某人,然后传输编码后的消息,他们可以用这个公钥来解码它。他们会确信我们发送了这条消息;除了我们之外,没有人能够生成可以被该公钥解密的消息。毕竟,私钥和公钥是一对。你需要一个来加密,以便第二个可以解密。

朱利叶斯·凯撒会为我们感到骄傲!

然而,我们还有另一件事要讨论。我们需要减轻负担。好吧,不是我们个人,而是我们文件中的有效载荷可能会从中受益。让我们谈谈文件压缩。

文件压缩

文件可能会变得相当大。正如我们已经讨论过的,文件 I/O 和网络 I/O 需要很长时间,尤其是与 CPU 的速度相比。我们能够做的任何减少从 I/O 读取或写入所需时间的操作都可能是有价值的。即使这意味着我们必须让 CPU 做更多的工作,这也同样是正确的。当然,你需要测量这一点并看看它是否也适用于你的情况,但有时,为了加快 I/O 速度而牺牲 CPU 时间可能会产生巨大的差异。

实现这一点的其中一种方法是通过限制写入文件或网络流中的数据量。这可以通过压缩来完成。

在 CLR 中,你有选择。你可以使用 DeflateStreamGZipStream 来做这件事。GZipStream 在内部使用 DeflateStream,所以 DeflateStream 显然更快。然而,GZipStream 生成的压缩文件可以被外部软件读取。GZip 是一个标准化的压缩算法。

压缩一些数据

让我们使用 GZipStream 压缩一个字符串:

public async Task<byte[]> CompressString(string input,
    CancellationToken cancellationToken)
{
    // Get the payload as bytes
    byte[] data =
    System.Text.Encoding.UTF8.GetBytes(input);
    // Compress to a MemoryStream
    await using var ms = new MemoryStream();
    await using var compressionStream = new GZipStream(ms,
    CompressionMode.Compress);
    await compressionStream.WriteAsync(data, 0,
    data.Length, cancellationToken);
    await compressionStream.FlushAsync(cancellationToken);
    // Get the compressed data.
    byte[] compressedData = ms.ToArray();
    return compressedData;
}

由于压缩和解压缩可能需要很长时间才能完成,我们在这里真的应该使用 Async/Await 模式。

我们将取一些想要压缩的字符串并将它们传递给输入变量。在这个例子中,我使用了 MemoryStream,但你也可以使用你喜欢的任何流。大多数现实世界的例子使用某种形式的 FileStream

我将创建一个 GZipStream 类的实例,并给它一个 MemoryStream 实例。这个内存流是它写入数据的地方。我还会告诉这个类我想要压缩数据。

然后,我只需将数据写入其中,刷新缓冲区,并从中获取字节。

就这样!我已经压缩了一个字符串。

解压缩一些数据

解压缩同样简单。看看下面的代码示例:

public async Task<string> DecompressString(byte[] input,
    CancellationToken cancellationToken)
{
    // Write the data into a memory stream
    await using var ms = new MemoryStream();
    await ms.WriteAsync(input, cancellationToken);
    await ms.FlushAsync(cancellationToken);
    ms.Position = 0;
    // Decompress
    await using var decompressionStream = new GZipStream(ms,     CompressionMode.Decompress);
    await using var resultStream = new MemoryStream();
    await decompressionStream.CopyToAsync(resultStream,     cancellationToken);
    // Convert to readable text.
    byte[] decompressedData = resultStream.ToArray();
    string decompressedString =
    System.Text.Encoding.UTF8.GetString(decompressedData);
    return decompressedString;
}

在这里,我使用了两个 MemoryStream 类的实例。我使用一个作为数据的源,另一个作为未压缩数据的目的地。再次提醒,请使用你想要的任何流。

你可以使用以下方法:

var cts = new CancellationTokenSource();
var myText = "This is some text that I want to compress.";
var compression = new Compression();
var compressed = await compression.CompressString(myText, cts.Token);
var decompressed = await
    compression.DecompressString(compressed, cts.Token);
decompressed.Dump(ConsoleColor.DarkYellow);

那并不太难,对吧?

然而,我们还没有完成。我们想要存储或读取的数据需要以某种格式存在。如果你有一个包含数据的 C# 类,你不能简单地将其写入文件。我们需要以某种方式将其转换。这就是序列化的用武之地。

序列化 – JSON 和二进制

在本章的早期,我们看到了如何将二进制数据写入流。我们可以调用所有 write 方法将各种类型写入文件。然而,这可能会相当困难,并且也容易出错。你必须跟踪数据的格式。一个简单的错误会使你的文件无法读取。

更好的方法是将你的数据序列化成流可以理解的格式。有两种方法可以做到这一点:JSON二进制

JSON 很简单:大多数编程语言和平台都理解它。JSON 已经成为在文本中显示结构的既定标准。在大多数地方,JSON 已经取代了 XML。JSON 更小,更轻量。

然而,它还可以更轻量。你还可以将你的数据序列化为二进制流。这需要更多的编码,但通常会产生更小文件和数据流。再次提醒,这可能是我们作为系统程序员所寻找的。

JSON 序列化

要将对象序列化为 JSON 格式,人们过去默认使用 NewtonSoft.JSONNewtonSoft.JSON 是首选库。它易于使用(并且仍然如此)并提供了许多人们喜欢的功能,例如自定义转换器。然而,微软随后发布了 System.Text.Json,它执行相同的操作但效率更高。作为系统程序员,我们关心内存效率和速度,因此我将重点关注这一点。

在我们能够序列化某个对象之前,我们需要一个可以序列化的对象。System.Text.Json 的优势在于我无需更改带有属性的类。框架足够智能,能够找出所需的内容并完成它。

我将使用本章前面看到的相同数据类在这些示例中。然而,为了节省您翻页的时间,我再次在这里向您展示它:

class MyData
{
    public int Id { get; set; }
    public double SomeMagicNumber { get; set; }
    public bool IsThisAGoodDataSet { get; set; }
    public MyFlags SomeFlags { get; set; }
    public string? SomeText { get; set; }
}
[Flags]
public enum MyFlags
{
    FlagOne,
    FlagTwo,
    FlagThree
}

如果我们想将此序列化为 JSON 以存储为文本并在以后重新读取,我们将使用以下代码:

public string SerializeToJSon(MyData myData)
{
    var options = new JsonSerializerOptions
    {
        WriteIndented = true,
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase
    };
    var result =
    System.Text.Json.JsonSerializer.Serialize(myData,options );
    return result;
}

这里展示的代码相当直接。我们将使用 MyData 类并将其传递给静态 Serialize 方法,System.Text.Json.JsonSerializer。此方法有几个重载。我将使用一个接受 JsonSerializerOptions 类实例的重载。这样,我可以格式化输出。我将 WriteIdented 属性设置为 True。如果没有这样做,我将会得到整个字符串在一行上。诚然,这会节省我几个换行符和制表符字符,但为了可读性,我更喜欢这种方式。

如果我们在类中运行一些值,我们将得到以下结果:

{
  "id": 42,
  "someMagicNumber": 3.1415,
  "isThisAGoodDataSet": true,
  "someFlags": 2,
  "someText": "This is some text that we want to serialize"
}

反序列化,即逆转该过程,同样简单:

public MyData DeserializeFromJSon(string json)
{
    var options = new JsonSerializerOptions
    {
        WriteIndented = true,
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase
    };
    var result = System.Text.Json.JsonSerializer.        Deserialize<MyData>(json, options);
    return result!;
}

如您所见,这个过程足够简单。

二进制序列化

这并不是将对象编码以便存储在文件中的最有效方式。它相对较快,但实际数据也相当大。二进制格式化需要更多的工作,结果也不是人类可读的,但它确实导致了文件更小。这意味着将数据写入和读取到慢速存储介质所需的时间显著减少。当然,权衡是 CPU 会变得稍微忙碌一些,但这可能值得。一如既往,先衡量,然后决定这是否适用于您的情况。

在 .NET Framework 中,在 .NET Core 和 .NET 之前,我们有一个名为 BinaryFormatter 的类。然而,该类现在已被标记为过时。与该类相关的存在严重的安全担忧,因此微软决定将其淘汰。

您可以使用第三方包来实现相同的目标。然而,如果您不想使用那些,您始终可以自己完成。我们已经讨论了 BinaryWriter 类及其方法。使用该类并没有什么问题,但缺点是您必须编写所有代码,包括写入和读取每个字段或属性。BinaryFormatter 类就是这样做的。坦白说,这相当方便。

目前实现相同功能的最佳包是 protobuf-net。此包可在 NuGet 上找到,这使得在项目中安装变得容易。如果您想使用 protobuf-net,您必须在序列化之前对您的类进行注解。再次使用我们的 MyData 类,它看起来会是这样:

[ProtoContract]
public class MyData
{
    [ProtoMember(1)]
    public int Id { get; set; }
    [ProtoMember(2)]
    public double SomeMagicNumber { get; set; }
    [ProtoMember(3)]
    public bool IsThisAGoodDataSet { get; set; }
    [ProtoMember(4)]
    public MyFlags SomeFlags { get; set; }
    [ProtoMember(5)]
    public string? SomeText { get; set; }
}

我们用 ProtoContract 属性装饰了类。然后,我们用 ProtoMember 属性装饰了属性。这个属性可以关联数据,但第一个是必需的。这是标签,它定义了字段在文件中的存储位置。除了一个规则之外,没有关于编号或顺序的硬性规定:你不能从 0 开始。是的。确实如此。我听到你在那里倒吸一口凉气。这是我能想到的唯一一个在编程中从 0 开始是禁止的例子。如果你想从 42 开始,你可以这样做。然而,数字必须是一个正整数,而 0 不是一个正整数。

序列化和反序列化很简单。您必须确保数据可以在内存流中可用或可以写入内存流,但这只是稍微复杂的一件事。这是代码:

public async Task<byte[]> SerializeToBinary(MyData myData)
{
    await using var stream = new MemoryStream();
    ProtoBuf.Serializer.Serialize(stream, myData);
    return stream.ToArray();
}
public async Task<MyData> DeserializeFromBinary(byte[] payLoad)
{
    await using var stream = new MemoryStream(payLoad);
    var myData =
        ProtoBuf.Serializer.Deserialize<MyData>(stream);
    return myData;
}

就这样了。我承诺这会很简单,不是吗?

如果我将与 JSON 序列化相同的数据进行比较,我可以看到二进制版本的数据量要小得多。即使我使用不写入预期文件的选择,从而节省了换行符和制表符,JSON 版本也有 131 字节。相比之下,二进制版本只有 60 字节长。这是一个很大的差异!

下一步

I/O 对所有软件都是至关重要的。没有软件是孤立运行的,尤其是为系统编写的软件。毕竟,这些应用程序没有传统的用户界面;它们是供其他软件使用的。与该软件的唯一通信方式是通过以某种方式交换数据。

本章探讨了将数据序列化和反序列化到存储中的方法。我们了解到 JSON 简单且生成可读性强的数据。然而,数据可能相当庞大。相比之下,二进制版本的数据量要小得多,但这样的数据不再适合人类阅读。此外,它还需要第三方包。最佳解决方案是什么?这取决于您的使用场景!无论您使用文件还是网络连接,它们都是 I/O 的方法。在本章中,您看到了如何高效、快速且安全地实现这一点。

然而,对于系统软件来说,一种更高效的方式来通信是通过直接在 进程间通信IPC)上进行通信。IPC 是系统软件建立其他软件可以与之交谈或监听的接口层的一个完美方式。这也是下一章的主题。

第六章:进程低语篇

进程间 通信 (IPC)

在上一章中,我们讨论了输入/输出。我们的大部分注意力都集中在文件上。文件是人们想到与其他系统共享数据时首先想到的事情之一。另一种常用的方法是网络。然而,系统之间还有其他通信方式。如果你想要长时间保留数据,文件是很好的选择。网络连接是连接不同机器上系统之间更直接连接的绝佳方式。但文件和网络更多地关于传输数据的基础技术。我们还必须决定如何使用这些方法连接到系统。简而言之,这就是进程间通信(IPC)的全部内容。我们如何让两个系统相互交谈?

在本章中,我们将涵盖以下主题:

  • 什么是 IPC?

  • 设计 IPC 时,我们需要关注哪些考虑因素?

  • Windows 消息 - 一种 Windows 原生的消息方式

  • 管道 - 命名和无名

  • 套接字 - 一种基于网络的 messaging 系统

  • 共享内存 - 一种快速简单的本地 messaging 系统

  • 远程过程调用(RPC)——控制其他机器

  • Google 远程过程调用(gRPC)——新加入的成员

欢迎来到迷人的低语系统世界!

技术要求

你可以在以下链接中找到本章中所有的代码:github.com/PacktPublishing/Systems-Programming-with-C-Sharp-and-.NET/tree/main/SystemsProgrammingWithCSharpAndNet/Chapter06.

IPC 概述及其在现代计算中的重要性

大多数软件都有用户界面。毕竟,用户应该通过这种方式与应用程序交互。用户点击按钮,输入文本,并在屏幕上读取响应。屏幕是数据、用户和应用程序交换数据和指令的方式。

人们不使用系统软件。其他软件会使用。因此,它需要不同的交互方式。我想技术上可能可以编写一个常规的用户界面并使用技巧来读取或输入数据,但这并不真正高效。

应用程序在相互交谈时会有不同的通信方式。它们有自己的语言和自己的协议。这就是进程间通信(IPC)的全部内容——进程之间的通信。

由于系统的性质,在设计系统之间的接口时,我们必须考虑几个关键点。在设计这个接口时,我们做出的选择与设计面向人的用户界面不同。这里有许多因素需要考虑。让我们逐一来看。

  • 明智地选择你的语言:系统可以使用许多不同的方式相互通信,就像人类的对话一样,如果所有参与方都说同一种语言,这会极大地帮助。本章描述了我们可以如何让系统相互通信,但还有更多方法。某些方法可能比其他方法更适合特定的环境或用例,所以你必须仔细思考。不要选择你感到最舒适的那个,因为你已经知道那个解决方案。考虑所有的用例场景,然后选择合适的协议。

  • 安全性:安全性是一个巨大的话题,尤其是在系统编程中。我们正在处理数据,系统隐藏在我们的计算机深处。大多数人不知道他们的机器上正在运行多个进程,因此他们不太可能检查它们并评估其安全性水平。

  • 数据格式和序列化:当你的数据从一个系统转移到另一个系统时,你必须考虑最佳的数据转换方式。数据必须是包、信封或其他传输方式的一部分。有众多不同的格式和序列化方式,但你选择哪一种取决于许多因素。例如,如果你在同一个 Windows 机器上的两个 64 位进程之间使用直接内存连接,你可以使用一个非常高效、轻量级的二进制表示。然而,如果你必须与地球上另一侧运行不同操作系统的机器通信,那么你必须设计出两个系统都能理解的序列化机制。

  • 错误处理和健壮性:软件可能会出错,我们都知道这一点。如果你在谈论多个独立的系统,那么错误和可用性的问题会呈指数级增长。因此,你必须对此保持警觉。你还必须考虑你的需求。你是否需要保证交付?你是否需要错误恢复?这两者可能很有用,但它们是有代价的。毕竟,没有什么是免费的。你需要考虑这些场景。通常,你必须设计一个可以应对的解决方案,而不是过度依赖错误纠正方案。

  • 性能和可扩展性:在进程内部传输内存块是非常快的。在进程之间移动数据可能非常慢,甚至难以想象地慢。通过传输控制协议TCP)连接将一个内存块移动到另一台机器比在内存中这样做慢数千倍。将数据写入磁盘,即使是快速的固态硬盘,也比这还要慢。

  • 这意味着你必须确保这些用例的最佳 I/O 策略。建立连接或创建文件可能很慢,但你必须为每次传输只做一次。一旦你有了这个,你就可以写入数据。如果你有很多小数据包,你可能想将它们捆绑在一起,这样你只需要启动一次。正如我们之前所述,在传输之前压缩数据可能是个好主意。是的,压缩会占用 CPU 周期,但考虑到将数据传输到另一个系统要慢得多,这可能值得。

  • 同步和死锁:一旦你的数据离开你的系统,你就不再知道它发生了什么。其他进程可能也在请求接收方的注意,或者接收方可能已经没有数据了。你必须非常小心,以确保数据同步。或者不。这当然取决于你的用例。此外,死锁也可能发生。你可能会等待接收方完成某个操作,但如果那个操作在等待你的系统,你就遇到了问题。要留意那些问题区域。

  • 文档和维护性:与其他系统共享数据意味着与其他开发者共享你的数据结构。别忘了,“其他开发者”可能是六个月后的你自己,当你回顾你所做的一切并想知道你在想什么时。记录你的工作、你的想法和你的数据结构可以为你和你的同伴节省很多麻烦。做件好事,记录你的数据及其结构、你为了满足所有限制所做的工作,以及你的假设。这使得你的代码更容易维护。当然,这适用于数据共享场景和所有软件开发,但在你需要与其他系统共享数据时,这一点尤为重要。不要跳过这一步!

  • 平台和环境限制:你未必总是清楚你的数据将会与哪种硬件共享。如果你不知道这一点,你必须考虑所有可用的选项。假设最坏的情况,并为此做好准备。例如,如果你传输几个吉字节的数据包,加密并使用压缩算法包装,你可能会收到投诉,说接收方是一个非常低端的 IOT 设备,内存和 CPU 处理能力有限。

并非所有平台都支持我在本章中概述的所有策略。例如,我们接下来要讨论的 Windows 消息,仅在 Windows 上可用。名字多少有点暗示,不是吗?要意识到平台和环境限制,并围绕这些限制设计你的数据共享。

因此,现在你已经知道了在选择通信方法时要考虑的因素,让我们看看我们有哪些可用方法。我们从一个经典的方法开始:Windows 消息。

Windows 消息

Windows 消息是 Windows 中最老类型的 IPC。当编写系统软件时,它们可能不是最佳选择,但它们可能很有帮助。更重要的是,它们非常快且轻量级。然而,正如其名所示,它们是 Windows 特有的功能。

消息与窗口一起工作。我说的不是操作系统;我指的是您监视器上的屏幕。Windows 中的几乎所有 GUI 元素都是一个窗口。窗口显然是,但按钮、编辑框、文本框、滑块等等也都是。操作系统通过向窗口发送消息与您的应用程序通信。您的应用程序至少有一个主窗口,然后它会将消息分发到子窗口或处理那些子窗口的消息。然而,每个窗口都可以有自己的消息处理逻辑。

由于消息与图形屏幕元素(如按钮、标签和列表框)一起工作,你可能认为它们不能用于控制台应用程序或 Windows 服务。这在技术上是对的,但我们可以绕过这一点。我们可以创建一个隐藏的窗口来接收这些消息。

消息很简单。它只是一个包含四个数字参数的结构。这就是参数是什么以及它们用于什么。

名称 类型 / C# 类型 描述
hWnd HWND / IntPtr 要接收消息的窗口的唯一句柄
Msg UINT / uint 消息的 ID
wParam WPARAM / IntPtr 一个附加参数,或数据结构的指针
lParam LPARAM / IntPtr 一个附加参数,或数据结构的指针

表 6.1:Windows 消息中的参数

消息就这么多。wParamlParam 指针指向包含有效载荷的内存。如果只想发送数字,它们也可以只是一个数字。在 16 位 Windows 中,wParam 是 16 位,lParam 是 32 位。在 Windows 的 32 位版本中,它们都是 32 位长,在 64 位版本中,它们都是 64 位长。因此,在长度方面,wParamlParam 没有真正的区别了。

这些消息都是操作系统向您的应用程序发送的通信。如果用户将鼠标移到您的窗口上,您会收到通知。好吧,在鼠标移动的情况下,您会收到数百个通知。如果用户按下一个键,您会收到一个消息。如果用户调整窗口大小,您会收到另一个消息。操作系统上发生的任何可能对您的应用程序有趣的事情都会以消息的形式发送给您。您的应用程序会一直接收到数百甚至数千条消息。您的应用程序需要监听这些消息。我们很快就会看到它是如何工作的。

消息标识符可以是预定义的;它可以是您选择的数字,或者操作系统可以生成它。

让我解释一下我的意思。

如果 Windows 发送一个消息,它就是预定义的其中之一。例如,如果鼠标移动了,你会收到 WM_MOUSEMOVE 消息。WM_MOUSEMOVE 是一个值为 0x0200 的常量。wParam 包含有关鼠标按钮和键的状态的信息,例如你键盘上的 Ctrl 键。你可以解码这些标志来查看鼠标移动时是否按下了按钮。lParam 包含鼠标相对于接收消息的窗口(左上角)的 XY 位置。lParam 的前半部分包含 Y 坐标,后半部分包含 X 坐标。

一个有趣的消息是 WM_CLOSE。它的值是 0x0010。如果一个窗口收到这个消息,用户想要关闭它。如果这发生在你的主窗口上,应用程序就会结束。

你也可以定义自己的消息。有一个名为 WM_USER 的常量(值为 0x0400)。你可以在你的应用程序中自由使用 WM_USER0x7FFF 之间的任何值来定义你的消息。有一个注意事项:你只能在你向应用程序的其他窗口发送这些消息时使用它们。你不能用它们与其他应用程序通信。原因很简单:你不知道系统外谁使用这些值。

如果你想要向其他应用程序发送消息,你需要向 Windows 注册。你可以调用一个 API 来保留一个在计算机运行期间唯一的保留号码。如果两个应用程序保留了相同的消息名称,它们将获得相同的 ID。这可以用来在进程之间进行通信,这正是我们现在要做的。

一个示例

要处理消息,我们需要使用大量的 Win32 API。逻辑并不复杂,但这个示例需要大量的设置。

我们可以将其分解如下:

  1. Window 类。它就像面向对象编程一样:首先定义一个类,然后创建实例。窗口就是这样。

  2. 定义消息循环方法:这个方法在消息可用时立即被调用。

  3. 创建窗口:一旦发生,消息就开始流动。

  4. WM_CLOSE,关闭应用程序。如果你想处理这个消息,请这样做。如果不处理,就将其传递给所有应用程序都有的默认处理器。

就这些了。

本书 GitHub 仓库中的源代码包含一个示例。我没有在这里包含它,因为该示例需要大量的样板代码,占据了数页。我决定将其从本章中省略,因为除了某些特定情况外,Windows 消息并未使用。然而,如果你感兴趣,只需查看示例代码。有了前面的解释,你可以很好地跟随。

现在你已经了解了 Windows 消息的工作原理,我们可以继续下一步,看看其他的方法。我们从一个简单的方法开始:管道。

在本地 IPC 中使用管道

管道最初来自 Unix,但也已经出现在其他平台上。管道就像两个系统之间的直接连接。它非常轻量级且易于设置。您可以使用它们在同一台机器上的进程之间以及通过网络在不同机器之间进行通信。从理论上讲,您可以使用管道在 Linux 和 Windows 之间进行通信。我说理论上是因为由于两个平台上的管道实现差异很大,您必须跳过许多步骤才能使其工作。实际上,您必须做的这项工作非常繁重,您可能更愿意使用其他方式,例如套接字,来实现相同的结果。这将更容易实现。

有两种类型的管道:命名管道匿名管道。命名管道是最简单的一种。

命名管道

命名管道是如果您想在同一台机器上的一个进程与另一个进程之间进行通信时的一个很好的解决方案。通过网络进行通信并不复杂,但需要更多关于安全和访问权限的思考。

在.NET 中,您可以使用NamedPipeServerStreamNamedPipeClientStream类来实现这一点。

代码很简单。例如,让我们看看一个等待连接的服务器。我们还添加了一个连接到该服务器的客户端。一旦建立连接,服务器就会向客户端发送一条消息,该消息将在屏幕上显示。

这里是服务器代码:

using System.IO.Pipes;
"Starting the server".Dump(ConsoleColor.Cyan);
await using var server = new
    NamedPipeServerStream("SystemsProgrammersPipe");
"Waiting for connection".Dump(ConsoleColor.Cyan);
await server.WaitForConnectionAsync();
await using var writer = new StreamWriter(server);
writer.AutoFlush = true;
writer.WriteLine("Hello from the server!");

再次,我使用我的Dump()扩展方法在这里快速着色屏幕上的消息。

首先,我创建了一个NamedPipeServerStream的实例。作为一个参数,我给它提供了一个唯一的名称。如果我使用一个已经注册的名称,我将能够访问那个其他命名管道。这些名称在您的机器上是唯一的,但一旦NamedPipeServerStream被销毁,它们就会消失。

然后,我们等待连接。当客户端连接时,我们创建StreamWriter,将其命名为管道服务器流,并将数据写入流。

我们在写入器上使用AutoFlush:我们不希望数据悬挂在那里。

让我们看看客户端代码:

using System.IO.Pipes;
await using var client = new NamedPipeClientStream(".",     "SystemsProgrammersPipe");
"Connecting to the server".Dump(ConsoleColor.Yellow);
await client.ConnectAsync();
using var reader = new StreamReader(client);
string? message = await reader.ReadLineAsync();
message.Dump(ConsoleColor.Yellow);

这段代码看起来应该很熟悉。我们创建了一个NamedPipeClientStream(而不是服务器)的实例,并给它提供了两个参数。第一个参数是网络上计算机的名称(在我们的例子中,是我们自己的计算机,由点指定)。第二个参数是管道的名称。显然,这应该与我们用于服务器流的名称相同。

我们将客户端连接到管道,使用该客户端创建一个StreamReader实例,并读取数据。最后,我们显示来自服务器的数据。

匿名管道

匿名管道的工作方式与命名管道大致相同。它们提供了一种轻量级的方式将进程连接起来。然而,命名管道和匿名管道之间存在一些差异。以下表格突出了其中最重要的几个:

特性 命名管道 匿名管道
识别 命名的。你可以通过名称找到它们。 未命名的。你必须知道运行时句柄才能连接。
通信 本地和网络通信。 只有本地通信。
对等方 每个服务器可以有多个客户端。可以设置为处理双向对话。 只有一对一。也只有单向。
复杂性 更复杂。允许进行异步通信,也能处理“发送后即忘”的场景。 更简单。直接的一对一父-子通信。
安全性 支持 ACL 以启用安全通信。 没有安全功能可用。
速度 由于控制更多而较慢。 快速。几乎没有开销。

表 6.2:命名管道和匿名管道功能比较。

设置匿名管道的代码实际上非常简单。让我们从服务器代码开始:

using System.IO.Pipes;
await using var pipeServer = new     AnonymousPipeServerStream(PipeDirection.Out, HandleInheritability.    Inheritable);
$"The pipe handle is: {pipeServer.GetClientHandleAsString()}".    Dump(ConsoleColor.Cyan);
pipeServer.DisposeLocalCopyOfClientHandle();
await using var sw = new StreamWriter(pipeServer);
sw.AutoFlush = true;
sw.WriteLine("From server");
pipeServer.WaitForPipeDrain();

让我带你了解这个过程。

首先,我创建了一个 AnonymousPipeServerStream 的实例。这个类处理所有通信的设置。我们可以看出它既可以发送也可以接收代码。我们不能使用提供的 PipeDirection.InOut 枚举:这将抛出异常。记住:匿名管道是单向的。

我们需要确保句柄可以被继承。这是因为客户端需要“继承”这个句柄。毕竟,这是我们唯一能够识别管道的方式。没有名称;它是匿名的!

我们调用 GetClientHandleAsString 以确定客户端应使用的内容。

当你创建 AnonymousPipeServerStream 时,它也会自动创建一个客户端。如果你想在进程内部进行通信,这会很有用。然而,如果另一个进程需要与这个服务器通信,你将遇到问题。匿名管道仅支持单连接。调用 DisposeLocalCopyOfClientHandle 会移除本地客户端,这样我们就有空间为另一个客户端服务。

然后,我们创建一个流,将其与管道关联,并向其写入。

最后,我们调用 WaitForPipeDrain,这是一个阻塞调用,只有当客户端读取完所有数据时才会继续。

客户端甚至更简单:

"Enter the pipeHandle".Dump(ConsoleColor.Yellow);
var pipeHandle = Console.ReadLine();
using var pipeClient = new AnonymousPipeClientStream(PipeDirection.In, pipeHandle);
using var sr = new StreamReader(pipeClient);
while (sr.ReadLine() is { } temp)
    temp?.Dump(ConsoleColor.Yellow);

我们首先从控制台读取句柄。这是服务器的输出,所以我们有可用的内容。然后,我们通过创建 AnonymousPipeClientStream 的实例来创建客户端,告诉它准备接收数据,并给它句柄。

然后,我们创建 stream 并从其读取。就这样!

有一个很大的注意事项。假设你编写了这两个控制台应用程序并运行它们。在这种情况下,你会看到,当你尝试创建AnonymousPipeClientStream的实例时,你会得到一个InvalidHandle异常。原因是 Windows 将进程分开,确保安全性尽可能高。如果你运行两个进程,它们无法相互访问句柄。因此,它无法访问管道,这意味着你无法通信。恐怕我们对此无能为力。如果你仔细想想,这确实是有道理的。你只能进行一对一的通信。所以,如果多个控制台应用程序连接到服务器,你怎么确保这种一对一的行为呢?答案是:你不能。

如果你想要独立的控制台应用程序,你应该使用命名管道。

然而,如果你想使用我提供的示例,你可以确保客户端和服务器在同一个地址空间中运行。你可以通过从服务器启动客户端来实现这一点。看起来是这样的:

Process pipeClient = new Process();
pipeClient.StartInfo.FileName = @"pipeClient.exe";
// Pass the client process a handle to the server.
pipeClient.StartInfo.Arguments =
    pipeServer.GetClientHandleAsString();
pipeClient.StartInfo.UseShellExecute = false;
pipeClient.Start();

不要忘记将客户端更改为从Main方法提供的args参数中获取句柄,而不是通过控制台从用户那里获取。

这里的秘密在于我们设置UseShellExecuteFalse的那一行。如果它是True,客户端将在另一个 shell 中启动,从而将其与服务器隔离开来。通过将其设置为False,我们防止了这种情况,并可以访问句柄,从而可以访问管道。

如果它们适合你的场景,匿名管道是通信工具箱中的绝佳补充。它们速度快且轻量级,这正是我们作为系统程序员所喜欢的。然而,还有其他可能更好的通信方式,尽管它们并不那么简单。让我们来谈谈套接字…

使用套接字建立基于网络的 IPC

套接字很棒。它们有点像通信领域的瑞士军刀。当你转向套接字时,管道和 Windows 消息的缺点就消失了。当然,没有免费的午餐,所以请准备好花大量时间思考错误处理和内存管理。尽管如此,一旦你掌握了这个概念,套接字并不难使用。

套接字是两个系统之间通过网络连接的端点。当然,系统可以位于同一台机器上,但它们也可以位于世界的两端。多亏了自 20 世纪 60 年代以来人们为构建网络所做的大量工作,我们现在可以接触到全球的各种机器。

网络基础

计算机网络已经存在很长时间了。然而,每个供应商都有自己的方式让机器相互通信。随着时间的推移,标准出现了。正如标准所做的那样,有好多可供选择。如今,我们在设置网络方面已经或多或少实现了标准化,所以你再也不必担心这个问题了。

但在我们深入具体细节之前,我们需要先谈谈开放系统互联OSI)。

OSI 是一个分层架构,你可以用它来描述网络的工作方式。每一层都建立在上一层之上(第一层似乎是一个例外)。

共有七层,以下是它们的描述:

  • 第 1 层 – 物理层:这是描述硬件的部分。例如,电缆的外观,开关的工作方式,施加的电压等。

  • 第 2 层 – 数据链路层:这一层描述了系统如何在物理层上连接。在这里,我们描述了以太网或 Wi-Fi 的工作方式。MAC 地址(每个网络设备的唯一编号)在这里定义。

  • 第 3 层 – 网络层:这一层完全是关于路由和寻址。在第 3 层定义了几个协议,例如互联网控制消息协议ICMP),用于网络诊断和错误报告,地址解析协议ARP),用于地址解析,蓝牙,当然还有互联网协议IP),包括 v4 和 v6。

  • 第 4 层 – 传输层:这一层负责端到端通信和可靠性。TCP 和用户数据报协议UDP),本章的主题,位于这一层。

  • 第 5 层 – 会话层:这一层管理应用程序之间的会话。

  • 第 6 层 – 表示层:这一层确保数据以其他系统可以理解的形式呈现。

  • 第 7 层 – 应用层:这里列出了使用网络的程序。

硬件和操作系统处理第 1 层至第 4 层。第 5 层至第 7 层由我们来负责。

几乎所有系统都使用 TCP 作为传输层,但有时人们会选择 UDP。我先解释 TCP 及其使用方法,然后在本文这部分结束时转向 UDP。IP 几乎是既定的。我们可以选择其他网络层协议,但这会使生活变得不必要地复杂。

设置会话(第 5 层)是我们编写代码在客户端或服务器上建立连接的地方。表示层(第 6 层)是关于我们如何打包数据:我们如何序列化,使用什么编码等。我们已经对此进行了广泛的讨论。第 7 层只是我们的应用程序;我将这一层留给你。

那么,让我们编写一些第 5 层的代码!

基于 TCP 的聊天应用程序

网络的“hello-world”应用程序是一个聊天应用程序。这类应用程序使我们能够研究系统如何连接并交换数据,而不必处理它们之间传递的数据类型的技术细节。数据类型是应用程序的一部分,我们在 OSI 模型中了解到它是第 7 层。我们对此不感兴趣。第 6 层是表示层,但对于一个简单的聊天应用程序,我们可以简单地处理:我们取一个字符串并将其编码为 UTF8 字节(当然,也可以反向操作)。由于操作系统负责第 1 层至第 3 层,我们只需处理第 4 层和第 5 层。

让我们让它成为现实。

我想在这里使用 TCP,这是一个非常优秀的协议,它为我们提供了可靠性并保证了数据到达的顺序。它也非常容易设置。

服务器看起来是这样的:

01: using System.Net;
02: using System.Net.Sockets;
03: using System.Text;
04:
05: "Server is starting up.".Dump();
06:
07: var server = new TcpListener(IPAddress.Loopback, 8080);
08: server.Start();
09:
10: "Waiting for a connection.".Dump();
11:
12: var client = await server.AcceptTcpClientAsync();
13: "Client connected".Dump();
14:
15: var stream = client.GetStream();
16: while (true)
17: {
18:     var buffer = new byte[1024];
19:     var bytes = await stream.ReadAsync(buffer, 0, buffer.Length);
20:     var message = Encoding.UTF8.GetString(buffer, 0, bytes);
21:     $"Received message: {message}".Dump();
22:
23:     if (message.ToLower() == "bye")
24:         break;
25:
26:     "Say something back".Dump();
27:     var response = Console.ReadLine();
28:     var responseBytes = Encoding.UTF8.GetBytes(response);
29:     await stream.WriteAsync(responseBytes, 0, responseBytes.           Length);
30:
31:     if (response.ToLower() == "bye")
32:         break;
33: }
34:
35: client.Close();
36: server.Stop();
37: "Connection closed.".Dump();

由于发生了很多事情,我决定在这里使用行号。这使得引用我所解释的内容变得容易一些。

在第 7 行,我们创建了一个新的TcpListener类实例。这个类处理所有关于通信的细节,但它需要我们从它那里获取一些信息。我们向构造函数提供了两个参数,告诉它所有需要知道的信息。第一个是我们使用的地址。地址是网络适配器的唯一标识符,例如您的以太网或 Wi-Fi 适配器。这个 IP 地址是 OSI 模型的第 3 层,即网络层的一部分。它是 IP 规范的一部分。然而,多个应用程序可以同时在一个计算机中使用网络适配器。我们可以指定端口号以确保所有应用程序都能获得它们所需的数据,并将其发送到线路另一端的正确应用程序。这个或多或少是任意选择的数字决定了连接到该 IP 地址的应用程序将获得哪些数据。这个端口号是 OSI 模型的第 4 层。我说这个数字或多或少是任意的。技术上,你可以选择你想要的任何数字,但关于这些数字有一些约定。由于端口号决定了哪个应用程序获取或发送数据,因此标准有助于确保我们所有人都为相同的应用程序使用相同的端口。例如,Web 服务器默认监听端口80,除非它们使用安全的 HTTPS 协议。后者使用端口443。有很多“保留”的数字,但技术上,没有任何东西阻止你为你的聊天应用程序使用端口80。尽管如此,我不建议这样做:这会混淆其他人。

我想确保我们的聊天服务器监听端口8080,这是一个“免费使用”的数字。

我在这里多次使用了“监听”这个词。监听意味着应用程序等待另一个进程连接,无论是我们机器上的还是外部机器上的。将其与等待电话响铃进行比较:你在等待你的铃声响起,并准备好接听。

由于您的机器可以有多个网络适配器,您必须指定您想要监听哪一个。在这种情况下,我选择了一个固定的 IP 地址,IPAddress.Loopback,它对应于127.0.0.1 IPv4 地址。这个地址是本地机器,没有连接到任何实际的适配器。换句话说,我们只监听来自同一物理机器的连接。

第 8 行很简单:我们启动服务器。在第 14 行的AcceptTcpClientAsync调用中,我们告诉服务器接受任何传入的连接。

多个客户端可以同时连接到同一个服务器。这里的客户端代表的是已连接的客户端。我们只期望有一个客户端,所以我们不需要处理会话。记住:会话管理是 OSI 模型的第 5 层。我们假设只有一个客户端,并将它存储在变量client中。客户端的类型是TcpClient,以防你有所疑问。

这个调用是阻塞的,并且只有当客户端连接时才会继续,我们通过第 15 行的消息告诉用户这一点。

一旦我们建立了连接,我们就打开一个流来访问客户端的数据或使我们能够向该客户端发送数据。这个流是NetworkStream类型,它是双向的。我们在第 17 行将这个流存储在变量stream中。

数据以二进制形式传入。因此,我们使用ReadAsync来读取一个数据缓冲区。我假设没有传入的数据超过 1,024 字节。在现实世界的应用中,你可能无法做出这个假设,所以你必须继续读取,直到你有了所有数据。在这里,我们将这些数据存储在一个长度为 1,024 字节的字节数组中(第 20 和 21 行),并将其转换为 UTF8 字符串(第 22 行)。这就是我们的数据呈现方式,它是 OSI 模型的第 6 层。一旦我们有了这个字符串,我们就显示它。如果字符串是“bye”,我们就认为客户端想要断开连接。否则,我们允许服务器端的用户输入一个响应,并在将其转换为另一个字节数组后将其发送给客户端。我们在这里使用相同的流。

如果流中没有更多数据,或者有人在对话中使用“bye”这个词,我们将在第 37 行关闭连接,并在第 38 行停止监听。

客户端的代码非常相似。下面是代码:

01: using System.Net.Sockets;
02: using System.Text;
03:
04: "Client is starting up.".Dump(ConsoleColor.Yellow);
05:
06: var client = new TcpClient("127.0.0.1", 8080);
07: "Connected to the server. Let's chat!".Dump(ConsoleColor.Yellow);
08: var stream = client.GetStream();
09:
10: while (true)
11: {
12:     "Say something".Dump(ConsoleColor.Yellow);
13:     var message = Console.ReadLine();
14:     var data = Encoding.UTF8.GetBytes(message);
15:     await stream.WriteAsync(data, 0, data.Length);
16:     if (message.ToLower() == "bye")
17:         break;
18:
19:     var buffer = new byte[1024];
20:     var bytesRead = await stream.ReadAsync(buffer, 0, buffer.            Length);
21:     var response = Encoding.UTF8.GetString(buffer, 0, bytesRead);
22:     $"Server says: {response}".Dump(ConsoleColor.Yellow);
23:     if (response.ToLower() == "bye")
24:         break;
25: }
26:
27: client.Close();
28: "Connection closed.".Dump(ConsoleColor.Yellow);

在第 6 行,我们创建了一个新的TcpClient类实例。同样,我们必须给它一个 IP 地址和一个端口号。这次,我们必须使用一个实际的数字。我们使用127.0.0.1,这意味着我们正在寻找同一台机器上的服务器。端口号仍然是8080;否则,我们的服务器将看不到任何进入的连接。

这个调用又是阻塞的,所以它不会继续,直到建立了一个连接。一旦我们有了连接,我们就可以访问流,就像第 8 行那样。这个流,再次,是NetworkStream类型,所以我们有一个双向连接。

我们为服务器所做的同样的事情。我们假设消息大小为 1,024 字节或更少。我们使用 UTF8 作为编码将字符串转换为字节数组,并从字节数组转换回字符串。我们使用“bye”这个词来表示停止交谈的愿望,并使用client.Close()来最终化连接。

如你所见,代码与服务器非常相似。我们在这里简化了许多事情:我们不考虑多个客户端连接到单个服务器的情况。我们对消息大小做了许多假设,并在出错时必须回退或重试机制。当跨机器工作连接时,事情出错是常有的事,所以你必须意识到这一点,并相应地编写代码。然而,由于这与实际的网络代码无关,正如我向你展示的那样,我可以安全地将这留给你去解决。

UDP

TCP 是一个优秀的协议,但它并非唯一。UDP 更直接且更轻量。当然,这也伴随着一些缺点。以下表格中,我概述了这两种协议之间的差异:

考虑因素 TCP UDP
主要目标 可靠性 速度
顺序 保证顺序 不保证消息顺序
握手
错误检查
拥塞控制
用例 网络浏览、聊天、文件传输、电子邮件 视频流、在线游戏、VOIP

表 6.3:TCP 和 UDP 对比

TCP 是可靠的。消息几乎总是到达。当事情出错时,TCP 会尝试重新发送数据,直到数据被成功交付。UDP 不关心这一点。它只是尽可能快地将数据发送出去。

TCP 确保消息按照发送的顺序到达。然而,UDP 并不保证:消息可能会以不同于离开源地的顺序到达目的地。

TCP 确保另一端准备好通信。UDP 只是开始发送数据。

TCP 检查数据以查看在传输过程中是否发生错误,甚至可以修复一些错误。UDP 并不关心:只要数据被发送,它就对此感到满意。

如果网络拥塞,TCP 可以减慢传输速度以帮助缓解。UDP 会尽可能快地丢弃数据,而不考虑网络条件。

当你必须有一个可靠、无错误的传输数据方式时,TCP 是最佳选择。例如,在聊天中,消息必须以预期的顺序正确传达。然而,UDP 完全是关于速度的。视频流就是一个例子:如果数据流中有时丢失了一部分,那并不是什么大问题。但是,缓慢的流会毁掉体验。

UDP 不常被使用,但它可以成为你工具箱中的一个宝贵工具。

使用共享内存在进程间交换数据

到目前为止,我们一直在向同一台计算机上的其他进程发送消息。使用命名管道和套接字,我们也可以使用其他机器。这正是这些协议的美丽之处:它们对网络是透明的。然而,如果你确定你只想留在同一台机器上,使用管道或套接字可能会成为一种负担。这些方法并不是最快的通信方式。在这种情况下,你可能更愿意使用 共享内存

共享内存的设置非常简单。当然,这也伴随着一些缺点。几乎没有任何方法可以保证数据的安全或防止冲突。然而,它的速度非常快;真的,非常快。所以,让我们看看一个示例。

首先,我们来看看如何将数据写入共享内存:

using System.IO.MemoryMappedFiles;
"Ready to write data to share memory.\nPress Enter to do     so.".Dump(ConsoleColor.Cyan);
Console.ReadLine();
using var mmf = MemoryMappedFile.CreateNew("SharedData", 1024);
// Create a view accessor to write data
using MemoryMappedViewAccessor accessor = mmf.CreateViewAccessor();
byte[] data = System.Text.Encoding.UTF8.GetBytes("Hello from Process     1");
accessor.WriteArray(0, data, 0, data.Length);
"Data written to shared memory. Press any key to     exit.".Dump(ConsoleColor.Cyan);
Console.ReadKey();

共享内存就像有一个只存在于内存中的文件。它是在内存中预留的一块区域。它有一个你可以用来识别它的名称。再次强调,它就像一个文件。在这里,我们创建了一个新的MemoryMappedFile类的实例,给它一个名称和大小。(在我们的例子中,1,024 字节)。如果你想使用那个文件,你必须获取MemoryMappedViewAccessor。你可以通过在MemoryMappedFile实例上调用CreateViewAccessor来获取它。

然后,你可以从这个访问器中读取和写入数据。

从那个共享文件中读取也同样简单。以下是代码:

using System.IO.MemoryMappedFiles;
"Wait for the server to finish. \nPress Enter to read the shared     data.".Dump(ConsoleColor.Yellow);
Console.ReadLine();
using var mmf = MemoryMappedFile.OpenExisting("SharedData");
// Create a view accessor to read data
using MemoryMappedViewAccessor accessor = mmf.CreateViewAccessor();
byte[] data = new byte[1024];
accessor.ReadArray(0, data, 0, data.Length);
$"Received message: {System.Text.Encoding.UTF8.GetString(data)}".    Dump(ConsoleColor.Yellow);

我们使用几乎与写入者相同的代码。然而,我们不是在内存中创建一个新的文件,而是打开一个现有的文件。我们不需要指定大小,但必须知道名称。

一旦我们有了那个文件,我们就可以使用相同的代码来获取一个访问器。有了它,我们可以读取数据并显示它。简单,不是吗?

再次强调,这是一种在相同机器上的进程之间快速共享数据的方法。然而,也有一些缺点需要注意。例如,任何知道共享内存块名称的进程都可以访问它。没有任何安全性。当然,你可以通过使用加密来规避这一点。

另一个缺点是没有内置的机制来通知进程新的或更改的数据。你必须使用像信号量(semaphores)和互斥锁(mutexes)这样的东西来做这件事。你可以使用实际的文件来设置FileSystemWatcher以获得通知,但这对内存中的这些共享文件不可用。

另一个潜在的缺点是它仅适用于 Windows。这可能会限制你以后部署的选择。

但总的来说,共享内存是快速在相同 Windows 机器上的进程之间共享大量数据的好方法。利用它的优势!

RPC 概述及其在 IPC 中的应用

到目前为止,我们探讨了我们可以共享数据的方法。在大多数情况下,开发者使用这种方法仅仅是为了数据:从一个系统向另一个系统发送有效载荷。然而,有效载荷也可以是其他东西。它们可以是指令,用来指示软件执行某些操作。我们不是在系统中存储、转换和使用数据,而是告诉其他系统执行操作。在这种情况下,我们谈论的是 RPC。

要从外部控制系统,建立通信线路,确保你的安全措施到位,并定义一个协议。

有很多种方法可以做到这一点。在以前,我们曾经使用过 SOAP、DCOM、WCF 和其他技术来做到这一点。

RESTful 服务与 RPC

你可以将 RESTful 服务视为某种 RPC。然而,它们并不相同,我不想在这里深入讨论 RESTful 服务。它们有很多相似之处,但 RESTful 服务背后的基本理念是它们都是关于资源的。调用网络服务通常用于从服务器检索数据。技术上,你可以设置 RESTful 服务仅接受命令,这样它们就是 RPC。这就像把 calzone 当作披萨。技术上,这是正确的,但在实践中存在足够的差异,需要采取不同的方法。因此,我决定不将 RESTful 服务包含在这本书中。如果你选择使用 RESTful 服务与你的系统通信,请随意。

基本上,这非常简单。你想出一个方法来结构和发送命令。只要双方都理解发生了什么,这就可以正常工作。当然,你不必重新发明轮子:存在几个经过良好建立的标准来做这件事。在本章的后面部分,我会向你展示如何使用 gRPC 来做这件事。然而,就像所有标准一样,它们都有代价。有时,你不需要一个已建立框架提供的额外复杂性。有时,你只想向系统发送一个简单的命令。假设你的场景允许一个不太安全和未知的协议。在这种情况下,你可以通过拥有自己的协议来提高速度和内存。

JSON RPC 是实现这一点的最常用方法之一。让我们看看。

JSON RPC

JSON RPC 就是将你的命令封装在 JSON 结构中,通过网络发送它们,在另一端拦截它们,并执行命令告诉系统执行的操作。

让我们从定义我们想要发送的命令开始:

[Serializable]
internal class ShowDateCommand
{
    public bool IncludeTime { get; set; }
}

我想让客户端通知服务器它需要打印当前的日期。我可能还想包括当前的时间。所以,这是我们创建的命令:带有 IncludeTime 字段的 ShowDateCommand

在我的示例中,我将客户端和服务器放在了同一个应用程序中,每个都在不同的任务上运行。我这样做是为了简化。当然,如果你想向同一应用程序的另一个部分发送命令,RPC 就过于冗余了。这甚至是不正确的:它根本不是远程的。然而,对于这个演示,它运行得很好。

对于通信,我选择了一个命名管道。它很容易设置,可以用来在网络中发送消息。除了这些考虑因素,我实际上没有选择这个选项的真正原因,所以你可以做任何你想做的事情。

服务器部分看起来是这样的:

internal class Server(CancellationToken cancellationToken)
{
    public async Task StartServer()
    {
        "Starting the server".Dump(ConsoleColor.Cyan);
        await using var server = new             NamedPipeServerStream("CommandsPipe");
        "Waiting for connection".Dump(ConsoleColor.Cyan);
        await server.WaitForConnectionAsync(cancellationToken);
        using var reader = new StreamReader(server);
        while (!cancellationToken.IsCancellationRequested)
        {
            var line = await reader.ReadLineAsync();
            if (line == null) break;
            $"Received this command: {line}".Dump(ConsoleColor.Cyan);
            var command = JsonSerializer.                Deserialize<ShowDateCommand>(line);
            if (command is { IncludeTime: true })
                DateTime.Now.ToString("yyyy-MM-dd
                    HH:mm:ss").Dump(ConsoleColor.Cyan);
            else
                DateTime.Now.ToString("yyyy-MM-dd").Dump(ConsoleColor.                    Cyan);
        }
    }
}

这个名为 Server 的类有一个名为 StartServer 的方法。它使用 CommandsPipe 名称创建一个 NamePipeServerStream 实例。然后,它等待客户端连接。一旦发生这种情况,我们就读取传入的数据。一旦我们得到一个字符串,我们就将其反序列化为正确的格式并执行它告诉的任务:打印当前的日期,并可选地包括时间。

客户端看起来像这样:

internal class Client(CancellationToken cancellationToken)
{
    public async Task StartClient()
    {
        var newCommand = new ShowDateCommand
        {
            IncludeTime = true
        };
        var newCommandAsJson = JsonSerializer.Serialize(newCommand);
        "Starting the client".Dump(ConsoleColor.Yellow);
        await using var client = new             NamedPipeClientStream("CommandsPipe");
        await client.ConnectAsync(cancellationToken);
        await using var writer = new StreamWriter(client);
        $"Sending this command: {newCommandAsJson}".Dump(ConsoleColor.            Yellow);
        await writer.WriteLineAsync(newCommandAsJson);
        await writer.FlushAsync();
    }
}

客户端创建了一个ShowDateCommand的实例,并将IncludeTime设置为true。然后,它使用正确的名称创建了NamedPipeClientStream并连接到服务器。最后,它通过电线发送 JSON。这就是全部内容。

为了完整性,我在程序的Main方法中给出了初始化服务器和客户端的代码:

var cancellationTokenSource = new CancellationTokenSource();
"Starting the server".Dump(ConsoleColor.Green);
var server = new Server(cancellationTokenSource.Token);
Task.Run(() => server.StartServer(), cancellationTokenSource.Token);
var client = new Client(cancellationTokenSource.Token);
    Task.Run(() => client.StartClient(),
    cancellationTokenSource.Token);
"Server and client are running, press a key to stop".    Dump(ConsoleColor.Green);
var input = Console.ReadKey();
"Stopping all".Dump(ConsoleColor.Green);

我创建了ServerClient的实例,在Task.Run()中启动它们,并等待用户按下键。在后台,ServerClient执行它们的工作,通过调用Dump()告诉你所有关于它的事情。请注意Dump中的线程 ID——它们对于了解线程(或刷新你的记忆)非常有信息量。

这种技术简单且非常快。然而,它仅在你知道等式的两端:服务器和客户端必须遵循你的专有协议。如果不是这样,你最好使用一个标准。这些标准之一就是 gRPC。让我们看看下一个。

gRPC 概述及其用于 IPC 的使用方法

目前建立进程间直接通信的领先方式之一是 gRPC。缩写gRPC代表Google 远程过程调用或递归命名的gRPC 远程过程调用。你可以选择你喜欢的。谷歌将其开发为一个公开版本和改进其内部框架 Stubby 的版本。

gRPC 使用协议缓冲区Protobufs)。这是一种描述可用命令、消息和可以传递的参数的格式。Protobufs 被编译成二进制形式,从而实现更快的数据传输。该系统建立在 HTTP/2 之上,因此我们可以使用多路复用(多个请求通过相同的 TCP 连接)。HTTP/2 比旧的 HTTP/1.x 具有更多优势,其中大多数涉及效率。

跨语言和平台支持也是主要的驱动因素之一。因此,你可以确信 gRPC 可以在许多设备上使用。

假设我们想要重新构建一个示例系统,该系统可以远程指示显示当前日期(带或不带时间)。在这种情况下,我们首先必须定义消息结构。然而,在我们这样做之前,我们需要向我们的服务器应用程序添加几个 NuGet 包:

描述
Google.Protobuf 处理 proto 文件
Grpc.Core gRPC 的核心实现
Grpc.Tools 包含,例如,proto 文件的编译器
Grpc.AspNetCore 需要在我们的应用程序中托管服务器

表 6.4:我们的 gRPC 服务器的 NuGet 包

在 C#控制台应用程序中,添加一个名为displayer.proto的新文件。这只是一个文本文件。我喜欢将它们放在一个单独的文件夹中,我称之为Protos。编译器会处理这个文件,为我们生成大量的 C#代码。

文件看起来像这样:

syntax = "proto3";
option csharp_namespace = "_02_GRPC_Server";
service TimeDisplayer {
    rpc DisplayTime (DisplayTimeRequest) returns (DisplayTimeReply);
}
message DisplayTimeRequest{
    string name = 1;
    bool wantsTime = 2;
}
message DisplayTimeReply{
    string message = 1;
}

让我们分析一下。

首先,我们告诉系统这是什么格式。我们使用proto3,这是最新也是推荐的版本。

然后,我们告诉系统在生成 C#文件时将它们放在哪个命名空间中。正如你所想象的,这个选项仅限于 C#。这是一个辅助选项,帮助我们保持代码的整洁。

然后,我们定义服务。我们有一个名为TimeDisplayer的服务。它有一个名为DisplayTime的 RPC 方法。它以DisplayTimeRequest作为参数,并返回DisplayTimeReply类型的某个东西。

DisplayTimeRequestDisplayTimeReply类型定义在下面。它们是消息,并且可以包含参数。我添加了一个名字来展示如何添加一个字符串。对于请求,我还添加了一个布尔值,表示我们是否想要显示时间。

参数需要按顺序和编号。这样,如果消息以某种方式被打乱,两个系统仍然知道数据最初看起来是什么样子。

Visual Studio 通常知道如何处理这个,如果你在你的应用程序中添加了一个.proto文件。然而,如果这没有发生(我偶尔也看到它出错),你必须指导编译器如何处理这个文件。在你的csproj文件中,只需添加以下部分:

<ItemGroup>
  <ProtoBuf Include="Protos\displayer.proto" GrpcServices="Server" />
</ItemGroup>

这应该足以让编译器开始工作了。

让我们构建服务器!

我在我的控制台应用程序中添加了服务器的代码。由于编译器会为我们编译所有必要的代码,我们可以使用以下代码:

internal class TimeDisplayerService : TimeDisplayer.TimeDisplayerBase
{
    public override Task<DisplayTimeReply> DisplayTime(
        DisplayTimeRequest request,
        ServerCallContext context)
    {
        var result = request.WantsTime
            ? DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")
            : DateTime.Now.ToString("yyyy-MM-dd");
        result.Dump();
        return Task.FromResult(new DisplayTimeReply
        {
            Message = $"I printed {result}"
        });
    }
}

我们的TimeDisplayerService类是从TimeDisplayer.TimeDisplayerBase基类派生的。这个基类是由我们的.proto文件生成的。正如你所看到的,TimeDisplayer的名字与我们在那个.proto文件中的名字匹配。

我们这里有一个名为DisplayTime的方法。再次,这与我们在.proto文件中的内容匹配。代码很简单;它只接受一个DisplayTimeRequest实例,查看WantsTime参数,并返回结果。

通常,gRPC 服务器运行在一些类型的 web 服务器上,将此代码添加到 ASP.NET 应用程序中是直接的。但当然,你可以在任何你想运行它的地方运行它,这是我们作为系统程序员真正可以利用的事情。所以,如果你打算在控制台应用程序中运行此代码,你可以按照以下方式设置。在你的程序的主要方法中,添加以下内容:

"Starting gRPC server...".Dump();
var port = 50051;
var server = new Server
{
    Services = {TimeDisplayer.BindService(new         TimeDisplayerService())},
    Ports = {new ServerPort("localhost", port, ServerCredentials.        Insecure)}
};
server.Start();
Console.WriteLine("Greeter server listening on port " + port);
Console.WriteLine("Press any key to stop the server...");
Console.ReadKey();
await server.ShutdownAsync();

我们创建了一个新的Server类实例。这来自于我们安装的gRPC.Core NuGet 包。我们给它提供了我们想要使用的服务(在我们的例子中,是TimeDisplayerService)并定义了我们决定使用的网络地址和端口。在这里我不关心凭证,但你可以使用 SSL、TLS 和其他安全方式。

我们启动服务器并等待用户按下任意键。然后,我们再次停止服务器。

接下来:客户端。

再次,我们需要向我们的控制台应用程序添加一些 NuGet 包。这些是你需要的:

描述
Google.Protobuf 处理 proto 文件
Grpc.Net.Client gRPC 的客户端实现
Grpc.Tools 包含编译器,用于处理 proto 文件等

表 6.5:我们的 gRPC 客户端所需的 NuGet 包

首先,我们需要一个 .proto 文件。更准确地说,我们需要与服务器上使用的相同的 .proto 文件。因此,最好链接到该文件而不是重新创建它。然而,如果你喜欢键入,请随意创建一个新的。只需确保在做出更改时这些文件保持同步即可。

我们不需要特定的客户端类;我们只需在我们的程序 Main 方法中添加以下代码即可:

"Starting gRPC client... Press ENTER to connect.".Dump(ConsoleColor.Yellow);
Console.ReadLine();
var channel = GrpcChannel.ForAddress("http://localhost:50051");
var client = new
TimeDisplayer.TimeDisplayerClient(channel);
var reply =
    await client.DisplayTimeAsync(
        new DisplayTimeRequest
        {
            Name = "World",
            WantsTime = false
        });
Console.WriteLine("From server: " + reply.Message);

我们从等待用户按下一个键开始。由于我在解决方案中同时启动服务器和客户端,如果客户端在设置连接时比服务器快一点,我可能会遇到时序问题。

然后,我们使用正确的参数调用 GrpcChannel.ForAddress() 来设置连接。有了这个连接,我们使用正确的 DisplayTimeRequest 设置调用 DisplayTimeAsync 方法。结果应该返回并显示服务器执行的操作。

就这些了!我们现在有一个完全功能的服务器和客户端应用程序,它们通过 gRPC 进行通信。

JSON RPC 和 gRPC 的差异

正如你所见,设置 gRPC 服务器和客户端并不太复杂。但仍然,它会给你的代码增加一些复杂性。如果你不需要 gRPC 的优势,你可以使用 JSON RPC。但你在什么时候选择哪一个呢?

如果你的消息变得很大,gRPC 是更好的选择。记得我之前说过 I/O 很慢吗?嗯,JSON 文件通常比它们的二进制等效文件大得多。gRPC 使用较小的二进制格式,因此使用该格式进行数据传输要快得多。

然而,JSON 更易于阅读,更易于调试,也更易于人类解释。代码也更容易设置。.proto 文件是你必须习惯的东西。除此之外,编译器需要将 .proto 文件转换为 C# 类,这会使你的系统更加复杂。

总的来说,这取决于你的场景。然而,为了便于参考,我在下表中概述了 JSON RPC 和 gRPC 之间的差异:

特性 gRPC 使用 JSON 的 RPC
序列化格式 Protobufs(二进制格式) JSON(文本格式)
性能 由于二进制序列化,通常更高,初始设置和连接可能较慢 低于二进制格式,但设置更快(取决于通信设置)
协议 HTTP/2 通常为 HTTP/1.1
流式传输 支持双向流 有限支持,通常是请求-响应
类型安全 强类型合约(Protobuf) 松散类型,容易在运行时出现错误
语言互操作性 高(原生支持许多语言) 高(JSON 被普遍支持)
网络效率 更高效(更小的负载,HTTP/2 特性) 更低效(更大的负载,HTTP/1.1)
错误处理 丰富的错误处理,具有明确的错误代码 通常依赖于 HTTP 状态代码
截止日期/超时 原生支持指定调用截止日期 通常在应用层管理
安全性 支持各种身份验证机制 通常在应用层添加

表 6.6:gRPC 和 JSON RPC 之间的差异

如您所见,尽管 gRPC 和使用 JSON 的 RPC 具有许多共同特性,但每个都有其特定的使用场景。选择最适合您场景的那个。

下一步

每个人都需要有人陪伴。这个真理甚至成为了一首歌的标题。对于系统来说,尤其是那些不是为人类使用而设计的系统,也是如此。它们需要某种东西来告诉它们该做什么,以及使用什么数据来做。它们需要相互通信。您现在已经看到了许多可以用来设置通信的方法。

我们已经探讨了 Windows 消息,这种传统的通信风格(尽管 Windows 仍然用于内部通信)。我们研究了命名管道和无名管道。然后,我们探讨了计算机之间最常用的通信方式:套接字。在这个过程中,我们还对 OSI 模型进行了研究,以了解我们需要在哪里编写代码,以及我们可以将哪些留给他人。

我们还探讨了在相同机器上使用共享内存快速共享数据的方法。

最后,我们研究了如何通过使用 JSON RPC 和 gRPC 来发布命令。

现在,我们应该准备好迈出下一步。毕竟,除了与我们的代码进行交流外,我们还可以使用操作系统来帮助我们。Windows 提供了许多我们可能需要或可以利用的服务,这是下一章的主题。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐