Código assíncrono, variáveis ​​compartilhadas, threads do pool de threads e segurança de threads

8

Quando escrevo código assíncrono com async / waitit, geralmente com ConfigureAwait(false)para evitar capturar o contexto, meu código está saltando de um segmento de pool de threads para o próximo após cada um await. Isso levanta preocupações sobre a segurança do encadeamento. Esse código é seguro?

static async Task Main()
{
    int count = 0;
    for (int i = 0; i < 1_000_000; i++)
    {
        Interlocked.Increment(ref count);
        await Task.Yield();
    }
    Console.WriteLine(count == 1_000_000 ? "OK" : "Error");
}

A variável não iestá protegida e é acessada por vários threads * do conjunto de encadeamentos. Embora o padrão de acesso seja não simultâneo, teoricamente deve ser possível para cada encadeamento incrementar um valor armazenado em cache localmente i, resultando em mais de 1.000.000 de iterações. Eu sou incapaz de produzir esse cenário na prática. O código acima sempre imprime OK na minha máquina. Isso significa que o código é seguro para threads? Ou devo sincronizar o acesso à ivariável usando um lock?

(* uma opção de thread ocorre a cada 2 iterações, em média, de acordo com meus testes)

Theodor Zoulias
fonte
1
Por que você acha que ié armazenado em cache em cada thread? Veja este SharpLab IL para aprofundar.
precisa
1
@AndreasHassing Minhas preocupações são levantadas por declarações como esta: O compilador, o CLR ou a CPU podem introduzir otimizações de cache, de forma que as atribuições a variáveis ​​não sejam visíveis para outros threads imediatamente. Parte 4: encadeamento avançado
Theodor Zoulias 07/10/19

Respostas:

2

O problema com a segurança de threads é sobre a leitura / gravação de memória. Mesmo quando isso pode continuar em um thread diferente, nada aqui é executado simultaneamente.

Jeroen van Langen
fonte
Teoricamente, um encadeamento pode ler e gravar em um cache local em vez da RAM principal, perdendo dessa maneira uma atualização feita por outro encadeamento. A variável inão é declarada volatilenem protegida por um bloqueio, portanto, pelo que entendi, o compilador, o Jitter e o hardware (CPU) têm permissão para fazer uma otimização como esta.
Theodor Zoulias
@TheodorZoulias Trocar um thread para retomar uma continuação não é o mesmo que acesso simultâneo. No sharplab que está vinculado acima, você pode ver toda a máquina de estado, que encapsula os locais em campos privados, é passada para o encadeamento que executará a continuação. Somente um thread está acessando ia qualquer momento.
JohanP
@ JohanP o campo private int <i>5__2na máquina de estado não é declarado volatile. Minhas preocupações não são sobre um thread que interrompe outro thread que está no meio da atualização i. Isso é impossível de acontecer neste caso. Minhas preocupações são sobre um encadeamento usando um valor antigo de i, armazenado em cache no cache local do núcleo da CPU, deixado lá de um loop anterior, em vez de buscar um novo valor ina RAM principal. Acessar o cache local é mais barato do que acessar a RAM principal, portanto, com otimizações ON, essas coisas são possíveis (de acordo com o que eu li).
Theodor Zoulias
@TheodorZoulias, você tem a mesma preocupação se esse loop não tiver asynccódigo lá?
JohanP 08/10/19
2
O segmento A do @TheodorZoulias é executado, incrementado i. Código bate await, o Thread A passa todo o estado para o Thread B e volta para o pool. Passe incrementos B i. Acessos await. O Thread B passa todo o estado para o Thread C, volta ao pool, etc. Em nenhum momento há acesso simultâneo i, não há necessidade de segurança do thread, não importa que tenha ocorrido uma troca de thread, todos os o estado necessário é passado para o novo encadeamento executando a continuação. Não há estado compartilhado, por isso você não precisa de sincronização.
JohanP 08/10/19
0

Acredito que este artigo de Stephen Toub possa esclarecer isso. Em particular, esta é uma passagem relevante sobre o que acontece durante uma troca de contexto:

Sempre que o código aguarda um aguardável cujo garçom diz que ainda não está completo (ou seja, o IsCompleted do garçom retorna falso), o método precisa ser suspenso e será retomado por uma continuação fora do garçom. Esse é um desses pontos assíncronos aos quais me referi anteriormente e, portanto, o ExecutionContext precisa fluir do código que emite a espera até a execução do delegado de continuação. Isso é tratado automaticamente pelo Framework. Quando o método assíncrono está prestes a suspender, a infraestrutura captura um ExecutionContext. O delegado que é passado para o garçom tem uma referência a esta instância ExecutionContext e a utilizará ao retomar o método. É isso que permite que as importantes informações "ambientais" representadas pelo ExecutionContext fluam entre aguardas.

Vale ressaltar que os YieldAwaitableretornados Task.Yield()sempre retornam false.

Daniel Crha
fonte
Obrigado Daniel pela resposta. Para ser sincero, ficaria surpreso se o fluxo ExecutionContextdo thread de thread para thread também servisse como um mecanismo para invalidar os caches locais do thread. Mas também não é impossível.
Theodor Zoulias
Talvez um especialista como @RaymondChen possa afirmar se sua resposta está certa ou errada. Acredito que pouquíssimas pessoas no mundo possam servir como fontes confiáveis ​​de informações sobre esse assunto.
Theodor Zoulias
"Invalidar os caches locais do encadeamento" implicaria que, quando um encadeamento executa uma alternância de contexto, de alguma forma também mantém um cache específico para esse contexto. Isso significaria que esses dados em cache precisam ser armazenados em algo que se assemelhe a um contexto ... mas por que, quando o contexto real está disponível para o encadeamento que precisará executá-lo? Também traria o problema de determinar quais dois contextos são os "mesmos", mas apenas representando um ponto posterior na execução. É claro que não pretendo ser um especialista, apenas tentando argumentar sobre o problema como um exercício mental.
Daniel Crha 17/10/19
Além disso, no caso de estar errado, posso invocar a lei de Cunningham: "A melhor maneira de obter a resposta certa na Internet não é fazer uma pergunta, é postar a resposta errada".
Daniel Crha 17/10/19
1
Mas um cache de hardware não é específico de segmento. De fato, até mesmo o código de thread único poderia ser forçado a produzir multitarefas preemptivas do lado do sistema operacional e poderia retomar a execução em um processador diferente (e, portanto, em um cache L1 e L2 diferente). Esta invalidação de cache não é específica para asyncou await. A invalidação do cache durante uma alternância de contexto afetaria o código único e multiencadeado da mesma maneira.
18719 Daniel Crha