你管这破玩意叫线程?
一切要从CPU说起
CPU并不知道线程、进程之类的概念。
CPU只知道两件事:
- 从内存中取出指令
- 执行指令,然后回到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) 好啦。
就这样操作系统诞生了,程序员再也不用自己手动加载可执行程序了。
现在进程和操作系统都有了,一切看上去都很完美。
小结
程序员无法把所有的硬件操作细节都了解到,管理这些硬件并且加以优化使用是非常繁琐的工作,这个繁琐的工作就是操作系统来干的,有了他,程序员就从这些繁琐的工作中解脱了出来,只需要考虑自己的应用软件的编写就可以了,应用软件直接使用操作系统提供的功能来间接使用硬件。
简单来说,操作系统就是一个协调、管理和控制计算机硬件资源和软件资源的控制程序。
而进程本质就是一个正在执行的程序。
当你在代码编译器上编写代码时,这时的代码不过就是磁盘上的普通文件,此时的程序和操作系统没有半毛钱关系,操作系统也不认知这种文本文件。
当我们写完代码后开始编译,这时编译器将普通的文本文件翻译成二进制可执行文件,此时的程序依然是保存在磁盘上的文件,和普通没有本质区别。但不一样的是,该文件是可执行文件,也就是说操作系统开始 “懂得” 这种文件,所谓 “懂得” 是指操作系统可以识别、解析、加载,因此必定有某种类似协议的规范,这样编译器按照这种协议生成可执行文件,操作系统就能加载了。
在 Linux 下可执行文件格式为 ELF ,在 Windows 下是 EXE 。
此时虽然操作系统可以识别可执行程序,但如果你不去双击一下(或者在Linux下运行相应命令)的依然和操作系统没有半毛钱关系。
但是当你开始运行可执行程序后魔法就开始了。
此时操作系统开始将可执行文件加载到内存,解析出代码段、数据段等,并为这个程序创建运行起来后需要的堆区栈区等。
最后,根据可执行文件操作系统知道该程序应该执行的第一条机器指令是什么,并将其告诉 CPU ,CPU 从该程序的第一条指令开始执行,程序就运行起来了。
我们把一个运行起来的程序叫做进程,这就是进程的由来。
从单核到多核,如何充分利用多核
人类的一大特点就是生命不息折腾不止,从单核折腾到了多核。
这时,假设我们想写一个程序并且要充分利用多核该怎么办呢?
有的同学可能会说不是有进程吗,多开几个进程不就可以了?听上去似乎很有道理,但是主要存在这样几个问题:
- 内存的浪费:进程是需要占用内存空间的(从上一节能看到这一点),如果多个进程基于同一个可执行程序,那么这些进程其内存区域中的内容几乎完全相同,这显然会造成内存的浪费
- 进程间通信复杂度: 计算机处理的任务可能是比较复杂的,这就涉及到了进程间通信,由于各个进程处于不同的内存地址空间,进程间通信天然需要借助操作系统,这就在增大编程难度的同时也增加了系统开销。
那么该怎么办呢?
从进程到线程
让我们再来仔细的想一想这个问题,所谓进程无非就是内存中的一段区域,这段区域中保存了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的PC寄存器指向线程的入口函数,这样线程就可以运行起来了,这就是为什么我们创建线程时必须指定一个入口函数的原因。那么线程和内存又有什么关联呢?
我们知道函数在被执行时产生的数据包括函数参数、局部变量、返回地址等信息,这些信息是保存在栈中的,线程这个概念还没有出现时进程中只有一个执行流,因此只有一个栈,这个栈的栈底就是进程的入口函数,也就是main函数,那么有了线程以后了呢?
有了线程以后一个进程中就存在多个执行入口,即同时存在多个执行流,那么只有一个执行流的进程需要一个栈来保存运行时信息,那么很显然有多个执行流时就需要有多个栈来保存各个执行流的信息,也就是说操作系统要为每个线程在进程的地址空间中分配一个栈,即每个线程都有独属于自己的栈。
同时我们也可以看到,创建线程是要消耗进程内存空间的,这一点也值得注意。
线程的使用
现在有了线程的概念,那么接下来作为程序员我们该如何使用线程呢?
从生命周期的角度讲,线程要处理的任务有两类:长任务和短任务。
长任务,顾名思义,就是任务存活的时间很长,以我们常用的word为例,我们在word中编辑的文字需要保存在磁盘上,往磁盘上写数据就是一个任务,那么这时一个比较好的方法就是专门创建一个写磁盘的线程,该写线程的生命周期和word进程是一样的,只要打开word就要创建出该写线程, 当用户关闭word时该线程才会被销毁,这就是长任务。
这种情况比较简单,非常适合创建专用的线程来处理某些特定任务。
有长任务,相应的就有短任务。
短任务,这个概念也很简单,那就是任务的处理时间很短,比如一次网络请求、一次数据库查询等, 这种任务可以在短时间内快速处理完成。因此短任务多见于各种Server,像web server、database server、file server、mail server等,这也是互联网行业的同学最常见的场景,这种场景是我们要重点讨论的。
这种场景有两个特点:一个是任务处理所需时间短;另一个是任务数量巨大。
如果让你来处理这种类型的任务该怎么办呢?
你可能会想,这很简单啊,当server接收到一个请求后就创建一个线程来处理任务,处理完成后销毁该线程即可,So easy。
这种方法通常被称为thread-per-request,也就是说来一个请求就创建一个线程,如果是长任务,那么这种方法可以工作的很好,但是对于大量的短任务这种方法虽然实现简单但是有这样几个缺点:
- 线程是操作系统中的概念(这里不讨论用户态线程实现、协程之类),因此创建线程天然需要借助操作系统来完成,操作系统创建和销毁线程是需要消耗时间的
- 每个线程需要有自己独立的栈,因此当创建大量线程时会消耗过多的内存等系统资源。
这就好比你是一个工厂老板(想想都很开心有没有),手里有很多订单,每来一批订单就要招一批工人,生产的产品非常简单,工人们很快就能处理完,处理完这批订单后就把这些千辛万苦招过来的工人辞退掉,当有新的订单时你再千辛万苦的招一遍工人,干活儿5分钟招人10小时,如果你不是励志要让企业倒闭的话大概是不会这么做到的,因此一个更好的策略就是招一批人后就地养着,有订单时处理订单,没有订单时大家可以闲呆着。
这就是线程池的由来。
从多线程到线程池
线程池的概念是非常简单的,无非就是创建一批线程,之后就不再释放了,有任务就提交给这些线程处理,因此无需频繁的创建、销毁线程,同时由于线程池中的线程个数通常是固定的,也不会消耗过多的内存,因此这里的思想就是复用、可控。
线程池是如何工作的
可能有的同学会问,该怎么给线程池提交任务呢?这些任务又是怎么给到线程池中线程呢?
很显然,数据结构中的队列天然适合这种场景,提交任务的就是生产者,消费任务的线程就是消费者,实际上这就是经典的生产者-消费者问题。
限于篇幅在这里博主不打算详细的讲解生产者消费者问题,参考操作系统相关资料就能获取答案。这里我们讲一讲一般提交给线程池的任务是什么样子的。
一般来说提交给线程池的任务包含两部分:
- 需要被处理的数据;
- 处理数据的函数。
struct task {
void* data; // 任务所携带的数据
handler handle; // 处理数据的方法
}
线程池中的线程会阻塞在队列上,当生产者向队列中写入数据后,线程池中的某个线程会被唤醒,该线程从队列中取出上述结构体,执行:
while(true) {
struct task = GetFromQueue(); // 从队列中取出数据
task->handle(task->data); // 处理数据
}
以上就是线程池最核心的部分。理解这些你就能明白线程池是如何工作的了。
线程池中线程的数量
现在线程池有了,那么线程池中线程的数量该是多少呢?
要知道线程池的线程过少就不能充分利用CPU,线程创建的过多反而会造成系统性能下降,内存占用过多,线程切换造成的消耗等等。因此线程的数量既不能太多也不能太少,那到底该是多少呢?
上面我们提到的长任务和短任务,这个是从生命周期的角度来看的,那么从处理任务所需要的资源角度看也有两种类型,是CPU密集型和I/O密集型。
- CPU密集型:所谓CPU密集型就是说处理任务不需要依赖外部I/O,比如科学计算、矩阵运算等等。在这种情况下只要线程的数量和核数基本相同就可以充分利用CPU资源。
- 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不是唯一需要考虑的点,随着线程数量的增多,内存占用、系统调度、打开的文件数量、打开的socket数量以及打开的数据库链接等等是都需要考虑的。
因此这里没有万能公式,要具体情况具体分析。
线程池不是万能的
线程池仅仅是多线程的一种使用形式,因此多线程面临的问题线程池同样不能避免,像死锁问题、race condition问题等等,关于这一部分同样可以参考操作系统相关资料就能得到答案,所以基础很重要呀老铁们。
总结
(1)操作系统:
操作系统位于计算机硬件与应用软件之间,是一个协调、管理和控制计算机硬件资源和软件资源的控制程序,本质上也是软件。
(2)进程:
在程序执行的过程中,需要将其放入内存中之后才会被CPU所处理。
早期的计算机中只支持单道程序,即一次只能有一个程序在内存中运行。内存被划分为程序段和数据段,程序段用于存放程序代码,数据段则用于存放程序中的数据。 因为只有一个程序,所以很容易就能在内存中找到相应的程序段和内存段。
只有当这个程序执行完毕,才能装入下一个程序。这样的方式虽然简单,但是效率低,因为在I/O等待的时间里,CPU被闲置了。
为了提高CPU利用率,后来引入了多道程序设计,可以同时将多个程序加载在内存中,互相独立地运行,当一个程序等待I/O时,操作系统调度另一个程序运行,这样,可以使CPU利用率大大提高。
然而这样内存中就需要存放各个运行的程序的相关信息,即程序段和数据段。这样就会产生一个问题,操作系统如何才能找到各程序的存放位置。
所以为了方便操作系统管理和完成各个程序并发执行,引入了进程和进程实体的概念。
操作系统为每个运行的程序配置了一个数据结构,进程控制块(PCB),用来描述进程的各种信息,例如程序代码存放的位置,数据所存放的位置。
所以PCB存放的是进程的相关信息,相当于程序段、数据段的地址,并不是直接存放程序段、数据段的内容。程序段、数据段的具体内容存放在内存中的其他地方,由于有PCB的存在,这些部分很容易被获取。
PCB、程序段、数据段三个部分构成了进程实体,也称进程印象。
一般情况下,我们把进程实体简称为进程,例如所谓的创建进程,实质上是创建进程实体的PCB;而撤销进程,实质上是撤销进程实体中的PCB。
从不同的角度,进程可以有不同的定义,比较传统典型的定义有:
- 进程是程序的一次执行过程
- 进程是一个程序及其数据在处理机上顺序执行时所发生的活动
- 进程是具有独立功能的程序在数据集合上运行的过程,它是系统进行资源分配和调度的一个独立单位
以上三种定义都是在强调进程的“动态性”,即强调进程它是一个运动的过程。
引入进程实体的概念后,可以把进程定义为:进程是进程实体的运行过程,是系统进行资源分配和调度的一个独立单位。
严格来说,进程实体和进程并不一样,进程实体是静态的,进程则是动态的。
进程怎样进化为线程?
我们先来看一下一个程序如果只有进程没有线程的话运行效果是怎样的?
假设一个程序运行需要三个功能:
- 网络功能
- 键盘
- 渲染
一个进程去实现程序所有的功能,会造成一种卡顿的效果。这个时候3个功能是共用一个CPU的,只能一个一个的来。网络功能用的时候其它三个就得等待。
既然一个进程会卡的话,那我为每个功能都分配一个进程。
- 网络功能 → 进程1
- 键盘 → 进程2
- 渲染 → 进程3
当网络卡的时候,我还可以进行键盘输入,渲染等功能。
虽然他也能解决卡顿的问题,但是时空开销非常大,每个进程都得建立PCB档案。在操作系统中PCB档案资源是非常有限的。并且进程之间CPU切换的时候,也会导致时间和空间的开销-上下文切换。
为了避免PCB过多,时空开销过大的情况。我们就引出了线程的概念。
(3)线程:
进程的颗粒度太大,每次的执行都要进行进程上下文的切换。如果我们把进程比喻为一个运行在电脑上的软件,那么一个软件的执行不可能是一条逻辑执行的,必定有多个分支和多个程序段,就好比要实现程序A,实际分成 a,b,c等多个块组合而成。
这里a,b,c的执行是共享了A进程的上下文,CPU在执行的时候仅仅切换线程的上下文,而没有进行进程上下文切换的。进程的上下文切换的时间开销是远远大于线程上下文时间的开销。这样就让CPU的有效使用率得到提高。这里的a,b,c就是线程,也就是说线程是共享了进程的上下文环境的更为细小的CPU时间段。线程主要共享的是进程的地址空间。
注意,这里通篇没有出现任何特定的编程语言,线程不是语言层面的。
最后,我们通过一个例子来结尾。
例子:用户去图书馆借书
早期图书馆的结构非常简单。只有一个图书管理员,一次只能处理用户的一个请求。如果多个用户一起来,他们就需要排队等待。【单道程序】。
后来,图书馆规模扩大。雇佣了更多的图书管理员,每个管理员都可以处理用户的请求。图书管理员A帮用户查找书籍,图书管理员B可以开始为用户办理借书【多道程序】。
之后,图书馆引入自助服务系统。每个用户都可以操作机器,自行查找书籍,借阅以及归还书籍。每个用户都有自己的任务,使用自己的账户和配置,并且不会互相干扰。这类似于计算机的进程,每个进程有自己的内存和资源,互相独立。
最后,图书馆的自助服务系统进一步更新,允许用户一次执行多种操作。比如,用户可以一边查找想要的书籍,一边查阅自己的借阅记录,还可以在查找书籍的同时,提交借阅请求。这些都在同一个操作界面中完成,互不影响,共享同一个用户账户的资源。这就像计算机的多线程,同一个进程中的多个线程共享资源,但执行各自的任务。
【参考文章】