¿Es segura la escritura simultánea en stdout thread?

Dec 09 2020

el siguiente código no lanza una carrera de datos

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)
}

Probé múltiples longitudes de datos, con variante https://play.golang.org/p/29Cnwqj5K30

Esta publicación dice que no es TS.

Este correo realmente no responde a la pregunta, o no la entendí.

La documentación del paquete de os y fmt no menciona mucho sobre esto. Admito que no indagué en el código fuente de esos dos paquetes para encontrar más explicaciones, me parecen demasiado complejos.

¿Cuáles son las recomendaciones y sus referencias ?

Respuestas

6 kostix Dec 09 2020 at 17:10

No estoy seguro de que califique como una respuesta definitiva, pero intentaré proporcionar una idea.

Las F*-funciones del fmtpaquete simplemente indican que toman un valor de un tipo de io.Writerinterfaz de implementación y lo invocan Write. Las funciones en sí mismas son seguras para el uso simultáneo, en el sentido de que está bien llamar a cualquier número de forma fmt.Fwhavetersimultánea: el paquete en sí está preparado para eso, pero el soporte de una interfaz en Go no indica nada sobre la concurrencia de tipo real.

En otras palabras, el punto real en el que la concurrencia puede o no estar permitida se le confiere al "escritor" al que pertenecen las funciones de fmtescritura. (También se debe tener en cuenta que las fmt.*Print*funciones pueden llamar Writea su destino cualquier número de veces, a diferencia de las proporcionadas por el paquete de acciones log).

Entonces, básicamente tenemos dos casos:

  • Implementaciones personalizadas de io.Writer.
  • Implementaciones de stock de la misma, como *os.Fileo envoltorios alrededor de sockets producidos por las funciones de netpaquete.

El primer caso es el simple: cualquier cosa que haya hecho el implementador.

El segundo caso es más difícil: según tengo entendido, la postura de la biblioteca estándar de Go sobre esto (aunque no se indique claramente en los documentos) en el sentido de que las envolturas que proporciona alrededor de "cosas" proporcionadas por el sistema operativo, como descriptores de archivos y sockets, son razonablemente "thin", y por lo tanto, cualquier semántica que implementen, se implementa transitivamente mediante el código stdlib que se ejecuta en un sistema en particular.

Por ejemplo, POSIX requiere que las write(2)llamadas sean atómicas entre sí cuando operan en archivos regulares o enlaces simbólicos. Esto significa que, dado que cualquier llamada a Writecosas que envuelven descriptores de archivos o sockets en realidad da como resultado una única llamada al sistema de "escritura" del sistema tagret, puede consultar los documentos del sistema operativo de destino y hacerse una idea de lo que sucederá.

Tenga en cuenta que POSIX solo informa sobre los objetos del sistema de archivos, y si os.Stdoutse abre a una terminal (o una pseudo-terminal) oa una tubería o cualquier otra cosa que admita la write(2)llamada al sistema, los resultados dependerán de lo que sea el subsistema relevante y / o el controlador. implementar, por ejemplo, los datos de múltiples llamadas simultáneas pueden estar intercalados, o una de las llamadas, o ambas, puede simplemente fallar por el sistema operativo, es poco probable, pero aún así.

Volviendo a Go, por lo que he recopilado, los siguientes hechos son ciertos sobre los tipos de stdlib de Go que envuelven los descriptores de archivo y los sockets:

  • Son seguros para uso simultáneo por sí mismos (quiero decir, en el nivel Go).
  • Ellos "mapean" Writey Readllaman 1 a 1 al objeto subyacente, es decir, una Writellamada nunca se divide en dos o más llamadas al sistema subyacentes, y una Readllamada nunca devuelve datos "pegados" de los resultados de múltiples llamadas al sistema subyacentes. (Por cierto, las personas ocasionalmente se tropiezan con este comportamiento sencillo; por ejemplo, vea esto o esto como ejemplos).

Entonces, básicamente, cuando consideramos esto con el hecho de que fmt.*Print*somos libres de llamar Writecualquier número de veces por una sola llamada, sus ejemplos que usan os.Stdout:

  • Nunca resulte en una carrera de datos, a menos que haya asignado a la variable os.Stdoutalguna implementación personalizada, pero
  • Los datos realmente escritos en el FD subyacente se mezclarán en un orden impredecible que puede depender de muchos factores, incluida la versión y la configuración del kernel del sistema operativo, la versión de Go utilizada para compilar el programa, el hardware y la carga en el sistema.

TL; DR

  • Varias llamadas simultáneas para fmt.Fprint*escribir al mismo valor de "escritor" difieren su concurrencia a la implementación (tipo) del "escritor".
  • Es imposible tener una carrera de datos con objetos "similares a archivos" proporcionados por Go stdlib en la configuración que ha presentado en su pregunta.
  • El problema real no será con las carreras de datos en el nivel del programa Go, sino con el acceso simultáneo a un solo recurso que ocurre en el nivel del sistema operativo. Y allí, no hablamos (generalmente) de carreras de datos porque los sistemas operativos básicos que soporta el sistema operativo Go exponen cosas en las que uno puede "escribir" como abstracciones, donde una carrera de datos real posiblemente indicaría un error en el kernel o en el controlador (y el El detector de carreras de Go no podrá detectarlo de todos modos, ya que esa memoria no sería propiedad del tiempo de ejecución de Go que alimenta el proceso).

Básicamente, en su caso, si necesita asegurarse de que los datos producidos por cualquier llamada en particular a fmt.Fprint*salgan como una sola pieza contigua al receptor de datos real proporcionado por el sistema operativo, debe serializar estas llamadas, ya que el fmtpaquete no ofrece garantías con respecto a el número de llamadas al Write"escritor" proporcionado para las funciones que exporta.
La serialización puede ser externa (explícita, es decir, "tomar un bloqueo, llamar fmt.Fprint*, liberar el bloqueo") o interna, envolviendo el os.Stdouten un tipo personalizado que administraría un bloqueo y usándolo). Y ya que estamos en ello, el logpaquete hace precisamente eso, y se puede utilizar de inmediato, ya que los "registradores" que proporciona, incluido el predeterminado, permiten inhibir la salida de "encabezados de registro" (como la marca de tiempo y el nombre del archivo).