iT邦幫忙

2022 iThome 鐵人賽

DAY 30
0
Modern Web

這些那些你可能不知道我不知道的Web技術細節系列 第 30

你可能不知道的Web API--Web Locks

  • 分享至 

  • xImage
  •  

前言

Web Locks相關的API目前還是實驗性質的,這意味著未來可能有所變動,會與本片內容提及用法、作用有差異。雖然是實驗性質,但目前主流瀏覽器都已經支援。

使用方式

最基本用法是透過navigator.locks.request()取得一把鎖,如果無法取得就必須等待直到能夠取得。如果取得了,就可以執行後續callback的動作。通常callback是一個異步函式,舉例來說寫法會如下:

navigator.locks.request('lock-1', async (lock) => {
  console.log('get lock-1');

  console.log('do something');

  console.log('release lock-1');
});

callback的執行區域,被稱作是 關鍵區域 (Critical section)。

如果設計的恰當,關鍵區域只會有一個在執行。把上面再改寫一下:

var lock_name = 'lock-1';

navigator.locks.request(lock_name, (lock) => {
    console.log(`A: get lock ${lock.name}`);
    return new Promise(res => {
        /// 10秒後釋放鎖
        setTimeout(() => {
            console.log(`A: release lock ${lock.name}`);
            res(); // release lock
        }, 10000 /*ms*/);
    })
})

navigator.locks.request(lock_name, (lock) => {
    console.log(`B: get lock ${lock.name}`);
    return new Promise(res => {
        /// 5秒後釋放鎖
        setTimeout(() => {
            console.log(`B: release lock ${lock.name}`);
            res(); // release lock
        }, 5000 /*ms*/);
    })
})

A: get lock lock-1
A: release lock lock-1
B: get lock lock-1
B: release lock lock-1

在上面範例,有兩個程式區塊A和B需要使用到lock-1這把鎖。A需要消耗10秒,並優先取得了鎖;B必須等待10秒後,才會開始執行。

可以透過將Promiseresolve()reject()傳遞出來,來決定什麼時候要釋放鎖:

var resolve, reject;
var p = new Promise((res, rej) => {
  resolve = res;
  reject = rej;
});

callback會改成:

navigator.locks.request('lock-1', (lock) => {
    console.log(`get lock ${lock.name}`);
    p.then(_ => console.log(`release lock ${lock.name}`))
    return p;
})

你可自行決定何時應該呼叫resolve()reject()

可能共用的資源

至於這究竟有什麼作用?通常這樣的設計在支援多執行緒的程式語言裡,算是蠻常見的。在關鍵區域裡,操作一個共用資料,可以避免因為中斷和執行順序的不確定,造成共用資料的不可預測。比如有兩段同時執行的程式的執行邏輯如下:

  1. 取得共用變數A,並先儲存於暫時的函式變數tmp
  2. 等待亂數時間0~5秒
  3. A值設為tmp + 1

試問A最後結果為何?

var A = 1;
function fn(name) {
    var tmp = A; // 取得共用變數`A`,並先儲存於暫時的函式變數`tmp`
    var wait = Math.random() * 5 * 1000; /*seconds*/
    var updateA = () => {
        A = tmp + 1;
        console.log(`finish ${name}`);
    };
    setTimeout(updateA, wait);
}

fn('甲');
fn('乙');
console.log(A)

下面兩張GIF執行的程式片段一模一樣,只是時間上略有些差異,但造成A的結果卻截然不同:

可以加上WebLock以保證其實行順序:

var A = 1;
function fn(name) {
    var resolve = null;
    var promise = new Promise(res => resolve = res);
    
    navigator.locks.request("A", (lock) => { // lock
        console.log(`${name} get lock A`)
        var tmp = A; // 取得共用變數`A`,並先儲存於暫時的函式變數`tmp`
        var wait = Math.random() * 5 * 1000; /*seconds*/
        var updateA = () => {
            A = tmp + 1;
            console.log(`finish ${name}`);
            resolve(); // unlock
        };

        setTimeout(updateA, wait);
        return promise;
    });
}

(() => fn('甲'))();
(() => fn('乙'))();
console.log(A);
await navigator.locks.query('A');

不過瀏覽器主頁面執行環境其實是單執行緒的,就算異步問題有時候非常難處理,但還是有跡可循。不過並不是沒有多執行緒的情況,在使用Web WorkerService Worker已經多頁籤的情況下就會有多執行緒資源競爭的問題。但哪些資源會互相共用競爭?以下我列出幾個我目前想到的:

  • Cookie
  • LocalStorage
  • IndexedDB

這些是目前想到的,同一個網站,甚至同一個網域或子網域有可能共同存取乃至寫入的資源,這些資源就有可能造成資源競爭。我不太確定SessionStorage又會在哪些情況下與頁面共用,但它也可能與Worker共用。

阻擋頁面進入凍結

除此之外,它也有可能阻止瀏覽器進入睡眠或凍結。已Microsoft Edge瀏覽器來說,當頁籤進入背景或視窗縮到最小,就有可能暫停運作。有一些方式可以避免暫停的行為,使用Web Locks可能就是一種處理方式:

瀏覽器睡眠索引標籤

某些瀏覽器具有索引標籤凍結或睡眠功能,以減少非使用中索引標籤的電腦資源使用量。 這可能會導致 SignalR 連線關閉,而且可能會導致不必要的使用者體驗。 瀏覽器會使用啟發學習法來找出索引標籤是否應該進入睡眠狀態,例如:

  • 播放音訊
  • 保留 Web 鎖定
  • IndexedDB按住鎖定
  • 連線到 USB 裝置
  • 擷取視訊或音訊
  • 正在鏡像
  • 擷取視窗或顯示
    瀏覽器啟發學習法可能會隨著時間而變更,而且瀏覽器之間可能會有所不同。 檢查支援矩陣,並找出最適合您案例的方法。

為了避免讓應用程式進入睡眠狀態,應用程式應該觸發瀏覽器使用的其中一個啟發學習法。

下列程式碼範例示範如何使用 Web 鎖定 讓索引標籤保持喚醒,並避免非預期的連線關閉。



var lockResolver;
if (navigator && navigator.locks && navigator.locks.request) {
    const promise = new Promise((res) => {
        lockResolver = res;
    });

    navigator.locks.request('unique_lock_name', { mode: "shared" }, () => {
        return promise;
    });
}

針對上述程式碼範例:

  • *Web 鎖定是實驗性的。 條件式檢查會確認瀏覽器支援 Web 鎖定。
  • 承諾解析程式 lockResolver 會儲存,以便在索引標籤可接受進入睡眠狀態時釋放鎖定。
  • 關閉連線時,會藉由呼叫 lockResolver() 來釋放鎖定。 釋放鎖定時,允許索引標籤進入睡眠狀態。

以上資訊取自 https://learn.microsoft.com/zh-tw/aspnet/core/signalr/javascript-client?view=aspnetcore-6.0&tabs=visual-studio

讀寫鎖

當只做讀取而不會改變資料狀態,這時若是每次存取都需要先取得鎖這樣就很麻煩。因此其實可以實現讀寫鎖:寫入的時候只允許一個使用,並且不能有其他在讀取,必須等待讀取結束;讀取時則允許多個同時讀取。

var A = 1;

function readA(id) {
    var resolve = null;
    var promise = new Promise(res => resolve = res);
    
    navigator.locks.request("A", {mode: 'shared'}, (lock) => { // shared lock
        console.log(`${id} get lock A`)
        return promise;
    });
    
    return {data: A, resolve};
}

function writeA(id, newData) {
    var resolve = null;
    var promise = new Promise(res => resolve = res);
    navigator.locks.request("A", {mode: 'exclusive'}, (lock) => { // shared lock
        console.log(`${id} get lock A`)
        A = newData;
        return promise;
    });
    return resolve;
}

透過指定選項modesharedexclusive,可以實現讀寫鎖。首先先看看先有讀取者的情況,若有需要寫入,則必須等待所有讀取完成:

var readers = [];
for(let i = 1; i <= 10; i ++) {
    readers.push(readA(i));
}

var writer = writeA("writer", 2); // 必須等待所有讀者結束動作

但如果有需要等待的寫入動作,就不能立刻再執行新的讀取需求的同樣必須等待。

var writer = writeA("writer", 2); // 必須等待所有讀者結束動作
var r11 = readA(11);              // 有需要寫入的情況在,禁止新的讀者

readers.forEach(r => r.resolve()); //全部閱讀完畢
// 寫入資料

等先前的讀取結束後,寫入會嘗試取得鎖,並執行寫入動作。這時候如果有新的讀取需求也依然需要等待寫入結束:

// 寫入未完成,禁止其他讀取
writer(); // 寫入資料完畢。 `r11`可以進行讀取
r11.resolve();

可選檢查

也可以添加選項ifAvailable,如果無法取得鎖就直接放棄動作:

function tryUpdateA(id, newData) {
    var resolve = null;
    var promise = new Promise(res => resolve = res);
    navigator.locks.request("A", {ifAvailable: true}, (lock) => { // shared lock
        if(lock == null) // get lock fail
            return promise
        console.log(`${id} get lock A`)
        A = newData;
        return promise;
    });
    return resolve;
}

超時

還可以傳入一個signal選項用來實現超時:

function updateA(id, newData, timeout /*seconds*/) {
    var resolve = null;
    var promise = new Promise(res => resolve = res);
    var controller = new AbortController();
    var signal = controller.signal;
    
    setTimeout(() => controller.abort('timeout'), timeout * 1000)
    
    navigator.locks.request("A", {signal}, (lock) => { // shared lock
        console.log(`${id} get lock A`)
        A = newData;
        return promise;
    });
    return resolve;
}

為了比較好操作,可以把controller傳出來:

function updateA2(id, newData) {
    var resolve = null;
    var promise = new Promise(res => resolve = res);
    var controller = new AbortController();
    var signal = controller.signal;
    
    navigator.locks.request("A", {signal}, (lock) => { // shared lock
        console.log(`${id} get lock A`)
        A = newData;
        return promise;
    });
    return {resolve, controller};
}

這麼一來可以手動決定是不是要放棄:

var updator2 = updateA2('up2', 101)

查看鎖狀態(query())

透過query()可以查詢取得鎖和等待鎖的數量:

console.log(await navigator.locks.query("A"));

結語

這個可能對於很多人並不是很好理解,就算是有過多執行緒平行處理撰寫經驗的也是一樣,可能在理解使用方式上會有一些誤會。

除此之外,像這種資料競爭問題,一樣會遇到像是死鎖或飢餓的問題。最經典的例子就是「哲學家就餐問題」。

最後提醒一下,這個API仍處在實驗階段。未來仍有可能被廢棄或改變用法,使用上應多加留意

參考資料

本文同時發表於我的隨筆


上一篇
你可能不知道的(Web)API--FinalizationRegistry(GC)
下一篇
目錄與完賽感想
系列文
這些那些你可能不知道我不知道的Web技術細節33
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言