上一篇介绍了匿名管道,现在介绍一下命名管道(Named Pipes)。本篇较长,建议读者朋友收藏后慢慢阅读。命名管道是需要在创建时为其命名的管道。命名管道可以是单向(One-way)的,也可以是双向(Two-way, or Duplex)的。命名管道可以有多个实例(instance),它们共享一个名称,但每个实例都有各自的缓冲区和句柄,可以同时与多个客户端通信。
命名管道可供同一台主机上的不同进程间相互通信,也可供网络中不同主机上的进程相互通信,服务端的主机要确保server服务处于运行状态。如果不希望创建的命名管道被网络上的其他主机访问,可以设置管道的安全属性,使其拒绝被NT AUTHORITY\NETWORK账户访问。
创建命名管道实例的进程叫服务端,连接到命名管道实例的进程叫客户端。只要能通过安全检查,任何进程都可以访问命名管道。
服务端用CreateNamedPipe()函数创建命名管道的实例,再用ConnectNamedPipe()函数接受客户端的连接。客户端用CreateFile()函数或CallNamedPipe()函数连接到命名管道。
下面从命名管道的设计、操作等方面对命名管道进行简要介绍。
一、命名管道的设计
命名管道功能强大,创建时需从多方面定义,都体现在CreateNamedPipe()函数的输入参数中。
CreateNamedPipe()原型如下:
HANDLE CreateNamedPipe(
[in] DWORD lpName,
[in] DWORD dwOpenMode,
[in] DWORD dwPipeMode,
[in] DWORD nMaxInstances,
[in] DWORD nOutBufferSize,
[in] DWORD nInBufferSize,
[in] DWORD nDefaultTimeOut,
[in,optional] LPSECURITY_ATTRIBUTES lpSecurityAttributes
);
在使用命名管道前要有一个总体规划,可提前填写以下工作表:
名称 | ||
缓冲区大小 | ||
打开模式 (OpenMode) | AccessMode | PIPE_ACCESS_INBOUND PIPE_ACCESS_OUTBOUND PIPE_ACCESS_DUPLEX |
OverlappedMode | YES NO | |
Write-Through Mode | YES NO | |
句柄模式 (PipeMode) | Type Mode | Byte Mode(default) Message Mode |
Read Mode | Byte Mode(default) Message Mode | |
Wait Mode | Blocking-wait Mode Nonblocking-wait Mode |
下面逐项做一下解释。
1.名称
每个命名管道都有一个唯一的名称。管道服务端在调用CreateNamedPipe()时要指定管道的名称,函数为该名称创建管道实例;管道客户端调用CreateFile()或CallNamedPipe()时也要用该名称指定所要连接的管道。
命名管道的名称采用以下格式:
\\<ServerName>\pipe\<PipeName>
ServerName既可以是远程计算机的名称,也可以是一个点(.),表示本地计算机。PipeName是给管道起的名称,可以包含除反斜线(\)之外的任何字符,整个名称字符串的长度要限制在256字符以内。管道名称对字母大小写不敏感。
由于服务端不能在远程主机创建管道,所以<ServerName>部分只能是一个点,调用CreateNamedPipe()时用\\.\pipe\<PipeName>。客户端在调用CreateFile()、WaitNamedPipe()以及CallNamedPipe()函数时根据希望连接的管道是在本地还是远程主机,可以将<ServerName>写成点或远程主机名。
要使客户端知道服务端所创建的命名管道的名称,服务端可将名称存入某些持久性的存储位置,比如文件、注册表,或者让客户端直接写入源代码。
2.缓冲区大小
每次命名管道被创建的时候,系统会从系统内核使用的内存(nonpaged pool,非分页池)中为其分配入站、出站缓存——实际上真正能被成功创建的管道实例数量是受这些内存的制约的。
管道每端实际的缓存大小可能是系统默认的,也可能是所允许的最小、最大值,或明确指定的值向上舍入到下一个分配边界。每当发生管道写操作时,系统首先尝试根据管道写配额扣除内存,如果剩余的管道写配额足以满足要求,写操作将立即完成,如果剩余的管道写配额不足以满足要求,系统将会使用为进程保留的非分页池扩展缓冲区以容纳数据,写操作将阻塞,直到从管道中读取数据,以便释放额外的缓冲区配额。因此,如果您指定的缓冲区太小,系统将根据需要增大缓冲区,但缺点时是操作将被阻塞。如果操作是重叠的,系统线程将被阻塞,如果操作不是重叠的,应用线程将被阻塞。
综上所述,缓冲区不能太小,否则不足以支持传输需求,还会影响性能,也不能太大,否则会过多占用非分页池。要尽可能根据通信内容进行预估,微软示例代码中通常设置为512字节。
3.打开模式(Open Mode)
CreateNamedPipe()函数的dwOpenMode参数可用来规定命名管道的三方面的特性:访问模式(Access Mode)、是否启用重叠(Overlapped)I/O、是否启用写透模式(Write-through Mode)。在客户端调用CreateFile()连接管道时也要指定与服务端兼容的打开模式。
(1)访问模式
访问模式规定了管道的传输方向,即哪一方写入,哪一方读出,或是双方都可写入和读出。下表列出了可在dwOpenMode中指定的访问模式以及与之对应的通用访问权限:
访问模式 | 等价通用权限 |
PIPE_ACCESS_INBOUND | GENERIC_READ |
PIPE_ACCESS_OUTBOUND | GENERIC_WRITE |
PIPE_ACCESS_DUPLEX | GENERIC_READ |GENERIC_WRITE |
如果服务端用PIPE_ACCESS_INBOUND创建管道实例,那么服务端就只能对管道进行读操作,客户端只能对管道进行写操作。如果用PIPE_ACCESS_OUTBOUND创建则反之。如果用PIPE_ACCESS_DUPLEX,则不论是服务端还是客户端都既可以写入又可以读出。当客户端用CreateFile()连接到命名管道时,必须在dwDesiredAccess参数中指定与访问模式兼容的访问权限,比如,如果服务端用PIPE_ACCESS_OUTBOUND创建管道,客户端必须指定GENERIC_READ权限进行连接。
关于访问模式须注意两点:对于某特定管道,创建的所有实例必须采用相同的访问模式。要读取、写入管道的属性(诸如读取模式、等待模式等),管道句柄必须分别具有FILE_READ_ATTRIBUTE、FILE_WRITE_ATTRIBUTE权限。这些权限要与通用权限组合,在提供相应的功能的同时确保足够的安全性。对只读管道,要组合GENERIC_READ和FILE_WRITE_ATTRIBUTES;对只写管道,要组合GENERIC_WRITE和FILE_READ_ATTRIBUTES。
(2)重叠模式(Overlapped Mode)
命名管道支持重叠模式的输入输出。在重叠模式中,执行比较耗时的读、写及连接操作的函数可以立即返回,使调用这些函数的线程可以执行其他操作。
要启用重叠模式,要在服务端调用CreateNamedPipe()创建管道实例时在dwOpenMode中合并入FILE_FLAG_OVERLAPPED标志;要在客户端启用重叠模式,要在调用CreateFile()时为dwFlagsAndAttributes参数中也合并入FILE_FLAG_OVERLAPPED。
ReadFile()、WriteFile()、TransactPipe()以及ConnectNamedPipe()既可以进行命名管道的同步I/O,也可以进行异步I/O。当进行同步操作时,这些函数要等待操作完成或出错后才能返回,即使所执行的操作很费时间,程序也要等待函数返回才能继续执行。而当这些函数执行异步操作时,这些函数可以立即返回,不管所执行的I/O操作是否完成,这使得那些费时的操作可以在后台运行,从而调用这些函数的程序可以继续执行。
在一个管道服务端同时与多个客户端通信的场景中,如果采用同步通信方式,服务端必须为每个客户端都创建一个线程,只有这样才能在某些线程处于等待状态时不影响其他线程的I/O操作。如果采用重叠通信方式,那么一个线程就可以同时对多个管道进行I/O操作,从而一个线程就可以同时与多个客户端进行有效的通信。
当这些函数执行异步操作时,如果函数返回时操作已经完成了,那么函数的返回值就代表操作的成功与否。如果成功则返回TRUE,否则就返回FALSE,调用GetLastError()会得到一个非ERROR_IO_PENDING的值来说明原因。如果函数返回时操作还未完成,那么返回值未FALSE,GetLastError()函数返回ERROR_IO_PENDING,这时调用函数的线程要等待操作完成,再用GetOverlappedResult()函数检查结果。
使用重叠通信方式时,在发起通信后管道服务端可循环做以下操作:
- 调用同步机制中的等待函数,在其中指定多个事件对象,待某个事件对象变为有信号状态时,等待函数返回;
- 使用等待函数的返回值判断是哪个重叠操作完成了;
- 对于已完成的I/O操作执行必要的清理操作,并为将该句柄用于下次操作做准备。
要使ReadFile()、WriteFile()、TransactNamedPipe()以及ConnectNamedPipe()可用于异步操作,必须在创建命名管道时指定启用重叠模式,还要在调用这些函数时提供有效的OVERLAPPED结构指针,否则这些函数的返回值会错误地表示操作已完成。OVERLAPPED结构中hEvent成员还必须包含一个手动复位(manual-reset)的事件对象的句柄,用CreateEvent()函数创建该对象。发起异步操作的线程使用该事件对象在异步操作完成时得到通知。注意,当用同一个命名管道句柄同时进行多个异步操作时,必须为每个操作指定自己的OVERLAPPED结构,且各自都有自己的hEvent对象。
ReadFileEx()、WriteFileEx()是两个扩展的I/O函数,可提供另一种形式的重叠I/O。它们不像ReadFile()、WriteFile()使用事件对象作为操作完成的通知手段,它们需要指定一个“完成例程”。完成例程是一个函数,它在I/O操作完成时被放入一个等待执行的序列。它们会在执行I/O操作的线程启动“可报警等待操作”时启动(将fAlertable参数设置为TRUE调用“可报警等待函数”)。
(3)写透模式(Write-through Mode)
写透模式针对跨网络的字节型(后面介绍)命名管道。在写透模式中,向管道执行写入操作的函数在数据跨越网络到达对方管道缓冲区前是不会返回的。当网络中的通信双方要求每次写操作都以同步方式进行时,这种模式是非常有用的,它确保了通信双方的操作按规定的顺序进行。
如果不启用写透模式,操作系统会对网络通信进行优化,以求让每个数据包包含尽可能多的信息以节省带宽。它会缓存待传输的数据,直到累积到一定的数量,或经过某个预定的时间再发送出去,这种优化会使多个写操作被一次性打包发送出去。跨网络的写操作函数成功返回只是代表了将要发送的数据成功进入了系统的发送缓存,并不代表数据已进入对方管道的缓冲区。说到这里,大家就会对写透模式的含义理解得更深入了。
要启用写透模式,服务端要在用CreateNamedPipe()创建实例时为dwOpenMode加入FILE_FLAG_WRITE_THROUGH标志;客户端要在用CreateFile()连接管道时为dwFlagAndAttributes加入FILE_FLAG_WRITE_THROUGH标志,一旦以写透模式创建了管道,就不能再更改了,但不是管道的两端都必须同时启用或不启用写透模式,允许一端启用而另一端不启用。客户端如果不启用写透模式,可以用SetNamedPipeHandleState()函数来控制网络传输前可累积的字节数以及等待打包的时间。如果是只读管道,那么要求句柄必须有GENERIC_READ和FILE_WRITE_ATTRIBUTES访问权限。
2.类型、读取及等待模式
CreateNamedPipe()的dwPipeMode参数规定了管道的写入、读取方式以及对耗时操作的等待方式。
(1)类型模式(Type Mode)
命名管道的类型模式规定了数据是如何写入管道的。数据可以以字节流的形式写入管道,即字节型管道(Byte-Mode),也可以以消息流的形式写入管道,即消息型管道(Message-Mode)。创建字节型管道,在dwPipeMode中指定PIPE_BYTE_MODE,或接受缺省值,数据以字节流的形式写入管道,系统无法区分哪些数据是由哪次写操作写入的;要创建消息型管道,在dwPipeMode中指定PIPE_MESSAGE_MODE,系统会将每次写操作写入的字节流视作一个消息单元。系统总是将消息类型管道上的写操作视作启用了写透模式。
在调用CreateNamedPipe()是一旦指定了管道类型,后面创建的实例都要采用这一模式。
(2)读取模式(Read Mode)
命名管道的读取模式规定了数据如何从管道内读出。
服务端调用CreateNamedPipe()时指定管道的初始读取模式。如果管道的类型模式是字节类型,那么它只能支持以字节方式读取;如果管道的类型模式是消息类型,那么它既可以以字节形式读取,也可以以消息形式读取,而且客户端可以采用不同于服务端的读取模式。
要得到以字节方式读取的管道句柄,在调用CreateNamedPipe()时在dwPipeMode中加入PIPE_READ_MODE_BYTE,或接受缺省值。当对管道进行读操作时,读函数会在将管道内容全部读出或读出指定数量的字节后成功返回。要得到以消息形式读取的管道句柄,在调用CreateNamedPipe()时在dwPipeMode加入PIPE_READ_MODE_MESSAGE,这样数据会以消息的形式从管道读取。读操作函数只有在完整读取整个消息后才会成功返回。如果在执行读操作时指定了字节数,且该字节数少于管道内待读取消息的字节数,该函数会在读取指定字节数后返回FALSE,这是GetLastError()会返回ERROR_MORE_DATA,剩余未读部分可在下一次读操作读取。
在客户端,CreateFile()返回的句柄初始设置为字节读取模式。服务端和客户端都可以通过调用SetNamedPipeHandleState()函数更改读取模式,只要句柄包含FILE_WRITE_ATTRIBUTES权限。
(3)等待模式(Wait Mode)
管道句柄的等待模式规定了ReadFile()、WriteFile()、ConnectNamedPipe()函数如何处理耗时操作。在阻塞等待模式(blocking-wait mode),这些函数在管道另一端的进程完成操作之前会一直等待,在非阻塞等待模式(nonblocking-wait mode),这些函数会立即返回。
ReadFile()函数会在管道为空时受等待模式影响。在管道为空时,如果管道句柄为阻塞等待模式,那么在管道另一端的进程完成写入操作前会一直等待;如果管道句柄为非阻塞等待模式,函数会立即返回,此时GetLastError()会返回ERROR_NO_DATA。
WriteFile()函数会在管道缓冲区内无足够空间时受等待模式影响。如果管道句柄设置为阻塞等待模式,那么函数会在管道另一端的线程读走数据空出足够空间前一直等待;如果管道句柄为非阻塞等待模式,那么对于消息类型管道,函数会立即返回TRUE而不写入任何数据,对于字节类型管道,函数会能写入多少就写入多少。
ConnectNamedPipe()会在没有客户端连接的时候受等待模式影响。如果管道句柄为阻塞等待模式,那么函数在客户端调用CreateFile()或CallNamedPipe()进行连接前不会退出。若句柄为非阻塞等待模式,那么函数会立即返回0,GetLastError()会给出ERROR_PIPE_LISTENING。
无论是CreateNamedPipe()为服务端返回的句柄,还是CreateFile()为客户端返回的句柄,默认情况下都采用阻塞等待模式。如果要使用非阻塞等待模式,服务端可在调用CreateNamedPipe()时为dwPipeMode指定PIPE_NOWAIT;服务端和客户端都可以调用SetNamedPipeHandleState()来改变句柄的等待模式。
注意:如果不能接受阻塞等待模式的效果,不要使用非阻塞等待模式。应直接使用重叠I/O模式,它可以使耗时操作在后台运行,从而使函数调用立即返回。
二、命名管道的常规操作
最简单的服务端创建管道的一个实例,与一个客户端建立连接,完成通信任务后断开与客户端的连接,关闭管道句柄,最后终止程序。更常见的情形是一个服务端与多个客户端相互通信,服务端可以按顺序依次与每个客户端建立连接、交换信息、断开连接,即服务端一次只与一个客户端通信,显然这种方式效率很低。要想高效地与多个客户端通信,服务端需建立多个实例同时与多个客户端通信,这通常有三个基本策略:
- 为每个实例创建一个线程。
- 使用重叠通信模式调用ReadFile()、WriteFile()、ConnectNamedPipe()。
- 使用重叠通信模式调用ReadFileEx()、WriteFileEx()。
第一个策略是多线程服务端策略,每个线程负责一个管道实例与一个客户端的通信。这种策略相对比较简单,只是线程多会影响系统性能。
后两个策略都是单线程服务端策略,任何可能影响多个客户端的操作都要小心协调,需注意保护被多个客户端共享的资源,更需要协调各重叠I/O操作以处理多个客户端的并发需求。
服务端第一次调用CreateNamedPipe()时用nMaxInstances参数规定该命名管道的最大实例数,只要不超过这个实例数,就可以重复调用CreateNamedPipe()创建新实例。每次成功的调用都会返回指定管道的一个服务端句柄。
服务端创建管道实例后,客户端就可以调用CreateFile()或CallNamedPipe()连接到该管道实例。如果尚没有管道的实例被创建,客户端可以用WaitNamedPipe()等待。
服务端可以通过对ConnectNamedPipe()决定何时客户端可以连接到管道。如果管道被创建为阻塞等待模式,ConnectNamedPipe()会在有客户端连接时才返回。
客户端和服务端可以调用几个函数对管道进行读、写操作,这些函数的具体行为取决于管道的类型以及句柄工作的模式。
- ReadFile()、WriteFile()既可用于字节型管道,也可用于消息型管道。如果管道启用了重叠模式,ReadFileEx()、WriteFileEx()也既可用于字节型管道又可用于消息型管道。
- PeekNamedPipe()可从字节型或消息型管道中读取信息,但并不将消息从管道中清除。
- 对于双向消息型管道,如果某端的读取模式为消息型,那么可用TransactNamedPipe()一次性向管道发送一个请求信息,并读取来自管道的应答信息以增强网络性能。
注意:在有客户端连接到管道之前,服务端不要采取阻塞模式的读取操作,因为这样可能会导致产生竞争条件,这通常会发生在初始化代码中,比如C运行库的初始化,这些操作会在检验句柄时对其进行锁定。
当管道两端都使用完管道,服务端应先调用FlushFileBuffers(),以确保所有写入管道的信息都被读取——该函数在客户端读取完管道信息前时不会返回的。然后服务端再调用DisconnectNamedPipe()关闭到客户端的连接,该函数会使客户端的句柄失效,管道中所有未读取的信息都被丢弃。最后服务端调用CloseHandle()关闭管道句柄,或者再调用ConnectNamedPipe()允许新的客户端再连接到该管道实例。
要获得管道的相关信息,比如管道的类型、缓冲区大小、最大实例数等信息,可调用GetNamedPipeInfo()函数。GetNamedPipeHandleState()可获取句柄的读取模式、等待模式以及当前管道实例数等信息,如果管道是跨网络的,还会返回网络相关信息。SetNamedPipeHandleState()函数管道句柄的读取模式和等待模式。对于连接到远程管道服务端的客户端,如果没有开启写透模式,那么可以用SetNamedPipeHandleState()设置传输开始前累积的字节数或可以等待的时间。
以上是对命名管道的一点理解,不当之处欢迎大家批评指正。