php多进程读写同一个文件锁的问题及flock详解

时间:2021-09-08 14:44:06

php是原生支持多进程编程的,可以利用pcntl_fork()在当前位置产生一个子进程,那么就可能存在多个进程读写同一个文件的问题,比如多进程程序读写同一个日志文件,这样就有必要解决读写同一个文件时加锁的问题,php已经内置了一个读写的文件锁方法flock,,官方的解释是轻便的文件咨询锁定,这很官方。


还是先看一个栗子吧:

<?php

$fp = fopen("logs/app.log", "a+");

if (flock($fp, LOCK_EX)) {  // 进行排它型锁定
    fwrite($fp, "Write something here\n");
    fflush($fp);            // flush output before releasing the lock
    flock($fp, LOCK_UN);    // 释放锁定
} else {
    echo "文件正在被其他程序占用";
}

fclose($fp);

?> 

flock的第一个参数是一个文件句柄,第二个参数可以设置锁定方式,有几个常量可以设置,下面一一介绍。

LOCK_SH : 取得共享锁定(读取的程序)也就是常说的共享锁,该进程只能读不能写,其他进程还是能读取该文件的。

LOCK_EX:取得独占锁定(写入的程序)常说的独占锁,该进程能读写该文件,其他进程则不能读写。

LOCK_UN:释放锁定(无论共享或独占)也就是释放上述两种锁。

LOCK_NB:配合LOCK_SH和LOCK_EX使用,使得在加锁时程序非阻塞。


下面用几个示例简单说明下:

--------------------------------------------------------------------------------------------------------------------------

2018-01-29更新

这篇文章是半年多前写的,当时只是看php的文档,远没有意识到linux/unix下文件锁的复杂,由于当时写这篇文章时也是存在疑惑,示例程序也就不了了之并随之遗忘,直到有人留言评论我才重新关注起了这个问题,最近花了两天的时间查看相关文档以及编写测试代码,现在就我当下的理解记录一下

PHP文档对于flock函数的中文翻译很不专业,这是flock英文原文的解释

(PHP 4, PHP 5, PHP 7)

flock — Portable advisory file locking

翻译过来应该是 可移植的协同锁,而中文文档中的翻译  轻便的文件咨询锁定 略显业余


一、协同锁(advisory lock) 和 强制锁 (mandatory lock)

1、协同锁
  协同锁要求参与操作的进程之间协同合作。假设进程“A”获得一个WRITE锁,并开始向文件中写入内容;此时,进程“B”并没有试图获取一个锁,它仍然可以打开文件并向文件中写入内容。在此过程中,进程“B”就是一个非合作进程。如果进程“B”试图获取一个锁,那么整个过程就是一个合作的过程,从而可以保证操作的“序列化”。
  只有当参与操作的进程是协同合作的时候,协同锁才能发挥作用。协同锁有时也被称为“非强制”锁。

2、强制锁
  强制锁不需要参与操作的进程之间保持协同合作。它利用内核来查检每个打开、读取、写入操作,从而保证在调用这些操作时不违反文件上的锁规则。

而flock使用协同锁,它要求进程都要遵守先拿锁,后操作的约定,这样才能实现文件锁的功能。


二、在介绍后续的内容之前,首先我们还要了解一下linux内核对于打开文件的处理机制,以下摘自《linux/unix系统编程手册》一书第5.4节

php多进程读写同一个文件锁的问题及flock详解

php多进程读写同一个文件锁的问题及flock详解

php多进程读写同一个文件锁的问题及flock详解

从上面的介绍可以知道,复制文件描述符(通过fork创建子进程或者dup系统调用)之后这些文件描述符指向内核中的同一个打开文件句柄,而进程每次调用fopen打开一个文件都会在内核中维护一个新的打开文件句柄

例如:

<?php
//例程
$fp = fopen("demo.log", "a");

$pid = pcntl_fork();

if ($pid == 0)
{
    echo "子进程\n";
} else {
    echo "父进程\n";
}
上面例程一中先打开一个文件,然后fork,相当于是复制了文件描述符,父子进程中的文件句柄指向内核中同一个打开文件句柄。

<?php
//例程二
$pid = pcntl_fork();

$fp = fopen("demo.log", "a");

if ($pid == 0)
{
    fwrite($fp, "子进程\n");
} else {
    fwrite($fp, "父进程\n");
}

而这个例程二是先fork,然后父子进程分别调用了一次fopen,这时父子进程的文件句柄指向内核中的不同的打开文件句柄,虽然它们打开的是同一个文件。

三、flock锁是基于内核中打开文件句柄的
前面之所以大费周章的介绍内核打开文件的数据结构,正是由于flock施加的锁是基于内核中打开的文件句柄,也就是说指向内核中同一个打开文件句柄的文件描述符(或文件句柄)是共享一个文件锁的,对其中任何一个文件句柄的加锁操作都会反映到其他的文件句柄。对于一个已经获得锁的内核打开文件句柄,再次加锁会先释放之前的锁,然后再次加新锁,可以理解是更新了 一次锁

<?php
$fp = fopen("demo.log", "a");

if(flock($fp, LOCK_EX))
{
    echo "加锁成功\n";
}

if(flock($fp, LOCK_EX))
{
    echo "加锁成功\n";
}
上面这个例程虽然第一次加锁之后没有释放锁,但第二次加锁还是会成功,这就是更新锁的情况。

<?php
$fp1 = fopen("demo.log", "a");
$fp2 = fopen("demo.log", "a");

if(flock($fp1, LOCK_EX))
{
        echo "fp1加锁成功\n";
}


if(flock($fp2, LOCK_EX))
{
        echo "fp2加锁成功\n";
}
这个例程打开同一个文件两次,fp1和fp2指向不同的内核打开文件句柄,fp1获得锁后没有释放,结果fp2将获取不到锁而一直阻塞。


<?php
//例程四
$fp = fopen("demo.log", "a");
$pid = pcntl_fork();

if ($pid == 0)
{
    if(flock($fp, LOCK_EX))
    {
            echo "子进程加锁成功\n";
            while(1)
            {
                sleep(1);
            }
    }
} elseif($pid > 0) {
    sleep(1);
    if(flock($fp, LOCK_EX))
    {
        echo "父进程加锁成功\n";
    }
}

上述例程四输出:

子进程加锁成功
父进程加锁成功
由于先打开文件然后fork,父子进程的文件句柄指向同一个内核打开文件句柄,父子进程每次加锁都相当于在更新同一个锁,所以虽然子进程先拿到了锁并且没有释放锁,父进程却仍然可以拿到锁,这本质上还是一种更新锁的情况,flock并没有达到并发控制的目的。

<?php
//例程五
$pid = pcntl_fork();

$fp = fopen("demo.log", "a");

if ($pid == 0)
{
    if(flock($fp, LOCK_EX))
    {
            echo "子进程加锁成功\n";
            while(1)
            {
                sleep(1);
            }
    }
} elseif($pid > 0) {
    sleep(1);
    if(flock($fp, LOCK_EX))
    {
        echo "父进程加锁成功\n";
    }
}
上述例程五中,先fork一个子进程,然后父子进程都用fopen打开文件,它们的文件句柄指向不同的内核打开文件句柄,所以当子进程拿到锁后,只要不释放锁,那么父进程将永远拿不到锁,这才是flock正确的使用场景。

四、flock的使用场景和示例代码

<?php
$pid = pcntl_fork();
$fp = fopen("log.txt", "a");

if ($pid == 0)
{
        for($i = 0; $i < 1000; $i++)
        {
                fwrite($fp, "黄河远上白云间,");
                fflush($fp);
                fwrite($fp, "一片孤城万仞山。");
                fflush($fp);
                fwrite($fp, "羌笛何须怨杨柳,");
                fflush($fp);
                fwrite($fp, "春风不度玉门关。\n");
                fflush($fp);
        }
}
else if ($pid > 0)
{
        for($i = 0; $i < 1000; $i++)
        {
                fwrite($fp, "葡萄美酒夜光杯,");
                fflush($fp);
                fwrite($fp, "欲饮琵琶马上催。");
                fflush($fp);
                fwrite($fp, "醉卧沙场君莫笑,");
                fflush($fp);
                fwrite($fp, "古来征战几人回。\n");
                fflush($fp);
        }
}
上面这个例子中创建一个子进程,然后父子进程以追加的模式分别打开同一个文件,父子进程向日志文件中分别循环写一首诗( 这里使用fflush每写一句就刷新文件缓冲,避免缓冲影响问题的显现),结束之后查看日志文件:

葡萄美酒夜光杯,欲饮琵琶马上催。醉卧沙场君莫笑,古来征战几人回。
葡萄美酒夜光杯,欲饮琵琶马上催。醉卧沙场君莫笑,古来征战几人回。
葡萄美酒夜光杯,欲饮琵琶马上催。黄河远上白云间,一片孤城万仞山。羌笛何须怨杨柳,春风不度玉门关。
黄河远上白云间,一片孤城万仞山。羌笛何须怨杨柳,春风不度玉门关。
黄河远上白云间,一片孤城万仞山。羌笛何须怨杨柳,春风不度玉门关。
可以看到上述代码的问题是一首诗还没写完,另一首诗就开始写了,结果破坏了诗的完整性,如果不想两首诗混在一起,那么就可以使用flock在开始写入一首诗之前加锁,写完之后释放锁。


真正的栗子来了

$pid = pcntl_fork();
$fp = fopen("log.txt", "a");

if ($pid == 0)
{
        for($i = 0; $i < 1000; $i++)
        {
                if (flock($fp, LOCK_EX)){
                        fwrite($fp, "黄河远上白云间,");
                        fflush($fp);
                        fwrite($fp, "一片孤城万仞山。");
                        fflush($fp);
                        fwrite($fp, "羌笛何须怨杨柳,");
                        fflush($fp);
                        fwrite($fp, "春风不度玉门关。\n");
                        fflush($fp);
                        flock($fp, LOCK_UN);
                }
        }
}
else if ($pid > 0)
{
        for($i = 0; $i < 1000; $i++)
        {
                if (flock($fp, LOCK_EX)){
                        fwrite($fp, "葡萄美酒夜光杯,");
                        fflush($fp);
                        fwrite($fp, "欲饮琵琶马上催。");
                        fflush($fp);
                        fwrite($fp, "醉卧沙场君莫笑,");
                        fflush($fp);
                        fwrite($fp, "古来征战几人回。\n");
                        fflush($fp);
                        flock($fp, LOCK_UN);
                }
        }
}

That's it!