date: 2018-5-30 14:31:38
title: swoole| swoole 協程初體驗
description: 通過協程的執行初窺 swoole 中協程的調度; 理解協程為什么快; swoole 協程和 go 協程對比
折騰 swoole 協程有一段時間了, 總結一篇入門貼, 希望對新手有幫助.
內容概覽:
- 協程的執行順序: 初窺 swoole 中協程的調度
- 協程為什么快: 減少IO阻塞帶來的性能損耗
- swoole 協程和 go 協程對比: 單進程 vs 多線程
協程的執行順序
先來看看基礎的例子:
go(function () {
echo "hello go1 \n";
});
echo "hello main \n";
go(function () {
echo "hello go2 \n";
});
go()
是 \Co::create()
的縮寫, 用來創建一個協程, 接受 callback 作為參數, callback 中的代碼, 會在這個新建的協程中執行.
備注: \Swoole\Coroutine
可以簡寫為 \Co
上面的代碼執行結果:
root@b98940b00a9b /v/w/c/p/swoole# php co.php
hello go1
hello main
hello go2
執行結果和我們平時寫代碼的順序, 好像沒啥區別. 實際執行過程:
- 運行此段代碼, 系統啟動一個新進程
- 遇到
go()
, 當前進程中生成一個協程, 協程中輸出heelo go1
, 協程退出 - 進程繼續向下執行代碼, 輸出
hello main
- 再生成一個協程, 協程中輸出
heelo go2
, 協程退出
運行此段代碼, 系統啟動一個新進程. 如果不理解這句話, 你可以使用如下代碼:
// co.php
<?php
sleep(100);
執行并使用 ps aux
查看系統中的進程:
root@b98940b00a9b /v/w/c/p/swoole# php co.php &
?
root@b98940b00a9b /v/w/c/p/swoole# ps aux
PID USER TIME COMMAND
1 root 0:00 php -a
10 root 0:00 sh
19 root 0:01 fish
749 root 0:00 php co.php
760 root 0:00 ps aux
?
我們來稍微改一改, 體驗協程的調度:
use Co;
go(function () {
Co::sleep(1); // 只新增了一行代碼
echo "hello go1 \n";
});
echo "hello main \n";
go(function () {
echo "hello go2 \n";
});
\Co::sleep()
函數功能和 sleep()
差不多, 但是它模擬的是 IO等待(IO后面會細講). 執行的結果如下:
root@b98940b00a9b /v/w/c/p/swoole# php co.php
hello main
hello go2
hello go1
怎么不是順序執行的呢? 實際執行過程:
- 運行此段代碼, 系統啟動一個新進程
- 遇到
go()
, 當前進程中生成一個協程 - 協程中遇到 IO阻塞 (這里是
Co::sleep()
模擬出的 IO等待), 協程讓出控制, 進入協程調度隊列 - 進程繼續向下執行, 輸出
hello main
- 執行下一個協程, 輸出
hello go2
- 之前的協程準備就緒, 繼續執行, 輸出
hello go1
到這里, 已經可以看到 swoole 中 協程與進程的關系, 以及 協程的調度, 我們再改一改剛才的程序:
go(function () {
Co::sleep(1);
echo "hello go1 \n";
});
echo "hello main \n";
go(function () {
Co::sleep(1);
echo "hello go2 \n";
});
我想你已經知道輸出是什么樣子了:
root@b98940b00a9b /v/w/c/p/swoole# php co.php
hello main
hello go1
hello go2
?
協程快在哪? 減少IO阻塞導致的性能損失
大家可能聽到使用協程的最多的理由, 可能就是 協程快. 那看起來和平時寫得差不多的代碼, 為什么就要快一些呢? 一個常見的理由是, 可以創建很多個協程來執行任務, 所以快. 這種說法是對的, 不過還停留在表面.
首先, 一般的計算機任務分為 2 種:
- CPU密集型, 比如加減乘除等科學計算
- IO 密集型, 比如網絡請求, 文件讀寫等
其次, 高性能相關的 2 個概念:
- 并行: 同一個時刻, 同一個 CPU 只能執行同一個任務, 要同時執行多個任務, 就需要有多個 CPU 才行
- 并發: 由于 CPU 切換任務非常快, 快到人類可以感知的極限, 就會有很多任務 同時執行 的錯覺
了解了這些, 我們再來看協程, 協程適合的是 IO 密集型 應用, 因為協程在 IO阻塞 時會自動調度, 減少IO阻塞導致的時間損失.
我們可以對比下面三段代碼:
- 普通版: 執行 4 個任務
$n = 4;
for ($i = 0; $i < $n; $i++) {
sleep(1);
echo microtime(true) . ": hello $i \n";
};
echo "hello main \n";
root@b98940b00a9b /v/w/c/p/swoole# time php co.php
1528965075.4608: hello 0
1528965076.461: hello 1
1528965077.4613: hello 2
1528965078.4616: hello 3
hello main
real 0m 4.02s
user 0m 0.01s
sys 0m 0.00s
?
- 單個協程版:
$n = 4;
go(function () use ($n) {
for ($i = 0; $i < $n; $i++) {
Co::sleep(1);
echo microtime(true) . ": hello $i \n";
};
});
echo "hello main \n";
root@b98940b00a9b /v/w/c/p/swoole# time php co.php
hello main
1528965150.4834: hello 0
1528965151.4846: hello 1
1528965152.4859: hello 2
1528965153.4872: hello 3
real 0m 4.03s
user 0m 0.00s
sys 0m 0.02s
?
- 多協程版: 見證奇跡的時刻
$n = 4;
for ($i = 0; $i < $n; $i++) {
go(function () use ($i) {
Co::sleep(1);
echo microtime(true) . ": hello $i \n";
});
};
echo "hello main \n";
root@b98940b00a9b /v/w/c/p/swoole# time php co.php
hello main
1528965245.5491: hello 0
1528965245.5498: hello 3
1528965245.5502: hello 2
1528965245.5506: hello 1
real 0m 1.02s
user 0m 0.01s
sys 0m 0.00s
?
為什么時間有這么大的差異呢:
- 普通寫法, 會遇到 IO阻塞 導致的性能損失
- 單協程: 盡管 IO阻塞 引發了協程調度, 但當前只有一個協程, 調度之后還是執行當前協程
- 多協程: 真正發揮出了協程的優勢, 遇到 IO阻塞 時發生調度, IO就緒時恢復運行
我們將多協程版稍微修改一下:
- 多協程版2: CPU密集型
$n = 4;
for ($i = 0; $i < $n; $i++) {
go(function () use ($i) {
// Co::sleep(1);
sleep(1);
echo microtime(true) . ": hello $i \n";
});
};
echo "hello main \n";
root@b98940b00a9b /v/w/c/p/swoole# time php co.php
1528965743.4327: hello 0
1528965744.4331: hello 1
1528965745.4337: hello 2
1528965746.4342: hello 3
hello main
real 0m 4.02s
user 0m 0.01s
sys 0m 0.00s
?
只是將 Co::sleep()
改成了 sleep()
, 時間又和普通版差不多了. 因為:
-
sleep()
可以看做是 CPU密集型任務, 不會引起協程的調度 -
Co::sleep()
模擬的是 IO密集型任務, 會引發協程的調度
這也是為什么, 協程適合 IO密集型 的應用.
再來一組對比的例子: 使用 redis
// 同步版, redis使用時會有 IO 阻塞
$cnt = 2000;
for ($i = 0; $i < $cnt; $i++) {
$redis = new \Redis();
$redis->connect('redis');
$redis->auth('123');
$key = $redis->get('key');
}
// 單協程版: 只有一個協程, 并沒有使用到協程調度減少 IO 阻塞
go(function () use ($cnt) {
for ($i = 0; $i < $cnt; $i++) {
$redis = new Co\Redis();
$redis->connect('redis', 6379);
$redis->auth('123');
$redis->get('key');
}
});
// 多協程版, 真正使用到協程調度帶來的 IO 阻塞時的調度
for ($i = 0; $i < $cnt; $i++) {
go(function () {
$redis = new Co\Redis();
$redis->connect('redis', 6379);
$redis->auth('123');
$redis->get('key');
});
}
性能對比:
# 多協程版
root@0124f915c976 /v/w/c/p/swoole# time php co.php
real 0m 0.54s
user 0m 0.04s
sys 0m 0.23s
?
# 同步版
root@0124f915c976 /v/w/c/p/swoole# time php co.php
real 0m 1.48s
user 0m 0.17s
sys 0m 0.57s
?
swoole 協程和 go 協程對比: 單進程 vs 多線程
接觸過 go 協程的 coder, 初始接觸 swoole 的協程會有點 懵, 比如對比下面的代碼:
package main
import (
"fmt"
"time"
)
func main() {
go func() {
fmt.Println("hello go")
}()
fmt.Println("hello main")
time.Sleep(time.Second)
}
> 14:11 src $ go run test.go
hello main
hello go
剛寫 go 協程的 coder, 在寫這個代碼的時候會被告知不要忘了 time.Sleep(time.Second)
, 否則看不到輸出 hello go
, 其次, hello go
與 hello main
的順序也和 swoole 中的協程不一樣.
原因就在于 swoole 和 go 中, 實現協程調度的模型不同.
上面 go 代碼的執行過程:
- 運行 go 代碼, 系統啟動一個新進程
- 查找
package main
, 然后執行其中的func mian()
- 遇到協程, 交給協程調度器執行
- 繼續向下執行, 輸出
hello main
- 如果不添加
time.Sleep(time.Second)
, main 函數執行完, 程序結束, 進程退出, 導致調度中的協程也終止
go 中的協程, 使用的 MPG 模型:
- M 指的是 Machine, 一個M直接關聯了一個內核線程
- P 指的是 processor, 代表了M所需的上下文環境, 也是處理用戶級代碼邏輯的處理器
- G 指的是 Goroutine, 其實本質上也是一種輕量級的線程
而 swoole 中的協程調度使用 單進程模型, 所有協程都是在當前進程中進行調度, 單進程的好處也很明顯 -- 簡單 / 不用加鎖 / 性能也高.
無論是 go 的 MPG模型, 還是 swoole 的 單進程模型, 都是對 CSP理論 的實現.
CSP通信方式, 在1985年時的論文就已經有了, 做理論研究的人, 如果沒有能提前幾年, 十幾年甚至幾十年的大膽假設, 可能很難提高了.
寫在最后
今天從 go()
出發, 得以一瞥協程世界, 協程的世界里還有很多很有意思的東西, 需要我們去發現. 比如:
- 我們普通版的代碼是當前進程里執行的, 只是單個進程, 可我們現在可能有了很多協程, 會不會有什么奇遇呢?
還有一個細節: swoole 中有 Co::sleep()
和 sleep()
2個方法的, 而 go 中只有 time.Sleep()
一個方法?
這是 swoole 協程需要經歷的一個階段(畢竟 go 快 10 年了), 還不夠 智能的判斷 IO阻塞, 所以上面也使用了相應的協程版 redis co\Redis()
-- 你得使用配套協程版, 才能達到協程調度的效果.
如果對協程的發展階段感興趣, 可以閱讀下面這篇文章:
- Why c++ coroutine?Why libgo?: 關于協程全景式的概述的, 推薦花時間讀一讀
想解鎖 swoole 協程的更多姿勢: