一个有趣的锁问题

前几天有人在论坛提出了这样一个问题:
问答:关于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://lock.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 协议》,转载必须注明作者和本文链接
悲观者永远正确,乐观者永远前行。
附言 1  ·  6个月前

我在写这篇帖子的时候,也用到了其他浏览器,发现只有 chrome 会这样,感兴趣的可以自己测试下,比如 safari。

《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
讨论数量: 15

:+1: 我说呢,跟 session 扯上什么问题,原来是浏览器复用请求链接。

关于 Session 阻塞我看有个文档这么写 -> Laravel 文档

6个月前 评论
梦想星辰大海

厉害呀

6个月前 评论

有意思的话题,可以研究研究~

6个月前 评论

仔细看了几遍, 又看了几遍文档, 终于悟了。

6个月前 评论

照这个理论

1请求锁了 2请求访问不到锁 (或者2请求锁了 1请求访问不到锁)

这不能叫"原子锁"了, (发起两个重复 http 请求还是挺常见的)

总感觉怪怪的...

6个月前 评论
徵羽宫 6个月前
徵羽宫 6个月前
徵羽宫 6个月前
MArtian (楼主) 6个月前

应该要换个浏览器测试

6个月前 评论
porygonCN

博主说的没毛病,前些日子因为一些原因我也找过浏览器的信息,确实有提到链接复用的情况 这个本身是浏览器的一种优化措施 很多同行可能还不知道 而且就那个锁的问题 我建议是多个浏览器一起测试比较好

6个月前 评论

众所周知,HTTP 协议是一次性的,而是 HTTP 基于的 TCP 是支持长连接的。此时并非是楼主所说的复用了 HTTP 链接,而是用同一个 TCP 链接建立了多个 HTTP 链接,如果不想让浏览器复用 TCP 链接,在 header 中返回 Connection 为 CLOSE 即可

之前在社区就有科普,可以详细看一下,令我疑惑的是此科普文的作者似乎就是楼主本人,附链接:分享创造:面试官问我:一个 TCP 连接可以发多少个 HTTP 请求?我竟然回答...

6个月前 评论
MArtian (楼主) 6个月前
徵羽宫 6个月前
MArtian (楼主) 6个月前

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!