在多数UNIX系统中,当多个进程或线程同时访问或编辑同一个文件时,该文件的最后状态取决于最后一个写该文件的进程。而对有些应用程序,比如数据库,各个进程需要保证它正在单独地写入一个文件,此时就需要使用文件锁。
文件锁也叫记录锁,所用是当一个进程读写文件的某部分时其它进程无法修改同一文件区域。能够实现文件锁的函数包括flock和fcntl。flock时fcntl基础上构造的函数,它允许对文件中任意字节区域加锁。
文件锁全称Advisory file lock,此类锁比较常见,比如MySQL、PHP-FPM启动之后都会有一个PID
文件用来记录进程编号,这个文件的作用就是文件锁。
文件锁的作用可以防止重复运行一个进程,比如在使用Crontab时限定每分钟执行一个任务,但这个进程运行时间可能会超过1分钟,如果不用进程所解决冲突的话,两个进程一起执行就会出现问题。使用PID
文件锁还有一个好处是可以方便进程向自己发送停止或重启信号。
例如:使用指令重启PHP-FPM,即发送USR2信号给PHP的PID文件记录的进程。
$ kill -USR2 `cat /usr/local/php/var/run/php-fpm.pid`
文件锁是针对文件的锁,也可认为是使用文件作为锁。文件锁是为了确保单个进程的执行,在程序执行前判断文件是否存在。若存在则不执行,若不存在则创建一个空文件,在进程结束后会删掉这个空文件。
在存在较大并发的应用场景下,比如文件操作在网络环境下完成时,可能存在多个客户端在同一时刻对服务器上的同一个文件进行并发访问, 同时读写有可能会破坏文件造成脏数据。
若通过fwrite
向文件尾部多次有序写入数据时,不加锁会发生什么情况呢?多次有序的写入操作其实相当于一个事务,如何保证事务的完整性呢?
为了确保操作的有效性和完整性,可以通过锁机制将并发状态转换为串行状态。作为锁机制中的一种,文件锁是为了应对资源竞争。
PHP文件锁
PHP利用flock()
函数对文件进行加锁(排它锁)以实现并发按序进行,flock()
允许执行一个简单且可在任何平台中使用的读取或写入模型锁定或解锁文件。简单来说,就是对一个文件进行锁定操作,多进程访问文件时,使用户有序的排队以避免混乱,从而受到限制以防止冲突。
使用flock()
添加的锁仅在当前PHP进程中使用,若权限允许其它进程是可以修改或删除PHP-locked文件的。
flock(resouce $handle, int $operation [, int &$wouldblock]):bool
PHP的flock()
的实现是基于系统调用函数flock()
,此函数的特点是当一个文件描述符被复制时(比如fork
),新文件描述符和旧的引用同一个锁,只有关闭了所有文件描述符副本或执行解锁操作,锁才能被释放。
比如父进程打开一个文件并取得了互斥锁,然后fork了一个子进程并关闭了相应的文件描述符,此时文件上的互斥锁依然还会存在,如果在子进程没有关闭锁或者文件描述符,就会造成其它地方对这个文件取得锁的操作被阻塞,也就一直无法获取到锁。
PHP5.3.2以后在文件资源关闭时将不再自动解锁,因此最好在处理完成之后就主动释放锁。
返回值
成功返回true
失败返回false
参数列表
参数 | 类型 | 必须 | 描述 |
---|---|---|---|
$handle | resouce | 必填 | 需锁定或释放的已打开文件(文件系统指针,由fopen()创建的资源)。 |
$operation | int | 必填 | 锁定类型 |
$wouldblock | boolean | 可选 | 锁定时是否阻塞其它进程 |
handle 文件指针
- handle是典型地由fopen()创建的resource资源类型的文件系统指针
- flock()操作的file对象必须是一个已经打开的文件指针
- flock()需要一个文件指针,因此可能不得不用一个特殊的锁定文件来保护打算通过写模式打开的文件的访问,即使用
fopen(filepath, "w")
或fopen(filepath, "w+")
的方式。
operation 锁定类型
类型 | 值 < PHP 4.0.1 | 描述 |
---|---|---|
LOCK_SH | 1 | 读锁,共享锁,针对读取的程序。 |
LOCK_EX | 2 | 写锁,独占锁,排它锁,针对写入的程序。 |
LOCK_UN | 3 | 解锁,释放锁定,无论共享或独占。 |
LOCK_NB | 4 | 锁定时阻塞,Windows不支持。 |
若不想出现脏数据,最好在读取数据时使用LOCK_SH
共享锁,在写入数据时使用LOCK_EX
独占锁,这样写程序会等待读程序执行完毕后,才会进行操作。但如果多个脚本同时申请对相同文件进行加锁时,则会导致竞争条件关系,此时应使用DBMS进行解决。
//获取当前进程的PID
$pid = posix_getpid();
//打开文件
$filename = "/tmp/process.pid";
$fp = fopen($filename, "w+");
//添加独占锁和非阻塞
if(flock($fp, LOCK_EX | LOCK_NB)){
echo "Got the lock file content".FILE_APPEND;
//写入内容
ftruncate($fp, 0);
fwrite($fp, $pid);
fflush($fp);
//模拟长时间运行进程
sleep(30);
//释放锁定
flock($fp, LOCK_UN);
}else{
echo "Cannot get pid lock, the process is already up".FILE_APPEND;
}
//释放资源
fclose($fp);
需要注意的是fwrite()之后文件会立即更新,而不是等fwrite到fclose之后才更新,这个可以通过fwrite之后fclose之前读取文件进行检查。
flock()函数默认是阻塞,若想非阻塞可在operation参数中添加LOCK_NB。
wouldblock 锁定是否阻塞
若锁定会阻塞的话,也就是说已经被flock()
锁定的文件再次锁定时flock()
函数会被挂起形成锁定阻塞。设置$wouldblock = true
或$wouldblock = 1
(Windows不支持)锁定时会阻塞其它进程。
文件锁处理高并发
$ vim code1.php
使用注意
- 虽然可以通过
fclose()
来释放锁定操作,代码执行完毕时会自动调用。但最好还是在代码的执行逻辑中添加LOCK_UN
进行解锁。 - 在部分操作系统中
flock()
是以进程级实现的,当使用一个多线程服务器API如ISAPI时可能不可以依靠flock()
来保护文件,因为运行在同一服务器实例中其它并行线程的PHP脚本可以对该文件进行处理。 -
flock()
函数无法在NFS
或其它网络文件系统中使用,也无法在多线程服务器API中使用。
0){
usleep(rand(1, 10000));
}
$count += 1;
}while(!flock($fp, LOCK_EX) and $count<=$max);
if($count == $max){
return false;
}
//写入文件解锁文件
fwrite($fp, $content.FILE_APPEND);
flock($fp, LOCK_UN);
//文件文件释放资源
fclose($fp);
return true;
}
例如:一个文件写入一个读取对比
SESSION中的文件锁
PHP的SESSION默认存储实现中用到了flock文件锁,当SESSION开始时会调用PS_READ_FUNC以O_CREAT|O_RDWR|O_BINARY打开SESSION数据文件,此时调用flock加上写锁,如果由其它进程访问当前文件,即同一用户再次对当前文件发起请求,会显示页面加载中进程被阻塞。添加写锁是为了保证本次会话中对SESSION的操作事务的完整性,防止其它进程干扰,保证数据的一致性。如果一个页面没有SESSION修改操作,可尽早调用session_write_close()方法释放掉锁。
flock存在的问题
在PHP中flock()
工作的似乎并不那么好,特别是在并发情况下,会经常独占资源不即时释放或根本就不释放从而造成死锁,导致CPU占用很高,甚至会让服务器彻底死掉。
flock()函数的不可靠让处理文件IO时很头疼,自定义函数以降低flock的不可靠性。
/**
* flock()的不可靠性让处理文件IO很麻烦
* 使用前后需要IGNORE USER ABOUT以避免用户突然关闭浏览器造成文件还没传完。
* 缺陷:若两个进程同时发现锁文件不存在则会同时建立出现冲突,虽然发生几率较少。
*/
function safewrite($filename, $content){
//锁文件
$lockfile = $filename.".lock";
//锁文件与当前时间间隔秒数
$seconds = 0;
while($seconds < 2){
clearstatcache();
//判断锁文件是否存在
if(file_exists($lockfile)){
//延迟100微秒后执行
usleep(100);
//获取锁文件的最近一次修改时间与当前时间间隔的秒数
$seconds = abs(date("s") - date("s", filemtime($lockfile)));
//跳开
continue;
}else{
//创建锁文件
@fclose(@fopen($lockfile, "w"));
//打开源文件
$fp = fopen($filename, "w");//无法打开
if($fp===false){
return false;
}
//向源文件写入内容
$ret = @fwrite($fp, $content);
if($ret === false){
return false;//无法写入
}
//关闭文件资源
@fclose($fp);
//删除锁文件
unlink($lockfile);//删除锁文件
//返回真
return true;
}
}
//若无法删除锁文件
if(!unlink($lockfile)){
return false;//无法删除锁文件
}
//向锁文件写入内容并关闭
@fclose(@fopen($lockfile, "w"));
//打开源文件
$fp = @fopen($filename, "w");
if($fp === false){
return false;//无法打开文件
}
//向源文件写入内容
$ret = @fwrite($fp, $content);
if($ret === false){
return false;//无法写入文件
}
//关闭文件
@fclose($fp);
//删除锁文件
unlink($lockfile);
//返回为true
return true;
}
//使用案例
ignore_user_abort(true);
$content = "hello world";
if(safewrite("test", $content)){
echo $content;
}else{
fopen("error", "w");
}
ignore_user_abort(FALSE);
所以在使用flock()
之前一定要谨慎考虑。但如果flock()
使用得当则完全可以解决死锁问题,解决的方案:
- 文件锁 + 超时限制
对文件进行加锁时设置一个超时时间,在超时时间内没有获得锁则反复获取直到获得对文件的操作权为止。如果超过时间限制则立即退出,以让出锁让其它进程进行操作。
/**
* 写入文件 防止死锁
* 超时1ms内若没有获得写入权限得独占锁则反复获取直到获得对文件操作权为止
*/
function filewrite($filename, $content){
$starttime = microtime();
//打开文件
if($fp = fopen($filename, "a")){
//超时1ms内若没有获得锁则反复获取直到获得对文件操作权为止
do{
//对文件进行加写锁,成功则获取写入权限。
$canWrite = flock($fp, LOCK_EX);
//没有获得锁则反复获取
if(!$canWrite){
usleep(round(rand(0, 100)*1000));//延迟代码执行若干微秒
}
}while(!$canWrite && (microtime() - $starttime < 1000));
//获得对文件操作权为止
if($canWrite){
fwrite($fp, $content);
}
}
//关闭文件
fclose($fp);
}
- 不使用flock()函数而是借助于临时文件解决文件读写冲突
实现思路
- 将需要更新的文件拷贝一份到临时文件目录,将文件最后修改时间保存为变量,并为临时文件定义随机不重复的文件名。
- 当对临时文件进行更新后再检测源文件的最后更新时间和先前保存的时间是否一致。
- 若最后一次修改时间保持一致则将所修改的临时文件重命名到原文件,为确保文件状态同步更新需清除一下文件状态。
- 若最后一次修改时间与先前保存一致则说明此期间原文件已经被修改过,此时需将临时文件删除后返回false,以表明文件此时有其它进程在操作。
对操作的文件进行随机读写以降低并发的可能性
典型的应用场景是日志记录,即先定义一个随机空间,假设随机读写空间为[1-`00],那么日志文件的分布就是log1~100不等。每次用户访问,都将数据随机写入到log1到log100之间的任一文件。在同一时刻,有2个进行进行记录日志。这种方案的好处是进程操作排队的可能性比较小,使进程很迅速的完成每次操作。将操作进程放入队列
死锁
死锁是操作系统或软件运行的一种状态,在多任务下当一个或多个进程等待系统资源而资源又被系统本身或其它进程占用时,就会形成死锁。死锁发生最常见的形式是两个或多个线程等待被另一个线程占用的资源。
线程1 | 线程2 |
---|---|
获取锁A | 获取锁B |
请求锁B | 请求锁A |
死锁形成条件
- 条件1. 互斥条件
进程对所分配到的资源进行排它性使用,即在一段时间内某资源只能被一个进程占用,如果此时还有其它进程请求资源则请求者只能等待,直到占有资源的进程使用完毕后释放。 - 条件2. 请求和保持条件
进程已经保持至少一个资源但又提出了新的资源请求,而该资源已被其它进程占用时,此时请求进程阻塞但又对自己已获得的资源保持不放。 - 条件3. 不剥夺条件
进程已获得的资源在使用结束之前不能被剥夺,只能在自己使用结束之后由自己释放。 - 条件4. 环路等待条件
当发生死锁时必然存在一个进程 - 资源的环形链,即进程集合{P0, P1, ..., Pn},其中P0正在等待P1占用的资源,P1正在等待P2占用的资源,以此类推。
预防死锁方案
预防死锁的方式是使四个条件中的第2、3、4条件之一不能成立即可
- 加锁顺序:按照同一顺序加锁
当多个进程需要相同的多个锁,又按照不同的顺序加锁时,就会很容易发生死锁。如果能保证所有的进程都按照相同的顺序获得锁,那么死锁就不会产生了。 - 加锁时限:进程尝试获取锁时加上一定的时限
如果申请锁时超过时限,该进程会放弃对该锁的请求并释放所有已经获得的锁,然后过一段随机的时间后重试。这段随机的时间让其它线程有机会尝试获取相同的锁,并让该应用在没有获得锁的时候继续进行。问题是,如果有非常多的进程同一时间去竞争同一批资源,即使有超时和回退机制,还是有可能会存在某些进程反复尝试却始终得不到锁的问题。
避免死锁
在资源的动态分配过程中,使用某种方法去防止系统进入不安全状态,从而避免发生死锁。
防死锁机制
多线程中访问公共资源时需要对资源进行加锁,当访问结束后需释放锁。如果没有释放锁,下一个线程来获取资源时将永远无法获取资源的锁,于是这个线程也就死锁了。
当多个进程同时发现锁文件不存在时会同时建立文件,会造成冲突,虽然概率很小但仍有可能。数据库本身有防死锁机制。
/**
* 使用锁机制实现单一任务执行
*/
class Schedule{
//锁文件地址
private $lockfile = "./lock";
//标记是否开始执行业务逻辑
private $flag = false;
/**
* 定时任务
*/
public function task(){
//本方法执行完毕时调用shutdown方法
register_shutdown_function([$this, "shutdown"]);
//若锁文件不存在则退出
if(file_exists($this->lockfile)){
//获取锁文件内容
$contents = file_get_contents($this->lockfile);
//锁文件超时(1小时)则删除以防止死锁
if(time() - $contents > 3600){
unlink($this->lockfile);
}
exit("任务正在执行");
}
//业务执行发生异常,可能会导致文件死锁。
try{
//向锁文件内写入内容
file_put_contents($this->lockfile, time());
//开始执行业务逻辑
$this->flag = true;
//执行业务逻辑 todo
//模拟异步休眠3秒
sleep(3);
}catch(Exception $e){
//exception deal
}
//删除锁文件
unlink($this->lockfile);
}
public function shutdown(){
if($this->flag){
unlink($this->lockfile);
}
}
}
$sch = new Schedule();
$sch->task();
使用Redis锁限制并发访问
对于需要限制同一用户并发访问的场景,若用户并发请求多次而服务器处理没有加锁限制,用户则可以多次请求成功。比如领取优惠卷,如果用户同一时间并发提交,在没有加锁的情况下用于会使用同一个领取吗同时兑换到多张优惠卷。虽然可以使用文件锁实现并发访问限制,但对分布式架构的环境,使用文件锁并不能保证多态服务器的并发访问限制。
使用Redis提供的setnx()实现分布式锁功能,setnx即Set it Not exists(若不存在则SET)的意思,setnx命令会将key值设置为value仅当key不存在时。
$ SETNX key value
_config = $config;
//创建Redis连接
$this->_redis = $this->connect();
}
/**
* 连接Redis
*/
public function connect(){
try{
//获取配置
$host = $this->_config["host"];//主机地址
$port = $this->_config["port"];//主机端口
$timeout = $this->_config["timeout"];//超时时长
$reserved = $this->_config["reserved"];//是否保留
$retry_interval = $this->_config["retry_interval"];//重试间隔
$auth = $this->_config["auth"];//授权密码
$index = $this->_config["index"];//目标数据库索引
//连接Redis
$redis = new Redis();
$redis->connect($host, $port, $timeout, $reserved, $retry_interval);
if(!empty($auth)){
$redis->auth($auth);
}
$redis->select($index);
}catch(RedisException $e){
throw new Exception($e->getMessage());
return false;
}
return $redis;
}
/**
* 获取锁
* @param String $key 锁标识
* @param Int $expire 锁过期时长
* @return Boolean
*/
public function lock($key, $expire=5){
//获取并设置锁
$is_lock = $this->_redis->setnx($key, time() + $expire);
if(!$is_lock){
//获取目标时间
$val = $this->_redis->get($key);
//若锁过期则删除后重新获取
if(time() > $val){
//删除锁
$this->unlock($key);
//重新获取并设置锁
$is_lock = $this->_redis->setnx($key, time() + $expire);
}
}
return $is_lock?true:false;
}
/**
* 删除锁
*/
public function unlock($key){
return $this->_redis->del($key);
}
}
//TEST
$config = [];
$config["host"] = "127.0.0.1";
$config["port"] = 6379;
$config["index"] = 0;
$config["auth"] = "";
$config["timeout"] = 1;
$config["reserved"] = null;
$config["retry_interval"] = 100;
$obj = new RedisLock($config);
$key = "lock";
$islock = $obj->lock($key, 10);
if($islock){
//todo
sleep(5);
$obj->unlock($key);
}