如何基於 Channel 實現多路複用

李銘昕發表於2021-02-11

前言

首先,我們先介紹一下 Swoole\Coroutine\Client 的一個限制,那便是同一個連線,不允許同時被兩個協程繫結,我們可以進行以下測試。

<?php
run(function () {
    $client = new \Swoole\Coroutine\Client(SWOOLE_SOCK_TCP);
    $client->set([
        'open_length_check' => true,
        'package_length_type' => 'N',
        'package_length_offset' => 0,
        'package_body_offset' => 4,
        'package_max_length' => 1024 * 1024 * 2,
    ]);
    $client->connect('127.0.0.1', 9601, 0.5);
    go(function () use ($client) {
        $ret = $client->send(str_repeat('xxx', 1000));
        $client->recv();
    });
    go(function () use ($client) {
        $ret = $client->send('xxx');
        $client->recv();
    });
});

當我們執行以上程式碼,就會丟擲以下錯誤

PHP Fatal error:  Uncaught Swoole\Error: Socket#9 has already been bound to another coroutine#2, reading of the same socket in coroutine#3 at the same time is not allowed in /Users/limingxin/Applications/GitHub/hyperf/repos/multiplex-socket/tests/swoole_client.php:32
Stack trace:
#0 /Users/limingxin/Applications/GitHub/hyperf/repos/multiplex-socket/tests/swoole_client.php(32): Swoole\Coroutine\Client->recv()
#1 /Users/limingxin/Applications/GitHub/hyperf/repos/multiplex-socket/vendor/hyperf/utils/src/Functions.php(271): {closure}()
#2 /Users/limingxin/Applications/GitHub/hyperf/repos/multiplex-socket/vendor/hyperf/utils/src/Coroutine.php(62): call(Object(Closure))
#3 {main}
  thrown in /Users/limingxin/Applications/GitHub/hyperf/repos/multiplex-socket/tests/swoole_client.php on line 32

但我們稍微改動一下程式碼,就不會再次報錯,程式碼如下

<?php
run(function () {
    $client = new \Swoole\Coroutine\Client(SWOOLE_SOCK_TCP);
    $client->set([
        'open_length_check' => true,
        'package_length_type' => 'N',
        'package_length_offset' => 0,
        'package_body_offset' => 4,
        'package_max_length' => 1024 * 1024 * 2,
    ]);
    $client->connect('127.0.0.1', 9601, 0.5);
    $chan = new \Swoole\Coroutine\Channel(1);
    go(function () use ($client, $chan) {
        $ret = $client->send(str_repeat('xxx', 1000));
        $chan->push(true);
        $client->recv();
        $chan->pop();
    });
    go(function () use ($client, $chan) {
        $ret = $client->send('xxx');
        $chan->push(true);
        $client->recv();
        $chan->pop();
    });
});

可見,我們只需要讓 recv 在一個協程裡迴圈呼叫,然後再根據收包發到不同的 Channel 當中,這樣我們就可以多個協程複用同一個連線。

包體設計

接下來的事情就很簡單了,我們設計一個十分簡單的包結構。包頭為使用 pack N 打包的包體長度,包體為 pack N 打包的 Channel ID 和 資料體。

因為 Swoole 中分包規則已經實現,所以我們可以簡單的配置一下實現上述效果

'open_length_check' => true,
'package_length_type' => 'N',
'package_length_offset' => 0,
'package_body_offset' => 4,
'package_max_length' => 1024 * 1024 * 2,

接下來我們只需要實現包體的 打包 和 解包功能即可,我們可以實現一個十分簡單的打包器。

<?php

declare(strict_types=1);

namespace Multiplex;

use Multiplex\Constract\PackerInterface;

class Packer implements PackerInterface
{
    public function pack(Packet $packet): string
    {
        return sprintf(
            '%s%s%s',
            pack('N', strlen($packet->getBody()) + 4),
            pack('N', $packet->getId()),
            $packet->getBody()
        );
    }

    public function unpack(string $data): Packet
    {
        $unpacked = unpack('Nid', substr($data, 4, 4));
        $body = substr($data, 8);
        return new Packet((int) $unpacked['id'], $body);
    }
}

服務端

服務端的設計就尤為簡單了,因為 Channel 機制主要是給 客戶端使用,所以服務端解包之後,原封不動的將 ChannelID 和 資料返回即可。

$server->handle(function (Connection $conn) {
    while (true) {
        $ret = $conn->recv();
        if (empty($ret)) {
            break;
        }

        Coroutine::create(function () use ($ret, $conn) {
            $packet = $this->packer->unpack($ret);
            $id = $packet->getId();
            try {
                $result = $this->handler->__invoke($packet, $this->getSerializer());
            } catch (\Throwable $exception) {
                $result = $exception;
            } finally {
                $conn->send($this->packer->pack(new Packet($id, $this->getSerializer()->serialize($result))));
            }
        });
    }
});

客戶端

客戶端相比而言,就要麻煩一些。我們需要建立一個 Channel 儲存需要 傳送的資料,還需要設計一個 Channel Map 儲存各個 ID 返回的資料,這樣方便 recv 時,直接使用 Channel::pop() 獲得資料,這樣一來就可以很方便的將 業務客戶端與實際客戶端進行解耦。

下述程式碼中,我們建立了兩個協程,迴圈呼叫 Client::sendClient::recv 方法。

protected function loop(): void
{
    if ($this->chan !== null && ! $this->chan->isClosing()) {
        return;
    }
    $this->chan = $this->getChannelManager()->make(65535);
    $this->client = $this->makeClient();
    Coroutine::create(function () {
        try {
            $chan = $this->chan;
            $client = $this->client;
            while (true) {
                $data = $client->recv();
                if (! $client->isConnected()) {
                    break;
                }
                if ($chan->isClosing()) {
                    break;
                }

                $packet = $this->packer->unpack($data);
                if ($channel = $this->getChannelManager()->get($packet->getId())) {
                    $channel->push(
                        $this->serializer->unserialize($packet->getBody())
                    );
                }
            }
        } finally {
            $chan->close();
            $client->close();
        }
    });

    Coroutine::create(function () {
        try {
            $chan = $this->chan;
            $client = $this->client;
            while (true) {
                $data = $chan->pop();
                if ($chan->isClosing()) {
                    break;
                }
                if (! $client->isConnected()) {
                    break;
                }

                if (empty($data)) {
                    continue;
                }

                $client->send($data);
            }
        } finally {
            $chan->close();
            $client->close();
        }
    });
}

實現元件

最後,根據上述的想法,我們實現了以下兩個元件

multiplex
multiplex-socket

隨手寫了兩段程式碼,對多路複用和連線池進行測試,我們建立 10000 個協程,同時呼叫服務端,當服務端接收到資料,立馬返回的情況下

二者差距不大,完全結束都在 0.3-0.5 秒之間。

但當我們在返回資料前,睡眠 10 毫秒的情況下,多路複用所用的時間要低於連線池的十分之一。

不僅速度更快,多路複用的連線,從始至終只用到 1 個,但連線池卻起了 100 個連線,綜合來說,多路複用要比使用連線池表現的更加優秀。

示例

客戶端

<?php
declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

run(function () use ($max) {
    $client = new \Multiplex\Socket\Client('127.0.0.1', 9601);
    for ($i = 0; $i < $max; ++$i) {
        go(function () use ($client, $channel) {
            $client->request('World.');
        });
    }
});

服務端

<?php

declare(strict_types=1);

use Multiplex\Packet;
use Multiplex\Socket\Server;

require_once __DIR__ . '/../vendor/autoload.php';

run(function () {
    $server = new Server();
    $config = collect([]);
    $server->bind('0.0.0.0', 9601, $config)->handle(static function (Packet $packet) {
        return 'Hello ' . $packet->getBody();
    })->start();
});

寫在最後

Hyperf 是基於 Swoole 4.5+ 實現的高效能、高靈活性的 PHP 協程框架,內建協程伺服器及大量常用的元件,效能較傳統基於 PHP-FPM 的框架有質的提升,提供超高效能的同時,也保持著極其靈活的可擴充套件性,標準元件均基於 PSR 標準 實現,基於強大的依賴注入設計,保證了絕大部分元件或類都是 可替換 與 可複用 的。

框架元件庫除了常見的協程版的 MySQL 客戶端、Redis 客戶端,還為您準備了協程版的 Eloquent ORM、WebSocket 服務端及客戶端、JSON RPC 服務端及客戶端、GRPC 服務端及客戶端、Zipkin/Jaeger (OpenTracing) 客戶端、Guzzle HTTP 客戶端、Elasticsearch 客戶端、Consul 客戶端、ETCD 客戶端、AMQP 元件、Apollo 配置中心、阿里雲 ACM 應用配置管理、ETCD 配置中心、基於令牌桶演算法的限流器、通用連線池、熔斷器、Swagger 文件生成、Swoole Tracker、Blade 和 Smarty 檢視引擎、Snowflake 全域性ID生成器 等元件,省去了自己實現對應協程版本的麻煩。

Hyperf 還提供了 基於 PSR-11 的依賴注入容器、註解、AOP 面向切面程式設計、基於 PSR-15 的中介軟體、自定義程式、基於 PSR-14 的事件管理器、Redis/RabbitMQ 訊息佇列、自動模型快取、基於 PSR-16 的快取、Crontab 秒級定時任務、Translation 國際化、Validation 驗證器 等非常便捷的功能,滿足豐富的技術場景和業務場景,開箱即用。%

本作品採用《CC 協議》,轉載必須註明作者和本文連結
Any fool can write code that a computer can understand. Good programmers write code that humans can understand.

相關文章