乐趣区

关于后端:看完这篇还不懂高并发中的线程与线程池你来打我

从这篇开始将会开启 高性能、高并发 系列,本篇是该系列的开篇,次要关注多线程以及线程池。

所有要从 CPU 说起

你可能会有疑难,讲多线程为什么要从 CPU 说起呢?起因很简略,在这里没有那些时尚的概念,你能够更加清晰的看清问题的实质

CPU 并不知道线程、过程之类的概念。

CPU 只晓得两件事:

  1. 从内存中取出指令
  2. 执行指令,而后回到 1

你看,在这里 CPU 的确是不晓得什么过程、线程之类的概念。

接下来的问题就是 CPU 从哪里取出指令呢?答案是来自一个被称为 Program Counter(简称 PC)的寄存器,也就是咱们熟知的程序计数器,在这里大家不要把寄存器想的太神秘,你能够简略的把寄存器了解为内存,只不过存取速度更快而已。

PC 寄存器中寄存的是什么呢?这里寄存的是指令在内存中的地址,什么指令呢?是 CPU 将要执行的下一条指令。

那么是谁来设置 PC 寄存器中的指令地址呢?

原来 PC 寄存器中的地址默认是主动加 1 的,这当然是有情理的,因为大部分状况下 CPU 都是一条接一条程序执行,当遇到 if、else 时,这种程序执行就被突破了,CPU 在执行这类指令时会依据计算结果来动静扭转 PC 寄存器中的值,这样 CPU 就能够正确的跳转到须要执行的指令了。

聪慧的你肯定会问,那么 PC 中的初始值是怎么被设置的呢?

在答复这个问题之前咱们须要晓得 CPU 执行的指令来自哪里?是来自内存,废话,内存中的指令是从磁盘中保留的可执行程序加载过去的,磁盘中可执行程序是编译器生成的,编译器又是从哪里生成的机器指令呢?答案就是 咱们定义的函数

留神是函数,函数被编译后才会造成 CPU 执行的指令,那么很天然的,咱们该如何让 CPU 执行一个函数呢?显然咱们只须要找到函数被编译后造成的第一条指令就能够了,第一条指令就是函数入口。

当初你应该晓得了吧,咱们想要 CPU 执行一个函数,那么 只须要把该函数对应的第一条机器指令的地址写入 PC 寄存器就能够了,这样咱们写的函数就开始被 CPU 执行起来啦。

你可能会有疑难,这和线程有什么关系呢?

从 CPU 到操作系统

上一大节中咱们明确了 CPU 的工作原理,咱们想让 CPU 执行某个函数,那么只须要把函数对应的第一条机器执行装入 PC 寄存器就能够了,这样即便没有操作系统咱们也能够让 CPU 执行程序,尽管可行但这是一个十分繁琐的过程,咱们须要:

  • 在内存中找到一块大小适合的区域装入程序
  • 找到函数入口,设置好 PC 寄存器让 CPU 开始执行程序

这两个步骤绝不是那么容易的事件,如果每次在执行程序时程序员本人手动实现上述两个过程会疯掉的,因而聪慧的程序员就会想罗唆间接写个程序来主动实现下面两个步骤吧。

机器指令须要加载到内存中执行,因而须要记录下内存的起始地址和长度;同时要找到函数的入口地址并写到 PC 寄存器中,想一想这是不是须要一个数据结构来记录下这些信息:

struct * {
void* start_addr;
int len;
void* start_point;
…};

接下来就是起名字时刻。

这个数据结构总要有个名字吧,这个构造体用来记录什么信息呢?记录的是程序在被加载到内存中的运行状态,程序从磁盘加载到内存跑起来叫什么好呢?罗唆就叫 过程 (Process) 好了,咱们的领导准则就是肯定要听下来比拟神秘,总之大家都不容易弄懂就对了,我将其称为“弄不懂准则”。

就这样过程诞生了。

CPU 执行的第一个函数也起个名字,第一个要被执行的函数听起来比拟重要,罗唆就叫 main 函数 吧。

实现上述两个步骤的程序也要起个名字,依据“弄不懂准则”这个“简略”的程序就叫 操作系统 (Operating System) 好啦。

就这样操作系统诞生了,程序员要想运行程序再也不必本人手动加载一遍了。

当初过程和操作系统都有了,所有看上去都很完满。

从单核到多核,如何充分利用多核

人类的一大特点就是生命不息折腾不止,从单核折腾到了多核。

这时,假如咱们想写一个程序并且要分利用多核该怎么办呢?

有的同学可能会说不是有过程吗,多开几个过程不就能够了?听下来仿佛很有情理,然而次要存在这样几个问题:

  • 过程是须要占用内存空间的(从上一节能看到这一点),如果多个过程基于同一个可执行程序,那么这些过程其内存区域中的内容简直完全相同,这显然会造成内存的节约
  • 计算机解决的工作可能是比较复杂的,这就波及到了过程间通信,因为各个过程处于不同的内存地址空间,过程间通信人造须要借助操作系统,这就在增大编程难度的同时也减少了零碎开销

该怎么办呢?

从过程到线程

让我再来认真的想一想这个问题,所谓过程无非就是内存中的一段区域,这段区域中保留了CPU 执行的机器指令以及函数运行时的堆栈信息,要想让过程运行,就把 main 函数的第一条机器指令地址写入 PC 寄存器,这样过程就运行起来了。

过程的毛病在于只有一个入口函数,也就是 main 函数,因而过程中的机器指令 只能被一个 CPU 执行,那么有没有方法让多个 CPU 来执行同一个过程中的机器指令呢?

聪慧的你应该能想到,既然咱们能够把 main 函数的第一条指令地址写入 PC 寄存器,那么其它函数和 main 函数又有什么区别呢?

答案是没什么区别,main 函数的非凡之处无非就在于是 CPU 执行的第一个函数,除此之外再无特别之处,咱们能够把 PC 寄存器指向 main 函数,就能够把 PC 寄存器指向任何一个函数

当咱们把 PC 寄存器指向非 main 函数时,线程就诞生了

至此咱们解放了思维,一个过程内能够有多个入口函数,也就是说属于同一个过程中的机器指令能够被多个 CPU 同时执行

留神,这是一个和过程不同的概念,创立过程时咱们须要在内存中找到一块适合的区域以装入过程,而后把 CPU 的 PC 寄存器指向 main 函数,也就是说过程中只有一个 执行流

然而当初不一样了,多个 CPU 能够在同一个屋檐下 (过程占用的内存区域) 同时执行属于该过程的多个入口函数,也就是说当初一个过程内能够有 多个执行流 了。

总是叫执行流如同有点太容易了解了,再次祭出”弄不懂准则“,起个不容易懂的名字,就叫线程吧。

这就是线程的由来。

操作系统为每个过程保护了一堆信息,用来记录过程所处的内存空间等,这堆信息记为数据集 A。

同样的,操作系统也须要为线程保护一堆信息,用来记录线程的入口函数或者栈信息等,这堆数据记为数据集 B。

显然数据集 B 要比数据 A 的量要少,同时不像过程,创立一个线程时无需去内存中找一段内存空间,因为线程是运行在所处过程的地址空间的,这块地址空间在程序启动时曾经创立结束,同时线程是程序在运行期间创立的(过程启动后),因而当线程开始运行的时候这块地址空间就曾经存在了,线程能够间接应用。这就是为什么各种教材上提的创立线程要比创立过程快的起因(当然还有其它起因)。

值得注意的是,有了线程这个概念后,咱们只须要过程开启后创立多个线程就能够让所有 CPU 都忙起来,这就是所谓高性能、高并发的基本所在

很简略,只须要创立出数量 适合 的线程就能够了。

另外值得注意的一点是,因为各个线程共享过程的内存地址空间,因而线程之间的通信无需借助操作系统,这给程序员带来极大不便的同时也带来了无尽的麻烦,多线程遇到的少数问题都出自于线程间通信几乎太不便了以至于非常容易出错。出错的本源在于 CPU 执行指令时基本没有线程的概念,多线程编程面临的 互斥 同步 问题须要程序员本人解决,对于互斥与同步问题限于篇幅就不具体开展了,大部分的操作系统材料都有具体解说。

最初须要揭示的是,尽管后面对于线程解说应用的图中用了多个 CPU,但不是说肯定要有多核能力应用多线程,在单核的状况下一样能够创立出多个线程,起因在于线程是操作系统层面的实现,和有多少个外围是没有关系的,CPU 在执行机器指令时也意识不到执行的机器指令属于哪个线程。即便在只有一个 CPU 的状况下,操作系统也能够通过线程调度让各个线程“同时”向前推动,办法就是将 CPU 的工夫片在各个线程之间来回调配,这样多个线程看起来就是“同时”运行了,但实际上任意时刻还是只有一个线程在运行。

线程与内存

在后面的探讨中咱们晓得了线程和 CPU 的关系,也就是把 CPU 的 PC 寄存器指向线程的入口函数,这样线程就能够运行起来了,这就是为什么咱们创立线程时必须指定一个入口函数的起因。无论应用任何编程语言,创立一个线程大体雷同:

// 设置线程入口函数
DoSomethingthread = CreateThread(DoSomething);
// 让线程运行起来
thread.Run();

那么线程和内存又有什么关联呢?

咱们晓得函数在被执行的时产生的数据包含 函数参数 局部变量 返回地址 等信息,这些信息是保留在栈中的,线程这个概念还没有呈现时过程中只有一个执行流,因而只有一个栈,这个栈的栈底就是过程的入口函数,也就是 main 函数,假如 main 函数调用了 funA,funcA 又调用了 funcB,如图所示:

那么有了线程当前了呢?

有了线程当前一个过程中就存在多个执行入口,即同时存在多个执行流,那么只有一个执行流的过程须要一个栈来保留运行时信息,那么很显然有多个执行流时就须要有多个栈来保留各个执行流的信息,也就是说 操作系统要为每个线程在过程的地址空间中调配一个栈,即每个线程都有独属于本人的栈,能意识到这一点是极其要害的。

同时咱们也能够看到,创立线程是要耗费过程内存空间的,这一点也值得注意。

线程的应用

当初有了线程的概念,那么接下来作为程序员咱们该如何应用线程呢?

从生命周期的角度讲,线程要解决的工作有两类:长工作和短工作。

1,长工作,long-lived tasks

顾名思义,就是工作存活的工夫很长,比方以咱们罕用的 word 为例,咱们在 word 中编辑的文字须要保留在磁盘上,往磁盘上写数据就是一个工作,那么这时一个比拟好的办法就是专门创立一个写磁盘的线程,该写线程的生命周期和 word 过程是一样的,只有关上 word 就要创立出该写线程,当用户敞开 word 时该线程才会被销毁,这就是长工作。

这种场景非常适合创立专用的线程来解决某些特定工作,这种状况比较简单。

有长工作,相应的就有短工作。

2,短工作,short-lived tasks

这个概念也很简略,那就是工作的解决工夫很短,比方一次网络申请、一次数据库查问等,这种工作能够在短时间内疾速解决实现。因而短工作多见于各种 Server,像 web server、database server、file server、mail server 等,这也是互联网行业的同学最常见的场景,这种场景是咱们要重点探讨的。

这种场景有两个特点:一个是 工作解决所需工夫短 ;另一个是 工作数量微小

如果让你来解决这种类型的工作该怎么办呢?

你可能会想,这很简略啊,当 server 接管到一个申请后就创立一个线程来解决工作,解决实现后销毁该线程即可,So easy。

这种办法通常被称为 thread-per-request,也就是说来一个申请就创立一个线程:

如果是长工作,那么这种办法能够工作的很好,然而对于大量的短工作这种办法尽管实现简略然而有这样几个毛病:

  1. 从前几节咱们能看到,线程是操作系统中的概念(这里不探讨用户态线程实现、协程之类),因而创立线程人造须要借助操作系统来实现,操作系统创立和销毁线程是须要耗费工夫的
  2. 每个线程须要有本人独立的栈,因而当创立大量线程时会耗费过多的内存等系统资源

这就好比你是一个工厂老板(想想都很开心有没有),手里有很多订单,每来一批订单就要招一批工人,生产的产品非常简单,工人们很快就能解决完,解决完这批订单后就把这些含辛茹苦招过去的工人解雇掉,当有新的订单时你再含辛茹苦的招一遍工人,干活儿 5 分钟招人 10 小时,如果你不是励志要让企业倒闭的话大略是不会这么做到的,因而一个更好的策略就是招一批人后就地养着,有订单时解决订单,没有订单时大家能够闲呆着。

这就是线程池的由来。

从多线程到线程池

线程池的概念是非常简单的,无非就是创立一批线程,之后就不再开释了,有工作就提交给这些线程解决,因而无需频繁的创立、销毁线程,同时因为线程池中的线程个数通常是固定的,也不会耗费过多的内存,因而这里的思维就是 复用、可控

线程池是如何工作的

可能有的同学会问,该怎么给线程池提交工作呢?这些工作又是怎么给到线程池中线程呢?

很显然,数据结构中的队列人造适宜这种场景,提交工作的就是生产者,生产工作的线程就是消费者,实际上这就是经典的 生产者 - 消费者问题

当初你应该晓得为什么操作系统课程要讲、面试要问这个问题了吧,因为如果你对生产者 - 消费者问题不了解的话,实质上你是无奈正确的写出线程池的。

限于篇幅在这里博主不打算具体的解说生产者消费者问题,参考操作系统相干材料就能获取答案。这里博主打算讲一讲个别提交给线程池的工作是什么样子的。

一般来说提交给线程池的工作蕴含两局部:1) 须要被解决的数据 ;2) 解决数据的函数

struct task {
void* data;     // 工作所携带的数据
handler handle; // 解决数据的办法
}

(留神,你也能够把代码中的 struct 了解成 class,也就是对象。)

线程池中的线程会阻塞在队列上,当生产者向队列中写入数据后,线程池中的某个线程会被唤醒,该线程从队列中取出上述构造体 (或者对象),以构造体(或者对象) 中的数据为参数并调用处理函数:

while(true) {
struct task = GetFromQueue(); // 从队列中取出数据
task->handle(task->data);     // 解决数据
}

以上就是线程池最 外围 的局部。

了解这些你就能明确线程池是如何工作的了。

线程池中线程的数量

当初线程池有了,那么线程池中线程的数量该是多少呢?

在接着往下看前先本人想一想这个问题。

如果你能看到这里阐明还没有睡着。

要晓得线程池的线程过少就不能充分利用 CPU,线程创立的过多反而会造成零碎性能降落,内存占用过多,线程切换造成的耗费等等。因而线程的数量既不能太多也不能太少,那到底该是多少呢?

答复这个问题,你须要晓得线程池解决的工作有哪几类,有的同学可能会说你不是说有两类吗?长工作和短工作,这个是从生命周期的角度来看的,那么从解决工作所须要的资源角度看也有两种类型,这就是没事儿找抽型和。。啊不,是 CPU 密集型和 I / O 密集型。

1,CPU 密集型

所谓 CPU 密集型就是说解决工作不须要依赖内部 I /O,比方科学计算、矩阵运算等等。在这种状况下只有线程的数量和核数基本相同就能够充分利用 CPU 资源。

2,I/ O 密集型

这一类工作可能计算局部所占用工夫不多,大部分工夫都用在了比方磁盘 I /O、网络 I / O 等。

这种状况下就略微简单一些了,你须要利用性能测试工具评估出用在 I / O 期待上的工夫,这里记为 WT(wait time),以及 CPU 计算所须要的工夫,这里记为 CT(computing time),那么对于一个 N 核的零碎,适合的线程数大略是 N * (1 + WT/CT),假如 I / O 等待时间和计算工夫雷同,那么你大略须要 2N 个线程能力充分利用 CPU 资源,留神这只是一个理论值,具体设置多少须要依据实在的业务场景进行测试。

当然充分利用 CPU 不是惟一须要思考的点,随着线程数量的增多,内存占用、系统调度、关上的文件数量、关上的 socker 数量以及关上的数据库链接等等是都须要思考的。

因而这里没有万能公式,要 具体情况具体分析

线程池不是万能的

线程池仅仅是多线程的一种应用模式,因而多线程面临的问题线程池同样不能防止,像死锁问题、race condition 问题等等,对于这一部分同样能够参考操作系统相干材料就能失去答案,所以根底很重要呀老铁们。

线程池应用的最佳实际

线程池是程序员手中弱小的武器,互联网公司的各个 server 上简直都能见到线程池的身影,应用线程池前你须要思考:

  • 充沛了解你的工作,是长工作还是短工作、是 CPU 密集型还是 I / O 密集型,如果两种都有,那么一种可能更好的方法是把这两类工作放到不同的线程池中,这样兴许能够更好的确定线程数量
  • 如果线程池中的工作有 I / O 操作,那么务必对此工作设置超时,否则解决该工作的线程可能会始终阻塞上来
  • 线程池中的工作最好不要 同步 期待其它工作的后果

总结

本节咱们从 CPU 开始一路来到罕用的线程池,从底层到下层、从硬件到软件。留神,这里通篇没有呈现任何特定的编程语言,线程不是语言层面的概念(仍然不思考用户态线程),然而当你真正了解了线程后,置信你能够在任何一门语言下用好多线程,你须要了解的是道,尔后才是术。

心愿这篇文章对大家了解线程以及线程池有所帮忙。

接下的一篇将是与线程池密切配合实现高性能、高并发的又一关键技术:I/ O 与 I / O 多路复用,敬请期待。

更多精彩内容,欢送关注公众号“码农的荒岛求生”。

退出移动版