Hoy os traemos un artículo algo diferente, y es que esta vez se trata de una traducción de otro artículo. El artículo al cual haremos referencia es: https://cronokirby.github.io/posts/data-races-vs-race-conditions, y hemos tomado esta opción porque el artículo original está genial como está, pero queremos que sea accesible nuestra comunidad hispana por lo interesante que es.
En este post se hablará sobre la diferencia entre Data Races y Race Conditions, y como las estructuras de datos o patrones de datos que a priori están libres Data Races pueden acabar en Race conditions.
Data Races
Bajo la definición de data races que ofrece rust, podemos decir que data races es cuando uno o más hilos acceden concurrentemente a una posición de memoria o variable, al menos uno está escribiendo y al menos uno no está sincronizado con los otros hilos.
El ejemplo anterior es perfectamente válido, donde hay múltiples lecturas concurrentes sobre una variable no sincronizada:
const a = 3
func main() {
go func() {
fmt.Printf("Thread B: %d\n", a)
}
fmt.Printf("Thread A: %d\n", a)
}
Aunque el orden de impresión variará de una ejecución a otra, no hay data races ya que ambas gorrutinas simplemente están leyendo de la constante.
Pero, si introducimos ahora una gorrutina que tenga acceso a modificar los datos, tendremos nuestro data race:
func main() {
a := 3
go func() {
a = 10
}
fmt.Printf("Thread A: %d\n", a)
}
Podemos solucionar este caso introduciendo un mutex
para sincronizar nuestros accesos a la variable a
:
func main() {
a := 3
var m sync.Mutex
go func() {
m.Lock()
a = 10
m.Unlock()
}
m.Lock()
fmt.Printf("Thread A: %d\n", a)
m.Unlock()
}
Ambas gorrutinas acceden a la variable a
al mismo tiempo, y una de ellas está escribiendo, pero como el acceso ha sido sincronizado ya no tendremos data race.
Race conditions
Las condiciones de carrera o Race conditions se producen por el no determinismo en los programas concurrentes. En teoría cualquier acción que lleve a un comportamiento no determinista mientras ejecutamos concurrencia podría ser considerado un race condition, pero en la práctica lo que constituye un race condition depende de la propiedades que queremos que nuestro programa respete.
Vamos a ver un ejemplo:
func main() {
go func() {
for {
fmt.Println("Thread B")
}
}
for {
fmt.Println("Thread A")
}
}
Veremos los dos mensajes intercalados de manera aleatoria:
Si lo que qusiéramos es que nuestro programa mantuviera el orden de impresión, podría considerarse un race condition. Podríamos utilizar algún tipo de sincronización para forzar el orden en que se muestran los mensajes, si fuera lo que quisieramos.
En la práctica no consideraríamos este caso como un race condition, incluso si la ejecución no es determinista, porque no es una propiedad que nos importe.
En resumen, un race condition es una violación de las propiedades de nuestro programa que se produce al ejecutarse de manera concurrente.
Races Conditions sin Data races
Si hemos escuchado charlas sobre concurrencia en Go hemos oído eso de que si usamos canales podremos evitar los races conditions, porque las operaciones sobre ellos son siempre seguras (thread safe)
Es verdad que los canales en Go están libres de data races, siempre y cuando la memoria no sea compartida de otra manera. Eso quiere decir que es realmente sencillo escribir un programa con un race condition aunque sólo utilicemos canales.
En este ejemplo, tendremos un servidor que responde a las peticiones para obtener y modificar el valor de un entero:
type msg struct {
id int
amount int
}
Usaremos 0
para el id de lectura y 1
para el de escritura.
Nuestro server será tal que así:
type server struct {
msgs chan msg
resps chan int
}
func newServer() *server {
msgs := make(chan msg)
resps := make(chan int)
return &server{msgs, resps}
}
Tenemos un canal que es capaz de enviar y recibir mensajes, así como un canal para las respuestas de esos mensajes.
Nuestro servidor empezará en segundo plano con la siguiente función:
func (s *server) start() {
state := 0
for {
m := <-s.msgs;
if m.id == 0 {
s.resps <- state
} else {
state = m.amount
}
}
}
Respondemos a las peticiones get
enviando el estado actual y a las de set
estableciendo el estado concurrente. Dado que sólo una gorrutina controla el estado, las interacciones con el servidor están libres de data races.
Las operaciones de get
y set
, son las siguientes:
func (s *server) get() int {
s.msgs <- msg{0, 0}
return <-s.resps
}
func (s *server) set(amount int) {
s.msgs <- msg{1, amount}
}
Ahora con estas operaciones básicas, podemos definir la siguiente función:
func (s *server) increment() {
x := s.get()
s.set(x + 1)
}
Esta función simplemente incrementa el estado.
En nuestra función main
, haremos lo siguiente:
func main() {
s := newServer()
go s.start()
for i := 0; i < 200; i++ {
s.increment()
}
fmt.Println(s.get())
}
Esto realizará 200 operaciones de incremento, dejando el estado en 200.
Pero si empezamos a compartir estas operaciones entre subprocesos, obtendremos una race condition.
func main() {
s := newServer()
go s.start()
go func() {
for i := 0; i < 100; i++ {
s.increment()
}
}
for i := 0; i < 100; i++ {
s.increment()
}
fmt.Println(s.get())
}
Lo que esperamos ver es 200, tal como pasaba antes, pero realmente lo que veremos es un número más pequeño. Esto es una race condition. Esto pasa porque 2 gorrutinas pueden acceder al mismo valor antes de que este sea modificado, y ambas acabaran por establecer el mismo valor, lo que hace que dos llamadas de incremento acaben siendo sólo una.
Las soluciones sencillas no son suficiente
Lo que realmente queremos es una operación atómica. En este caso un incremento atómico. Un incremento atómico significaría que cada incremento pasa en un sólo paso, y por lo tanto esto evitaría que ocurrieran dos incrementos simultáneos que acaben en una sola operación.
Podríamos agregar dicha operación a nuestro servidor con un mensaje adicional, por ejemplo con el id 2
:
else if m.id == 2 {
state++
}
El problema es que la siguiente función seguiría sin ser atómica:
func (s *server) doubleIncrement() {
s.increment()
s.increment()
}
No importa qué conjunto de operaciones atómicas implemente nuestro servidor para su estado, simplemente no podremos realizar operaciones secuencialmente de manera no sincronizadas, y esperar que el resultado también sea atómico.
Podéis encontrar el código completo para este ejemplo aquí: https://play.golang.org/p/995MLEiqIVV
Resumen
Los data races no deben combinarse con las race conditions. El hecho de que una estructura de datos este libre de data races no quiere decir que no puedan ocurrir race conditions. Además lanzar operaciones atómicas de manera secuencial no produce operaciones atómicas.
Diseñar programas concurrentes sin errores no es una tarea trivial, y se vuelve aún más complejo cuando empiezas a trabajar con varios procesadores.
Ya sabéis que si tenéis cualquier duda o comentario podéis dejarlo en los comentarios o en nuestro Twitter @FriendsofGoTech.