é a gravação simultânea em stdout threadsafe?

Dec 09 2020

o código abaixo não lança uma corrida de dados

package main

import (
    "fmt"
    "os"
    "strings"
)

func main() {
    x := strings.Repeat(" ", 1024)
    go func() {
        for {
            fmt.Fprintf(os.Stdout, x+"aa\n")
        }
    }()

    go func() {
        for {
            fmt.Fprintf(os.Stdout, x+"bb\n")
        }
    }()

    go func() {
        for {
            fmt.Fprintf(os.Stdout, x+"cc\n")
        }
    }()

    go func() {
        for {
            fmt.Fprintf(os.Stdout, x+"dd\n")
        }
    }()

    <-make(chan bool)
}

Tentei vários comprimentos de dados, com variante https://play.golang.org/p/29Cnwqj5K30

Este post diz que não é TS.

Este e-mail realmente não respondeu à pergunta, ou eu não entendi.

A documentação do pacote os e fmt não menciona muito sobre isso. Admito que não cavei o código-fonte desses dois pacotes para encontrar mais explicações, eles parecem muito complexos para mim.

Quais são as recomendações e suas referências ?

Respostas

6 kostix Dec 09 2020 at 17:10

Não tenho certeza se isso se qualificaria como uma resposta definitiva, mas tentarei fornecer algumas dicas.

As F*funções -funções do fmtpacote simplesmente declaram que pegam um valor de um tipo de io.Writerinterface de implementação e o chamam Write. As próprias funções são seguras para uso simultâneo - no sentido em que é normal chamar qualquer número de fmt.Fwhavetersimultaneamente: o próprio pacote está preparado para isso, mas o suporte de uma interface em Go não afirma nada sobre o tipo real em termos de simultaneidade.

Em outras palavras, o ponto real de onde a simultaneidade pode ou não ser permitida é transferido para o "escritor" para o qual as funções de fmtgravação. (Deve-se também ter em mente que as fmt.*Print*funções podem chamar Writeseu destino qualquer número de vezes - ao contrário das fornecidas pelo pacote de estoque log.)

Portanto, temos basicamente dois casos:

  • Implementações personalizadas de io.Writer.
  • Implementações de estoque dele, como *os.Fileou invólucros em torno de soquetes produzidos pelas funções de netpacote.

O primeiro caso é o simples: tudo o que o implementador fez.

O segundo caso é mais difícil: pelo que entendi, a postura da biblioteca padrão Go sobre isso (embora não seja claramente declarada na documentação) em que os invólucros que ela fornece em torno de "coisas" fornecidas pelo sistema operacional - como descritores de arquivo e soquetes - são razoavelmente "thin" e, portanto, qualquer semântica que implementem, é transitivamente implementada pelo código stdlib em execução em um sistema específico.

Por exemplo, POSIX requer que as write(2)chamadas sejam atômicas umas em relação às outras quando operam em arquivos regulares ou links simbólicos. Isso significa que, uma vez que qualquer chamada a Writecoisas envolvendo descritores de arquivo ou soquetes, na verdade resulta em uma única syscall de "gravação" do sistema tagret, você pode consultar a documentação do sistema operacional de destino e ter uma ideia do que acontecerá.

Observe que POSIX apenas informa sobre objetos do sistema de arquivos, e se os.Stdoutfor aberto para um terminal (ou um pseudoterminal) ou para um pipe ou qualquer outra coisa que suporte o write(2)syscall, os resultados dependerão de qual subsistema relevante e / ou driver implementar - por exemplo, dados de várias chamadas simultâneas podem ser intercalados, ou uma das chamadas, ou ambos, podem apenas falhar pelo sistema operacional - improvável, mas ainda assim.

Voltando ao Go, pelo que percebi, os seguintes fatos são verdadeiros sobre os tipos stdlib do Go que envolvem descritores de arquivo e soquetes:

  • Eles são seguros para uso simultâneo por si próprios (quero dizer, no nível Go).
  • Eles "mapeiam" Writee Readchamam 1 para 1 para o objeto subjacente - ou seja, uma Writechamada nunca é dividida em duas ou mais syscalls subjacentes e uma Readchamada nunca retorna dados "colados" dos resultados de várias syscalls subjacentes. (A propósito, as pessoas ocasionalmente se surpreendem com esse comportamento simples - por exemplo, veja isto ou isto como exemplos.)

Então, basicamente, quando consideramos isso com o fato de estarmos fmt.*Print*livres para ligar Writequalquer número de vezes em uma única chamada, seus exemplos que usam os.Stdout, irão:

  • Nunca resulte em uma corrida de dados - a menos que você tenha atribuído à variável os.Stdoutalguma implementação personalizada, - mas
  • Os dados realmente gravados no FD subjacente serão misturados em uma ordem imprevisível que pode depender de muitos fatores, incluindo a versão do kernel do SO e configurações, a versão do Go usada para construir o programa, o hardware e a carga no sistema.

TL; DR

  • Várias chamadas simultâneas para fmt.Fprint*gravar no mesmo valor de "gravador" adiam sua simultaneidade para a implementação (tipo) do "gravador".
  • É impossível ter uma corrida de dados com objetos "semelhantes a arquivos" fornecidos pelo Go stdlib na configuração que você apresentou em sua pergunta.
  • O verdadeiro problema não será com corridas de dados no nível do programa Go, mas com o acesso simultâneo a um único recurso acontecendo no nível do sistema operacional. E lá, nós (normalmente) não falamos sobre corridas de dados porque os SOs commodities que Go suportam expõem coisas que podem ser "escritas" como abstrações, onde uma corrida de dados real possivelmente indicaria um bug no kernel ou no driver (e o O detector de corrida de Go não será capaz de detectá-lo de qualquer maneira, pois essa memória não seria de propriedade do tempo de execução Go que alimenta o processo).

Basicamente, no seu caso, se você precisa ter certeza de que os dados produzidos por qualquer chamada em particular fmt.Fprint*saem como uma única peça contígua ao receptor de dados real fornecido pelo sistema operacional, você precisa serializar essas chamadas, pois o fmtpacote não oferece nenhuma garantia em relação o número de chamadas para Writeno "escritor" fornecido para as funções que exporta.
A serialização pode ser externa (explícita, ou seja, "pegue um bloqueio, chame fmt.Fprint*, libere o bloqueio") ou interna - envolvendo o os.Stdoutem um tipo personalizado que gerencie um bloqueio e usando-o). E já que estamos nisso, o logpacote faz exatamente isso, e pode ser usado imediatamente como os "loggers" que fornece, incluindo o padrão, permite inibir a saída de "cabeçalhos de log" (como o carimbo de data / hora e o nome do arquivo).