TL;DR

  • Javascript chạy đơn luồng và chạy trong thời gian dài làm cho trang không phản hồi
  • cách làm việc web cho phép chạy Javascript trong các luồng riêng biệt, giao tiếp các luồng chính thông qua thông báo.
  • các thông báo truyền dữ liệu lớn trong TypedArrays hoặc ArrayBuffers gây ra chi phí bộ nhớ lớn do dữ liệu được sao chép
  • Sử dụng transfers mitigates giúp giảm thiểu chi phí bộ nhớ nhân bản, nhưng làm cho dữ liệu không thể truy cập được đến người gửi
  • Tất cả các mã có thể được tìm thấy trong kho lưu trữ này
  • Tùy thuộc vào loại công việc mà javascript thực hiện, navigator.hardwareConcurrency có thể giúp chúng tôi truyền bá công việc trên các bộ xử lý.

Một ứng dụng ví dụ

Để làm ví dụ, chúng tôi muốn xây dựng 1 ứng dụng web xây dựng một bảng trong đó mỗi mục nhập vào 1 số và kiểm tra xem số đó có phải là số nguyên tố hay không.

Chúng ta sẽ sử dụng một ArrayBuffer để giữ boleans và in đậm, làm cho nó lớn hơn 10 megabyte 

Bây giờ điều này chỉ giúp kịch bản của chúng ta thực hiện một số công việc nặng nhọc - đó không phải là một điều rất hữu ích, nhưng tôi có thể sử dụng các kỹ thuật trong bài đăng này để xử lý dữ liệu nhị phân thuộc các loại khác nhau (ví dụ như hình ảnh, âm thanh, video)

Ở đây chúng ta sẽ sử dụng một thuật toán cơ bản (có sẵn các thuật toán khác tốt hơn):

function isPrime(candidate) {
  for(var n=2; n <= Math.floor(Math.sqrt(candidate)); n++) {
    // if the candidate can be divided by n without remainder it is not prime
    if(candidate % n === 0) return false
  }
  // candidate is not divisible by any potential prime factor so it is prime
  return true
}

Đây là phần còn lại của ứng dụng chúng ta:

index.html

<!doctype html>
<html>
<head>
  <style>
    /* make the page scrollable */
    body {
      height: 300%;
      height: 300vh;
    }
  </style>
<body>
  <button>Run test</button>
  <script src="app.js"></script>
</body>
</html>

Chúng tôi làm cho trang có thể cuộn để xem hiệu ứng của mã JavaScript của chúng tôi trong giây lát.

app.js

document.querySelector('button').addEventListener('click', runTest)

function runTest() {
  var buffer = new ArrayBuffer(1024 * 1024 * 10) // reserves 10 MB
  var view = new Uint8Array(buffer) // view the buffer as bytes
  var numPrimes = 0

  performance.mark('testStart')
  for(var i=0; i<view.length;i++) {
    var primeCandidate = i+2 // 2 is the smalles prime number
    var result = isPrime(primeCandidate)
    if(result) numPrimes++
    view[i] = result
  }
  performance.mark('testEnd')
  performance.measure('runTest', 'testStart', 'testEnd')
  var timeTaken = performance.getEntriesByName('runTest')[0].duration

  alert(`Done. Found ${numPrimes} primes in ${timeTaken} ms`)
  console.log(numPrimes, view)
}

function isPrime(candidate) {
  for(var n=2; n <= Math.floor(Math.sqrt(candidate)); n++) {
    if(candidate % n === 0) return false
  }
  return true
}

Chúng tôi đang sử dụng API thời gian của người dùng để đo thời gian và thêm thông tin của riêng chúng tôi vào dòng thời gian.

Bây giờ tôi để thử nghiệm chạy trên chiếc Nexus 7 "cũ" đáng tin cậy của tôi (2013):

Được rôi! điều đó không ấn tượng lắm phải không?

Làm cho vấn đề tồi tệ hơn là trang web ngừng phản ứng với bất cứ điều gì trong 39 giây này - không cuộn, không nhấp, không gõ. Các trang bị đóng băng.

Điều này xảy ra vì javascript là đơn luồng và trong 1 luồng duy nhất chỉ 1 nhiệm vụ duy nhất được xử lý. Làm cho vấn đề tồi tệ hơn, hầu như bất cứ điều gì liên quan đến tương tác cho trang của chúng tôi (vì vậy mã trình duyệt để cuộn, nhập văn bản, v.v.) chạy trên cùng một chuỗi.

Như vậy, liệu chúng ta không thay đổi được cách nó hoạt động?

Không, đây chỉ là loại công việc chúng ta có thể sử dụng Web Workers 

Một Web Worker là một tệp JavaScript có cùng nguồn gốc với ứng dụng web của chúng tôi sẽ chạy trong một luồng riêng biệt.

Chạy trong một luồng riêng biệt có nghĩa là:

  • Nó sẽ chạy song song
  • nó sẽ không làm cho trang không phản hồi bằng cách chặn luồng chính
  • nó sẽ không có quyền truy cập vào DOM hoặc bất kỳ biến hoặc hàm nào trong luồng chính
  • nó có thể sử dụng mạng và liên lạc với luồng chính bằng tin nhắn

Vậy làm thế nào để chúng tôi giữ cho trang của chúng tôi phản hồi trong khi công việc tìm kiếm chính tiếp tục? Dưới đây là thủ tục:

  • Chúng tôi bắt đầu một worker và gửi ArrayBuffer tới nó
  • worker làm công việc của nó
  • Khi worker hoàn thành, nó sẽ gửi ArrayBuffer và số lượng các số nguyên tố mà nó tìm thấy trở lại luồng chính

Đây là mã cập nhật:

app.js

document.querySelector('button').addEventListener('click', runTest)

function runTest() {
  var buffer = new ArrayBuffer(1024 * 1024 * 10) // reserves 10 MB
  var view = new Uint8Array(buffer) // view the buffer as bytes

  performance.mark('testStart')
  var worker = new Worker('prime-worker.js')
  worker.onmessage = function(msg) {
    performance.mark('testEnd')
    performance.measure('runTest', 'testStart', 'testEnd')
    var timeTaken = performance.getEntriesByName('runTest')[0].duration
    view.set(new Uint8Array(buffer), 0)
    alert(`Done. Found ${msg.data.numPrimes} primes in ${timeTaken} ms`)
    console.log(msg.data.numPrimes, view)
  }
  worker.postMessage(buffer)
}

prime-worker.js

self.onmessage = function(msg) {
  var view = new Uint8Array(msg.data),
      numPrimes = 0
  for(var i=0; i<view.length;i++) {
    var primeCandidate = i+2 // 2 is the smalles prime number
    var result = isPrime(primeCandidate)
    if(result) numPrimes++
    view[i] = result
  }
  self.postMessage({
    buffer: view.buffer,
    numPrimes: numPrimes
  })
}

function isPrime(candidate) {
  for(var n=2; n <= Math.floor(Math.sqrt(candidate)); n++) {
    if(candidate % n === 0) return false
  }
  return true
}

Và đây là những gì chúng tôi nhận được khi chạy lại trên Nexus 7 của mình:

Chà, sau 1 loạt update chúng mang lại cho chúng ta gì không?Sau tất cả bây giờ nó còn chạy chậm hơn.

Lợi ích mang lại ở đây không làm cho nó nhanh hơn, nhưng hay thử cuộn trang hay tương tác... nó luôn phản hồi mọi lúc, Với việc tính toán được đưa ra theo luồng của chính nó, chúng ta không cản trở luồng chính xử lý phản hồi cho người dùng.

Nhưng trước khi chúng ta tiếp tục làm cho mọi thứ nhanh hơn, chúng ta sẽ tìm ra một chi tiết quan trọng về cách thức hoạt động của PostMessage.

Chi phí để copy dữ liệu

Như đã đề cập trước đó, luồng chính và luồng worker được tách ra, vì vậy chúng ta cần đưa dữ liệu giữa chúng bằng các tin nhắn

Nhưng làm thế nào mà thực sự di chuyển dữ liệu giữa chúng? Câu trả lời cho cách chúng tôi đã làm trước đây là structured cloning 

Điều này có nghĩa là chúng tôi đang sao chép ArrayBuffer 10 megabyte của mình cho worker và sau đó sao chép ArrayBuffer từ worker trở lại.

Tôi giả định rằng điều này sẽ có tổng dung lượng sử dụng bộ nhớ 30 MB: 10 trong ArrayBuffer ban đầu của chúng tôi, 10 trong bản sao được gửi cho worker và 10 bản sao khác được gửi lại.

Đây là cách sử dụng bộ nhớ trước khi bắt đầu thử nghiệm:

Và đây là ngay sau khi thử nghiệm:

Đợi đã, nó đã thêm 50 megabytes, như thế nay:

  • chúng tôi bắt đầu với 10mb cho ArrayBuffer
  • bản sao nhân bản * tạo thêm + 10mb
  • bản sao được sao chép vào worker, + 10mb
  • worker nhân bản bản sao của nó một lần nữa, + 10mb
  • bản sao được sao chép vào luồng chính, + 10mb

*) Tôi không chắc chắn chính xác lý do tại sao bản sao không được di chuyển đến luồng đích thay vì được sao chép, nhưng bản thân việc xê-ri hóa dường như phải chịu chi phí bộ nhớ bất ngờ

Giảm chi phí truyền dữ liệu

May mắn thay cho chúng ta, có một cách chuyển dữ liệu khác nhau giữa các luồng trong tham số thứ hai tùy chọn của postMessage, được gọi là transfer list.

Tham số thứ hai này có thể chứa một danh sách các đối tượng có thể chuyển sẽ được loại trừ khỏi nhân bản và thay vào đó sẽ được di chuyển

Tuy nhiên, việc truyền một đối tượng sẽ vô hiệu hóa nó trong luồng nguồn, vì vậy, ví dụ ArrayBuffer của chúng ta sẽ không chứa bất kỳ dữ liệu nào trong luồng chính sau khi nó được chuyển đến worker và byte của nó sẽ bằng không.
Điều này là để tránh chi phí phải thực hiện các cơ chế để đối phó với một loạt các vấn đề có thể xảy ra khi nhiều luồng truy cập dữ liệu chia sẻ.

Đây là mã được điều chỉnh bằng cách sử dụng transfers:

app.js

worker.postMessage(buffer, [buffer])

Và đây là số của chúng ta:

vì vậy code chúng ta chạy nhanh hơn 1 chút so với cloning worker, gần với phiên bản main-thread-blocking ban đầu, chúng ta đã làm gì trong bộ nhớ?

vì vậy nó bắt đầu với 40mb và kết thúc với hơn 50mb 

Nhiều workers = tốc độ hơn?

Cho tới thời điểm bây giờ chúng ta có:

  • Bỏ chặn main theard
  • Loại bỏ bộ nhớ trên khỏi nhân bản

Chúng ta có thể tăng tốc nó thêm không?

Chúng ta có thể phân chia phạm vi số (và bộ nhớ đệm của chúng ta) cho nhiều worker cho nó chạy song song và hợp nhất kết quả

app.js

Thay vì chạy trên 1 worker, chúng ta sẽ chạy trên 4 worker. Mỗi worker sẽ nhận 1 thông báo giao nhiệm vụ phần thực hiện và số lượng số cần kiểm tra

Khi worker hoàn thành nó sẽ báo cáo lại 

  • 1 ArrayBuffer chứa thông tin mục nào là số nguyên tố
  • số lượng các số nguyên tố nó tìm thấy
  • nhiệm vụ ban đầu
  • chiều dài ban đầu của nó

Sau đó chúng tôi sao chép dữ liệu từ bộ đệm vào bộ đệm đích, tổng hợp tổng số các số nguyên tố được tìm thấy.

Khi tất cả các worker hoàn thành, chúng tôi hiển thị kết quả cuối cùng

document.querySelector('button').addEventListener('click', runTest)

function runTest() {
  const TOTAL_NUMBERS = 1024 * 1024 * 10
  const NUM_WORKERS = 4
  var numbersToCheck = TOTAL_NUMBERS, primesFound = 0
  var buffer = new ArrayBuffer(numbersToCheck) // reserves 10 MB
  var view = new Uint8Array(buffer) // view the buffer as bytes

  performance.mark('testStart')
  var offset = 0
  while(numbersToCheck) {
    var blockLen = Math.min(numbersToCheck, TOTAL_NUMBERS / NUM_WORKERS)
    var worker = new Worker('prime-worker.js')
    worker.onmessage = function(msg) {
      view.set(new Uint8Array(msg.data.buffer), msg.data.offset)
      primesFound += msg.data.numPrimes

      if(msg.data.offset + msg.data.length === buffer.byteLength) {
        performance.mark('testEnd')
        performance.measure('runTest', 'testStart', 'testEnd')
        var timeTaken = performance.getEntriesByName('runTest')[0].duration
        alert(`Done. Found ${primesFound} primes in ${timeTaken} ms`)
        console.log(primesFound, view)
      }
    }

    worker.postMessage({
      offset: offset,
      length: blockLen
    })

    numbersToCheck -= blockLen
    offset += blockLen
  }
}

prime-worker.js

worker tạo ra 1 Uint8Array view đủ lớn để chứa độ dài các byte theo thứ tự của luồng chính

Kiểm tra số nguyên tố bắt đầu ở phần đầu offset và cuối cùng dữ liệu được chuyển trở lại

self.onmessage = function(msg) {
  var view = new Uint8Array(msg.data.length),
      numPrimes = 0
  for(var i=0; i<msg.data.length;i++) {
    var primeCandidate = i+msg.data.offset+2 // 2 is the smalles prime number
    var result = isPrime(primeCandidate)
    if(result) numPrimes++
    view[i] = result
  }
  self.postMessage({
    buffer: view.buffer,
    numPrimes: numPrimes,
    offset: msg.data.offset,
    length: msg.data.length
  }, [view.buffer])
}

function isPrime(candidate) {
  for(var n=2; n <= Math.floor(Math.sqrt(candidate)); n++) {
    if(candidate % n === 0) return false
  }
  return true
}

Và đây là kết quả:

Vì vậy, giải pháp này mất khoảng một nửa thời gian với khá nhiều chi phí bộ nhớ (sử dụng bộ nhớ cơ sở 40mb + 10mb cho bộ đệm đích + 4 x 2,5mb cho bộ đệm trong mỗi worker + 2mb trên mỗi worker.

Dưới đây là dòng thời gian của ứng dụng sử dụng 4 worker:

Chúng ta có thể thấy rằng các worker chạy song song, nhưng chúng ta không tăng tốc lên gấp 4 lần vì 1 số worker mất nhiều thời gian hơn những worker khác. Đây là kết quả cách chúng tôi chia phạm vi số: vì mỗi worker cần chia mỗi số x cho tất cả các số từ x đến √x, các worker có số lớn hơn cần thực hiện nhiều phép chia hơn, tức là nhiều công việc hơn. Điều này chắc chắn có thể giảm thiểu bằng cách chia các số theo cách kết thúc việc truyền bá các hoạt động đồng đều hơn giữa chúng. Tôi sẽ để nó như một bài tập cho bạn, người đọc quan tâm ;-)

Một câu hỏi khác là : chúng ta có thể thêm worker vào không?

Đây là kết quả cho 8 workers:

Vâng, điều này đã chậm hơn! Dòng thời gian cho chúng ta thấy lý do tại sao điều này xảy ra:

Chúng tôi thấy rằng, ngoài các luồng chồng chéo nhỏ, không quá 4 worker đang hoạt động cùng một lúc.

Điều này sẽ phụ thuộc vào đặc điểm của hệ thống và worker và không phải là một con số khó và nhanh.

Một hệ thống chỉ có thể làm rất nhiều việc cùng một lúc và công việc thường là bị ràng buộc I / O (tức là bị giới hạn bởi thông lượng mạng hoặc tệp) hoặc bị ràng buộc bởi CPU (tức là bị giới hạn bởi chạy tính toán trên CPU).

Trong trường hợp của chúng tôi, mỗi worker sử dụng CPU để tính toán. Vì Nexus 7 của tôi có bốn lõi, nó có thể xử lý đồng thời bốn worker, nên hoàn toàn bị ràng buộc CPU của chúng tôi.

Tóm lại

Chúng tôi phát hiện ra rằng JavaScript là một luồng đơn và chạy trên cùng một luồng với các tác vụ của trình duyệt để giữ cho giao diện người dùng của chúng tôi luôn mới mẻ và linh hoạt.


Sau đó, chúng tôi đã sử dụng worker web để giảm tải công việc của chúng tôi để phân tách các luồng và sử dụng `postMessage * để truyền thông giữa các luồng.

Chúng tôi nhận thấy rằng các luồng không mở rộng vô hạn, vì vậy một số cân nhắc cho số lượng các luồng chúng tôi chạy được khuyên dùng.

Khi làm như vậy, chúng tôi phát hiện ra rằng dữ liệu được sao chép theo mặc định, điều này dễ gây ra nhiều bộ nhớ hơn so với trước.

Chúng tôi đã sửa lỗi thông qua truyền dữ liệu là một tùy chọn khả thi cho một số loại dữ liệu nhất định, được gọi là Chuyển giao.