Created attachment 384152 [details]
postMessage with a long list of transferables
Over in the the Mapbox GL JS project, we've seen severe memory growth in WebKit and have been tracking the issue on https://github.com/mapbox/mapbox-gl-js/issues/8771. Severe memory growth means that the page's process can quickly jump to more than a gigabyte of memory when continually loading tiles (e.g. panning or zooming).
We've observed that this only happens when using web workers. We use web workers to load and process tiled map data for use with WebGL, then send it to the main thread. A typical tile can have up to a few hundred ArrayBuffers. We are using `postMessage` with an array of transferable objects to avoid buffer copies.
Furthermore, we've observed that _disabling_ transferable objects mostly fixes the memory growth issue and the process stays at ~500-600 MB.
I've dug in to find out why this happens:
When sending a message to another thread with `postMessage` (https://github.com/WebKit/webkit/blob/6a89f57fb9482c4f5afef8a863d7b0dc08ab7b94/Source/WebCore/workers/Worker.cpp#L133), WebKit creates a `SerializedScriptValue` object, passing in a list of transferable objects. The message gets serialized by the `CloneSerializer`, which adds indices to the ArrayBuffers stored directly in the `SerializedScriptValue` (https://github.com/WebKit/webkit/blob/6a89f57fb9482c4f5afef8a863d7b0dc08ab7b94/Source/WebCore/bindings/js/SerializedScriptValue.h#L120) object.
The message with the `SerializedScriptValue` gets pushed to the worker's or main thread's queue. Once the event get's dispatched (https://github.com/WebKit/webkit/blob/6a89f57fb9482c4f5afef8a863d7b0dc08ab7b94/Source/WebCore/workers/WorkerMessagingProxy.cpp#L120), a new `MessageEvent` object is created, which obtains ownership of the `SerializedScriptValue`.
WebKit doesn't seem to garbage collect `MessageEvent` objects frequently enough. I instrumented `SerializedScriptValue` and found that it can accumulate several thousand of those objects until GC kicks in, causing the process to allocate more and more memory. This means it's not technically a leak, but still causes excessive memory growth.
A few other observations:
The `MessageEvent` has to actually be dispatched into JS land. If there's no event listener, the `SerializedScriptValue`s get destructed immediately when the message is processed on the other thread. This means that the mere creation of a `MessageEvent` doesn't cause the leak.
Memory grows regardless of whether the value is ever _deserialized_. I rebuilt WebKit with deserialization disabled entirely and still observed the memory growth caused by WebKit hanging on to `MessageEvent` objects.
While the memory growth is impacted by the byte count of the transferred ArrayBuffers, the _number_ of elements in the transferables list has at least an equal effect. I created a small benchmark that allocates zero-size ArrayBuffer, then adds the same object 8192 times to the list of transferables. This causes the vector allocated to hold the ArrayBufferContent objects to become pretty large even if all of its members are zero because WebKit filters duplicates.
When attaching a smallish ArrayBuffer to the `MessageEvent` object when it is _received_ by just executing `event.foo = new ArrayBuffer(32768)`, I was able to trigger GC of those `MessageEvent` objects much more frequently, mitigating the memory growth somewhat. This leads me to believe that GC isn't aware of the actual size of `MessageEvent` objects. It should be a combination of the byte size of the stored ArrayBuffers and the count of them.
I've used the GC Heap Inspector and it looks like the `MessageEvent` objects aren't referenced from a GC root.
The direction of transfer (worker → main, or main → worker) doesn't seem to matter for how frequently they are GCed.
*** This bug has been marked as a duplicate of bug 203990 ***