Comportamento multithread vazio do list ()?

9

Eu tenho uma lista que eu quero tópicos diferentes para pegar elementos. Para evitar o bloqueio do mutex que guarda a lista quando está vazio, eu verifico empty()antes de bloquear.

Tudo bem se a chamada para list::empty()não estiver correta 100% do tempo. Eu só quero evitar bater ou interromper chamadas list::push()e concorrentes list::pop().

Estou seguro de presumir que o VC ++ e o Gnu GCC às vezes vão empty()errar e nada pior?

if(list.empty() == false){ // unprotected by mutex, okay if incorrect sometimes
    mutex.lock();
    if(list.empty() == false){ // check again while locked to be certain
         element = list.back();
         list.pop_back();
    }
    mutex.unlock();
}
Anne Quinn
fonte
11
Não, você não pode assumir isso. Você pode usar um recipiente concorrente como do VC concurrent_queue
Panagiotis Kanavos
2
@ Fureeish Esta deve ser uma resposta. Eu acrescentaria que std::list::sizegarantiu uma complexidade de tempo constante, o que basicamente significa que o tamanho (número de nós) precisa ser armazenado em uma variável separada; vamos chamá-lo size_. std::list::emptyprovavelmente retorna algo como size_ == 0, e a leitura e gravação simultâneas size_causariam corrida de dados, portanto, UB.
precisa saber é o seguinte
@DanielLangr Como é medido o "tempo constante"? Está em uma única chamada de função ou no programa completo?
112519
11
@curiousguy: DanielLangr respondeu sua pergunta por "independente do número de nós da lista", que é a definição exata de O (1), o que significa que toda chamada é executada em menos de um tempo constante, independentemente do número de elementos. en.wikipedia.org/wiki/Big_O_notation#Orders_of_common_functions A outra opção (até C ++ 11) seria linear = O (n), significando que esse tamanho teria que contar os elementos (lista vinculada), o que seria ainda pior para a simultaneidade (corrida de dados mais óbvia do que leitura / gravação não atômica no contador).
FIRDA
11
@curiousguy: Tomando seu próprio exemplo com dV, a complexidade do tempo é o mesmo limite matemático. Todas essas coisas são definidas recursivamente ou na forma de "Existe C tal que f (N) <C para cada N" - ou seja, a definição de O (1) (para dado / todo HW existe C constante que o algo termina em menos de tempo C em qualquer entrada). Amortizado significa, em média , o que significa que algumas entradas podem levar mais tempo para serem processadas (por exemplo, é necessário re-hash / realocação), mas ainda são constantes em média (assumindo todas as entradas possíveis).
Firmes # 31/19

Respostas:

10

Tudo bem se a chamada para list::empty()não estiver correta 100% do tempo.

Não, não está bem. Se você verificar se a lista está vazia fora de algum mecanismo de sincronização (bloqueando o mutex), você terá uma corrida de dados. Ter uma corrida de dados significa que você tem um comportamento indefinido. Ter um comportamento indefinido significa que não podemos mais raciocinar sobre o programa e qualquer resultado obtido é "correto".

Se você valoriza sua sanidade, você pega o desempenho e bloqueia o mutex antes de verificar. Dito isto, a lista pode até não ser o contêiner correto para você. Se você puder nos informar exatamente o que está fazendo, sugerimos um contêiner melhor.

NathanOliver
fonte
Perspectiva pessoal, chamada list::empty()é uma ação de leitura que não tem nada a ver comrace-condition
Ngọc Khánh Nguyễn
3
@ NgọcKhánhNguyễn Se eles estão adicionando elementos à lista, isso definitivamente causa uma corrida de dados enquanto você está escrevendo e lendo o tamanho ao mesmo tempo.
precisa saber é o seguinte
6
@ NgọcKhánhNguyễn Isso é falso. Uma condição de corrida é read-writeou write-write. Se você não me acreditar, dar a seção padrão em corridas de dados uma leitura
NathanOliver
11
@ NgọcKhánhNguyễn: Como nem a gravação nem a leitura são garantidas como atômicas, portanto, podem ser executadas simultaneamente, portanto, a leitura pode ter algo totalmente errado (chamado leitura rasgada). Imagine escrever alterando 0x00FF para 0x0100 no pequeno MCU endian de 8 bits, começando reescrevendo 0xFF baixo para 0x00 e a leitura agora obtém exatamente esse zero, lendo os dois bytes (segmento de gravação lento ou suspenso), a gravação continua atualizando byte alto para 0x01, mas o segmento de leitura já obteve um valor errado (nem 0x00FF, nem 0x0100, mas inesperado 0x0000).
FIRDA
11
@ NgọcKhánhNguyễn Pode acontecer em algumas arquiteturas, mas a máquina virtual C ++ não oferece essa garantia. Mesmo se o seu hardware o fizesse, seria legal para o compilador otimizar o código de uma maneira que você nunca veria uma alteração, pois, a menos que haja sincronização de encadeamento, ele pode assumir que está executando apenas um único encadeamento e otimizar de acordo.
NathanOliver 10/10/19
6

Há uma leitura e uma gravação (provavelmente para o sizemembro de std::list, se assumirmos que ele tem esse nome) que não são sincronizadas no reagard entre si . Imagine que um thread chame empty()(na sua parte externa if()) enquanto o outro thread entra no interior if()e executa pop_back(). Você está lendo uma variável que está possivelmente sendo modificada. Esse é um comportamento indefinido.

Fureeish
fonte
2

Como um exemplo de como as coisas podem dar errado:

Um compilador suficientemente inteligente pode ver que mutex.lock()não é possível alterar o list.empty()valor de retorno e, assim, pular ifcompletamente a verificação interna , levando a pop_backuma lista que teve seu último elemento removido após o primeiro if.

Por que isso pode fazer isso? Não há sincronização list.empty(), portanto, se ela fosse alterada simultaneamente, isso constituiria uma corrida de dados. O padrão diz que os programas não devem ter corridas de dados; portanto, o compilador tomará isso como garantido (caso contrário, ele poderia executar quase nenhuma otimização). Portanto, ele pode assumir uma perspectiva de thread único no não sincronizado list.empty()e concluir que ele deve permanecer constante.

Essa é apenas uma das várias otimizações (ou comportamentos de hardware) que podem quebrar seu código.

Max Langhof
fonte
Compiladores atuais nem sequer parecem querer otimizar a.load()+a.load()...
curiousguy
11
@curiousguy Como isso seria otimizado? Você solicitar consistência seqüencial completa lá, então você vai ter que ...
Max Langhof
@ MaxLanghof Você não acha que a otimização a.load()*2é óbvia? Nem mesmo a.load(rel)+b.load(rel)-a.load(rel)é otimizado de qualquer maneira. Nada é. Por que você espera que os bloqueios (que por essência têm consistência seq) sejam mais otimizados?
curiousguy
@curiousguy Porque a ordem de memória dos acessos não atômicos (aqui antes e depois do bloqueio) e os atômicos são totalmente diferentes? Não espero que o bloqueio seja otimizado "mais", espero que os acessos não sincronizados sejam otimizados mais do que acessos sequencialmente consistentes. A presença da fechadura é irrelevante para o meu ponto. E não, o compilador não tem permissão para otimizar a.load() + a.load()a 2 * a.load(). Sinta-se à vontade para fazer uma pergunta sobre isso, se quiser saber mais.
quer
@ MaxLanghof Eu não tenho idéia do que você está tentando dizer. Os bloqueios são essencialmente sequencialmente consistentes. Por que a implementação tentaria fazer otimizações em algumas primitivas de encadeamento (bloqueios) e não em outras (atômicas)? Você espera que os acessos não atômicos sejam otimizados em torno dos usos dos atômicos?
curiousguy