HTML5 web worker: truyền dữ liệu

HTML5 web worker: truyền dữ liệu
Photo by Nejc Soklič from Unsplash

Trong bài viết trước, chúng ta đã tìm hiểu những điều cơ bản về web worker cùng cách sử dụng đơn giản của nó. Trong bài viết này, chúng ta sẽ tìm hiểu sâu hơn về cơ ché truyền và nhận dữ liệu giữa thread chính của trang web và worker.

Cơ bản về việc truyền và nhận dữ liệu

Dữ liệu được truyền và nhận giữa thread chính và worker theo phương pháp clone, chứ không truyền cùng một object. Object được serialize sau đó được truyền cho worker, rồi lại deserialize lúc nhận được. Thread chính và worker không bao giờ sử dụng chung một instance, dữ liệu nhận được ở một bên luôn là bản sao của dữ liệu từ bên còn lại.

Phần lớn các trình duyệt đều cài đặt tính năng này bằng thuật toán structured cloning.

Để minh hoạ cho quá trình này, chúng ta hãy xem xét một ví dụ như sau:

const emulateMsg = (val) => {
    return eval(`(${JSON.stringify(val)})`);
}

Hàm emulateMsg mô phỏng hành vi của các tham số được truyền bằng cách clone chứ không phải giữ nguyên object cũ. Chúng ta có thể test thử xem sao:

val1 = new Number(10)
typeof val1 // "object"
typeof emulateMsg(val1) // "number"
val2 = true
typeof val2 // "boolean"
typeof emulateMsg(val2) // "boolean"
val3 = new String('foo')
typeof val3 // "object"
typeof emulateMsg(val3) // "string"
val4 = {foo: 'foo', bar: 'bar'}
typeof val4 // "object"
typeof emulateMsg(val4) // "object"

Như chúng ta đã biết, việc truyền và nhận dữ liệu từ thread chính và worker được thực hiện bằng cách truyền tham số vào hàm postMessage và giá trị của thuộc tính data trong message event. Với cách truyền tham số bằng clone như trên, việc gửi và nhận dữ liệu của thread chính và worker cũng hoàn toàn là copy.

Đó cũng là lý do khiến cho dữ liệu truyền và nhận giữa worker được gọi là “message”. Thuật toán structured cloning có thể nhận dữ liệu dưới dạng JSON và cả những dữ liệu phức tạp khác nữa.

Ví dụ

Nếu cần phải truyền dữ liệu phức tạp, mà dữ liệu đó được gọi ở nhiều nơi cả thread chính lẫn worker thì chúng ta có thể xây dựng một hệ thống gộp chung tất cả.

Trước hết, chúng ta tạo một class QueryableWorker, dùng để track các handler và giúp chúng ta giao tiếp với worker.

class QueryableWorker {
    constructor(url, defaultHandler, errorHandler) {
        this.worker = new Worker(url);
        this.handlers = {};
        this.defaultHandler = defaultHandler || function(){};

        if (errorHandler) {
            this.worker.onerror = errorHandler;
        }

        this.worker.onmessage = (event) => {
            if (event.data instanceof Object &&
                event.data.hasOwnProperty('method') &&
                event.data.hasOwnProperty('arguments')) {
                    this.handlers[event.data.method].apply(this,
                        event.data.arguments);
            } else {
                this.defaultHandler.call(this, event.data);
            }
        }
    }

    postMessage(message) {
        this.worker.postMessage(message);
    }

    terminate() {
        this.worker.terminate();
    }

    addHandler(name, handler) {
        this.handlers[name] = handler;
    }

    removeHandler(name) {
        delete this.handlers[name];
    }

    sendQuery(...args) {
        if (args.length < 1 ) {
            throw new TypeError('at least 1 argument');
        }

        this.worker.postMessage({
            method: args[0],
            arguments: Array.prototype.slice.call(args, 1)
        })
    }
}

Trong class trên, chúng ta sử dụng hai phương thức để thêm và bớt các handler.

Các handler sẽ được thêm vào tuỳ ý sau khi tạo ra object thuộc class trên, cho phép nó có thể linh hoạt trong việc xử lý các phản hồi từ worker (xử lý thế nào hoàn toàn phụ thuộc vào handler được thêm vào).

Còn phương thức sendQuery đùng dể gửi dữ liệu đến worker (bao gồm tên phương thức và tham số), yêu cầu worker thực hiện thao tác tương ứng.

Với worker, để minh hoạ, chúng ta sẽ xây dựng worker thực hiện hai thao tác đơn giản: lấy một số ngẫu nhiên, tạm dừng 1 giây.

const reply = (...args) => {
    if (args.length < 1 ) {
        throw new TypeError('at least 1 argument');
    }
    postMessage({
        method: args[0],
        arguments: Array.prototype.slice.call(args, 1)
    })
}

const queryableFunctions = {
    getRandom: () => {
        reply('printStuff', Math.random());
    },
    waitSomeTime: () => {
        setTimeout(() => {reply('doAlert', 1, 'seconds')}, 1000);
    }
}

Phương thức onmessage của worker rất đơn giản: gọi và thực thi các phương thức cùng tham số nhận được. Code chỗ này tương tự như onmessage của thread chính, chỉ khác đó là nó sẽ gọi các hàm được định nghĩa sẵn chứ không phải handler được thêm vào sau.

onmessage = (event) => {
    if (event.data instanceof Object &&
        event.data.hasOwnProperty('method') &&
        event.data.hasOwnProperty('arguments')) {
        queryableFunctions[event.data.method].apply(self,
            event.data.arguments)
    } else {
        // do something
    }
}

Demo cùng toàn bộ code của ví dụ này, mời các bạn xem ở đây.

Transferable

Structured cloning rất tuyệt vời, nhưng hoạt động của nó cũng là hành động copy dữ liệu mà thôi. Trong một số trường hợp cần truyền dữ liệu với kích thước lớn, structured cloning lại khiến tốc độ bị chậm đi. Ví dụ, nếu chúng ta cần truyền một ArrayBuffer khoảng vài chục MB chẳng hạn, sẽ phải mất kha khá thời gian (khoảng nửa giây).

Nửa giây nghe thì ít nhưng với các ứng dụng hiện nay, như vậy đã là một sự trễ giờ quá lớn. Rất may, các trình duyệt hiện đại đã có một cơ chế trong trường hợp này, đó là Transferable object.

Các object thuộc loại Transferable sẽ được truyền mà không hề cần tới thao tác clone. Điều đó sẽ khiến cho tốc độ tăng lên đáng kể. Tuy nhiên, việc sử dụng cách truyền dữ liệu này cũng có những tác dụng phụ. Không giống với cách thức truyền dữ liệu thông thường, dữ liệu truyền bằng cách này sẽ không còn truy cập được ở nơi đã truyền nó đi nữa.

Ví dụ, nếu chúng ta truyền một ArrayBuffer từ thread chính vào worker thì dữ liệu của ArrayBuffer đó ở thread chính sẽ bị xoá và không thể truy cập được nữa. Có thể nói, toàn bộ dữ liệu đã được di chuyển hoàn toàn vào worker và không còn tồn tại ở chỗ cũ nữa.

Để truyền dữ liệu vào worker dạng Transferable, chúng ta cần truyền hai tham số cho hàm postMessage: tham số đầu tiền là message, tham số thứ hai là danh sách các item cần transfer. Trong trường hợp này, chúng ta có thể truyền như sau:

const uInt8Array = new Uint8Array(1024 * 1028 * 32); // 32 MB
for (let i = 0; i < uInt8Array.length; i++) {
    uInt8Array[i] = i;
}
worker.postMessage(uInt8Array.buffer, [uInt8Array.buffer]);

So sánh tốc độ

Dưới đây là cấu hình máy tính dùng để so sánh tốc độ giữa cách truyền dữ liệu thông thường (structured cloning) và cách dùng Transferable.

cau hinh

Với máy tính như vậy, tôi sẽ thực hiện so sánh với các trình duyệt Safari, ChromeFirefox. Chúng ta sẽ so sánh tốc độ truyền dữ liệu với kích thước lớn (500MB) bằng hai phương pháp khác nhau.

Ở đây, chúng ta chỉ so sánh tốc độ truyền tải dữ liệu giữa thread chính và worker, không so sánh tốc độ xử lý khi nhận dữ liệu. Dưới đây là code dùng để so sánh:

Truyền dử dụng bằng structured cloning

const myWorker = new Worker("worker.js");
const data = new Uint8Array(500 * 1024 * 1024);
const startTime = new Date().getTime();
myWorker.postMessage(data);
const timeTaken = new Date().getTime() - startTime;
console.log(`Transfer completed in ${timeTaken}ms.`);

Transferable

const myWorker = new Worker("worker.js");
const data = new Uint8Array(500 * 1024 * 1024);
const startTime = new Date().getTime();
myWorker.postMessage(data, [data.buffer]);
const timeTaken = new Date().getTime() - startTime;
console.log(`Transfer completed in ${timeTaken}ms.`);

Dưới đây là bảng kết quả đo được khi sử dụng truyền dữ liệu bằng hai phương thức này:

Safari1168ms1ms
Chrome604ms10ms
Firefox690ms1ms

Nhìn vào đây chúng ta cũng thấy rằng, sử dụng Transferable để truyền dữ liệu giữa worker và thread chính có tốc độ cao hơn hẳn (nhanh hơn hàng trăm lần, thậm chí với Safari là hơn nghìn lần). Đây có thể là một lựa chọn tốt để truyền những dữ liệu lớn nhưng không có nhu cầu tái sử dụng ở nguồn.

Vấn đề về hiệu năng với Transferable phức tạp

Trong phần trước, chúng ta đã so sánh tốc độ truyền dữ liệu giữa cách thông thường và dùng Transferable. Và chúng ta đã thấy rằng, tốc độ khi sử dụng Transferable đã từng lên đáng kể. Thế nhưng, đó là trường hợp dữ liệu đơn giản, dữ liệu của message và của buffer tương ứng với nhau.

Tuy nhiên, trong các trường hợp phức tạp hơn, mọi chuyện không còn như vậy nữa. Dưới đây là một đoạn code dùng để test tốc độ truyền tải dữ liệu giữa thread chính và worker bằng hai phương pháp đó. Điểm khác biệt ở đây là dữ liệu mà chúng ta truyền tải không phải dữ liệu đơn giản (chỉ là một mảng nữa) mà phức tạp hơn nhiều.

Chúng ta sẽ truyền tải message là một mảng hai chiều, và một mảng buffer cũng là mảng hai chiều của các buffer. Trong thực tế, chúng ta có thể gặp các trường hợp dữ liệu phức tạp hơn nữa.

function transferByLine(line, transferable) {
    var arr = [];
    var bufferArr = [];
    for (var i = 0; i < line; i++) {
        arr[i] = new Uint8Array(100);
        if (transferable) {
            bufferArr.push(arr[i].buffer);
        }
    }
    console.log('Successfully created the array.');
    console.log(`The array has ${line} items, each item size is 100 bytes`);
    console.log(`Start transferring with transferable=${transferable}...`);
    var startTime = new Date().getTime();
    if (!transferable) {
        myWorker.postMessage(arr);
    } else {
        myWorker.postMessage(arr, bufferArr);
    }
    var timeTaken = new Date().getTime() - startTime;
    console.log(`Transfer completed in ${timeTaken}ms.`);
}

Dưới đây là kết quả kiểm tra với các kích thước khác nhau của dữ liệu:

20006ms / 3ms3ms / 11ms7ms / 11ms
400021ms / 4ms1ms / 20ms37ms / 19ms
600017ms / 9ms2ms / 40ms22ms / 28ms
800019ms / 15ms19ms / 56ms57ms / 56ms
1000033ms / 14ms10ms / 70ms78ms / 53ms
2000063ms / 35ms28ms / 205ms84ms / 192ms
40000106ms / 67ms67ms / 678ms187ms / 203ms
60000137ms / 106ms92ms / 1417ms357ms / 552ms
80000218ms / 146ms169ms / 2415ms595ms / 490ms
100000266ms / 174ms170ms / 3796ms834ms / 717ms
200000521ms / 391ms453ms / 15572ms866ms / 865ms

Nhìn các con số hơi khô khan một chút, chúng ta hãy xem biểu đồ minh hoạ cho kết quả này như sau:

chart

Trong số các trình duyệt được test, chỉ có Safari là giữ được phong độ khi tốc độ truyền tải bằng Transferable vẫn nhanh hơn cách truyền thống. Chrome và Firefox đều sa sút khi tốc độ truyền tải bằng cách truyền thống còn nhanh hơn. Tuy nhiên đó chưa phải là điều đáng nói ở đây.

Nếu để ý, chúng ta sẽ thấy rằng, khi kích thước dữ liệu tăng lên,thời gian truyền tải của Safari hay Firefox tăng rất từ từ, cả cách truyền thống lẫn dùng Transferable.

Thế nhưng, riêng với Chrome, thời gian truyền tải tăng theo hàm mũ khi sử dụng Transferable, trong khi cách truyền thống vẫn tăng tuyến tính giống Safari và Firefox. Nguyên nhân có thể là do Chrome mất nhiều thời gian để phân tích tham số thứ hai của hàm postMessage (một mảng các mảng buffer) và tìm cách map nó mới tham số thứ nhất. Rõ ràng Chrome không hề được tối ưu cho thao tác này.

Kết luận

Web worker có hai phương thức khác nhau để truyền tải dữ liệu giữa thread chính và worker. Mỗi phương thức đều có những ưu điểm riêng. Nếu cần truyền tải dữ liệu với kích thước lớn, chúng ta có thể sử dụng Transferable nếu dữ liệu đó chỉ được lưu ở một vài biến. Trong trường hợp dữ liệu kích thước lớn có cấu trúc phức tạp, được lưu bằng một mảng nhiều phần tử, thì cách truyền thống dùng structured cloning lại tốt hơn.

Tôi xin lỗi nếu bài viết có bất kỳ typo nào. Nếu bạn nhận thấy điều gì bất thường, xin hãy cho tôi biết.

Nếu có bất điều gì muốn nói, bạn có thể liên hệ với tôi qua các mạng xã hội, tạo discussion hoặc report issue trên Github.