Eliminando al recolector de basura: The RAII Way

Publicado: 2022-03-11

Al principio, había C. En C, hay tres tipos de asignación de memoria: estática, automática y dinámica. Las variables estáticas son las constantes incrustadas en el archivo fuente, y como tienen tamaños conocidos y nunca cambian, no son tan interesantes. La asignación automática se puede considerar como asignación de pila: el espacio se asigna cuando se ingresa un bloque léxico y se libera cuando se sale de ese bloque. Su característica más importante está directamente relacionada con eso. Hasta C99, se requería que las variables asignadas automáticamente tuvieran sus tamaños conocidos en tiempo de compilación. Esto significa que cualquier cadena, lista, mapa y cualquier estructura derivada de estos tenía que vivir en el montón, en la memoria dinámica.

Eliminando al recolector de basura: The RAII Way

El programador asignó y liberó explícitamente la memoria dinámica mediante cuatro operaciones fundamentales: malloc, realloc, calloc y free. Los primeros dos de estos no realizan ninguna inicialización, la memoria puede contener cruft. Todos ellos, excepto los gratuitos, pueden fallar. En ese caso, devuelven un puntero nulo, cuyo acceso es un comportamiento indefinido; en el mejor de los casos, su programa explota. En el peor de los casos, su programa parece funcionar por un tiempo, procesando datos basura antes de explotar.

Hacer las cosas de esta manera es un poco doloroso porque usted, el programador, tiene la responsabilidad exclusiva de mantener un montón de invariantes que hacen que su programa explote cuando se viola. Debe haber una llamada malloc antes de acceder a la variable. Debe verificar que malloc se haya devuelto correctamente antes de usar su variable. Debe existir exactamente una llamada libre por malloc en la ruta de ejecución. Si es cero, la memoria se pierde. Si hay más de uno, tu programa explota. Es posible que no haya intentos de acceso a la variable después de que se haya liberado. Veamos un ejemplo de cómo se ve esto realmente:

 int main() { char *str = (char *) malloc(7); strcpy(str, "toptal"); printf("char array = \"%s\" @ %u\n", str, str); str = (char *) realloc(str, 11); strcat(str, ".com"); printf("char array = \"%s\" @ %u\n", str, str); free(str); return(0); }
 $ make runc gcc -oc cc ./c char * (null terminated): toptal @ 66576 char * (null terminated): toptal.com @ 66576

Ese código, por simple que sea, ya contiene un antipatrón y una decisión cuestionable. En la vida real, nunca debe escribir los recuentos de bytes como literales, sino usar la función sizeof. De manera similar, asignamos la matriz char * exactamente al tamaño de la cadena que necesitamos dos veces (una más que la longitud de la cadena, para tener en cuenta la terminación nula), lo cual es una operación bastante costosa. Un programa más sofisticado podría construir un búfer de cadena más grande, permitiendo que crezca el tamaño de la cadena.

La invención de RAII: una nueva esperanza

Toda esa gestión manual fue desagradable, por decir lo menos. A mediados de los 80, Bjarne Stroustrup inventó un nuevo paradigma para su nuevo lenguaje, C++. Lo llamó Adquisición de recursos es inicialización, y las ideas fundamentales fueron las siguientes: los objetos se pueden especificar para tener constructores y destructores que el compilador llama automáticamente en los momentos apropiados, esto proporciona una forma mucho más conveniente de administrar la memoria de un objeto dado. requiere, y la técnica también es útil para recursos que no son memoria.

Esto significa que el ejemplo anterior, en C++, es mucho más limpio:

 int main() { std::string str = std::string ("toptal"); std::cout << "string object: " << str << " @ " << &str << "\n"; str += ".com"; std::cout << "string object: " << str << " @ " << &str << "\n"; return(0); }
 $ g++ -o ex_1 ex_1.cpp && ./ex_1 string object: toptal @ 0x5fcaf0 string object: toptal.com @ 0x5fcaf0

¡Ninguna gestión de memoria manual a la vista! El objeto de cadena se construye, se llama a un método sobrecargado y se destruye automáticamente cuando la función finaliza. Desafortunadamente, esa misma simplicidad puede llevar a otras complicaciones. Veamos un ejemplo con cierto detalle:

 vector<string> read_lines_from_file(string &file_name) { vector<string> lines; string line; ifstream file_handle (file_name.c_str()); while (file_handle.good() && !file_handle.eof()) { getline(file_handle, line); lines.push_back(line); } file_handle.close(); return lines; } int main(int argc, char* argv[]) { // get file name from the first argument string file_name (argv[1]); int count = read_lines_from_file(file_name).size(); cout << "File " << file_name << " contains " << count << " lines."; return 0; }
 $ make cpp && ./c++ makefile g++ -o c++ c++.cpp File makefile contains 38 lines.

Todo eso parece bastante sencillo. Las líneas vectoriales se llenan, se devuelven y se llaman. Sin embargo, siendo programadores eficientes que se preocupan por el rendimiento, algo de esto nos molesta: en la declaración de retorno, el vector se copia en un nuevo vector debido a la semántica de valores en juego, poco antes de su destrucción.

Esto ya no es estrictamente cierto en C++ moderno. C ++ 11 introdujo la noción de semántica de movimiento, en la que el origen se deja en un estado válido (para que aún pueda destruirse correctamente) pero no especificado. Las llamadas de devolución son un caso muy fácil de optimizar para que el compilador mueva la semántica, ya que sabe que el origen se destruirá poco antes de cualquier otro acceso. Sin embargo, el propósito del ejemplo es demostrar por qué la gente inventó un montón de lenguajes recolectados a finales de los 80 y principios de los 90, y en esos tiempos la semántica de movimiento de C++ no estaba disponible.

Para grandes datos, esto puede ser costoso. Optimicemos esto y devolvamos un puntero. Hay algunos cambios de sintaxis, pero por lo demás es el mismo código:

En realidad, vector es un identificador de valor: una estructura relativamente pequeña que contiene punteros a elementos en el montón. Estrictamente hablando, no es un problema simplemente devolver el vector. El ejemplo funcionaría mejor si se devolviera una matriz grande. Como intentar leer un archivo en una matriz preasignada no tendría sentido, usamos el vector en su lugar. Solo pretenda que es una estructura de datos imprácticamente grande, por favor.

 vector<string> * read_lines_from_file(string &file_name) { vector<string> * lines; string line; ifstream file_handle (file_name.c_str()); while (file_handle.good() && !file_handle.eof()) { getline(file_handle, line); lines->push_back(line); } file_handle.close(); return lines; }
 $ make cpp && ./c++ makefile g++ -o c++ c++.cpp Segmentation fault (core dumped)

¡Ay! Ahora que las líneas son un puntero, podemos ver que las variables automáticas funcionan como se anuncia: el vector se destruye cuando se sale de su alcance, dejando el puntero apuntando a una ubicación hacia adelante en la pila. Una falla de segmentación es simplemente un intento de acceso a la memoria ilegal, por lo que realmente deberíamos haber esperado eso. Aún así, queremos recuperar las líneas del archivo de nuestra función de alguna manera, y lo natural es simplemente mover nuestra variable fuera de la pila y colocarla en el montón. Esto se hace con la nueva palabra clave. Simplemente podemos editar una línea de nuestro archivo, donde definimos líneas:

 vector<string> * lines = new vector<string>;
 $ make cpp && ./c++ makefile g++ -o c++ c++.cpp File makefile contains 38 lines.

Desafortunadamente, aunque esto parece funcionar perfectamente, todavía tiene un defecto: pierde memoria. En C++, los punteros al montón deben eliminarse manualmente cuando ya no se necesitan; de lo contrario, esa memoria deja de estar disponible una vez que el último puntero queda fuera del alcance y no se recupera hasta que el sistema operativo la administra cuando finaliza el proceso. Idiomatic moderno C ++ usaría un unique_ptr aquí, que implementa el comportamiento deseado. Elimina el objeto apuntado cuando el puntero queda fuera del alcance. Sin embargo, ese comportamiento no fue parte del lenguaje hasta C++11.

En este ejemplo, esto se puede arreglar fácilmente:

 vector<string> * read_lines_from_file(string &file_name) { vector<string> * lines = new vector<string>; string line; ifstream file_handle (file_name.c_str()); while (file_handle.good() && !file_handle.eof()) { getline(file_handle, line); lines->push_back(line); } file_handle.close(); return lines; } int main(int argc, char* argv[]) { // get file name from the first argument string file_name (argv[1]); vector<string> * file_lines = read_lines_from_file(file_name); int count = file_lines->size(); delete file_lines; cout << "File " << file_name << " contains " << count << " lines."; return 0; }

Desafortunadamente, a medida que los programas se expanden más allá de la escala de un juguete, rápidamente se vuelve más difícil razonar sobre dónde y cuándo exactamente se debe eliminar un puntero. Cuando una función devuelve un puntero, ¿lo posee ahora? ¿Debería eliminarlo usted mismo cuando haya terminado con él, o pertenece a alguna estructura de datos que se liberará de una vez más adelante? Hágalo mal de una manera y se perderá la memoria, hágalo mal de la otra y habrá corrompido la estructura de datos en cuestión y probablemente otras, ya que intentan eliminar la referencia de los punteros que ahora ya no son válidos.

Relacionado: Depuración de fugas de memoria en aplicaciones Node.js

"¡Al recolector de basura, piloto!"

Los recolectores de basura no son una tecnología nueva. Fueron inventados en 1959 por John McCarthy para Lisp. Con Smalltalk-80 en 1980, la recolección de basura comenzó a generalizarse. Sin embargo, la década de 1990 representó el verdadero florecimiento de la técnica: entre 1990 y 2000, se lanzaron una gran cantidad de lenguajes, todos los cuales usaban recolección de basura de un tipo u otro: Haskell, Python, Lua, Java, JavaScript, Ruby, OCaml y C# se encuentran entre los más conocidos.

¿Qué es la recolección de basura? En resumen, es un conjunto de técnicas utilizadas para automatizar la gestión manual de la memoria. A menudo está disponible como una biblioteca para idiomas con administración de memoria manual, como C y C++, pero se usa mucho más comúnmente en idiomas que lo requieren. La gran ventaja es que el programador simplemente no necesita pensar en la memoria; todo está abstraído. Por ejemplo, el equivalente de Python a nuestro código de lectura de archivos anterior es simplemente este:

 def read_lines_from_file(file_name): lines = [] with open(file_name) as fp: for line in fp: lines.append(line) return lines if __name__ == '__main__': import sys file_name = sys.argv[1] count = len(read_lines_from_file(file_name)) print("File {} contains {} lines.".format(file_name, count))
 $ python3 python3.py makefile File makefile contains 38 lines.

La matriz de líneas surge cuando se asigna por primera vez y se devuelve sin copiar al ámbito de llamada. El recolector de basura lo limpia en algún momento después de que cae fuera de ese alcance, ya que el tiempo es indeterminado. Una nota interesante es que en Python, RAII para recursos que no son de memoria no es idiomático. Está permitido: simplemente podríamos haber escrito fp = open(file_name) en lugar de usar un bloque with , y dejar que el GC limpie después. Pero el patrón recomendado es usar un administrador de contexto cuando sea posible para que puedan ser liberados en momentos deterministas.

Tan bueno como es abstraerse de la gestión de la memoria, hay un costo. En la recolección de elementos no utilizados de recuento de referencias, todas las asignaciones de variables y salidas de alcance obtienen un pequeño costo para actualizar las referencias. En los sistemas de marcado y barrido, a intervalos impredecibles, se detiene la ejecución de todos los programas mientras el GC limpia la memoria. Esto a menudo se llama un evento de detener el mundo. Las implementaciones como Python, que usan ambos sistemas, sufren ambas penalizaciones. Estos problemas reducen la idoneidad de los lenguajes recolectados en los casos en los que el rendimiento es crítico o las aplicaciones en tiempo real son necesarias. Uno puede ver la penalización de rendimiento en acción incluso en estos programas de juguetes:

 $ make cpp && time ./c++ makefile g++ -o c++ c++.cpp File makefile contains 38 lines. real 0m0.016s user 0m0.000s sys 0m0.015s $ time python3 python3.py makefile File makefile contains 38 lines. real 0m0.041s user 0m0.015s sys 0m0.015s

La versión de Python requiere casi tres veces más tiempo real que la versión de C++. Si bien no toda esa diferencia se puede atribuir a la recolección de basura, sigue siendo considerable.

Propiedad: RAII despierta

¿Es ese el final, entonces? ¿Todos los lenguajes de programación deben elegir entre rendimiento y facilidad de programación? ¡No! La investigación del lenguaje de programación continúa y estamos comenzando a ver las primeras implementaciones de la próxima generación de paradigmas de lenguaje. De particular interés es el lenguaje llamado Rust, que promete una ergonomía similar a Python y una velocidad similar a C mientras hace que los punteros colgantes, los punteros nulos y demás sean imposibles: no se compilarán. ¿Cómo puede hacer esas afirmaciones?

La tecnología central que permite estas impresionantes afirmaciones se llama el verificador de préstamos, un verificador estático que se ejecuta en la compilación y rechaza el código que podría causar estos problemas. Sin embargo, antes de profundizar demasiado en las implicaciones, debemos hablar sobre los requisitos previos.

Propiedad

Recuerde que en nuestra discusión sobre punteros en C++, mencionamos la noción de propiedad, que en términos generales significa "quién es responsable de eliminar esta variable". Rust formaliza y fortalece este concepto. Cada vinculación variable tiene la propiedad del recurso que vincula, y el comprobador de préstamo garantiza que haya exactamente una vinculación que tenga la propiedad general del recurso. Es decir, el siguiente fragmento del Rust Book no se compilará:

 let v = vec![1, 2, 3]; let v2 = v; println!("v[0] is: {}", v[0]);
 error: use of moved value: `v` println!("v[0] is: {}", v[0]); ^

Las asignaciones en Rust tienen semántica de movimiento por defecto: transfieren la propiedad. Es posible dar semántica de copia a un tipo, y esto ya se hace para las primitivas numéricas, pero es inusual. Debido a esto, a partir de la tercera línea de código, v2 posee el vector en cuestión y ya no se puede acceder a él como v. ¿Por qué es útil? Cuando cada recurso tiene exactamente un propietario, también tiene un momento único en el que queda fuera del alcance, que se puede determinar en tiempo de compilación. Esto significa, a su vez, que Rust puede cumplir la promesa de RAII, inicializando y destruyendo recursos de manera determinista en función de su alcance, sin usar un recolector de basura ni requerir que el programador libere nada manualmente.

Compare esto con la recolección de basura de conteo de referencias. En una implementación RC, todos los punteros tienen al menos dos piezas de información: el objeto apuntado y el número de referencias a ese objeto. El objeto se destruye cuando ese conteo llega a 0. Esto duplica el requisito de memoria del puntero y agrega un pequeño costo a su uso, ya que el conteo se incrementa, decrementa y verifica automáticamente. El sistema de propiedad de Rust ofrece la misma garantía, que los objetos se destruyen automáticamente cuando se quedan sin referencias, pero lo hace sin ningún costo de tiempo de ejecución. Se analiza la propiedad de cada objeto y se insertan las llamadas de destrucción en tiempo de compilación.

Préstamo

Si la semántica de movimiento fuera la única forma de pasar datos, los tipos de devolución de función se volverían muy complicados, muy rápidos. Si quisiera escribir una función que usara dos vectores para producir un número entero, que luego no destruyera los vectores, tendría que incluirlos en el valor de retorno. Si bien eso es técnicamente posible, es terrible de usar:

 fn foo(v1: Vec<i32>, v2: Vec<i32>) -> (Vec<i32>, Vec<i32>, i32) { // do stuff with v1 and v2 // hand back ownership, and the result of our function (v1, v2, 42) } let v1 = vec![1, 2, 3]; let v2 = vec![1, 2, 3]; let (v1, v2, answer) = foo(v1, v2);

En cambio, Rust tiene el concepto de préstamo. Puede escribir la misma función así, y tomará prestada la referencia a los vectores, devolviéndosela al propietario cuando finalice la función:

 fn foo(v1: &Vec<i32>, v2: &Vec<i32>) -> i32 { // do stuff 42 } let v1 = vec![1, 2, 3]; let v2 = vec![1, 2, 3]; let answer = foo(&v1, &v2);

v1 y v2 devuelven su propiedad al ámbito original después de que fn foo regresa, queda fuera del ámbito y se destruye automáticamente cuando el ámbito contenedor sale.

Vale la pena mencionar aquí que existen restricciones sobre el préstamo, impuestas por el verificador de préstamos en tiempo de compilación, que el Rust Book expresa de manera muy sucinta:

Todo préstamo debe tener una duración no superior a la del propietario. En segundo lugar, puede tener uno u otro de estos dos tipos de préstamos, pero no ambos al mismo tiempo:

una o más referencias (&T) a un recurso

exactamente una referencia mutable (&mut T)

Esto es notable porque forma un aspecto crítico de la protección de Rust contra carreras de datos. Al evitar múltiples accesos mutables a un recurso dado en tiempo de compilación, garantiza que no se puede escribir código en el que el resultado sea indeterminado porque depende de qué subproceso llegó primero al recurso. Esto evita problemas como la invalidación del iterador y el uso después de la liberación.

El verificador de préstamos en términos prácticos

Ahora que conocemos algunas de las características de Rust, veamos cómo implementamos el mismo contador de línea de archivo que hemos visto antes:

 fn read_lines_from_file(file_name: &str) -> io::Result<Vec<String>> { // variables in Rust are immutable by default. The mut keyword allows them to be mutated. let mut lines = Vec::new(); let mut buffer = String::new(); if let Ok(mut fp) = OpenOptions::new().read(true).open(file_name) { // We enter this block only if the file was successfully opened. // This is one way to unwrap the Result<T, E> type Rust uses instead of exceptions. // fp.read_to_string can return an Err. The try! macro passes such errors // upwards through the call stack, or continues otherwise. try!(fp.read_to_string(&mut buffer)); lines = buffer.split("\n").map(|s| s.to_string()).collect(); } Ok(lines) } fn main() { // Get file name from the first argument. // Note that args().nth() produces an Option<T>. To get at the actual argument, we use // the .expect() function, which panics with the given message if nth() returned None, // indicating that there weren't at least that many arguments. Contrast with C++, which // segfaults when there aren't enough arguments, or Python, which raises an IndexError. // In Rust, error cases *must* be accounted for. let file_name = env::args().nth(1).expect("This program requires at least one argument!"); if let Ok(file_lines) = read_lines_from_file(&file_name) { println!("File {} contains {} lines.", file_name, file_lines.len()); } else { // read_lines_from_file returned an error println!("Could not read file {}", file_name); } }

Más allá de los elementos ya comentados en el código fuente, vale la pena revisar y rastrear la vida útil de las diversas variables. file_name y file_lines duran hasta el final de main(); sus destructores son llamados en ese momento sin costo extra, usando el mismo mecanismo que las variables automáticas de C++. Al llamar a read_lines_from_file , file_name se presta inmutablemente a esa función durante su duración. Dentro de read_lines_from_file , el buffer actúa de la misma manera, se destruye cuando queda fuera del alcance. lines , por otro lado, persiste y se devuelve con éxito a main . ¿Por qué?

Lo primero que debe tener en cuenta es que, dado que Rust es un lenguaje basado en expresiones, la llamada de retorno puede no parecerlo al principio. Si la última línea de una función omite el punto y coma final, esa expresión es el valor de retorno. La segunda cosa es que los valores devueltos reciben un manejo especial. Se supone que quieren vivir al menos tanto como la persona que llama a la función. La nota final es que debido a la semántica de movimiento involucrada, no se necesita una copia para transmutar Ok(lines) en Ok(file_lines) , el compilador simplemente hace que la variable apunte al bit de memoria apropiado.

“Solo al final te das cuenta del verdadero poder de RAII”.

La gestión manual de la memoria es una pesadilla que los programadores han estado inventando formas de evitar desde la invención del compilador. RAII era un patrón prometedor, pero estaba paralizado en C++ porque simplemente no funcionaba para los objetos asignados en montón sin algunas soluciones extrañas. En consecuencia, hubo una explosión de lenguajes recolectados en la basura en los años 90, diseñados para hacer la vida más placentera para el programador incluso a expensas del rendimiento.

Sin embargo, esa no es la última palabra en el diseño de lenguajes. Mediante el uso de nociones nuevas y sólidas de propiedad y préstamo, Rust logra fusionar la base de alcance de los patrones RAII con la seguridad de la memoria de la recolección de basura; todo sin necesidad de que un recolector de basura detenga el mundo, al tiempo que ofrece garantías de seguridad que no se ven en ningún otro idioma. Este es el futuro de la programación de sistemas. Después de todo, “errar es humano, pero los compiladores nunca olvidan”.


Lecturas adicionales en el blog de ingeniería de Toptal:

  • Tutorial de WebAssembly/Rust: Procesamiento de audio perfecto
  • Cazando fugas de memoria en Java