很多负载均衡器都说自己是 event-driven、nonblocking I/O 的,那这些概念到底是什么?

2021-04-24 20:50:08 +08:00
 JasonLaw

我查看了Non-blocking, event-driven model of Node JS explained using real-world analogies | by Swagnik Dutta | The Startup | Medium,所以 event-driven 、nonblocking I/O 只是实现了收银员的非阻塞而已吗?想要更快地为顾客提供咖啡,最后还是需要请很多很多的厨师?

那么对于 HAProxy 或 Nginx 来说,它们的处理方式也是类似这样吗?

3977 次点击
所在节点    程序员
28 条回复
ipwx
2021-04-24 20:55:15 +08:00
根本原因是线程切换的开销太大。一次线程上下文切换的开销是 20us 级别( Linux 系统)。而且一个线程自己有自己的栈空间(和可能的堆空间),在 Linux 上这个线程的内存空间虽然不会立刻分配,但是也在 80MB 量级。

如果是阻塞式的,那么每个客户端都要有一个单独的线程去为他服务。那么有 1 万个线程以后,大部分时间都浪费在上下文切换了(很多时候两次 IO 之间的几行代码的时间根本不会到 20us,可能是纳秒级的),很多内存都浪费在线程空间上了。

所以要用非阻塞式,在每个线程中服务上百上千个客户端,每个客户端绑定到这个线程,这样这些客户端的处理代码就不会有线程上下文切换和独立内存空间的这么多开销。
ipwx
2021-04-24 20:59:36 +08:00
…… 上下文切换指的是某个 CPU 物理内核刚刚在执行某个线程,但是它没事做了(或者已经做了很长时间了),就把它的状态保存在内存里,从内存里找出另一个线程的状态,然后去执行。Linux 是时间片操作系统,当核数小于线程,并且线程都不是等待状态,那每个线程也就只会运行个几百上千微妙 ( us ),然后就让给别的线程执行了,这就是上下文切换。

阻塞指的是线程因为要 IO 操作(比如从客户端读网络数据包),本身等待了没事干了,那么就会停在那里。这叫阻塞。参见上一段,这时这个线程没事干了,如果有别的线程要干事,就会发生上下文切换。上下文切换很耗时间。
ipwx
2021-04-24 21:00:40 +08:00
(以上数据可能记忆有误,但是相对大小大致就是这样。所以要避免阻塞)
Jirajine
2021-04-24 21:10:29 +08:00
阻塞 /非阻塞 IO 只是把并行处理由系统线程移到用户程序。
根本原因是多任务的实现,线程由于是抢占式的,在切换上下文时不知道需要保留哪些数据,于是只能把栈帧、寄存器等都保留,因而开销大。为了支持大并发降低开销,需要一种机制让用户决定保留哪些状态,而实现这种机制的抽象 /模式就是各种协程 /callback/async 等等。
mogg
2021-04-24 21:18:45 +08:00
reactor 模式和 epoll 吧
RicardoY
2021-04-24 21:18:58 +08:00
@ipwx 80MB 数量级不对,新创建一个线程不可避免的开销应该只有几 KB
JasonLaw
2021-04-24 21:43:54 +08:00
@ipwx #1 你说“ 所以要用非阻塞式,在每个线程中服务上百上千个客户端”,一个线程是怎么能够服务上百上千个客户端的?怎么实现的?难道是咖啡店收银员的概念,只是收银了,并没有提供咖啡给顾客。是这样吗?
ch2
2021-04-24 21:59:39 +08:00
@ipwx #1 多说了十倍吧?
ch2
2021-04-24 22:07:45 +08:00
非阻塞 io 仅适用于单纯的只是收发数据,不适用于你在接收的数据后还有非常繁重的计算任务要做这种情况
通常 nginx 这种反向代理只是个中间人,如果有真正的累活它的 qps 也不会跟只是回一个 hello world 一样高
GuuJiang
2021-04-24 22:16:41 +08:00
@JasonLaw 打个比方,你去某部门办事,分好几个阶段,每个阶段对方会告诉你回去准备一些新的材料,回来交了以后再回去准备下一阶段的材料,如此重复若干次

那么某部门有两种选择

plan A:每来一个新的用户,就给他指派一个办事员,这个办事员只负责这一个用户,当用户回去准备材料时这个办事员就干等着,这就是阻塞式,缺点显而易见,就是需要的办事员数量和用户数相关,大多数时候办事员无所事事
plan B:只需要一个办事员,当一个用户回去准备材料时继续服务下一个用户,这就是非阻塞

办事员 == IO 线程
办事 == 实际的 IO 读写
用户回去准备材料 == 无数据可读写

在 plan B 中我之所以故意不提"用户在部门排队",是为了避免引入不必要的误解,因为实际情况中网络设备相比起 CPU 是慢速设备,并且单个连接大部分情况下处于无数据传输状态,真正花在 IO 上的时间只占每个连接生命周期中的很小一部分,所以单个线程足以支撑大量的连接
JasonLaw
2021-04-24 22:24:51 +08:00
@ch2 #9 那么 Nginx 到底做了什么?真正的脏活累活是谁做的呢?
JasonLaw
2021-04-24 22:35:31 +08:00
@GuuJiang #10 那么用户准备材料回来之后,ta 会怎么样呢?能获取到及时的服务吗?
ch2
2021-04-24 23:11:54 +08:00
@JasonLaw #11 一个 nginx 后面可以接非常多的 worker,每个 worker 负责干累活,nginx 只负责把数据排个队收进来再发出去仅此而已
JasonLaw
2021-04-24 23:27:53 +08:00
@ch2 #13 从收到客户请求,到最后返回客户响应,这整个流程是怎样的?可以用实际的例子描述一下吗?先谢谢啦。
GuuJiang
2021-04-24 23:37:56 +08:00
@JasonLaw 我之所以有点犹豫到底要不要用这个来举例,就是因为很容易让人联想到日常生活中排队办事的场景,你可以理解为在另一个宇宙里,真正办事所需要的时间远远小于准备材料的时间,所以对于单独的每一个人来说,每当他准备好材料回去时,办事员都是空闲的,能够第一时间为他服务
那么有没有可能出现由于准备材料时间太短导致一个办事员服务不过来造成了堆积呢,当然是有可能的,这时候就需要增加办事员了,非阻塞 IO 说的是“少量”线程可以处理大量连接,并没有说过永远是单线程,实际应用中也需要选择合适的 IO 线程数来保证宏观上不会出现 IO 的堆积
其实说白了就是时分复用,利用“每个连接实际进行 IO 的时间远小于等待的时间”这一特性,把碎片时间充分利用起来
另外一个易混淆的概念是,这里提到的 IO 线程仅负责单一的一个职责,就是读写,至于读到业务数据后具体的业务处理到底是同步还是异步(实际情况通常都会是异步,除非是一些非常简单的例如 echo server 这种只用于演示级别),完全是另一个独立的问题,不在 IO 框架需要考虑的范围内,使用者根据自己的情况自行抉择
ch2
2021-04-24 23:38:00 +08:00
@JasonLaw #14
麦当劳来了 100 个顾客,每个人要一个汉堡
阻塞 IO 模式:
厨房每次只接一个顾客的单
一块肉饼下油锅炸了,厨师就去一边休息,不管后面的顾客了
等到炸熟了,油锅通知厨师,厨师再把一个汉堡打包给当前等待的顾客
别的人只能望着油锅里的一块肉饼干等着一个一个排队
非阻塞 IO 模式:
由前台告诉厨房一次性做 100 个汉堡
同时下 100 个肉饼到油锅,厨师也是一样等,但是这次一次炸了 100 个
等到炸熟了,油锅通知厨师,厨师再把 100 个汉堡全部打包给前台
前台挨个把汉堡递给顾客,吞吐量提高了 100 倍
这个前台就是 nginx
GuuJiang
2021-04-25 00:20:19 +08:00
@JasonLaw 其实关于非阻塞 IO 有一个非常普遍非常具有迷惑性的误解,就是把 IO 和业务处理这两者混为一谈,这就会产生两种错误的理解
其一:误以为非阻塞 IO 对于业务处理也是有帮助的,觉得只要用上了非阻塞 IO 就能用单线程或者少量线程处理大量的业务
其二:虽然没有上述的误解,但是会觉得反正到了业务处理阶段还是要多线程的,那 IO 处的单线程就是无意义的,从而觉得非阻塞 IO 没用

事实上这二者是完全独立的两个问题,你可以尝试下面的思考方式
第一步:先假设最简单的一种业务,就是一个黑洞,读到数据后直接丢弃,这样抛开业务处理的影响来单独分析 IO,在这个前提下对比阻塞式和非阻塞式的区别并正确理解非阻塞 IO 的意义
第二步:此时加入业务处理,在读到数据后进行异步处理(具体的方式可能是开线程、投递到线程池、发送到消息队列、异步 RPC 等等),总之具体方式不重要,重要的是异步处理,由于是异步处理,那么对于 IO 的影响可以认为和上述的直接丢弃几乎没区别,所以上一步中得到的各种结论仍然成立

在充分理解了这一切之后,再回过头来看你在标题中的问题,答案是肯定的,是否使用非阻塞 IO 只影响收银员(即 IO 线程)与顾客之间的关系,也就是实现了用少量收银员服务大量顾客,至于能否更快提供咖啡,这已经是业务处理阶段的问题了,与是否使用非阻塞模型没有关系,非阻塞只负责用户不会卡在收银这个环节,至于后面怎么提供服务,一个厨师还是多个厨师,这些都不是收银员需要关心的问题
Zhuzhuchenyan
2021-04-25 00:22:42 +08:00
个人愚见,
想要理解这些问题,需要把这些概念拆开了去理解
首先是最下面的 IO,IO 可以按阻塞,非阻塞区分,也可以按同步,异步区分,组合起来就有 4 种情况,比较常见的有同步阻塞和同步非阻塞。这个网上有很多博客讲的很好,我就不在这里献丑了
理解了 IO 之后,接着就可以看 IO 多路复用,常见的有 select,epoll,
IO 多路复用里,无论何种实现都会有一种方式通知“你”我这边有数据,你可以开始处理了,或者说这些 socket 里有可读的数据,你可以去调用他们的 read 函数了,我们将这种通知统一抽象为事件,也就是 event
所谓事件驱动,event driven,本质上就是寻找一种方式不断的去聆听到来的事件,并对到来的事件进行处理的一种编程范式。
以 Node 的事件循环 event loop 为例子,其实可以简化为以下代码,通过主线程跑一个死循环 while,不断的去拿到当前可以处理的事件,在主线程处理它,然后循环

```
while(events = wait_for_events()){
process_events(events)
}
```
此处的事件可以有很多种
1. 最常见的 IO 多路复用器告诉你哪些 socket 可以读了,主线程去读这些 socket,此时的 read 并不会阻塞主线程
2. 还可以比如说 settimeout 的定时器到时间了,主线程就会把闭包里的东西执行一下

以上可以引申出来大部分的时候我们可以认为所有跑在 nodejs 主线程的程序不需要考虑多线程问题,因为真正的脏活累活都交给了内核或者背后的 IO 线程池去做了。这样做的好处是我们极大可能的避免了线程切换带来的开销,坏处就是一个 foreach(1e100)这样的耗时任务就可以把整个主线程堵塞。

---
我不知道什么叫收银员非阻塞,但是顾客极多导致主线程来不及处理到来的事件的时候,就需要部署多个主线程,这也是 pm2 多 instance 部署能带来性能提升的原因。

---
没看过 HAProxy 和 Nginx 的具体实现,所以不确定以上的对 Node 的观点是否可以迁移到这两个上。
cqsc
2021-04-25 00:48:53 +08:00
@JasonLaw 更准确的来说是 IO 多路复用 使得单个线程能处理的连接数变多了 然后类似 epoll 可以基于事件去获取某些就绪的 socket 并将其交付给 work 线程处理 极大的避免了线程阻塞在不必要的 IO 等待上。
3dwelcome
2021-04-25 02:02:19 +08:00
"只是实现了收银员的非阻塞而已吗?想要更快地为顾客提供咖啡,最后还是需要请很多很多的厨师"
因为以前网页都是静态为多,动态和静态没办法比速度,两者不在一个水平线上。就算 2021 年,网站服务器提升容量的便捷手段之一,也是静态化页面。

说白了,就是收银员太少。如果厨师慢了,可以用半成品,总有取巧办法。但是收银员这块堵住,用户就根本进不来。一切优化都免谈。

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/772976

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX