65.9K
CodeProject 正在变化。 阅读更多。
Home

实现异步命名管道服务器 - 第 1 部分

starIconstarIconstarIconstarIconstarIcon

5.00/5 (7投票s)

2022年12月9日

MIT

21分钟阅读

viewsIcon

15775

如何实现命名管道服务器与客户端应用程序通信

引言

Windows 提供了多种实现进程间通信(Inter Process Communication,简称 IPC)的方法。其中一种称为命名管道(Named Pipes),它允许一个进程接收来自多个客户端的数据并作出响应。命名管道有几个 API 用于设置和配置管道,并使用通用的文件 IO 函数进行实际通信。

微软提供了各种场景的示例,包括异步 IO 的示例。这些示例非常有助于理解管道,并且保证是正确的。但它们存在将基础设施代码与应用程序逻辑混杂在一起的问题。尽管有一个多线程示例,但它回避了处理动态缓冲区大小或用户提供的完成例程等问题。

在本文中,我将展示如何构建一个命名管道服务器,该服务器可以:

  1. 将所有连接处理从主应用程序线程中分离出来。
  2. 使用可配置的线程池异步执行所有数据处理,并使用用户提供的消息处理程序。
  3. 不使用锁来确保线程安全。
  4. 将所有命名管道基础设施隐藏在应用程序逻辑之外。

通过这样做,我们可以获得一个可重用的命名管道服务器组件。在第一篇文章中,我将介绍如何实现服务器端和连接处理。实际的 IO 处理将在第二部分介绍。将此主题拆分的原因是,第一部分在逻辑上与实际 IO 处理是分开的。IO 可以有多种实现方式,因此通过这样做,我可以轻松地制作第三部分来涵盖不同的 IO 实现。

总体思路是,任何人都可以轻松地将命名管道服务器添加到他们的项目中,而无需担心实现细节,并提供一个函数,该函数将在接收到来自客户端的每条消息时执行。该函数具有以下格式:

    typedef void (*PipeWorkerMessageHandler)(
        void* context,
        CHandle& pipeHandle,
        CIOBuffer& input,
        CIOBuffer& output);

这个概念类似于为线程提供一个函数。context 是在 PipeWorkerMessageHandler 函数执行时提供给它的一个指针。这是在不使用全局变量的情况下,使该函数能够访问应用程序上下文数据的唯一方法。inputoutput 纯粹是关于 IO 的。该函数可以自行决定如何处理输入数据,如果它想发送响应消息,可以将数据放入输出缓冲区。

在典型情况下,pipeHandle 不会被使用,因为数据发送和接收是通过 IO 缓冲区完成的。然而,命名管道支持模拟等功能,这时就需要句柄。此外,消息处理程序可能会决定关闭连接。对于此类操作,它需要 pipeHandle,这就是服务器提供它的原因。根据关闭句柄的方式,IO 处理程序中可能需要进行特定的清理。我们将在第二部分对此进行介绍。

设计 CNamedPipeServer

由于我们正在构建一个异步运行以执行工作的组件,因此使用状态图来可视化它是有意义的。

在创建对象并初始化所有内部变量后,管道服务器处于 initialized 状态,但处于空闲状态。它只是在 Initialized 状态下等待,直到对象所有者做出决定。它不会立即开始服务连接请求,因为应用程序本身可能仍在初始化。例如,Microsoft SQL Server 通过命名管道接受客户端连接。但命名管道处理的初始化只是整个应用程序启动的一个方面。在应用程序确定所有其他子系统都已启动并运行时,才阻塞所有通信是有意义的。根据应用程序的决定,状态可以更改为 **shutting down**(正在关闭)或 **accepting client connections**(正在接受客户端连接)。

组件不能立即关闭是有原因的。该组件是异步运行的。该组件在任何确切给定时间所做的事情基本上是不可知的。它可能正在进行需要清理的操作,或者很可能如此。我们不能在没有任何后果的情况下将其突然移除,因此我们指示它采取所有必要的措施来关闭。最终的状态转换取决于组件本身。

请注意,过渡到 **shutting down**(正在关闭)状态只能发生在它正在 initialized(已初始化)状态下等待,或者它正在 **accepting client connections**(正在接受客户端连接)时。如果它正在 **starting client IO**(正在启动客户端 IO)状态下执行任何操作,则无法中断。如果在此时发送了关闭信号,它将根本不会被执行。在服务器能够响应关闭信号之前,任何新的连接都将被完全卸载。

就设计而言,目前就这些。IO 处理程序的设计将在第二部分介绍。

Win32 命名管道 API 参考

在深入研究设计本身之前,讨论一些与命名管道(尤其是在服务器端)相关的 Win32 API 非常重要。

IO 本身相对容易稍后讨论,因为 IO 是使用与常规文件 IO 相同的 ReadFileWriteFile 等 API 执行的。但在服务器端,有几个额外的函数对于管理管道对象至关重要。

本节可能有点枯燥乏味。如果您只想了解整个管道服务器的设计,可以跳过它。但要理解一些设计选择或细节,就需要本章提供的信息。

创建管道

我们可以使用 CreateNamedPipe 函数调用来创建管道。这个函数有一个不幸的名字。虽然它确实创建了具有指定名称的命名管道内核对象,但更准确的说法是,如果该对象尚不存在,它会创建内核对象,并创建一个单一的服务器端连接端点,单个客户端可以连接到该端点。

不需要并行拥有多个这样的端点(打开的句柄),因为这个句柄用于等待操作,等待客户端连接。当客户端连接时,它会被卸载到 IO 池,并创建一个新的端点以准备下一个客户端。只要下一个连接在连接超时之前可用,客户端就永远不会知道服务器是否暂时繁忙以卸载上一个连接。

HANDLE CreateNamedPipeW(
    LPCwSTR lpName,
    DWORD dwOpenMode,
    DWORD dwPipeMode,
    DWORD nMaxInstances,
    DWORD nOutBufferSize,
    DWORD nInBufferSize,
    DWORD nDefaultTimeOut,
    LPSECURITY_ATTRIBUTES lpSecurityAttributes );

lpName 是管道的设备名称。它应该采用 \\.\Pipe\pipename 的格式。dwOpenMode 设置了管道的一些重要属性,这些属性决定了它的使用方式。我们使用的有:

  • PIPE_ACCESS_DUPLEX,它允许双向数据传输。
  • FILE_FLAG_OVERLAPPED,它启用了异步读写操作。这很重要,因为我们想构建一个以异步方式执行 IO 和连接管理的服务器。如果我们不指定此标志,所有 IO 都将变为同步。

dwPipeMode 指定了我们如何使用管道。基本上有两种方式:将其用作独立、分离的消息流,或者用作字节流。当然,这个选择取决于您要做什么。有些事情很自然地适合消息(服务应用程序的命令和控制),或者字节流(音频流)。我正在构建一个用于基于消息的 IO 的组件,因此我们使用以下值:

  • PIPE_TYPE_MESSAGE,它指定写入管道的每个写入操作都被视为单个消息,并且可以与下一个写入操作区分开来。
  • PIPE_READMODE_MESSAGE,它指定从管道读取数据时,将其作为消息而不是字节流。

起初,指定一个与写入模式不同的读取模式似乎很奇怪。为什么要把消息读成字节流呢?这样做的话,就无法区分连续的消息了。答案取决于具体用例。如果您将每条消息都视为独立的消息,这可以简化逻辑,因为您不必实现协议逻辑来识别消息的开始和结束。如果您读取字节流,您可以使用一次读取操作读取多条消息,当您处理大量非常小的消息并且频率很高时,这可能会节省时间。

在这种情况下,PIPE_TYPE_MESSAGE 仍然有益,因为管道服务器仍然保证不必处理不完整的消息。每次写入仍被视为一个整体,因此当服务器读取消息流时,它保证缓冲区中的消息数量有多少,最后一条消息都是完整的。

nMaxInstances 指定可以存在多少个管道实例。在这种情况下,一个实例是客户端-服务器连接的服务器端。可以连接的客户端数量与可能的实例数量相同。

nInBufferSizenOutBufferSize 只是连接缓冲区的默认大小。在 文档 中有关于如何调整它们的大段内容。请注意,这不是消息长度的硬限制。它们应该足够大以容纳典型消息,但又足够小以避免对非分页池产生负面影响。

nDefaultTimeOut 可以保留为 0。这是当客户端尝试连接到命名管道时使用的超时时间。如果客户端在未指定超时的情况下尝试连接且没有可用实例,则在尝试失败之前会使用此超时。

最后一个参数是 lpSecurityAttributes。使用 NULL 接受命名管道的默认 ACL。这对于大多数用途来说就足够了。

接受客户端连接

创建命名管道后,下一步是主动等待客户端连接。这通过以下 API 完成。

BOOL ConnectNamedPipe(
    HANDLE       hNamedPipe,
    LPOVERLAPPED lpOverlapped
);

hNamedPipe 是先前创建的句柄。如果我们不提供 OVERLAPPED 结构,此函数将简单地阻塞直到客户端连接或句柄关闭。出于关闭服务器的目的而取消操作将很难可靠地完成(我将在下面详细解释)。使用 OVERLAPPED 结构使我们能够使用单独的等待函数来等待连接,从而解决这个问题。

OVERLAPPED 结构是 Win32 API 处理异步请求的一种方式。它包含数据字段,用于跟踪异步 IO 操作的结果,以及一个 HANDLE 字段,该字段包含一个 Win32 事件的句柄,在 IO 操作完成时触发该事件。

使用 Windows 事件

说到事件:事件是我设计中的一个重要部分,无论是在服务器端还是在稍后讨论的 IO 处理中。Windows 事件就是可以用来触发正在进行的等待操作的对象。如果您使用 CreateEvent 创建一个事件,您可以将返回的 HANDLE 值用于,例如,WaitForSingleObject 来等待事件被触发。

在使用事件方面有相当大的灵活性。有两种类型:手动重置事件或自动重置事件。如果手动重置事件被设置,那么所有当前和未来的等待操作,在所有线程中都会被满足(继续),直到事件被重置。如果自动重置事件被触发,那么只有 1 个当前或未来的等待操作会被满足。因此,即使有 4 个线程正在等待同一个自动重置事件,也只有一个会被触发。

CreateEvent 有几个关于安全、名称以及初始状态是否设置的参数。由于大多数时候您将使用相同的默认设置,只需要手动/自动选择,我创建了 2 个轻量级包装器,并附带合适的构造函数。

    class CEvent : public CHandle
    {
    public:
        CEvent(
            LPSECURITY_ATTRIBUTES lpEventAttributes,
            BOOL bManualReset,
            BOOL bInitialState,
            LPCWSTR lpName);

        CEvent(
            DWORD dwDesiredAccess,
            BOOL bInheritHandle,
            LPCWSTR lpName);
        
        /*Interface details removed for clarity*/
    };

    class CManualResetEvent : public CEvent
    {
    public:
        CManualResetEvent(
            BOOL bInitialState = FALSE,
            LPCWSTR lpName = NULL,
            LPSECURITY_ATTRIBUTES lpEventAttributes = NULL) :
            CEvent(lpEventAttributes, TRUE, bInitialState, lpName) {}  
    };

    class CAutoResetEvent : public CEvent
    {
    public:
        CAutoResetEvent(
            BOOL bInitialState = FALSE,
            LPCWSTR lpName = NULL,
            LPSECURITY_ATTRIBUTES lpEventAttributes = NULL) :
            CEvent(lpEventAttributes, FALSE, bInitialState, lpName) {}
    };

在最低级别,事件与其他 Windows 句柄一样,并且必须像这样进行管理。CEvent 有 1 个构造函数:一个用于打开现有事件,一个用于创建新事件。

CManualResetEventCAutoResetEvent 都只有 1 个构造函数。它们只支持通过创建新事件的构造函数进行构造。原因很简单:事件类型在创建事件时确定。当某人打开现有事件时,他们别无选择。这就是为什么我选择不为 CManualResetEventCAutoResetEvent 提供打开现有事件的构造函数。没有固有的方法可以确保实际的事件类型与预期的类型匹配。因此,我选择只允许通过通用的 CEvent 打开现有事件。

如果我没有这些类,每次创建事件时都需要提供 TRUEFALSE,这太容易出错了。

设置句柄状态

我之前提到过管道 IO 与文件 IO 类似。当然,从客户端来看没有区别。甚至连接到管道也是使用 CreateFile 完成的。但是,就像服务器一样,客户端可以控制它是想将 IO 接收为消息还是字节流。只是这不能通过 CreateFile 配置。相反,客户端需要在创建句柄后使用以下 API 来完成此操作。

BOOL SetNamedPipeHandleState(
    HANDLE  hNamedPipe,
    LPDWORD lpMode,
    LPDWORD lpMaxCollectionCount,
    LPDWORD lpCollectDataTimeout
);

这里主要的关注点是 lpMode 参数。它可以设置为 PIPE_READMODE_MESSAGEPIPE_READMODE_BYTE。通常,这只需要在客户端进行,因为服务器端可以在创建管道时直接指定,但如果需要,服务器也可以使用 SetNamedPipeHandleState

实现 CNamedPipeServer

在掌握了所有基础知识后,我们现在可以实现服务器了。

构造对象

第一步是实现构造函数。这将初始化在 API 部分解释过的成员变量,所以这里不再赘述。需要注意的主要事项是 IO 处理程序,它在服务器初始化期间初始化,以及在此处启动的连接处理线程。

在构造阶段发生异常时,我们不需要清理代码来处理线程。它是最后一个可能创建异常的操作。无论异常发生在何处,都可以保证线程没有在运行。

    CNamedPipeServer::CNamedPipeServer(
        wstring const& lpName,
        PipeWorkerMessageHandler messageHandler,
        void* messageContext,
        DWORD nOutBufferSize,
        DWORD nInBufferSize,
        DWORD nMaxInstances,
        DWORD nNumThreads) :
        m_Name(lpName),
        m_MaxInstances(nMaxInstances),
        m_InBufferSize(nInBufferSize),
        m_OutBufferSize(nOutBufferSize),
        m_ConnectEvent(),
        m_StartEvent(),
        m_ShutdownEvent(),
        m_WorkerPool(nNumThreads, messageHandler, messageContext, 
                     nInBufferSize, nOutBufferSize)
    {
        memset(&m_ConnectOverlap, 0, sizeof(m_ConnectOverlap));
        m_ConnectOverlap.hEvent = m_ConnectEvent;

        //Create a named pipe server endpoint once.
        //This is just to validate the supplied parameters. If they are invalid, it's
        //better to find out now than when this is offloaded into a worker thread.
        CHandle pipeHandle = CreateNamedPipeHandle();

        //check the reason for failure
        if (pipeHandle == INVALID_HANDLE_VALUE) {
            throw ExWin32Error(L"CreateNamedPipeW " + lpName);
        }

        m_ConnectionHandlerThread = 
          CreateThread(NULL, 0, ConnectionHandlerFunc, this, 0, NULL);
        if (!m_ConnectionHandlerThread) {
            throw ExWin32Error(L"CreateThread ConnectionHandlerFunc");
        }
    }

当应用程序提供启动管道的详细信息时,这些参数可能无效。为了避免在连接处理线程中发现这些问题并异步处理它们(此时服务器对象已构造),我们尝试在此处创建它。如果这会引起问题,最好在此处检测到并中止。句柄将在创建后自动关闭。句柄的创建是通过帮助函数完成的。

    HANDLE CNamedPipeServer::CreateNamedPipeHandle()
    {
        return CreateNamedPipeW(m_Name.c_str(),
            PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED,
            PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE,
            m_MaxInstances,
            m_OutBufferSize,
            m_InBufferSize,
            0,
            NULL);
    }

需要注意的其他事项是,启动事件和连接事件是自动重置的,这意味着每次处理完事件后,新的 wait 操作都可以开始,而无需手动重置它。对于关闭,我们使用手动重置事件,原因是关闭一旦启动,所有涉及关闭事件的等待操作都应该继续。如果我没有这些类,每次创建事件时都需要提供 TRUEFALSE,这太容易出错了。

连接处理程序状态机:已初始化

这就是魔术发生的地方。为了便于阅读,我将其分成了几个部分。请记住,这在一个独立的线程中运行,与应用程序的其余部分分开。

其中第一部分是我们 statemachineinitialized 状态的代码。

    DWORD CNamedPipeServer::ConnectionHandlerFunc(void* ptr)
    {
        CNamedPipeServer* server = (CNamedPipeServer*) ptr;

        HANDLE initialWaitObjects[] = { server->m_StartEvent, server->m_ShutdownEvent };
        DWORD StartEventIndex = 0;
        DWORD ShutdownEventIndex = 1;

        DWORD retVal = WaitForMultipleObjects(2, initialWaitObjects, FALSE, INFINITE);
        if (retVal == StartEventIndex) {
            ; //Do nothing. Connection processing is allowed to start
        }
        else {
            server->m_WorkerPool.Shutdown();
            return 0;
        }

我们使用 WaitForMultipleObjects 来等待关闭事件或启动事件。这是为了让应用程序自己完成初始化,然后再打开客户端连接。返回值是满足等待操作的句柄的索引。此时不需要特殊的终止代码,因为连接处理尚未激活。如果我们进入 shutting down 状态,我们也会启动工作池的关闭。

从这里开始,状态要么是 shutting down(在析构函数中结束),要么是 accepting client connections(正在接受客户端连接)。我们代码的下一部分是 accepting client connections

连接处理程序状态机:接受客户端连接

    HANDLE waitObjects[] = { server->m_ConnectOverlap.hEvent, server->m_ShutdownEvent };
    DWORD ConnectEventIndex = 0;
    DWORD retVal = 0;

    while (!server->IsShuttingDown())
    {
        CHandle pipeHandle = server->CreateNamedPipeHandle();
        if (pipeHandle == INVALID_HANDLE_VALUE) {
            DWORD error = GetLastError();

            if (ERROR_PIPE_BUSY == error) {
                Sleep(1000);
                continue;
            }
            else {
                server->Shutdown();
                retVal = error;
            }
        }

        if(!ConnectNamedPipe(pipeHandle, &server->m_ConnectOverlap)) {
            DWORD error = GetLastError();
            if (error == ERROR_IO_PENDING)
                ; //normal. The wait operation has started.
            else if (error == ERROR_PIPE_CONNECTED) {
                SetEvent(server->m_ConnectOverlap.hEvent);
            }
            else {
                server->Shutdown();
                retVal = error;
            }
        }

        DWORD retVal = WaitForMultipleObjects(2, waitObjects, FALSE, INFINITE);

我们通过创建新的管道服务器端点来开始此状态。我们已经预先验证了参数,因此通常只有一个原因导致管道无法创建,那就是实例已达到上限。如果服务器以例如最多 10 个并发实例启动,并且有 10 个已连接的客户端,那么我们无法创建第 11 个端点是很自然的。

因此,如果 GetLastError 返回 ERROR_PIPE_BUSY,我们只需休眠一秒钟然后重试。总的来说,我试图避免休眠。这是需要权衡的两种设计选择之一:为了更优的解决方案而增加复杂性,还是采用简单的休眠。另一种解决方案是使用信号量,并使用 wait 操作将信号量与打开的连接关联起来。这当然是未来改进的一个选项。目前,我们将在连接的客户端达到上限之后才会遇到这个问题,这让我选择了休眠。

在创建了服务器端点后,我们使用 ConnectNamedPipe 来等待客户端连接。我们使用重叠等待,以便可以使用 WaitForMultipleObjects 来等待直到我们收到连接事件或关闭事件。现在这是非常不可能但仍然可能发生的,在创建管道和尝试连接客户端连接的毫秒之间,恰好有一个客户端连接。当发生这种情况时,异步操作从未启动,而是返回错误 ERROR_PIPE_CONNECTED

由于异步操作从未启动,wait 操作将不会被触发。我们可以通过两种方式来处理它。第一种是绕过等待操作。但这将留下两种需要启动 IO 的情况(当返回 ERROR_PIPE_CONNECTED 时,或者在返回 ERROR_IO_PENDING 并且等待被满足后)。相反,我选择手动触发连接事件。最大的好处是它协调了控制流,并且其余过程对于这两种情况都是相同的。

我想花点时间退一步,解释另一个 ConnectNamedPipe 被重叠使用的原因。考虑替代方案:该 API 会阻塞直到客户端连接。如果我们发送关闭事件会怎样?什么也不会发生。相反,如果我们想能够发起关闭,我们必须从另一个线程关闭管道句柄。这会中止 ConnectNamedPipe。然而,在多线程场景中,你不能保证任何特定的时间。在两个地方使用一个句柄可能会导致问题。假设,线程 1 在关闭句柄后被抢占,恰好在内部关闭句柄但尚未将其内部值设置为 NULL 的点。此时,线程 2 可以满足等待,因为恰好有客户端连接,并尝试卸载句柄(这将失败,但这在这里无关紧要),并且原始 CHandle 值将通过 RAII 删除。请记住,线程 1 在清理其内部状态之前被中断了。因此,线程 2 也会关闭该句柄。

如果连接了调试器,这将导致异常。如果没有连接调试器,它实际上不会造成太大危害。但我希望很清楚,在多个线程中使用对象和数据通常是一个坏主意,除非你真的有必要。显然,有办法解决这个问题。你可以使用锁定原语、InterlockedExchange 函数或类似的东西。但正如你所见,它很快就会变得一团糟,而且正确性越来越难以证明。另一方面,像 WaitForMultipleObjects 这样的函数专门用于等待可以来自不同源和线程的触发器,因此是响应源自不同线程的事件的理想选择。最后两段不特定于管道,而是适用于任何多线程场景:保持简单,并使用专门为该场景设计的原语。

连接处理程序状态机:启动客户端 IO

现在到了 **starting client IO**(启动客户端 IO)状态。

    while (!server->IsShuttingDown())
    {
    // previous code omitted
    // DWORD retVal = WaitForMultipleObjects(2, waitObjects, FALSE, INFINITE);
        if (retVal == ConnectEventIndex) {
            try
            {
                server->m_WorkerPool.AddClientConnection(pipeHandle);
            }
            catch (exception& ex)
            {
                //the pipehandle is not added. The only remaining handle will
                //be cleaned up automatically when the local variable goes out of scope
            }
        }
        else if (retVal == ShutdownEventIndex) {
            //We don't have to do anything special here.
            //The loop will terminate because IsShuttingDown is true
        }
        else {
            //Wait failed. the only logical conclusion is the application itself is
            //experiencing a critical failure
            server->Shutdown();
            retVal = GetLastError();
        }
    }
    server->m_WorkerPool.Shutdown();
    m_WorkerPool.WaitUntilFinished(INFINITE);
    return retVal;
}

这一部分相对简单。如果收到了连接事件,则将句柄添加到工作池中。从那时起,它就不再被关心了。详细信息将在第二部分介绍,但足以说明只有两种选择:句柄被复制并存储以供工作池使用,或者发生异常,这意味着没有启动 IO。无论哪种方式,我们都完成了。

正如原始状态图所示,代码的这一部分对事件不敏感。**starting client IO** 是无条件的。即使此时已经发送了关闭请求,也不会影响这里正在发生的事情。此时,IO 处理已启动,不再是我们的关注点。IO 工作池本身将负责关闭自身并终止所有连接。但是,如果等待操作出现意外错误,我们确实会启动关闭。这并不违反状态图,因为只有在收到该关闭事件时状态才会改变。

如果状态机最终进入 **shutting down**(正在关闭)状态,则连接循环将停止,并启动工作池的关闭。重要的是,我们要在 while 循环停止后执行此操作。这样,我们可以保证在启动关闭时工作池的内部状态不会发生变化。这使得工作池的内部设计更加简单。

我们知道 IO 处理程序正在根据应用程序的请求执行消息处理程序(回调函数)。请记住前面关于线程陷阱的解释。这是另一个。有可能在连接线程完成(即使 IO 关闭已启动)的同时,IO 处理程序仍在执行该消息处理程序。如果应用程序认为服务器已关闭,并执行了一些使消息处理程序的地址空间或内部状态无效的操作,我们的应用程序可能会崩溃。为了避免这种情况,我们确保命名管道服务器连接线程在 IO 池关闭之前不会结束。这保证了服务器状态机的关闭状态代表了整个命名管道组件。

与状态机的状态交互

触发关闭、检测是否正在关闭等操作,都是通过这些简单的、不言自明的辅助函数完成的。

    bool CNamedPipeServer::IsShuttingDown()
    {
        return (0 == WaitForSingleObject(m_ShutdownEvent, 0));
    }

    void CNamedPipeServer::Shutdown()
    {
        m_ShutdownEvent.SetEvent();
    }

    void CNamedPipeServer::StartServing()
    {
        m_StartEvent.SetEvent();
    }

当应用程序本身确定是时候关闭时,它会触发事件。但是,由于这是并行发生的,因此它需要一个等待来知道何时可以安全地销毁对象。它可以通过无限期等待,或以短间隔等待来使用此函数。后者通常在 Windows 服务应用程序中使用,因为当服务应用程序停止时,它需要定期报告进度。

    bool CNamedPipeServer::WaitUntilFinished(DWORD dwMilliSeconds)
    {
        DWORD error = WaitForSingleObject(m_ConnectionHandlerThread, dwMilliSeconds);
        if (error == WAIT_OBJECT_0)
            return true;
        else if (error == WAIT_TIMEOUT)
            return false;

        throw ExWin32Error("CNamedPipeServer::WaitUntilFinished");
    }

线程句柄是可等待对象,因此很容易确定线程是否已关闭。

销毁对象

销毁对象不需要特殊的清理。所有句柄、数组等都使用 RAII,因此它们的析构函数将执行所有必要的操作。我们唯一需要补充的是保证在对象被销毁时,没有其他线程在访问对象数据,否则会适得其反。

一般来说,这应该是没有必要的。应用程序应该发起关闭,并且应该等待直到关闭完成。但这里有两个“应该”太多了,为了确保这一点,我们设置了以下保护措施。

    CNamedPipeServer::~CNamedPipeServer()
    {
        Shutdown();
        WaitUntilFinished(INFINITE);
    }

结论

就这样!我们现在拥有一个功能齐全的管道服务器,它是异步的,使用多线程,并且不使用锁。它还设法将所有命名管道基础设施代码排除在应用程序之外。

IO 处理本身将在第二部分中介绍。这也是我没有在本文档中附带测试应用程序或源代码下载的原因。到目前为止提供的信息,还没有什么可以测试/下载的。

一篇这样的文章需要平衡:在 API 层面足够深入地解释给想了解细节的人,同时保持足够简短,让大多数人仍愿意阅读。同样,我想强调一些多线程问题和竞态条件,这些问题在任何允许异步交互的设计中都可能存在风险。即使几率非常低,我认为在实现多线程代码时,它是正确还是不正确。“大部分正确”不存在。但我也不想把它变成一篇关于多线程一切的论文。

我希望我取得了正确的平衡。请在评论区告诉我。

历史

  • 2002年12月9日:第一个版本
© . All rights reserved.