Transferable 객체의 새로운 정의 방법

컨텍스트 간 사용자 정의 객체 전달

MessagePort는 서로 다른 컨텍스트 간에 메시지를 교환하기 위한 메커니즘입니다. V8 자바스크립트 엔진은 기본 객체들의 컨텍스트 간 이동 시 직렬화와 역직렬화 방법을 제공하며, 사용자 객체에 대해서도 직렬화와 역직렬화를 위한 인터페이스를 제공합니다. 그러나 V8이 기존에 제공하던 이러한 인터페이스에는 문제점이 있어, Web 호환성 API 등의 기능을 구현할 때 성능 저하가 발생할 수 있었습니다. 이에 대한 개선을 위한 제안이 등장하여 해당 제안을 검토해보았습니다.

기존 방법

Transferable objects

Transferable object는 한 컨텍스트에서 다른 컨텍스트로 전송 가능한 리소스를 포함하는 객체입니다. 이 객체는 전송하는 컨텍스트를 벗어난 후에는 사용할 수 없으며, 다른 컨텍스트에서만 활용 가능합니다. 객체를 전송한 후에는 해당 컨텍스트에서 더 이상 사용할 수 없으며, 전송된 객체를 사용하려고 시도하면 에러가 발생합니다. 예를 들어, ArrayBuffer를 전송하는 경우 내부 버퍼가 분리되어 전송되며, 이후에 해당 버퍼에 접근하려고 하면 에러가 발생할 수 있습니다.

1
2
3
4
5
let ab = new ArrayBuffer(1);
worker.postMessage(ab, [ab]);
if (ab.byteLength == 0) {
// ab is transferred.
}

예: ArrayBuffer, MessagePort, ReadableStream, WritableStream, TransformStream, and so on.

HTML Structured clone algorithm

postMessage() 함수 내부에서 객체를 전송할 때에는 structured clone algorithm를 이 호출됩니다. 이 알고리즘이 지원하는 객체는 일반 반 객체로 한정하여 처리하며, 사용자가 별도로 정의한 Class와 같은 객체는 전송이 지원되지 않습니다.

Transferable in Node.js

이 알고리즘을 따라 일관된 형식으로 객체를 전송 가능하도록 만들기 위해서는 사용자 정의 객체를 Host Object로 취급되게 해야 합니다. 객체가 Host Object로 인식되면 V8 엔진은 사용자가 제공한 Serialize/Deserialize 대리자를 호출합니다.

문제는 Native 계층에서는 객체를 Host Object로 인식시키는 것이 가능하지만, JavaScript 계층에서는 이를 수행할 방법이 없었습니다. 이에 따라, Native 층에서 JSTransferable 클래스를 생성하고 이를 JavaScript 객체 프로토타입 체인의 상위에 등록하여 Host Object로 인식되도록 했습니다. 그리고 해당 클래스에 Private Symbol ([kTransfer], [kClone], [kDeserialize])을 이용한 함수를 구현하여 Serialize 및 Deserialize 작업이 해당 클래스의 대리자로 호출되도록 구현했습니다.

또 다른 문제는 WritableStream 및 ReadableStream과 같은 Web API가 표준에서 다른 클래스를 상속하도록 정의되어 있지 않아, JSTransferable로 이러한 클래스를 확장할 수 없었습니다. 이를 해결하기 위해 makeTransferable이라는 일종의 해킹 함수를 사용하여 상속 관계를 조작했습니다. 이 방식은 표준을 올바르게 구현하는 방법이긴 하지만, 인스턴스가 생성될 때마다 makeTransferable 함수가 호출되기 때문에 성능 측면에서는 좋지 않을 수 있었습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function makeTransferable(obj) {
// If the object is already transferable, skip all this.
if (obj instanceof JSTransferable) return obj;
const inst = ReflectConstruct(JSTransferable, [], obj.constructor);
const properties = ObjectGetOwnPropertyDescriptors(obj);
const propertiesValues = ObjectValues(properties);

for (let i = 0; i < propertiesValues.length; i++) {
// We want to use null-prototype objects to not rely on globally mutable
// %Object.prototype%.
ObjectSetPrototypeOf(propertiesValues[i], null);
}
ObjectDefineProperties(inst, properties);
ObjectSetPrototypeOf(inst, ObjectGetPrototypeOf(obj));
return inst;
}

새로운 방법

markTransferMode

앞에서 언급한 성능 문제를 개선하기 위해 새로운 패치가 적용되었습니다. JavaScript 객체에 대해서도 Host Object로 인식할 수 있도록 하기 위해 transfer_mode_private_symbol을 이용하는 방법이 도입되었습니다. 또한, V8 엔진에서 기존에 Host Object를 판단하는 방식으로 EmbedderFieldCount를 확인하던 것을 명시적으로 판단할 수 있도록 하기 위해 IsHostObject() 인터페이스가 추가되었습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// node/lib/internal/worker/js_transferable.js

/**
* Mark an object as being transferable or customized cloneable in
* `.postMessage()`.
* This should only applied to host objects like Web API interfaces, Node.js'
* built-in objects.
* Objects marked as cloneable and transferable should implement the method
* `@@kClone` and `@@kTransfer` respectively. Method `@@kDeserialize` is
* required to deserialize the data to a new instance.
*
* Example implementation of a cloneable interface (assuming its located in
* `internal/my_interface.js`):
*
* ```
* class MyInterface {
* constructor(...args) {
* markTransferMode(this, true); // <--
* this.args = args;
* }
* [kDeserialize](data) {
* this.args = data.args;
* }
* [kClone]() {
* return {
* data: { args: this.args },
* deserializeInfo: 'internal/my_interface:MyInterface',
* }
* }
* }
*
* module.exports = {
* MyInterface,
* };
* ```
* @param {object} obj Host objects that can be either cloned or transferred.
* @param {boolean} [cloneable] if the object can be cloned and `@@kClone` is
* implemented.
* @param {boolean} [transferable] if the object can be transferred and
* `@@kTransfer` is implemented.
*/
function markTransferMode(obj, cloneable = false, transferable = false) {
if ((typeof obj !== 'object' && typeof obj !== 'function') || obj === null)
return; // This object is a primitive and therefore already untransferable.
let mode = kDisallowCloneAndTransfer;
if (cloneable) mode |= kCloneable;
if (transferable) mode |= kTransferable;
obj[transfer_mode_private_symbol] = mode; // <--
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// src/node_messaging.cc

// static
bool JSTransferable::IsJSTransferable(Environment* env,
v8::Local<v8::Context> context,
v8::Local<v8::Object> object) {
return object->HasPrivate(context, env->transfer_mode_private_symbol()) // <--
.ToChecked();
}

// Provide a wrapper class created when a built-in JS classes that being
// transferable or cloneable by postMessage().
// See e.g. FileHandle in internal/fs/promises.js for an example.
class JSTransferable : public BaseObject {
public:
static JSTransferable* Wrap(Environment* env, v8::Local<v8::Object> target);
static bool IsJSTransferable(Environment* env,
v8::Local<v8::Context> context,
v8::Local<v8::Object> object);
}


// This tells V8 how to serialize objects that it does not understand
// (e.g. C++ objects) into the output buffer, in a way that our own
// DeserializerDelegate understands how to unpack.
class SerializerDelegate : public ValueSerializer::Delegate {
...

bool HasCustomHostObject(Isolate* isolate) override { return true; }

Maybe<bool> IsHostObject(Isolate* isolate, Local<Object> object) override {
if (BaseObject::IsBaseObject(object)) {
return Just(true);
}

return Just(JSTransferable::IsJSTransferable(env_, context_, object)); // <--
}

Maybe<bool> WriteHostObject(Isolate* isolate, Local<Object> object) override {
if (BaseObject::IsBaseObject(object)) {
return WriteHostObject(
BaseObjectPtr<BaseObject> { Unwrap<BaseObject>(object) });
}

if (JSTransferable::IsJSTransferable(env_, context_, object)) {
JSTransferable* js_transferable = JSTransferable::Wrap(env_, object);
return WriteHostObject(BaseObjectPtr<BaseObject>{js_transferable});
}

...
}
}


// This is used to tell V8 how to read transferred host objects, like other
// `MessagePort`s and `SharedArrayBuffer`s, and make new JS objects out of them.
class DeserializerDelegate : public ValueDeserializer::Delegate {
...

MaybeLocal<Object> ReadHostObject(Isolate* isolate) override {
// Identifying the index in the message's BaseObject array is sufficient.
uint32_t id;
if (!deserializer->ReadUint32(&id))
return MaybeLocal<Object>();

if (id != kNormalObject) {
CHECK_LT(id, host_objects_.size());
Local<Object> object = host_objects_[id]->object(isolate);

if (env_->js_transferable_constructor_template()->HasInstance(object)) {
return Unwrap<JSTransferable>(object)->target();
} else {
return object;
}
}

...
}
}

V8

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
// deps/v8/include/v8-value-serializer.h

/**
* Value serialization compatible with the HTML structured clone algorithm.
* The format is backward-compatible (i.e. safe to store to disk).
*/
class V8_EXPORT ValueSerializer {
/**
* The embedder overrides this method to enable custom host object filter
* with Delegate::IsHostObject.
*
* This method is called at most once per serializer.
*/
virtual bool HasCustomHostObject(Isolate* isolate);

/**
* The embedder overrides this method to determine if an JS object is a
* host object and needs to be serialized by the host.
*/
virtual Maybe<bool> IsHostObject(Isolate* isolate, Local<Object> object);
};

// deps/v8/src/api/api.cc

bool ValueSerializer::Delegate::HasCustomHostObject(Isolate* v8_isolate) {
return false;
}

Maybe<bool> ValueSerializer::Delegate::IsHostObject(Isolate* v8_isolate,
Local<Object> object) {
i::Isolate* i_isolate = reinterpret_cast<i::Isolate*>(v8_isolate);
i::Handle<i::JSObject> js_object =
i::Handle<i::JSObject>::cast(Utils::OpenHandle(*object));
return Just<bool>(
i::JSObject::GetEmbedderFieldCount(js_object->map(i_isolate)));
}

// deps/v8/src/objects/value-serializer.cc

ValueSerializer::ValueSerializer(Isolate* isolate,
v8::ValueSerializer::Delegate* delegate)
: isolate_(isolate),
delegate_(delegate),
zone_(isolate->allocator(), ZONE_NAME),
id_map_(isolate->heap(), ZoneAllocationPolicy(&zone_)),
array_buffer_transfer_map_(isolate->heap(),
ZoneAllocationPolicy(&zone_)) {
if (delegate_) {
v8::Isolate* v8_isolate = reinterpret_cast<v8::Isolate*>(isolate_);
has_custom_host_objects_ = delegate_->HasCustomHostObject(v8_isolate);
}
}

// 이전에 Host Object를 구별하는 방법이 EmbedderFieldCount를 확인하는 방법이었다면
// 명시적으로 이를 확인하는 방법을 추가하였다.

Maybe<bool> ValueSerializer::WriteJSReceiver(Handle<JSReceiver> receiver) {
...
HandleScope scope(isolate_);
switch (instance_type) {
case JS_ARRAY_TYPE:
return WriteJSArray(Handle<JSArray>::cast(receiver));
...
case JS_API_OBJECT_TYPE: {
Handle<JSObject> js_object = Handle<JSObject>::cast(receiver);
// if (JSObject::GetEmbedderFieldCount(js_object->map(isolate_))) { // -
Maybe<bool> is_host_object = IsHostObject(js_object); // +
if (is_host_object.IsNothing()) { // +
return is_host_object; // +
} // +
if (is_host_object.FromJust()) { // +
return WriteHostObject(js_object);
} else {
return WriteJSObject(js_object);
}
}
}
}

Maybe<bool> ValueSerializer::IsHostObject(Handle<JSObject> js_object) {
if (!has_custom_host_objects_) {
return Just<bool>(
JSObject::GetEmbedderFieldCount(js_object->map(isolate_)));
}
DCHECK_NOT_NULL(delegate_);

v8::Isolate* v8_isolate = reinterpret_cast<v8::Isolate*>(isolate_);

Maybe<bool> result =
delegate_->IsHostObject(v8_isolate, Utils::ToLocal(js_object)); // <--

RETURN_VALUE_IF_SCHEDULED_EXCEPTION(isolate_, Nothing<bool>());
DCHECK(!result.IsNothing());

if (V8_UNLIKELY(out_of_memory_)) return ThrowIfOutOfMemory();
return result;
}

References

Share