Cơ bản về Web Workers

Worker Diagram

Introduction

JavaScript là một môi trường đơn luồng (single-threaded environment), có thể hiểu đơn giản là tại một thời điểm nhất định chỉ có một script được thực thi. Trong thời gian gần đây, việc sử dụng JavaScript trong việc xây dựng các ứng dụng web trở nên phổ biến hơn bao giờ hết, các công việc được đưa phần lớn về phía client. Tính chất đơn luồng của JavaScript là một cản trở đối với việc trên do nó làm ảnh hường khá lớn đến hiệu năng của các ứng dụng web. Một ví dụ đơn giản là các ứng dụng web thường phải truy vấn database, thực hiện các thay đổi phức tạp trên DOM, xử lý các event liên quan đến UI và request đến các API bên thứ ba - khá nhiều việc cho một thread.

Trong quá khứ chúng ta thường sử dụng setInterval , setTimeout, XMLHttpRequestevent handlers để thực hiện quá trình đồng bộ hóa (concurrency). Sử dụng các trick trên cho phép thực thi code một cách bất đồng bộ, nhưng nó không hoàn toàn không có nghĩa chúng ta đang thực hiện concurrency một cách đầy đủ. Hiện tại WEB API cung cấp một công cụ tốt hơn để thực hiện multi-threading task một cách hiệu quả hơn - Web Workers. Web Workers không phải là một công nghệ mới khi nó ra đời cùng với HTML5, nhiệm vụ chính của các Workers là thực hiện các scripts xử lý các công việc nặng (querying API, complex mathematical computations) ở background và tách biệt với các scripts liên quan đến UI (thường là Main Thread). Việc này cho phép long-running scripts được thực thi mà không gây ảnh hướng đến trải nghiệm của người dùng.

Trong bài viết này mình sẽ trình bày những kiến thức cơ bản liên quan đến Web Workers, cách hoạt động, các use-cases cũng như những lưu ý khi sử dụng công cụ này khi phát triển.

Web Workers

Definition

Web Worker (hay Worker) là một đối tượng JavaScript được tạo bằng việc sử dụng Worker constructor (e.g Worker, SharedWorker, AudioWorker) với tham số là tên (đường dẫn) đến một JavaScript file. JavaScript file đó chứa các logic được khởi chạy bên trong thread của worker vừa tạo. Script bên trong Worker sẽ được thực thi ở background và tách biệt khỏi thread chính của web application.

Trong bài viết này, chúng ta sẽ chủ yếu tìm hiểu về Worker (DedicatedWorker). Để bắt đầu chúng ta sẽ tạo một instance của DedicatedWorker và hiển thị nó trong console. Để đơn giản chúng ta giả sử những logic bên dưới được thực hiện bên trong script tag của một file HTML.

var worker = new Worker('prime.js')
console.log(worker)
console.log(this)

Dedicated Worker Instance

Sau khi đã log Worker instance và context hiện tại chúng ta có thể thấy một số API cơ bản của Worker như onerror, onmessage, postMessageterminate. Những API này sẽ được đề cập đến trong phần sau của bài viết. Chú ý rằng context hiện tại là global context hay Window. Bây giờ chúng ta sẽ chỉnh sửa file prime.js và thêm một dòng alert đơn giản alert("Hello from the outside!") và reload lại trình duyệt. Quan sát kết quả trong console ta sẽ thấy một error.

Worker DOM error

Chúng ta dùng JavaScript nhưng không thể truy cập được alert function. Nguyên nhân ở đây là prime.js sẽ được thực thi bên trong một context khác với Window. Script bên trong worker được phép truy cập đến các DOM API tuy nhiên không phải tất cả các API đều truy cập được bên trong Worker. Để kiểm tra context hiện tại, chúng ta sẽ thêm dòng lệnh đơn giản sau vào bên trong prime.js: console.log(this). Kết quả thu được sẽ như hình bên dưới:

Dedicated Worker Global Scope

Để ý rằng context hiện tại đã được thay đổi thành DedicatedWorkerGlobalScope thay vì Window (nếu sử dụng SharedWorker context sẽ là SharedWorkerGlobalScope). Bên trong context của các Worker chúng ta có thể thực hiện các logic JavaScript thông thường. Tuy nhiên chúng ta không thể thực hiện các thao tác trên DOM một cách trực tiếp, cũng như không thể truy cập đến một số method và property của window object. Ngoài những giới hạn trên, chúng ta vẫn có thể truy cập đến rất nhiều API quan trọng như:

Communication Between Threads

Worker script sẽ được thực thi bởi một background thread tách biệt và chạy song song với thread chính. Trong nhiều trường hợp chúng ta cần truyền dữ liệu từ các background worker cho main thread để xử lý và ngược lại. Nói cách khác, chúng ta cần coordinate (điều phối) mối quan hệ giữa các thread với nhau.

Nếu bạn đã từng làm việc với Concurrency trong Java, có thể bạn đã nghe đến hai khái niệm khá cơ bản là ProducerConsumer. Khi lập trình với thread hai công việc bạn thường phải làm đó là synchronizecoordinate các thread. Trong Java việc đó sẽ được thực hiện bằng cách sử dụng Semaphores (một số phương pháp cụ thể đó là sử dụng wait-notify hoặc wait-notifyAll). Trong mối quan hệ Producer/Consumer, việc coordinating sẽ giúp cho việc truy cập share resources trở nên chính xác.

Quay trở lại với Worker, cơ chế coordinate giữa các worker và main thread sẽ là message-passing sử dụng event model. Main thread có thể gửi thông tin đến các Worker thông qua postMessage. postMessage sẽ nhận tham số đầu vào là một string hoặc một JSON object. Main thread và Worker sẽ nhận thông tin bằng việc lắng nghe message event, và truy cập dữ liệu thông qua event.data.

var worker = new Worker('foo.js')
worker.postMessage('Hello from main thread')
worker.addEventListener('message', function (e) {
    console.log(e.data) // Hello from worker
}, false)
// foo.js
self.addEventListener('message'. function (e) {
    console.log(e.data) // Hello from main thread
    self.postMessage('Hello from worker')
}, false)

// another way to listen for the vent
self.onmessage = function (e) {...}

Trong context của một Worker, thisself đều chỉ đến global scope - WorkerGlobalScope

Lưu ý rằng dữ liệu truyền giữa Main Thread và các Worker sẽ được sao chép (copied) chứ không được chia sẻ (shared). Giả sử dữ liệu truyền đi từ Main Thread và Worker là một JSON object chứa name property, property này sẽ truy cập được ở cả Main Thread và Worker tuy nhiên giá trị sẽ là khác nhau. Trên thực tế, dữ liệu sẽ được serialized trước khi được gửi đến cho các Worker và dữ liệu đó sẽ được de-serialized sau đó. Dữ liệu không được chia sẻ nên sẽ tạo ra một bản sao sau mỗi lần được truyền đi. Đó cũng là lý do tại sao chúng ta nên lưu ý khi dùng Worker vì nó khá tốn tài nguyên của hệ thống. Trong phần tiếp theo của bài viết, chúng ta sẽ đề cập đến một phương pháp để giải quyết vấn đề này.

Transferrable objects

Hầu hết các trình duyệt hiện nay đều cài đặt structured cloning algorithm cho phép chúng ta sử dụng những kiểu dữ liệu phức tạp hơn cho Worker, ví dụ như: Blob, File, FileList, ArrayBuffer, Map, và JSON objects. Mục đích của structured cloning algorithm là làm cho quá trình sao chép các object phức tạp trở nên hiệu quả hơn. Tuy nhiên nên nhớ rằng, dữ liệu vẫn sẽ được copied trong mỗi lần gọi postMessage. Giả sử chúng ta cần chuyển một file khoảng 100MB giữa Worker và Main Thread, việc này sẽ gây tốn khá nhiều tài nguyên hệ thống.

Các kiểu dữ liệu trên cung cấp nhiều lựa chọn mềm dẻo hơn khi định dạng dữ liệu truyền đi, tuy nhiên quá trình copy có thể kéo dài nếu dữ liệu có kích thước quá lớn. Transferrable objects là một cách để khắc phục vấn đề này. Khi sử dụng transferrable objects dữ liệu sẽ chỉ thay đổi context và không bị copy lại. Hiểu một cách đơn giản, nó giống như pass-by-reference (thay vì pass-by-value) trong ngôn ngữ C.

ArrayBuffer thường hay được sử dụng trong việc trao đổi dữ liệu giữa Worker và Main Thread. Sử dụng ArrayBuffer chúng ta có thể truyền binary data ví dụ như ảnh, âm thanh hiệu quả hơn, thay vì sử dụng base64 encoding như trước.

Syntax cho postMessage sẽ như sau:

worker.postMessage(message, [transferList]);

Trong cấu trúc trên, message sẽ là dữ liệu được gửi đến Worker, nó không nhất thiết phải là một ArrayBuffer, nó là dữ liệu mà chúng ta có thể lấy ra sử dụng e.data. Tham số thứ hai sẽ là các transferrable objects mà chúng ta muốn chuyển quyền sở hữu. Lý do chúng ta nói chuyển quyển sở hữu (ownership) là do transferrable object sau khi được chuyển từ Main Thread sang Worker thì chỉ có thể sử dụng được bên Worker và ngược lại.

Các bạn có thể tìm hiều một ví dụ chi tiết về việc sử dụng Transferrable objects cho Web Worker tại đây

Dedicated Worker

Trong phần này của bài viết, chúng ta sẽ tìm hiểu cách sử dụng DedicatedWorker. Về SharedWorker các bạn có thể tìm hiều thêm tại đây

Trước khi tìm hiểu cách sử dụng, chúng ta cần chú ý rằng Dedicated Worker sẽ chỉ được sử dụng bởi 1 script duy nhất, khác với Shared Worker khi nó có thể được sử dụng bởi nhiều script khác nhau (cùng domain với Worker). Tuy nhiên hiện tại DedicatedWorker phổ biến hơn khá nhiều so với SharedWorker và một trình duyệt như SafariInternet Explorer không hỗ trợ SharedWorker trong bất kì phiên bản nào tính đến thời điểm bài viết này.

Worker Detection

Trước khi sử dụng Worker chúng ta cần kiểm tra xem trình duyệt hiện tại có hỗ trợ Worker hay không. Do Worker constructor function được truy cập thông qua window object, chúng ta có thể kiểm tra như sau:

if (window.Worker) {
    var worker = new Worker('foo.js')
}

// Using Modernizr
if (Modernizr.webworkers) {
    var worker = new Worker('bar.js')
}

Create New Worker

Việc tạo mới một Dedicated Worker instance là khá đơn giản, chúng ta chỉ cần sử dụng Worker() constructor function với tham số đầu vào là URI đến script sẽ được thực thi bên trong worker thread:

if (window.Worker) {
    var worker = new Worker('./math/prime.js')
}

Exchange Messages

Trong phần trước khi đề cập đến message-passing trong Worker, chúng ta đã đi qua một ví dụ đơn giản minh họa quá trình trao đổi dữ liệu giữa Main Thread và Worker. Sau khi đã có một instance của Worker, để khởi động Worker đó chúng ta sẽ sử dụng postMessage function. Lưu ý, trong trường hợp không có dữ liệu được truyền đi, chúng ta cần pass một empty string như argument đầu tiên cho postMessage. Lý do là việc cài đặt function trên giữa các trình duyệt là không hoàn toàn giống nhau. Một số trình duyệt như Firefox sẽ báo lỗi khi chúng ta không truyền tham số cho postMessage

worker.postMessage('')
worker.postMessage('Hello World!')
worker.postMessage({ name: 'Foo Bar', age: 100000 })

Sau khi đã kick off Worker, chúng ta sẽ sử dụng Event Model để trao đổi dữ liệu. Cách thường được sử dụng là lắng nghe message event thay vì sử dụng onmessage property. Chúng ta sẽ đi qua một ví dụ nữa, tuy nhiên thay vì trao đổi string chúng ta sẽ sử dụng object cho dữ liệu.

// index.html
<button onclick="greeting()">Greeting</button>
<button onclick="stop()">Stop Worker</button>
<output id="result"></output>

<script>
    if (window.Worker) {
        var worker = new Worker('worker.js')
        
        worker.addEventListener('message', function (e) {
            document.getElementById('result').textContent = e.data
        }, false)
        
        function greeting() {
            worker.postMessage({ 'name': 'Anonymous', 'age': 25 })
        }
        
        function stop() {
            worker.terminate()
        }
    }
</script>
// worker.js
self.addEventListener('message', function (e) {
    var data = e.data
    var mesage = "Hello! I am " + data.name + "I am " + data.age + " years old"
    self.postMessage(message)
}, false)

Trong ví dụ trên Main Thread sẽ gửi đến Worker một object chứa thông tin về một người nào đó. Nhiệm vụ của Worker sẽ là tạo ra một câu giới thiệu về người đó là trả lại cho Main Thread. Main Thread sẽ lắng nghe message event và in câu chào đó sử dụng output HTML tag. Để dừng worker chúng ta sử dụng terminate function. Chúng ta cũng sử dụng một giao diện đơn giản với hai button cho hai việc là hiển thị câu chào và dừng worker.

Terminate Worker

Để dừng một worker chúng ta có hai cách sau (chúng ta có thể ngắt kết nối từ Main Thread hoặc từ bên Worker):

  • Sử dụng terminate function trên Worker instance.
  • Sử dụng close bên trong Worker script.
// Terminate worker using worker
worker.terminate()

// Terminate worker inside worker script
self.close()

Hande Errors

Trong những ví dụ trên chúng ta chưa đề cập đến việc xử lý các error có thể xảy ra. Tương tự quá trình nhận và xử lý dữ liệu, chúng ta sẽ lắng nghe error event và handle các error trong callback:

worker.addEventListener('error', function (e) {
    console.log(e)
    // Handle the error
}, false)

Event object trả về triển khai ErrorEvent interface, và cung cấp cho chúng ta khá nhiều thông tin hữu ích:

  • lineno: Vị trí của dòng code gây ra lỗi.
  • filename: tên của file mà lỗi xảy ra.
  • message: mô tả chi tiết về lỗi đang xảy ra.

Import Scripts

Bên trong một Worker, chúng ta có thể sử dụng this.importScripts(urls) để import các thư viện và dependency cần thiết cho worker script.

this.importScripts('foo.js')
this.importScripts('foo.js', 'bar.js', 'fizz.js')

Lưu ý rằng chúng ta không thể import một số thư viện như jQuery bên trong worker script bằng cách này. Trong phần đầu của bài viết chúng ta từng đề cập đến việc không thể truy cập toàn bộ các API bên trong window object (do một số vấn đề về bảo mật), nếu sử dụng this.importScripts('jquery.js') sẽ có lỗi xảy ra.

Subworkers

Một Worker có thể chứa nhiều Worker con (Subworker) khác nhau, cho phép chúng ta chia nhỏ hơn nữa một task bên trong một Worker. Khi sử dụng Subworker chúng ta cần lưu ý một số vấn đề sau:

  • Subworkers phải được lưu trữ cùng origin so với Worker gốc.
  • URI trong Subworkers sẽ là đường dẫn tương đối so với vị trí của Worker gốc.

Hầu hết các trình duyệt đều cung cấp một process riêng cho mỗi Worker. Trước khi sử dụng thêm một Worker chúng ta cần quan tâm đến tài nguyên hiện tại của hệ thống. Nguyên nhân là dữ liệu truyền đi giữa cách Worker sẽ được sao chép chứ không chung một nguồn, sử dụng quá nhiều Worker sẽ không mang lại hiểu quả và làm ảnh hưởng đến performance của ứng dụng.

  • Để hiểu thêm về Subworker, các bạn có thể tìm hiểu thêm tại đây

Limitations

Worker là một công cụ khá hữu ích, tuy nhiên nó cũng có một số hạn chế nhất định, trong phần này chúng ta sẽ đề cập đến một số vấn đề gặp phải khi sử dụng Worker.

Same Origin

Tất cả các Worker script phải được served từ cùng một domain như script mà ở đó Worker được khởi tạo. Điều này cũng áp dụng cho cả loại protocol đang sử dụng.

Limited Access

Logic bên trong Worker sẽ là tách biết so với Main Thread, do đó Worker có thể sẽ không truy cập được đến một số DOM APIs (như đã nói đến trong phần đầu của bài viết). Một cách để thực hiện những việc liên quan đến Main Thread là gửi message đến nó từ Worker thông qua postMessage.

Restricted Local Access

Worker sẽ không hoạt động nếu web page được server trực tiếp từ filesystem. Chúng ta cần có một server nếu muốn sử dụng Worker.

Conclusion

Trong bài viết này mình có trình bày một cách khá cơ bản về Web Worker (tập trung chủ yếu vào Dedicated Workers): định nghĩa, cách hoạt động, cách truyền tải dữ liệu, cũng như những hạn chế là lưu ý khi sử dụng Web Worker. Mong rằng bài viết sẽ giúp ích được một phần nào đó cho các bạn trong công việc sau này.

References