[C#]基于命名管道的一对多进程间通讯
在工作中碰到了一个进程间通讯的问题,大概是这样的:
项目本身是.net Core做的,但是有部分功能Core中不方便实现,有的是依赖Framework,有的是因为权限和安全问题。
那基于这个问题,问了问度娘进程通讯的问题,但是一水大神都在说,Socket啊,WebApi啊,内存共享啊,文件共享啊,等等。好不容易有个人在问管道的问题,大家都是一个口气:“用这么古老的东西干什么?”
既然大家都说管道这个“老古董”,那我今天就来扒扒这个坟。
先来尝试一下管道通讯
首先要清楚什么是管道,有不少大神写了,我就这里就不废话了,给个链接 Windows中的管道技术
基础概念知道了,接下来看看网上的别人怎么做的 C#命名管道通信
这篇Blog中用的是 System.IO.Pipes 下的 NamedPipeClientStream 和 NamedPipeServerStream,从微软的官方看这个命名空间是System.Core.dll中提供的,无论是Framework还是Core都可以使用。
建立一个解决方案,下面两个控制台应用,一个Framework的作为服务端,一个Core的作为客户端。把Blog的代码粘进去,运行OK。(废话)
接下来修改代码,让程序支持半双工通讯。
为什么是半双工?不是单工或者双工?
C/S的沟通方式还是模拟的“请求-响应”模式,既然需要“响应”那么单工自然不满足需求,而服务端本身不需要在客户端发送请求数据的同时回传数据,自然全双工也没有意义,并发也不是靠双工解决的。所以选择了实现比较简单的半双工模式。
修改后的代码:
1 using System; 2 using System.IO; 3 using System.IO.Pipes; 4 5 namespace Server 6 { 7 class Program 8 { 9 static void Main(string[] args) 10 { 11 using (NamedPipeServerStream pipeServer = new NamedPipeServerStream("testpipe", PipeDirection.InOut, 1)) 12 { 13 try 14 { 15 pipeServer.WaitForConnection(); 16 pipeServer.ReadMode = PipeTransmissionMode.Byte; 17 StreamWriter Writer = new StreamWriter(pipeServer); 18 StreamReader Reader = new StreamReader(pipeServer); 19 20 while (true) 21 { 22 var input = Reader.ReadLine(); 23 if (string.IsNullOrEmpty(input)) 24 { 25 break; 26 } 27 28 Console.WriteLine($"Server Get Message:{input}"); 29 Writer.WriteLine($"Server Get Message:{input}"); 30 Writer.Flush(); 31 } 32 } 33 catch (IOException e) 34 { 35 throw e; 36 } 37 } 38 Console.ReadKey(); 39 } 40 } 41 }
1 using System; 2 using System.IO; 3 using System.IO.Pipes; 4 using System.Security.Principal; 5 using System.Threading.Tasks; 6 7 namespace Client 8 { 9 class Program 10 { 11 static void Main(string[] args) 12 { 13 try 14 { 15 using (NamedPipeClientStream pipeClient = new NamedPipeClientStream("localhost", "testpipe", PipeDirection.InOut, PipeOptions.None, TokenImpersonationLevel.None)) 16 { 17 pipeClient.Connect(3000);//连接服务端 18 StreamWriter sw = new StreamWriter(pipeClient); 19 StreamReader sr = new StreamReader(pipeClient); 20 while (true) 21 { 22 Console.WriteLine("SendMessage:"); 23 var Input = Console.ReadLine(); 24 sw.WriteLine(Input); 25 sw.Flush(); 26 27 var Result = sr.ReadLine(); 28 Console.WriteLine($"Reply:{Result}"); 29 } 30 } 31 } 32 catch (Exception ex) 33 { 34 throw ex; 35 } 36 } 37 38 } 39 }
这样一个一对一的半双工通讯的程序就完成了
但是Web端肯定会有并发存在,如果这么一对一的通讯我总不能在Web后台长期阻塞等待服务器相应吧?如果采用消息列队就得不到服务器的响应了。
开始第一版踩坑设计
接下来说回正题,如何让管道进行“一对多”通讯。
首先客户端和服务端,再等待输入输出的时候线程都是阻塞的,那多个管道要进行同时通讯,就是要每个管道一个线程。
开始设计的“分发式”
1、客户端 链接 服务端 的主管道
2、服务端根据客户端发过来的密钥,生成一个带安全认证的私有管道
3、私有管道的名称和密钥发送回客户端,之后断开链接等待其他客户端链接
4、客户端根据发回来密钥和名称来链接私有管道
5、开始正常私有管道通讯,客户端断开链接后,私有管道自动销毁。
这个设计有个3个问题
1、首先客户端需要创建2个管道
2、所有的客户端都要先链接主管道,即使极大的减少了管道处理量,但是依旧会产生阻塞
3、占用大量的管道名称
等代码都写完了,回头重新产看微软官方的文档的时候,发现其实这种“分发式”是极为的脑残的
微软爸爸早就提供了更优雅的解决方案,其实同名的管道可以存在多个实例,每个实例可以链接一个不同的客户端。
那么之前的设计就需要改改了
最终的来了
经过修改,改成了“自分裂式”
设计思路
1、创建一个管道池,保持内部有一个待链接的管道实例
2、客户端去链接,系统会自动分配给客户端一个等待链接的服务端实例
3、当服务端链接后,再创建一个新的待链接的管道实例
4、当客户端断开链接或者超时,那么将自动销毁服务端实例
保持管道池中至少有1个待链接的实例,但是不能超过上限数量。
我就不上图了,直接上代码
Pipeline.cs
作为管理管道用的类
1 using System; 2 using System.Collections.Generic; 3 using System.IO; 4 using System.IO.Pipes; 5 using System.Threading; 6 using System.Threading.Tasks; 7 8 namespace Server 9 { 10 public class Pipeline : IDisposable 11 { 12 public Guid ID { get; } 13 14 private NamedPipeServerStream Server; 15 private Task Task; 16 private AutoResetEvent Get, Got; 17 private string inputContext; 18 private StreamWriter Writer; 19 private StreamReader Reader; 20 21 public const int MaxServer = 100; 22 public const string ServerName = "testpipe"; 23 public const int ServerWaitReadMillisecs = 10000; //10s 24 public const int MaxTimeout = 3; 25 26 public Pipeline() 27 { 28 ID = Guid.NewGuid(); 29 Get = new AutoResetEvent(false); 30 Got = new AutoResetEvent(false); 31 Server = new NamedPipeServerStream(ServerName, PipeDirection.InOut, MaxServer); 32 } 33 34 public void Start() 35 { 36 Task = Task.Factory.StartNew(TaskRun); 37 } 38 39 private void TaskRun() 40 { 41 Server.WaitForConnection(); 42 PipelinePool.CreatePipeLineAsync(); 43 try 44 { 45 Writer = new StreamWriter(Server); 46 Reader = new StreamReader(Server); 47 while (true) 48 { 49 var input = TryReadLine(); 50 if (string.IsNullOrEmpty(input)) break; 51 //Do Somethin.... 52 Console.WriteLine($"Server {ID} Get Message:{input}"); 53 Writer.WriteLine($"Server Get Message:{input}"); 54 Writer.Flush(); 55 } 56 } 57 catch (TimeoutException) 58 { 59 Console.WriteLine($"管道{ID}超时次数过多,视为丢失链接"); 60 } 61 Console.WriteLine($"管道{ID}即将关闭"); 62 Dispose(); 63 } 64 65 private void readerThread() 66 { 67 Get.WaitOne(); 68 inputContext = Reader.ReadLine(); 69 Got.Set(); 70 } 71 72 private string TryReadLine() 73 { 74 int TimeOutCount = 0; 75 var thread = new Thread(readerThread); 76 thread.Start(); 77 Get.Set(); 78 while (!Got.WaitOne(ServerWaitReadMillisecs)) 79 { 80 if (TimeOutCount++ > MaxTimeout) 81 { 82 thread.Abort(); 83 throw new TimeoutException(); 84 } 85 Console.WriteLine($"管道{ID}第{TimeOutCount}次超时"); 86 } 87 return inputContext; 88 } 89 90 public void Dispose() 91 { 92 Server.Close(); 93 Server.Dispose(); 94 Get.Dispose(); 95 Got.Dispose(); 96 PipelinePool.DisposablePipeLineAsync(ID); 97 } 98 } 99 }
其中值得一提的是 TryReadLine 这个方法。
在普通的ReadLine时候,线程是阻塞的,造成后面的代码无法为运行。
如果客户端因为某个问题早成死锁或者崩溃,但是又未丢掉链接,这个线程就会一直阻塞下去。也无法释放。
无奈上了下最大的同性交友技术交流网站Stackoverflow,大神的确多,找到了这么一个解决方案 How to add a Timeout to Console.ReadLine()?
经过修改,就成了上面的TryReadLine方法。
接下来池子就比较简单了
1 using System; 2 using System.Collections.Concurrent; 3 using System.Threading.Tasks; 4 5 namespace Server 6 { 7 public class PipelinePool 8 { 9 /// <summary> 10 /// 用于存储和管理管道的进程池 11 /// </summary> 12 private static ConcurrentDictionary<Guid, Pipeline> ServerPool = new ConcurrentDictionary<Guid, Pipeline>(); 13 14 /// <summary> 15 /// 创建一个新的管道 16 /// </summary> 17 private static void CreatePipeLine() 18 { 19 lock (ServerPool) 20 { 21 if (ServerPool.Count < Pipeline.MaxServer) 22 { 23 var pipe = new Pipeline(); 24 pipe.Start(); 25 ServerPool.TryAdd(pipe.ID, pipe); 26 } 27 28 } 29 Console.WriteLine($"管道池添加新管道 当前管道总数{ServerPool.Count}"); 30 } 31 32 /// <summary> 33 /// 根据ID从管道池中释放一个管道 34 /// </summary> 35 private static void DisposablePipeLine(Guid Id) 36 { 37 lock (ServerPool) 38 { 39 Console.WriteLine($"开始尝试释放,管道{Id}"); 40 if (ServerPool.TryRemove(Id, out Pipeline pipe)) 41 Console.WriteLine($"管道{Id},已经关闭,并完成资源释放"); 42 else 43 Console.WriteLine($"未找到ID为{Id}的管道"); 44 if (ServerPool.Count == 0) 45 CreatePipeLine(); 46 } 47 } 48 49 /// <summary> 50 /// (异步)创建一个新的管道进程 51 /// </summary> 52 public static async void CreatePipeLineAsync() => await Task.Run(new Action(CreatePipeLine)); 53 54 /// <summary> 55 /// (异步)根据ID从管道池中释放一个管道 56 /// </summary> 57 /// <param name="id"></param> 58 public static async void DisposablePipeLineAsync(Guid id) => await Task.Run(() => { DisposablePipeLine(id); }); 59 60 } 61 }
然后程序的入口方法
1 using System; 2 3 namespace Server 4 { 5 class Program 6 { 7 8 static void Main(string[] args) 9 { 10 PipelinePool.CreatePipeLineAsync(); 11 Console.ReadKey(); 12 } 13 14 } 15 16 }
服务端的修改就完成了
客户端其实保持不变就可以
为了测试,修改一下
1 using System; 2 using System.Collections.Generic; 3 using System.IO; 4 using System.IO.Pipes; 5 using System.Security.Principal; 6 using System.Threading.Tasks; 7 using Models; 8 9 namespace Client 10 { 11 class Program 12 { 13 static void Main(string[] args) 14 { 15 List<PipeTest> list = new List<PipeTest>(); 16 for (int i = 0; i < 10; i++) 17 list.Add(new PipeTest(i)); 18 list.ForEach(a => Task.Factory.StartNew(a.run)); 19 Console.ReadKey(); 20 } 21 } 22 23 public class PipeTest 24 { 25 public int id { get; } 26 27 public PipeTest(int id) 28 { 29 this.id = id; 30 } 31 32 private List<string> Message = new List<string> { "aaa", "bbb", "ccc", "ddd", "eee", "fff", "ggg" }; 33 34 public void run() 35 { 36 try 37 { 38 using (NamedPipeClientStream pipeClient = new NamedPipeClientStream( 39 PipeCommunicationConfiguration.ServerName, PipeCommunicationConfiguration.PipeName, 40 PipeDirection.InOut, PipeOptions.None, TokenImpersonationLevel.None) 41 ) 42 { 43 pipeClient.Connect(PipeCommunicationConfiguration.ClientConnectTimeout);//连接服务端 44 StreamWriter sw = new StreamWriter(pipeClient); 45 StreamReader sr = new StreamReader(pipeClient); 46 foreach(string msg in Message) 47 { 48 Console.WriteLine($"Client {id} SendMessage:" + msg); 49 sw.WriteLine(msg);//传递消息到服务端 50 sw.Flush();//注意一定要有,同服务端一样 51 52 string temp = sr.ReadLine();//获取服务端返回信息 53 if (!pipeClient.IsConnected) 54 { 55 Console.WriteLine("Pipe is Broken"); 56 break; 57 } 58 Console.WriteLine("replyContent:" + temp); 59 } 60 pipeClient.Close(); 61 } 62 } 63 catch (Exception ex) 64 { 65 } 66 Console.WriteLine($"Client {id} end of conversation"); 67 } 68 } 69 }
这样客户端就可以同时启动多个线程,并向服务端通讯
运行结果
测试成功!
总结:
管道本身只能实现一对一进行通信,这个是不能改变的。
那多管道通信就成了必然,对管道数量的伸缩和进程的管理就成了主要的问题。
一开始没有好好看文档走了不少弯路,还整了个“分发式”...笑哭😂
整个学习过程主要还是耗费在了多线程的管理上。
写篇博客留以后复习用
如果有问题,欢迎指出