美文网首页
协程coroutine的PHP与Golang实现

协程coroutine的PHP与Golang实现

作者: Separes | 来源:发表于2021-05-23 13:21 被阅读0次

    1、概念

    • 传统单进程OS(单核):
      是按顺序执行进程A->B->C,
      缺点是单一执行流程,进程阻塞会浪费CPU时间资源。
    单进程OS.png
    • 多进程OS:
      进程ABC由CPU进行轮询调度,同时规定时间片执行时间,时间到时CPU会强行切换进程;
      优点是并发执行,解决了阻塞问题;
      缺点是:
      1、进程切换需要保存进程的当前状态,CPU有切换成本(浪费),而且进程/线程数量越多,切换成本越大;
      2、多线程存在同步竞争问题(锁、资源争抢),造成开发设计愈发复杂;
      3、每个线程承载一个任务时,CPU调度高,会造成高内存占用;
    多进程OS.png
    • 协程(Coroutine):
      把一个线程分为用户空间(user space)和内核空间(kernel space)并绑定在一起,CPU只关注内核空间;
      此时用户空间用来执行用户线程,这个用户线程就叫做协程,即co-routine,内核空间用来执行内核线程,即thread;
      除此之外,我们还需要一个调度器用来绑定、关联并调度thread和coroutine;
    将单线程拆分为thread和coroutine 协程的调度模型
    • Golang的GMP模型:
      G:Goroutine - 协程
      M:Machine - 内核线程
      P:Processer - 调度器
    go的GMP模型,源自 https://www.bilibili.com/video/BV1gf4y1r79E?p=26

    2、实现

    2.1、 原生PHP实现简单的协程

    • 核心在于实现一个调度器,并在协程执行逻辑中使用yield出让协程的CPU使用权;

    一个调度器demo(源自 https://segmentfault.com/a/1190000012457145

    /**
     * Class Scheduler
     */
    Class Scheduler
    {
        /**
         * @var SplQueue
         */
        protected $taskQueue;
        /**
         * @var int
         */
        protected $tid = 0;
    
        /**
         * Scheduler constructor.
         */
        public function __construct()
        {
            /* 原理就是维护了一个队列,
             * 前面说过,从编程角度上看,协程的思想本质上就是控制流的主动让出(yield)和恢复(resume)机制
             * */
            $this->taskQueue = new SplQueue();
        }
    
        /**
         * 增加一个任务
         *
         * @param Generator $task
         * @return int
         */
        public function addTask(Generator $task)
        {
            $tid = $this->tid;
            $task = new Task($tid, $task);
            $this->taskQueue->enqueue($task);
            $this->tid++;
            return $tid;
        }
    
        /**
         * 把任务进入队列
         *
         * @param Task $task
         */
        public function schedule(Task $task)
        {
            $this->taskQueue->enqueue($task);
        }
    
        /**
         * 运行调度器
         */
        public function run()
        {
            while (!$this->taskQueue->isEmpty()) {
                // 任务出队
                $task = $this->taskQueue->dequeue();
                $res = $task->run(); // 运行任务直到 yield
    
                if (!$task->isFinished()) {
                    $this->schedule($task); // 任务如果还没完全执行完毕,入队等下次执行
                }
            }
        }
    }
    

    调度器与协程的使用

    <?php
    function task1() {
        for ($i = 1; $i <= 10; ++$i) {
            echo "This is task 1 iteration $i.\n";
            yield; // 主动让出CPU的执行权
        }
    }
     
    function task2() {
        for ($i = 1; $i <= 5; ++$i) {
            echo "This is task 2 iteration $i.\n";
            yield; // 主动让出CPU的执行权
        }
    }
     
    $scheduler = new Scheduler; // 实例化一个调度器
    $scheduler->addTask(task1()); // 添加不同的闭包函数作为任务
    $scheduler->addTask(task2());
    $scheduler->run();
    
    
    • 个人认为,这种方式实现的coroutine,功能非常局限,且设计复杂,没有太多的实战价值。

    2.2 PHP-Swoole

    • 环境要求:

    PHP版本要求:>= 7.0
    基于Server、Http\Server、WebSocket\Server进行开发,底层在onRequet, onReceive, onConnect等事件回调之前自动创建一个协程,在回调函数中使用协程API
    使用Coroutine::create或go方法创建协程,在创建的协程中使用协程API

    function Swoole\Coroutine::create(callable $function, ...$args) : int|false;
    或
    function go(callable $function, ...$args) : int|false; // 短名API
    

    创建失败返回false
    创建成功返回协程的ID
    由于底层会优先执行子协程的代码,因此只有子协程挂起时,Coroutine::create才会返回,继续执行当前协程的代码。

    • 执行顺序:

    在一个协程中使用go嵌套创建新的协程。因为Swoole的协程是单线程模型,因此:
    使用go创建的子协程会优先执行,子协程执行完毕或挂起时,将重新回到父协程向下执行代码
    如果子协程挂起后,父协程退出,不影响子协程的执行

    go(function() {
        go(function () {
            co::sleep(3.0);
            go(function () {
                co::sleep(2.0);
                echo "co[3] end\n";
            });
            echo "co[2] end\n";
        });
    
        co::sleep(1.0);
        echo "co[1] end\n";
    });
    
    • 协程开销:

    协程需要创建单独的内存栈,在PHP-7.2版本中底层会分配8K的stack来存储协程的变量,zval的尺寸为16字节,因此8K的stack最大可以保存512个变量。协程栈内存占用超过8K后ZendVM会自动扩容。
    协程退出时会释放申请的stack内存。
    PHP-7.1、PHP-7.0默认会分配256K栈内存
    可调用Co::set(['stack_size' => 4096])修改默认的栈内存尺寸

    2.3 Golang

    • 关键字go 定义一个golang的协程goroutine;
    
    package main
    
    import (
        "fmt"
        "time"
    )
    
    //子goroutine
    func newTask() {
        i := 0
        for {
            i++
            fmt.Printf("new Goroutine : i = %d\n", i)
            time.Sleep(1 * time.Second)
        }
    }
    
    //主goroutine
    func main() {
        //创建一个go程 去执行newTask() 流程
        go newTask()
    
        fmt.Println("main goroutine exit")
    
        /*
            i := 0
            for {
                i++
                fmt.Printf("main goroutine: i = %d\n", i)
                time.Sleep(1 * time.Second)
            }
        */
    }
    
    
    

    3、对比

    • Swoole4与Go协程在设计上是完全一致的,均是stackful的,每个协程拥有独立的运行栈。协程调度器使用汇编代码,切换协程上下文。

    • Swoole4的协程调度器是单线程的,因此不存在数据同步问题,同一时间只会有一个协程在运行
      Go协程调度器是多线程的,同一时间可能会有多个协程同时执行
      因此在Swoole4协程中操作全局变量是不需要加锁的。而Go的程序由于依然是类似Java的多线程模式,因此务必要对临界资源加锁,避免出现数据同步问题。或者使用官方sync包提供的各种并发容器。

    • 实际上Go的chan和并发容器,底层仍然使用了Mutex进行锁操作,锁的争抢是普遍存在的。

    • Swoole4由于是单线程多进程的,底层没有使用任何Mutex锁,不存在锁的争抢。 同样带来的问题是,没有超全局变量。只有进程级全局变量,读写PHP全局变量只在当前进程内有效。如果希望多进程共享数据,有3种解决方案:
      1、使用Table和Atomic对象,或者其他共享内存数据结构
      2、使用IPC进程间通信
      3、借助存储实现数据的共享和中转,如Redis、MySQL或文件操作

    Go
    func test() {
        db := new(database)
        close := db.connect()
    
        go func(db) {
            db.query(sql);
        } (db);
    
        go func(db) {
            db.query(sql);
        } (db);
    }
    

    Go是允许这样操作的,实际上这个可能会存在严重问题。socket读写操作产生并发,可能产生数据包错乱。

    Swoole中禁止了这种行为。不允许多个协程同时读取同一个Socket。否则会产生致命错误。

    PHP
    function test() {
        $db = new Database;
        $db->connect('127.0.0.1', 6379);
    
        go(function () use ($db) {
            $db->query($sql);
        });
    
        go(function () use ($db) {
            $db->query($sql);
        });
    }
    

    以上代码中有2个协程同时操作$db对象,可能会产生严重错误。底层会直接抛出致命错误,错误信息为:

    "%s has already been bound to another coroutine#%ld,
    reading or writing of the same socket in multiple coroutines at the same time is not allowed."
    错误码:SW_ERROR_CO_HAS_BEEN_BOUND

    使用Channel或SplQueue实现连接池,管理资源对象,就可以很好地解决此问题。


    相关文章

      网友评论

          本文标题:协程coroutine的PHP与Golang实现

          本文链接:https://www.haomeiwen.com/subject/nuqojltx.html