以原子方式更改两个数字而无需使用锁

时间:2019-06-28 13:25:50

标签: c# multithreading thread-safety

我正在创建驻留在内存中的仅追加数据结构,并将序列化为字节数组的记录追加到该内存。我需要它是线程安全的并且非常快,所以我想出了下面的代码,到目前为止效果很好(这是一个伪代码,实际版本更复杂,并且做了其他一些事情,但这只是为了理解这个想法)

public sealed class MemoryList : IDisposable
{
    private int nextOffset = 0;
    private readonly MemoryMappedFile file;
    private readonly MemoryMappedViewAccessor va;

    public MemoryList(uint capacity)
    {
        // Some checks on capacity here
        var mapName = Guid.NewGuid().ToString("N");
        this.file = MemoryMappedFile.CreateNew(mapName, capacity);
        this.va = file.CreateViewAccessor(0, capacity);
    }

    public void AppendMessage(byte[] messagePayload)
    {
        if (messagePayload == null) 
            throw new ArgumentNullException(nameof(messagePayload));
        if (messagePayload.Length == 0)
            throw new ArgumentOutOfRangeException(nameof(messagePayload));

        if (TryReserveCapacity(messagePayload.Length, out var offsetToWriteTo))
        {
            this.va.Write(offsetToWriteTo, messagePayload.Length);
            this.va.WriteArray(offsetToWriteTo + sizeof(int), messagePayload, 0, messagePayload.Length);
        }
    }

    private bool TryReserveCapacity(int dataLength, out long reservedOffset)
    {
        // reserve enough room to store data + its size
        var packetSize = sizeof(int) + dataLength;
        reservedOffset = Interlocked.Add(ref this.nextOffset, packetSize) - packetSize;

        if (this.nextOffset <= this.va.Capacity)
            return true;
        reservedOffset = -1;
        return false;
    }

    public void Dispose()
    {
        file?.Dispose();
        va?.Dispose();
    }
}

这非常快,而且效果很好。无论我多么努力,我都无法打破它。

因此,现在我需要为每个附加消息使用TryReserveCapacity方法来输出每个消息的逻辑索引。 因此,对于第一条消息,获取索引0,对于第二条消息-获取索引1,依此类推。 这导致对Interlocked使用两个调用offset,对messageIndex使用两个调用,这显然不是线程安全的,我最终可能遇到导致以下情况的竞争条件。 / p>

MI:101,偏移:10000 MI:100,偏移:10500

关于如何保证没有一个MI会比另一个具有较大偏移量的MI大的想法?所有这些都没有使用任何锁?

那么,基本上,我们如何更改以下方法以使其行为正确?

private bool TryReserveCapacity(int dataLength, out long reservedOffset, out long messageId)
{
    // reserve enough room to store data + its size
    var packetSize = sizeof(int) + dataLength;
    reservedOffset = Interlocked.Add(ref this.nextOffset, packetSize) - packetSize;
    messageId = Interlocked.Increment(ref this.currentMessageId);

    if (this.nextOffset <= this.va.Capacity)
        return true;
    reservedOffset = -1;
    return false;
}

P.S我知道示例代码的字节序问题,但正如我所说,只是将其视为说明问题的伪代码。

1 个答案:

答案 0 :(得分:1)

很抱歉,如果这不能直接解决您的主要问题(非锁定原子性),但是我看到您正在使用MemoryMappedFileMemoryMappedViewAccessor类来操纵内存映射文件。

我真的不知道.NET Framework的当前迭代是否已解决此问题,但是在大约三年前编写的代码库中,我们发现使用这些类提供的内存映射文件操作确实很差的性能(如果我没记错的话,性能要慢7倍左右),即使是在托管 C ++ / CLI类内部,与使用Win32 API和对映射内存的直接指针操作相比,

我强烈建议您对这种方法进行测试,您可能会对性能提升感到惊讶(就像我们当然所做的那样),并且性能提升如此显着,以至于您可以负担标准锁定的成本来实现您想要的原子性。

如果您想探索这一途径,请参考以下代码片段,了解该技术的基础。

Int32 StationHashStorage::Open() {
   msclr::lock lock(_syncRoot);
   if( _isOpen )
      return 0;
   String^ fileName = GetFullFileName();

   _szInBytes = ComputeFileSizeInBytes(fileName);
   String^ mapExtension = GetFileExtension();
   String^ mapName = String::Format("{0}{1}_{2}", _stationId, _date.ToString("yyyyMMdd"), mapExtension);

   marshal_context context;
   LPCTSTR pMapName = context.marshal_as<const TCHAR*>(mapName);

   {
      msclr::lock lock( _openLock );
         // Try to see if another storage instance has requested the same memory-mapped file and share it
         _hMapping = OpenFileMapping(FILE_MAP_READ | FILE_MAP_WRITE, FALSE, pMapName);
         if( !_hMapping ) {
            // This is the first instance acquiring the file
            LPCTSTR pFileName = context.marshal_as<const TCHAR*>(fileName);
            // Try to open the existing file, or create new one if not exists
            _hFile = CreateFile(pFileName, 
                                GENERIC_READ | GENERIC_WRITE, 
                                FILE_SHARE_READ,
                                NULL,
                                OPEN_ALWAYS,
                                FILE_ATTRIBUTE_NORMAL,
                                NULL);
            if( !_hFile )
               throw gcnew IOException(String::Format(Strings::CreateFileFailed, GetLastError(), _stationId));
            _hMapping = CreateFileMapping(_hFile, 
                                          NULL,
                                          PAGE_READWRITE | SEC_COMMIT,
                                          0,
                                          _szInBytes,
                                          pMapName);
            if( !_hMapping ) 
               throw gcnew IOException(String::Format(Strings::CreateMappingFailed, GetLastError(), _stationId));
            _usingSharedFile = false;
         } else {
            _usingSharedFile = true;
         }
      }

// _pData gives you access to the entire requested memory range, you can directly
// dereference it,  memcopy it, etc.

   _pData = (UInt32*)::MapViewOfFile(_hMapping, FILE_MAP_READ | FILE_MAP_WRITE, 0, 0, 0);

   if( !_pData ) 
      throw gcnew IOException(String::Format(Strings::MapViewOfFileFailed, ::GetLastError(), _stationId));

   // warm-up the view by touching every page
   Int32 dummy = 0;
   for( int i = 0; i < _szInBytes / sizeof(Int32); i+= 1024 ) {
      dummy ^=  _pData[i];
   }
   // return the dummy value to prevent the optimizer from removing the apparently useless loop
   _isOpen = true;
   return dummy;
}

void StationHashStorage::Cleanup() {
     if( !_disposed ) {
      // dispose unmanaged resources here
      if( _pData ) {
         if( !UnmapViewOfFile(_pData) ) 
            LOG_ERROR(Strings::UnmapViewOfFileFailed, ::GetLastError(), _stationId);
         _pData = NULL;
      }

      if( _hMapping ) {
         if( !CloseHandle(_hMapping) ) 
            LOG_ERROR(Strings::CloseMappingFailed, ::GetLastError(), _stationId);
         _hMapping = NULL;
      }


      if( _hFile ) {
         if( !CloseHandle(_hFile) ) 
            LOG_ERROR(Strings::CloseFileFailed, ::GetLastError(), _stationId);
         _hFile = NULL;
      }
      _disposed = true;
   }
}

现在,关于您的 real 问题。您是否有可能将生成的ID嵌入到数据流中? 我的想法是这样的:

  1. 用一个虚拟的已知值(也许是0xffffffff)预写存储器的全部内容。

  2. 使用当前的容量检查原子逻辑。

  3. 写入消息有效负载后,您立即写入计算出的消息ID(您的容量检查将需要考虑此额外数据)

  4. 而不是使用Interlocked.Add来获取下一个ID,您将进入一个循环,该循环检查当前消息(先前消息ID)之前的 ,直到与您的消息不同为止。虚拟已知值。退出循环后,当前消息ID将为读取值+ 1。

这将需要对第一个插入的消息进行一些特殊的操作(因为它需要在流中播种第一个Id标记。您还需要小心(如果您使用的是长Id并且处于32位模式) ),您的ID流读写是原子的。

祝您好运,我真的鼓励您尝试一下Win32 API,找出希望有所改善的事情将非常有趣!如果您需要有关C ++ / CLI代码的帮助,请随时与我联系。