☕【并发技术系列】「多线程并发编程」技术体系和并发模型的基础探究(夯实基础)
让我们通过本篇文章一同进入并发编程技术的世界里面,相信通过这篇文文章一定会对话你的并发技术体系有一定帮助以及夯实你的基础功底。
基本概念
并发 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 操作,会造成整个进程阻塞,库的兼容性必须非常小心
参考资料
版权声明: 本文为 InfoQ 作者【浩宇天尚】的原创文章。
原文链接:【http://xie.infoq.cn/article/de7d430b412eeea756b2f05be】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论