写点什么

☕【并发技术系列】「多线程并发编程」技术体系和并发模型的基础探究(夯实基础)

作者:浩宇天尚
  • 2021 年 12 月 02 日
  • 本文字数:3745 字

    阅读完需:约 12 分钟

☕【并发技术系列】「多线程并发编程」技术体系和并发模型的基础探究(夯实基础)

让我们通过本篇文章一同进入并发编程技术的世界里面,相信通过这篇文文章一定会对话你的并发技术体系有一定帮助以及夯实你的基础功底。

基本概念

  • 并发 concurrency

  • 并行 parallelism

  • 吞吐量 throughput

并发操作处理机制

并发:CPU 划分时间片,轮流执行每个请求任务,时间片到期后,换到下一个


并行操作处理机制

并行:在多核服务器上,每个 CPU 内核执行一个任务,是真正的并行


吞吐量

单位时间内服务器总的请求处理量

  • 以 request/second 来衡量,如 1200rps

  • 每个请求的处理时间 latency

  • 服务器处理请求的并发 workers

  • 其他因素如 GC 也会影响吞吐量

CSDN new bbs 的案例

  • 平均每个请求的 latency – 200ms

  • 总共 40 个 workers

  • 理论吞吐量上限 1000/200*40 = 200rps

  • 理论每日处理动态请求上限 1700 万,目前实际每日处理动态请求 270-330 万,预估实际处理上限 600 万

IO 类型

  • 磁盘文件操作,例如读硬盘文件

  • 操作系统调用,例如 shell 命令

  • 网络操作

  • 访问数据库 MySQL, MongoDB, ...

  • 访问其他 Web 服务,发起网络连接

  • 访问缓存服务器 Memcached, Redis

IO 密集请求

IO 操作的延时远远高于 CPU 时钟周期和内存访问,所以一旦 Web 请求涉及 IO 操作,CPU 处于 wait 状态,被浪费了。


IO 密集型并发

并发真能提高吞吐量吗?


假设每个请求执行 100ms,顺序执行 10 个请求共需要 1s 单核服务器并发处理 10 个请求,假设平均分配时间片 10ms,请求 1 到请求 10 将在 900ms 到 1000ms 间执行完毕。


顺序执行 10 个请求,每个请求 100ms,总共 1s 执行完毕



并发执行 10 个请求,每个请求分配 10ms 的时间片,仍然 1s 执行完毕吞吐量没有提高,每个请求处理时间变长。


吞吐量没有任何提高。并发越多,所有请求都变得非常缓慢。(考虑到任务的场景切换开销,吞吐量还会下降,需要超过 1s 才能执行完毕)。


大多数 Web 型应用都是 IO 密集型


  • 并发执行 10 个请求,每个请求分配 10ms 的时间片

  • 200ms 之后 CPU 处于空闲状态

  • 执行请求 100ms 当中,可能有 80ms 花在 IO 上,只有 20ms 消耗 CPU 时钟周期,最好情况下,请求 1 到请求 10 将在 190ms 到 280ms 间执行完毕,吞吐量极大提高。



  • IO 密集型应用,大部分 CPU 花在等待 IO 上了,所以并发可以有效提高系统的吞吐量

并发和并行

纯 CPU 密集型的应用

  • 在单核上并发执行多个请求,不能提高吞吐量

  • 由于任务来回场景切换的开销,吞吐量反而会下降

  • 只有多核并行运算,才能有效提高吞吐量

IO 密集型的应用

由于请求过程中,很多时间都是外部 IO 操作,CPU 在 wait 状态,所以并发执行可以有效提高系统吞吐量。

并发模型模型发展

  • multi-process(多进程)

  • multi-thread(多线程)

  • multi-process + multi-thread(GIL)(多进程+多线程)

  • event I/O(事件驱动)

  • coroutine(协程)

常见多进程 Web 服务端编程模型

  • PHP

  • Python

  • Ruby

多进程优点

  • 并发模型非常简单

  • 由操作系统调度运行稳定强壮

  • 非常容易管理

  • 很容易通过操作系统方便的监控,例如每个进程 CPU,内存变化状况,甚至可以观测到进程处理什么 Web 请求很容易通过操作系统管理进程,例如可以通过给进程发送 signal,实现各种管理: unicorn。

  • 隔离性非常好

  • 一个进程崩溃不会影响其他进程

  • 某进程出现问题的时候,只要杀掉它重启即可,不影响整体服务的可用性

  • 很容易实现在线热部署和无缝升级

  • 代码兼容性极好,不必考虑线程安全问题

  • 多进程可以有效利用多核 CPU,实现并行处理

多进程监控
  • 监控进程 CPU top –p pid

  • 简单处理甚至可以查看进程处理的 URL 请求

  • 监控进程的 IO iotop –p pid

  • 监控进程的物理内存使用 ps, /proc

多进程缺点

内存消耗很多

每个独立进程都需要加载完整的应用环境,内存消耗超大。(COW 模式可以缓解这个问题)


例如每个 Rails 进程物理内存占用为 150MB,20 个 workers,则需要 3GB 物理内存。

CPU 消耗偏高

多进程并发,需要 CPU 内核在多个进程间频繁切换,而进程的场景切换(context switch)是非常昂贵的,需要大量的内存换页操作。

很低的 I/O 并发处理能力
  • 多进程的并发能力非常有限

  • 每个进程只能并发处理 1 个请求

  • 单台服务器启动的进程数有限,并发处理能力无法有效提高

  • 只适合处理短请求,不适合处理长请求

  • 每个请求都能在很短时间内执行完毕,因而不会造成进程被长期阻塞一旦某个操作特别是 IO 操作阻塞,就会造成进程阻塞

  • 当大面积 IO 操作阻塞发生,服务器就无法响应了

  • 对于无法预知的外部 IO 操作,应用代码必须设置 timeout 参数,以防进程阻塞

缓解多进程低 IO 并发问题
  • 用 nginx 做前端 Web Server

  • 适当增大 proxy buffer size,避免多进程 request/response buffer IO 开销

  • 使用 X-sendfile,避免多进程读取大文件 IO 开销

  • 凡 IO 操作都要设置 timeout

  • 避免无法预知的 IO 挂起造成进程阻塞

  • 长请求和短请求分离开,不要放在一起

multi-thread 多线程操作模型

常见多线程模型(1:1)

1 native thread : 1 process thread


  • 在一个重量级进程当中启动多个线程并发处理请求

  • 多线程并发

  • 每个线程可以并发处理 1 个请求,并发能力取决于线程数量线程的调度由 VM 负责,可以通过编程控制

多线程优点

多线程并发内存消耗比较少


  • 每个线程需要一个 thread stack 保存线程场景,thread stack 一般只需要十几到几十 KB 内存,不像多进程,每个进程需要加载完整的应用环境,需要分配十几到上百 MB 内存。

  • 线程可以共享资源,特别是可以共享整个应用环境,不必像多进程每个进程要加载应用环境。


多线程并发 CPU 消耗比较小


  • 线程的场景切换开销小于进程的场景切换


很容易创建和高效利用共享资源


  • 数据库线程池

  • 字典表,进程内缓存......


IO 并发能力很高


  • Java VM 可以轻松维护几百个并发线程的线程切换开销,远高于多进程单服务器上几十个并发的处理能力


可有效利用多核 CPU,实现并行运算

多线程的缺点

VM 的内存管理要求超高


  • 对内存管理要求非常高,应用代码稍不注意,就会产生 OOM(out of memory),需要应用代码长期和内存泄露做斗争

  • GC 的策略会影响多线程并发能力和系统吞吐量,需要对 GC 策略和调优有很好的经验

  • 在大内存服务器上的物理内存利用率问题


对共享资源的操作


  • 对共享资源的操作要非常小心,特别是修改共享资源需要加锁操作,很容易引发死锁问题


应用代码和第三方库都必须是线程安全的


  • 使用了非线程安全的库会造成各种潜在难以排查的问题


单进程多线程模型不方便通过操作系统管理


  • 一旦出现线程死锁或者线程阻塞很容易导致整个 VM 进程挂起失去响应,隔离性很差

multi-thread with GIL

  • Global Interpeter Lock:有限制的并发

  • IO 操作或者操作系统调用,释放锁,多线程 IO 并发

  • 由于加锁,无法利用多核,只能使用 1 个 CPU 内核,因而无法实现多核并行运算

提供简化的并发策略

对 CPU 密集型运算,并发不能提高吞吐量:加锁,禁止并发对 IO 密集型运算,并发可以有效提高吞吐量:解锁,允许多线程并发

性能

对 CPU 密集型运算,多线程并发由于线程场景切换带来的开销,吞吐量要差于单进程顺序执行

兼容性

加锁可以保证代码和库的兼容性


multi-process + multi-thread(GIL)

  • 由于 GIL,多线程只能跑在 1 个 CPU 内核上,无法有效利用多核 CPU,跑多个进程可以有效利用多核,一般进程数略多于服务器 CPU 内核数

  • 一个进程不宜跑过多线程,否则会引发严重的 GC 内存管理问题

pros and cons

  • 内存消耗低于单纯的多进程并发

  • 非常有效的提高了 IO 并发处理能力

  • IO 库和操作系统调用库必须保证线程安全

event IO

常见 event IO 编程模型

  • Nginx / Lighttpd

  • Ruby EventMachine / Python Twisted

  • node.js

event IO 原理

  • 单进程单线程

  • 内部维护一个事件队列

  • 每个请求切成多个事件

  • 每个 IO 调用切成一个事件

  • 编程调用 process.next_Tick()方法切分事件

  • 单进程顺序从事件队列当中取出每个事件执行下去


event IO 的优点

惊人的 IO 并发处理能力
  • nginx 单机可以处理 50K 以上的 HTTP 并发连接

  • node.js 单机可以处理几千上万个 HTTP 并发连接

极少的内存消耗

单一进程单一线程,无场景切换无需保存场景

CPU 消耗偏低

无进程或者线程场景切换的开销

event IO 的缺点

必须使用异步编程

异步编程是一种原始的编程方式代码量和复杂度都会有很大的增加,提高了编程的难度,以及开发和维护成本复杂的业务逻辑(例如工作流业务)会造成代码迅速膨胀,极难维护异步事件流使得异常处理和调试有很大困难

CPU 密集型的运算会阻塞住整个进程

需要通过编程,将密集型的任务拆分为多个事件

所有 IO 操作必须使用异步库

一旦不小心使用同步 IO 操作,会造成整个进程阻塞,库的兼容性必须非常小心

只能跑在 1 个 CPU 内核上,无法有效利用多核并行运算

运行多个进程来利用多核 CPU

coroutine 原理

  • 在单个线程上运行多个纤程,每个纤程维护 1 个 context

  • 纤程非常轻量级,单个线程可以轻易维护几万个纤程

  • 纤程调度依赖于应用程序框架

  • 纤程切换

  • 必须自己编程来实现

  • 一般应用层代码不需要编程,框架层实现纤程调度

  • 纤程本质上是基于 event IO 之上的高级封装,但消除了 event IO 原始的异步编程复杂度



单一线程通过程序调度了 3 个纤程并发,底层仍然是 event IO 驱动但是有 3 个清晰的并发执行体,仍然是同步并发编程风格,但实现了异步驱动

coroutine 的优点

  • 支持极高的 IO 并发,和 event IO 基本相当

  • 纤程的创建和切换的系统开销非常小,CPU 和内存消耗都很小

  • 编程方式和常见的同步编程基本一致,是 event IO 的高级封装形式

coroutine 的缺点

  • 纤程运行在单线程上,无法有效利用多核实现并行运算

  • 通过启动多个进程或者多个线程来利用多核 CPU

  • CPU 密集型的运算会阻塞住整个进程

  • 通过编程,将密集型的任务拆分为多步

  • 所有 IO 操作必须使用异步库

  • 一旦不小心使用同步 IO 操作,会造成整个进程阻塞,库的兼容性必须非常小心

参考资料

  1. multithreaded-rails-is-generally-better

  2. threads-in-ruby-enough-already

  3. about-concurrency-and-the-gil

  4. the-ruby-global-interpreter-lock

发布于: 1 小时前阅读数: 8
用户头像

浩宇天尚

关注

🏆 InfoQ写作平台-签约作者 🏆 2020.03.25 加入

【个人简介】酷爱计算机技术、醉心开发编程、喜爱健身运动、热衷悬疑推理的”极客狂人“ 【技术格言】任何足够先进的技术都与魔法无异 【技术范畴】Java领域、Spring生态、MySQL专项、APM专题及微服务/分布式体系等

评论

发布
暂无评论
☕【并发技术系列】「多线程并发编程」技术体系和并发模型的基础探究(夯实基础)