我查看了Non-blocking, event-driven model of Node JS explained using real-world analogies | by Swagnik Dutta | The Startup | Medium,所以 event-driven 、nonbl美国服务器ocking I/O 只是实现了收银员的非阻塞而已吗?想要更快地为顾客提供咖啡,最后还是需要请很多很多的厨师?
那么对于 HAProxy 或 Nginx 来说,它们的处理方式也是类似这样吗?
根本原因是线程切换的开销太大。一次线程上下文切换的开销是 20us 级别( Linux 系统)。而且一个线程自己有自己的栈空间(和可能的堆空间),在 Linux 上这个线程的内存空间虽然不会立刻分配,但是也在 80MB 量级。
如果是阻塞式的,那么每个客户端都要有一个单独的线程去为他服务。那么有 1 万个线程以后,大部分时间都浪费在上下文切换了(很多时候两次 IO 之间的几行代码的时间根本不会到 20us,可能是纳秒级的),很多内存都浪费在线程空间上了。
所以要用非阻塞式,在每个线程中服务上百上千个客户端,每个客户端绑定到这个线程,这样这些客户端的处理代码就不会有线程上下文切换和独立内存空间的这么多开销。
…… 上下文切换指的是某个 CPU 物理内核刚刚在执行某个线程,但是它没事做了(或者已经做了很长时间了),就把它的状态保存在内存里,从内存里找出另一个线程的状态,然后去执行。Linux 是时间片操作系统,当核数小于线程,并且线程都不是等待状态,那每个线程也就只会运行个几百上千微妙 ( us ),然后就让给别的线程执行了,这就是上下文切换。
阻塞指的是线程因为要 IO 操作(比如从客户端读网络数据包),本身等待了没事干了,那么就会停在那里。这叫阻塞。参见上一段,这时这个线程没事干了,如果有别的线程要干事,就会发生上下文切换。上下文切换很耗时间。
(以上数据可能记忆有误,但是相对大小大致就是这样。所以要避免阻塞)
阻塞 /非阻塞 IO 只是把并行处理由系统线程移到用户程序。
根本原因是多任务的实现,线程由于是抢占式的,在切换上下文时不知道需要保留哪些数据,于是只能把栈帧、寄存器等都保留,因而开销大。为了支持大并发降低开销,需要一种机制让用户决定保留哪些状态,而实现这种机制的抽象 /模式就是各种协程 /callback/async 等等。
reactor 模式和 epoll 吧
80MB 数量级不对,新创建一个线程不可避免的开销应该只有几 KB
你说“ 所以要用非阻塞式,在每个线程中服务上百上千个客户端”,一个线程是怎么能够服务上百上千个客户端的?怎么实现的?难道是咖啡店收银员的概念,只是收银了,并没有提供咖啡给顾客。是这样吗?
多说了十倍吧?
非阻塞 io 仅适用于单纯的只是收发数据,不适用于你在接收的数据后还有非常繁重的计算任务要做这种情况
通常 nginx 这种反向代理只是个中间人,如果有真正的累活它的 qps 也不会跟只是回一个 hello world 一样高
打个比方,你去某部门办事,分好几个阶段,每个阶段对方会告诉你回去准备一些新的材料,回来交了以后再回去准备下一阶段的材料,如此重复若干次
那么某部门有两种选择
plan A:每来一个新的用户,就给他指派一个办事员,这个办事员只负责这一个用户,当用户回去准备材料时这个办事员就干等着,这就是阻塞式,缺点显而易见,就是需要的办事员数量和用户数相关,大多数时候办事员无所事事
plan B:只需要一个办事员,当一个用户回去准备材料时继续服务下一个用户,这就是非阻塞
办事员 == IO 线程
办事 == 实际的 IO 读写
用户回去准备材料 == 无数据可读写
在 plan B 中我之所以故意不提"用户在部门排队",是为了避免引入不必要的误解,因为实际情况中网络设备相比起 CPU 是慢速设备,并且单个连接大部分情况下处于无数据传输状态,真正花在 IO 上的时间只占每个连接生命周期中的很小一部分,所以单个线程足以支撑大量的连接
那么 Nginx 到底做了什么?真正的脏活累活是谁做的呢?
那么用户准备材料回来之后,ta 会怎么样呢?能获取到及时的服务吗?
一个 nginx 后面可以接非常多的 worker,每个 worker 负责干累活,nginx 只负责把数据排个队收进来再发出去仅此而已
从收到客户请求,到最后返回客户响应,这整个流程是怎样的?可以用实际的例子描述一下吗?先谢谢啦。
我之所以有点犹豫到底要不要用这个来举例,就是因为很容易让人联想到日常生活中排队办事的场景,你可以理解为在另一个宇宙里,真正办事所需要的时间远远小于准备材料的时间,所以对于单独的每一个人来说,每当他准备好材料回去时,办事员都是空闲的,能够第一时间为他服务
那么有没有可能出现由于准备材料时间太短导致一个办事员服务不过来造成了堆积呢,当然是有可能的,这时候就需要增加办事员了,非阻塞 IO 说的是“少量”线程可以处理大量连接,并没有说过永远是单线程,实际应用中也需要选择合适的 IO 线程数来保证宏观上不会出现 IO 的堆积
其实说白了就是时分复用,利用“每个连接实际进行 IO 的时间远小于等待的时间”这一特性,把碎片时间充分利用起来
另外一个易混淆的概念是,这里提到的 IO 线程仅负责单一的一个职责,就是读写,至于读到业务数据后具体的业务处理到底是同步还是异步(实际情况通常都会是异步,除非是一些非常简单的例如 echo server 这种只用于演示级别),完全是另一个独立的问题,不在 IO 框架需要考虑的范围内,使用者根据自己的情况自行抉择
麦当劳来了 100 个顾客,每个人要一个汉堡
阻塞 IO 模式:
厨房每次只接一个顾客的单
一块肉饼下油锅炸了,厨师就去一边休息,不管后面的顾客了
等到炸熟了,油锅通知厨师,厨师再把一个汉堡打包给当前等待的顾客
别的人只能望着油锅里的一块肉饼干等着一个一个排队
非阻塞 IO 模式:
由前台告诉厨房一次性做 100 个汉堡
同时下 100 个肉饼到油锅,厨师也是一样等,但是这次一次炸了 100 个
等到炸熟了,油锅通知厨师,厨师再把 100 个汉堡全部打包给前台
前台挨个把汉堡递给顾客,吞吐量提高了 100 倍
这个前台就是 nginx
其实关于非阻塞 IO 有一个非常普遍非常具有迷惑性的误解,就是把 IO 和业务处理这两者混为一谈,这就会产生两种错误的理解
其一:误以为非阻塞 IO 对于业务处理也是有帮助的,觉得只要用上了非阻塞 IO 就能用单线程或者少量线程处理大量的业务
其二:虽然没有上述的误解,但是会觉得反正到了业务处理阶段还是要多线程的,那 IO 处的单线程就是无意义的,从而觉得非阻塞 IO 没用
事实上这二者是完全独立的两个问题,你可以尝试下面的思考方式
第一步:先假设最简单的一种业务,就是一个黑洞,读到数据后直接丢弃,这样抛开业务处理的影响来单独分析 IO,在这个前提下对比阻塞式和非阻塞式的区别并正确理解非阻塞 IO 的意义
第二步:此时加入业务处理,在读到数据后进行异步处理(具体的方式可能是开线程、投递到线程池、发送到消息队列、异步 RPC 等等),总之具体方式不重要,重要的是异步处理,由于是异步处理,那么对于 IO 的影响可以认为和上述的直接丢弃几乎没区别,所以上一步中得到的各种结论仍然成立
在充分理解了这一切之后,再回过头来看你在标题中的问题,答案是肯定的,是否使用非阻塞 IO 只影响收银员(即 IO 线程)与顾客之间的关系,也就是实现了用少量收银员服务大量顾客,至于能否更快提供咖啡,这已经是业务处理阶段的问题了,与是否使用非阻塞模型没有关系,非阻塞只负责用户不会卡在收银这个环节,至于后面怎么提供服务,一个厨师还是多个厨师,这些都不是收银员需要关心的问题
个人愚见,
想要理解这些问题,需要把这些概念拆开了去理解
首先是最下面的 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 的观点是否可以迁移到这两个上。
更准确的来说是 IO 多路复用 使得单个线程能处理的连接数变多了 然后类似 epoll 可以基于事件去获取某些就绪的 socket 并将其交付给 work 线程处理 极大的避免了线程阻塞在不必要的 IO 等待上。
"只是实现了收银员的非阻塞而已吗?想要更快地为顾客提供咖啡,最后还是需要请很多很多的厨师"
因为以前网页都是静态为多,动态和静态没办法比速度,两者不在一个水平线上。就算 2021 年,网站服务器提升容量的便捷手段之一,也是静态化页面。
说白了,就是收银员太少。如果厨师慢了,可以用半成品,总有取巧办法。但是收银员这块堵住,用户就根本进不来。一切优化都免谈。
所以那些负载均衡器就像收银员一样?只是简单地接受请求,然后直接交给 backend 异步处理?还有一个问题,当咖啡处理好了之后,是收银员将咖啡拿给顾客吗?
我觉得语言描述只能告诉你为什么多线程 /阻塞式开销很大,但什么是非阻塞、事件驱动,我觉得你得结合代码理解。
你没有收银员,你怎么请很多厨师呢?你还得为每个厨师自己配个收银台、让厨师自己收银?
谈完非阻塞 IO,回到负载均衡,一个收银员可以接受了一百个汉堡,让十名厨师分别做 10 个肉饼。
“负载均衡器” 的核心工作是分配负载,标题里说的那些是为了更有效地实现这一工作的优化。
“你没有收银员,你怎么请很多厨师呢?你还得为每个厨师自己配个收银台、让厨师自己收银?”。你好像没明白我的问题是什么。
我猜测:你没有意识到你的问题里蕴含的前提。故,我把这个前提指出来。
或者说,
> 想要更快地为顾客提供咖啡,最后还是需要请很多很多的厨师?
当然,你如果不想请很多很多厨师,那为什么要请收银员呢?
> 所以 event-driven 、nonblocking I/O 只是实现了收银员的非阻塞而已吗?
对的。
直接看这个名词,non-blocking I/O,是说 I/O 的,假设你在挖矿,出一个块需要 1 个小时,I/O 的时间可以忽略,要怎么做 I/O 根本就是无关紧要的事情。
I/O 只是收钱和送餐这种事情,后面的计算任务相当于厨师。
> 想要更快地为顾客提供咖啡,最后还是需要请很多很多的厨师?
对的。
但是在计算机里面,不存在“请很多厨师”这个操作。因为 CPU 是通用的,相当于你一共有 100 个员工,安排更多人去收银的话,厨师就少了,所以提高了收银的效率,做饭也会更快。
假设你在挖矿,当然是分配 99.99 个人去做饭,做好了让那 0.01 个人去送餐就行。
> 那么对于 HAProxy 或 Nginx 来说,它们的处理方式也是类似这样吗?
对,也是这样。
这个东西是个代理,它不做计算任务,它的主业就是 I/O 。
相当于外卖平台,它的工作仅限于收钱和送餐。
只要考虑用最少的资源送好餐就行,请多少厨师和它没关系。
假设我有一个 service,那么 service 的 instance 的数量是不是相当于厨师的数量?
* 如果是的话,那隔离收银员和厨师就不会共享资源了,对吗?(用一台专门的服务器部署负载均衡器,用其他机器水平扩展厨师)
* 如果不是的话,那么什么是厨师呢?
以下描述不针对负载均衡类应用,而针对广义的抽象的 IO+业务处理
想象收银台(IO 模块)和厨房(业务模块)是两个独立的部门,对彼此是黑盒,收银台收到订单后将菜单交给厨房制作(读到数据后调用业务模块),厨房制作完成后将食品让收银台交给用户(业务处理完成后结果写回客户端),针对你的问题“当咖啡处理好了之后,是收银员将咖啡拿给顾客吗”,答案是是的,只有收银台才能和顾客打交道(持有连接)
那么每个部门内部可以分别独立地作出以下选择
收银台:
1. 一个顾客首次到来(建立连接)后指派一个专门的收银员(IO 线程)全程服务这个顾客直到他离开(断开连接),这就是阻塞式 IO,在这个过程中顾客完全可以点一样东西,思考几分钟,再点下一样,他思考的时候收银员就在等待(阻塞)
2. 一个收银员服务轮流服务所有顾客,并且只服务那些已经想好了(数据就绪)的顾客,如果顾客点了一样东西然后卡壳了,需要继续思考(就绪数据已读完),那么对不起,请你马上让开给下一个想好的顾客,等你想好了再来(下一轮 select/poll/epoll),当然收银员有职责记住每个顾客已点的部分食品,下一轮继续回来点的东西能够拼接上,最终形成完整订单(即上层协议的分割和解析,当然这一步严格来说到底算 IO 模块还是算业务模块暂时存疑,具体取决于期望 IO 模块的输出是 TCP 流还是上层协议内容),这就是非阻塞 IO
厨房:
1. 收银员递交订单后需要一直等着,直到拿到做完的东西后才能转身继续服务顾客,这就是同步调用
2. 收银员递交完订单后就转身继续服务顾客,厨房有需要的时候再把东西交给收银台,这就是异步调用
建立完这个模型后再来逐一进行整体的分析
1. 收银台采用阻塞式
优点
1) 和厨房打交道时既可以选择同步方式也可以选择异步方式,当然实际情况多采用同步,因为即使使用了异步,仍然会搭配 wait 等调用,本质上还是转化成同步了
2) 在描述收集整个订单(即上层协议解析)时可以站在收银员的角度,在写一些复杂协议时比较符合人的直觉,即当读完某一部分数据后可以根据需要主动地进行读操作,如果无数据可读就进入阻塞
缺点
1) 要么保证需要的收银员数量完全等于此刻所有处在点单过程中的顾客数量(即一客户端一线程),造成了大量的资源消耗(线程的内存等资源开销),甚至耗尽资源,要么给收银员人数设上限,从而导致了某些顾客无法分配到收银员(客户端连接被阻塞)
2) 即使收银员数量是充足的,但是收银员只有在收银台才能和顾客交互,所有的收银员只能轮流使用收银台(CPU 时间片),而收银台数量是有限的,且一个收银员用完收银台换下一个收银员用时必须要进行一些交接工作(线程上下文切换),那么当收银员数量多到一定程度时可能花在交接收银台上的时间比真正使用收银台的时间还要多
2. 收银台采用非阻塞式
优点
只需要一个或少数几个收银员就可以服务大量顾客
缺点
1) 通常来说和厨房打交道时就只能选异步方式了,当然这个没有任何强制,开发者也可以自行选择同步方式,但是如果业务是耗时操作,那带来的灾难就远大于阻塞式,因为这时不单阻塞了一个顾客,而是阻塞了所有顾客,这其实是一种典型的误用,我个人觉得真出现了这种情况,需要为之负责的是开发者自己,但是却有人觉得这是非阻塞式 IO 的缺陷,我是难以认同的
2) 协议解析、业务处理等部分不能再站在收银员的角度,即收银员无权主动要求读下一块数据,只能被动地接收数据,由顾客来驱动,这有点违反直觉,在写一些复杂协议的解析时需要人工改写为状态机,这也是有的人不习惯使用非阻塞 IO 的原因,无法扭转这个视角
回到你的问题来,我没深入研究过负载均衡类系统,所以不敢妄下结论,只能说说自己的猜测,即如果我自己来实现一个负载均衡系统的话会选择怎么做
还是老话,IO 和业务独立分析,IO 部分既然它们自己说了是非阻塞那就是非阻塞了,所以重点看业务部分,作为一个负载均衡系统,假设它的业务就是选择合适的后端->透传数据,从这个角度讲其实就是把两个反向的 IO 模块背靠背连接起来,并且以无状态的方式透传数据,那么可以认为它的业务是非常轻量的,不好用厨师的例子来做类比