并发的定义
异步编程富有魅力,但是错误的使用不仅不会带来益处,还会使得系统变得难以维护、Bug 遍地,接下来我希望总结一下遇到的异步任务场景,减少以后遇到类似问题阻塞在设计上的时间。
曾经经历过因三方(传统行业)接口效率过低而导致服务不可用的情况,交流发现对方根本没有考虑在系统里加入缓存、消息队列等中间件,原因竟然是希望保证高一致性。
实际上大部分场景中,查询操作并没有保证一致性的意义,而写操作就算不能马上被看到结果也不会对体验造成太大的影响——只要最终能成功即可,这是符合BASE
设计原则的。这个问题后续经排查发现原因是对方因为系统设计有问题、导致频繁大规模的接口超时,重启了后问题缓解,对方就不再追究了,非常无奈。没有不能解决的技术难题,只是有时候沟通、惰性等会阻碍问题的定位。
并发的话题真是非常的多,从最底层的硬件到高级语言 Java 中的 JUC,从最繁琐的业务系统(现在一般是微服务架构)到比较新的人工智能(如分布式机器学习),几乎无所不包,一直想爬出坑来,但是总觉得差点意思,在此我也仅仅能根据一些现成的资料总结出一些结论。
[x] 异步和非阻塞
[x] 并发和并行
[x] 并发模式 STM 介绍
[x] 并发模式 Actor 介绍
异步是什么
异步和非阻塞
异步和非阻塞是容易混淆的两个概念,异步聚焦的是消息的通信方式——是由另一个线程来通知任务已完成的,而非阻塞定义的是方法调用逻辑——是直接返回的、不会等待任务实际执行完毕了的,当我们讨论异步时,并不一定非得牵扯上阻塞或非阻塞,异步也并不意味着一定是非阻塞的:
- 异步阻塞:方法调用不会马上返回,线程任务交给一个任务处理框架的实例来处理,调用方会轮询查看任务的执行状况,直到任务执行完毕。
举一个例子:Java 的 Future#get 方法会等待直到任务执行完毕并准备好数据后才会返回; - 异步非阻塞:方法调用会马上返回,任务执行完毕时由另一个线程来通知完成情况,一般调用方还会提供一个回调方法,供被调用方通知使用。
举一个例子,购物下单场景中,用户支付时会提交一个支付表单,然后执行具体的支付操作,支付完毕后再由三方(如果微信支付就是微信)回调某个接口来通知支付的状态。
异步意味着并行吗
并行是“真的同时运行”,指的是多个 CPU 或机器同时执行。
并发是假的并行,指的是由 CPU 通过时间片轮转等调度算法产生可以同时执行多个任务的假象,往往使用并发数来表现一个系统的效率。
在多线程同时运行时,往往只是并发的,CPU 通过分配时间片让多线程轮流执行,如果这些线程同时操作一段内存,根据 CPU 分配时间片的顺序不同可能会出现不同的结果,就会出现线程安全的问题。
异步的优势
- 性能
将任务丢到线程池中执行虽然不能提高这个任务本身被执行的效率、甚至必然会降低效率,但是,对用户来说,复杂而耗时的逻辑被抽到了线程池中执行、不会拖慢接口响应速度;从整体上来说,任务由线程池来调度,可以根据预定义的策略来优化调度过程,比如因为服务器的 CPU 是 4 核 8 线程,那么我们就可以控制线程池同时执行的任务数是 8 个,如果是 IO 密集型的场景(大部分情况下都是),则这个数可以扩大到 16; - 异步
一些诸如发邮件、下单后扣减库存这样的对用户来说并不是非得“马上看到结果”的功能,就完全可以丢到线程池里去异步执行,实际上这样的功能本身也无法执行太快,很多用户甚至默许了需要等一会。 - 隔离
Tomcat 通过线程池来隔离多个应用服务器实例,这样一个服务器或崩溃、或阻塞,并不会影响另一个服务器(其实只能尽量降低影响,不过也足够达到我们的目的了)。
异步的缺陷
异步同样存在很多缺陷,滥用很容易导致项目随着规模的扩大而变得不可维护:
- 可读性差。
- 会导致更多并发问题,而且大部分情况下不能通过简单的内存锁来同步。
- 测试困难,出现线上问题难以排查定位,甚至无法复现。
并发
并发和并行
- 并发
同一个时间段内多个任务同时都在执行,并且都没有执行结束,并发任务强调在一个时间段内同时执行,而一个时间段有多个单位时间累积而成,所以说并发的多个任务在单位时间内不一定同时在执行; - 并行
在单位时间内多个任务同时在执行。
在单 CPU的时代多个任务同时运行都是并发,这是因为 CPU 同时只能执行一个任务,单个 CPU 时代多任务是共享一个 CPU 的,当一个任务占用 CPU 运行时候,其它任务就会被挂起,当占用 CPU 的任务时间片用完后,会把 CPU 让给其它任务来使用,所以在单 CPU 时代多线程编程相对来说意义不大,并且线程间频繁的上下文切换还会带来开销。
当计算机存在多 CPU时,每个 CPU 都可以分配时间片给线程执行,因此在线程数小于等于 CPU 数的情况下,可以实现真正的并行,而在多线程编程实践中线程的个数往往多于 CPU 的个数,所以平时都是称多线程并发编程而不是多线程并行编程。
线程安全 / 并发安全
单机的 Java 环境下并发安全 = 线程安全,分布式环境下会有多个 JVM 进程协调的问题,不过我们讨论的粒度还是线程。
线程安全问题是指当多个线程同时读写一个共享资源(多线程都可以访问的资源)并且没有任何同步措施的时候,导致脏数据或者其它不可预见的结果的问题。
但是并不是多线程访问共享资源就会触发线程安全问题,只有当至少一个线程修改共享资源时候才会存在线程安全问题。
并发度计算
指标
并发编程的核心目标是高性能,高性能的体现是高并发,那么怎么样才能算是高并发呢?
- QPS:每秒请求数
- TPS:每秒事务数
- PV:每日页面访问量
- 峰值 QPS = (总 PV * 80%) / (一天时间的秒数 * 20%)
PV 统计的量相对较粗,但是页面的访问量可以使用这个公式转换为对接口的 QPS,一般用作预估线上容量时的参考。
比如一个页面的 PV 为 300W,那么可以估算该页面上的接口的峰值 QPS = (3000000 * 0.8) / (86400 * 0.2) = 138.8。 - 每日 PV = 网站各页面被浏览的总次数,一种说法是每日千万级的 PV 才算得上是高并发。
- SLA(Service-Level Agreement):许多基础设施建设比较完善的公司会为业务划分 SLA,等级比较高的表示比较重要的业务,对接口延时、并发承载能力相对也会更高。
- 专线:专线给某个机构提供一条独立的网络信道,包括物理上独立的网线或由运营商划分的虚拟专用信道等,我们平时使用的 VPN 也是一种专线。
- 吞吐率:单位时间内请求数(有时候也用数据量来表示)
- 并发用户数:某一时刻同时向服务器发送请求的用户总数
100 个用户同时分别向服务器进行 10 次请求,和 1 个用户连续进行 1000 次请求是不一样的,因为网卡接收缓冲区中的请求数总是和用户数有关。
实际的并发用户数也可以理解为 Web 服务器当前维护的代表不同用户的文件描述符总数,也就是并发连接数,当然,不是同时来了多少用户请求就建立多少连接,Web 服务器一般会限制同时服务的最多用户数。
但是最大并发用户数和最大并发连接数的决定因素从本质来说是不同的:如果每个用户请求所需的处理时间非常少,那么每个请求都可以被快速处理然后释放文件描述符,这样从用户的角度而言,等待时间几乎不会减少太多,适合使用 select 模型来处理;如果每个请求都需要花费相当长的时间,比如下载 10M 大小的静态文件,那么除了服务器的并发处理能力,还需要考虑带宽等因素,而对于请求动态内容,可能由于 CPU 的时间瓜分导致每个用户的等待时间过长,因此,在这种情况下,我们希望服务的最大并发用户数小于理论上的最大并发连接数。
这样看来,其实 Web 服务器所做的工作的本质就是争取以最快的速度将内核缓冲区中的用户请求数据拿出来,然后尽可能快地处理完毕,并将响应数据放到内核维护的另一块用于发送数据的缓冲区中,以使用户不会等待太久。 - 请求等待时间和请求处理时间
不一样,因为用户发出请求后需要经过发送到服务器、进入内核网卡接收缓冲区中等待被处理,之后才会被服务器处理,而且 Web 服务器一般会采用多进程或多线程的并发模型,通过多个执行流来同时处理多个并发用户的请求,它们会轮流交错使用 CPU 时间片,所以每个执行流的时间都被拉长,对每个用户来说,每个请求的平均等待时间必然增加,而对于服务器来说,如果并发策略得当,每个请求的平均处理时间可能减少。
这两个时间的本质区别在于,用户平均请求等待时间主要用户衡量服务器在一定并发用户数的情况下,对于单个用户的服务质量。而服务器平均请求处理时间与前者相比,则用于衡量服务器的整体服务质量,它其实是吞吐率的倒数。
并发量级
- QPS < 50:没有太多技术瓶颈的小网站;
- QPS < 100:DB 为瓶颈,一般关系型数据库一次请求的时间能控制在 0.01s 内,如果希望加快页面加载速度,就不得不考虑对网站进行重构,如引入缓存或分库分表;
- QPS < 800:网络带宽成为瓶颈,假设机房的出口带宽是百兆,那么分给每个用户的就只有不到 1M,用户侧的入口带宽更为复杂,因为绝大多数下都是整栋房的所有用户共用带宽,因此存在网速有时慢有时快的情况。这种情况下,最好的方案是将机房流量迁出,比如 CDN 加速、异地缓存、多机负载等技术;
- QPS < 1000:超过内网带宽和 Linux 文件系统的极限,实际上网络读写也可以看作是一种文件 IO。这种情况下,必须将网站重构为分布式架构,避免单点。
虚假的 QPS
直接计算的 QPS 其实包含很多”水分”,比如,一个用户比较心急,下单时点了好几下,对我们来说,这之中只有一次点击是有效的。
一种可行的解决方案是在接入层限制重复下单,在接入层就过滤掉这些流量,也减轻了业务服务做下单校验的压力。
- 业务服务提供库存量,作为虚拟库存配置到配置中心;
- Nginx 启动时获取这个虚拟库存,实际上编写 Lus 脚本供 Nginx 执行就可以完成这个功能;
- Nginx 根据用户标识(一般是 openid)来防重,下单操作不能重复,其他普通接口则可以允许重试 1 次,可以根据 SLA 来决定可以放行的重复请求数。
并发度计算
计算系统并发连接阈值的最主要方法是执行压力测试。
- JMeter
- TestRunner
- ab
1
2
3
4
5
6# 查看版本
ab -V
# 总请求数1000,并发用户数10,Requests per second(吞吐率)、Time per request(用户平均等待时间)、Time per request (across all concurrent requests)(服务器平均请求处理时间)这几个属性
ab -n1000 -c10 http://localhost/test.html
# 将并发用户数增加到100,注意观察吞吐率、用户平均请求等待时间、服务器平均请求处理时间
ab -n1000 -c100 http://localhost/test.html一些公式可以估算并发度,但是这些公式多少有点启发式的意味;另一种可行的方案是自适应化,在运行时根据系统负载动态计算系统的并发承载能力。
负载预估
通过压力测试计算出当前服务器可支撑的并发度后,还需要评估业务面临的负载强度,从而得出我们应该加服务器还是保持现状的结论。
高并发设计原则
无状态
应用是无状态的,进程内不会保存任何类似 Session 的数据,如果需要则将这些数据放到配置中心。
只有做到无状态,才能实现代码一次编写到处运行的目的,Docker、OpenStack 这些技术都是基于这个前提。
拆分
根据不同维度进行拆分:
- 业务(垂直):比如订单系统与支付系统完全可以拆开来,它们不会互相占用对方的资源,比如网络带宽、服务器、缓存等。
- 复制(水平):将业务服务器、中间件复制几份,这些副本都可以提供服务,从而可以将负载压力均摊到多台服务器上。在此基础上可以实现读写分离,Master 负责写、Slave 负责读,但是如果对一致性有较高要求也可以 Master 读。
消息队列
BASE
架构风格的核心就是善于利用消息队列,只要不是必须马上看到结果的任务,就可以把这个任务丢到消息队列中异步执行,从而提高效率、提升用户体验。
缓存
业务数据虽然都会被存储到 MySQL 这样的关系数据库中,但是 MySQL 的能力是有限的,一般 QPS 达到 100 左右后 MySQL 就会成为瓶颈,这时最常见的优化方案是缓存,虽然架构的发展,现在几乎任何可利用的组件都包含了缓存功能:
- 浏览器或其他客户端;
- 代理服务器(CDN);
- 接入层(Nginx、OpenResty);
数据异构
MySQL 并不适合搜索、大数据存储(指的是业务数据库中分表后不适合大数据分析),如果有这种需求,最好通过Canal
等工具进行同步,称为数据异构。
并发化
利用线程池实现并发化。
异步任务的应用
调度方式
根据需求,异步任务的调度可以有很多种方式,其中最常见的往往有三种:
- 本地线程池
简单,但是不具备分布式能力,比如如果业务需求需要 baseinfo 服务每 10 分钟同步一次基础数据到 ES,那么有多少台 baseinfo 机器,就会 10 分钟执行多少次同步任务。 - 消息队列
需要引入消息队列中间件,一般功能都会覆盖到,但是可能出现的问题也比较多,对开发者水平要求比较高。 - 分布式任务调度
将定时任务放到一个任务调度中间件里管理,实时性相对 MQ 消息更低(只能等到下次调度时才会执行任务)。
架构优化中的启发
当系统出现瓶颈时,异步几乎是万金油式的设计优化思路,只要哪里耗时,就将哪里的代码丢到消息队列、任务调度中间件中,可以快速提升响应速度。
虽然说起来很简单,但是一般都需要根据代码逻辑做很多调整。
多线程并发编程
Actor
Actor 模型是处理并发计算的一种概念模型。它为系统组件如何表现及互相交互定义了一些一般性规则。使用这种模型最有名的语言是 Erlang。
下面关于 Actor 的描述,翻译自《The actor model in 10 minutes》。
Actors
Actor 是计算的原始单元,它会接收消息并在其基础上做一些计算。
思想和面向对象语言非常相似:一个对象接收一条消息(方法调用)然后使用消息进行一些特定的处理(根据调用的方法不同而不同)。主要区别是 actors 之间是完全独立的、且不共享内存,每个 actor 会保持一个私有的状态、不能被其他 actor 直接修改。
一只蚂蚁相当于没有蚂蚁
一个 actor 相当于没有 actor。actor 来自系统,在 actor 模型中任务事物都是 actor,它们需要有地址,这样一个 actor 才能发送消息给另一个。
Actors 需要邮箱来接收
需要注意的是即使多个 actors 可以同时运行,但是一个 actor 仍然会顺序执行一个消息,这意味着如果你发送 3 条消息给同一个 actor,它每次同时只会处理一条。为了并发处理这 3 条消息,你需要创建三个 actors 并分别给每个 actor 发送一条消息。
消息是被异步分发给 actor 的,这意味着在处理之前需要先将它们存储到什么地方,“邮箱”即消息要存储的位置。
Actors 之间通过发送异步消息来进行通讯,这些消息会被保存到其他 actor 的邮箱里,直到被最终处理。
Actor 做了什么
当 Actor 接收到一条消息,它会做三件事:
- 创建更多的 actors;
- 发送消息到其他 actors;
- 指定下条消息要做什么事;
第三条比较难以理解:每个 actor 会维持一个私有状态,“指定下条消息要做什么事”定义了接收到下条消息后这个状态会怎么变,更准确地说,就是 actor 如何转换状态。
假设我们有一个计算器类型的 actor,它的初始状态是数字 0,当这个 actor 接收到 add(1)的消息,不是转换其原始状态,而是指定当它接收到下一条消息后状态会变成 1。
容错
Erlang 引入了“let it crash”哲学,它的意思是:你不需要防范错误、试图预料所有可能的问题并找到方法来解决,因为没有办法考虑到所有的错误点。
Erlang 简单地让程序崩溃,并把这段危险的代码交给相应的处理代码(比如将这个代码单元重新设置为一个稳定的状态),负责这个功能的是 actor 模块。
Erlang 将 actor 称为 process,一个 process 是完全独立的,其状态不会影响任何其他 process。我们有一个监管者(是另一个 process)来处理崩溃了的 process。
这让创建一个可自恢复系统成为了可能,当一个 actor 因为某些原因进入了一个异常状态并崩溃,可以由监管者将其恢复到一个一致的状态(有很多策略,最常见的是重启该 actor 并使其进入初始状态)。
分发
actor 模型的另一个有趣的主题是消息的分发,我们发消息的目标 actor 处于本地或另一个节点都是可行的。
试想如果 actor 只是一个拥有内部状态、带有邮箱的代码单元,它会响应对应的消息,但是谁会在意它运行在哪台机器上?只要目标能接收到消息即可。
这让我们在构建系统的时候可以负载多台机器并在部分机器挂掉的时候恢复过来。
下一步
应用了 Actor 模式的包括一些像 Erlang、Elixir 这样的语言,还有一些在虚拟机环境内的如 Akka(基于 JVM)、Celluloid(基于 Ruby)。
下面这些书是关于 Actor 的:
Seven Concurrency Models in Seven Weeks: When Threads Unravel
Programming Elixir
Elixir in Action
Actor 与其他编程模型的区别
Actor 模型
消息通讯,各自处理自己的数据,能够实现并行,有点像 RPC。
skynet 是 actor 模型的。
Reactor 模型
- 向事件分发器注册事件回调
- 事件发生
- 事件分发器调用之前注册的函数
- 在回调函数中读取数据,对数据进行后续处理
libevent 是 reactor 模型。
Proactor
- 向事件分发器注册事件回调
- 事件发生
- 操作系统读取数据,并放入应用缓冲区,然后通知事件分发器
- 事件分发器调用之前注册的函数
- 在回调函数中对数据进行后续处理
ASIO 是 preactor 模型。
React 与 Proactor 区别
前者应用在回调函数中读取数据,然后进行后续的数据处理;而后者数据读取有操作系统完成,回调函数制作数据处理。
QA
- 什么是多线程并发和并行?
- 什么是线程安全问题?
参考
并发与高性能 Web 服务
- 网络编程懒人入门(八):手把手教你写基于 TCP 的 Socket 长连接
- The C10K problem
- 认清性能问题
- 构建 C1000K 的服务器(1) – 基础
- 扛住 100 亿次请求?我们来试一试
- xiaojiaqi/C1000kPracticeGuide
并行化
- 操作系统与多核处理器
- 有哪些向量化写法让你拍案叫绝?
- 《并行算法设计与性能优化》