锁定一个实习的字符串?

wsa*_*lle 17 c# locking thread-safety higher-order-functions

更新:如果这个方法不是线程安全的,这是可以接受的,但我有兴趣学习如何使它保持线程安全.另外,key如果我可以避免它,我不想锁定所有值的单个对象.

原始问题:假设我想编写一个带有键和函数的高阶函数,并检查对象是否已使用给定的密钥进行高速缓存.如果是,则返回缓存的值.否则,运行给定的函数并缓存并返回结果.

这是我的代码的简化版本:

public static T CheckCache<T>(string key, Func<T> fn, DateTime expires)
{
    object cache = HttpContext.Current.Cache.Get(key);
    //clearly not thread safe, two threads could both evaluate the below condition as true
    //what can I lock on since the value of "key" may not be known at compile time?
    if (cache == null)
    {
        T result = fn();
        HttpContext.Current.Cache.Insert(key, result, null, expires, Cache.NoSlidingExpiration);
        return result;
    }
    else
        return (T)cache;
}
Run Code Online (Sandbox Code Playgroud)

另外,假设我key在编译时不知道所有可能的值.

如何使这个线程安全?我知道我需要在这里引入锁定,以防止1+线程将我的条件评估为真,但我不知道要锁定什么.我读过的许多关于锁定的例子(例如Jon Skeet的文章)建议使用仅用于锁定的"虚拟"私有变量.在这种情况下,这是不可能的,因为密钥在编译时是未知的.我知道我可以通过为每个人使用相同的锁来轻松地使这个线程安全key,但这可能是浪费.

现在,我的主要问题是:

有可能锁定key吗?字符串实习会有帮助吗?

在读完.NET 2.0字符串内部后,我明白我可以显式调用String.Intern()从字符串的值到字符串的实例获取1到1的映射. 这适合锁定吗?我们将上面的代码更改为:

public static T CheckCache<T>(string key, Func<T> fn, DateTime expires)
{
    //check for the scenario where two strings with the same value are stored at different memory locations
    key = String.Intern(key); 
    lock (key) //is this object suitable for locking?
    {
        object cache = HttpContext.Current.Cache.Get(key);
        if (cache == null)
        {
            T result = fn();
            HttpContext.Current.Cache.Insert(key, result, null, expires, Cache.NoSlidingExpiration);
            return result;
        }
        else
            return (T)cache;
    }
}
Run Code Online (Sandbox Code Playgroud)

上面的实现线程安全吗?

Eug*_*sky 21

@ wsanville自己的解决方案的问题,部分提到:

  1. 代码库的其他部分可能会出于不同的目的锁定相同的实际字符串实例,如果幸运的话只会导致性能问题,如果不幸则会导致死锁(可能仅在将来,随着代码库的增长,由于编码器不知道你的扩展而扩展)String.Intern锁定模式) - 请注意,即使它们位于不同的AppDomain中,这包括对同一个interned字符串的锁定,可能导致跨AppDomain死锁
  2. 它是不可能为你回收实习内存的情况下,你决定这样做
  3. String.Intern() 是慢的

要解决所有这三个问题,您可以实现自己Intern() 的特定锁定目的,即不要将其用作全局通用字符串内插器:

private static readonly ConcurrentDictionary<string, string> concSafe = 
    new ConcurrentDictionary<string, string>();
static string InternConcurrentSafe(string s)
{
    return concSafe.GetOrAdd(s, String.Copy);
}
Run Code Online (Sandbox Code Playgroud)

我调用了这个方法...Safe(),因为在实习时我不会存储传入的String实例,因为它可能已经被实例化,String使其受到上述1.中提到的问题的影响.

为了比较各种实习字符串方式的性能,我还尝试了以下两种方法,以及String.Intern.

private static readonly ConcurrentDictionary<string, string> conc = 
    new ConcurrentDictionary<string, string>();
static string InternConcurrent(string s)
{
    return conc.GetOrAdd(s, s);
}

private static readonly Dictionary<string, string> locked = 
    new Dictionary<string, string>(5000);
static string InternLocked(string s)
{
    string interned;
    lock (locked)
        if (!locked.TryGetValue(s, out interned))
            interned = locked[s] = s;
    return interned;
}
Run Code Online (Sandbox Code Playgroud)

基准

100个线程,每个线程随机选择5000个不同的字符串(每个包含8个数字)中的一个50000次,然后调用相应的实习方法.充分预热后的所有数值.这是4核i5上的Windows 7,64位.

注意预热上述设置意味着在预热后,不会对相应的实习词典进行任何写入,而只会读取.这是我对手头的用例感兴趣,但不同的写入/读取比率可能会影响结果.

结果

  • String.Intern():2032毫秒
  • InternLocked():1245毫秒
  • InternConcurrent():458毫秒
  • InternConcurrentSafe():453毫秒

鉴于这些数字在升温后的事实(见上文NB),这个事实InternConcurrentSafeInternConcurrent有意义的,所以实际上String.Copy在测试期间没有或只有少数调用.


为了正确封装它,创建一个这样的类:

public class StringLocker
{
    private readonly ConcurrentDictionary<string, string> _locks =
        new ConcurrentDictionary<string, string>();

    public string GetLockObject(string s)
    {
        return _locks.GetOrAdd(s, String.Copy);
    }
}
Run Code Online (Sandbox Code Playgroud)

并且在StringLocker为每个用例实例化一个之后,就像调用一样简单

lock(myStringLocker.GetLockObject(s))
{
    ...
Run Code Online (Sandbox Code Playgroud)

NB

再想一想,如果你想要做的只是锁定它就没有必要返回一个类型的对象string,所以复制字符是完全没必要的,并且以下将比上面的类表现更好.

public class StringLocker
{
    private readonly ConcurrentDictionary<string, object> _locks =
        new ConcurrentDictionary<string, object>();

    public object GetLockObject(string s)
    {
        return _locks.GetOrAdd(s, k => new object());
    }
}
Run Code Online (Sandbox Code Playgroud)


Luk*_*keH 6

丹尼尔答案的一个变种......

您可以共享一小组锁定,而不是为每个单独的字符串创建一个新的锁定对象,根据字符串的哈希码选择要使用的锁定.如果您可能拥有数千或数百万个密钥,这将意味着更少的GC压力,并且应该允许足够的粒度以避免任何严重阻塞(可能在经过一些调整后,如果需要).

public static T CheckCache<T>(string key, Func<T> fn, DateTime expires)
{
    object cached = HttpContext.Current.Cache[key];
    if (cached != null)
        return (T)cached;

    int stripeIndex = (key.GetHashCode() & 0x7FFFFFFF) % _stripes.Length;

    lock (_stripes[stripeIndex])
    {
        T result = fn();
        HttpContext.Current.Cache.Insert(key, result, null, expires,
                                         Cache.NoSlidingExpiration);
        return result;
    }
}

// share a set of 32 locks
private static readonly object[] _stripes = Enumerable.Range(0, 32)
                                                      .Select(x => new object())
                                                      .ToArray();
Run Code Online (Sandbox Code Playgroud)

这将允许您通过更改_stripes数组中的元素数量来调整锁定粒度以满足您的特定需求.(但是,如果你需要接近每个字符串一个锁定的粒度,那么你最好不要使用Daniel的答案.)