勇哥注:
我们继续上一篇《C# 勇哥关于多线程读写plc内存的研究续,解决UI控件读写的效率问题》
此系列贴子已经写了好几篇了:
(3)C# 勇哥关于多线程读写plc内存的研究续,解决UI控件读写的效率问题
(2)C# 勇哥关于多线程读写plc内存的研究续,解决lock锁的效率问题
(1) C# 勇哥关于多线程读写plc内存的研究
在第(3)篇里勇哥牛皮已经吹出去了,说这个plc多线程类已经比较好用了。
实际情况是,在引入了ReaderWriterLockSlim锁后,因为读操作变成并发方式,长时间运行会产生新的问题~~~
当初认为可以了,实际上只是测试的时间不够长而矣。
问题一:
ReadCmdList列表保存的是读指令。
但是会出现ReadCmdList[i]==null的情况。
这个是为什么呢?
这一种情况是多个线程在执行Read(),所以相当于同时向ReadCmdList进行Add
但是由于List的add执行时,动作分为两步,一是容器大小先加1,二是把新数据进行赋值。
因为上图中线程刚好在一个add的第一步时进行读操作,因此会读到元素为null。
此问题网上有一篇博文讨论过,如下:
多线程并发导致List的add()失败,元素为null
总结一下就是: List.Add 方法自己都不是一个原子操作,要经过1,2,3.....步操作。 只有它做到最后一步时,你的线程去读,才能读到正确的结果。 当add只操作到第1步的时候,碰巧你的线程非要去读,于是完蛋了,得到null。
问题二:
对于List的add方法,居然报了索引超出了数组界限??
是不是很奇怪。
可以看到并不是logBuffer超内存,它才35个元素。
传入的data,其数据结构中并没有数组。
勇哥查了一下关于异常System.IndexOutofRangeException 的官方解释。
其有这么一条:
违反线程安全性。 从同一 StreamReader 个对象读取、从多个线程写入同 StreamWriter 一 Hashtable 对象或从不同线程枚举对象等操作,如果无法以线程安全的方式访问对象,则可能会引发 IndexOutOfRangeException 该对象。 此异常通常是间歇性的,因为它依赖于争用条件。
官方文档见: https://learn.microsoft.com/zh-cn/dotnet/api/system.indexoutofrangeexception?view=netframework-4.7.2
勇哥的理解是,List<T>不是线程安全的。
查阅一下官方文档关于List<T>的解释: https://learn.microsoft.com/zh-cn/dotnet/api/system.collections.generic.list-1?view=netframework-4.7.2
关于其线程安全说明如下:
线程安全性 此类型的Visual Basic) 成员中的公共静态 (Shared是线程安全的。 但不保证所有实例成员都是线程安全的。 可以安全地对某个 List<T>集合执行多个读取操作,但如果正在读取集合时修改集合,则可能会出现问题。 若要确保线程安全,请在读取或写入操作期间锁定集合。 若要允许多个线程访问集合以读取和写入,必须实现自己的同步。 有关具有内置同步的集合,请参阅命名空间中的 System.Collections.Concurrent 类。 有关固有线程安全的替代方法,请参阅 ImmutableList<T> 该类。
这说明List<T>只是读取是线程安全,但是像什么Add方法也是线程不安全的,需要自己加锁。或者改用其它线程安全的类型。
文章讲的ImmutableList<T> 类,看上去即有Add方法,也有Remove方法,看上去很美好。(要知道Concurrent类型的集合虽然有8种之多,但是有一个问题是没有Remove方法。)
但是此集合.net Framework不能直接使用,需要Nuget进行安装。
注解 使用不可变集合,你可以: 。共享集合,使其使用者可以确保集合永远不会发生更改。 。提供多线程应用程序中的隐式线程安全(无需锁来访问集合)。 。遵循函数编程做法。 。在枚举过程中修改集合,同时确保该原始集合不会更改。 .NET Core 和 .NET 5 + 中均提供不可变的集合类,但它们不是随 .NET Framework 一起分发的核心类库的一部分。 从 .NET Framework 4.5 开始,可以通过 NuGet 开始使用。 通过 NuGet 安装不可变集合: 在 Visual Studio 中打开项目,然后从 " 项目 " 菜单中选择 " 管理 NuGet 包 "。 还可以选择 " 包括预发行 版" 复选框。 此选项可让你访问不可变类的新预发布版本,因为这些版本可用。 使用 " 搜索 " 框找到 "" 。 在左窗格中,选择 " system.web " 包。 在右侧窗格中,选择所需的版本,然后选择 "安装"。 上述安装步骤适用于 Visual Studio 2015。 对于其他版本的 Visual Studio,这些步骤可能稍有不同,因为用户界面 (UI) 不同。
但是这个说明中搜索的“system.web”是错误的,也许是微软的说明是机译版本的原因吧。
正确的是搜索下面的关键词。
第一个1.11g下载的是.net Core用的。第三个那个才是.net Framwork用的。
(这是插一句题外话, 从下载量来看,.net Core已经远远超过 .net Framework了,看来要变天了呀?)
勇哥用不可变对象集合ImmutableList<T> 改造了一下程序。
有关此集合的基本用法,请看勇哥的另几篇贴子
(
)
跑起来后,在Read()函数中,时间一长还是出现问题,例如下图所示的错误。
也可能出现在:
var resData = ReadCmdList[idx].readResult;
这一句的错误可能出现idx超出ReadCmdList的最大值。
而图片所示的错误,可能是因为RemoveAt后,又做一了次赋值,更新ReadCmdList。
ReadCmdList= ReadCmdList.RemoveAt(idx);
如果把这个赋值取消,变成:
ReadCmdList.RemoveAt(idx);
则没有问题。但是这样的话,ReadCmdList就无法更新记录了。
所以多线程时,ReadCmdList删除记录时怎么保证不会报错,还是一个未知的问题。
并且,此集合应用时存在大量的元素数量被改变,开销也是很大的。
因此也不适合使用此类集合。
考虑到ReadCmdList的数据结构中有使用id,还是使用的uid做为唯一标识,所以勇哥考虑尝试使用ConcurrentDictionary<>线程安全字典。
此集合有多种类型,统称为Concurrent安全集合,ConcurrentDictionary<>只是其中之一。
如下表所示:
详细见微软官方说明:
https://learn.microsoft.com/zh-cn/dotnet/api/system.collections.concurrent?view=net-6.0
这类集合已经是.net Framework自带了。不需要Nuget额外安装。
经过改造后的源码,成功的杜绝了上面的多线程造成的问题。长时间运行无报错。
耗能也下降到最低,如下图所示:
另外,勇哥发现,在死循环中进行Thread.Sleep(), 取值1和取值10相差10%的耗能,即值越大耗能越少。
代码主要改变的地方:
ReadCmdList的定义
WriteCmdList的定义没有改变
/// <summary> /// 读plc的指令列表 /// </summary> static ConcurrentDictionary<Guid, ReadCmd> ReadCmdList = new ConcurrentDictionary<Guid, ReadCmd>(); /// <summary> /// 写plc的指令列表 /// </summary> static List<WriteCmd> WriteCmdList = new List<WriteCmd>();
readPlc()
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); list666.Add($"{plcdataF[0]},{plcdataF[1]},{plcdataF[2]},{plcdataF[3]}"); var s1 = ReadCmdList; for (int i = 0; i < ReadCmdList.Count; i++) { ReadCmdList.ElementAt(i).Value.readStatus = 1; ReadCmdList.ElementAt(i).Value.readResult = plcdataF[ReadCmdList.ElementAt(i).Value.addr]; } mr[1].Set(); }
Read()
/// <summary> /// 读plc内存地址 /// </summary> /// <param name="addr">plc内存地址,填写相对地址,从0开始</param> /// <param name="_id">guid</param> /// <param name="threadId">线程id</param> /// <returns></returns> public int Read(int addr,Guid _id,int threadId) { rwlock.EnterReadLock(); { try { var readSw = new Stopwatch(); readSw.Start(); isRead = true; ReadCmdList.TryAdd(_id, new ReadCmd() { threadID = threadId, id = _id, addr = addr, readResult = -1, readStatus = 0 }); int js1 = 0; while (true) { mr[1].WaitOne(); if(!readSw.IsRunning || readSw.ElapsedMilliseconds>60) { readSw.Restart(); } if (readSw.ElapsedMilliseconds > 2000) { //读plc超时 readSw.Stop(); return -1; } if (sw1.ElapsedMilliseconds > 50 && js1 == 0) { //通知读plc ReadCmdList[_id].swTime = sw1.ElapsedMilliseconds; mr[1].Reset(); mr[0].Set(); sw1.Restart(); ++js1; } if(ReadCmdList[_id].readStatus==1) { var data = new ReadCmd(); ReadCmdList.TryRemove(_id, out data); return data.readResult; } Thread.Sleep(1); } } finally { rwlock.ExitReadLock(); } } }
程序的运行逻辑改为下面的规则:
当50ms时间到后,立刻把 mr[1].Reset(); 此时后继的Read指令因为 mr[1].WaitOne() 被挂起,不允许进行计时执行。
把这一轮的read指令的ReadCmdList交给plc线程处理,然后返回plc取得结果到ReadCmdList,然后mr[1].Set(),通知后继的Read指令继续处理下一轮。
经过此次修后,貌似正常了。
不过还需要长时间验证。
《多线程读写plc内存》这个话题,如勇哥开头讲的,是一件比较困难的事。
从这个话题我们见识到并发编程带来的复杂性。
2022/11/11 勇哥注:
跑了一晚上后,发现只有最后一个地址的数值(13414)在跳动,其它的都不动,应该是死掉了。
这又是为什么呢?
还是再开一贴继续研究吧....
源代码下载:
-----------------
---------------------
作者:hackpig
来源:www.skcircle.com
版权声明:本文为博主原创文章,转载请附上博文链接!

