15.3 进程通信
所谓“进程通信”,是指正在运行的进程之间相互交换信息。
每个进程都拥有自己的地址空间,其他进程不能直接访问,因此,通常需要通过一个第三方媒介间接地在进程之间交换信息。
剪贴板是最常用的在进程间交换信息的媒介之一。
多个进程间通过共享一个数据文件,也可以实现进程间通信。
除此之外,Windows还提供了另一种强大而灵活的进程间通信的方式,这就是COM。基于COM技术开发出来的程序(称为“COM服务器”)可以被另一程序(称为“COM客户端”)在后台激活,客户端可以向服务器传送各种信息,要求服务器完成某些工作,并取回其工作结果。最典型的COM服务器就是微软公司的Office软件包,前端的程序可以通过COM接口启动这些程序完成许多工作。在.NET出现之前,COM是实现进程间通信的主要方式之一。
交叉链接
7.6节《互操作程序集与本地类型》介绍了一个在C#程序中通过COM接口“远程”操控Word进程完成打印预览工作的实例。
.NET出现之后,实现进程通信更为方便,手段更多。
比如在.NET 4.0中,可以通过内存映射文件实现进程通信。
真正强大的进程通信手段是WCF。WCF可用于开发复杂的分布式软件系统,实现进程通信对于它来说,实在是“小菜一碟”。
本节将通过几个实例展示如何让同一台计算机上的进程可以相互通信。
15.3.1 使用剪贴板在进程间传送对象
剪贴板简介
剪贴板是一个供应用程序使用的公有区域。在Windows上运行的所有程序,在需要时都可以使用剪贴板存放信息(见图15-13)。
图15-13 各种应用程序都可存取剪贴板
剪贴板的一个重要特点可以简述如下:
剪贴板相当于一个“物品临时寄存处”,一次只能保存一个“物品”,而且这个“物品”是大家共享的。
例如原来使用Word复制了一段文本放在剪贴板上,现在又使用“画图”程序将一幅图放在剪贴板上,则图片数据将替换掉文本数据。
将数据放到剪贴板之后,其他程序也可以存取它。比如使用“画图”程序将一幅图放在剪贴板上,则Word、写字板、Photoshop等其他应用程序也可以从剪贴板中获取这些数据。
由于剪贴板可以保存多种类型的数据,因此.NET定义了一个DataFormats类,此类包容了一些静态字段,定义了剪贴板中可以存放的数据类型,如表15-2所示。
表15-2 剪贴板支持的数据类型(DataFormats类所包容的静态字段)
通过Clipboard类使用剪贴板
剪贴板上可以暂存各种类型的数据,那么如何编程将特定类型的数据放入剪贴板?又如何编程向剪贴板中查询当前数据的类型并且将数据从剪贴板中取出?
答案是使用.NET Framework提供的Clipboard类。
例如,将一段文本放入剪贴板只需一条语句:
Clipboard.SetDataObject("这是一段文字");
Clipboard.GetDataObject方法用于从剪贴板取出数据,其定义如下:
public static IDataObject GetDataObject();
此方法返回一个实现了IDataObject接口的对象(称为“数据对象”)。IDataObject接口定义了四个方法群,每个都拥有多个重载的形式,如表15-3所示:
表15-3 IDataObject所定义的方法群
以下示例代码从剪贴板上提取文本型数据,然后显示在一个文本框控件中:
IDataObject data=Clipboard.GetDataObject();//获取剪贴板上的数据 if(data.GetDataPresent(DataFormats.Text))//是文本型数据吗? TextBox1.Text=data.GetData(DataFormats.Text).ToString();
剪贴板示例1:现在剪贴板上有什么?
示例程序ClipboardInfo可以获取剪贴板上存放的数据信息类型(见图15-14)。
图15-14 示例程序ClipboardInfo
图15-14所示为从“记事本”中复制文本到剪贴板上后得到的数据类型信息。
提示
运行ClipboardInfo程序,然后在资源管理器中复制几个文件,使用ClipboardInfo程序查看一下剪贴板中的信息,读者会发现原来在Windows中复制文件也会用到剪贴板。
示例程序ClipboardInfo的实现方法非常简单。
先获取剪贴板上的数据,再调用IDataObject.GetFormats方法即可知道剪贴板中的数据类型:
IDataObject data=Clipboard.GetDataObject(); string[]dataFormats=data.GetFormats();//获取当前剪贴板数据所支持的格式清单
剪贴板实例2:使用剪贴板保存自定义对象
由于剪贴板是所有进程共享的,所以可以很方便地使用它在不同进程间共享信息。
请看实例UseClipboard,程序运行时的界面如图15-15所示。
图15-15 使用剪贴板传送信息
如图15-15所示,读者可以在示例程序中装入一张图片,然后在“图片说明”文本框中输入文字。
点击“复制到剪贴板”按钮后,再次运行另一个UseClipboard程序,现在就有了两个同时运行的UseClipboard进程,在新的UseClipboard进程窗体中点击“从剪贴板粘贴”按钮,可以发现新的程序主窗体中出现了与第一个进程一模一样的图片和文字信息。
这个示例程序展示的技术关键点是:
可以在不同进程间通过剪贴板传送可序列化的对象。
示例程序定义了一个类MyPic,它所创建的对象可被放置在剪贴板上共享:
[Serializable]
class MyPic
{
public Image pic;//图片
public string picInfo;//图片信息说明
}
这里特别要注意的是必须给MyPic类加上“[Serializable]”标记,表明此类是可以序列化的。
交叉链接
对象的序列化是指将对象的当前属性和字段值保存到流中,以便在合适的时候从流中重新创建对象。
第13章《对象的复制与序列化》详细介绍了序列化技术及其应用。
将自定义类型对象放到剪贴板的关键是DataObject类,它实现了IDataObject接口。可以将它看成是一个数据容器,存放那些将被放置在剪贴板上的数据。
当示例程序运行时,根据用户装入的图片和输入的图片说明创建MyPic对象,紧接着将此对象装入DataObject对象中,再调用Clipboard的SetDataObject方法将DataObject对象保存到剪贴板中,以下是摘录的示例代码:
MyPic obj=……;//创建MyPic对象 //创建一个数据对象,将MyPic类型的对象装入 IDataObject dataobj=new DataObject(obj); //其他类型的数据也可以装入同一个数据对象中,可以取消以下两句代码的注释进行试验 //dataobj.SetData(DataFormats.UnicodeText,info); //dataobj.SetData(DataFormats.Bitmap,bmp); //复制到剪贴板上,第二个参数表明程序退出时不清空剪贴板Clipboard.SetDataObject(dataobj,true);
特别需要注意的是,当使用Clipboard.SetDataObject方法将一个DataObject对象放到剪贴板以后,外界要想访问此DataObject对象所包容的“真正”对象时,必须指明这一“真正”对象的完整类型名称:
//剪贴板上有我需要的数据吗?
if(Clipboard.ContainsData("UseClipboard.MyPic"))
{
IDataObject clipobj=Clipboard.GetDataObject();//读取数据
//将数据转换为需要的类型
MyPic mypicobj=clipobj.GetData("UseClipboard.MyPic")as MyPic;
//……
}
如果只允许剪贴板上的数据被特定类型的进程所使用,则仅需创建一个DataObject对象,并在其构造函数中传入可序列化的对象就够了,这时,其他类型的进程不能读取剪贴板的数据(因为它们不知道具体的数据类型),比如UseClipboard示例程序使用DataObject对象将MyPic对象复制到剪贴板后,这时“记事本”的“编辑”菜单中“粘贴”命令是灰掉的,它认为剪贴板上是“空的”。
如果要让剪贴板上的数据被多种类型的程序所使用,可以多次调用DataObject对象的SetData方法装入多种类型的数据,在装入时指明其数据类型。请看前一段代码中的以下两句代码:
dataobj.SetData(DataFormats.UnicodeText,info); dataobj.SetData(DataFormats.Bitmap,bmp);
第1句代码指明将info字段所引用的字符串对象按照UnicodeText的格式放到剪贴板上,第2句则将bmp字段所引用的图片以Bitmap的格式放到剪贴板上。这样一来,任何一个可以粘贴UnicodeText和Bitmap类型数据的进程都可以获取UseClipboard进程保存在剪贴板上的数据。
提示
读者可按以下步骤对上述代码进行测试:
1)运行UseClipboard程序,选择一个图片并输入一些文字说明,点击相应按钮复制到剪贴板上。
2)打开“附件”中的记事本程序,按Ctrl+V(或从“编辑”菜单中选择“粘贴”命令),可以看到用户输入的图片说明被复制到了记事本中。
3)打开“附件”中的“画图”程序,按Ctrl+V(或从“编辑”菜单中选择“粘贴”命令),可以看到图片被插入到了绘图面板中。
小结
剪贴板是由操作系统所提供的供各进程共享的数据存储区,使用起来非常方便,但其弱点在于一个进程将数据放到剪贴板之后,它没法通知其他进程数据已放到剪贴板上了。除非在等待接收数据的进程中设计一个辅助线程定时监控剪贴板,在数据来到时主动从剪贴板中获取数据,但这并非最佳方式,后面将介绍更合适的进程通信方式。
15.3.2 使用FileSystemWatcher实现进程同步
FileSystemWatcher是.NET Framework所提供的一个组件,它可以监控特定的文件夹或文件,比如在此文件夹中某文件被删除或内容被改变时引发对应的事件。
通过使用FileSystemWatcher组件,让多个进程同时监控一个文件,就可以让此文件充当“临时的”进程间通信渠道。
请看示例解决方案FileReaderWriter,它包含两个项目:FileReader和FileWriter。
FileWriter项目运行时,它使用RichTextBox控件打开并编辑一个TXT或RTF文件,FileReader也使用RichTextBox控件显示一个TXT或RTF文件的内容。
同时启动FileWriter和FileReade,如果FileWriter和FileReader都选择了同一个文件,则在Filewriter程序中单击“保存”按钮时,可以看到在FileReader会同步显示出文件更新后的内容(见图15-16)。
简要介绍一下本示例的关键点。
正确设置文件的共享与读写权限
由于文件会被多个进程所访问,因此FileWriter在保存文件时,必须正确设定文件流的共享权限为FileShare.Read:
using(StreamWriter writer=new StreamWriter(new FileStream(
FileName,FileMode.Create,FileAccess.Write,FileShare.Read),
Encoding.Default))
{
writer.Write(txtEditor.Text);
}
图15-16 使用文件在进程间传送信息
而FileReader在打开文件时,必须将文件流的共享权限设为FileShare.ReadWrite:
using(StreamReader reader=new StreamReader(new FileStream(
FileName,FileMode.Open,FileAccess.Read,FileShare.ReadWrite),
Encoding.Default))
{
txtReader.Text=reader.ReadToEnd();
}
监控文件内容的改变
实现进程通信的关键在于FileSystemWatcher组件(它放在Visual Studio工具箱的“组件”面板中)。它可以监控特定文件或文件夹一个或多个属性的改变(见表15-4)。
表15-4 FileSystemWatcher组件可以监控的文件(夹)属性
例如以下代码将监控“C:\test”文件夹及其子文件夹下的所有TXT文件的“大小”或“文件名改变”情景的出现:
fileSystemWatcher1.Filter="*.txt"; fileSystemWatcher1.Path="C:\\test"; fileSystemWatcher1.IncludeSubdirectories=true; fileSystemWatcher1.NotifyFilter= NotifyFilters.Size|NotifyFilters.FileName;
FileSystemWatcher组件根据程序员设定的NotifyFilter属性,当监控对象的特定属性发生改变时,激发以下事件(见表15-5)。
表15-5 FileSystemWatcher组件的常用事件
FileReader示例程序监控指定文件内容的改变,然后编码响应FileSystemWatcher组件的Changed事件。
请读者自行阅读源代码,其中的代码并不复杂。
FileSystemWatcher小结
FileSystemWatcher在实际开发中是比较实用的,举个例子,在网络应用程序中可以使用此组件监控特定的专用于上传文件的文件夹,当发现用户上传了文件之后,系统可以自动地启动一系列的处理流程。
请读者多动动脑筋,看看FileSystemWatcher还能用在什么地方。
15.3.3 使用内存映射文件实现进程通信
操作系统很早就开始使用“内存映射文件(Memory Mapped File)”来作为进程间的共享存储区,这是一种非常高效的进程通信手段。Win32 API中也包含有创建内存映射文件的函数,然而这些函数都运行于非托管环境下,在.NET中只能通过平台调用机制来使用它们,用起来很不方便。幸运的是,.NET 4.0新增加了一个System.IO.MemoryMappedFiles命名空间,其中添加了几个类和相应的枚举类型,从而使我们可以很方便地创建内存映射文件。
内存映射文件原理
所谓“内存映射文件”,其实就是在内存中开辟出一块存放数据的专用区域,这区域往往与硬盘上特定的文件相对应。进程将这块内存区域映射到自己的地址空间中,访问它就像是访问普通的内存一样(见图15-17)。
在.NET中,使用MemoryMappedFile对象表示一个内存映射文件,通过它的CreateFromFile方法根据磁盘现有文件创建内存映射文件,此方法有多个重载形式。
以下示例代码动态地在当前文件夹中创建或打开一个名为MyFile.dat文件,然后将其映射到系统内存中创建一个内存映射文件,分配给此映射文件一个“MyFile”的名字,并设定其容量为1MB:
MemoryMappedFile memoryFile=MemoryMappedFile.CreateFromFile( "MyFile.dat",FileMode.OpenOrCreate,"MyFile",1024*1024);
图15-17 内存映射文件原理图
提示
用于创建内存映射文件的文件流必须是可读写的。
扩充阅读
关于内存映射文件的容量
默认情况下,在调用MemoryMappedFile.CreateFromFile方法基于现有磁盘文件创建内存映射文件时,如果不指定内存映射文件的容量,那么创建的内存映射文件的容量等同于磁盘文件的现有大小。
在设定内存映射文件的容量时,其值不能小于磁盘文件的现有长度,可以比它大。但要注意这将导致一个戏剧化的结果:磁盘文件自动增长到内存映射文件声明的容量大小!
可以多次调用MemoryMappedFile.CreateFromFile方法,每次传给它一个更大的容量数值以不断扩充磁盘文件的大小。
当不再使用一个MemoryMappedFile对象时,注意应该及时地调用其Dispose方法释放它所占有的系统资源。因为MemoryMappedFile实际上对应着运行于操作系统核心的核心对象(Kernel Object),如果不及时关闭,会造成操作系统核心资源(比如句柄)的浪费,要等到MemoryMappedFile对象被CLR垃圾回收,或者整个进程中止时,这些资源才会被操作系统回收再利用。
另外,内存映射文件的容量其实是指最大允许分配给内存映射文件的虚拟内存存储区的字节数,并不意味着系统会马上分配指定容量的内存。进程访问内存映射文件时,操作系统如果发现需要的内容还未装入,就会从磁盘文件装入相应内容到内存中。因此,不用担心声明一个大的内存映射文件会导致内存的浪费。
当MemoryMappedFile对象创建之后,并不能直接对其进行读写,必须通过一个MemoryMappedViewAccessor对象(可称之为“内存映射视图访问对象”)来访问这个内存映射文件。
MemoryMappedFile.CreateViewAccessor方法可以创建MemoryMappedViewAccessor对象,而此对象提供了一系列读写的方法,用于向内存映射文件中读取和写入数据。
以下示例代码创建了一个内存映射视图访问对象并使用它写入数据:
MemoryMappedFile memoryFile=…;//创建内存映射文件 //创建内存映射视图访问对象 MemoryMappedViewAccessor accessor= memoryFile.CreateViewAccessor(0,1024); for(int i=0;i<1024;i+=2) accessor.Write(i,'c');
注意在上述代码中要创建内存映射视图访问对象时,需要指定它所能访问的内存映射文件的内容范围,这个“范围”称为“内存映射视图(Memory Mapped View)”。可以将它与放大镜类比,当使用一个放大镜阅读书籍时,一次只能放大指定部分的文字。类似地,只能在内存映射视图所规定的范围内存取内存映射文件。
在上述代码中,我们看到内存映射视图访问对象accessor只提取了内存映射文件开头1024个字节的内容,然后,向其中写入了512个“c”字符。
当调用内存映射视图访问对象的Write方法时,要指明从哪个位置(即方法的第一个参数)开始写入数据,并且需要计算清楚要写入的数据占几个字节,这样,当写入下一个数据时,就知道应该从哪个位置开始。
提示
Write方法中的存取位置是相对“内存映射视图”而非内存映射文件本身的,因此,此位置数值再加上“内存映射视图”距内存映射文件开头的偏移量才是写入的数据在文件中的真实位置。
Write方法有多个重载形式,可以向内存映射文件中写入多种类型的数据,但要注意计算清楚其写入的位置,避免造成数据覆盖问题。
类似地,内存映射视图对象提供了多个重载的Read方法,可以从内存映射文件中读取数据。
比较有趣的是,在同一个进程中可以针对同一个内存映射文件创建多个“内存映射视图访问对象”,从而允许我们同时修改同一个文件的不同部分,在关闭这些对象时由操作系统保证将所有修改都写回到原始文件中。
下面来看一个示例。
在同一进程内同时读写同一内存映射文件
示例项目UseMMFInProcess运行时会在程序的当前目录下创建一个“MyFile.dat”文件,然后,创建了两个内存映射视图访问对象,分别向文件的前半部分和后半部分写入不同的数据,然后再从中读出来(见图15-18)。
这个示例展示的技术很基础,没有什么特别需要注意的地方,请读者自行查看源码。
图15-18 在同一进程内同时读写同一内存映射文件
使用内存映射文件在进程间传送值类型数据
在前面的例子中,内存映射文件直接与某个特定的磁盘文件相对应,其实也可以不用创建磁盘文件而直接使用Windows的分页文件。这种方式是实现进程间互传数据的典型方式。
调用MemoryMappedFile.CreateNew或CreateOrOpen方法可以在系统内存中直接创建一个内存映射文件,这个内存映射文件所对应的“物理文件”是Windows的系统分页文件。两个方法都需要给映射文件指定一个唯一的名称。不同之处在于CreateOrOpen方法在指定名称的映射文件存在时就直接将其返回给进程,而CreateNew方法始终是新创建一个内存映射文件。
扩充阅读
Windows的系统分页文件和休眠文件
默认情况下,在安装Windows的分区根目录下,会找到两个具有“隐藏”属性文件:pagefile.sys和hiberfil.sys。
- pagefile.sys是Windows的分页文件,用于保存从物理内存中换出的内存页,可以用它的一部分来创建内存映射文件。
- hiberfil.sys则是“系统休眠”文件,当Windows启用了休眠功能时,就会在硬盘上找到这个文件,它的内容是系统休眠时物理内存中的数据,当计算机从休眠中“醒”过来时,通过从此文件中加载信息以恢复上次工作的状态。
内存映射文件创建好以后,可以如同前面介绍的方法一样创建视图访问对象,然后使用Read和Write系列方法存取。
只要指定同一个名字,那么多个进程就可以使用同一个内存映射文件交换数据。示例UseMMFBetweenProcess展示了在两个进程间相互交换一个结构变量的情况(见图15-19)。
图15-19 使用内存映射文件在进程间传送值类型数据
两个进程要交换的数据格式如下:
public struct MyStructure { public int IntValue { get;set;} public float FloatValue { get;set;} }
启动UseMMFBetweenProcess程序的两个实例,在其中一个窗体上输入两个数字之后,点击“保存”按钮,然后在另一个进程的窗体上点击“提取”,可以看到文本框中出现了前一个进程写入的信息。
示例程序采用MemoryMappedFile.CreateOrOpen方法创建或打开一个内存映射文件,然后调用MemoryMappedViewAccessor类的Write<T>和Read<T>泛型方法向内存映射文件中写入和读取数据。
Write<T>和Read<T>方法中的泛型参数T必须是值类型(比如整型int和结构),特别地,对于用户自定义的结构(struct),要求其成员也必须是值类型。
例如,以下结构将无法写入到内存映射文件中,因为其成员Info是string类型的,属于引用类型。
public struct ErrorStruct { public string Info; }
之所以要求泛型参数不能是引用类型,其道理非常简单:
如果结构中的某个成员是引用类型,那么在程序运行时,计算机无法知道应该向内存映射文件中写入多少个字节,因为引用类型的变量所引用的对象位于托管堆中,其占用存储空间的大小不经过计算是难以确定的,而完成这个计算工作将耗费不少的系统资源(想想一个对象可能又会引用到另一个对象就明白了),这有可能会严重影响内存映射文件读写操作效率。
两个进程不能交换引用类型的数据,这个限制似乎还不小,但可以通过对象序列化技术来突破这个限制,在两个进程间交换任意大小的对象(只要内存映射文件有足够的容量)。请看下一小节的示例UseMMFBetweenProcess2。
利用序列化技术通过内存映射文件实现进程通信(见图15-20)
图15-20 利用序列化技术通过内存映射文件实现进程通信
如图15-20所示,运行示例程序UseMMFBetweenProcess2的多个实例,加载图片并输入图片说明,点击相应按钮后,可以在多个进程间直接交换以下格式的信息:
[Serializable] class MyPic { public Image pic;//图片 public string picInfo;//图片信息说明 }
请注意这是一个引用类型,并且它附加了“[Serializable]”标记。
如果要向内存映射文件中序列化对象,必须将内存映射文件转换为可顺序读取的流。MemoryMappedFile类的CreateViewStream方法可以创建一个MemoryMappedViewStream对象,通过它即可序列化对象,其代码框架如下:
//创建或打开内存映射文件 MemoryMappedFile memoryFile=MemoryMappedFile.CreateOrOpen(...); //创建内存映射流 MemoryMappedViewStream stream=memoryFile.CreateViewStream(); //创建要在进程间交换的信息对象 MyPic obj=...; //向内存映射流中序列化对象 IFormatter formatter=new BinaryFormatter(); stream.Seek(0,SeekOrigin.Begin); formatter.Serialize(stream,obj);
请读者自行阅读源码了解更多技术细节,此处不再赘述。
15.3.4 使用WCF通过管道实现进程通信
本小节从进程通信的角度,介绍如何使用WCF通过“命名管道(Named Pipe)”实现同一台计算机中的多个客户端进程同时向一个服务端进程传送消息。
什么是管道?
“管道(pipe)”是Windows所提供的一种进程间通信机制,用于在两个进程之间相互传送数据。
Windows提供了两种类型的管道。
一种称为“匿名管道(Anonymous Pipe)”,这种类型的管道只允许单向通信,由于没有名字,因此要通信的两个进程应该是父子关系,父进程在创建子进程时,负责将代表匿名管道的句柄传送给子进程,子进程获取此句柄后,即可接收从父进程发来的信息。这种管道虽然占用的资源少,效率较高,但要求通信进程必须为父子关系,因此限制了使用场景,在实际开发中用得不多。
另一种称为“命名管道(Named Pipe)”,这种类型的管道拥有一个在本机唯一的名字,可以用于在一个服务进程和多个客户进程间同时进行单向(也可以是双向的)通信。命名管道支持基于消息的通信模式,这就是说,一个进程一次可以向另一方进程连续发送多个消息(消息之间通过消息的定界符进行划分),而接收方能够正确地从接收到的数据流中找到消息的定界符,从而可以提取出完整的消息。
从.NET 3.5开始,基类库中增加了一个System.IO.Pipes命名空间,其中提供了几个类用于实现基于管道的进程间通信,比如AnonymousPipeClientStream和AnonymousPipeServerStream可用于实现匿名管道,而NamedPipeClientStream和NamedPipeServerStream可用于实现命名管道,MSDN中也提供了相应的代码实例。
使用System.IO.Pipes中的类实现管道比较繁琐,WCF也可以使用管道进行进程间通信,而且更为简便和灵活,所以本书不介绍System.IO.Pipes中的类,而是直接介绍在WCF应用程序中使用命名管道的方法。
WCF应用程序使用命名管道实现进程通信
WCF提供了一个NetNamedPipeBinding绑定,此绑定在底层使用命名管道实现进程通信。
请看示例UseNamedPipeBetweenProcess(见图15-21)。
图15-21 WCF使用命名管道实现进程通信
如图15-21所示,启动服务端进程之后,可以再启动多个客户端进程,每个客户端进程都可以向服务端进程发送信息。
在服务端项目的App.config文件中,确定了服务协定和服务的终结点地址,并指定使用命名管道绑定实现进程通信:
<system.serviceModel>
<services>
<service name="WCFServer.frmServer">
<endpoint address="net.pipe://localhost/WCFServer"
binding="netNamedPipeBinding"
contract="WCFServer.IWCFServerService">
</endpoint>
</service>
</services>
</system.serviceModel>
这个示例中由于使用了NetNamedPipeBinding绑定,因此要求所有进程必须运行于同一台计算机上,然而只需改动一下App.config文件使用其他类型的绑定,程序代码不用作太多修改就可实现网络中的多台计算机上的多个进程间相互通信,这实际上就是一个简单分布式系统了。
本小节的这个实例,读者在掌握了WCF的基础知识之后,再来看会发现它非常的简单,而对WCF开发分布式系统技术的介绍,超出了本书的范畴。因此,请读者参考WCF的相关技术资料阅读本小节的示例代码,此处不再赘述。
15.3.5 实现进程间的“通知机制”
通过前面介绍的内容,我们已经可以采用很多种方式实现进程之间的数据传送,但大多数进程通信手段都缺乏一种通知机制,这就是说,如果一个进程负责“生产”数据,另一个进程负责“消费”数据,那么应该在这两个进程之间建立一种通知的机制,当“生产者”产生出来数据以后,主动通知“消费者”可以提取数据了。
要实现进程之间的通知机制,可以有很多种方法。本节介绍一种比较简便的利用.NET线程同步对象Mutex和EventWaitHandle实现进程通知机制的方法。
交叉链接
在.NET应用程序中,Mutex和EventWaitHandler主要用于同一进程内部的线程同步,但如果给它们起一个名字,则这些对象就成为“全局”的了,可以被同一台计算机上的所有进程所访问。第17章《线程同步与并发访问共享资源》集中介绍.NET线程同步技术的内容,其中包括Mutex和EventWaitHandler。
请看示例解决方案ProcessSynchronizeExample,此解决方案中包含两个Windows Forms项目,ProcessSynchronizeEventSource项目是“发出通知”者,称其为“通知发送端”,而ProcessSynchronizeEventResponsor则是“等待通知者”,称其为“通知接收端”。
当两个程序都运行起来以后,点击通知发送端程序主窗体上的按钮,则通知接收端程序会显示出它所接收到的“按钮点击通知”的次数(见图15-22)。
图15-22 示例解决方案ProcessSynchronizeExample
本示例的关键点在于这两个进程之间的“通知”机制是如何建立的。
由通知发送端程序负责创建一个有名字的EventWaitHandle对象,并将其置于non-signal状态,通知接收端程序在启动时尝试连接这一对象,如果能连接上,则通知接收端程序会启动一个后台线程,调用EventWaitHandle.WaitOne方法等待EventWaitHandle对象的状态转换为signaled状态。
在通知发送端程序的按钮单击事件中,调用EventWaitHandle.Set方法将EventWaitHandle对象的状态转换为signaled状态,从而使另一端等待的进程得到通知。
通知接收端程序得到“按钮点击”通知以后,它更新界面显示,调用EventWaitHandle.ReSet方法将EventWaitHandle对象的状态恢复为non-signaled状态,然后继续循环调用EventWaitHandle.WaitOne方法等待下一次通知。
示例的另外一个关键点是如何保证这两个程序都只运行一个实例。
对于通知发送端程序而言,它只要调用EventWaitHandle.OpenExisting方法检测一下EventWaitHandle对象是否己存在即可知道是否自身已有一个实例运行。
对于通知接收端程序而言,它启动时调用Mutex.OpenExisting方法检测一个特定名字的Mutex对象是否存在,从而推知自身是否已经有一个实例在运行。
另外通知接收端程序除了需要保证自身只运行一个实例之外,还必须保证在它运行前已有一个通知发送端程序在运行,代码中实现起来也很简单,直接用上面介绍的方法检测一下EventWaitHandle对象是否已存在即可。
提示
本节的示例是有缺陷的,那就是实现的仅是“单向通知”,换句话说,就是通知发出方只管发出通知,它不知道通知接收方是否真的接到了通知。
如果希望实现消息的“双向通知”,使用两个EventWaitHandle对象即可。
请读者结合本章前面介绍的进程通信手段和本节的进程通知机制,编程实现经典的“生产者”与“消费者”模式:“生产者”进程负责提供产品,“消费者”进程负责消费产品,只有“消费者”消费了产品之后,“生产者”才能继续生产。
举个例子,可以编写这样的一个实例:两个进程通过内存映射文件分块传送一个很大的文件。
顺便提一下:17.8.3节《自动锁定的集合——BlockingCollection》介绍了如何使用.NET 4.0新增的BlockingCollection实现线程间的“生产者-消费者”合作模式。