前言扯淡

烧水是一件很神奇的事情, 首先有这么一个家喻户晓的传说故事:

“瓦特小的时候,看见炉子上壶里的水沸腾了。蒸汽把壶盖顶了起来,瓦特从中受到启发,长大后发明了蒸汽机,成为著名的发明家。”

当然,真实的蒸汽机的真正意义上发明也是类似的, “约1679年法国物理学家丹尼斯·巴本在观察蒸汽冒出他的高压锅后制造了第一台蒸汽机的工作模型”。后来,人类进入了蒸汽时代。

直到今天都没有找到能够替代”烧开水”获取能源的方案,这个有意思的概念来源于一个知乎问题人类的能源大多都是靠烧开水,这种说法正确吗?,最后得出的结论是:我们寿命内,可用的能源主要来源靠烧水。

烧开水问题

当然,今天想说的协程之于烧开水问题,和上述烧开水没有一毛钱关系(狗头,而是与另外一个家喻户晓的烧开水问题息息相关:

烧开水10分钟,洗衣机洗衣服21分钟,做作业20分钟,最少多少分钟完成这些事情

这是我们小学时候常做的逻辑题,那时候心智不够,很容易掉进陷阱,没有能够调度各个任务的思维,把时间加在一起,这就是经典的同步阻塞

  1. 你烧水
  2. 等水开
  3. 水开后用洗衣机洗衣服
  4. 等衣服洗完
  5. 做作业

而正解是,我们要给事件分类,哪些是可以并发且可并行的,哪些是需要单独做的:

  • 可并发并行的:洗衣机洗衣服,烧开水
  • 需要单独做的:做作业

将他们类比成计算机的任务

  • 耗时任务,但不需要使用脑子(CPU)的:磁盘IO,可定时/后台运行的任务等
  • 需要CPU密集计算处理的:业务逻辑,数据分析等

那么就是:

  1. 设定好洗衣机和烧上水 (发起并发请求), 挂起任务让出控制权(yield), 然后马上去写作业(CPU继续干活)
  2. 完成提示音通知你任务完成你可以收尾(事件回调)

这样我们实际上耗费的时间就是 CPU运算任务耗时 + Max(...可并发并行任务耗时)

这是这个问题最优解, 大脑(CPU)没有把时间浪费到无谓的等待中, 而(客户端)可并发特性使得两个请求可以同时开始,最后洗衣机的电子音和水壶的水烧开的声音会提醒你(Callback)让你收尾处理这两个事件的完成

IO阻塞

同步

我们可以看下面这样一段代码

1
2
$data = file_get_contents('./data.json');
echo $data;

这是常见的文件读取操作, 在file_get_contents函数从磁盘中拿回文件数据前, 代码并不会继续运行, 而是等待返回, 因为后续的打印数据依赖上一条指令获取的数据的返回值, 这就是常见的同步编程.

异步

我们再来看一个经典的jQuery时代的ajax

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$.ajax({
url: "foo",
data:1,
success: function (a) {
$.ajax({
url: "bar",
data:a,
success: function (b) {
$.ajax({
url: "baz",
data:b,
success: function (c) {
console.log(c)
}
})
}
})
}
})
console.log('lol~')

代码在执行到ajax的时候, 函数会直接返回, 你马上就可以看到屏幕上欢快地打印出了lol~

这就是异步, 这样你永远不会被IO阻塞, 但是它带来了新的问题, 在你运行到lol之后, 你就不知道现在代码运行到哪去了, 你只能等待回调被触发, 然后屏幕上打印出相应的log, 它的执行不是单层顺序的, 而是嵌套的.

如果在业务代码中, 这样的层层嵌套可读性可想而知.

异步+

后来为了解决异步回调地狱, 发展出了Promise的方案, 这样的写法比回调要直观多了

以下代码引用自 理解 JavaScript 的 async/await

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
function takeLongTime(n) {
return new Promise(resolve => {
setTimeout(() => resolve(n + 200), n);
});
}
function step1(n) {
console.log(`step1 with ${n}`);
return takeLongTime(n);
}

function step2(n) {
console.log(`step2 with ${n}`);
return takeLongTime(n);
}

function step3(n) {
console.log(`step3 with ${n}`);
return takeLongTime(n);
}

function doIt() {
console.time("doIt");
const time1 = 300;
//promise的链式调用,比callback清晰多了
step1(time1)
.then(time2 => step2(time2))
.then(time3 => step3(time3))
.then(result => {
console.log(`result is ${result}`);
console.timeEnd("doIt");
});
}
doIt();

异步++

Promise以后, 又进化出了async/await语法糖, 可以说是异步终极方案了, 看起来简直就跟同步代码一模一样!

1
2
3
4
5
6
7
8
9
10
async function doIt() {
console.time("doIt");
const time1 = 300;
const time2 = await step1(time1);
const time3 = await step2(time2);
const result = await step3(time3);
console.log(`result is ${result}`);
console.timeEnd("doIt");
}
doIt();

协程

其实在实际的程序中, 磁盘IO等阻塞的时间是远远大于CPU运算时间的, 根据Amdahl定理, 你想要加速一个系统, 必须提升全系统中相当大的部分的速度, 而现在的大部分WEB服务, 瓶颈都在数据库IO而非密集运算, 大家可以参考一篇文章: 让 CPU 告诉你硬盘和网络到底有多慢,这篇文章很形象地告诉了你, IO是如何把团队发育带崩的:

如果假设CPU执行一个指令需要1秒, 那么磁盘寻址花费的时间就是10个月, 从磁盘读取 1MB 连续数据需要20个月! 而如果是网络IO, 很可能达到十数年甚至更久!

也就是说, 在IO等待的时候, CPU足足荒废了几年的美好光阴!

让我们来看看这张经典的存储器层次结构示例:

所以如果能把IO阻塞浪费的时间优化掉, 就可以提升了多倍的并发处理能力, 比起优化代码逻辑和算法的收益更加可观, 因此而节省的硬件成本也相当可观(否则你会陷入不断加机器/换SSD/加内存做cache的困扰中)

协程不能解决的问题

小学课上,女孩对男孩说“蒸一个包子要3分钟,那蒸3个包子要几分钟”,男孩说“9分钟”,女孩说你傻呀,你家蒸包子是一个一个地蒸啊…然后男孩对女孩说“吃一个苹果要一分钟,那吃9个苹果要几分钟”,女孩说你以为我和你一样傻啊,当然是9分钟了。男孩什么也没说,直接拿了9个苹果放到女孩面前说你9分钟把它们都吃完吧……

包子可以一起蒸, 是因为一个正常蒸笼(预防杠精)有蒸三个正常包子(预防杠精)的能力

苹果只能一个个吃, 是因为正常人一般(预防杠精)只有一次吃一个正常苹果(预防杠精)的能力

所以协程不能解决的问题是: 它不能解决你数据库的上限瓶颈, 数据库能承受多少压力, 它还是多少

(已做连接池的情况下, 连接池是常驻内存运行的福利, 和协程无关)

有人在PHPcon上问Rango: “韩老师, 我们的业务在高并发的时候, redis数据库很容易被击穿, 这该怎么办?”

Rango就答了: “这不是swoole可以解决的问题, 你可以了解下twemproxy

并发和并行

这两个词对于编程新手就像/\两个符号一样难以记忆, 网上也没有看到一个比较好又形象的通俗解释, 在这里我可以给出一种不错的记忆方法:

并发可以理解为客户端的一个特性, 客户端可以一次性发出多个请求, 称之为并发.

并行可以理解为服务器同时能处理任务的这个能力, 比如一般来说, MySQL一个连接就是一个线程, 如果不使用线程池等技术, 它所能创建线程数量就是它可以并行处理请求的能力.

并发: 同时发出(请求)

并行: 同时执行(任务)