简体   繁体   English

如何为每个异步操作创建一个新上下文并以线程安全的方式使用它来存储一些数据

[英]How to create a new context per every async operation and use it in a thread-safe manner for storing some data

I'm working on something like a context-bound caching and a little bit stuck on thread-safety...我正在研究诸如上下文绑定缓存之类的东西,并且有点卡在线程安全上...

Let's say I have the following code:假设我有以下代码:

public class AsynLocalContextualCacheAccessor : IContextualCacheAccessor
{
    private static readonly AsyncLocal<CacheScopesManager> _rCacheContextManager = new AsyncLocal<CacheScopesManager>();

    public AsynLocalContextualCacheAccessor()
    {
    }

    public CacheScope Current
    { 
        get
        {
            if (_rCacheContextManager.Value == null)
                _rCacheContextManager.Value = new CacheScopesManager();

            return _rCacheContextManager.Value.Current;
        }
    } 
}

public class CacheScopesManager
{
    private static readonly AsyncLocal<ImmutableStack<CacheScope>> _scopesStack = new AsyncLocal<ImmutableStack<CacheScope>>(OnValueChanged);

    public CacheScopesManager()
    {
        CacheScope contextualCache = _NewScope();

        _scopesStack.Value = ImmutableStack.Create<CacheScope>();
        _scopesStack.Value = _scopesStack.Value.Push(contextualCache);
    }

    public CacheScope Current
    {
        get
        {
            if (_scopesStack.Value.IsEmpty)
                return null;

            CacheScope current = _scopesStack.Value.Peek();
            if (current.IsDisposed)
            {
                _scopesStack.Value = _scopesStack.Value.Pop();
                return Current;
            }

            // Create a new scope if we entered the new physical thread in the same logical thread
            // in order to update async local stack and automatically have a new scope per every logically new operation
            int currentThreadId = Thread.CurrentThread.ManagedThreadId;
            if (currentThreadId != current.AcquiredByThread)
            {
                current = _NewScope();
                _scopesStack.Value = _scopesStack.Value.Push(current);
            }

            return current;
        }
    }

    private static void OnValueChanged(AsyncLocalValueChangedArgs<ImmutableStack<CacheScope>> args)
    {
        // Manual is not interesting to us.
        if (!args.ThreadContextChanged)
            return;

        ImmutableStack<CacheScope> currentStack = args.CurrentValue;
        ImmutableStack<CacheScope> previousStack = args.PreviousValue;

        int threadId = Thread.CurrentThread.ManagedThreadId; 

        int threadIdCurrent = args.CurrentValue?.Peek().AcquiredByThread ?? -1;
        int threadIdPrevious = args.PreviousValue?.Peek().AcquiredByThread ?? -1;

        // Be sure in disposing of the scope
        // This situation means a comeback of the previous execution context, in case if in the previous scope Current was used.
        if (currentStack != null && previousStack != null
            && currentStack.Count() > previousStack.Count())
            currentStack.Peek().Dispose();
    }
}

And I'm trying to satisfy the next test:我正在努力满足下一个测试:

    [TestMethod]
    [TestCategory(TestCategoryCatalogs.UnitTest)]
    public async Task AsyncLocalCacheManagerAccessor_request_that_processed_by_more_than_by_one_thread_is_threadsafe()
    {
        IContextualCacheAccessor asyncLocalAccessor = new AsynLocalContextualCacheAccessor();
        Task requestAsyncFlow = Task.Run(async () =>
        {
            string key1 = "key1";
            string value1 = "value1";

            string key2 = "key2";
            string value2 = "value2";

            CacheScope scope1 = asyncLocalAccessor.Current;

            string initialKey = "k";
            object initialVal = new object();

            scope1.Put(initialKey, initialVal);
            scope1.TryGet(initialKey, out object result1).Should().BeTrue();
            result1.Should().Be(initialVal);

            var parallel1 = Task.Run(async () =>
            {
                await Task.Delay(5);
                var cache = asyncLocalAccessor.Current;
                cache.TryGet(initialKey, out object result2).Should().BeTrue();
                result2.Should().Be(initialVal);

                cache.Put(key1, value1);
                await Task.Delay(10);

                cache.Items.Count.Should().Be(1);
                cache.TryGet(key1, out string result11).Should().BeTrue();
                result11.Should().Be(value1);
            });

            var parallel2 = Task.Run(async () =>
            {
                await Task.Delay(2);
                var cache = asyncLocalAccessor.Current;

                cache.StartScope();

                cache.TryGet(initialKey, out object result3).Should().BeTrue();
                result3.Should().Be(initialVal);

                cache.Put(key2, value2);
                await Task.Delay(15);

                cache.Items.Count.Should().Be(1);
                cache.TryGet(key2, out string result21).Should().BeTrue();
                result21.Should().Be(value2);
            });

            await Task.WhenAll(parallel1, parallel2);

            // Here is an implicit dependency from Synchronization Context, and in most cases
            // the next code will be handled by a new thread, that will cause a creation of a new scope,
            // as well as for any other await inside any async operation,  which is quite bad:( 

            asyncLocalAccessor.Current.Items.Count.Should().Be(1);
            asyncLocalAccessor.Current.TryGet(initialKey, out object result4).Should().BeTrue();
            result4.Should().Be(initialVal);
        });

        await requestAsyncFlow;

        asyncLocalAccessor.Current.Items.Count.Should().Be(0);
    }

And actually this test is green, but there is one (or more) problem.实际上这个测试是绿色的,但是有一个(或多个)问题。 So, what I'm trying to achieve, is to create a stack of scopes per every new async operation (if the current scope was accessed) and when this operation is finished I need to successfully come back to the previous stack.所以,我想要实现的是,为每个新的异步操作创建一个范围堆栈(如果当前的 scope 被访问),当这个操作完成时,我需要成功返回到前一个堆栈。 I have done this based on a current thread ID (because I didn't find any other way how to do that automatically, but I don't like my solution in any way), but if continuation of async operation was executed not in the initial thread (implicit dependency from current SynchronizationContext ), but in any other, then this causes the creation of a new scope, which is very bad, as for me.我已经根据当前线程 ID 完成了此操作(因为我没有找到任何其他方法来自动执行此操作,但我不喜欢我的解决方案),但是如果继续执行异步操作不在初始线程(来自当前SynchronizationContext的隐式依赖),但在任何其他情况下,这都会导致创建一个新的 scope,这对我来说非常糟糕。

I would be glad if someone could suggest how to do that correctly, big thanks: :)如果有人能建议如何正确地做到这一点,我会很高兴,非常感谢::)

UPD 1. Code updated in order to add static for every AsyncLocal field since the value of every AsyncLocal is acquired from ExecutionContext.GetLocalValue() which is static, so non-static AsyncLocal just a redundant memory pressure. UPD 1. 更新代码以便为每个AsyncLocal字段添加static ,因为每个AsyncLocal的值是从ExecutionContext.GetLocalValue()获取的,即 static,因此非静态 AsyncLocal 只是一个冗余 static 压力。

UPD 2. Thanks, @weichch for the answer, since comment could be big, I just added additional info directly to the question. UPD 2. 谢谢@weichch 的回答,因为评论可能很大,我只是直接在问题中添加了附加信息。 So, in my case logic with AsyncLocal stuff encapsulated, and what client of my code can do - it only invokes Current on IContextualCacheAccessor , which will get the instance of an object under AsyncLocal<CacheScopesManager> , AsyncLocal is used here just to have one instance of CacheScopesManager per logical request and share it across this request, similar to IoC-Container scoped lifecycle, but the lifecycle of such object is defined from the creation of the object until the end of the async flow where this object was created.因此,在我的案例逻辑中,封装了AsyncLocal东西,以及我的代码的客户端可以做什么 - 它只调用IContextualCacheAccessor上的Current ,这将获得AsyncLocal<CacheScopesManager>下的 object 的实例,这里使用AsyncLocal只是为了拥有一个实例每个逻辑请求的CacheScopesManager并在此请求中共享它,类似于 IoC 容器范围的生命周期,但此类 object 的生命周期是从创建 object 到创建此 ZA8CFDE6331BD49EB2ACZ96B8 的异步流程结束定义的。 Or let's think about ASP NET Core where we have IHttpContext , IHttpContext does not seem to be immutable, but is still used as AsyncLocal through IHttpContextAccessor , isn't it?或者让我们想想我们有IHttpContext的 ASP NET Core , IHttpContext似乎不是不可变的,但仍然通过IHttpContextAccessor AsyncLocal不是吗? Similar to this way CacheScopesManager was designed.类似于这种方式CacheScopesManager的设计。

So, if client code, to get current CacheScope , can only invoke Current on IContextualCacheAccessor , then in case of AsyncLocal implementation of IContextualCacheAccessor call stack will fall into next code:因此,如果客户端代码要获取当前的CacheScope ,只能在IContextualCacheAccessor上调用Current ,那么如果IContextualCacheAccessor调用堆栈的AsyncLocal实现将落入下一个代码:

public CacheScope Current
{
    get
    {
        if (_scopesStack.Value.IsEmpty)
            return null;

        CacheScope current = _scopesStack.Value.Peek();
        if (current.IsDisposed)
        {
            _scopesStack.Value = _scopesStack.Value.Pop();
            return Current;
        }

        // Create a new scope if we entered the new physical thread in the same logical thread
        // in order to update async local stack and automatically have a new scope per every logically new operation
        int currentThreadId = Thread.CurrentThread.ManagedThreadId;
        if (currentThreadId != current.AcquiredByThread)
        {
            current = _NewScope();
            _scopesStack.Value = _scopesStack.Value.Push(current);
        }

        return current;
    }
}

and if another thread decided to use Current , this will cause the creation of new scope, and since ImmutableStack<CacheScope> is 'AsyncLocal', we are saving the stack of the previous async flow from any changes, which means that when we return to it, the stack will be fine without any corruptions (for sure, if hacks were not used).如果另一个线程决定使用Current ,这将导致新的 scope 的创建,并且由于ImmutableStack<CacheScope>是 'AsyncLocal',我们正在保存先前异步流的堆栈以防止任何更改,这意味着当我们返回到它,堆栈会很好,没有任何损坏(当然,如果没有使用黑客)。 All this was done to make the stack of scopes threadsafe, rather than a real 'AsyncLocal'.所有这些都是为了使范围堆栈线程安全,而不是真正的“AsyncLocal”。 So, your code所以,你的代码

async Task Method1()
{
    Cache.Push(new CacheScope { Value = "Method1" });

    await Task.WhenAll(Method2(), Method3());

    Cache.Pop();
}

async Task Method2()
{
    await Task.Delay(10);

    var scope = Cache.CurrentStack.Peek();
    scope.Value = "Method2";

    Console.WriteLine($"Method2 - {scope.Value}");
}

async Task Method3()
{
    await Task.Delay(10);

    var scope = Cache.CurrentStack.Peek();

    Console.WriteLine($"Method3 - {scope.Value}");
}

in case if my accessor is used, will not cause mutation in the async flow which will be reflected in another one (and adding data to the scope of the previous async flow, before thread switching - is fine for me).如果使用我的访问器,则不会导致异步流中的突变,这将反映在另一个流中(并在线程切换之前将数据添加到前一个异步流的 scope - 对我来说很好)。 But there is one problem, actually, the purpose of this CacheScope is to have some storage that goes across the logical request and caches some data, and this data is scoped to CacheScope and will be popped up from the referencable memory, as soon as the scope will end.但是有一个问题,其实这个CacheScope的目的是为了有一些存储,跨越逻辑请求,缓存一些数据,而这些数据的作用域是CacheScope ,会从可引用的memory中弹出,只要scope 将结束。 And I want to minimize the creation of such scopes, which means that if code was executed sequentially, there should not be any reasons to create a new scope, even if continuation of some async operation has happened on another thread because logically code is still 'sequential' and it's ok to share the same scope per such 'sequential' code.而且我想尽量减少此类范围的创建,这意味着如果代码是按顺序执行的,则不应该有任何理由创建新的 scope,即使在另一个线程上发生了一些异步操作的继续,因为逻辑上的代码仍然是 '顺序的”,并且可以按照这样的“顺序”代码共享相同的 scope。 Please, correct me if I wrong somewhere.如果我在某个地方错了,请纠正我。

But your answer and explanation are really useful and for sure will protect others from making mistakes.但是您的回答和解释确实很有用,并且肯定会保护其他人免于犯错。 Also, it helped me to understand what Stephen meant under:此外,它帮助我理解了斯蒂芬的意思:

If you do go down this route, I recommend writing lots and lots of unit tests.如果您按照这条路线执行 go,我建议您编写大量的单元测试。

my English is poor and I thought that 'route' means 'link to the article', now understand that it's is rather 'way' in that context.我的英语很差,我认为“路线”的意思是“链接到文章”,现在明白在这种情况下它是相当“方式”。

UPD 3. Added some code of CacheScope for a better picture. UPD 3. 添加了一些CacheScope代码以获得更好的图片。

public class CacheScope : IDisposableExtended
{
    private ICacheScopesManager _scopeManager;
    private CacheScope _parentScope;

    private Dictionary<string, object> _storage = new Dictionary<string, object>();


    internal CacheScope(Guid id, int boundThreadId, ICacheScopesManager scopeManager, 
        CacheScope parentScope)
    {
        _scopeManager = scopeManager.ThrowIfArgumentIsNull(nameof(scopeManager));

        Id = id;
        AcquiredByThread = boundThreadId;

        _parentScope = parentScope;
    }

    public Guid Id { get; }

    public int AcquiredByThread { get; }

    public IReadOnlyCollection<object> Items => _storage?.Values;

    public bool IsDisposed { get; private set; } = false;

    public bool TryExpire<TItem>(string key, out TItem expiredItem)
    {
        _AssertInstanceIsDisposed();

        key.ThrowIfArgumentIsNull(nameof(key));

        expiredItem = default(TItem);

        try
        {
            expiredItem = (TItem)_storage[key];
        }
        catch (KeyNotFoundException)
        {
            // Even if item is present in parent scope it cannot be expired from inner scope.
            return false;
        }

        _storage.Remove(key);

        return true;
    }


    public TItem GetOrPut<TItem>(string key, Func<string, TItem> putFactory)
    {
        _AssertInstanceIsDisposed();

        key.ThrowIfArgumentIsNull(nameof(key));
        putFactory.ThrowIfArgumentIsNull(nameof(putFactory));

        TItem result;

        try
        {
            result = (TItem)_storage[key];
        }
        catch (KeyNotFoundException)
        {
            if (_parentScope != null && _parentScope.TryGet(key, out result))
                return result;

            result = putFactory(key);

            _storage.Add(key, result);
        }

        return result;
    }

    public void Put<TItem>(string key, TItem item)
    {
        _AssertInstanceIsDisposed();

        key.ThrowIfArgumentIsNull(nameof(key));

        _storage[key] = item;

        // We are not even thinking about to change the parent scope here,
        // because parent scope should be considered by current as immutable.
    }

    public bool TryGet<TItem>(string key, out TItem item)
    {
        _AssertInstanceIsDisposed();

        key.ThrowIfArgumentIsNull(nameof(key));

        item = default(TItem);

        try
        {
            item = (TItem)_storage[key];
        }
        catch (KeyNotFoundException)
        {
            return _parentScope != null && _parentScope.TryGet(key, out item);
        }

        return true;
    }

    public void Dispose()
    {
        if (IsDisposed)
            return;

        Dictionary<string, object> localStorage = Interlocked.Exchange(ref _storage, null);
        if (localStorage == null)
        {
            // that should never happen but Dispose in general is expected to be safe to call so... let's obey the rules
            return;
        }

        foreach (var item in localStorage.Values)
            if (item is IDisposable disposable)
                disposable.Dispose();

        _parentScope = null;
        _scopeManager = null;

        IsDisposed = true;
    }

    public CacheScope StartScope() => _scopeManager.CreateScope(this);
}

Your code is really fighting the way AsyncLocal<T> works.您的代码确实与AsyncLocal<T>的工作方式作斗争。 Setting in a getter, trying to manage the scopes manually, having an async local manager for an async local type, and the code using the change handler are all problematic.在 getter 中设置、尝试手动管理范围、为异步本地类型设置异步本地管理器以及使用更改处理程序的代码都是有问题的。

I believe all this is really to try to deal with the fact that CacheScope isn't immutable.我相信所有这一切都是为了尝试处理CacheScope不是不可变的事实。 The best way to solve this is to make CacheScope a proper immutable object.解决此问题的最佳方法是使CacheScope成为适当的不可变 object。 Then everything else will fall into place more or less naturally.然后其他一切都会或多或少自然地到位。

I find it's often easier to write a separate static API for immutable objects which is more "async local-friendly".我发现为更“异步本地友好”的不可变对象编写单独的static API 通常更容易。 Eg:例如:

public class ImplicitCache
{
  private static readonly AsyncLocal<ImmutableStack<(string, object)>> _asyncLocal = new AsyncLocal<ImmutableStack<(string, object)>>();

  private static ImmutableStack<(string, object)> CurrentStack
  {
    get => _asyncLocal.Current ?? ImmutableStack.Create<ImmutableDictionary<string, object>>();
    set => _asyncLocal.Current = value.IsEmpty ? null : value;
  }

  // Separate API:

  public static IDisposable Put(string key, object value)
  {
    if (key == null)
      throw new InvalidOperationException();
    CurrentStack = CurrentStack.Push((key, value));
    return new Disposable(() => CurrentStack = CurrentStack.Pop());
  }

  public static bool TryGet(string key, out object value)
  {
    var result = CurrentStack.Reverse().FirstOrDefault(x => x.Item1 == key);
    value = result.Item2;
    return result.Item1 != null;
  }
}

Usage:用法:

public async Task AsyncLocalCacheManagerAccessor_request_that_processed_by_more_than_by_one_thread_is_threadsafe()
{
  Task requestAsyncFlow = Task.Run(async () =>
  {
    string key1 = "key1";
    string value1 = "value1";

    string key2 = "key2";
    string value2 = "value2";

    string initialKey = "k";
    object initialVal = new object();

    using var dispose1 = ImplicitCache.Put(initialKey, initialVal);
    ImplicitCache.TryGet(initialKey, out object result1).Should().BeTrue();
    result1.Should().Be(initialVal);

    var parallel1 = Task.Run(async () =>
    {
      await Task.Delay(5);
      ImplicitCache.TryGet(initialKey, out object result2).Should().BeTrue();
      result2.Should().Be(initialVal);

      using var dispose2 = ImplicitCache.Put(key1, value1);
      await Task.Delay(10);

      ImplicitCache.TryGet(key1, out string result11).Should().BeTrue();
      result11.Should().Be(value1);
    });

    var parallel2 = Task.Run(async () =>
    {
      await Task.Delay(2);

      ImplicitCache.TryGet(initialKey, out object result3).Should().BeTrue();
      result3.Should().Be(initialVal);

      using var disose3 = ImplicitCache.Put(key2, value2);
      await Task.Delay(15);

      ImplicitCache.TryGet(key2, out string result21).Should().BeTrue();
      result21.Should().Be(value2);
    });

    await Task.WhenAll(parallel1, parallel2);

    ImplicitCache.TryGet(initialKey, out object result4).Should().BeTrue();
    result4.Should().Be(initialVal);
  });

  await requestAsyncFlow;

  ImplicitCache.TryGet(initialKey, out _).Should().BeFalse();
}

I think @StephenCleary is not saying mutable CacheScope is incorrect, but semantically incorrect, meaning using mutable CacheScope may violate the purpose of AsyncLocal<T> being used in your cache.我认为@StephenCleary 并不是说 mutable CacheScope不正确,而是在语义上不正确,这意味着使用 mutable CacheScope可能会违反缓存中使用AsyncLocal<T>的目的。

AsyncLocal<T> is designed for providing access to ambient data which is local to an async control flow. AsyncLocal<T>旨在提供对异步控制流本地的环境数据的访问。 Using mutable data type in AsyncLocal<T> could make such local data escape its scope.AsyncLocal<T>中使用可变数据类型可以使此类本地数据逃脱其 scope。

For example, consider this例如,考虑这个

static class Cache
{
    private static AsyncLocal<ImmutableStack<CacheScope>> StackValue 
        = new AsyncLocal<ImmutableStack<CacheScope>>();

    public static ImmutableStack<CacheScope> CurrentStack
    {
        get => StackValue.Value;
        set => StackValue.Value = value;
    }

    public static void Push(CacheScope scope)
    {
        CurrentStack = CurrentStack.Push(scope);
    }

    public static CacheScope Peek()
    {
        return CurrentStack.Peek();
    }

    public static void Pop()
    {
        CurrentStack = CurrentStack.Pop();
    }
}

Assuming there are two methods:假设有两种方法:

async Task Method1()
{
    // Push scope where Value = Method1
    Cache.Push(new CacheScope { Value = "Method1" });

    // Call method2
    await Method2();

    // Unexpected: value = Method2 
    var value = Cache.CurrentStack.Peek().Value;

    Cache.Pop();
}

async Task Method2()
{
    await Task.Delay(10);

    var scope = Cache.CurrentStack.Peek();
    scope.Value = "Method2";
}

Method2 is a new async flow which has its own logical call context copied from Method1 . Method2是一个新的异步流,它具有从Method1复制的自己的逻辑调用上下文。 However, because the copy is a shallow copy, the two contexts will share the same CacheScope instances in the ImmutableStack .但是,由于副本是浅副本,因此两个上下文将共享ImmutableStack中的相同CacheScope实例。 Mutations made in Method2 could be unexpectedly reflected into Method1 . Method2中的突变可能会意外地反映到Method1中。

And remember, we could also do fork/join :请记住,我们也可以执行fork/join

async Task Method1()
{
    Cache.Push(new CacheScope { Value = "Method1" });

    await Task.WhenAll(Method2(), Method3());

    Cache.Pop();
}

async Task Method2()
{
    await Task.Delay(10);

    var scope = Cache.CurrentStack.Peek();
    scope.Value = "Method2";

    Console.WriteLine($"Method2 - {scope.Value}");
}

async Task Method3()
{
    await Task.Delay(10);

    var scope = Cache.CurrentStack.Peek();

    Console.WriteLine($"Method3 - {scope.Value}");
}

You could see two sets of results:您可以看到两组结果:

Method3 - Method1
Method2 - Method2

and

Method3 - Method2
Method2 - Method2

The mutation made in Method2 unexpectedly flows into Method3 . Method3 Method2

This kind of unexpected behavior could be problematic to your cache.这种意外行为可能会给您的缓存带来问题。 It would be really hard to locate bugs related to the implicit context when the call stack is fairly big, which is why Stephen suggested:当调用堆栈相当大时,很难找到与隐式上下文相关的错误,这就是 Stephen 建议的原因:

If you do go down this route, I recommend writing lots and lots of unit tests.如果您按照这条路线执行 go,我建议您编写大量的单元测试。

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM