協(xié)程coroutine的PHP與Golang實(shí)現(xiàn)

1、概念

  • 傳統(tǒng)單進(jìn)程OS(單核):
    是按順序執(zhí)行進(jìn)程A->B->C,
    缺點(diǎn)是單一執(zhí)行流程,進(jìn)程阻塞會(huì)浪費(fèi)CPU時(shí)間資源。
單進(jìn)程OS.png
  • 多進(jìn)程OS:
    進(jìn)程ABC由CPU進(jìn)行輪詢調(diào)度,同時(shí)規(guī)定時(shí)間片執(zhí)行時(shí)間,時(shí)間到時(shí)CPU會(huì)強(qiáng)行切換進(jìn)程;
    優(yōu)點(diǎn)是并發(fā)執(zhí)行,解決了阻塞問題;
    缺點(diǎn)是:
    1、進(jìn)程切換需要保存進(jìn)程的當(dāng)前狀態(tài),CPU有切換成本(浪費(fèi)),而且進(jìn)程/線程數(shù)量越多,切換成本越大;
    2、多線程存在同步競爭問題(鎖、資源爭搶),造成開發(fā)設(shè)計(jì)愈發(fā)復(fù)雜;
    3、每個(gè)線程承載一個(gè)任務(wù)時(shí),CPU調(diào)度高,會(huì)造成高內(nèi)存占用;
多進(jìn)程OS.png
  • 協(xié)程(Coroutine):
    把一個(gè)線程分為用戶空間(user space)和內(nèi)核空間(kernel space)并綁定在一起,CPU只關(guān)注內(nèi)核空間;
    此時(shí)用戶空間用來執(zhí)行用戶線程,這個(gè)用戶線程就叫做協(xié)程,即co-routine,內(nèi)核空間用來執(zhí)行內(nèi)核線程,即thread;
    除此之外,我們還需要一個(gè)調(diào)度器用來綁定、關(guān)聯(lián)并調(diào)度thread和coroutine;
將單線程拆分為thread和coroutine
協(xié)程的調(diào)度模型
  • Golang的GMP模型:
    G:Goroutine - 協(xié)程
    M:Machine - 內(nèi)核線程
    P:Processer - 調(diào)度器
go的GMP模型,源自 https://www.bilibili.com/video/BV1gf4y1r79E?p=26

2、實(shí)現(xiàn)

2.1、 原生PHP實(shí)現(xiàn)簡單的協(xié)程

  • 核心在于實(shí)現(xiàn)一個(gè)調(diào)度器,并在協(xié)程執(zhí)行邏輯中使用yield出讓協(xié)程的CPU使用權(quán);

一個(gè)調(diào)度器demo(源自 https://segmentfault.com/a/1190000012457145

/**
 * Class Scheduler
 */
Class Scheduler
{
    /**
     * @var SplQueue
     */
    protected $taskQueue;
    /**
     * @var int
     */
    protected $tid = 0;

    /**
     * Scheduler constructor.
     */
    public function __construct()
    {
        /* 原理就是維護(hù)了一個(gè)隊(duì)列,
         * 前面說過,從編程角度上看,協(xié)程的思想本質(zhì)上就是控制流的主動(dòng)讓出(yield)和恢復(fù)(resume)機(jī)制
         * */
        $this->taskQueue = new SplQueue();
    }

    /**
     * 增加一個(gè)任務(wù)
     *
     * @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;
    }

    /**
     * 把任務(wù)進(jìn)入隊(duì)列
     *
     * @param Task $task
     */
    public function schedule(Task $task)
    {
        $this->taskQueue->enqueue($task);
    }

    /**
     * 運(yùn)行調(diào)度器
     */
    public function run()
    {
        while (!$this->taskQueue->isEmpty()) {
            // 任務(wù)出隊(duì)
            $task = $this->taskQueue->dequeue();
            $res = $task->run(); // 運(yùn)行任務(wù)直到 yield

            if (!$task->isFinished()) {
                $this->schedule($task); // 任務(wù)如果還沒完全執(zhí)行完畢,入隊(duì)等下次執(zhí)行
            }
        }
    }
}

調(diào)度器與協(xié)程的使用

<?php
function task1() {
    for ($i = 1; $i <= 10; ++$i) {
        echo "This is task 1 iteration $i.\n";
        yield; // 主動(dòng)讓出CPU的執(zhí)行權(quán)
    }
}
 
function task2() {
    for ($i = 1; $i <= 5; ++$i) {
        echo "This is task 2 iteration $i.\n";
        yield; // 主動(dòng)讓出CPU的執(zhí)行權(quán)
    }
}
 
$scheduler = new Scheduler; // 實(shí)例化一個(gè)調(diào)度器
$scheduler->addTask(task1()); // 添加不同的閉包函數(shù)作為任務(wù)
$scheduler->addTask(task2());
$scheduler->run();

  • 個(gè)人認(rèn)為,這種方式實(shí)現(xiàn)的coroutine,功能非常局限,且設(shè)計(jì)復(fù)雜,沒有太多的實(shí)戰(zhàn)價(jià)值。

2.2 PHP-Swoole

  • 環(huán)境要求:

PHP版本要求:>= 7.0
基于Server、Http\Server、WebSocket\Server進(jìn)行開發(fā),底層在onRequet, onReceive, onConnect等事件回調(diào)之前自動(dòng)創(chuàng)建一個(gè)協(xié)程,在回調(diào)函數(shù)中使用協(xié)程API
使用Coroutine::create或go方法創(chuàng)建協(xié)程,在創(chuàng)建的協(xié)程中使用協(xié)程API

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

創(chuàng)建失敗返回false
創(chuàng)建成功返回協(xié)程的ID
由于底層會(huì)優(yōu)先執(zhí)行子協(xié)程的代碼,因此只有子協(xié)程掛起時(shí),Coroutine::create才會(huì)返回,繼續(xù)執(zhí)行當(dāng)前協(xié)程的代碼。

  • 執(zhí)行順序:

在一個(gè)協(xié)程中使用go嵌套創(chuàng)建新的協(xié)程。因?yàn)镾woole的協(xié)程是單線程模型,因此:
使用go創(chuàng)建的子協(xié)程會(huì)優(yōu)先執(zhí)行,子協(xié)程執(zhí)行完畢或掛起時(shí),將重新回到父協(xié)程向下執(zhí)行代碼
如果子協(xié)程掛起后,父協(xié)程退出,不影響子協(xié)程的執(zhí)行

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";
});
  • 協(xié)程開銷:

協(xié)程需要?jiǎng)?chuàng)建單獨(dú)的內(nèi)存棧,在PHP-7.2版本中底層會(huì)分配8K的stack來存儲(chǔ)協(xié)程的變量,zval的尺寸為16字節(jié),因此8K的stack最大可以保存512個(gè)變量。協(xié)程棧內(nèi)存占用超過8K后ZendVM會(huì)自動(dòng)擴(kuò)容。
協(xié)程退出時(shí)會(huì)釋放申請的stack內(nèi)存。
PHP-7.1、PHP-7.0默認(rèn)會(huì)分配256K棧內(nèi)存
可調(diào)用Co::set(['stack_size' => 4096])修改默認(rèn)的棧內(nèi)存尺寸

2.3 Golang

  • 關(guān)鍵字go 定義一個(gè)golang的協(xié)程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() {
    //創(chuàng)建一個(gè)go程 去執(zhí)行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、對(duì)比

  • Swoole4與Go協(xié)程在設(shè)計(jì)上是完全一致的,均是stackful的,每個(gè)協(xié)程擁有獨(dú)立的運(yùn)行棧。協(xié)程調(diào)度器使用匯編代碼,切換協(xié)程上下文。

  • Swoole4的協(xié)程調(diào)度器是單線程的,因此不存在數(shù)據(jù)同步問題,同一時(shí)間只會(huì)有一個(gè)協(xié)程在運(yùn)行
    Go協(xié)程調(diào)度器是多線程的,同一時(shí)間可能會(huì)有多個(gè)協(xié)程同時(shí)執(zhí)行
    因此在Swoole4協(xié)程中操作全局變量是不需要加鎖的。而Go的程序由于依然是類似Java的多線程模式,因此務(wù)必要對(duì)臨界資源加鎖,避免出現(xiàn)數(shù)據(jù)同步問題。或者使用官方sync包提供的各種并發(fā)容器。

  • 實(shí)際上Go的chan和并發(fā)容器,底層仍然使用了Mutex進(jìn)行鎖操作,鎖的爭搶是普遍存在的。

  • Swoole4由于是單線程多進(jìn)程的,底層沒有使用任何Mutex鎖,不存在鎖的爭搶。 同樣帶來的問題是,沒有超全局變量。只有進(jìn)程級(jí)全局變量,讀寫PHP全局變量只在當(dāng)前進(jìn)程內(nèi)有效。如果希望多進(jìn)程共享數(shù)據(jù),有3種解決方案:
    1、使用Table和Atomic對(duì)象,或者其他共享內(nèi)存數(shù)據(jù)結(jié)構(gòu)
    2、使用IPC進(jìn)程間通信
    3、借助存儲(chǔ)實(shí)現(xiàn)數(shù)據(jù)的共享和中轉(zhuǎn),如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是允許這樣操作的,實(shí)際上這個(gè)可能會(huì)存在嚴(yán)重問題。socket讀寫操作產(chǎn)生并發(fā),可能產(chǎn)生數(shù)據(jù)包錯(cuò)亂。

Swoole中禁止了這種行為。不允許多個(gè)協(xié)程同時(shí)讀取同一個(gè)Socket。否則會(huì)產(chǎn)生致命錯(cuò)誤。

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個(gè)協(xié)程同時(shí)操作$db對(duì)象,可能會(huì)產(chǎn)生嚴(yán)重錯(cuò)誤。底層會(huì)直接拋出致命錯(cuò)誤,錯(cuò)誤信息為:

"%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."
錯(cuò)誤碼:SW_ERROR_CO_HAS_BEEN_BOUND

使用Channel或SplQueue實(shí)現(xiàn)連接池,管理資源對(duì)象,就可以很好地解決此問題。


?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容