架构系列六(高性能高并发系统设计思考)

124 阅读9分钟

1.引子

在架构设计系统的时候,我们需要关注一些性能指标,比如说RT(响应时间)、TPS(吞吐量)。另外有时候我们在面试新机会的时候,也会跟面试官聊一些高性能高并发的的话题。

那么,高性能高并发具体都指什么呢?我以为谈到一个系统的高性能的时候,我们主要关注的是系统能高效利用资源(CPU、内存、磁盘、网络等),能在更短的时间处理更多的业务请求。你需要注意最后这句话

  • 更短的时间:响应时间
  • 更多的业务请求:吞吐量

你看这就是我们说一个系统高性能,需要考虑的指标归属。另外我们说系统要能高效利用资源,高效利用资源有两个大的基本原则

  • 减少资源浪费:比如减少系统调用、避免线程阻塞,因为系统调用线程阻塞会引起上下文切换,对CPU资源不友好,白白耗费CPU资源
  • 当某种资源成为瓶颈,必要时可以通过其它资源来换取:比如我们常说空间换时间、时间换空间,缓存方案通过内存换CPU;数据压缩传输通过CPU换带宽

下面我们通过项目中常用的一些设计实现方案展开分享,主要有:选择IO模型、减少系统调用、零拷贝、池化、高效并发

2.案例

2.1.选择IO模型

在网络通信、持久化存储应用场景中,IO是很重要的一个关注点。那么首先IO的具体含义是什么呢?

我们给IO下一个定义,IO指数据在内存与外部设备之间的拷贝过程,这个过程称之为IO,这里的外部设备可能是磁盘、可能是网卡

再来看IO模型,在unix操作系统中,定义了五种IO通信模型:同步阻塞、同步非阻塞、IO多路复用、信号驱动IO、异步IO。那么IO模型为我们解决了什么问题呢?

我们思考这么一个问题:当CPU发出一个读取数据的指令,数据在从外部设备拷贝到内存的过程中,需要一段时间,在这段时间里,CPU是等在这里呢,还是可以干点别的事?

你看这就是IO模型所要解决的问题。我们拆解不同的IO模型,发现有两组关键词

  • 同步与异步
  • 阻塞与非阻塞

每一个应用进程拥有用户地址空间、内核地址空间,即我们平常说的用户态、内核态。为了系统安全,操作系统是不允许用户空间,直接访问内核空间的,因此有些事情必须要有内核空间来完成。比如说IO拷贝数据这个事情,一定要由内核空间来完成,这样一来我们就明确了,完成一次IO需要两个动作

  • 触发拷贝数据的动作
  • 拷贝数据动作

拷贝数据,我们已经明确了,是由内核空间完成拷贝;那么谁来触发呢?这就是同步与异步的区别

  • 同步指由用户空间触发
  • 异步指有内核空间触发

搞清楚了同步异步,自然阻塞与非阻塞的区别就更加容易理解了

  • 阻塞指整个IO过程中,用户线程不能干别的事情,只能等着
  • 非阻塞指在IO过程中,用户线程可以去干点别的事情,不需要干等着

所以你看,这样一来我们就可以很好的去理解不同的IO模型了

  • 同步阻塞:用户线程发起系统调用read,直到数据从外部设备、拷贝到内核空间、再从内核空间拷贝到用户空间,read返回。在这期间用户线程,就这么干等着
  • 同步非阻塞:用户线程发起系统调用read,内核空间反馈数据没有准备好,read返回;用户线程不用等,利用空闲时间去干点别的吧;过一会儿,再次发起read调用询问,数据准备好了吗?一直这么循环往复,直到IO完成。重点用户线程不用等
  • IO多路复用:用户线程发起系统调用select,询问数据准备好了吗?内核如果反馈没呢,select返回;一直这么select询问反馈,直到内核说数据准备好了,用户线程发起read调用,内核完成数据拷贝,read返回。重点用户线程不用等,且一个内核线程支持多个用户线程的IO操作
  • 信号驱动IO:用户线程发起read调用,内核空间反馈数据没有准备好;用户线程不用等,留下联系电话,麻烦数据准备好以后通知我一下,接着去干别的事情;内核空间将数据从外部设备,拷贝到内核buffer后,打电话给用户线程,数据准备好了,用户线程处理数据。重点留下电话号码(注册系统调用)
  • 异步IO:类似信号驱动IO,用户线程发起read调用,不用等待内核空间准备数据,直接开启托管模式,即将如何处理数据告诉内核空间,说数据准备好以后,你直接处理就可以了,不用再来烦我。这就是异步IO,与信号驱动IO的区别是,信号驱动IO需要用户线程来处理业务数据,而异步IO是用户线程直接托管,做了甩手掌柜

理论上我们理解了IO,与IO模型,那么有哪些实际的应用案例吗?我们知道早期的jdk,只提供了同步阻塞IO(BIO),相信很多小伙伴一定记得ServerSocket、与Socket编程,写的简易聊天室程序。

因为同步阻塞,IO效率上比较糟糕,不能有效的利用资源,这也是早期java在服务端编程表现不好,始终被C/C++压着打的原因。

直到jdk1.4发布的时候,java提供了新的api,即我们今天熟悉的NIO(非阻塞IO),底层采用了IO多路复用模型,极大的提升了IO效率,java终于在服务端编程领域扬眉吐气了

再到jdk1.7中提供了AIO(异步IO),底层采用了异步IO模型,IO效率上更进了异步

我们看到关于IO在有效利用资源上,更优秀的方案是异步、非阻塞,这也是今天NIO、AIO是我们首选的原因。比如像web应用服务器tomcat,选择提供了NIO(非阻塞IO)、AIO(NIO2异步IO),APR(非阻塞IO)支持;网络通信框架netty采用了NIO(非阻塞IO)的支持

我建议你应该去读一读tomcat、netty这些优秀开源应用的源码,参考优秀的开源项目,提升我们自身的技术能力非常有帮助,当然这里我就不展开了,内容太多展开不了。

2.2.减少系统调用

我们已经知道应用进程包含用户空间,与内核空间,每一次系统调用,比如说IO读取数据会发生如下事情

  • 用户线程发起read系统调用,发生上下文切换:从用户空间切换到内核空间
  • 内核空间准备数据,数据从外部设备拷贝到内核空间
  • 内核空间将数据拷贝到用户空间,发生上下文切换:从内核空间切换到用户空间

我们发现一次读IO操作,发生了两次上下文切换,上下文切换是一个浪费CPU资源的过程,这就是系统调用带来的成本。

那么在实际应用中,我们该如何减少系统调用呢?答案是缓冲区,这也是为什么NIO编程中三大组件有:Selector选择器、Channel通道、buffer缓冲区的原因。当然这里我们关注的是buffer,通过buffer换存数据,减少系统调用次数,减少上下文切换带来的收益

另外我们常说的零拷贝,也是从减少系统调用,减少资源浪费方面来考虑。关于零拷贝,我推荐你看我的另一篇文章:日拱一卒系列(理解零拷贝)

2.3.池化、高效并发

池化技术方案应用比较多,比如说连接池、缓存池、对象池、线程池。池化方案本质上是空间换时间,利用内存空间开销,减少CPU时间上的开销。

那么什么场景下,适合池化方案呢?

当你的应用中,需要创建大量的对象,创建对象的过程复杂耗时,且对象使用过程短时。这个时候我们就可以考虑使用池化的方案,来提升应用的响应效率

最后我们再来看高效并发,在并发应用场景,有两个话题是绕不考的

  • 竞争
  • 安全

竞争是指有共享资源的存在,并发的多个线程需要去抢占该资源,同时为了对资源的有序使用,避免破坏性行为。因此需要对资源加锁

但是加锁本身是一个成本很高的事情,需要

  • 获取锁
  • 释放锁
  • 且串行执行

那么无论从资源耗费上,还是处理能力上都是不友好的。于是在高效并发中,有了一些常用好用的实践方案

  • 无锁化方案,比如说cas
  • 减小锁的方案,比如说不在方法上加锁,而是将锁的范围控制在局部代码块

更详细的这篇文章就不展开了,关于并发编程,我推荐你看我的另外一个系列文章:高级并发编程系列