勇哥注:
我们继续上一篇《勇哥关于多线程读写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
版权声明:本文为博主原创文章,转载请附上博文链接!


少有人走的路



















