面试官问我高并发服务模型哪家强?

面试中经常会被问到高性能服务模型选择对比,以及如何提高服务性能和处理能力,这其中涉及操作系统软件和计算机硬件知识,其实都是在考察候选人的基础知识掌握程度,但如果没准备的话容易一头雾水,这次带大家从头到尾学习一遍,学完这一篇再也不怕面试官刨根问底了!

任务类型

谈高并发服务模型选择之前,我们先来看下程序的的任务类型,程序任务类型一般分为 CPU 密集型任务和 IO 密集型任务,这两种任务有各自的特点,对程序的要求是不一样的需要分开对待。

CPU密集型任务

一个程序任务大部分是计算类的,比如逻辑处理、数值比较和计算,我们就称它是 CPU 密集型任务或计算密集型任务。CPU 密集型任务的特点是要进行大量的计算,消耗 CPU 资源,比如计算圆周率、视频编解码这些靠的是 CPU 的运算能力。

CPU 密集型任务虽然也可以用多任务完成,但是任务越多,任务之间切换的时间就越多,CPU 执行效率反而更低,所以要最高效地利用 CPU,任务并行数应当等于 CPU 的核心数,避免任务在 CPU 核之间频繁切换。

芯片线路 |图片来源:www.hippopx.com License CC0

IO密集型任务

一个程序涉及到大量网络、磁盘等比较耗时的输入输出任务,就称它是 IO 密集型任务,这类任务的特点是 CPU 消耗很少,任务的大部分时间都在等待 IO 操作完成(因为 IO 的速度远远低于 CPU 和内存的速度,不是一个数量级的)。

对于 IO 密集型任务,任务越多 CPU 效率越高,但也不是无限的开启多任务,如果任务过多频繁切换的开销也不可忽视。常见的大部分程序都是执行 IO 密集型任务,比如互联网业务的 Web 服务,数据库操作等。

五彩的以太网口 |图片来源:www.hippopx.com License CC0

服务模型

不管是 CPU 密集型任务还是 IO 密集型任务,要提高服务器处理能力,可以从软件和硬件两个层面来做文章。

先说软件层面,单个任务处理能力有限,可以通过启动多个功能完全相同的服务实例,借此来提高服务整体处理性能,多服务实例的实现主流的技术有三种:多进程、多线程、多协程。当然除了用多实例的方式,还有 IO 多路复用、异步 IO 等技术,为了文章主题明确,不在本文展开讨论。

服务模型哪家强

既然有三种技术实现,那么你可能会问,在三个模型里选一个最好的来实现服务,该如何选择一个适合的服务模型呢?

抱歉,小孩子才做选择我全都要!哈哈,开个玩笑。

答案是没有最好,服务模型选择要结合自身服务处理的任务类型。任务类型就是我们上面说的 CPU 密集型和 IO 密集型,只有清楚的知道所处理业务的任务类型,才能在上述服务模型中选择其一或多种模型组合,来搭建适合你的高性能服务框架。

多进程服务模型

进程概念

程序是一些保存在磁盘上的指令的有序集合,是静态的。进程是程序执行的过程,包括了动态创建、调度和消亡的整个过程,进程是程序资源管理的最小单位

多进程模型

多进程模型是启动多个服务进程。原来由一个进程做的事,当一个进程忙不过来,创建几个功能一样的进程来帮它一起干活,人多力量大。

由于多进程地址空间不同,数据不能共享,一个进程内创建的变量在另一个进程是无法访问。操作系统看不下去了,凭什么同在一台机器,彼此相爱的两个进程不能说说话呢?

于是操作系统提供了各种系统调用,搭建起各个进程间通信的桥梁,这些方法统称为进程间通信 IPC (IPC InterProcess Communication)

常见进程间通信方式

管道 Pipe

管道的实质是一个内核缓冲区,进程以先进先出 FIFO 的方式从缓冲区存取数据。是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系(父子进程间)的进程间通信。

管道工作原理

  1. 管道一端的进程顺序的将数据写入缓冲区,另一端的进程则顺序的读出数据。

  2. 缓冲区可以看做是一个循环队列,一个数据只能被读一次,读出来后在缓冲区就不复存在了。

  3. 当缓冲区为读空或写满,读数据的进程或写数据进程进入等待队列。

  4. 空的缓冲区有新数据写入,或者满的缓冲区有数据读出时,唤醒等待队列中的进程继续读写。

管道图解

命名管道 FIFO

上面介绍的管道也称为匿名管道,只能用于亲缘关系的进程间通信。为了克服这个缺点,出现了有名管道 FIFO 。有名管道提供了一个路径名与之关联,以文件形式存在于文件系统中,这样即使不存在亲缘关系的进程,只要可以访问该路径也能相互通信。

命名管道支持同一台计算机的不同进程之间,可靠的、单向或双向的数据通信。

信号 Signal

信号是Linux系统中用于进程间互相通信或者操作的一种机制,信号可以在任何时候发给某一进程,无需知道该进程的状态。如果该进程当前不是执行态,内核会暂时保存信号,当进程恢复执行后传递给它。

如果一个信号被进程设置为阻塞,则该信号的传递被延迟,直到其阻塞被取消时才被传递给进程。

信号在用户空间进程和内核之间直接交互,内核可以利用信号来通知用户空间的进程发生了哪些系统事件,信号事件主要有两个来源:

  • 硬件来源:用户按键输入Ctrl+C退出、硬件异常如无效的存储访问等。
  • 软件终止:终止进程信号、其他进程调用 kill 函数、软件异常产生信号。

消息队列 Message Queue

消息队列是存放在内核中的消息链表,每个消息队列由消息队列标识符表示, 只有在内核重启或主动删除时,该消息队列才会被删除。

消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。另外,某个进程往一个消息队列写入消息之前,并不需要另外读进程在该队列上等待消息的到达。

共享内存 Shared memory

共享内存是一个进程把地址空间的一段,映射到能被其他进程所访问的内存,一个进程创建、多个进程可访问,进程就可以直接读写这一块内存而不需要进行数据的拷贝,从而大大提高效率。

共享内存使得多个进程可以可以直接读写同一块内存空间,是最快的可用 IPC 形式,是针对其他通信机制运行效率较低而设计的。共享内存往往与其他通信机制,如信号量配合使用,来实现进程间的同步和互斥通信。

共享内存

套接字 Socket

套接字你可能没听过这个名字,但绝对是接触的最多的一种进程间通信方式。因为我们熟悉的 TCP/IP 协议栈,也是建立在 socket 通信之上,TCP/IP 构建起了当前的互联网通信网络。

它是一种通信机制,凭借这种机制,既可以在本机进程间通信,也可以跨网络通过,因为,套接字通过网络接口将数据发送到本机的不同进程或远程计算机的进程。

socket套接字
多线程服务模型

线程概念

线程是操作操作系统能够进行运算调度的最小单位。线程被包含在进程之中,是进程中的实际运作单位,一个进程内可以包含多个线程,线程是资源调度的最小单位。

多线程模型

启动多个相同功能的进程能提高服务处理能力,但由于各个进程的地址空间相互隔离,通信不便。

于是,多线程服务模型出场。通过前面的学习我们知道,一个进程内的多个线程可以共享进程的全部系统资源。进程内创建的多个线程都可以访问进程内的全局变量。

当然没有免费的午餐,线程虽然能方便的访问进程资源,但也带来了额外的问题。比如多线程访公共资源带来的同步与互斥问题,不同线程访问资源的先后顺序会相互影响,如果不做好同步和互斥会产生预期之外的结果,甚至死锁。

什么是多线程同步

多线程同步是线程之间的一种直接制约关系,一个线程的执行依赖另一个线程的通知,当它没有得到另一个线程的通知时必须等待,直到消息到达时才被唤醒,即有很强的执行先后关系。

比如你搭建了一个商城服务。这个服务的下单流程是这样的:第一步必须要先挑选商品加入购物车,第二步才能结账计算订单金额,假设这两个步骤的操作分别由两个线程去完成,则这两个线程的操作顺序很重要,必须是先下单再结账,这就是线程同步。 购物车||图片来源:www.hippopx.com License CC0

什么是多线程互斥

多线程互斥指的是多线程对资源访问的排他性。所谓排他性,就是当有多个线程都要使用某一共享资源时,任何时刻最多只允许一个线程获得对这个共享资源的使用权,当共享资源被其中一个线程占有时,其他未获得资源的线程必须等待,直到占用资源的线程释放资源。

打个比方,你们班只有一台投影仪,当一个同学在上面放电影的时候,如果老师进来上课要用这个投影仪,那就只能由这个同学放弃投影仪的使用权,交给老师上课投影使用,对,教室里唯一的投影仪是共享资源,具有排他性,老师和学生比作是两个线程的话,那这两个线程是互斥的访问共享资源(投影仪)。

投影仪 |图片来源:www.hippopx.com License CC0

多线程同步和互斥方法

Linux 系统提供以下几种方法来解决多线程的同步和互斥问题,分别是:互斥锁、条件变量、读写锁、自旋锁、条件变量。

互斥锁

互斥锁的作用是对临界区加以保护,以使任意时刻只有一个线程能够执行临界区的代码,实现了多线程对临界资源的互斥访问。

互斥锁接口函数:

互斥锁api

条件变量

条件变量是用来等待而不是用来上锁的。条件变量用来自动阻塞一个线程,直到某特殊情况发生为止。适合多个线程等待某个条件的发生,不使用条件变量,那么每个线程就不断尝试互斥锁并检测条件是否发生,浪费系统资源

通常条件变量和互斥锁同时使用。条件的检测是在互斥锁的保护下进行的。如果一个条件为假,一个线程自动阻塞,并释放等待状态改变的互斥锁。如果另一个线程改变了条件,它发信号给关联的条件变量,唤醒一个或多个等待它的线程,重新获得互斥锁,重新评价条件,可以用来实现线程间的同步。

条件变量系统 API 如下:

条件变量API

读写锁

互斥量要么是加锁状态,要么是不加锁状态,而且一次只有一个线程对其进行加锁。读写锁可以有3种状态:读加锁状态、写加锁状态和不加锁状态

一次只有一个线程可以占有写模式读写锁,但是可以有多个线程同时占有读模式的读写锁。因此,读写锁适合于对数据结构的读次数比写次数多得多的情况,且读写锁比互斥量具有更高的并行性。

读写锁加锁规则

1:如果某线程申请了读锁,其它线程可以再申请读锁,但不能申请写锁;

2:如果某线程申请了写锁,其它线程不能申请读锁,也不能申请写锁。

读写锁系统 API

读写锁API

自旋锁

互斥锁得不到锁时,线程会进入休眠,引发任务上下文切换,任务切换涉及一系列耗时的操作,因此用互斥锁一旦遇到阻塞切换代价是十分昂贵的。

而自旋锁阻塞后不会引发上下文切换,当锁被其他线程占有时,获取锁的线程便会进入自旋,不断检测自旋锁的状态,直到得到锁,所谓的自旋就是循环等待的意思。

自旋锁在用户态使用的比较少,在内核使用的比较多。自旋锁适用于临界区代码比较短,锁的持有时间比较短的场景,否则会让其他线程一直等待造成饥饿现象。

自旋锁 API 接口

自旋锁API

信号量

信号量本质上是一个非负的整数计数器,它被用来控制对公共资源的访问。

信号量是一个特殊类型的变量,它可以被增加或者减少。可根据操作信号量值的结果判断是否对公共资源具有访问的权限,当信号量值大于 0 时,则可以访问,否则将阻塞。但对其的访问被保证是原子操作,即使在一个多线程程序中也是如此。

信号量类型:

  • 二进制信号量,它只有0和1两种取值。适用于临界代码每次只能被一个执行线程运行,就要用到二进制信号量。

  • 计数信号量。它可以有更大的取值范围,适用于临界代码允许有限数目的线程执行,就需要用到计数信号量。

信号量 API

信号量API

协程服务模型

什么是协程

什么是协程呢?协程 Coroutines 是一种比线程更加轻量级的微线程。类比一个进程可以拥有多个线程,一个线程也可以拥有多个协程,因此协程又称微线程和纤程。

协程图解

可以粗略的把协程理解成子程序调用,每个子程序都可以在一个单独的协程内执行。

协程子程序模型

协程服务模型

为了说明什么是协程模型,先用多线程下的生产者消费者模型举个栗子。

启动两个线程分别执行两个函数 Do_some_IODo_some_process ,第一个做耗时的 IO 处理操作,第二个对 IO 操作结果做快速的处理计算工作。伪代码如下:

函数伪代码

多线程执行过程是这样的:

  1. 生产者线程先调用函数 Do_some_IO 做比较耗时的 IO 操作,比如从网络套接字中读取数据这类操作。

  2. 在生产者线程执行 Do_some_IO 完成数据读取之前,消费者线程要阻塞等待。

  3. 在消费者线程执行 Do_some_process 完成数据处理完成之前,生产者线程要阻塞等待。

  4. 在消费者线程执行 Do_some_process 完成数据处理完成之后,要通知生成者线程继续 Do_some_IO 线程执行时间线

可以看到,多线程模型为了保证各个线程并行工作,需要额外做很多线程间的同步和通知工作,而且线程频繁的在阻塞和唤醒间切换,我们知道 Linux 下线程是轻量级线程 LWP ,每次线程切换涉及用户态和内核态的切换,还是很消耗性能的。

同样的场景在协程模型里是怎么处理的呢?还是用前面的例子,说明协程模型的执行流程。

Do_some_IO()       // IO处理协程
Do_some_process()  // 计算处理协程
  1. 分配生产者协程执行 Do_some_IO 做 IO 处理操作,分配消费者协程执行 Do_some_process 计算处理操作。
  2. 在生产者协程工作期间,消费者协程保持等待。
  3. 当生产者协程完成 IO 处理,返回处理结果给消费者,并把程序执行权限交给消费者协程向下执行。
协程执行时间线

协程优势

  • 由于协程在线程内实现,因此始终都是一个线程操作共享资源,所以不存在多线程抢占资源和资源同步问题。

  • 生产者协程和消费者协程,互相配合协作完成工作,而不是相互抢占,而且协程创建和切换的开销比线程小得多。

硬件提升性能

前面讲的多线程、多进程、协程都还只是软件层面的提高服务处理能力。真正硬核的是从硬件层面提高处理能力,增加 CPU 物理核心数目,当然硬件都是有成本的,所以只有软件层面已经充分榨干性能才会考虑增加硬件。

不过,老板有钱买最好最贵的服务器另说,这是人民币玩家和穷逼玩家的区别了,软件工程师留下了贫困的泪水。

增加机器核心数

CPU领域有一条摩尔定律:大概 18 个月会将芯片的性能提高一倍。现在这个定律变的越来越难以突破,CPU 晶体管密度工作频率很难再提高,转而通过增加 CPU 核心数目的方式提高处理器性能。

CPU |图片来源:www.hippopx.com License CC0

目前商用服务器架构基本都是多核处理器,多核的处理器能够真正做到程序并行运行,处理效率大幅度提升,那该如何查看 CPU 核心数目呢?

对于 Windows 操作系统,打开任务管理器,通过界面的「内核」和「逻辑处理器」能看到。

windows 查看核心

查看 cpu 核心数

对于 Linux 操作系统,通过下面 2 种方式查看 CPU 核心相关信息。

1. 通过cpuinfo文件查看

使用cat /proc/cpuinfo查看 cpu 核心信息,如下两个信息:

  • processor,指明第几个cpu处理器
  • cpu cores,指明每个处理器的核心数

cpuinfo 输出示例:

cpuinfo

2. 通过编程接口查看

除了上面以文件的形式查看 cpu 核心信息之外,系统还提供了编程接口可以查询,系统 API 如下。

查看核数API

CPU亲和性

CPU 亲和性是绑定某一进程或线程到特定的 CPU 或 CPU 集合,从而使得该进程或线程只能被调度运行在绑定的 CPU或 CPU 集合上。

为什么要设置 CPU 亲和性绑定 CPU 呢?理论上进程上一次运行后的上下文信息会保留在 CPU 的缓存中,如果下一次仍然将该进程调度到同一个 CPU 上,就能避免缓存未命中对 CPU 处理性能的影响,从而使得进程的运行更加高效。

假如某些进程或线程是 CPU 密集型的,不希望被频繁调度,又或者你有其他特殊需求,不希望进程或线程被调度在不同 CPU 之间频繁切换,则可以将该进程或线程绑定到特定的 CPU 上 ,可以在特定场景下优化程序性能。

绑定进程

在多进程模型中,绑定进程到特定的核心,下面是绑定进程的系统 API

绑定线程

在多线程模型中,绑定线程到特定的核心,下面是绑定线程的系统 API

设置线程亲和性

总结

本文从程序任务类型出发,区分任务为 CPU 密集型和 IO 密集型两大类。接着分别说明提高基于这两类任务的服务性能方法,分为软件层面的方法和硬件层面的方法。

其中软件层面主要讲述利用多进程、多线程以及协程模型,当然现有的技术还有 IO 多路复用、异步 IO 、池化技术等方案,讲到多线程和多进程,顺势说明了进程间通信和线程间同步互斥技术。

第二部分,讲解了从硬件层面提高服务性能:提高机器核心数,并教你如何查看 CPU 核心数的方法。最后,还可以通过软硬结合的方式,把硬件核心绑定到指定进程或者线程执行,最大程度的利用 CPU 性能。

希望通过本文的学习,读者对高性能服务模型有个初步的了解,并能对服务优化的方法和利弊举例一二,就是本文的价值所在。