首页 > 解决方案 > 虽然更新并发字典中的值最好锁定字典或值

问题描述

我正在对从 TryGet 获得的值执行两次更新我想知道其中哪个更好?

选项 1:只锁定价值?

if (HubMemory.AppUsers.TryGetValue(ConID, out OnlineInfo onlineinfo))
{
    lock (onlineinfo)
    {
        onlineinfo.SessionRequestId = 0;
        onlineinfo.AudioSessionRequestId = 0;
        onlineinfo.VideoSessionRequestId = 0;
    }
}

选项 2:锁定整个字典?

if (HubMemory.AppUsers.TryGetValue(ConID, out OnlineInfo onlineinfo))
{
    lock (HubMemory.AppUsers)
    {
        onlineinfo.SessionRequestId = 0;
        onlineinfo.AudioSessionRequestId = 0;
        onlineinfo.VideoSessionRequestId = 0;
    }
}

标签: c#signalrconcurrentdictionary

解决方案


我将建议一些不同的东西。

首先,您应该在字典中存储不可变类型以避免大量线程问题。事实上,任何代码都可以通过从中检索项目并更改其属性来修改字典中任何项目的内容。

其次,ConcurrentDictionary提供TryUpdate()允许您更新字典中的值而无需实现显式锁定的方法。

TryUpdate()需要三个参数:要更新的项目的键,更新的项目和你从字典中得到然后更新的原始项目。

TryUpdate()然后通过将字典中当前的值与您传递给它的原始值进行比较来检查原始值是否未更新。只有当它是 SAME 时,它才会真正用新值更新它并返回true。否则它会返回false而不更新它。

这允许您检测并适当地响应某些其他线程在您更新它时更改了您正在更新的项目的值的情况。您可以忽略这一点(在这种情况下,第一个更改优先)或重试直到成功(在这种情况下,最后一个更改优先)。你做什么取决于你的情况。

请注意,这需要您的类型实现IEquatable<T>,因为它用于ConcurrentDictionary比较值。

这是一个示例控制台应用程序,演示了这一点:

using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;

namespace Demo
{
    sealed class Test: IEquatable<Test>
    {
        public Test(int value1, int value2, int value3)
        {
            Value1 = value1;
            Value2 = value2;
            Value3 = value3;
        }

        public Test(Test other) // Copy ctor.
        {
            Value1 = other.Value1;
            Value2 = other.Value2;
            Value3 = other.Value3;
        }

        public int Value1 { get; }
        public int Value2 { get; }
        public int Value3 { get; }

        #region IEquatable<Test> implementation (generated using Resharper)

        public bool Equals(Test other)
        {
            if (other is null)
                return false;

            if (ReferenceEquals(this, other))
                return true;

            return Value1 == other.Value1 && Value2 == other.Value2 && Value2 == other.Value3;
        }

        public override bool Equals(object obj)
        {
            return ReferenceEquals(this, obj) || obj is Test other && Equals(other);
        }

        public override int GetHashCode()
        {
            unchecked
            {
                return (Value1 * 397) ^ Value2;
            }
        }

        public static bool operator ==(Test left, Test right)
        {
            return Equals(left, right);
        }

        public static bool operator !=(Test left, Test right)
        {
            return !Equals(left, right);
        }

        #endregion
    }

    static class Program
    {
        static void Main()
        {
            var dict = new ConcurrentDictionary<int, Test>();

            dict.TryAdd(0, new Test(1000, 2000, 3000));
            dict.TryAdd(1, new Test(4000, 5000, 6000));
            dict.TryAdd(2, new Test(7000, 8000, 9000));

            Parallel.Invoke(() => update(dict), () => update(dict));
        }

        static void update(ConcurrentDictionary<int, Test> dict)
        {
            for (int i = 0; i < 100000; ++i)
            {
                for (int attempt = 0 ;; ++attempt)
                {
                    var original  = dict[0];
                    var modified  = new Test(original.Value1 + 1, original.Value2 + 1, original.Value3 + 1);
                    var updatedOk = dict.TryUpdate(1, modified, original);

                    if (updatedOk) // Updated OK so don't try again.
                        break;     // In some cases you might not care, so you would never try again.

                    Console.WriteLine($"dict.TryUpdate() returned false in iteration {i} attempt {attempt} on thread {Thread.CurrentThread.ManagedThreadId}");
                }
            }
        }
    }
}

那里有很多样板代码来支持IEquatable<T>实现并支持不变性。

幸运的是,C# 9 引入了record使不可变类型更容易实现的类型。这是使用 a 的相同示例控制台应用程序record。请注意,record类型是不可变的,也可以IEquality<T>为您实现:

using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;

namespace System.Runtime.CompilerServices // Remove this if compiling with .Net 5
{                                         // This is to allow earlier versions of .Net to use records.
    class IsExternalInit {}
}

namespace Demo
{
    record Test(int Value1, int Value2, int Value3);

    static class Program
    {
        static void Main()
        {
            var dict = new ConcurrentDictionary<int, Test>();

            dict.TryAdd(0, new Test(1000, 2000, 3000));
            dict.TryAdd(1, new Test(4000, 5000, 6000));
            dict.TryAdd(2, new Test(7000, 8000, 9000));

            Parallel.Invoke(() => update(dict), () => update(dict));
        }

        static void update(ConcurrentDictionary<int, Test> dict)
        {
            for (int i = 0; i < 100000; ++i)
            {
                for (int attempt = 0 ;; ++attempt)
                {
                    var original  = dict[0];

                    var modified  = original with
                    {
                        Value1 = original.Value1 + 1,
                        Value2 = original.Value2 + 1,
                        Value3 = original.Value3 + 1
                    };

                    var updatedOk = dict.TryUpdate(1, modified, original);

                    if (updatedOk) // Updated OK so don't try again.
                        break;     // In some cases you might not care, so you would never try again.

                    Console.WriteLine($"dict.TryUpdate() returned false in iteration {i} attempt {attempt} on thread {Thread.CurrentThread.ManagedThreadId}");
                }
            }
        }
    }
}

record Test请注意与 相比要短多少class Test,即使它提供相同的功能。(另请注意,我添加class IsExternalInit了允许记录与 .Net 5 之前的 .Net 版本一起使用。如果您使用的是 .Net 5,则不需要。)

最后,请注意,您不需要使您的类不可变。如果您的类是可变的,我为第一个示例发布的代码将运行良好;它只是不会阻止其他代码破坏事物。


附录 1:

您可能会查看输出并想知道为什么在TryUpdate()失败时会进行如此多的重试尝试。您可能希望它只需要重试几次(取决于有多少线程同时尝试修改数据)。这个问题的答案很简单,因为这Console.WriteLine()需要很长时间,以至于在我们写入控制台时,其他线程更有可能再次更改字典中的值。

我们可以稍微更改代码以仅打印循环外的尝试次数,如下所示(修改第二个示例):

static void update(ConcurrentDictionary<int, Test> dict)
{
    for (int i = 0; i < 100000; ++i)
    {
        int attempt = 0;
        
        while (true)
        {
            var original  = dict[1];

            var modified  = original with
            {
                Value1 = original.Value1 + 1,
                Value2 = original.Value2 + 1,
                Value3 = original.Value3 + 1
            };

            var updatedOk = dict.TryUpdate(1, modified, original);

            if (updatedOk) // Updated OK so don't try again.
                break;     // In some cases you might not care, so you would never try again.

            ++attempt;
        }

        if (attempt > 0)
            Console.WriteLine($"dict.TryUpdate() took {attempt} retries in iteration {i} on thread {Thread.CurrentThread.ManagedThreadId}");
    }
}

通过此更改,我们看到重试尝试的次数显着下降。TryUpdate()这表明了尽量减少尝试之间的代码时间量的重要性。


附录 2:

正如下面 Theodor Zoulias 所指出的,您也可以使用ConcurrentDictionary<TKey,TValue>.AddOrUpdate(),如下面的示例所示。这可能是一种更好的方法,但有点难以理解:

static void update(ConcurrentDictionary<int, Test> dict)
{
    for (int i = 0; i < 100000; ++i)
    {
        int attempt = 0;
        
        dict.AddOrUpdate(
            1,                        // Key to update.
            key => new Test(1, 2, 3), // Create new element; won't actually be called for this example.
            (key, existing) =>        // Update existing element. Key not needed for this example.
            {
                ++attempt;

                return existing with
                {
                    Value1 = existing.Value1 + 1,
                    Value2 = existing.Value2 + 1,
                    Value3 = existing.Value3 + 1
                };
            }
        );

        if (attempt > 1)
            Console.WriteLine($"dict.TryUpdate() took {attempt-1} retries in iteration {i} on thread {Thread.CurrentThread.ManagedThreadId}");
    }
}

推荐阅读