從併發處理談PHP程序間通訊----併發鎖表操作
程序間通訊
程序間通訊(IPC,Inter-Process Communication),多程序開發中,程序間通訊是一個永遠也繞不開的問題。在 web開發中,我們經常遇到的併發請求問題,本質上也可以作為程序間通訊來處理。
程序間通訊,指至少兩個程序或執行緒間傳送資料或訊號的一些技術或方法。程序是計算機系統分配資源的最小單位(嚴格說來是執行緒)。每個程序都有自己的一部分獨立的系統資源,彼此是隔離的。為了能使不同的程序互相訪問資源並進行協調工作,才有了程序間通訊。
根據定義可知,要進行程序間通訊,我們需要解決兩個問題:
- 互相訪問:訊息傳輸和暫時儲存介質選擇問題;
- 協調工作:訊息的存取衝突問題;
文章介紹的中心就是圍繞著這麼兩點來說的, 為了更使文章更簡明,這邊以之前在公司做的一個需求為例:
需要一個迴圈ID生成器,迴圈生成從 Min 到 Max 的數字ID,在ID遞增到 Max 後,返回到 Min 重新開始遞增;必須能保證多個程序併發請求時生成的ID不同。
此需求要解決的問題恰好為我們要解決的程序間通訊需要解決的兩個問題:
- 需要一個訊息傳輸通道來傳輸和儲存當前的遞增值。這個比較容易解決,我們常用的檔案、資料庫、session、快取等都能做到。
- 需要解決多程序同時訪問生成器生成相同ID的問題。要滿足這個需要就必須要用到鎖了,而且為了保證多個程序讀取的資料是不同的,需要互斥鎖,另外為了能保證呼叫成功率,鎖的獲取最好能實現自旋。
本文通過此需求的不同實現,來介紹通過外部介質進行的程序間通訊的方式。另外,不只PHP語言,其他語言也能使用這些方法。
文章如有錯漏之處,煩請指出,如果您有更優的辦法,歡迎在下面留言討論。
檔案
flock
檔案是最基本的儲存介質,它當然可以作為訊息的傳輸通道來使用。檔案的存取各種語言都有各自的多種方案,問題點是多程序併發時的衝突問題。
解決存取衝突問題我們使用PHP的 flock()
函式:
bool flock ( resource $handle , int $operation [, int &$wouldblock ] )
-
$handler 是 使用
fopen($path_to_file)
-
$operation 是 對檔案加鎖的方式,有以下值可選:
LOCK_SH (獲取共享鎖) / LOCK_EX (獲取互斥鎖) / LOCK_UN (解鎖)
這裡我們選用互斥鎖,一個程序獲取到互斥鎖後,其他程序在嘗試獲取鎖會被阻塞,直到鎖被釋放,即實現了自旋;
此外,還有一個引數 LOCK_NB,flock 在獲取不到鎖時,預設會阻塞住直到鎖被其他程序釋放,傳入 LOCK_NB 與 LOCK_SH 或 LOCK_EX 進行或運算結果(
LOCK_EX | LOCK_NB
),flock 在鎖被其他程序佔有時,不會阻塞,而是直接返回 false,這裡僅作介紹,我們並不使用它。 -
$wouldblock 引數是一個引用值,在獲取不到鎖,且不阻塞模式時,$wouldblock 會被設定為 true;(手冊中說阻塞時才會被設定為 true。其實我也奇怪這個變數名的。不知道是不是 bug,我的PHP版本是 5.4.5,有知道的煩請解惑)
程式碼實現
下面是迴圈ID生成器程式碼,說明在註釋中:
function getCycleIdFromFile($max, $min = 0) {
$handler = fopen('/tmp/cycle_id_generator.txt', 'c+');
if (!flock($handler, LOCK_EX)) {
throw new Exception('error_get_file_lock!');
}
$cycle_id = trim(fread($handler, 9));
$cycle_id++;
if ($cycle_id > $max) {
$cycle_id = $min;
}
// 檔案指標返回到檔案頭,並向檔案內寫入新的cycle_id
rewind($handler);
fwrite($handler, $cycle_id);
// 多寫入一些空格為了防止數值升到多位後,突然置為少位後面的數字仍保留
fwrite($handler, str_repeat(' ', 9));
flock($handler, LOCK_UN);
return $cycle_id;
}
mysql
select for update
我們常用的 mysql 也可以被當作中間介質來實現程序間的通訊,我們規定好某一個數據表內的某一行資料作為訊息交換的中轉站,使用 mysql 自帶的鎖來協調多個程序的存取衝突。
事務的設計目的就是為了解決多程序併發查詢時資料衝突的問題,可是我們常用的事務只能保證資料衝突時會被回滾,資料不會出現錯誤,並不能實現請求的並行化。對一些資料衝突回滾的請求,需要我們在外層新增邏輯重試。
這裡介紹 mysql 的一種語法: select for update
,會給固定資料加上互斥鎖,且另一個請求在獲取鎖失敗時,會阻塞至獲取鎖成功,mysql
幫我們實現了自旋;
用法如下:
-
關閉 mysql 的自動提交,自動提交預設開啟,除非使用 transition 語句顯示開啟事務,預設會將每一條 sql 作為一個事務直接提交執行,這裡關閉。
set autocommit=0;
-
使用
select for update
語句給資料新增互斥鎖。注意:需求 mysql 的 innodb 引擎支援; - 進行資料更新和處理操作;
-
主動提交事務,並將 自動提交恢復;
commit; set autocommit=1;
程式碼實現
然後是程式碼實現:
// 資料庫連線實現各有不同,demo 可以自己修改一下。
function getCycleIdFromMysql($max, $min = 0){
Db::db()->execute('set autocommit = 0');
$res = Db::db()->qsqlone('SELECT cycle_id FROM cycle_id_generator WHERE id = 1 FOR UPDATE');
$cycle_id = $res['cycle_id'] + 1;
if($cycle_id > $max){
$cycle_id = $min;
}
Db::db()->execute("UPDATE cycle_id_generator SET cycle_id = {$cycle_id} WHERE id = 1");
Db::db()->execute('commit');
Db::db()->execute('set autocommit = 1');
return $cycle_id;
}
redis
incr
redis 是我們常用的快取伺服器,由於其使用記憶體儲存資料,效能很高。我們使用一個固定的普通鍵來作為訊息中轉站,然後利用其 incr
命令的原子性和其執行結果(遞增後的值),實現
cycle_id 的遞增。
incr(key)
若 key 不存在,redis 會先將值設定為0,然後執行遞增操作;
遞增沒有問題,可是我們還有個需求是在要其值達到 max 時,再將其置為 min,這時就可能會出現程序A在更新值為 min 時,另一個程序B也檢測到值大於了 max,然後將值置為 min,可是這時的值已經不是 max,即發生了值重複更新,那麼返回的值必然會有重複;
這時,我們就需要自己來實現鎖了。
SETNX
redis 的 SETNX 命令檢測某一個 key 是否存在,若不存在,則將 key 的值設定為 value,並返回結果1; 若 key 已存在,則設定失敗,返回值0。
SETNX key value
它能實現鎖是因為它是一個原子命令,即 檢測 key 是否存在和設定 key 值在一個事務內,不會出現同時兩個程序都檢測到 key 不存在,然後同時去設定 key 的情況。
我們以另一個值的存在與否,來表示 cycle_id 是否正在被另一個程序修改。
程式碼實現
function getCycleIdFromRedis($max, $min = 0) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$key_id = 'cycle_id_generator';
$cycle_id = $redis->incr($key_id);
if ($cycle_id > $max) {
// 設定"鎖鍵"的結果 = 獲取互斥結果
$key_lock = 'cycle_id_lock';
if (!$redis->setnx($key_lock, 1)) {
return null;
}
$cycle_id = $min;
$redis->set($key_id, $cycle_id);
// 最後別忘記釋放互斥鎖
$redis->delete($key_lock);
}
$redis->close();
return $cycle_id;
}
注意:由於 redis 裡沒有能實現自旋鎖的命令,如果需求最高的獲取成功率,我們在檢測到 cycle_id 已經是最大值,且試圖修改獲取鎖失敗時,退出重試,在外層進行重試。
function getCycleId($max, $min = 0) {
$cycle_id = getCycleIdFromRedis($max, $min);
if (!is_null($cycle_id)) {
return $cycle_id;
}
// 稍微等待下正在更改的程序
usleep(500);
// 這裡使用遞迴,直至獲取成功 併發很高,cycle_id重置很頻繁時慎用.
return getCycleId($max, $min);
}
優化
審查程式碼我們會發現,如果 max-min 的值很小的話,redis 會需要經常重置 key 的值,也就經常需要加鎖,重試也就很多。這裡,我提供一個優化方法:
我們將其 max 設定為一個很大的值(要能被 max-min 整除),返回值時稍做處理,返回 $current % ($max - $min) + $min;
。這樣,key
需要遞增到一個很大的值才會被重置,加鎖邏輯和外層邏輯會很少執行到,達到提升效率的目的。
總結:
這裡簡單的評價一下上面所說的三種方法:
-
效能上沒有測試,而且 redis 的效能跟 ID 的大小差值相關,不過猜測在ID大小差值大的情況下 redis 應該更好一點。
-
程式碼上非常直觀,使用 mysql 非常簡潔,而且 redis 要自己實現自旋,比較噁心。
-
實現上,當然是檔案最為方便,無任何新增。
本文介紹的都是通過外部介質來進行的通訊,下篇介紹下通過 PHP內建函式庫來進行程序間通訊,歡迎關注;