Skip to content

Conversation

@bitfaster
Copy link
Owner

@bitfaster bitfaster commented Jun 29, 2020

By caching Lazy, we can easily guarantee a single invocation of the value create logic. However, the standard Lazy implementation has some drawbacks:

  1. It caches exceptions, and there is no way turn this off. This isn't how ConcurrentDictionary.GetOrAdd works (only values that are able to be created are stored).
  2. The Func value factory is cached until the value is created or fails to create. This means that even if problem 1 were solved, in the case where the factory relied on some 'per invocation' state like a local connection used to query a database, subsequent invocations from other threads could fail if the database connection was closed.
  3. It does not handle IDisposable values.
  4. There is no built in AsyncLazy.

This PR introduces a family of Atomic classes that guarantee atomic value creation, do not cache exceptions, handle IDisposable, async value creation and async creation of IDisposable values.

@bitfaster bitfaster changed the title Async lazy Implement scopes for Lazy<T> and AsyncLazy<T> Jun 29, 2020
@bitfaster bitfaster marked this pull request as draft November 8, 2021 17:16
@bitfaster bitfaster changed the title Implement scopes for Lazy<T> and AsyncLazy<T> Atomic value creation and scoped atomic creation Nov 9, 2021
@bitfaster bitfaster linked an issue Nov 9, 2021 that may be closed by this pull request
@bitfaster bitfaster changed the title Atomic value creation and scoped atomic creation Atomic value creation Nov 9, 2021
// if value !created, no dispose, cannot create - throws object disposed exception
// if created, dispose value
[DebuggerDisplay("IsValueCreated={IsValueCreated}, Value={ValueIfCreated}")]
public class DisposableAtomic<K, V> : IDisposable where V : IDisposable
Copy link
Owner Author

@bitfaster bitfaster Nov 9, 2021

Choose a reason for hiding this comment

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

Could also create a ScopedLazy, allowing the user to substitute in Lazy behavior if preferred to Atomic.

This would work the same way - check the value is created property when disposing scope.

namespace BitFaster.Caching
{
[DebuggerDisplay("IsValueCreated={IsValueCreated}, Value={ValueIfCreated}")]
public class AsyncAtomic<K, V>
Copy link
Owner Author

Choose a reason for hiding this comment

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

This would support both sync and async.

Sync adds are slightly penalized vs pure Lazy because Initializer.CreateValue creates a TCS, which internally creates a Task. These are both pretty lightweight - likely cheap compared to value factory.

namespace BitFaster.Caching
{
    [DebuggerDisplay("IsValueCreated={IsValueCreated}, Value={ValueIfCreated}")]
    public class AsyncAtomic<K, V>
    {
        private Initializer initializer;

        [DebuggerBrowsable(DebuggerBrowsableState.Never)]
        private V value;

        public AsyncAtomic()
        {
            this.initializer = new Initializer();
        }

        public AsyncAtomic(V value)
        {
            this.value = value;
        }

        public V GetValue(K key, Func<K, V> valueFactory)
        {
            if (this.initializer == null)
            {
                return this.value;
            }

            return CreateValue(key, valueFactory);
        }

        public async Task<V> GetValueAsync(K key, Func<K, Task<V>> valueFactory)
        {
            if (this.initializer == null)
            {
                return this.value;
            }

            return await CreateValueAsync(key, valueFactory).ConfigureAwait(false);
        }

        public bool IsValueCreated => this.initializer == null;

        public V ValueIfCreated
        {
            get
            {
                if (!this.IsValueCreated)
                {
                    return default;
                }

                return this.value;
            }
        }

        private V CreateValue(K key, Func<K, V> valueFactory)
        {
            Initializer init = this.initializer;

            if (init != null)
            {
                this.value = init.CreateValue(key, valueFactory);
                this.initializer = null;
            }

            return this.value;
        }

        private async Task<V> CreateValueAsync(K key, Func<K, Task<V>> valueFactory)
        {
            Initializer init = this.initializer;

            if (init != null)
            {
                this.value = await init.CreateValueAsync(key, valueFactory).ConfigureAwait(false);
                this.initializer = null;
            }

            return this.value;
        }

        private class Initializer
        {
            private object syncLock = new object();
            private bool isInitialized;
            private Task<V> valueTask;

            public V CreateValue(K key, Func<K, V> valueFactory)
            {
                var tcs = new TaskCompletionSource<V>(TaskCreationOptions.RunContinuationsAsynchronously);

                var synchronizedTask = Synchronized.Initialize(ref this.valueTask, ref isInitialized, ref syncLock, tcs.Task);

                if (ReferenceEquals(synchronizedTask, tcs.Task))
                {
                    try
                    {
                        var value = valueFactory(key);
                        tcs.SetResult(value);
                        return value;
                    }
                    catch (Exception ex)
                    {
                        Volatile.Write(ref isInitialized, false);
                        tcs.SetException(ex);
                        throw;
                    }
                }

                // TODO: how dangerous is this?
                // it can block forever if value factory blocks
                return synchronizedTask.GetAwaiter().GetResult();
            }

            public async Task<V> CreateValueAsync(K key, Func<K, Task<V>> valueFactory)
            {
                var tcs = new TaskCompletionSource<V>(TaskCreationOptions.RunContinuationsAsynchronously);

                var synchronizedTask = Synchronized.Initialize(ref this.valueTask, ref isInitialized, ref syncLock, tcs.Task);

                if (ReferenceEquals(synchronizedTask, tcs.Task))
                {
                    try
                    {
                        var value = await valueFactory(key).ConfigureAwait(false);
                        tcs.SetResult(value);

                        return value;
                    }
                    catch (Exception ex)
                    {
                        Volatile.Write(ref isInitialized, false);
                        tcs.SetException(ex);
                        throw;
                    }
                }

                return await synchronizedTask.ConfigureAwait(false);
            }
        }
    }

    internal static class Synchronized
    {
        public static V Initialize<V>(ref V target, ref bool initialized, ref object syncLock, V value)
        {
            // Fast path
            if (Volatile.Read(ref initialized))
            {
                return target;
            }

            lock (syncLock)
            {
                if (!Volatile.Read(ref initialized))
                {
                    target = value;
                    Volatile.Write(ref initialized, true);
                }
            }

            return target;
        }
    }
}

Copy link
Owner Author

@bitfaster bitfaster Jul 9, 2022

Choose a reason for hiding this comment

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

Then use a decorator:

namespace BitFaster.Caching
{
    public class AtomicCacheDecorator<K, V> : ICache<K, V>
    {
        private readonly ICache<K, AsyncAtomic<K, V>> cache;

        public AtomicCacheDecorator(ICache<K, AsyncAtomic<K, V>> cache)
        {
            this.cache = cache;
        }

        public int Capacity => this.cache.Capacity;

        public int Count => this.cache.Count;

        public void AddOrUpdate(K key, V value)
        {
            cache.AddOrUpdate(key, new AsyncAtomic<K, V>(value));
        }

        public void Clear()
        {
            this.cache.Clear();
        }

        public V GetOrAdd(K key, Func<K, V> valueFactory)
        {
            var synchronized = cache.GetOrAdd(key, _ => new AsyncAtomic<K, V>());
            return synchronized.GetValue(key, valueFactory);
        }

        public Task<V> GetOrAddAsync(K key, Func<K, Task<V>> valueFactory)
        {
            var synchronized = cache.GetOrAdd(key, _ => new AsyncAtomic<K, V>());
            return synchronized.GetValueAsync(key, valueFactory);
        }

        public void Trim(int itemCount)
        {
            this.cache.Trim(itemCount);
        }

        public bool TryGet(K key, out V value)
        {
            AsyncAtomic<K, V> output;
            bool ret = cache.TryGet(key, out output);

            if (ret && output.IsValueCreated)
            {
                value = output.ValueIfCreated;
                return true;
            }

            value = default;
            return false;
        }

        public bool TryRemove(K key)
        {
            return this.cache.TryRemove(key);
        }

        public bool TryUpdate(K key, V value)
        {
            return cache.TryUpdate(key, new AsyncAtomic<K, V>(value)); ;
        }
    }
}

@bitfaster
Copy link
Owner Author

Replaced with the following PRs to support atomic create: #155 + #153

@bitfaster bitfaster closed this Jul 24, 2022
@bitfaster bitfaster deleted the users/alexpeck/asynclazy branch July 24, 2022 02:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a GetOrAdd that takes a value?

2 participants