站内链接:

知识点

MEM 和 CPU

存储器山: 基于成本,效率的考虑, 计算机不同存储器以类似金字塔结构存储, 其中塔顶是速度最快, 成本最高, 容量极小的寄存器, 塔底则是成本低, 速度慢, 容量极大的广域网云存储.

存储器山

从存储器山可以联想到以前学习操作系统原理中关于内存的两个局部性概念:

  • 时间局部性: 相同的时间内, 程序访问同一个地址的次数越多, 时间局部性效果越好
  • 空间局部性: 固定的访问次数内, 相邻访问顺序的存储器地址位置越近, 空间局部性越好

除了内存的存储器山概念, 还有一个 CPU 对于不同操作的时间观, 以便直观的感受 CPU 执行效率, 内存读取效率, 磁盘读取效率等.

cpu时间观

上面的数据参考引用文章(见文章末尾), 其数据(数据来自微信公众号 驹说码事), 从人类的感官来替代 CPU 的感官, 可以这样形容上面的数据:

  1. 执行单条 register 指令时间: 1 秒钟
  2. 磁盘读取 1MB 时间: 1 年半
  3. ping 一个不同城域网主机, 网络包耗时: 12.5 年

从上面的数据就可以理解 CPU 效率和硬件效率的巨大差距, 由此联想到服务器整体效率的瓶颈主要在 IO 读取效率上.

C10K 和 C10M 问题

C10K问题: 早期的服务器都是基于进程/线程模型(早期 apache), 对于每一个客户端 TCP 连接, 服务器都会分配一个进程(线程)来处理请求, 而 C10K(单机 10000+并发连接请求)就会创建一万个进程(线程), 对于单机服务器而言不可能承受, 采用分布式往往又会产生巨大的成本. 所以, 如何突破单机性能局限就是 C10K 提出的主要原因.C10K问题解决方案的探讨:

  1. 每一个进程处理一个连接请求: 进程池没有这么多进程, 进程上下文切换极其消耗资源. 不合理
  2. 利用 IO 多路复用技术可以解决此问题

其中IO 多路复用实现方案有如下几种:

  1. select 轮询检查所有监控文件句柄. 问题: 连接过多时, 逐个检查速度慢; 存在文件句柄上限.
  2. poll 传递待监控的文件句柄, 解决句柄上限. 问题: 逐个检查, 速度慢.
  3. 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 上下文切换主要有三种类型:

  1. 进程上下文切换: 进程时间片到达, IO 请求, 系统调用等等都会导致进程上下文切换.系统调用发生时,往往会发生 2 次以上进程上下文切换(用户态,内核态,用户态).
  2. 线程上下文切换: 线程私有数据(栈和寄存器)在线程切换时需要进行额外的保存工作
  3. 中断上下文切换: 硬件响应事件,IO 等等会发生中断

上下文切换的性能问题:

  1. 进程上下文切换: 每次上下文切换都需要几十纳秒到数微秒的 CPU 时间, 大量的上下文切换会影响效率; Linux 中使用 TLB 管理虚拟内存,一旦发生切换, 缓存就需要刷新, 从而影响其他进程.
  2. 线程上下文切换: 类似进程上下文切换消耗, 每一个线程也存在自己独立的栈和寄存器信息, 更何况, 在 LINUX 中线程实际上是一个轻量级进程.

上下文切换中 CPU 的开销有两类: 直接开销, 间接开销, 其中直接开销如下:

  • 切换页表
  • 切换堆栈
  • 切换硬件上下文(寄存器, 指令计数器等)
  • 刷新 TLB
  • 系统调度器代码执行

间接开销主要指切换到一个新的进程时, 根据上文提及的时间局限性,空间局限性, 缓存实际上并未真正的发挥效果, 导致新进程穿透到内存的 IO 会变多, 导致上下文切换的开销可能更大. 最后, 让我们了解一下在哪些场景中会发生上下文切换:

  1. 进程: CPU 时间片; 系统资源不足时(内存等)挂起; 主动挂起(sleep 等); 高优先级; 硬件中断
  2. 线程: 两线程属于不同进程, 因资源不共享导致的切换, 消耗跟进程切换一样; 时间片等.

单机 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 实际上是由红黑树组成. 上图的流程如下:

  1. 注册两个 socket 文件描述符 fd3, fd4, 并设置了回调等信息, 阻塞等待事件返回结果
  2. 网卡收到 TCP 包, 根据五元组找到相应 socket 描述符, 自动触发对应事件, 例如图中的 fd5
  3. 程序 epoll.poll()返回当前触发的所有事件集合, 并调用相应处理函数
  4. 循环上面流程.

事件循环从早期的select/poll不断演化到epoll/kqueue, 大大的提高了服务器的效率. 为了改进代码的可维护性, 避免开发者过度关注底层机制, 基于epoll等封装的 EventLoop 事件循环库被不断开发出来. 下面是 python 中常见的几个事件循环库:

  • libevent/libev: Gevent 使用的网络库
  • tornado: tornado 自己实现的 IOLoop
  • uvloop: python3 提出, asyncio 库可以配置可插拔的 event loop.

另外关于同步 IO 和异步 IO的知识, 其与同步异步编程是完全不一类的概念, 要厘清他们之间的区别和关系. 例如 epoll 实际上仍然属于同步IO的范畴.

libev 事件库

这里简单的介绍一下事件库libev的使用方法, 以便更好的了解事件回调机制.

  1. libev 核心是一个事件循环(ev_loop).
  2. libev 通过分配和注册监控器(watcher), 对注册事件进行监听, 触发时进行自定义的回调操作

下面就是 libev 的一个简单实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <ev.h>
#include <stdio.h>
#include <signal.h>
#include <sys/unistd.h>


ev_io io_w;


static void io_action(struct ev_loop *main_loop, ev_io *io_w, int e)
{
return
}


int main(int argc ,char *argv[])
{
// 1. 创建事件循环实例
struct ev_loop *main_loop = ev_default_loop(0);

// 2. 初始化并注册FD 以及设置回调函数
ev_init(&io_w,io_action);
ev_io_set(&io_w,STDIN_FILENO,EV_READ);

// 3. 挂载事件watcher到事件驱动器上
ev_io_start(main_loop,&io_w);

// 4. 启动
ev_run(main_loop,0);

return 0;
}

协程

历史

现在, 让我们再次回顾一下上面的同步编程, 异步编程的历史, 以便更好的了解协程提出的目的.

  1. 同步编程阻塞的机制极大的降低了程序利用 CPU 资源.
  2. 同步编程使用多线程/多进程机制来提高程序利用率, 但是加大程序编写难度并且会造成资源浪费和瓶颈
  3. 异步编程提出, 以事件轮询方式大大的提高了服务器的效率.

那么, 为啥还会有协程提出呢?

EventLoop的封装极大的提高了事件处理机制, 但是处理事件回调仍然是一个极其麻烦的事情, 有时候可能会造成callback hell(回调地狱). 故而, 古老的子例程和事件循环被结合在一起, 协程协作式子例程被提出并发展, 例如 golang 的 goroutine, Python 的 gevent 等. 那么, 协程的优势有哪些呢?

  1. 以近似同步代码的编程模式取代异步回调模式, 虽然底层逻辑仍然是callback hell, 但是进行了封装.
  2. 异常处理更加健全, 复用语言内部的错误处理机制, 使回调错误处理更加简单.
  3. 上下文管理简单化, 若使用回调方式会导致函数大量的耦合
  4. 方便处理并发行为, 协程的开销成本很低, 每一个协程仅有一个轻巧的用户态栈空间.

那么, 协程为何开销远远小于线程? 协程的准确定义是什么?

协程

在 1.3 节我们提及了进程和线程的上下文, 那么协程的上下文是什么呢?

  1. cpu 上下文: CPU 寄存器和计数器
  2. 进程上下文: 寄存器,信号, 分配的内存空间, 文件描述符等抽象出来的硬件资源
  3. 线程上下文: 寄存器, 线程堆栈, 但是切换过程也涉及系统调用, 需要完成用户态和内核态的转换
  4. 函数上下文: 函数当前所处命名空间
  5. 协程上下文: 寄存器, 堆栈等, 但是切换过程仅仅发生在用户态空间.

从上面可知, 协程优于线程的方面:

  1. 线程需要进行系统调用, 每次系统调用都是一个较大的开销.
  2. 协程可以自主调度, 其调度完成由程序自己控制(当然也包括解释器)
  3. python 的线程调用策略会在每执行 100 个字节码或者遇到阻塞就停止当前线程, 而协程只会在阻塞是切换

下面让我们看下协程的官方定义:

1
2
3
4
A coroutine(协程) is a function that can suspend its execution (yield, 让/屈服, 产量) until the
given given YieldInstruction finishes.

The word “coroutine” is composed of two words: “co” (cooperative 合作) and “routines” (functions)

下面是传统意义上的函数调用以及协程方式下的函数调用:

no-coroutine
coroutine

再次强调, 协程(coroutine)不被操作系统内核所管理, 由程序所控制, 减少了类似线程那样上下文切换导致的额外开销. 另外, 协程是一种伪多线程技术, 实际上就是并发而非并行(单 CPU 处理多个任务). 关于协程 python 用法, 见python 协程.

参考

书籍参考:

  • <UNIX环境高级编程>
  • <深入理解计算机系统>