Skip to content
This repository has been archived by the owner on Jan 23, 2023. It is now read-only.
/ corefx Public archive

Fix semantics of ArrayMemoryPool #27615

Merged
merged 3 commits into from
Mar 6, 2018

Conversation

pakrym
Copy link

@pakrym pakrym commented Mar 1, 2018

https://github.com/dotnet/corefx/issues/27544
https://github.com/dotnet/corefx/issues/27543

  1. Make ArrayMemoryPool block ref counting thread-safe.
  2. ArrayMemoryPool block returned from the pool has ref count == 1
  3. Releasing ArrayMemoryPool block causes it to be disposed and returned to the pool.

@pakrym pakrym requested review from davidfowl, stephentoub, KrzysztofCwalina and a user March 1, 2018 19:02
}

public sealed override bool Release()
{
if (IsDisposed)
ThrowHelper.ThrowObjectDisposedException_ArrayMemoryPoolBuffer();

int newRefCount = --_refCount;
int newRefCount = Interlocked.Decrement(ref _refCount);
Copy link
Member

@stephentoub stephentoub Mar 1, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you actually want thread-safety, these Interlocked.Decrement/Increments should be changed to CompareExchange loops, e.g.

// in Retain
while (true)
{
    int currentCount = Volatile.Read(ref _refCount);
    if (currentCount <= 0) ThrowHelper.ThrowObjectDisposedException_ArrayMemoryPoolBuffer();
    if (Interlocked.CompareExchange(ref _refCount, currentCount + 1, currentCount) == currentCount) break;
}
...
// in Release
while (true)
{
    int currentCount = Volatile.Read(ref _refCount);
    if (currentCount <= 0) ThrowHelper.ThrowObjectDisposedException_ArrayMemoryPoolBuffer();
    if (Interlocked.CompareExchange(ref _refCount, currentCount - 1, currentCount) == currentCount)
    {
        if (currentCount == 1)
        {
            Dispose();
            return true;
        }
        return false;
    }
}

Otherwise, consider this:

  1. The current _refCount is 1.
  2. Thread A calls Retain and gets past the IsDisposed check.
  3. Thread B calls Release, disposing the object, and drops the count to 0.
  4. Thread A resumes, increments the _refCount, and proceeds to party on the array thinking it successfully owns it.
  5. Lots of other threads come along, and all Retain/Release, and as the ref count is now positive again, they're all successful.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@atsushikan could you look at adding thread safety tests here?

@@ -22,7 +22,9 @@ public sealed override OwnedMemory<T> Rent(int minimumBufferSize = -1)
else if (((uint)minimumBufferSize) > s_maxBufferSize)
ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.minimumBufferSize);

return new ArrayMemoryPoolBuffer(minimumBufferSize);
var buffer = new ArrayMemoryPoolBuffer(minimumBufferSize);
buffer.Retain();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't need to incur an interlocked operation, as it's not published yet.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to do this? It changes the contract quite a bit. Can you fix pipelines in the same PR?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll fix pipelines. It's just strange to get an OwnedMemory with 0 references.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Everyone has to agree on the semantics, today our pool in kestrel does not do this so it'll result in memory leaks...

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed Retain and added dispose call to Pipelines.

@@ -75,6 +74,7 @@ public void SetMemory(OwnedMemory<byte> ownedMemory, int start, int end, bool re
public void ResetMemory()
{
_ownedMemory.Release();
_ownedMemory.Dispose();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't correct. We just need to release.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure? Pipe is the thing that got OM from the pool, so it is its responsibility to dispose it.

Copy link
Member

@KrzysztofCwalina KrzysztofCwalina Mar 2, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But even your PR descriptions says "Releasing ArrayMemoryPool block causes it to be disposed and returned to the pool." Having said that I do think it's a bit strange that we have both Release and Dispose on this type.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But calling dispose would also validate that there are no other owners left.

This is beneficial in the case of pipelines. Imagine a situation when someone reads ROS from the pipe, get's memory from it and calls Retain. Now when he tries to advance, Dispose would throw and avoid use after free bugs.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the other hand, we can decide to allow consumers to Retain memory received from pipe and calling dispose here would break this feature.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should just release and not dispose

@@ -22,7 +22,8 @@ public sealed override OwnedMemory<T> Rent(int minimumBufferSize = -1)
else if (((uint)minimumBufferSize) > s_maxBufferSize)
ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.minimumBufferSize);

return new ArrayMemoryPoolBuffer(minimumBufferSize);
var buffer = new ArrayMemoryPoolBuffer(minimumBufferSize);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can revert this piece.

@pakrym pakrym force-pushed the pakrym/memory-pool-samantics branch from 27e7b74 to 4eecbab Compare March 1, 2018 22:28
@davidfowl
Copy link
Member

Does disposing throw if the reference count is > 0?

@pakrym
Copy link
Author

pakrym commented Mar 1, 2018

Yes

@pakrym
Copy link
Author

pakrym commented Mar 1, 2018

This is a general question about OwnedMemory design: is something creates owned memory for you, should it be returned with ref count equal to one or zero?

@pakrym pakrym force-pushed the pakrym/memory-pool-samantics branch 2 times, most recently from 34779af to d5467b2 Compare March 2, 2018 00:04
}

public sealed override int Length => _array.Length;

public sealed override bool IsDisposed => _array == null;

protected sealed override bool IsRetained => _refCount > 0;
protected sealed override bool IsRetained => Volatile.Read(ref _refCount) > 0;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@stephentoub, does it need to be interlocked read?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does it need to be interlocked read?

No, it's only 32-bit, so there's no risk of tearing and Interlocked.Read isn't needed. Volatile.Read may not even be necessary depending on what it's used for, but it's unlikely to hurt and so might as well be used.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I missed that it's an int.

@pakrym
Copy link
Author

pakrym commented Mar 2, 2018

@KrzysztofCwalina why was dispose added to OwnedMemory? What was it's intended semantics?

@pakrym pakrym force-pushed the pakrym/memory-pool-samantics branch 3 times, most recently from 394b421 to efa461f Compare March 2, 2018 19:31
@KrzysztofCwalina
Copy link
Member

KrzysztofCwalina commented Mar 2, 2018

@KrzysztofCwalina why was dispose added to OwnedMemory? What was it's intended semantics?

Dispose is for "I own the memory; I am disposing it. If you (Memory) still try to access it, you will get an exception as there is a bug somewhere". Relying on just release opens the possibility of undetected leaks.

@@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information.

using System.Runtime.InteropServices;
using System.Threading;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: add new line

@@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information.

using System.Buffers;
using System.Diagnostics;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this using directive?

@pakrym
Copy link
Author

pakrym commented Mar 2, 2018

@KrzysztofCwalina

Dispose is for "I own the memory; I am disposing it. If you (Memory) still try to access it, you will get an exception as there is a bug somewhere". Relying on just release opens the possibility of undetected leaks.

So when you get memory from the pool, does it mean that you own it and have to dispose?

@davidfowl
Copy link
Member

The issue with disposing in pipelines is that you lose the ability for the pipe to transfer ownership to another pipe (Append). The original idea was that the reference count would just work because once you transfer the buffer to another pipe, it would just up the reference count and take over those segments.

@pakrym pakrym force-pushed the pakrym/memory-pool-samantics branch 2 times, most recently from 4576877 to edcf2e5 Compare March 5, 2018 19:38
@pakrym pakrym force-pushed the pakrym/memory-pool-samantics branch from edcf2e5 to 0843b65 Compare March 5, 2018 19:42
@pakrym
Copy link
Author

pakrym commented Mar 5, 2018

Okay, I removed dispose call from pipe but kept the part where OwnedMemory is returned with refCount == 1

@davidfowl
Copy link
Member

@KrzysztofCwalina speak now or forever hold your peace. This change means that if you get an owned memory from a MemoryPool<T>.Rent, you have to call Release before Dispose.

Copy link
Member

@davidfowl davidfowl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants