并发与并行
并发
在单核处理器下,OS把CPU资源按照时间片段,假设一个片段10ms给一个任务(进程、线程)。OS在调度这些任务的时候,让每个任务只占用了CPU的一个时间片。也就是说,一个任务不能把CPU的资源一直占用着,那其他任务都没有机会受OS的调度了。所谓的抢占式,就是当一个任务享有的CPU时间片到了,系统就会把CPU的资源夺过来,抢占过来,继续调度下一个任务。每一个任务都是串行执行的。task1执行完,task2执行,task2时间片到了。task3执行......
但是每一个任务占用的CPU的时间片是非常短的,假设为10ms,所以在应用层看起来就是多个任务共同执行一样,物理上还是串行的。
在单核上,OS将CPU资源划分为多个时间片,按照时间片轮转的方式为每一个任务分配CPU资源,使各个任务交替执行。虽然从物理层面上看,这些任务是串行执行的,但是由于时间片非常短,所以看起来多个任务就像是在共同执行一样。
那么在开发一个任务的时候,要不要实现成并发程序呢?后面我们再说。
并行
在多核处理器中。同一时间,task1和task2就能同时被调度。在每一个单核上,同样是并发调度任务。
task1在下一次被调度的时候一定由CPU核1调度吗?不一定,由OS调度机制决定,但是如果task1是一个经常执行的程序,最好放在同一个核中执行,因为cache的存在,可以提高效率,如果每一次task1总是由不同内核调度,cache就失效了。
在多核或者多CPU上,多个任务是在真正的同时执行。
多线程的优势
多线程程序一定好吗?不一定,需要根据当前程序的类型来判断:程序是IO密集型?还是CPU密集型?
- IO密集型程序:在运行过程中,需要花费大量的时间在I/O操作上,而不是CPU计算上。需要频繁的进行数据的读取和写入。比如,从磁盘读文件,向网络发数据,等待用户输入等。
- CPU密集型程序:也称计算密集型,主要消耗的是CPU的资源,需要进行大量的计算和数据处理,而对IO操作的需要相对较少。比如:加密、解密、算法。
在单核或者多核处理器的场景下,IO密集型程序都适合。
当程序执行IO操作时,通常是需要等待IO设备“准备好”的,在这个等待过程中,如果程序没有采取特殊的处理方式,就会被阻塞,被OS放进阻塞队列里等待设备准备好,再给这样的程序分配CPU时间片,CPU就相当于空闲下来了。
- 提高CPU利用率
在IO操作时,CPU通常处于等待状态,使用多线程可以在一个线程进行 I/O 操作等待时,让其他线程继续执行,从而充分利用 CPU 的空闲时间,提高 CPU 的利用率。
- 增强并发处理能力(多核也增强并行处理能力)
多线程可以同时处理多个 I/O 请求,实现并发操作。对于 I/O 密集型程序,如 Web 服务器,可能会同时收到大量客户端的请求,每个请求都涉及到网络 I/O 操作。通过多线程,服务器可以为每个请求分配一个线程来处理,在一个请求的 I/O 操作等待时,切换到其他请求进行处理,从而提高服务器对并发请求的处理能力,增加系统的吞吐量。
在多核处理器下,CPU密集型程序适合使用多线程。
- 并行执行任务
使用多线程可以将不同的计算任务分配到不同的核心上并行执行,从而充分利用多核处理器的计算能力,显著提高程序的执行效率。
- 提高资源利用率
多核下,某个线程等待某种资源而暂时无法执行,而其他线程可以在其他核心继续执行,不会导致整个处理器处于空闲状态,提高了处理器资源利用率。
在单核处理器下,CPU密集型程序使用不适合使用多线程。
- 存在上下文切换开销
在单核处理器上,多线程上是通过时间片轮转的方式来实现并发执行的。当一个线程的时间片用完后,操作系统需要保存当前线程的上下文信息(包括寄存器的值、程序计数器等),并切换到另一个线程执行,这个过程会带来一定的开销。对于 CPU 密集型程序,本身就需要大量的 CPU 计算时间,频繁的上下文切换会浪费大量的 CPU 时间,导致程序的实际执行效率下降。
线程池
线程的消耗
为了完成任务,创建很多的线程可以吗?线程真的是越多越好吗?
- 线程的创建和销毁都是非常“重”的操作!
创建线程时,系统要为其分配内存等资源,销毁时要进行资源回收和数据结构清理。如果在服务执行的过程中创建和销毁大量的线程,会消耗系统大量的资源和时间。比如一个秒杀服务已经执行了,此时去创建和销毁大量的线程,导致消耗系统大量的资源,而分配给服务的内存资源和CPU时间就少了。
- 线程栈本身占用大量的内存!
创建了一大批线程,每一个线程都需要线程栈,线程还没有做具体的事情,栈几乎都被占完了,线程还怎么执行任务。(Linux在32位系统下,用户空间3G,内核空间1G,一个线程栈大小为8M,3G*1024/8M等于384,一共能创建384个线程。)
- 线程的上下文切换占用大量的时间!
线程的调度需要进行上下文切换,如果线程过多,线程调度上下文切换花费的CPU时间也越多,CPU的利用率就不高了,大量的时间都用来进行线程的上下文切换了。
- 大量线程同时被唤醒会使系统经常出现锯齿状负载或者瞬间负载量很大导致宕机!
如果有很多线程在同时等待IO操作,那么当IO操作准备好时,大量的线程同时被唤醒会使系统经常出现锯齿状负载(即负载在短时间内快速上升和下降)或者瞬间负载量很大导致宕机。(负载指的是系统正在处理的工作量或任务量,它反映了系统资源被占用的程度)
设计多少个线程合适呢?
C++muduo网络库、libevent,Java Netty、mina,Golong和Rust......
一般都是按照当前CPU的核心数量来确定的,八核的就创建八个线程,四核的就创建四个线程。
线程池的优势
操作系统上创建线程和销毁线程都是很“重”的操作,花费CPU资源和时间都比较多,那么在服务执行的过程中,如果业务量比较大,实时的去创建线程、执行业务、业务完成后销毁线程,会导致系统的实时性能降低,业务处理能力也会降低。
线程池的优势就是,在服务进程启动之初,事先就先创建好线程池里面的线程,当业务流量到来需要分配线程时,直接从线程池中获取一个空闲线程执行task任务即可,任务执行完以后,也不用释放线程,而是把线程归还到线程池中继续给后续的task提供服务。
fixed模式的线程池
线程池里面的线程个数是固定不变的,一般是线程池创建时根据当前机器的CPU核心数量来指定。
C++11也提供了一些方法,可以直接从语言层面获取当前机器的CPU核心数量,我们实现的线程池默认就是支持的fixed模式。是一个通用模式。
cached模式的线程池
线程池里面的线程个数是可动态增长的,根据任务的数量动态的增加线程的数量,但是会设置一个线程数量的阈值(线程过多的坏处上面也有提到),任务处理完成后,如果动态增长的线程空闲了60s之后,还没有其他任务要处理,那么关闭线程,保持线程池中最初数量的线程即可。
为什么需要cached模式的线程池?
比如CPU核心数量为4,设置线程池中线程个数为4,当任务队列里有任务到来时,恰好有4个任务要进行IO操作,比较耗时,同时占用了这4个线程。那么线程池里就没有空闲的线程来处理其他任务了,再有其他任务到来时,只能阻塞等待在任务队列里。此时,在这种场景下,我们就可以根据任务的数量动态的增加线程的数量。
为什么还让动态增长的线程等待一段时间之后,又关闭了呢?
比如一个秒杀业务,可能一分钟之内就有大量的任务到来,流量非常大,那么线程池中就可以增长线程的数量来处理这些任务。过了秒杀的时间段以后,没有流量了,刚才动态增加的很多线程可能根本就没有任务要处理了,线程池中也就不需要那么多的线程了。
线程池中有两个队列,一个用来缓存线程的,一个用来缓存任务的。对于这两个队列,在多线程环境下,就要考虑线程同步的问题了。下面我们先复习一些相关概念。
线程同步
同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题。
线程互斥
互斥锁mutex
atomic原子类型
互斥:任何时刻,互斥保证有且只有一个执行流进入临界区代码段,访问临界资源,通常对临界资源起保护作用。
临界区代码段能不能在多线程环境被多线程访问,看临界区代码段是否存在竞态条件,如果存在,则要保证对这段代码段操作的原子性,称为不可重入代码段。如果不存在,则称为可重入代码段。
- 临界资源:多线程执行流共享的资源就叫临界资源。
- 临界区:每个线程内部,访问临界区的代码,就叫临界区。
- 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
- 竞态条件:临界区代码段在多线程环境下执行,随着线程的调度时序不同,而得到不同的结果。
- 可重入代码段:一段代码,能够被多个线程或进程安全地并发执行,即便该代码段在执行过程中被中断,然后再次被调用,也不会产生任何错误或出现不可预测的结果。
- 不可重入代码段:在多线程或多进程环境下,当该代码段正在执行时,如果被再次调用,可能会导致程序出现错误、数据不一致或其他不可预测结果的代码。
线程通信
条件变量condition_variable
信号量semaphore
项目设计
项目整体架构梳理
线程池我们写出来,最终是要形成动态库的,提供给别人使用。我们先来看一下线程池是怎么使用的。需要给线程池提供的接口有哪些。
我们定义出一个对象,直接调用pool.start()方法就可以让线程池启动起来,默认是fixed模式,线程数量是固定的。如果想改变线程池模式,让线程的数量可动态增长,只需要调用pool.setMode()方法即可。
用户调用pool.submitTask()提交任务。用户提交的任务,是各种各样类型的任务,我们设计的线程池不能只是针对某一种类型的任务,而是针对所有类型。有的任务用户需要收到任务处理的返回值,有的任务用户则不需要关心任务处理的返回值,所以result也是我们在线程池中要实现的。
用户调用result.get()方法获取任务处理结果。任务是各种各样类型的,有的任务返回结果是整型,有的是字符串等等,所以我们需要用一个类型期望接收未来任意任务返回的类型,可以使用C++17的Any类型,在项目中,我们自己实现Any。用户提交的任务,他肯定知道任务的返回值是什么类型,用户调用result.get()方法指定用户想要的结果类型,就可以获取任务执行的返回值。
以上是要外部提供给线程池的一些接口。
我们再来看线程池内部都有什么。
- 线程池要缓存线程,肯定需要一个容器来存放线程,所以除了要抽象出一个ThreadPool类,还需要Thread类来抽象。
- 并且我们实现的线程池有两种方式,默认为fixed模式,线程数量是固定的,我们也可以通过调用相应的接口来实现线程数量是可动态增加的cached模式。所以容器应该是可动态增长的。
- 但是线程也不是越多越好,我们已经说过了,线程过多会消耗CPU时间片和资源,所以还需要一个变量来表示线程数量的阈值。
- 我们设计线程池是用来干嘛的?执行任务。所以需要一个容器来存放用户提交的任务。
- 多个用户要将任务放进任务队列里,多个线程要从任务队列里面取任务执行任务,那么这个容器就涉及线程安全问题,未来写代码的时候就要有所考虑。
- 任务也不能堆积过多,任务过多占用内存很多,搞得系统都没有内存了还怎么执行业务,所以也需要定义一个变量来表示任务数量的阈值,当用户提交任务过多过快时,可以通过sumbitTask给用户返回一个标识,提交失败。
- 线程池用什么类型来接收用户提交进来的各种各样类型的任务(对象)呢?这就用到了继承多态的思想。我们设计的时候就采用了多态的思想,不同继承体系的对象调用同一函数,能产生不同行为,只有基类的指针能指向从基类继承而来的各种各样的派生类对象。在线程池里设计一个抽象的task基类,task基类里提供了一个纯虚函数virtual void run()。当用户pool.submitTask(concrete Task)想提交一个任务concrete Task时,不管这个任务是做什么的,只需要将这个concrete Task继承task基类,然后重写run方法,提交给线程池即可。
基于上述架构的梳理,我们先开始实现基于fixed模式的线程池......