Bailando con Mutexes de Go

Nivel de lector: Intermedio : en este artículo se supone que tiene cierta familiaridad básica con Go y su modelo de simultaneidad y que al menos está un poco familiarizado con la sincronización de datos en forma de bloqueo y comunicación de canal.

Nota del lector : Un querido amigo mío ha inspirado esta publicación. Como lo ayudé a resolver problemas en algunas carreras de datos y he hecho todo lo posible por darle un consejo decente sobre el arte de la sincronización de datos, me di cuenta de que este consejo podría beneficiar a otros. En caso de que se encuentre heredando una base de código donde ya se han tomado ciertas decisiones de diseño o si solo quiere comprender las primitivas de sincronización más tradicionales de Go que este artículo podría estar delante de usted.

Cuando comencé a trabajar con el lenguaje de programación Go inmediatamente compré el eslogan de Go: " No se comunica compartiendo la memoria; compartir la memoria mediante la comunicación. "Para mí, esto significaba escribir todo el código concurrente a la manera" adecuada ", siempre usando siempre los canales. Mi pensamiento es que si aprovecha los canales, está seguro de evitar las trampas de contención, bloqueo, interbloqueos, etc.

A medida que avanzaba con Go, aprendí a escribir Go idiomático y aprendí sobre las mejores prácticas; Me tropezaría con bases de código bastante grandes, donde a menudo encontrabas personas que usaban sincronización Sy / mutex primitiva, sincronizada / atómica de Go y algunas otras primitivas de sincronización de "nivel inferior" y quizás "de la vieja escuela". Mi primer pensamiento fue, bueno, lo están haciendo mal y es evidente que no han visto ninguna de las charlas de Rob Pike sobre los méritos de la concurrencia basada en canales, donde a menudo hace referencia a la influencia del diseño de la comunicación de procesos secuenciales de Tony Hoare.

La realidad fue dura. La comunidad de Go recita el eslogan anterior una y otra vez, pero al asomarse a muchos proyectos de código abierto, los mutexes abundan y abundan . Luché con este acertijo por un tiempo, pero al final vi la luz, ya que era hora de ensuciarme las manos y apartar los canales para variar. Ahora avancemos rápidamente a 2015, donde he estado escribiendo Go por alrededor de 2,5 años, y desde entonces he tenido una epifanía o dos con respecto a los enfoques de sincronización basados ??en más tradicionales, como el bloqueo basado en mutex. Adelante, pregúntame otra vez ahora en 2015? Hola, @deckarep, ¿sigues escribiendo solo aplicaciones concurrentes usando solo canales? Hoy respondo no, y aquí está el por qué.

Primero, no olvidemos la importancia de ser pragmático. Cuando se trata de proteger el estado compartido con el bloqueo tradicional o la sincronización basada en el canal; Comencemos con la siguiente pregunta: "Entonces, ¿qué enfoque debe usar"? Y resulta que hay un pequeño escrito que resume muy bien la respuesta :

Utilice el que sea más expresivo y / o más simple.

Un error común de los principiantes es usar en exceso los canales y los goroutines solo porque es posible y / o porque es divertido. No tengas miedo de usar un sync.Mutex si eso se adapta mejor a tu problema. Go es pragmático al permitirle usar las herramientas que resuelven mejor su problema y no lo fuerzan a un estilo de código.

Tenga en cuenta las palabras clave en ese ejemplo: expresivo, simple, uso excesivo, miedo, pragmático. Puedo admitir algunas cosas aquí: tenía miedo cuando recogí Go por primera vez. Era un recién llegado al idioma, y ??necesitaba pasar tiempo con el lenguaje antes de sacar conclusiones tan rápido. También sacará sus propias conclusiones en referencia al artículo anterior, y mientras exploramos algunas de las mejores prácticas usando el bloqueo basado en mutex y qué tener en cuenta. El artículo mencionado anteriormente también tiene algunas buenas directrices sobre mutex vs canales.

Cuándo usar canales: pasar la propiedad de los datos, distribuir las unidades de trabajo y comunicar los resultados de la sincronización

Cuándo usar Mutexes: cachés, estado

En definitiva, cada aplicación es diferente y puede llevar algo de experimentación y falsos comienzos. Para mí, sigo las pautas anteriores, pero déjame dar más detalles sobre ellas. Cuando necesite proteger el acceso a una estructura de datos bastante simple, como un sector o un mapa, o incluso algo personalizado, y si la interfaz con dicha estructura de datos es sencilla, comience con un mutex. Además, siempre ayuda a encapsular los detalles sucios del bloqueo dentro de su API. Los usuarios finales de su estructura de datos no necesitan preocuparse por cómo su estructura hace su sincronización interna.

Si su sincronización basada en mutex comienza a hacerse difícil de manejar y está jugando el baile mutex , es hora de pasar a una estrategia diferente. De nuevo, reconozca que los mensajes mutex son útiles y sencillos para escenarios simples para proteger el estado compartido mínimamente. Úselos como lo que son, pero respételos y no los deje salir de control . Recupere el control de la lógica de su aplicación, y si está luchando con mutexes, considere volver a pensar su diseño. Quizás moverse a los canales se adaptaría mejor a la lógica de su aplicación, o mejor aún, no comparta el período de estado .

Enhebrar no es difícil, el bloqueo es difícil.

Entiendo que no estoy abogando por usar mutexes en canales. Simplemente digo que se familiaricen con ambos métodos de sincronización, y si descubrieran que su solución basada en canales parece ser demasiado complicada, tendrán otra opción. Los temas de este artículo están aquí para ayudarlo a escribir un código mejor, más fácil de mantener y más sólido. Nosotros, como ingenieros, tenemos que ser conscientes de cómo manejamos el estado compartido y evitar las carreras de datos en aplicaciones de subprocesos múltiples . Go hace que sea increíblemente fácil producir aplicaciones concurrentes y / o paralelas de alto rendimiento, pero las trampas están ahí, y se debe tener cuidado para construir una aplicación correcta. Veamos los detalles entonces:

Elemento 1 : al declarar una estructura donde el mutex debe proteger el acceso a uno o más campos, coloque el mutex sobre los campos que protegerá como una práctica recomendada. Aquí hay un ejemplo de este modismo dentro del propio código fuente de Go . Tenga en cuenta que esto es puramente convencional y no afecta la lógica de su aplicación.

 var sum struct { 
sync.Mutex // <- este mutex protege
i int // <- este entero debajo
}

Elemento 2 : Mantenga un bloqueo de exclusión mutua solo por el tiempo que sea necesario. Ejemplo: si puede evitarlo, no mantenga un mutex durante una llamada basada en IO. En su lugar, asegúrese de proteger solo su recurso por el tiempo que sea necesario. Si hiciste algo como esto en un manejador web, por ejemplo, efectivamente anulas los efectos de la concurrencia serializando el acceso al manejador.

 // En el siguiente código, supongamos que `mu` solo existe 
// para proteger el acceso a la variable de caché
// NOTA: excusar el manejo de errores por brevedad
 // No hagas lo siguiente si puedes evitarlo 
func doSomething () {
mu.Lock ()
item: = caché ["myKey"]
http.Get () // Alguna llamada io costosa
mu.Desbloquear ()
}
 // En su lugar, haz lo siguiente cuando sea posible 
func doSomething () {
mu.Lock ()
item: = caché ["myKey"]
mu.Desbloquear ()
http.Get () // ¡Esto puede tomar un tiempo y está bien!
}

Elemento 3 : Utilizar diferir para desbloquear su mutex donde una función dada tiene múltiples ubicaciones que puede devolver. Esto significa menos contabilidad para usted y puede mitigar los bloqueos cuando alguien llega a lo largo de 3 meses a partir de ahora y agrega un nuevo caso para regresar temprano.

 func doSomething () { 
mu.Lock ()
diferir mu.Desbloquear ()
 err: = ... 
si err! = nil {
// error de registro
return // <- tu desbloqueo sucederá aquí
}
 err = ... 
si err! = nil {
// error de registro
return // <- o aquí aquí
}
 return // <- y por supuesto aquí 
}

Sin embargo, tenga cuidado con solo confiar en los difers en todos los casos. El siguiente código es una trampa que puede suceder cuando piensas que los difers se limpian en el alcance del bloque en lugar del alcance de la función.

 func doSomething () { 
para {
mu.Lock ()
diferir mu.Desbloquear ()

// un código interesante
// <- el aplazamiento no se ejecuta aquí ya que uno * puede * pensar
}
// <- se ejecuta aquí cuando la función sale
}
 // ¡Por lo tanto, el código anterior se bloqueará! 

Por último, considere no utilizar la declaración de aplazamiento en absoluto cuando tiene funciones extremadamente simples que no tienen múltiples rutas de retorno para exprimir un poco el rendimiento. Las declaraciones diferidas tienen un costo indirecto leve que a menudo es insignificante. Considere esto como una optimización muy prematura y sobre todo innecesaria .

Ítem ??4 : El bloqueo de grano fino puede conducir a un mejor rendimiento a costa de una contabilidad más complicada, mientras que el bloqueo de granularidad puede ser menos eficaz, pero conlleva una contabilidad mucho más simple. De nuevo, sé pragmático en tu diseño. Si te encuentras jugando el "baile mutex" puede ser hora de refactorizar tu código o cambiar a sincronización basada en canales.

Ítem ??5: Como se mencionó anteriormente en esta publicación, siempre es bueno si puede ocultar o encapsular el método de sincronización utilizado. Los usuarios de su paquete no necesitan preocuparse por las complejidades de cómo está protegido su estado compartido.

En el siguiente ejemplo, consideremos el caso en el que proporcionamos una llamada al método get (), que solo extraerá de la memoria caché si hay al menos uno o más elementos en la memoria caché. Bueno, dado que tenemos que hacer un bloqueo para sacar el elemento del caché y obtener también el recuento de cachés, este código se estancará .

 paquete principal 
 importación ( 
"Fmt"
"Sincronizar"
)
 tipo DataStore struct { 
sync.Mutex // ? este mutex protege el caché a continuación
Cache map [string] string
}
 func New () * DataStore { 
return & DataStore {
caché: make (map [string] string),
}
}
 conjunto func (ds * DataStore) (cadena clave, cadena de valor) { 
ds.Lock ()
aplazar ds.Unlock ()
 ds.cache [clave] = valor 
}
 func (ds * DataStore) get (cadena de clave) cadena { 
ds.Lock ()
aplazar ds.Unlock ()
 si ds.count ()> 0 {<- count () también toma un bloqueo! 
item: = ds.cache [tecla]
Devolver objeto
}
regreso ""
}
 func (ds * DataStore) count () int { 
ds.Lock ()
aplazar ds.Unlock ()
return len (ds.cache)
}
 func main () { 
/ * Ejecutar esto a continuación se bloqueará debido a que el método get () tomará un bloqueo y llamará al método count () que también tomará un bloqueo antes de que el método set () desbloquee ()
* /
 tienda: = Nuevo () 
store.set ("Ir", "Lang")
resultado: = store.get ("Ir")
fmt.Println (resultado)
}

Un patrón sugerido para tratar con el hecho de que las cerraduras de Go no son reentrantes es el siguiente:

 paquete principal 
 importación ( 
"Fmt"
"Sincronizar"
)
 tipo DataStore struct { 
sync.Mutex // ? este mutex protege el caché a continuación
Cache map [string] string
}
 func New () * DataStore { 
return & DataStore {
caché: make (map [string] string),
}
}
 conjunto func (ds * DataStore) (cadena clave, cadena de valor) { 
ds.cache [clave] = valor
}
 func (ds * DataStore) get (cadena de clave) cadena { 
si ds.count ()> 0 {
item: = ds.cache [tecla]
Devolver objeto
}
regreso ""
}
 func (ds * DataStore) count () int { 
return len (ds.cache)
}
 func (ds * DataStore) Set (cadena de clave, cadena de valor) { 
ds.Lock ()
aplazar ds.Unlock ()
 ds.set (clave, valor) 
}
 func (ds * DataStore) Get (cadena de caracteres) cadena { 
ds.Lock ()
aplazar ds.Unlock ()
 devolver ds.get (clave) 
}
 func (ds * DataStore) Count () int { 
ds.Lock ()
aplazar ds.Unlock ()
devolver ds.count ()
}
 func main () { 
tienda: = Nuevo ()
store.Set ("Ir", "Lang")
resultado: = store.Get ("Go")
fmt.Println (resultado)
}

Observe en el código anterior que hay un método exportado coincidente para cada método no exportado. Los métodos exportados que operan en el nivel de API pública se encargarán de bloquear y desbloquear. Luego reenvían a sus respectivos métodos no exportados que no toman ninguna cerradura en absoluto. Esto significa que todas las invocaciones exportadas de su código solo tomarán un bloqueo una vez para evitar el problema de reingreso.

Ítem ??6: en todos los ejemplos anteriores, utilizamos la sincronización básica . Bloqueo Mutex que puede simplemente: Bloquear () y Desbloquear () solamente. El bloqueo sync.Mutex proporciona la misma garantía de exclusión mutua, sin importar si el administrador está leyendo o escribiendo datos. Existe también el sync.RWMutex que ofrece un poco más de control con la semántica del bloqueo durante los escenarios de lectura. ¿Cuándo le gustaría usar el RWMutex sobre el Mutex estándar?

Respuesta: Use RWMutex cuando pueda garantizar que su código dentro de su sección crítica no mute el estado compartido.

 // Puedo usar con seguridad un RLock () para contar, no muta 
recuento de func () {
rw.RLock () // <- observe la R en RLock (bloqueo de lectura)
differ rw.RUnlock () <- observe el R en RUnlock ()
 return len (sharedState) 
}
 // Debo usar Lock () para set, muta el sharedState 
conjunto de func (cadena de clave, cadena de valor) {
rw.Lock () // <- note que tomamos un bloqueo 'regular' (write-lock)
difieren rw.Unlock () // <- note que Desbloquear () no tiene R en él
 sharedState [key] = value // <- muta el sharedState 
}

En el código anterior, podemos suponer que la variable `sharedState` es algún tipo de objeto; podría tratarse de un mapa en el que podamos consultar su longitud. Dado que la función `count () `anterior respeta la regla de que no hay mutación en la variable` sharedState`, esto significa que es seguro para un número arbitrario de lectores (goroutines) llamar a este método al mismo tiempo. En ciertos escenarios, esto podría reducir el número de goroutines en un estado de bloqueo, y potencialmente garantizar una ganancia de rendimiento en un escenario de lectura pesada. Pero recuerde, cuando tiene un código que muta estado compartido como en `set ()` no debe usar un comando rw.RLock () sino el comando rw.Lock ().

Punto 7: Conozca Go's Bad-Ass y el Detector de carrera incorporado . El detector de raza ha encontrado decenas de carreras de datos incluso dentro de la biblioteca estándar de Go. Es por eso que el detector de razas está allí y por qué hay bastantes charlas y artículos que explicarán esta herramienta mejor que yo.

  • Si aún no está ejecutando las pruebas de unidad / integración bajo el detector de carrera como parte de su sistema de construcción / entrega continuo, configúrelo ahora.
  • Si no tiene buenas pruebas que ejercen la simultaneidad de su aplicación, el detector de carreras no le servirá de nada.
  • No lo ejecute en producción a menos que realmente lo necesite, le costará una penalización de rendimiento
  • Si el detector de la raza encontró una carrera de datos, es una carrera de datos real.
  • Las condiciones de carrera aún pueden mostrar sincronización basada en canales si no tiene cuidado.
  • Todo el bloqueo en el mundo no lo salvará si un goroutine de alguna manera lee o escribe datos compartidos que no están dentro de una sección crítica.
  • Si el equipo de Go puede escribir carreras de datos sin saberlo tú también puedes.

En resumen, espero que este artículo ofrezca algunos consejos sólidos para tratar con los mutexes de Go. Juega con las primitivas de sincronización de bajo nivel de Go, comete errores, respeta y comprende las herramientas. Sobre todo, sea pragmático en su desarrollo y utilice la herramienta adecuada para el trabajo. No tengas miedo como originalmente. Si siempre escuché cada cosa negativa acerca de la programación y el bloqueo de subprocesos múltiples, hoy no estaría en este negocio escribiendo sistemas distribuidos kick-ass mientras uso un lenguaje como Go.

Nota: Me encantan los comentarios, si te pareció útil, por favor hazme un seguimiento, twittear o darme comentarios constructivos.

¡Salud y feliz codificación!

@deckarep

Hacker Noon es cómo los hackers comienzan sus tardes. Somos parte de la familia @AMI . Ahora estamos aceptando presentaciones y estamos felices de conversar sobre oportunidades de publicidad y patrocinio .

Si disfrutaste esta historia, te recomendamos que leas nuestras últimas historias tecnológicas e historias tecnológicas de tendencia . Hasta la próxima, ¡no des por sentado las realidades del mundo!