1

.NET进阶:readOnly、 Concurrent字典,如何写出线程安全字典

Part 0 你所不知的特殊字典📑

在日常的开发中,论使用率最高的容器,List和Dictionary数一数二🏆。

这里主要想讲讲作为初学者的你,可能不知道的字典。

  • ConcurrentDictionary<TKey, TValue>:这是一个线程安全的字典实现,允许多个线程同时读写而不需要额外的同步机制。它在多线程环境中非常有用,可以提高性能并减少锁的开销。例如,如果你需要在多个线程之间共享字典数据,ConcurrentDictionary 是一个很好的选择。
  • ReadOnlyDictionary<TKey, TValue>:这个类提供了一个只读的字典视图,一旦创建,就不能修改字典的内容。这在你需要防止字典被修改,但又想提供字典内容的访问时非常有用。例如,你可以将一个可变的字典转换为只读字典,然后将其传递给不需要修改字典的组件或方法。
  • SortedDictionary<TKey, TValue>:这个字典类型按照键的顺序存储元素,并且可以在O(log n)时间内进行查找、插入和删除操作。它适合于需要按键的顺序访问元素的场景。

这里我们展开聊聊前两种。

Part 1 Concurrent字典💯

刚才已经提过了,这是一个线程安全字典。你使用多线程或者异步操作时,字典内部有一套同步机制,会防止死锁🔗。

这里聊一些比较常用的扩展方法。不同的.NET版本、.NET core可能会导致细微差别,但是使用的方式都是差不多的。

1. GetOrAdd🌼

顾名思义,同时包含了Get和Add的一个方法,返回值是对应的TValue,也就是你定义字典的值的类型。这个方法会先试图查询字典获取对应值,如果没有则将你给出的一个新值插入字典。

这个方法有三个重载:

  • TValue GetOrAdd(TKey key, TValue value)
  • TValue GetOrAdd(TKey key, Func<TKey, TValue> valueFactory)
  • TValue GetOrAdd<TArg>(TKey key, Func<TKey, TArg, TValue> valueFactory, TArg factoryArgument):
  • 第一个重载,很好理解。value是初始值,或者一个默认值。如果你用key键对字典查询,没有对应值就会把默认值加进去,并且不会扔出错误。

适用场景:我需要获取一个字典当中一个键值对。但是我并不知道我的同事🐒有没有在其他地方,已经给这个键赋过值了。为了避免冲突,使用GetorAdd,如果其他地方出现了,那我拿到的就是同事🐵之前赋值过的值。反之,我将一个值存入了字典,其他同事同样使用GetorAdd,可以拿取我赋的值。这既避免了重复声明,也避免了在不同的地方获取的值不一样。

  • 第二个重载稍微麻烦了一点,第一个参数还是键,第二个参数是一个接受键,返回值的委托。这个其实和第一种情况是大致一样的,只不过这里值不是确定的,被参数明确传进来的。而是传入了一个可以通过键生成值的函数。如果Get到了对应的值,那么你就会获得对应的值。如果没有获得到,就会调用委托,生成一个值,并放入字典中。

适用场景:很显然这是第一种情况的补充,如果我们的值是一个很复杂的对象,它的生成⚙需要经历很复杂的⚙生成过程。那么就应该使用这种方式,第二个参数用一个封装好的工厂🏭方法。在不确定字典里对应的对象有没有生成的情况下,如果没有对应的对象才构造对象;反之,直接拿取其他地方已经生成的对象。

  • 第三个重载一看就觉得很麻烦,你可能觉得没什么用。事实是确实没有用,你绝大部分时候没有这种需求。第三种重载是对第二种情况的进一步补充,如果你的工厂方法产生一个值,需要不止一个参数,那么就需要使用这个重载。这个重载允许你额外传入一个任意由你指定的类型(TArg)参数。

使用场景:几乎不会被使用到。GetOrAdd方法最多支持两个参数的工厂🏭委托。如果你的值构建过程真的很复杂,需要大量的外部参数。那GetOrAdd也起不到延迟执行的作用。

2. Coount、ISEmpty、Clear、Keys、Values、ContainsKey等💫

这部分跳过,和普通的字典完全一摸一样。需要额外补充说明的是,虽然ContainKey能做到检查某个键是否存在,但还是建议使用下面要将的TryGet方法。

3. TryGet和TryAdd ☄

bool TryGetValue(TKey key, out TValue value);
bool TryAdd(TKey key, TValue value);

我们先来讲讲基本不用🚫的TryAdd方法,同样顾名思义的一个方法。传入一个键和一个值,如果键不存在,添加成功并返回true;反之,返回false。

这个方法完全被GetOrAdd和接下来要说的AddOrUpdate取代。首先我们知道GetOrAdd本身就包含了如果没有值就放一个值进去的功能,而AddOrUpdate又包含了更新的功能。所以这个方法处于一种“🚷查无此人的状态”。

TryGet,也是顾名思义的一个方法。你需要记住的只有一点,返回值是bool,第二参数是传出(out)参数,才是你要的值。

这个更加适合处理我们之前谈到的,当你需要执行复杂的值构造。

TValue value;
if(dict.TryGet(key,vlaue))
{
///构造vlaue
}

你既可以用来处理这种复杂情况,也可以用来判断字典中是否包含特定键。
需要注意的是🐞因为vlaue是传出参数,当key不存在时,会返回null。所以这个值的类型本身是可空的,编译器应该会给出提示,但你不应该忘记。

4. AddOrUpDate🌷

  • AddOrUpdate(TKey key, TValue addValue, Func<TKey, TValue, TValue> updateValueFactory)
  • AddOrUpdate(TKey key, Func<TKey, TValue> addValueFactory, Func<TKey, TValue, TValue> updateValueFactory)
  • AddOrUpdate<TArg>(TKey key, Func<TKey, TArg, TValue> addValueFactory, Func<TKey, TValue, TArg, TValue> updateValueFactory, TArg factoryArgument)

功能十分强大的函数,看起来就高级,使用起来令人头痛不已。和GetOrAdd方法类似,先试图向字典里添加一个键值对,如果失败,则试图对键的对应值进行更新。所以🥜 你应该确保你真的是要更新这个值 🥜,如果你只是试图添加,那去用GetOrAdd,还是之前情况,对于你和同事🦉如果维护的是同一个对象,你不确定他(她)是否创建了这个对象,那就用GetOrAdd。如果你明确这里需要一个新的值,那才应该使用AddOrUpdate。

第一种和第二种重载是同一种情况的两种不同形式。第一种重载很显然是试图对key添加值addValue。如果键已存在,则执行update委托。
第二种是先检查key是否存在,如果不存在,执行构造值委托addValueFactory。如果键存在,则执行update委托。

和GetOrAdd一样,你传入的值构造委托会被延迟执行。🐈
我们来谈一谈关于update委托,update的委托的参数是key,oldValue,返回值是newValue。你需要告知旧值如何处理。

如果你没有什么特殊需求,给了一个key🔑,一个value🔓我希望的就是字典里key对应的值变成value。那么我给出一种平时常用的使用方式:

dict.AddOrUpdate(1,sunsun,(key,oldValue)=>sunsun);

sunsun🌞是一个对象变量,1是一个键,你可以换你需要的类型。这行执行了一个很简单的事,我不管字典有没有1这个键,执行完以后1对应sunsun。

第三重载和GetOrAdd的第三重载一样,对于复杂的update委托,提供了一个额外类型的额外参数。

5. TryRemove和TryUpdate☂

  • bool TryUpdate(TKey key, TValue newValue, TValue comparisonValue)
  • bool TryRemove(KeyValuePair<Tkey,TValue> item);
  • bool TryRemove(TKey key, out TValue value);

remove的第一重载你可能和我不一样,可能参数是一个key,这是因为NEt版本不同,所以系统库存在的一些差异。但实现的功能是一样,删除对应key的键值对。

第二重载是删除的同时将值通过传出(out)参数返回给你,同样注意如果不存在键,返回的会是null。

tryupdate这里和AddOrUpdate不同,需要注意。
🔥🔥🔥第三个参数不是新值🔥🔥🔥
第一个参数是key,这很好理解。第二个才是newValue,你希望更新的值。第三个值用来干什么呢?如果键存在,且旧值=comparisonValue,才会更新为newValue。就是说相比于AddOrUpdate而言,多了一步比较的过程,你只有知道你更新的键和值分别是什么,才能更新成功。如果因为异步操作,导致值变化了,那么你的更新操作会失败。
这是一种稳妥的更新方式,避免了你以为旧值是这个,所以更新了新值。其实在程序的其他地方或者你的同事🐅依旧把旧值给改过了,因为你的擅自更新,导致其他地方出错了,的这一种情况。

Part 2 ReadOnly与Concurent结合的字典☯

在讲经验之谈之前,我们先简单讲一下ReadOlnyDictionary。这个字典类型只包含keys、Values、count一些简单的成员。你无法使用add、clear等任何方式修改字典的键值对。键值对在初始化后就无法被修改。

顺带再讲讲readOnly关键字,也许你没有使用过这个关键字,但能看懂。

  public readonly int a=1;

我们在类中定义了一个成员属性a,他的值永远为1不能修改。
这对于非引用🎫类型毫无意义,对于引用📷类型十分有用。可以防止你以任何方式对这个成员对象重新分配(new)一个新对象,而不会影响你对这个对象自身包含的成员的使用和修改。

明白了以上的内容,我将告诉你如何写出一个线程安全的字典。

public class MyDictionary
{
    private readonly ConcurrentDictionary<string, string> _myDicttionary = new ConcurrentDictionary<string, string>();

    public IReadOnlyDictionary<string, string> TheDicttionary=> _myDicttionary;

    public void AddorUpDate(String key, String value) {
        _myDicttionary.AddOrUpdate(key, value, (key, oldValue) => value);
    }

    public void GetOrAdd(string key, string value) {

        _myDicttionary.GetOrAdd(key, value);
    }
    //其他你需要的函数
}

我们将 ConcurrentDictionary进行readonly并且私有,任何人无法再分配一个新的对象给这个字典,如果你使用依赖注入,可以进一步实现全局单例模式。我们用IReadOnlyDictionary接口封装我们的线程安全字典,这样类外无法直接通过扩展方法修改这个字典中的键值对。下面几个函数是我们保留的修改接口。在类内可以正常修改字典的键值对,如果你需要使用之前介绍的其他扩展方法,可以自己封装一个。
并且封装的修改函数相比于直接调用扩展函数的优势在于,你可以在函数内写一些逻辑,阻止你不希望的修改,也可以在方法当中加入同步的逻辑,保证线程安全。


wang_hun
9 声望1 粉丝