一個有趣的鎖問題

MArtian發表於2023-10-25

前幾天有人在論壇提出了這樣一個問題:
問答:關於Laravel Cache原子鎖的問題

function () {
    $lock = Cache::lock('foo', 10);
    if ($lock->get()) {
        dump(1);
        sleep(10);
        $lock->release();
    } else {
        dump(0);
}

他在測試程式碼中,大概是想同時開啟多個瀏覽器視窗測試併發,所以使用了 sleep(10) 新增了阻塞,來方便測試。

然後讓他感到疑惑的是為什麼在第一個視窗發生阻塞時,他建立的第二個視窗、第三個視窗… 這些視窗返回結果都發生了阻塞?按照他的設想,應該是第一個視窗發生阻塞時,已經取到了鎖,但是再開啟另外一個視窗時,因為沒有取到鎖,應該返回結果是 0,但是當第一個視窗發生阻塞時,用瀏覽器的「無痕模式」再次訪問,卻可以立即返回結果。

我當時猜想了一下大概是 Session 問題導致的,自己也試了下,確實是這樣,好奇心驅使我去了解一下這背後的原理。


先說結論

這並不是程式碼的問題,和 sleep()session 也沒什麼關係,上面的程式碼是正確的,問題在於瀏覽器

我使用的是 chrome,當時我測試的時候還以為是 sleep() 原因導致的,但其實這是 chrome 瀏覽器的特性:

  1. 當「視窗1」開啟 https://test.lock 地址時,已經建立了http連線,並且取到了鎖
  2. 當「視窗2」,輸入相同地址時,chrome 會去「http連線庫」(猜想) 中查詢是否已經與這個地址有過連線請求,如果有的話,會複用這個 http 連線。(複用 http 連線的好處是可以減少網路阻塞,加快響應速度,並且也會減少伺服器資源消耗)
  3. 但 chrome 複用 http 連線請求是「序列」的,也就是說,在「視窗1」沒有執行完畢之前「視窗2」要排隊等待。

這就解釋了為什麼開啟多個視窗時,同時發生了阻塞,那是因為最開始的「視窗1」因為指令碼中的 sleep() 發生了阻塞遲遲沒有返回結果,所以「視窗2」在等待「視窗1」的響應結束,「視窗2」才會再發起請求。

同時也解釋了為什麼使用「無痕視窗」就會立即返回結果,因為無痕模式下,會建立一個全新的 http 連線,不存在複用的問題。

最簡單的方法,我們直接使用 curl 來測試一下

curl -X GET https://test.lock > output1.txt &
curl -X GET https://test.lock > output2.txt &

我們可以看到返回結果中,獲取鎖失敗的那個請求並沒有發生阻塞,獲取到鎖的那個請求發生了阻塞。由此可見並不是 sleep() 問題導致的。


細說 sleep()

話又說回 sleep(),說實話,我在開發中幾乎從來沒使用過這個函式,這個函式會導致 session 阻塞。

舉例:

Route::get('/session1', function () {
    session_start();
    $_SESSION['data'] = 'Test Session';
    echo "Session 開啟...\n";
    sleep(3);

    echo "Session 資料: " . $_SESSION['data'] . "\n";
});

Route::get('/session2', function () {
    session_start();
    echo "Session 資料: " . $_SESSION['data'] . "\n";
});

上面定義了兩個路由,「session1」 和 「session2」,當開啟 session_start() 開啟會話後,如果 session_wirte_close() 關閉前的程式碼包含 sleep(),那麼它就會造成阻塞,導致其他請求均無法訪問當前 session 資料。

PHP 的 Session I/O 是序列機制,原理參考上面的 chrome 複用 http 連線。當相同的 session 請求在 session_start() 開啟後,在沒有 session_wirte_close() 之前,其他請求只能等待它寫入關閉,才能再進行訪問。

所以在使用 sleep() 之前,我們要先確保已經關閉了 session 寫入:

Route::get('/session1', function () {
    session_start();
    $_SESSION['data'] = 'Test Session';
    echo "Session 開啟...\n";
    session_wirte_close(); // 關閉寫入
    sleep(3);

    echo "Session 資料: " . $_SESSION['data'] . "\n";
});

Route::get('/session2', function () {
    session_start();
    echo "Session 資料: " . $_SESSION['data'] . "\n";
});

如何確認到底有沒有複用 http 連線

我們來驗證一下這個問題,首先是 chrome 瀏覽器

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Chrome</title>
</head>
<body>

<script>
  for (let i = 0; i < 6; i++) {
    fetch('https://mixo2o.test/lock')
      .then(response => response.text())
      .then(data => console.log(`Response ${i}:`, data))
  }
</script>
</body>
</html>

開啟開發者工具——網路

一個有趣的鎖問題

從阻塞排隊中就可以看出來,因為第一個請求有 sleep() 導致了阻塞,後續的請求都在排隊等待,響應時間都是 20s+,並且只有一條 http 請求。


再來看下 safari

一個有趣的鎖問題

時間線上看:這裡只有一條請求複用了 http 連線,就是靜態 HTML 載入和第一次 ajax 請求,後續的 ajax 請求都是獨立的

一個有趣的鎖問題

再看一下網路請求,沒有因為第一個請求的 sleep() 導致阻塞發生,第一個請求取到鎖後,其他請求都是瞬間返回。

Chrome 連線複用猜想

一個有趣的鎖問題

關於瀏覽器視窗複用 http 連線的問題,可以參考這篇文章
www.zhihu.com/question/554796551

本作品採用《CC 協議》,轉載必須註明作者和本文連結
我從未見過一個早起、勤奮、謹慎,誠實的人抱怨命運。

相關文章