从根上理解高性能、高并发(一):深刻计算机底层,理解线程与线程池

一、系列文章引言

1.1 文章目的

做为即时通信技术的开发者来讲,高性能、高并发相关的技术概念早就了然与胸,什么线程池、零拷贝、多路复用、事件驱动、epoll等等名词信手拈来,又或许你对具备这些技术特征的技术框架好比:Java的Netty、Php的workman、Go的nget等熟练掌握。但真正到了面视或者技术实践过程当中遇到没法释怀的疑惑时,方知自已所掌握的不过是皮毛。php

返璞归真、回归本质,这些技术特征背后的底层原理究竟是什么?如何能通俗易懂、绝不费力真正透彻理解这些技术背后的原理,正是《从根上理解高性能、高并发》系列文章所要分享的。html

1.2 文章源起

我整理了至关多有关IM、消息推送等即时通信技术相关的资源和文章,从最开始的开源IM框架MobileIMSDK,到网络编程经典巨著《TCP/IP详解》的在线版本,再到IM开发纲领性文章《新手入门一篇就够:从零开发移动端IM》,以及网络编程由浅到深的《网络编程懒人入门》、《脑残式网络编程入门》、《高性能网络编程》、《鲜为人知的网络编程》系列文章。git

越往知识的深处走,越以为对即时通信技术了解的太少。因而后来,为了让开发者门更好地从基础电信技术的角度理解网络(尤为移动网络)特性,我跨专业收集整理了《IM开发者的零基础通讯技术入门》系列高阶文章。这系列文章已然是普通即时通信开发者的网络通讯技术知识边界,加上以前这些网络编程资料,解决网络通讯方面的知识盲点基本够用了。程序员

对于即时通信IM这种系统的开发来讲,网络通讯知识确实很是重要,但回归到技术本质,实现网络通讯自己的这些技术特征:包括上面提到的线程池、零拷贝、多路复用、事件驱动等等,它们的本质是什么?底层原理又是怎样?这就是整理本系列文章的目的,但愿对你有用。github

1.3 文章目录

从根上理解高性能、高并发(一):深刻计算机底层,理解线程与线程池》(* 本文web

《从根上理解高性能、高并发(二):深刻操做系统,理解I/O与零拷贝技术 (稍后发布..)》面试

《从根上理解高性能、高并发(三):深刻操做系统,完全理解I/O多路复用 (稍后发布..)》数据库

《从根上理解高性能、高并发(四):深刻操做系统,完全理解同步与异步 (稍后发布..)》编程

《从根上理解高性能、高并发(五):高并发高性能服务器究竟是如何实现的 (稍后发布..)》后端

1.4 本篇概述

本篇是该系列文章的开篇,主要是从CPU这一层来说解多线程以及线程池原理,力求避免复杂的技术概念罗列,尽可能作到通俗易懂、老小皆宜。

二、本文做者

应做者要求,不提供真名,也不提供我的照片。

本文做者主要技术方向为互联网后端、高并发高性能服务器、检索引擎技术,网名是“码农的荒岛求生”,公众号“码农的荒岛求生”。感谢做者的无私分享。

三、一切要从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执行程序,虽然可行但这是一个很是繁琐的过程。

咱们须要:

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

这两个步骤毫不是那么容易的事情,若是每次在执行程序时程序员本身手动实现上述两个过程会疯掉的,所以聪明的程序员就会想干脆直接写个程序来自动完成上面两个步骤吧。

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

数据结构大体以下:

struct *** {

   void* start_addr;

   intlen;

   void* start_point;

   ...

};

接下来就是起名字时刻。

这个数据结构总要有个名字吧,这个结构体用来记录什么信息呢?记录的是程序在被加载到内存中的运行状态,程序从磁盘加载到内存跑起来叫什么好呢?干脆就叫进程(Process)好了,咱们的指导原则就是必定要听上去比较神秘,总之你们都不容易弄懂就对了,我将其称为“弄不懂原则”。

就这样进程诞生了。

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

完成上述两个步骤的程序也要起个名字,根据“弄不懂原则”这个“简单”的程序就叫操做系统(Operating System)好啦。

就这样操做系统诞生了,程序员要想运行程序不再用本身手动加载一遍了。

如今进程和操做系统都有了,一切看上去都很完美。

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

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

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

有的同窗可能会说不是有进程吗,多开几个进程不就能够了?

听上去彷佛颇有道理,可是主要存在这样几个问题:

  • 1)进程是须要占用内存空间的(从上一节能看到这一点),若是多个进程基于同一个可执行程序,那么这些进程其内存区域中的内容几乎彻底相同,这显然会形成内存的浪费;
  • 2)计算机处理的任务多是比较复杂的,这就涉及到了进程间通讯,因为各个进程处于不一样的内存地址空间,进程间通讯自然须要借助操做系统,这就在增大编程难度的同时也增长了系统开销。

该怎么办呢?

六、从进程到线程

让我再来仔细的想想这个问题,所谓进程无非就是内存中的一段区域,这段区域中保存了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寄存器指向线程的入口函数,这样线程就能够运行起来了,这就是为何咱们建立线程时必须指定一个入口函数的缘由。

不管使用任何编程语言,建立一个线程大致相同:

// 设置线程入口函数DoSomething

thread = 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问题等等,关于这一部分一样能够参考操做系统相关资料就能获得答案,因此基础很重要呀老铁们。

1三、线程池使用的最佳实践

线程池是程序员手中强大的武器,互联网公司的各个server上几乎都能见到线程池的身影。

但使用线程池前你须要考虑:

  • 1)充分理解你的任务,是长任务仍是短任务、是CPU密集型仍是I/O密集型,若是两种都有,那么一种可能更好的办法是把这两类任务放到不一样的线程池中,这样也许能够更好的肯定线程数量;
  • 2)若是线程池中的任务有I/O操做,那么务必对此任务设置超时,不然处理该任务的线程可能会一直阻塞下去;
  • 3)线程池中的任务最好不要同步等待其它任务的结果。

1四、本文小结

本文咱们从CPU开始一路来到经常使用的线程池,从底层到上层、从硬件到软件。

注意:这里通篇没有出现任何特定的编程语言,线程不是语言层面的概念(依然不考虑用户态线程),可是当你真正理解了线程后,相信你能够在任何一门语言下用好多线程,你须要理解的是道,此后才是术。

但愿这篇文章对你们理解线程以及线程池有所帮助。

接下的一篇将是与线程池密切配合实现高性能、高并发的又一关键技术《从根上理解高性能、高并发(二):深刻操做系统,理解I/O与零拷贝技术》,敬请期待。(本文已同步发布于:http://www.52im.net/thread-3272-1-1.html