¿Por qué el bash de escribir un archivo grande hace que js heap se quede sin memoria?

este código

const file = require("fs").createWriteStream("./test.dat"); for(var i = 0; i < 1e7; i++){ file.write("a"); } 

da este mensaje de error después de ejecutar durante unos 30 segundos

  [47234:0x103001400] 27539 ms: Mark-sweep 1406.1 (1458.4) -> 1406.1 (1458.4) MB, 2641.4 / 0.0 ms allocation failure GC in old space requested [47234:0x103001400] 29526 ms: Mark-sweep 1406.1 (1458.4) -> 1406.1 (1438.9) MB, 1986.8 / 0.0 ms last resort GC in old spacerequested [47234:0x103001400] 32154 ms: Mark-sweep 1406.1 (1438.9) -> 1406.1 (1438.9) MB, 2628.3 / 0.0 ms last resort GC in old spacerequested  ==== JS stack trace ========================================= Security context: 0x30f4a8e25ee1  1: /* anonymous */ [/Users/matthewschupack/dev/streamTests/1/write.js:~1] [pc=0x270efe213894](this=0x30f4e07ed2f1 ,exports=0x30f4e07ed2f1 ,require=0x30f4e07ed2a9 ,module=0x30f4e07ed221 ,__filename=0x30f493b47221 <String[49]: /Users/matthewschupack/dev/streamTests/... FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory 1: node::Abort() [/usr/local/bin/node] 2: node::FatalException(v8::Isolate*, v8::Local, v8::Local) [/usr/local/bin/node] 3: v8::internal::V8::FatalProcessOutOfMemory(char const*, bool) [/usr/local/bin/node] 4: v8::internal::Factory::NewFillerObject(int, bool, v8::internal::AllocationSpace) [/usr/local/bin/node] 5: v8::internal::Runtime_AllocateInTargetSpace(int, v8::internal::Object**, v8::internal::Isolate*) [/usr/local/bin/node] 6: 0x270efe08463d 7: 0x270efe213894 8: 0x270efe174048 [1] 47234 abort node write.js 

mientras que este código

 const file = require("fs").createWriteStream("./test.dat"); for(var i = 0; i < 1e6; i++){ file.write("aaaaaaaaaa");//ten a's } 

corre perfectamente casi instantáneamente y produce un archivo de 10MB. Como lo entendí, el punto de las secuencias es que ambas versiones deberían ejecutarse en aproximadamente la misma cantidad de tiempo ya que los datos son idénticos. Incluso boost el número de a a 100 o 1000 por iteración apenas aumenta el tiempo de ejecución y escribe un archivo de 1GB sin ningún problema. Escribir un solo carácter por iteración en 1e6 iteraciones también funciona bien.

¿Que está pasando aqui?

El error de falta de memoria ocurre porque no está esperando que se emita el evento de drain , sin esperar que Node.js almacenará en búfer todos los fragmentos escritos hasta que se produzca el uso máximo de memoria.

.write devolverá false si el búfer interno es mayor que highWaterMark que por defecto es de 16384 bytes (16kb). En su código, no está manejando el valor de retorno de .write , por lo que el búfer nunca se vacía.

Esto se puede probar muy fácilmente usando: tail -f test.dat

Al ejecutar su script, verá que no se está escribiendo nada en test.dat hasta que el script finalice.

Para 1e7 el búfer se debe borrar 610 veces.

 1e7 / 16384 = 610 

Una solución es envolver .write y, si se devuelve false , esperar hasta que se emita el evento de drain

NOTA: writable.writableHighWaterMark se agregó en el nodo v9.3.0

 const file = require("fs").createWriteStream("./test.dat"); function write(stream, data) { if(!stream.write(data)) return new Promise(resolve => stream.once('drain', resolve)); return true; } (async() => { for(let i = 0; i < 1e7; i++) { const res = write(file, 'a'); // Will pause every 16384 iterations until `drain` is emitted if(res instanceof Promise) await res; } })(); 

Ahora, si realiza tail -f test.dat , verá cómo se escriben los datos mientras se ejecuta el script.


A partir del motivo por el que tiene problemas de memoria con 1e7 y no con 1e6, debemos analizar cómo Node.Js realiza el almacenamiento en búfer, que ocurre en la función writeOrBuffer .

Este código de ejemplo nos permitirá tener una estimación aproximada del uso de la memoria:

 const count = Number(process.argv[2]) || 1e6; const state = {}; function nop() {} const buffer = (data) => { const last = state.lastBufferedRequest; state.lastBufferedRequest = { chunk: Buffer.from(data), encoding: 'buffer', isBuf: true, callback: nop, next: null }; if(last) last.next = state.lastBufferedRequest; else state.bufferedRequest = state.lastBufferedRequest; state.bufferedRequestCount += 1; } const start = process.memoryUsage().heapUsed; for(let i = 0; i < count; i++) { buffer('a'); } const used = (process.memoryUsage().heapUsed - start) / 1024 / 1024; console.log(`${Math.round(used * 100) / 100} MB`); 

Cuando se ejecuta:

 // node memory.js  1e4: 1.98 MB 1e5: 16.75 MB 1e6: 160 MB 5e6: 801.74 MB 8e6: 1282.22 MB 9e6: 1442.22 MB - Out of memory 1e7: 1602.97 MB - Out of memory 

Entonces, cada objeto usa ~0.16 kb , y al hacer las writes 1e7 sin esperar drain evento de drain , tiene 10 millones de esos objetos en la memoria (para ser justos, se bloquea antes de alcanzar los 10M)

No importa si usas a o 1000, el aumento de memoria es insignificante.


Puede boost la memoria máxima utilizada por el nodo con el --max_old_space_size={MB} :

 node --max_old_space_size=4096 memory.js 1e7 

ACTUALIZACIÓN : Cometí un error en el fragmento de memoria que llevó a un aumento del 30% en el uso de memoria. Estaba creando una nueva callback para cada .write , Node reutiliza la callback nop .


ACTUALIZACIÓN II

Si está escribiendo siempre el mismo valor (dudoso en un escenario real), puede reducir considerablemente el uso de memoria y el tiempo de ejecución pasando el mismo búfer cada vez:

 const buf = Buffer.from('a'); for(let i = 0; i < 1e7; i++) { const res = write(file, buf); // Will pause every 16384 iterations until `drain` is emitted if(res instanceof Promise) await res; } 
    Intereting Posts