從並發處理談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 幫我們實現了自旋;

用法如下:

  1. 關閉 mysql 的自動提交,自動提交默認打開,除非使用 transition 語句顯示開啟事務,默認會將每一條 sql 作為一個事務直接提交執行,這里關閉。 set autocommit=0;
  2. 使用select for update 語句給數據添加互斥鎖。注意:需求 mysql 的 innodb 引擎支持;
  3. 進行數據更新和處理操作;
  4. 主動提交事務,並將 自動提交恢復;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內置函數庫來進行進程間通信,歡迎關注;

如果您覺得本文對您有幫助,您可以點一下推薦。博客持續更新,歡迎關注。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM