勇哥注:
我们继续上一篇《勇哥关于多线程读写plc内存的研究》
在上篇结尾,我们看到lock锁带来的效率问题。
本着程序员应有的工匠精神,本节我们来研究并解决这个问题。
先回顾一下上篇文章结尾勇哥截的程序效率图。
Monitor.Enter的函数占用cpu时间75.85%,此耗能大户就是lock锁。(因为lock关键字实际上是Monitor的语法糖)
由于plc读写操作中,读的次数一定远大于写入的次数,因此我们希望的是,无论有多少个线程都可以一起读,
只是同一时间只能有一个线程能写。而lock是独占,它区分不了读与写的不同,反正不管是读还是写,只要有一个线程在操作,
其它的线程就只有等待。
按上面的分析,我们使用lock是不合理的。
要达到这个要求,可以使用ReaderWriterLockSlim锁,它的特点是多个线程可以读,但只允许一个线程写入
(必须等读线程完成后才可以),因为我们就是希望读与写是互斥的,所以能满足我们的要求。
改动后代码如下:
代码仅仅是在Read()和Write()函数中做了一些修改。
有关ReaderWriterLockSlim锁的知识,请看本站另一篇文章《C#读写锁ReaderWriterLockSlim》
using netMarketing.automation.plc.Omron.FinsLibrary; using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; namespace plcreadmode { public class PlcReadWrite { static private ReaderWriterLockSlim rwlock = new ReaderWriterLockSlim(); public static log plclog = new log(); static List<int> MemBuffer = new List<int>(); /// <summary> /// 读plc的指令列表 /// </summary> static List<ReadCmd> ReadCmdList = new List<ReadCmd>(); /// <summary> /// 写plc的指令列表 /// </summary> static List<WriteCmd> WriteCmdList = new List<WriteCmd>(); static Thread t1 = new Thread(new ThreadStart(doWork)) { IsBackground = true }; static readonly ManualResetEvent[] mr = new ManualResetEvent[2]; static Stopwatch sw1 = new Stopwatch(); static Stopwatch sw2 = new Stopwatch(); static FinsSocket cp1h = null; /// <summary> /// 读写起始地址 /// </summary> static public ushort StartAddr { get; set; } = 2000; /// <summary> /// 读写最大数量 /// </summary> static public ushort ReadWriteCount { get; set; } = 4; static public string PlcIPAddr { get; set; } = "192.168.250.1"; static public int PlcPort { get; set; } = 9600; static bool isRead { get; set; } = false; private static readonly object readwriteLock = new object(); static PlcReadWrite() { if (!t1.IsAlive) { cp1h = new FinsSocket(); cp1h.IP = PlcIPAddr; cp1h.Port = PlcPort; for (int i=0;i<ReadWriteCount;i++) { MemBuffer.Add(0); } mr[0] = new ManualResetEvent(false); mr[1] = new ManualResetEvent(false); sw1.Start(); sw2.Start(); t1.Start(); } } static private void doWork() { while (true) { mr[0].WaitOne(); if (isRead) { readPlc(); } else { writePlc(); } mr[0].Reset(); } } static void writePlc() { var writeResult = false; if (WriteCmdList.Count > 0 && WriteCmdList.Count <= 3) { //单个写 for (int i = 0; i < WriteCmdList.Count; i++) { var offsetAddr = WriteCmdList[i].addr; var data = WriteCmdList[i].writeData; writeResult=cp1h.Write(_eMemory.DM, (ushort)(StartAddr + offsetAddr), data); WriteCmdList[i].writeStatus = writeResult?1:0; } } else { var list1 = new List<short>(); for (int i = 0; i < ReadWriteCount; i++) { list1.Add(0); } var plcdataBuffer = list1.ToArray(); //多个写 if (WriteCmdList.Count >= ReadWriteCount) { for (int i = 0; i < ReadWriteCount; i++) { plcdataBuffer[i] = (short)WriteCmdList[i].writeData; } //如果要写的数量刚好等于读写最大数量ReadWriteCount,则直接写 writeResult=cp1h.WriteMultiple(_eMemory.DM, StartAddr, plcdataBuffer); } else { //先读出全部地址数据 cp1h.Read(_eMemory.DM, StartAddr, ref plcdataBuffer); //再由待写数据更新缓存,再把缓存一次写入地址 for(int i=0;i< ReadWriteCount;i++) { plcdataBuffer[WriteCmdList[i].addr] = (short)WriteCmdList[i].writeData; } writeResult=cp1h.WriteMultiple(_eMemory.DM, StartAddr, plcdataBuffer); } for (int i = 0; i < WriteCmdList.Count; i++) { WriteCmdList[i].writeStatus = writeResult?1:0; } } } static void readPlc() { var list1 = new List<short>(); for(int i=0;i< ReadWriteCount;i++) { list1.Add(0); } var plcdataF = list1.ToArray(); var readResult = cp1h.Read(_eMemory.DM, StartAddr, ref plcdataF); for (int i = 0; i < ReadCmdList.Count; i++) { if (readResult) { ReadCmdList[i].readStatus = 1; ReadCmdList[i].readResult = plcdataF[ReadCmdList[i].addr]; } else { ReadCmdList[i].readStatus = 0; ReadCmdList[i].readResult = -1; } } } public int Write(int addr,short data, Guid _id) { rwlock.EnterWriteLock(); try { var writeSw = new Stopwatch(); writeSw.Start(); isRead = false; WriteCmdList.Add(new WriteCmd() { addr = addr, id = _id, writeData = data, writeStatus = -1 }); var idx = -1; int js1 = 0; while (true) { if (writeSw.ElapsedMilliseconds > 2000) { //读plc超时 writeSw.Stop(); return -1; } if (sw2.ElapsedMilliseconds > 50 && js1 == 0) { //通知写plc mr[0].Set(); sw2.Restart(); ++js1; } idx = WriteCmdList.FindIndex(s => s.id == _id && s.writeStatus == 1); if (idx >= 0) { //写成功了,返回1 WriteCmdList.RemoveAt(idx); plclog.AddLog(new logdata() { id = _id, readOrWrite = readwriteEnum.write, plcAddr = (short)addr, recTime = DateTime.Now, itemCount =WriteCmdList.Count }); return 1; } Thread.Sleep(1); } } finally { rwlock.ExitWriteLock(); } } public int Read(int addr,Guid _id) { rwlock.EnterReadLock(); try { var readSw = new Stopwatch(); readSw.Start(); isRead = true; ReadCmdList.Add(new ReadCmd() { id = _id, addr = addr, readResult = -1, readStatus = 0 }); var idx = -1; int js1 = 0; while (true) { if(readSw.ElapsedMilliseconds>2000) { //读plc超时 readSw.Stop(); return -1; } if (sw1.ElapsedMilliseconds > 50 && js1 == 0) { //通知读plc mr[0].Set(); sw1.Restart(); ++js1; } idx = ReadCmdList.FindIndex(s => s.id == _id && s.readStatus == 1); if (idx >= 0) { //读成功后返回读到的值 var resData = ReadCmdList[idx].readResult; ReadCmdList.RemoveAt(idx); plclog.AddLog(new logdata() { id = _id, readOrWrite = readwriteEnum.read, plcAddr = (short)addr, recTime = DateTime.Now, itemCount= ReadCmdList.Count }); return resData; } Thread.Sleep(1); } } finally { rwlock.ExitReadLock(); } } } public class WriteCmd { public Guid id { get; set; } public int addr { get; set; } = -1; public short writeData { get; set; } = 0; public int writeStatus { get; set; } = 0; } public class ReadCmd { public Guid id { get; set; } public int addr { get; set; } = -1; public int readResult { get; set; } = 0; public int readStatus { get; set; } = 0; } }
我们再来分析一下效率的提升。
占cpu时间最多的第一名 GetHostAddresses函数,实际上是读写plc的网络操作,这个是系统操作。不关我们的事。
Application.Run这个是整winform程序占用的总时间,这次是17.79%, 上篇文章中是2.69%,不过不能说是增加,只是对比的基数不同而已。
其它跟我们能扯上点有关系的就是RichTextBox.set_Text,占比6.44%,这个是控件更新造成的。 这个我们可以继续优化一下。
下图是写控件的耗能情况,确实值得再优化一下。
其它一些语句的耗能情况如下:
总得来讲,效率比起lock来说是提升了一大截。
在讲读写锁ReaderWriterLockSlim那篇文章,讲了一组纸面数据,很能说明问题:
就500个Task,每个Task占用一个线程池线程,其中20个写入线程和480个读取线程, 模拟操作。其中读取数据花10ms,写入操作花100ms,分别测试了对于lock方式和ReaderWriterLockSlim方式。 可以做一个估算,对于ReaderWriterLockSlim,假设480个线程同时读取,那么消耗10ms, 20个写入操作占用2000ms,因此所消耗时间2010ms, 而对于普通的lock方式,由于都是独占性的,因此480个读取操作占时间4800ms+20个写入操作2000ms=6800ms。 运行结果显示了性能提升明显
勇哥2022/11/2注:
上面程序今天执行时出现下面的报错。
想了一下,这应该是使用ReaderWriterLockSlim锁后副作用出现了。
因为该锁允许N个线程同时读,假设执行ReadCmdList.RemoveAt(idx) 这句的线程A,它要删除的索引是2,
但是可能同一时间另一个线程B也执行了此句,它先删除了索引1。
当线程A要删除索引2的时候,已经没有索引2了,它现在列表中的索引变化了。
因此这是多线程删除集合元素时产生的问题。
显然如果是lock,是不会出现这种问题的,这个时候各位是不是又觉得傻瓜方式的lock确实易用得多?
怎么解决这个问题?
由于C#没有线程安全的List<T>,所以勇哥现在很苦恼。。。
想来想去,有一个办法,就是把删除ReadCmdList内容的时机放在Write函数里。
因为ReaderWriterLockSlim锁的原因,当正在执行写操作的时候,所有Read的线程都会被锁住进行排队等待。这个时机我们用来删除ReadCmdList的项目。
1. 首先定义一个List<Guid>用来记录Read中要删除的项目,按id
2. 在Read中不要做ReadCmdList.RemoveAt(idx); 而是把要删除的项目id压入readyRemoveItems。
3. 在Write中按readyRemoveItems记录的项目进行删除。此时删除是安全的。
/// <summary> /// 要删除的项目列表 /// </summary> static List<Guid> readyRemoveItems = new List<Guid>();
(第一步)
(第二步)
(第三步)
但是这样做又有一个新问题,如果整个类,只读不写,则没机会删除ReadCmdList的内容了,最终会撑爆列表。
怎么办呢?
。。。。
那就只剩下一招了,在这个类里加一个ClearReadCmdList() 成员函数,让调用者自己在适合的时机去清除了。
因为想自动时机清除,臣妄做不到啊~~~
注意: 这里勇哥是从程序的效率出发在考虑这个问题,否则,如果抛开效率,我们解决这种问题办法还是很多的。
---------------------
作者:hackpig
来源:www.skcircle.com
版权声明:本文为博主原创文章,转载请附上博文链接!

