1、概念
- 传统单进程OS(单核):
是按顺序执行进程A->B->C,
缺点是单一执行流程,进程阻塞会浪费CPU时间资源。
- 多进程OS:
进程ABC由CPU进行轮询调度,同时规定时间片执行时间,时间到时CPU会强行切换进程;
优点是并发执行,解决了阻塞问题;
缺点是:
1、进程切换需要保存进程的当前状态,CPU有切换成本(浪费),而且进程/线程数量越多,切换成本越大;
2、多线程存在同步竞争问题(锁、资源争抢),造成开发设计愈发复杂;
3、每个线程承载一个任务时,CPU调度高,会造成高内存占用;
- 协程(Coroutine):
把一个线程分为用户空间(user space)和内核空间(kernel space)并绑定在一起,CPU只关注内核空间;
此时用户空间用来执行用户线程,这个用户线程就叫做协程,即co-routine,内核空间用来执行内核线程,即thread;
除此之外,我们还需要一个调度器用来绑定、关联并调度thread和coroutine;
- Golang的GMP模型:
G:Goroutine - 协程
M:Machine - 内核线程
P:Processer - 调度器
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实现连接池,管理资源对象,就可以很好地解决此问题。