前陣子討論到 Chrome 88+ 加入的 Intensive Throttling 會造成 setInterval 大走鐘,同事提到可用 Worker 避開問題,發文後讀者 Scott 也留言提到。不過,在這個議題上,我覺得搬進 Worker 規避 Chrome 節能設計雖然省事但非最佳解,若有其他更有效率的替代方案,改用更省 CPU 省電的寫法是上策。

但這倒提醒我,自己從沒用過 Web Worker,不清楚其優勢及應用時機,趁此機會補上吧。

Web Worker 已經推出多年,不是什麼新技術,連 IE10 都支援即可見一斑,網路上教學與討論不少,我主要參考阮一峰老師的這篇 - Web Worker 使用教程,寫得很淺白完整,這篇文章會跳過 Web Worker 基本教學,直接來看 Web Worker 的優點及應用場合。

首先,要先確立一個觀念,網頁裡的 JavaScript 一直是用同一條執行緒執行,即便 setTimeout/setInterval 設定完就能跑其他程式,時間到會自動執行指定的函式,給人多執行緒並行的錯覺。事實上,setTimeout/setInterval 執行時會中斷原本執行中的 JavaScript 指令,等其結束後再繼續 setTimeout/setInterval 跟網頁事件與其他 setTimeout/setInterval JavaScript 會排隊執行,因此若 setTimeout/setInterval 跑很久,其他 JavaScript 指令必須等待其結束才能執行,因此便可能出現網頁久無回應被卡住的感覺。Web Worker 的好處即在於它在獨立的背景執行緒執行,不會跟網頁裡的 JavaScript 搶 CPU,這便是我想藉由實驗驗證的點。

要突顯 Web Worker 多執行緒的優勢,要找個網頁 JavaScript 很忙的範例,我找到很吃 CPU 的粒子移動模擬 JavaScript Canvas 效能測試

至於同時要跑的重量級運算,我很沒創意地選擇計算 0 ~ n 包含的質數,第一個版本用 setTimeout 來做:

<!DOCTYPE html>
<html>

<head>
	<title>Benchmark - setTimeout</title>
	<script src="excanvas.compiled.js"></script>
	<script src="animation.js"></script>
	<style>
		#info {
			position: absolute;
			z-index: 99;
			color:yellow;
			font-size: 14pt;
			top: 10px;
			left: 10px;
			padding: 6px;
			background-color: gray;
		}
	</style>
</head>

<body>
	<div id='benchmark' style='width: 800px; height: 400px'></div>
	<div id=info>
	</div>
	<script>
		let info = document.getElementById('info');
		function isPrime(num) {
			if (num <= 3) return num > 1;
			if ((num % 2 === 0) || (num % 3 === 0)) return false;
			let count = 5;
			while (Math.pow(count, 2) <= num) {
				if (num % count === 0 || num % (count + 2) === 0) return false;
				count += 6;
			}
			return true;
		}
		function listPrimes(maxNum) {
			let res = [];
			for (let i = 1; i <= maxNum; i++) {
				if (isPrime(i)) res.push(i);
			}
			return res;
		}
		var st = new Date().getTime();
		setTimeout(function () {
			let res = listPrimes(10000000);
			dura = (new Date().getTime() - st);
			document.getElementById('info').innerText += dura + 'ms ' + res.length + ' primes';
		}, 1000);
	</script>
</body>

</html>

計算質數部分我刻意延遲一秒開始,用意讓粒子動晝先播放一陣子,一秒後透過 setTimeout 執行尋找 1 ~ 1000 萬所含質數,如包含一秒延遲,共耗時 3.3s。

接著來改寫 Web Worker 版,寫一支 worker.js:

function isPrime(num) {
    if (num <= 3) return num > 1;
    if ((num % 2 === 0) || (num % 3 === 0)) return false;
    let count = 5;
    while (Math.pow(count, 2) <= num) {
        if (num % count === 0 || num % (count + 2) === 0) return false;
        count += 6;
    }
    return true;
}
function listPrimes(maxNum) {
    let res = [];
    for (let i = 1; i <= maxNum; i++) {
        if (isPrime(i)) res.push(i);
    }
    return res;
}
self.addEventListener('message', function (e) {
    var res = listPrimes(e.data);
    console.log('done');
    self.postMessage(res);   
    self.close(); 
}, false);

網頁程式部分一樣先跑動晝,setTimeout 延遲一秒啟動 Worker 尋找 1 ~ 1000 萬所含質數:

<!DOCTYPE html>
<html>

<head>
	<title>Benchmark - Worker</title>
	<script src="excanvas.compiled.js"></script>
	<script src="animation.js"></script>
	<style>
		#info {
			position: absolute;
			z-index: 99;
			color:yellow;
			font-size: 14pt;
			top: 10px;
			left: 10px;
			padding: 6px;
			background-color: gray;
		}
	</style>
</head>

<body>
	<div class="snowflakes">
	</div>
	<div id=info>
	</div>
	<script>
		var worker = new Worker('worker.js');
		worker.onmessage = function(e) {
			let dura = (new Date().getTime() - st);
			document.getElementById('info').innerText = dura + 'ms ' + e.data.length + ' primes';
			console.log(new Date());
		}
		var st = new Date().getTime();
		setTimeout(() => {
			worker.postMessage(10000000);			
		}, 1000);
	</script>
</body>

</html>

Web Wroker 版,包含 1 秒延遲啟動耗時約 3.5s,咦?怎麼比 setTimout 還慢?建立 Worker 物件及 postMessage 溝通會耗用一些額外資源,但用 Worker 不就為了效能,反而更慢像話嗎?

其實上面我賣了個關子,如果實際看過兩者的執行結果,大家就會知道為什麼該用 Web Worker 了。

左邊的 setTimeout 版本,質數計算期間動晝完全凍結,算完才繼續,且因執行間隔錯亂,粒子原本應隨機亂跑,一度出現整群同步移動。而 Web Wroker 版,全程動畫順暢未受干擾,證明質數計算是用另一個執行緒在跑,不中斷網頁的 JavaScript 執行,真正實踐了多執行緒。

最後再補充另一個實驗,瀏覽器本身是多執行緒環境,受單一執行緒限制的是網頁的 JavaScript 程式,其他如 Render、CSS 等運算等作業,瀏覽器會安排不同執行緒處理。因此,如果今天是用純 CSS 製作的動晝(我找到一個雪花飄效果當範例),用 setTimeout 或 Web Worker 的差異不大。(註:測量結果未包含一秒延遲,故比之前少一秒)

從以上實驗,我的結論是 - 對於等待外部回應或短短幾行的簡單作業,用 Web Worker 有點殺雞用牛刀,寫成 setTimeout/setInterval 就夠了,程式還比較簡潔;但如果是會持續數秒以上的重度運算,若不想在計算過程畫面卡死凍結,便可借重 Web Worker 發揮多執行緒威力,提供更好的使用者體驗。

想動手玩看看的同學,線上版本在這裡:

A example of when to use web worker to improve user experience.


Comments

# by 布丁布丁吃布丁

不錯的實驗

# by 阿尼

讚讚

# by Dennis

>> setTimeout/setInterval 執行時會中斷原本執行中的 JavaScript 指令,等其結束後再繼續。 Wrong

# by Karcher

web worker讓js不在受限於單執行緒 學習到了,謝謝

# by CY

>># 2021-10-05 06:39 PM by Dennis >>>> setTimeout/setInterval 執行時會中斷原本執行中的 JavaScript 指令,等其結束後再繼續。 >>Wrong 如同 Dennis 所抓出的這句,其實這樣講不太對,甚至應該是相反。 setTimeout/setInterval 只能保證 callback 不「早於」所預定的時間執行,但會晚於預期的時間, 所以如果有原本執行中的 「同步」型 JavaScript 指令,會等到全部執行完, 才會輪到 event loop 裡的其他 callback 執行,如已到期的 Timer 、async 操作有結果要被傳入執行的 callback、事件發生要被 trigger 的 listener 等等。

# by Jeffrey

to Dennis, CY,謝謝指正。 我原先把網頁的事件、其他 setInterval/setTimeout 想像成一整包不再細分的程式,進而衍生原本該跑的程式被它卡住延後執行,原想藉此忽略細節較易理解,但同意兩位的觀點,此一說法讓讀者對 JavaScript Event Loop 機制產生誤解,已修改原文。

Post a comment