虽然更新并发字典中的值最好锁定字典或值

Dan*_*med 6 c# concurrentdictionary signalr

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

选项 1:只锁定价值?

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

选项 2:锁定整个字典?

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

Mat*_*son 5

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

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

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

TryUpdate()需要三个参数:要更新的项目的键、更新后的项目以及从字典中获取然后更新的原始项目。

TryUpdate()然后通过将字典中当前的值与传递给它的原始值进行比较来检查原始值是否尚未更新。仅当它相同时,它才会实际使用新值更新它并返回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}");
                }
            }
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

那里有很多样板代码来支持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}");
                }
            }
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

请注意,尽管它提供了相同的功能,但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}");
    }
}
Run Code Online (Sandbox Code Playgroud)

通过此更改,我们看到重试尝试的次数显着下降。这表明最大限度地减少两次尝试之间花费在代码上的时间的重要性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}");
    }
}
Run Code Online (Sandbox Code Playgroud)


AAA*_*ddd 1

如果您只需要锁定字典,例如确保同时设置 3 个值。那么,锁定什么引用类型并不重要,只要它引用类型,它是同一个实例,并且需要读取或修改这些值的其他所有内容也都锁定在同一个实例上。

您可以在此处阅读有关Microsoft CLR 实现如何处理锁定以及锁如何以及为何与引用类型一起使用的更多信息

为什么 C# 中需要锁实例?

如果你试图保持字典值的内部一致性,也就是说,你不仅试图保护字典的内部一致性,还试图保护字典中对象的设置和读取。那么你的根本不合适。

您需要整个语句(包括)以及添加到字典或读取/修改值的每个其他位置放置一个锁。 再说一遍,您锁定的对象并不重要,只要它一致即可。TryGetValue

注1object :根据您的需要,使用静态或实例成员来锁定(即一些实例化)专用实例是正常的,因为这样您搬起石头砸自己的脚的可能性就较小。

注 2:这里有很多方法可以实现线程安全,具体取决于您的需要、您是否对陈旧的值感到满意、您是否需要每一盎司的性能、以及您是否具有最小锁编码的程度以及多少您想要融入的努力和天生的安全感。这完全取决于您和您的解决方案