Antes de mirar a Rust en sí, volvamos a 1992. Guido van Rossum, en un intento de abordar la condición de carrera en el intérprete de CPython, agregó un bloqueo conocido como Global Interpreter Lock o GIL para abreviar. Dos décadas y media después, esta fue una de las principales deficiencias del intérprete de Python que todos amamos y usamos.
Entonces, ¿qué es el bloqueo de intérprete global?
En Python, todo lo que crea se asigna como un objeto de Python en la memoria y se devuelve una referencia a él. Usemos imágenes para entender mejor lo que está pasando. Considere la línea de código:
a = []
Detrás de las cortinas, esto es lo que hace el intérprete de Python
Se asigna el espacio para [] y se devuelve una referencia del mismo a A. ¿Qué pasa si asignamos a a otra variable b?
a = [] b = a
Si miramos detrás de las cortinas, así es como se ve:
¿Cómo se libera la memoria cuando se eliminan todas las referencias?
Aquí es donde entra en juego la simplicidad de Python. Con el objeto Python hay otro valor vinculado a él llamado recuento de referencia. El recuento de referencia es un número que indica cuántas variables tienen una referencia al valor asignado dado. Cuando se hace una nueva referencia, este valor aumenta. Cuando se elimina una referencia, este valor se reduce. Para que los diagramas anteriores sean más claros, así es como se ven con el recuento de referencia.
Cuando el recuento de referencias cae a cero, la memoria asignada para el objeto se libera y así es como el intérprete de CPython administra la memoria. Sin ningún recolector de elementos no utilizados que se ejecute periódicamente, hace que la integración de la API de C con Python sea muy sencilla.
Nota: Para obtener más información, consulte ¿Qué es el bloqueo de intérprete global (GIL) de Python?
Con esto viene una gran limitación: ¿Qué pasa si dos subprocesos quieren hacer una nueva referencia o eliminar la referencia a un objeto?
Tome el ejemplo anterior con las variables a y b. Si a y b están en subprocesos separados y quieren eliminar la referencia exactamente al mismo tiempo, se crea algo llamado condición de carrera. Digamos que primero se lee, se decrementa y se almacena el recuento de referencias; esto es lo que sucede en el código ensamblador. Si la lectura ocurre exactamente al mismo tiempo, ambos subprocesos tomarán el valor 2, lo reducirán a 1 y lo volverán a escribir en el objeto. El problema aquí es que ambas referencias se eliminan, pero el recuento de referencias del objeto se mantiene en 1, lo que significa que este objeto nunca se puede liberar y provoca una pérdida de memoria.
El otro escenario es aún más aterrador: ¿qué pasa si la adición de dos nuevas referencias en dos subprocesos solo aumenta el valor del recuento de referencias en 1? En algún momento, cuando se elimina la referencia de uno, el recuento de referencias cae a cero y se recopila la memoria, pero aún existe una referencia. Esto conducirá a un escenario similar a un volcado de núcleo o recuperación de valor de basura de la memoria.
GIL evita esto al agregar un bloqueo global para que Python diga en cualquier momento, el subproceso que adquiere el GIL es el único subproceso que puede hacer E/S de memoria, traducción de código de bytes y todas las demás cosas de bajo nivel. Básicamente, esto significa que aunque puede haber 16 subprocesos, solo el subproceso que adquirió GIL está haciendo el trabajo, mientras que todos los demás subprocesos están ocupados tratando de adquirirlo. Esto hace que Python sea extrañamente de un solo subproceso porque solo se ejecuta un subproceso a la vez.
Lo que se suma al problema es que no existe una forma eficiente de eliminar GIL y preservar la velocidad de una carga de trabajo de un solo subproceso. Un intento de eliminar GIL con incrementos y decrementos atómicos hizo que el intérprete se ralentizara un 30%, lo que para un lenguaje como CPython es un gran no-no.
Buena historia, pero ¿qué tiene que ver todo esto con Rust?
Rust es un lenguaje creado por Mozilla Research para una concurrencia segura. Un código en Rust con condiciones de carrera es casi imposible de compilar. El compilador de Rust no aceptará ningún código que no sea seguro para memoria o subprocesos. Realiza una verificación para ver si surge alguna condición de carrera dentro del código y no se compila si existe tal escenario.
Maravilloso, entonces, ¿por qué no se puede agregar esto a otros compiladores para evitar estos escenarios por completo?
Es complicado. Rust no sigue el patrón de programación tradicional. En cambio, sigue el proceso de propiedad y préstamo. Esto significa que en cualquier momento, Rust se asegurará de que solo haya una referencia mutable al objeto en cuestión. Puede tener varias referencias de solo lectura, pero si desea escribir en una ubicación, debe tomar posesión del objeto y luego realizar la mutación.
El modelo de Rust no se puede transferir directamente a otros compiladores de manera eficiente, ya que la forma de escribir el código de Rust es fundamentalmente diferente en comparación con la forma en que se podría escribir el código C y C++. Donde Rust realmente brilla es la forma en que reúne seguridad y rendimiento en una sola base de código. Esta es la razón por la que Microsoft está apostando fuerte por Rust usándolo para desarrollar bibliotecas y proyectos de código abierto para abordar los problemas de memoria que paralizan algunos de sus productos principales.
Si es un desarrollador web, Rust es un gran lenguaje para escribir código de ensamblaje web. Web Assembly es un lenguaje intermedio de bajo nivel para el navegador y Rust es uno de los lenguajes que se pueden compilar en WASM. Es tan eficiente que NPM ahora usa Rust en su string de herramientas.
Rust está aquí para quedarse e interrumpir la forma en que escribimos programas concurrentes lejos de un mundo de recolección de basura. La comunidad en crecimiento es una prueba clara de su fortaleza y su adopción por parte de las grandes empresas de tecnología es una clara señal de que es un lenguaje que vale la pena analizar.