我坎坷的 swoole 協程之旅

yujiarong發表於2019-04-30

Swoole 協程

協程可以理解為純使用者態的執行緒,其透過協作而不是搶佔來進行切換。相對於程式或者執行緒,協程所有的操作都可以在使用者態完成,建立和切換的消耗更低。協程主要用於最佳化IO操作頻繁的任務,當然這個IO需要使用非同步IO,能夠yeild的非同步IO。

yield 實現協程多工排程

這裡有兩篇分享很好講訴了使用yeild來實現生成器,從而實現協程多工排程,PHP 多工協程處理PHP 協程實現,借花獻佛哈哈。主要分以下兩步。
這個和Python的asyncio協程實現很像。asyncio.event_loop:程式開啟一個無限迴圈,把一些函式註冊到事件迴圈上,當滿足事件發生的時候,呼叫相應的協程函式。asyncio.task:一個協程物件就是一個原生可以掛起的函式,任務則是對協程進一步封裝,其中包含了任務的各種狀態。

Task

Task 是普通生成器的裝飾器。我們將生成器賦值給它的成員變數以供後續使用,然後實現一個簡單的 run() 和 finished() 方法。run() 方法用於執行任務,finished() 方法用於讓排程程式知道何時終止執行。

class Task
{
    protected $generator;

    protected $run = false;

    public function __construct(Generator $generator)
    {
        $this->generator = $generator;
    }

    public function run() 
    {
        if ($this->run) { //判斷是否是第一次run,第一次用next那直接會跑到第二個yield
            $this->generator->next();
        } else {
            $this->generator->current();
        }

        $this->run = true;
    }

    public function finished()
    {
        return !$this->generator->valid();
    }
}

Scheduler

Scheduler 用於維護一個待執行的任務佇列。run() 會彈出佇列中的所有任務並執行它,直到執行完整個佇列任務。如果某個任務沒有執行完畢,當這個任務本次執行完成後,我們將再次入列。

class Scheduler
{
    protected $queue;

    public function __construct()
    {
        $this->queue = new SplQueue(); //FIFO 佇列
    }

    public function enqueue(Task $task)
    {
        $this->queue->enqueue($task);
    }

    public function run()
    {
        while (!$this->queue->isEmpty()) {
            $task = $this->queue->dequeue();
            $task->run();

            if (!$task->finished()) {
                $this->queue->enqueue($task);
            }
        }
    }
}

使用

$scheduler = new Scheduler();

$task1 = new Task(call_user_func(function() {
    for ($i = 0; $i < 3; $i++) {
        print "task1: " . $i . "\n";
        yield sleep(1); //掛起IO操作
    }
}));

$task2 = new Task(call_user_func(function() {
    for ($i = 0; $i < 6; $i++) {
        print "task2: " . $i . "\n";
        yield sleep(1); //掛起IO操作
    }
}));

$scheduler->enqueue($task1);
$scheduler->enqueue($task2);
$startTime = microtime(true);
$scheduler->run();
print "用時: ".(microtime(true) - $startTime);

執行結果

交替執行,task1執行到yeild交出控制權,輪到task2執行到yeild再交出控制權,再一次輪到task1,直到task1執行完,佇列裡只剩下task2自我陶醉了。
雖然執行結果是這樣的,但是效果並不是我們想要的,執行了9秒那和我們同步執行有什麼區別,因為sleep()是同步阻塞的,接下來我們把sleep換一下。

task1: 0
task1: 1
task2: 0
task2: 1
task1: 2
task2: 2
task2: 3
task2: 4
task2: 5
用時: 9.0115599632263

非同步sleep

需要用到swoole,co::sleep()是swoole自帶的非同步sleep,go()是 swoole協程 的建立命令

function async_sleep($s){
    return  go(function ()use($s)  {
                co::sleep($s); // 模擬請求介面、讀寫檔案等I/O
            }); 
}

$scheduler = new Scheduler();

$task1 = new Task(call_user_func(function() {
    for ($i = 0; $i < 3; $i++) {
        print "task1: " . $i . "\n";
        yield async_sleep(1);
    }
}));

$task2 = new Task(call_user_func(function() {
    for ($i = 0; $i < 6; $i++) {
        print "task2: " . $i . "\n";
        yield async_sleep(1);
    }
}));

$scheduler->enqueue($task1);
$scheduler->enqueue($task2);
$startTime = microtime(true);
$scheduler->run();
print "用時: ".(microtime(true) - $startTime);

執行結果,這應該就我們想要的IO操作非同步併發,一共9個IO實際時間=1個IO,如果這個非同步IO是非同步mysql,非同步http等就大大提升了我們指令碼的併發能力

task1: 0
task2: 0
task1: 1
task2: 1
task1: 2
task2: 2
task2: 3
task2: 4
task2: 5
用時: 1.0025930404663

Swoole 協程

從4.0版本開始Swoole提供了完整的協程(Coroutine)+通道(Channel)特性。應用層可使用完全同步的程式設計方式,底層自動實現非同步IO。這句話是swoole說的。

for ($i = 0; $i < 10; ++$i) {
    // swoole 建立協程
    go(function () use ($i) {
        co::sleep(1.0); // 模擬非同步請求介面、讀寫檔案等I/O
        var_dump($i);
    });
}
swoole_event_wait(); //阻塞等所有協程完成任務
print "協程用時: ".(microtime(true) - $time);

執行時間是1秒這裡就不多說了。協程之所以快是因非同步IO可以yield,但是我們平常使用的mysql請求,http請求等都是同步的,就算使用協程排程也提升不了併發,這不swoole提供了我們想要的東東。

Swoole 協程MySQL客戶端

swoole的Coroutine\MySQL具體操作可以看這裡,程式碼中舉了非同步和同步的mysql請求和併發試一下, dump需要引入symfony,方便列印物件的結構。

//非同步mysql
function asyncMysql(){
    go(function () {
        $db = new \Swoole\Coroutine\Mysql();
        $server = array(
            'host' => '127.0.0.1',
            'user' => 'root',
            'password' => '123456',
            'database' => 'test',
            'port' => '3306',
        );
        $db->connect($server); //非同步
        $result = $db->query('select * from users limit 1');
        // dump( $result);
    });
}
//同步msql
function synMysql(){
    $servername = "127.0.0.1";
    $username = "root";
    $password = "123456";
    $dbname = "test";
    $conn = mysqli_connect($servername, $username, $password, $dbname);

    if (!$conn) {
        die("連線失敗: " . mysqli_connect_error());
    }

    $sql = "select * from users limit 1";
    $result = mysqli_query($conn, $sql);

    if (mysqli_num_rows($result) > 0) {
        while($row = mysqli_fetch_assoc($result)) {
            // dump($row);
        }
    } else {
        echo "0 結果";
    }

    mysqli_close($conn);
}

$startTime = microtime(true);

for($i=0;$i<100;$i++){
    asyncMysql();
}
swoole_event_wait();
$endTime = microtime(true);

dump($endTime-$startTime);

非同步所花時間
0.029722929000854
0.017247200012207
0.029895067214966
0.024247884750366
同步所花時間
0.086297988891602
0.083254814147949
0.0831139087677
0.083254814147949

看執行時間不太對哈,這個怎麼差了這麼一點。我想的是這樣的哈,Coroutine\MySQL 上面的例子非同步IO操作應該是 connect 和 query,其他的例如建立客戶端那就是同步操作了,這個消耗是同步阻塞的,而且佔了比例不小,所以才出現這樣的情況。
那想一下我們是不是可以這樣寫,把mysql非同步客服端直接拿出來讓協程共享。

function asyncMysql(){
    go(function(){
        $db = new \Swoole\Coroutine\Mysql();
        $server = array(
            'host' => '127.0.0.1',
            'user' => 'root',
            'password' => '4QqRbtNCc3LnHko4LQ9H',
            'database' => 'tracknumer_share',
            'port' => '3306',
        );   
        $db->connect($server); 
        $startTime = microtime(true);
        for($i=0;$i<10;$i++){
            go(function ()use($db) {
                $result = $db->query('select * from users limit 1');
            });
        }
        swoole_event_wait();
        $endTime = microtime(true);
        dump($endTime-$startTime);
    });
}
[2019-04-30 11:23:36 @4769.0]   ERROR   check_bind (ERROR 10002): mysql client has already been bound to another coroutine#2, reading or writing of the same socket in multiple coroutines at the same time is not allowed.
Stack trace:
#0  Swoole\Coroutine\MySQL->query() called at [/data/web/dev/swoole-demo/src/Coroutine/mysql.php:44]

哦天哪發生了什麼,報錯了,它說這個mysql客戶端已經有其他協程佔用了。是我太天真的了。官網說swoole這樣做是為了防止多個協程同一時刻使用同一個客戶端導致資料錯亂。
那我們就簡單實現一個mysql的連線池,複用協程客戶端,實現長連線。

Swoole 協程MySQL連線池

<?php 
require __DIR__ . '/../bootstrap.php';
class MysqlPool
{
    protected $available = true;
    public $pool;
    protected $config; //mysql服務的配置檔案
    protected $max_connection = 50;//連線池最大連線 
    protected $min_connection = 20;
    protected $current_connection = 0;//當前連線數

    public function __construct($config)
    {
        $this->config = $config;
        $this->pool   = new SplQueue;
        $this->initPool();
    }
    public function initPool(){
        go(function () {
            for($i=1;$i<=$this->min_connection;$i++){
                $this->pool->push($this->newMysqlClient());
            }
        });
    }
    public function put($mysql)
    {
        $this->pool->push($mysql);
    }

    /**
     * @return bool|mixed|\Swoole\Coroutine\Mysql
     */
    public function get()
    {
        //有空閒連線且連線池處於可用狀態
        if ($this->available && $this->pool->length > 0) {
            return $this->pool->pop();
        }

        //無空閒連線,建立新連線
        $mysql = $this->newMysqlClient();
        if ($mysql == false) {
            return false;
        } else {
            return $mysql;
        }
    }

    protected function newMysqlClient()
    {

        if($this->current_connection >= $this->max_connection){
            throw new Exception("連結池已經滿了"); 
        }
        $this->current_connection++;
        $mysql = new Swoole\Coroutine\Mysql();
        $mysql->connect($this->config); 
        return $mysql;
    }

    public function destruct()
    {
        // 連線池銷燬, 置不可用狀態, 防止新的客戶端進入常駐連線池, 導致伺服器無法平滑退出
        $this->available = false;
        while (!$this->pool->isEmpty()) {
            go(function(){
                $mysql = $this->pool->pop();
                $mysql->close();
            });
        }
    }

    public function __destruct(){
        $this->destruct();
    }
}

$config = array(
            'host' => '127.0.0.1',
            'user' => 'root',
            'password' => '123456',
            'database' => 'test',
            'port' => '3306',
        );

$pool = new MysqlPool($config);

好了,一個簡單的連線池已經搞好了,我先用一下


go(function()use($config){
    $pool = new MysqlPool($config);
    for($i=0;$i<2;$i++){
        go(function ()use($pool) {
            $mysql = $pool->get();
            $result = $mysql->query('select * from users limit 1');
            dump($result);
            $pool->put($mysql);
        });
    }
    dump($pool);

});

好了結果出來了,新增一個defer(),在協程推出之前釋放連線池的資源。


go(function()use($pool){ 
    $pool = new MysqlPool($config);
    defer(function () use ($pool) { //用於資源的釋放, 會在協程關閉之前(即協程函式執行完畢時)進行呼叫, 就算丟擲了異常, 已註冊的defer也會被執行.
        echo "Closing connection pool\n";
        $pool->destruct();
    });
    for($i=0;$i<2;$i++){
        go(function ()use($pool) {
            $mysql = $pool->get();
            $result = $mysql->query('select * from users limit 1');
            dump($result);
            $pool->put($mysql);
        });
    }
     dump($pool);
});

這個有一個比較完善的 協程客戶端連結池包

Swoole 協程 Channel 實現併發資料收集

這裡使用子協程+通道來併發收集資料,理想的情況是使用連線池。

//每個子程式建立一個mysql連線
go(function()use($pool,$config){
    $chan = new chan(10);
    for($i=0;$i<2;$i++){
        go(function()use($pool,$chan,$config){
            $mysql = new \Swoole\Coroutine\Mysql();
            $mysql->connect($config); 
            $result = $mysql->query('select * from users limit 1');
            $chan->push($result);
            $mysql->close();
        });
    }

    for($i=0;$i<2;$i++){
        dump($chan->pop());//這個pop()如果遇到空會yield,直到子協程的push()資料之後才會重新喚醒
    }

});
//使用連線池
go(function()use($config){
    $pool = new MysqlPool($config);
    defer(function () use ($pool) { //用於資源的釋放, 會在協程關閉之前(即協程函式執行完畢時)進行呼叫, 就算丟擲了異常, 已註冊的defer也會被執行.
        echo "Closing connection pool\n";
        $pool->destruct();
    });
    $chan = new chan(10);
    for($i=0;$i<2;$i++){
        go(function()use($pool,$chan,$config){
            $mysql = $pool->get();
            $result = $mysql->query('select * from users limit 1');
            $chan->push($result);
            $pool->put($mysql);
        });
    }
    for($i=0;$i<2;$i++){
        dump($chan->pop());//這個pop()如果遇到空會yield,直到子協程的push()資料之後才會重新喚醒
    }
});

過了一圈swoole協程感覺還是沒有Python的asyncio包好用,有些地方總是搞不明白,希望各位大佬不吝指教。原連結

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章