Cada programador que usa mais de um thread em seu programa encontrou primitivas de sincronização. No contexto do .NET, existem muitos, não os
listarei , o
MSDN já fez isso por mim.
Eu tive que usar muitas dessas primitivas, e elas ajudaram perfeitamente a lidar com as tarefas. Porém, neste artigo, quero falar sobre o bloqueio usual no aplicativo de desktop e como o novo (pelo menos para mim) apareceu, que pode ser chamado de PriorityLock.
O problema
Ao desenvolver um aplicativo multithread altamente carregado, um gerente aparece em algum lugar que processa inúmeros threads. Então foi comigo. E esse gerente trabalhou, processou toneladas de solicitações de várias centenas de threads. E estava tudo bem com ele, mas dentro da trava usual funcionava.
E então um dia um usuário (por exemplo, eu) clica em um botão na interface do aplicativo, o fluxo voa para o gerente (não o fluxo da interface do usuário, é claro) e espera ver uma recepção super amigável, mas, em vez disso, ele é recebido por tia Klava da recepção mais densa da clínica mais densa com as palavras "eu não dou a mínima" quem te dirigiu. Eu tenho 950 mais como você. Vá e vá até eles. Não me importo com o que você descobre. ” É assim que o bloqueio funciona no .NET. E tudo parece estar bem, tudo será executado corretamente, mas o usuário claramente não planejou esperar alguns segundos por uma resposta à sua ação.
É aí que a história comovente termina e a solução para o problema técnico começa.
Solução
Tendo estudado as primitivas padrão, não encontrei uma opção adequada. Portanto, decidi escrever meu bloqueio, que teria uma entrada padrão e de alta prioridade. A propósito, depois de escrever, também estudei pepitas, não encontrei nada parecido lá, apesar de ter pesquisado mal.
Para escrever uma primitiva assim (ou não mais uma primitiva), eu precisava das operações SemaphoreSlim, SpinWait e Interlocked. No spoiler, citei a primeira versão do meu PriorityLock (apenas código síncrono, mas é o mais importante) e explicações para ele.
Texto ocultoEm termos de sincronização, não há descobertas, enquanto alguém está no bloqueio, outros não podem entrar. Se uma prioridade alta chegou, ela é impulsionada por todos os que aguardam por baixa prioridade.
A classe LockMgr, propõe-se trabalhar com ela no seu código. É ele quem é o próprio objeto da sincronização. Cria objetos Locker e HighLocker, contém semáforos, SpinWait, contadores que desejam entrar na seção crítica, no segmento atual e no contador de recursão.
public class LockMgr { internal int HighCount; internal int LowCount; internal Thread CurThread; internal int RecursionCount; internal readonly SemaphoreSlim Low = new SemaphoreSlim(1); internal readonly SemaphoreSlim High = new SemaphoreSlim(1); internal SpinWait LowSpin = new SpinWait(); internal SpinWait HighSpin = new SpinWait(); public Locker HighLock() { return new HighLocker(this); } public Locker Lock(bool high = false) { return new Locker(this, high); } }
A classe Locker implementa a interface IDisposable. Para implementar a recursão ao capturar um bloqueio, lembramos o ID do fluxo e, em seguida, verificamos. Além disso, dependendo da prioridade, no caso de uma alta prioridade, dizemos imediatamente que viemos (aumentamos o contador HighCount), obtivemos o semáforo Alto e esperamos (se necessário) para liberar o bloqueio da baixa prioridade, após o qual estamos prontos para obter o bloqueio. No caso de baixa prioridade, o semáforo baixo é obtido, aguardamos a conclusão de todos os fluxos de alta prioridade e, levando o semáforo alto por um tempo, aumentamos o número baixo.
Vale ressaltar que o significado de HighCount e LowCount é diferente. O HighCount exibe o número de threads de prioridade que chegaram ao bloqueio, quando LowCount apenas significa que o thread (um único) com baixa prioridade entrou no bloqueio.
public class Locker : IDisposable { private readonly bool _isHigh; private LockMgr _mgr; public Locker(LockMgr mgr, bool isHigh = false) { _isHigh = isHigh; _mgr = mgr; if (mgr.CurThread == Thread.CurrentThread) { mgr.RecursionCount++; return; } if (_isHigh) { Interlocked.Increment(ref mgr.HighCount); mgr.High.Wait(); while (Interlocked.CompareExchange(ref mgr.LowCount, 0, 0) != 0) mgr.HighSpin.SpinOnce(); } else { mgr.Low.Wait(); while (Interlocked.CompareExchange(ref mgr.HighCount, 0, 0) != 0) mgr.LowSpin.SpinOnce(); try { mgr.High.Wait(); Interlocked.Increment(ref mgr.LowCount); } finally { mgr.High.Release(); } } mgr.CurThread = Thread.CurrentThread; } public void Dispose() { if (_mgr.RecursionCount > 0) { _mgr.RecursionCount--; _mgr = null; return; } _mgr.RecursionCount = 0; _mgr.CurThread = null; if (_isHigh) { _mgr.High.Release(); Interlocked.Decrement(ref _mgr.HighCount); } else { _mgr.Low.Release(); Interlocked.Decrement(ref _mgr.LowCount); } _mgr = null; } } public class HighLocker : Locker { public HighLocker(LockMgr mgr) : base(mgr, true) { } }
O uso do objeto de classe LockMgr foi muito conciso. O exemplo mostra claramente a possibilidade de reutilizar _lockMgr dentro da seção crítica, enquanto a prioridade não é mais importante.
private PriorityLock.LockMgr _lockMgr = new PriorityLock.LockMgr(); public void LowPriority() { using (_lockMgr.Lock()) { using (_lockMgr.HighLock()) {
Então eu resolvi meu problema. O processamento das ações dos usuários começou a ser executado com alta prioridade, ninguém ficou ferido, todos venceram.
Assincronia
Como os objetos da classe SemaphoreSlim oferecem suporte à espera assíncrona, também adicionei essa oportunidade a mim mesmo. O código difere minimamente e no final do artigo fornecerei um link para o código-fonte.
É importante observar aqui que a Tarefa não está anexada ao encadeamento de maneira alguma, portanto, a reutilização assíncrona do bloqueio não pode ser implementada de maneira semelhante. Além disso, a propriedade
Task.CurrentId , conforme descrita pelo MSDN, não garante nada. É aqui que minhas opções terminam.
Em busca de uma solução, me deparei com o projeto
NeoSmart.AsyncLock , na descrição de qual suporte para reutilizar o bloqueio assíncrono foi indicado. Tecnicamente, a reutilização funciona. Mas, infelizmente, o bloqueio em si não é um bloqueio. Cuidado ao usar este pacote, lembre-se de que NÃO funciona corretamente!
Conclusão
O resultado é uma classe que suporta operações síncronas com reutilização e operações assíncronas sem reutilização. Operações assíncronas e síncronas podem ser usadas lado a lado, mas não podem ser usadas juntas! Tudo devido à falta de suporte para reutilizar a opção assíncrona.
Espero não estar sozinho nesses problemas e minha solução será útil para alguém. Publiquei a biblioteca no github e nuget.
Existem testes no repositório que mostram a integridade do PriorityLock. Na parte assíncrona deste teste, o NeoSmart.AsyncLock foi testado e o teste falhou.
Link para nugetLink do Github