Qual é a melhor maneira de implementar um Dicionário thread-safe?

109

Consegui implementar um dicionário thread-safe em C # derivando de IDictionary e definindo um objeto SyncRoot privado:

public class SafeDictionary<TKey, TValue>: IDictionary<TKey, TValue>
{
    private readonly object syncRoot = new object();
    private Dictionary<TKey, TValue> d = new Dictionary<TKey, TValue>();

    public object SyncRoot
    {
        get { return syncRoot; }
    } 

    public void Add(TKey key, TValue value)
    {
        lock (syncRoot)
        {
            d.Add(key, value);
        }
    }

    // more IDictionary members...
}

Em seguida, bloqueio este objeto SyncRoot em todos os meus consumidores (vários threads):

Exemplo:

lock (m_MySharedDictionary.SyncRoot)
{
    m_MySharedDictionary.Add(...);
}

Consegui fazer funcionar, mas isso resultou em um código feio. Minha pergunta é: existe uma maneira melhor e mais elegante de implementar um Dicionário thread-safe?

GP.
fonte
3
O que você acha de feio nisso?
Asaf R
1
Acho que ele está se referindo a todas as instruções de bloqueio que possui em todo o seu código nos consumidores da classe SharedDictionary - ele está bloqueando o código de chamada toda vez que está acessando um método em um objeto SharedDictionary.
Peter Meyer,
Em vez de usar o método Add, tente atribuir valores ex- m_MySharedDictionary ["key1"] = "item1", isso é thread-safe.
testuser

Respostas:

43

Como Peter disse, você pode encapsular toda a segurança do thread dentro da classe. Você precisará ter cuidado com todos os eventos que expor ou adicionar, certificando-se de que sejam chamados fora de quaisquer bloqueios.

public class SafeDictionary<TKey, TValue>: IDictionary<TKey, TValue>
{
    private readonly object syncRoot = new object();
    private Dictionary<TKey, TValue> d = new Dictionary<TKey, TValue>();

    public void Add(TKey key, TValue value)
    {
        lock (syncRoot)
        {
            d.Add(key, value);
        }
        OnItemAdded(EventArgs.Empty);
    }

    public event EventHandler ItemAdded;

    protected virtual void OnItemAdded(EventArgs e)
    {
        EventHandler handler = ItemAdded;
        if (handler != null)
            handler(this, e);
    }

    // more IDictionary members...
}

Edit: Os documentos do MSDN apontam que enumerar é inerentemente não thread-safe. Esse pode ser um motivo para expor um objeto de sincronização fora de sua classe. Outra maneira de abordar isso seria fornecer alguns métodos para executar uma ação em todos os membros e bloquear a enumeração dos membros. O problema com isso é que você não sabe se a ação passada para aquela função chama algum membro do seu dicionário (isso resultaria em um deadlock). Expor o objeto de sincronização permite que o consumidor tome essas decisões e não esconde o impasse dentro de sua classe.

fryguybob
fonte
@fryguybob: Na verdade, a enumeração foi a única razão pela qual eu estava expondo o objeto de sincronização. Por convenção, eu executaria um bloqueio nesse objeto apenas quando estou enumerando por meio da coleção.
GP.
1
Se o seu dicionário não for muito grande, você pode enumerar em uma cópia e incorporá-lo à classe.
fryguybob 01 de
2
Meu dicionário não é muito grande e acho que funcionou. O que fiz foi criar um novo método público chamado CopyForEnum () que retorna uma nova instância de um Dicionário com cópias do dicionário privado. Este método foi então chamado para enumarations, e o SyncRoot foi removido. Obrigado!
GP.
12
Esta também não é uma classe threadsafe inerentemente, uma vez que as operações de dicionário tendem a ser granulares. Um pouco de lógica ao longo das linhas de if (dict.Contains (qualquer)) {dict.Remove (qualquer); dict.Add (qualquer que seja, newval); } é com certeza uma condição de corrida esperando para acontecer.
plinto
207

A classe .NET 4.0 que oferece suporte à simultaneidade é chamada ConcurrentDictionary.

Hector Correa
fonte
4
Marque como resposta, você não precisa de dicionário personalizado se o próprio .Net tiver uma solução
Alberto León
27
(Lembre-se de que a outra resposta foi escrita muito antes da existência do .NET 4.0 (lançado em 2010).)
Jeppe Stig Nielsen,
1
Infelizmente, não é uma solução sem bloqueio, portanto, é inútil em assemblies seguros CLR do SQL Server. Você precisaria de algo como o que está descrito aqui: cse.chalmers.se/~tsigas/papers/Lock-Free_Dictionary.pdf ou talvez esta implementação: github.com/hackcraft/Ariadne
Triynko
2
Muito antigo, eu sei, mas é importante observar que usar o ConcurrentDictionary em vez de um Dicionário pode resultar em perdas significativas de desempenho. Isso é provavelmente o resultado de uma troca de contexto cara, portanto, certifique-se de precisar de um dicionário thread-safe antes de usá-lo.
criado em
60

Tentar sincronizar internamente quase certamente será insuficiente porque está em um nível de abstração muito baixo. Digamos que você torne as operações Adde ContainsKeyindividualmente thread-safe da seguinte maneira:

public void Add(TKey key, TValue value)
{
    lock (this.syncRoot)
    {
        this.innerDictionary.Add(key, value);
    }
}

public bool ContainsKey(TKey key)
{
    lock (this.syncRoot)
    {
        return this.innerDictionary.ContainsKey(key);
    }
}

Então, o que acontece quando você chama esse pedaço de código supostamente thread-safe a partir de vários threads? Sempre funcionará bem?

if (!mySafeDictionary.ContainsKey(someKey))
{
    mySafeDictionary.Add(someKey, someValue);
}

A resposta simples é não. Em algum ponto, o Addmétodo lançará uma exceção indicando que a chave já existe no dicionário. Como pode ser isso com um dicionário thread-safe, você pode perguntar? Bem, só porque cada operação é segura para thread, a combinação de duas operações não é, já que outra thread poderia modificá-la entre sua chamada para ContainsKeye Add.

O que significa que para escrever este tipo de cenário corretamente, você precisa de um cadeado fora do dicionário, por exemplo

lock (mySafeDictionary)
{
    if (!mySafeDictionary.ContainsKey(someKey))
    {
        mySafeDictionary.Add(someKey, someValue);
    }
}

Mas agora, visto que você está tendo que escrever código de bloqueio externo, você está misturando a sincronização interna e externa, o que sempre leva a problemas como código pouco claro e deadlocks. Então, no final das contas, você provavelmente é melhor:

  1. Use um normal Dictionary<TKey, TValue>e sincronize externamente, envolvendo as operações compostas nele, ou

  2. Escreva um novo wrapper thread-safe com uma interface diferente (ou seja, não IDictionary<T>) que combine as operações, como um AddIfNotContainedmétodo, para que você nunca precise combinar as operações a partir dele.

(Eu mesmo costumo ir com o nº 1)

Greg Beech
fonte
10
Vale ressaltar que .NET 4.0 incluirá um monte de containers thread-safe, como coleções e dicionários, que têm uma interface diferente da coleção padrão (ou seja, eles estão fazendo a opção 2 acima para você).
Greg Beech
1
Também vale a pena notar que a granularidade oferecida até mesmo pelo bloqueio bruto muitas vezes será suficiente para uma abordagem de um único gravador e múltiplos leitores, se alguém projetar um enumerador adequado que permite que a classe subjacente saiba quando é descartado (métodos que gostariam de escrever o dicionário enquanto existe um enumerador não eliminado que deve substituir o dicionário por uma cópia).
supercat
6

Você não deve publicar seu objeto de bloqueio privado por meio de uma propriedade. O objeto de bloqueio deve existir em particular com o único propósito de atuar como um ponto de encontro.

Se o desempenho for ruim usando o bloqueio padrão, a coleção de bloqueios Power Threading do Wintellect pode ser muito útil.

Jonathan Webb
fonte
5

Existem vários problemas com o método de implementação que você está descrevendo.

  1. Você nunca deve expor seu objeto de sincronização. Fazer isso vai se abrir para um consumidor agarrar o objeto e travá-lo, e você estará frito.
  2. Você está implementando uma interface não segura para thread com uma classe segura para thread. IMHO isso vai te custar no caminho

Pessoalmente, descobri que a melhor maneira de implementar uma classe thread-safe é por meio da imutabilidade. Isso realmente reduz o número de problemas que você pode encontrar com a segurança de thread. Confira o Blog de Eric Lippert para mais detalhes.

JaredPar
fonte
3

Você não precisa bloquear a propriedade SyncRoot em seus objetos de consumidor. O bloqueio que você tem nos métodos do dicionário é suficiente.

Para Elaborar: O que acaba acontecendo é que seu dicionário fica travado por mais tempo do que o necessário.

O que acontece no seu caso é o seguinte:

Say thread A adquire o bloqueio no SyncRoot antes da chamada para m_mySharedDictionary.Add. O thread B então tenta adquirir o bloqueio, mas é bloqueado. Na verdade, todos os outros threads estão bloqueados. O thread A pode chamar o método Add. Na instrução de bloqueio dentro do método Add, o encadeamento A tem permissão para obter o bloqueio novamente porque já o possui. Ao sair do contexto de bloqueio dentro do método e, em seguida, fora do método, o encadeamento A liberou todos os bloqueios permitindo que outros encadeamentos continuem.

Você pode simplesmente permitir que qualquer consumidor chame o método Add, pois a instrução de bloqueio dentro do método Add da sua classe SharedDictionary terá o mesmo efeito. Neste ponto no tempo, você tem bloqueio redundante. Você só travaria SyncRoot fora de um dos métodos de dicionário se tivesse que realizar duas operações no objeto de dicionário que precisassem ser garantidas para ocorrer consecutivamente.

Peter Meyer
fonte
1
Não é verdade ... se você fizer duas operações após a outra que são seguras para threads internamente, isso não significa que o bloco de código geral é seguro para threads. Por exemplo: if (! MyDict.ContainsKey (someKey)) {myDict.Add (someKey, someValue); } não seria threadsafe, mesmo que ContainsKey e Add sejam threadsafe
Tim,
Seu ponto está correto, mas está fora de contexto com minha resposta e a pergunta. Se você olhar para a pergunta, ela não fala sobre como chamar ContainsKey, nem minha resposta. Minha resposta refere-se à aquisição de um bloqueio no SyncRoot, que é mostrado no exemplo da pergunta original. Dentro do contexto da instrução de bloqueio, uma ou mais operações thread-safe realmente seriam executadas com segurança.
Peter Meyer
Acho que se tudo o que ele está fazendo é adicionar ao dicionário, mas como ele tem "// mais membros do IDictionary ...", presumo que em algum momento ele também queira ler os dados do dicionário. Se for esse o caso, deve haver algum mecanismo de travamento acessível externamente. Não importa se é o SyncRoot no próprio dicionário ou outro objeto usado exclusivamente para bloqueio, mas sem tal esquema, o código geral não será seguro para thread.
Tim,
O mecanismo de bloqueio externo é como ele mostra em seu exemplo na questão: lock (m_MySharedDictionary.SyncRoot) {m_MySharedDictionary.Add (...); } - seria perfeitamente seguro fazer o seguinte: lock (m_MySharedDictionary.SyncRoot) {if (! m_MySharedDictionary.Contains (...)) {m_MySharedDictionary.Add (...); }} Em outras palavras, o mecanismo de bloqueio externo é a instrução de bloqueio que opera na propriedade pública SyncRoot.
Peter Meyer,
0

Apenas uma ideia, por que não recriar o dicionário? Se a leitura for uma infinidade de gravações, o bloqueio sincronizará todas as solicitações.

exemplo

    private static readonly object Lock = new object();
    private static Dictionary<string, string> _dict = new Dictionary<string, string>();

    private string Fetch(string key)
    {
        lock (Lock)
        {
            string returnValue;
            if (_dict.TryGetValue(key, out returnValue))
                return returnValue;

            returnValue = "find the new value";
            _dict = new Dictionary<string, string>(_dict) { { key, returnValue } };

            return returnValue;
        }
    }

    public string GetValue(key)
    {
        string returnValue;

        return _dict.TryGetValue(key, out returnValue)? returnValue : Fetch(key);
    }
verbedr
fonte
-5

Coleções e sincronização

MagicKat
fonte
8
-1 Eu votei contra isso porque a) é apenas um link sem explicação eb) é apenas um link sem explicação!
El Ronnoco