它们为什么这么快:从多进程到多线程再到 I/O 复用

发布于: 2020 年 07 月 06 日
它们为什么这么快:从多进程到多线程再到I/O复用

一直以来,计算机体现的都是它的工具价值:提高人们的效率。所以,无论是从底层硬件、中间操作系统层还是上层应用软件,速度(包括计算速度和响应速度)快始终是计算机不懈的追求。

快都是相似的,不快有各种各样的原因。

本篇就来聊一聊计算机这相似的『快』,并结合NginxRedis的案例来详细说明这些『快』的常见『套路』。

分时操作系统

早期计算机资源极为有限(现在也是如此且永远会如此),但是科技的成果不应该被独享,需要为大众服务。于是在20世纪70年代引入了分时的概念,让计算机可以对它的资源进行按时间片轮转(共享)的方式供多个用户使用,极大地降低了计算机成本(经济性),让个人和组织可以在不实际拥有计算机的情况下使用计算机。这种使用分时的方案为用户服务的计算机系统即为分时操作系统。

:分时模型的引入是计算机历史上的一次重大技术革新,Unix以及类Unix操作系统都属于分时操作系统。

多进程与多线程

面对多个用户任务的请求,操作系统自然需要寻找一种优秀的模型来高效地调度它们。用户的任务映射到计算机中就是程序,而程序的执行实例即进程,所以这个优秀的模型就是进程模型,进程是分时操作系统进行资源分配和调度的基本单位,所有发生的一切均在在此基础上展开。

从用户任务到操作系统进程图

进程模型很好地解决了多任务请求的问题,且IPC(进程间通信)模型很好地解决了多任务之间的协作问题,但是计算机资源仍然是有限的(始终要记住这一点),进程有自己独立的虚拟地址空间、文件描述符以及信号处理等,单个进程运行起来仍然消耗大量的内存和处理器资源,且多进程切换的开销也很大,所以还需要再共享/复用些什么,于是线程模型便应运而生了。

线程是在进程的基础上进一步优化操作系统的调度方式,增大可以共享的粒度。一个进程内的线程除了可以拥有独立的调用栈、寄存器和本地存储,还可以共享当前进程的所有资源。多线程模型可以更大限度地利用CPU资源,在当前线程阻塞的时候可以由其他线程获取CPU执行权,从而提高系统的响应速度。如果再加上Processor Affinity(处理器亲和性/关联)特性:把任务分配到指定的CPU Core(核心)上去,这样还可以省去线程切换的开销(一个Core分配一个线程)。

进程-线程模型图

如果说一个任务对应一个进程,那么一个任务内的一个部分(子任务)则对应一个线程。如果说多进程解决的是计算问题(基于多核CPU),提高了计算性能,那么一个进程中的多线程解决的是阻塞问题,提升了响应速度。

IO多路复用

现实场景中,多进程/线程可以一定程度上提高计算机执行效率,但是在有限的Cores上可以并发的进程/线程数会达到一定的瓶颈,即无法规模化增长:进程/线程数过少则并发性能不高;进程/线程数过多则频繁的上下文切换会带来巨大的时间开销。

例如设计一个网络应用程序,每个连接分配一个进程/线程。这种架构简单且容易实现,但是当要同时处理成千上万个连接时,服务器程序则无法规模化增长(系统资源会随着连接数增长而逐渐耗尽,Apache服务器程序就有这个问题)。同时,这也存在一个巨大的资源利用上的不对称:相当轻量级的连接(由文件描述符和少量内存表示)映射到单独的线程或进程(这是一个非常重量级的操作系统对象)。所以说,一个请求分配一个进程/线程的模式虽然容易实现,但它是对计算机资源极大的浪费。这就是著名的C10K问题(即单机支持1万个并发连接问题)。

计算机资源仍然是有限的(始终要记住这一点),如何在有限的计算机资源上让计算机执行的更快呢?优秀的程序员每天都会问计算机一遍:『还可以更快吗』。

所以,优秀的程序员们很快就会想到:是否可以让一个进程/线程处理多个连接。从而解决C10K问题的良药则是:I/O多路复用(从select到poll),对I/O异步调用而不会产生阻塞。如通过select系统调用,本来由请求线程进行的轮询操作现在改由内核负责,表面上多了一层系统调用的时间,但是由于其支持多路I/O,故提高了效率。这是高并发的真正关键所在。如果将内核的多路轮询操作改为基于事件通知的方式(即epoll),epoll会把哪个描述符发生了怎样的I/O事件通知我们,免去了轮询操作,从而进一步提高了并发性能。

上面简单论述了计算机应用程序追求更快执行的历史演进与基本原理,下面来看一下业界在『追求更快』上基于此的最佳实践。

Nginx为什么这么快

Nginx是一款业界公认的高性能Web和反向代理服务器程序,以高性能与高并发著称,官方测试结果中,其可支持五万个并发连接(在实际场景中,支持2万-4万个并发连接)。

Nginx高并发的因素大致可以归纳为以下几点:

  • I/O多路复用:从select到poll

  • 事件通知(异步):从poll到epoll

  • 内存映射文件:从读文件到内存映射文件 

  • Processor Affinity:一个Core指定分配一个进程数

  • 进程单线程模型:避免了线程切换开销 

  • 线程池功能(1.7.1+),将可能阻塞的I/O模块扔到线程池里去

Redis为什么这么快

Redis是一种应用广泛的高并发内存KV数据库,其高并发主要由以下几点保证:

  • I/O多路复用:从select到poll

  • 事件通知(异步):从poll到epoll

  • 基于内存操作:读写速度非常快

  • 单线程模式:避免了线程切换开销

  • 多线程启用:无独有偶,类似Nginx的线程池功能,Redis4.0引入了多线程,专门用于处理一些容易阻塞的大键值对的场景。

总结

通过Nginx与Redis案例可以看到,设计此类应用程序使其快的『套路』是相似的:

  • 第一步:使用Processor Affinity特性:根据CPU核心数目确认并发的进程个数,然后将一个进程指定绑定在某一个CPU核心上。

  • 第二步:使用进程单线程模式,每个进程-线程固定绑定在某个CPU核上执行,避免了线程切换带来的开销。且多个CPU核上的进程之间并行执行。

  • 第三步:使用I/O多路复用,1个线程处理N个连接,这是高并发最为关键的一步。

  • 第四步:使用事件通知,由阻塞改为非阻塞事件驱动,避免了文件描述符的轮询操作。

  • 第五步:针对某些执行时间过长容易阻塞的场景启动线程池功能,进一步提高响应速度

那么,除了这些,还可以更快吗?不远的将来,服务器将要处理数百万的并发连接(C10M问题)。由于CMOS 技术方法已经接近物理极限,摩尔定律即将终结,CPU则向着多核的方向发展。多核特性将识别并行性和决定如何利用并行性的职责从内核转交给了『用户态』的程序员和编程语言系统。于是便出现了专门用于并发场景的编程语言,如Erlang,Golang等,从一个全新的模型视角去重新审视问题,解决问题,让计算机应用程序更快。

发布于: 2020 年 07 月 06 日 阅读数: 64
用户头像

Ya

关注

to be a maker 2017.11.23 加入

腾讯高级工程师

评论 (1 条评论)

发布
用户头像
挺有意思的内容,就是标题让人有点看不懂
2020 年 07 月 06 日 09:05
回复
谢谢你的建议,优化了下标题
2020 年 07 月 06 日 16:09
回复
没有更多了
它们为什么这么快:从多进程到多线程再到I/O复用