操作系统:同步编程到异步编程的技术演进
站内链接:
知识点
MEM 和 CPU
存储器山
: 基于成本,效率的考虑, 计算机不同存储器以类似金字塔结构存储, 其中塔顶是速度最快, 成本最高, 容量极小的寄存器, 塔底则是成本低, 速度慢, 容量极大的广域网云存储.
从存储器山可以联想到以前学习操作系统
原理中关于内存的两个局部性
概念:
- 时间局部性: 相同的时间内, 程序访问同一个地址的次数越多, 时间局部性效果越好
- 空间局部性: 固定的访问次数内, 相邻访问顺序的存储器地址位置越近, 空间局部性越好
除了内存的存储器山
概念, 还有一个 CPU 对于不同操作的时间观
, 以便直观的感受 CPU 执行效率, 内存读取效率, 磁盘读取效率等.
上面的数据参考引用文章(见文章末尾), 其数据(数据来自微信公众号 驹说码事), 从人类的感官来替代 CPU 的感官, 可以这样形容上面的数据:
- 执行单条 register 指令时间: 1 秒钟
- 磁盘读取 1MB 时间: 1 年半
- ping 一个不同城域网主机, 网络包耗时: 12.5 年
从上面的数据就可以理解 CPU 效率和硬件效率的巨大差距, 由此联想到服务器整体效率的瓶颈主要在 IO 读取效率上.
C10K 和 C10M 问题
C10K
问题: 早期的服务器都是基于进程/线程模型(早期 apache), 对于每一个客户端 TCP 连接, 服务器都会分配一个进程(线程)来处理请求, 而 C10K(单机 10000+并发连接请求)就会创建一万个进程(线程), 对于单机服务器而言不可能承受, 采用分布式往往又会产生巨大的成本. 所以, 如何突破单机性能局限就是 C10K 提出的主要原因.C10K
问题解决方案的探讨:
- 每一个进程处理一个连接请求: 进程池没有这么多进程, 进程上下文切换极其消耗资源. 不合理
- 利用 IO 多路复用技术可以解决此问题
其中IO 多路复用
实现方案有如下几种:
- select 轮询检查所有监控文件句柄. 问题: 连接过多时, 逐个检查速度慢; 存在文件句柄上限.
- poll 传递待监控的文件句柄, 解决句柄上限. 问题: 逐个检查, 速度慢.
- epoll 回调返回状态发生变化的句柄, 在连接达到 10K 时, 效率远远高于 select/poll.
其中 epoll 解决方案又可以被称为: Reactor
, 事件驱动
, 事件轮询
等, 那么为何 IO 多路复用可以解决此问题呢? IO 多路复用在阻塞 IO 的基础上, 基于事件监听机制最大化的解决高并发情况下的网络连接问题, 关于 IO 多路复用见select 复用说明.
在C10k
解决之后随着数据量的增长产生了新的C10M
问题: 单机千万并发连接和处理能力, 这在目前大部分情况下是不可能的. C10M 表现:
- 1 千万的并发连接数
- 100 万个连接/秒
- 10GB/秒的连接
- 1 千万个数据包/秒
- 10 微秒的延迟
- 并发 10 核技术
C10M 的问题解决可能需要硬件方面, 操作系统方面进行改进, 当然, 采用分布式可以解决千万连接请求的最初目的.
上下文切换
关于上下文介绍见上下文基本定义, 这里的上下文切换
主要指CPU 上下文切换
. 让我们先理解一下下面的几个基本概念:
CPU 上下文
: CPU 寄存器和程序计数器, 它们是 CPU 运行任务之前必须依赖的环境.CPU寄存器
: 内置的高速,小容量内存程序计数器
: 用来存储 CPU 正在执行的指令位置或者下一条指令位置CPU 上下文切换
: 保存前一个任务的CPU 上下文
, 加载并执行新的任务, 后续又重新执行保存的任务.
CPU 上下文切换主要有三种类型:
- 进程上下文切换: 进程时间片到达, IO 请求, 系统调用等等都会导致进程上下文切换.系统调用发生时,往往会发生 2 次以上进程上下文切换(用户态,内核态,用户态).
- 线程上下文切换: 线程私有数据(栈和寄存器)在线程切换时需要进行额外的保存工作
- 中断上下文切换: 硬件响应事件,IO 等等会发生中断
上下文切换的性能问题:
- 进程上下文切换: 每次上下文切换都需要几十纳秒到数微秒的 CPU 时间, 大量的上下文切换会影响效率; Linux 中使用 TLB 管理虚拟内存,一旦发生切换, 缓存就需要刷新, 从而影响其他进程.
- 线程上下文切换: 类似进程上下文切换消耗, 每一个线程也存在自己独立的栈和寄存器信息, 更何况, 在 LINUX 中线程实际上是一个轻量级进程.
上下文切换中 CPU 的开销有两类: 直接开销
, 间接开销
, 其中直接开销如下:
- 切换页表
- 切换堆栈
- 切换硬件上下文(寄存器, 指令计数器等)
- 刷新 TLB
- 系统调度器代码执行
间接开销
主要指切换到一个新的进程时, 根据上文提及的时间局限性,空间局限性
, 缓存实际上并未真正的发挥效果, 导致新进程穿透到内存的 IO 会变多, 导致上下文切换的开销可能更大. 最后, 让我们了解一下在哪些场景中会发生上下文切换:
- 进程: CPU 时间片; 系统资源不足时(内存等)挂起; 主动挂起(sleep 等); 高优先级; 硬件中断
- 线程: 两线程属于不同进程, 因资源不共享导致的切换, 消耗跟进程切换一样; 时间片等.
单机 TCP 连接限制
影响单服务器的 TCP 连接上限的因素有: 文件句柄限制, 内存, 带宽等. 其中文件句柄可以通过设置句柄设置文件/etc/sysctl.conf
等来进行设置. 一个 TCP 连接包含四元组: (local ip, local port,remote ip,remote port)
, server 最大 tcp 连接数为客户端IP 数 * 客户端port数
, 对于 IPV4, 最大 tcp 连接数约为 2 的 32 次方.
关于互联网服务器架构, 可以参考淘宝技术演进.
并发和并行
关于并发并行见文章并发和并行.
无论是并发还是并行, 从逻辑上都是实现了多端同时运行, 在代码中就的表现就是多线程和多进程
,那么同步编程
和异步编程
又是怎样一个概念呢?
首先, 需要厘清并发
同下面所述的同步/异步
的区别, 两者实际不在同一个考虑层面. 后者仅仅是为了是当前开发应用高效,极致
的使用硬件资源而提出的概念.
同步编程和异步编程
同步编程
同步编程
简单描述: 一个线程获得了一个任务,然后去执行这个任务, 当这个任务执行完毕后,才能执行接下来的另外一个任务.
同步编程
就是传统的流式阻塞编程(虽然 CPU 层面仍旧表现出并发特性), 在代码层面, 一旦发生阻塞或者主动 sleep, 则整个应用就会原地等待
, 不会再执行任何代码. 当然, 此时 CPU 会去执行另外一个应用的代码去了(笑). 这样导致一个程序无法高效机制的榨干 CPU 性能, 高效的利用服务器资源.
- 触发场景: 内存数据读写,磁盘寻道读写等 IO 操作.
- 解决办法: 多进程/多进程
- 问题: 多进程是非可控的逻辑, 进程间上下文切换也非常耗费资源, 涉及竞态条件时, 还需要引入锁与队列等保障原子性操作.
异步编程
异步编程
简单描述: 一个线程中执行一堆任务, 其可以自由的保存,恢复任务, 有能力穿插的执行多个任务.
异步编程
是为了解决同步编程
中因为多进程/多线程
机制导致的大量资源浪费而提出的一种事件循环
机制, 其大大的提高了程序的利用效率, 并成功解决了C10K
问题. 下面是epoll
的一个简单事件循环注册和通知实例:
epoll
注册 fd 实际上是由红黑树组成. 上图的流程如下:
- 注册两个 socket 文件描述符 fd3, fd4, 并设置了回调等信息, 阻塞等待事件返回结果
- 网卡收到 TCP 包, 根据五元组找到相应 socket 描述符, 自动触发对应事件, 例如图中的 fd5
- 程序 epoll.poll()返回当前触发的所有事件集合, 并调用相应处理函数
- 循环上面流程.
事件循环从早期的select/poll
不断演化到epoll/kqueue
, 大大的提高了服务器的效率. 为了改进代码的可维护性, 避免开发者过度关注底层机制, 基于epoll
等封装的 EventLoop 事件循环库被不断开发出来. 下面是 python 中常见的几个事件循环库:
- libevent/libev: Gevent 使用的网络库
- tornado: tornado 自己实现的 IOLoop
- uvloop: python3 提出, asyncio 库可以配置可插拔的 event loop.
另外关于同步 IO 和异步 IO
的知识, 其与同步异步编程是完全不一类的概念, 要厘清他们之间的区别和关系. 例如 epoll 实际上仍然属于同步IO
的范畴.
libev 事件库
这里简单的介绍一下事件库libev
的使用方法, 以便更好的了解事件回调机制.
- libev 核心是一个事件循环(ev_loop).
- libev 通过分配和注册监控器(watcher), 对注册事件进行监听, 触发时进行自定义的回调操作
下面就是 libev 的一个简单实例:
1 |
|
协程
历史
现在, 让我们再次回顾一下上面的同步编程, 异步编程的历史, 以便更好的了解协程提出的目的.
- 同步编程阻塞的机制极大的降低了程序利用 CPU 资源.
- 同步编程使用多线程/多进程机制来提高程序利用率, 但是加大程序编写难度并且会造成资源浪费和瓶颈
- 异步编程提出, 以事件轮询方式大大的提高了服务器的效率.
那么, 为啥还会有协程提出呢?
EventLoop
的封装极大的提高了事件处理机制, 但是处理事件回调仍然是一个极其麻烦的事情, 有时候可能会造成callback hell
(回调地狱). 故而, 古老的子例程和事件循环被结合在一起, 协程
即协作式子例程
被提出并发展, 例如 golang 的 goroutine, Python 的 gevent 等. 那么, 协程的优势有哪些呢?
- 以近似同步代码的编程模式取代异步回调模式, 虽然底层逻辑仍然是
callback hell
, 但是进行了封装. - 异常处理更加健全, 复用语言内部的错误处理机制, 使回调错误处理更加简单.
- 上下文管理简单化, 若使用回调方式会导致函数大量的耦合
- 方便处理并发行为, 协程的开销成本很低, 每一个协程仅有一个轻巧的用户态栈空间.
那么, 协程为何开销远远小于线程? 协程的准确定义是什么?
协程
在 1.3 节我们提及了进程和线程的上下文, 那么协程的上下文是什么呢?
- cpu 上下文: CPU 寄存器和计数器
- 进程上下文: 寄存器,信号, 分配的内存空间, 文件描述符等抽象出来的硬件资源
- 线程上下文: 寄存器, 线程堆栈, 但是切换过程也涉及
系统调用
, 需要完成用户态和内核态的转换 - 函数上下文: 函数当前所处命名空间
- 协程上下文: 寄存器, 堆栈等, 但是切换过程仅仅发生在用户态空间.
从上面可知, 协程优于线程的方面:
- 线程需要进行系统调用, 每次系统调用都是一个较大的开销.
- 协程可以自主调度, 其调度完成由程序自己控制(当然也包括解释器)
- python 的线程调用策略会在每执行 100 个字节码或者遇到阻塞就停止当前线程, 而协程只会在阻塞是切换
下面让我们看下协程的官方定义:
1 | A coroutine(协程) is a function that can suspend its execution (yield, 让/屈服, 产量) until the |
下面是传统意义上的函数调用以及协程方式下的函数调用:
再次强调, 协程(coroutine)不被操作系统内核所管理, 由程序所控制, 减少了类似线程那样上下文切换导致的额外开销. 另外, 协程是一种伪多线程
技术, 实际上就是并发而非并行(单 CPU 处理多个任务). 关于协程 python 用法, 见python 协程.
参考
- 高性能网络编程系列
- 谈谈 Python 协程技术演进
- 事件库之 Libev
- 同步编程和异步编程
- 一文让你明白 CPU 上下文切换
- 进程/线程上下文切换会用掉你多少 CPU?
- 淘宝千万级并发分布式架构的 14 次演进
- 小白的 asyncio
- what is a coroutine
书籍参考:
<UNIX环境高级编程>
<深入理解计算机系统>