CPU、内存、进程、线程原理

2021-06-07 03:02

阅读:654

标签:广播   问题   process   char   mes   initial   dram   矩阵   很多   

内存与磁盘IO原理

一般来说,索引本身也很大,不可能全部存储在内存中,因此索引往往以索引文件的形式存储的磁盘上。这样的话,索引查找过程中就要产生磁盘I/O消耗,相对于内存存取,I/O存取的消耗要高几个数量级,所以评价一个数据结构作为索引的优劣最重要的指标就是在查找过程中磁盘I/O操作次数的渐进复杂度。换句话说,索引的结构组织要尽量减少查找过程中磁盘I/O的存取次数。

内存IO

简单点说说内存读取,内存是由一系列的存储单元组成的,每个存储单元存储固定大小的数据,且有一个唯一地址。当需要读内存时,将地址信号放到地址总线上传给内存,内存解析信号并定位到存储单元,然后把该存储单元上的数据放到数据总线上,回传。

写内存时,系统将要写入的数据和单元地址分别放到数据总线和地址总线上,内存读取两个总线的内容,做相应的写操作。

内存存取效率,跟次数有关,先读取A数据还是后读取A数据不会影响存取效率。

磁盘IO

磁盘I/O涉及机械操作。磁盘是由大小相同且同轴的圆形盘片组成,磁盘可以转动(各个磁盘须同时转动)。磁盘的一侧有磁头支架,磁头支架固定了一组磁头,每个磁头负责存取一个磁盘的内容。磁头不动,磁盘转动,但磁臂可以前后动,用于读取不同磁道上的数据。磁道就是以盘片为中心划分出来的一系列同心环(如图标红那圈)。磁道又划分为一个个小段,叫扇区,是磁盘的最小存储单元。
技术图片
磁盘读取时,系统将数据逻辑地址传给磁盘,磁盘的控制电路会解析出物理地址,即哪个磁道哪个扇区。于是磁头需要前后移动到对应的磁道,消耗的时间叫寻道时间,然后磁盘旋转将对应的扇区转到磁头下,消耗的时间叫旋转时间。所以,适当的操作顺序和数据存放可以减少寻道时间和旋转时间。
为了尽量减少I/O操作,磁盘读取每次都会预读,大小通常为页的整数倍。即使只需要读取一个字节,磁盘也会读取一页的数据(通常为4K)放入内存,内存与磁盘以页为单位交换数据。因为局部性原理认为,通常一个数据被用到,其附近的数据也会立马被用到。

01 CPU与内存

CPU的内部架构和工作原理

CPU从逻辑上可以划分成3个模块,分别是控制单元、运算单元和存储单元,这三部分由CPU内部总线连接起来。如下所示:

技术图片

*控制单元*:控制单元是整个CPU的指挥控制中心,由程序计数器PC(Program Counter), 指令寄存器IR(Instruction Register)、指令译码器ID(Instruction Decoder)和操作控制器OC(Operation Controller)等,对协调整个电脑有序工作极为重要。它根据用户预先编好的程序,依次从存储器中取出各条指令,放在指令寄存器IR中,通过指令译码(分析)确定应该进行什么操作,然后通过操作控制器OC,按确定的时序,向相应的部件发出微操作控制信号。操作控制器OC中主要包括节拍脉冲发生器、控制矩阵、时钟脉冲发生器、复位电路和启停电路等控制逻辑。

*运算单元*:是运算器的核心。可以执行算术运算(包括加减乘数等基本运算及其附加运算)和逻辑运算(包括移位、逻辑测试或两个值比较)。相对控制单元而言,运算器接受控制单元的命令而进行动作,即运算单元所进行的全部操作都是由控制单元发出的控制信号来指挥的,所以它是执行部件。

*存储单元*:包括CPU片内缓存和寄存器组,是CPU中暂时存放数据的地方,里面保存着那些等待处理的数据,或已经处理过的数据,CPU访问寄存器所用的时间要比访问内存的时间短。采用寄存器,可以减少CPU访问内存的次数,从而提高了CPU的工作速度。但因为受到芯片面积和集成度所限,寄存器组的容量不可能很大。寄存器组可分为专用寄存器和通用寄存器。专用寄存器的作用是固定的,分别寄存相应的数据。而通用寄存器用途广泛并可由程序员规定其用途,通用寄存器的数目因微处理器而异。这个是我们以后要介绍这个重点,这里先提一下。

CPU缓存的原理

CPU的运算处理速度与内存读写速度的差异非常巨大,为了解决这种差异充分利用CPU的使用效率,CPU缓存应运而生,它是介于CPU处理器和内存之间的临时数据交换的缓冲区。

  CPU缓存和内存都是一种断电即掉的非永久随机存储器RAM,那么它和内存在物理上有什么差异吗?当然有,CPU缓存基本是由SRAM(static RAM)构成(也有IBM的Power系列处理是eDRAM构成的CPU缓存),而内存经常称之为DRAM,其实它是SDRAM(同步动态随机存储器),是DRAM的一种。构成内存的DRAM只含有一个晶体管和一个电容器,集成度非常高可用轻易的做到大容量,但因为靠电容器来存储信息所以需要不间断刷新电容器的电荷,而充放电之间的时间差导致DRAM的数据读写速度较SRAM慢的多。
?
构成缓存的SRAM却比构成内存的DRAM的复杂度高了不止一筹,所以占据空间大,成本高,集成度很低,以至于在CPU工艺低下的前期,CPU缓存不能集成进CPU内部而只有集成到主板上,但它的好处却是不需要刷新电路所以读写速度快。
?
如果说SRAM与DRAM物理结构及性能的不同展现了CPU高速缓存的物理原理,那么时间局部性原理和空间局部性原理则是支撑CPU高速缓存的逻辑原理。时间局部性原理是说:被引用过的内存位置很可能在不远的将来还会被多次引用,空间局部性原理说的是:如果一个内存位置不引用了,那么程序很可能会在不远的将来引用该内存位置附近的内存位置。

CPU缓存的层次结构

最开始对CPU缓存进行分类是由于CPU内部集成的CPU缓存已经不能满足高性能CPU的需要,而制造工艺上的限制又不能在CPU内部大幅提高缓存的数量,所以出现了集成在主板上的缓存,当时人们就把CPU内部的缓存称为一级缓存,即L1 Cache,在CPU外部主板上的缓存称为二级缓存,即L2 Cache。而一级缓存其实还分为一级数据缓存(Data Cache,D-Cache,L1d)和一级指令缓存(Instruction Cache,I-Cache,L1i),分别用于存放数据和执行数据的指令解码,两者可以同时被CPU访问,减少了CPU多核心,多线程争用缓存造成的冲突。
?
早期Intel和AMD似乎对最后一层缓存L2上存在不同的见解,每个CPU核心都具备独立的一级缓存L1,那么二级缓存L2呢?AMD的做法是依然每个CPU核心使用独立的二级缓存,但Intel却采用了一个CPU的多个核心共享二级缓存的设计,即所谓的“Smart Cache”技术,这在当时确实要比AMD的设计性能更好。
?
随着制造工艺的提升,L2也被集成进了CPU缓存,但是接踵而来对大数据处理和游戏性能等等需要,在高端CPU上出现了三级缓存L3 Cache。三级缓存的出现据说对CPU的性能有着爬坡似的提升。当然到了2018年的今天,拥有三级缓存已经不再是高端CPU的特权,在一些特殊的CPU上据说还出现了四级缓存,当然这并不是说缓存的级数越多越能提升性能,到了三级缓存之后由于距离CPU的传输距离和本身容量的提升,CPU访问缓存和直接访问内存所能带来的性能提升已经被逐渐抵消,所以与其增加所谓的四级缓存还不如就直接访问内存。 综上所述,大概描述了CPU缓存的层次结构,下图是来至《深入理解计算机系统》书中关于CPU缓存和内存、硬盘存储器的层次结构:

技术图片

深入理解计算机系统一书将寄存器划分为L0级缓存,接着依次是L1,L2,L3,内存,本地磁盘,远程存储。越往上的缓存存储空间越小,速度越快,成本也更高;越往下的存储空间越大,速度更慢,成本也更低。从上至下,每一层都可以看做是更下一层的缓存,即:L0寄存器是L1一级缓存的缓存,L1是L2的缓存,依次类推;每一层的数据都是来至它的下一层,所以每一层的数据是下一层的数据的子集

每个核心拥有独立的运算处理单元、控制器、寄存器、L1、L2缓存,然后一个CPU的多个核心共享最后一层CPU缓存L3,使其可以同时运行一个进程的多个线程,如下图所示:

技术图片

单核两线程是什么?其实这就是所谓的超线程技术(HyperthreadingTechnology),就是通过采用特殊的硬件指令,可以为一个逻辑内核再模拟出来一个物理芯片,所以如果你通过Windows的设备管理查看处理器你会看到四个,其实有两个都是模拟出来的,这样做可以将CPU内部暂时闲置的资源充分“调动”起来,因为我们的CPU在运行一个程序时其实还有很多执行单元是被闲置的。模拟出一个核就是为了使用CPU一些空闲的地方(资源),充分榨取CPU的性能。但是模拟出来的内核毕竟是虚拟的,所以它会和被模拟的逻辑核共享寄存器,L1,L2,因此就算是双核四线程还是只有2个一级缓存L1,2个二级缓存L2,一个三级缓存L3,所以假如物理核与它的模拟核中的线程要同时使用同一个执行单元里的东西时,或者访问同一个缓存行数据,还是只能一个一个的来。

那么双CPU或者双处理器呢?前面所说的双核心是在一个处理器里拥有两个处理器核心,核心是两个,但是其他硬件还都是两个核心在共同拥有,而双CPU则是真正意义上的双核心,不光是处理器核心是两个,其他例如缓存等硬件配置也都是双份的。一个CPU对应一个物理插槽,多处理器间通过QPI总线相连。我们常见的计算机(例如上面我的笔记本)几乎都是单CPU多核心的,真正的多CPU并不是个人PC所常用的。

 

CPU缓存的内部结构 对CPU缓存的层次结构有了了解之后,我们再深入进CPU缓存内部,看看它内部的结构。

技术图片

上图是一个CPU缓存的内部结构视图,来至《深入理解计算机系统》一书,结合上图我这里只做简单的说明,若要细致深入的了解请参考原书。

CPU缓存内部一般是由S组构成,这个S的大小与该缓存的存储大小寻址空间有关,然后每一组里面又有若干缓存行cache line,例如上图每一组有E行cache line,E等于2,每一个缓存行包含一个标记其是否有效的有效位和t个标记位,然后才是真正存储缓存数据部分有B个字节大小。整个缓存区的大小C=B*E*S.
?
而一个内存地址在做缓存查找的时候,首先中间的s位指明了应该放在哪一组,高位的t位指明位于组中的哪一行,低位的b位表示应该从缓存行中的多少个偏移开始读取,毕竟一个缓存行可以存放很多数据的,一般是64个字节。
?
这里面,代表行数量的E等于1的时候称之为“直接映射高速缓存”,E等于C/B即一个组包含所有行的时候称之为“全相联高速缓存”,当1>E>C/B即缓存行数介于这之间时称之为“组相联高速缓存”。由于CPU缓存的空间一般很小,内存数据映射到CPU缓存的算法必然将导致有很多不同的数据将被映射放置到相同的缓存行,这种访问同一个缓存行的不同数据就将导致缓存不命中,需要重新到下一级缓存或内存加载数据来替换掉原来的缓存,这种不命中称之为“冲突不命中”,如果这种冲突不命中持续产生,我们将之称之为“抖动”。很显然,直接映射高速缓存每一组只有一行所以这种“抖动”将可能是很频繁的,而这显然也不是最高的缓存设计方案。而“全相联高速缓存”虽然能最大限度的解决这种“抖动”但是由于行数太多想要CPU能够快速的在比较大的缓存中匹配出想要的数据也是非常困难的,而且代价昂贵。所以它只适合做小的高速缓存。最后只有“组相联高速缓存”才是我们最佳的方案。
?
在上面CPU-Z的截图中,我的CPU缓存就是采用的组相联高速缓存,L1/L2后面的8-way说明它们每一组有8行,L3有12行,L1 d/L1 i的缓存总大小都是是32KB(注意前面有个乘以2 其实就是指有两个核心),L2的缓存大小是256KB....

一般缓存行的大小是64个字节(不包含有效位和标记位),即B等于64,其实我的这个笔记本也是,这在上面CPU-Z的第二张图中可以看到,这些信息还可以通过CoreInfo工具或者如果我们用Java编程,还可以通过CacheSize API方式来获取Cache信息, CacheSize是一个谷歌的小项目,java语言通过它可以进行访问本机Cache的信息。示例代码如下:

public static void main(String[] args) throws CacheNotFoundException {
CacheInfo info = CacheInfo.getInstance();
CacheLevelInfo l1Datainf = info.getCacheInformation(CacheLevel.L1, CacheType.DATA_CACHE);
System.out.println("第一级数据缓存信息:"+l1Datainf.toString());
   CacheLevelInfo l1Instrinf = info.getCacheInformation(CacheLevel.L1, CacheType.INSTRUCTION_CACHE);
System.out.println("第一级指令缓存信息:"+l1Instrinf.toString());
}

 

打印结果如下:

第一级数据缓存信息:CacheLevelInfo [cacheLevel=L1, cacheType=DATA_CACHE, cacheSets=64, cacheCoherencyLineSize=64, cachePhysicalLinePartitions=1, cacheWaysOfAssociativity=8, isFullyAssociative=false, isSelfInitializing=true, totalSizeInBytes=32768]
?
第一级指令缓存信息:CacheLevelInfo [cacheLevel=L1, cacheType=INSTRUCTION_CACHE, cacheSets=64, cacheCoherencyLineSize=64, cachePhysicalLinePartitions=1, cacheWaysOfAssociativity=8, isFullyAssociative=false, isSelfInitializing=true, totalSizeInBytes=32768]

显然这是和实际相符的,cacheSets表面有64组,cacheCoherencyLineSize表明缓存行的大小为64字节,cacheWaysOfAssociativity表示一组里面有8个缓存行,totalSizeInBytes就是整个缓存行的大小32KB,L1数据/指令缓存大小都为:C=B×E×S=64×8×64=32768字节=32KB。

CPU缓存的读写与缓存一致性协议 首先是读,CPU执行一条读内存字w的指令时是从上往下依次查找的,下层查找到包含字w的缓存行之后,再由下层将该缓存行返回给上一层高速缓存,上一层高速缓存将这个缓存行放在它自己的一个高速缓存行中之后,继续返回给上一层,直到到达L1。L1将数据行放置到自己的缓存行之后,从被存储的缓存行中抽取出CPU真正需要的字w,然后将它返回给CPU。大概就是高速缓存确定一个请求是否命中,然后1)组选择;2)行匹配;3)字抽取。

这里面有一个很重要的地方就是,CPU缓存在不命中的时候,向下层缓存请求的时候,返回的数据是以一个缓存行为单位的,并不是只返回给你想要的单个字,另外当出现不命中冲突的时候,会执行相应的替换策略进行替换。

最后,关于写分为两种请况:

      1.要写一个已经缓存了的字w,即写命中:首先更新本级缓存的w副本之后,怎么更新它的下一级缓存?最简单是“直写”,即立即将包含w的高速缓存行写回到第一层的缓存层, 这样做虽然简单,但是你知道CPU每时每刻可能都在进行写数据,如果大家都不停的写势必会产生很大的总线流量,不利于其他数据的处理;另一种方法称为“写回”,尽可能的推迟更新,只有当替换策略需要替换掉这个更新过的缓存行时才把它写回到紧接着的第一层的缓存中,这样总量流量减少了,但是增加了复杂性,高速缓存行必须额外的维护一个“修改位”,表明这个高速缓存行是否被修改过。
?
    2.写一个不在缓存中的字,即写不命中:一种是写分配,就是把不命中的缓存先加载过来,然后再更新整个缓存行,后面就是写命中的处理逻辑了;另一种是非写分配,直接把这个字写到下一层。

缓存一致性协议MESI

说到CPU缓存的写操作还有一个很重要的话题,那就是缓存一致性协议MESI。关于缓存一致性协议及其变种又是另一个繁杂的内容,而MESI其实仅仅是众多一致性协议中最著名的一个,其名字的得名也来至于该协议中对四种缓存状态的缩写简称,缓存一致性协议规定了如何保证缓存在各个CPU缓存的一致性问题:

以MESI协议为例,每个Cache line有4个状态,可用2个bit表示,它们分别是:

M(Modified):这行数据有效,但数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。
?
E(Exclusive):这行数据有效,数据和内存中的数据一致,数据只存在于本Cache中。
?
S(Shared):这行数据有效,数据和内存中的数据一致,数据存在于很多Cache中。
?
I(Invalid):这行数据无效。

关于缓存一致性协议,由于其又是一个比较繁多的内容,我这里仅仅粗略的说一下我的理解,总之它是一种保证数据在多个CPU缓存中一致的手段,至于到底是什么样的手段,根据各个CPU厂商采用的一致性协议的不同而不同,以我目前的了解,主要有以下几种:

1. 当CPU在修改它的缓存之前,会通过最后一级缓存L3(因为最后一级缓存是多核心共享的)或者总线(多CPU跨插槽的情况)广播到其他CPU缓存,使其它存在该缓存数据的缓存行无效,然后再更改自己的缓存数据,并标记为M,当其他CPU缓存需要读取这个被修改过的缓存行时(或者由于冲突不命中需要被置换出去时),会导致立即将这个被修改过的缓存行写回到内存,然后其他CPU再从内存加载最新的数据到自己的缓存行。
?
2. 当CPU缓存采用“直写”这种一更改马上写回内存的方式更新缓存的时候,其他CPU通过嗅探技术,从总线上得知相关的缓存行数据失效,则立即使自己相应的缓存行无效,从而再下次读不命中的时候重新到内存加载最新的数据。
?
3. 当CPU修改自己的缓存行数据时,主动将相关的更新通过最后一级缓存L3或者总线(如果是多CPU跨插槽的情况)发送给其它存在相关缓存的CPU,使它们同步的更新自己的缓存到一致。

总之,达到CPU缓存一致性的手段层出不穷,并且通过以上3种方式,可以看到在处理缓存一致性问题的时候,如果是单CPU多核心处理器,那么总是免不了使用最后一级缓存L3来传递数据,而这还不是最糟糕的,当多处理器跨插槽的时候,数据还要穿过总线跨插槽进行传输以保证缓存一致性, 这对性能将是更严峻的考验。这种CPU缓存一致性带来的问题将是我们在文章开始提出的“伪共享”的根本所在,具体讲在下一章节进行说明

02 内核与虚拟内存

电脑或手机开机以后,上电跑启动代码,运行OS内核,内核里也有线程,这个我们把它叫做内核态。

内核启动以后, 内核将物理内存管理起来。内核提供虚拟内存管理机制给每个进程(应用程序App)内存服务。

它的思路是什么呢?每个进程(应用App) 都有自己的虚拟内存空间,注意这里的空间只是一个数字空间,没有划分实际的物理内存。

这样做的好处是多个进程(应用App)内存都是独立的相互不影响,物理内存只有一个,多个进程(应用App)不会因为直接使用物理内存而冲突。

那么OS是如何管理物理内存的呢?进程(应用App)需要内存的时候,OS分配一块虚拟内存(起点—终点),然后OS再从自己管理的物理内存里面分配出来物理内存页,然后通过页表或者段表,将分配的虚拟内存与物理内存映射起来,这样,读写虚拟内存地址最终通过映射来使用物理内存地址,这样每个进程之间的内存是独立的,安全的。每个进程会把虚拟内存空间分成4个段(代码段, 数据段,堆,栈)

1页表与物理内存映射关系

技术图片

2段与物理内存映射关系

技术图片

3页表加段表与物理内存映射关系

技术图片

 

代码段:用来存放进程(应用App)的代码指令。
?
数据段:用来存放全局变量的内存。
?
堆:调用os的malloc/free 来动态分配的内存。
?
栈:用来存放局部变量,函数参数,函数调用与跳转。

每个进程(应用App)相当于一个容器,所有应用App里面需要的资源和机制都在进程里面。

线程是OS独立调度执行的单元,OS调度执行的单位就是线程,线程需要以进程作为容器和使用进程相关的环境。

应用态没有进程就不会有线程。

内存分配原理

内存分配策略

关于内存分配,通常有2种比较常用的分配策略:可变大小分配策略、固定尺寸分配策略。

可变大小分配策略,关键就在用他们的通用性上,通过他们,用户可以向系统申请任意大小的内存空间,显然,这样的分配方式很灵活,应用也很广泛,但是他们也有自己致命的缺陷,不过,对于我们来说,影响最大的大约在2个方面: 1、为了满足用户要求和系统的要求,不得不做一些额外的工作,效率自然就会有所下降;2、在程序运行期间,可能会有频繁的内存分配和释放动作,利用我们已有的数据结构和操作系统的知识,这样就会在内存中形成大量的、不连续的、不能够直接使用的内存碎片,在很多情况下,这对于我们的程序都是致命的。如果我们能够每隔一段时间就重新启动系统自然就没有问题,但是有的程序不能够中断,就算是能够中断,让用户每隔一段时间去重起系统也是不现实的(谁还敢用你做的东东?)

固定尺寸分配策略,这个策略的关键就在于固定,也就是说,当我们申请内存时,系统总是为我们返回一个固定大小(通常是2的指数倍)的内存空间,而不管我们实际需要内存的大小。和上面我们所说的通用分配策略相比,显得比较呆板,但是速度更快,不会产生太多的细小碎片。

一般情况下,可变大小分配策略和固定尺寸分配策略经常共同合作,例如,分配器会有一个分界线M,当申请的内存大小小于M的时候,就采用固定尺寸分配,当申请的大小大于M的时候就采用可变大小的分配。其实,在SGI STL里面就是采用的这种混合策略,它采用的分界线是128B,如果申请的内存大小超过了128B,就移交第一级配置器处理,如果小于128B,则采用内存池策略,维护一个16个free-lists的小额区块。

内存服务层次

内存分配有很多种策略,那么我们怎么知道是谁负责内存分配的呢? 内存的分配服务和存储结构一样,也是分层次的: 第一层,操作系统内核提供最基本服务,这是内存分配策略的基础,也是和硬件系统联系最紧密的,所以说不同的平台这些服务的特点也是不一样的。 第二层,编译器的缺省运行库提供自己的分配服务,new/malloc提供的就是基于本地的分配服务,具体的服务方式要依赖于不同的编译器,编译器的服务可以只对本地分配服务做一层简单的包装,没有考虑任何效率上的强化,例如,new就是对malloc的一层浅包装。编译器的服务也可以对本地服务进行重载,使用去合理的方式去分配内存。 第三层,标准容器提供的内存分配服务,和缺省的运行库提供的服务一样,他也可以简单的利用编译器的服务,例如,SGI中的标准配置器allocator,虽然为了符合STL标准,SGI定义了这个配置器,但是在实际应用中,SGI却把它抛弃了。也可以对器进行重载,实现自己的策略和优化措施。,例如,SGI中使用的具有此配置能力的SGI空剑配置器。 最外面的一层,用户自定义的容器和分配器提供的服务,我们可以对容器的分配器实施自己喜欢的方案,也可以对new/delete重载,让他做我们喜欢的事情。

内存分配的开销

内存的开销主要来自两部分:维护开销、对齐开销。

1、维护开销

在可变大小的分配策略下,在分配的时候,会采用一定的策略去维护分配和释放内存空间的大小,例如,在VC6下面,就会在分配的内存块其实位置放一个Cookie,,当进行delete的时候,指针前移4个字节,读出内存大小size,然后释放size+4的空间,我们可以用下面的小程序进行简单的测试:    

#include 
    using namespace std;
    class A
    {
    public:
       A() {cout"A"endl;}
       int i;
        ~A() {cout"~A"endl;}
    };
    int main()
    {
     A* pA=new A[5];
     int* p=(int*)pA;
     *(p-1)=1;
     delete []pA;
     return 0;
    }

对于固定大小分配策略,因为已经知道内存块的确定大小,自然就不需要这方面的开销。

2、对齐开销

很多的平台都要求数据的对齐,在数据的间隙或尾端进行填充,我们可以利用sizeof进行测试:

  struct A
      {
          char c1;
          int i;
          char c2;
      };

 

在我们进行如下运算的时候,我们可能会发现sizeof(A)=12,但是char只是占用1个字节,int占用4个字节,加起来也不过6个字节,怎么会多了一倍呢?     

这就是对齐现象在起作用,实际占用的空间是这3个变量都占用4个字节,在每一个char型的末尾都会填充3个字节的0。那你把char c2和 int i交换位置,看看结果是多少?怎么解释呢?     

初看起来,只是一种浪费,为什么会有这个特点呢?目的很简单,就是要使bus运输量达到最大。

03 进程与线程

上面说过进程是容器,应用态的线程必须要基于进程来创建出来。

那么进程与线程他们之间到底是一个什么样的关系,接下来我们来分析一下。

技术图片

例如"在桌面上双击打开一个App", 桌面App程序会调用OS的系统调用接口fork,让OS 创建一个进程出来,OS为你准备好进程的结构体对象,将这个App的文件(xxx.exe, 存放编译好的代码指令)加载到进程的代码段,同时OS会为你创建一个线程(main thread), 在代码里面,还可以调用OS的接口,来创建多个线程,这样OS就可以调度这些线程执行了。

虚拟内存空间是进程的概念,那么线程如何使用的呢?各线程使用共享进程的代码段,数据段,堆,每个线程在进程的栈空间创建一个属于自己的栈空间。

04 OS如何调度线程的

CPU一般会有多个核心,每个核心都调度一个线程执行。

CPU有几个核心,最多同时可调度几个线程(多核能让电脑更快就是这个原理)。

OS的功能就是要在合适的时候分配CPU核心来调度合适的线程。

为了能实现多任务并发,OS不允许一个OS核心长期固定调度一个线程。

OS是如何调度CPU核心来执行各个线程呢?

OS会根据线程的优先级分配每次调度最多执行的时间片,这个时间一到,无论如何都要重新调度一次线程(也许还是调度到这个线程,这个不重要)。
?
除了时间片以外,线程会等待某些条件(磁盘读取文件,网卡发送完数据,线程休眠, 等待用户操作)这样也会把这个线程挂起,OS会重新找一个新的线程继续执行,只到挂起的这个线程的条件满足了,重新把这个线程放到可调度队列里面,这个线程又有机会被OS调度CPU核心来执行。
当我们打开电脑的任务管理器,你会发现很多线程的CPU占有率为0%, 说明这些线程都由于某些条件而挂起了,没有被OS调度。
每个线程“随时随地”都可能被OS中断执行,并调度到其它的线程执行。

OS是如何保证一个线程在调度出去后,再重新调度回来能继续之前的数据状态来执行呢?

OS是这么做的:每个线程都会有一个运行时的环境(运行时CPU的每个寄存器的值、栈独立。栈的内存数据不会变。数据段、堆共用,可能调度回来会变)。
?
当OS要把某个CPU核心调度出去给其它线程的时候,首先会把当前线程的运行环境(寄存器的值等)保存到内存,然后调度到其它线程,等再次调度回来的时候,再把原来保存到内存的寄存器的值,再设置会CPU核心的寄存器里面,这样就回到了调度出去之前的进度。
?
因为多线程之间共用了代码段(代码段只读,不会改),数据段(全局变量调度回来后,可能被其它线程篡改,不是调度之前的那个值了),堆(调度回来后,动态内存分配的对象内存数据可能被其它线程出篡改),调度回来后,栈上的数据是不变的,因为每个线程都有自己的栈空间。线程调度前后哪些会变,哪些不变你要清楚。这样你写多线程代码的时候才能清晰。
?
线程调度的开销就是:保存上下文执行环境,内核态运行算法决定接下来调度那个线程,切换这个线程的上下文环境。

05 线程锁的核心原理是什么?

多线程切换的时候,栈、代码段的数据不会变,数据段与堆的数据切换前后可能会发生改变,这个就造成了"竞争", 如果某些关键数据,在执行代码的时候,不允许这种竞争性的改变,怎么办呢?这个时候多线程就给了一个机制,这个机制就是锁,那么锁的原理是什么?接下来我来这你详细讲解。

例如: 我编写一个函数

void funcA() {
   lock(锁);   // 要保护的数据的逻辑部分。
   unlock(锁);
}

当线程A调用FuncA(),线程B也调用FUNCA(),OS如何设计锁能保证他们竞争的唯一性的呢?我们把具体过程来分析一下。

假设线程A调用funcA();它获取了锁,执行到中间某个代码的时候,时间片用完了,被OS调度出去,OS调度线程B来执行funcA(), 当线程B跑到lock(锁),发现这个锁已经被线程A拿了,此时,线程B会主动把自己挂起到锁这个“事件”上(等着锁释放)
?
OS从新调度线程执行,当重新调度到线程A的时候,线程A执行,执行完成以后,释放掉这个锁,那么线程B又从等待这个锁的队列,到线程调度的就绪队列,又可被OS调度到,等线程A调度出去后,线程B去lock这个锁,就占用了这个锁,然后继续执行。这样就保证了lock/unlock之间的代码永远只有一个线程跑进去了。这样保护了这段代码里面相关的数据和逻辑。

所以这样就得到一些结论如下:

每个线程共享进程的代码段内存空间,所以我们编写多线程代码的时候,可以在任何线程调用任何函数。
?
每个线程共享进程的数据段内存空间,所以我们编写多线程代码的时候,可以在任何线程访问全局变量。
?
每个线程共享进程的堆,所以我们编写多线程代码的时候,可以在一个线程访问另外一个线程new/malloc出来的内存对象。
?
每个线程都有自己的栈的空间,所以可以独立调用执行函数(参数,局部变量,函数跳转)相互之间不受影响。

由于共享资源可以被不同的线程访问从而引发了多线程数据一致性问题

多线程

01线程的生命周期

技术图片

线程池

又以上介绍我们可以看出,在一个应用程序中,我们需要多次使用线程,也就意味着,我们需要多次创建并销毁线程。而创建并销毁线程的过程势必会消耗内存。而在Java中,内存资源是及其宝贵的,所以,我们就提出了线程池的概念。

线程池:Java中开辟出了一种管理线程的概念,这个概念叫做线程池,从概念以及应用场景中,我们可以看出,线程池的好处,就是可以方便的管理线程,也可以减少内存的消耗。

Java中已经提供了创建线程池的一个类:Executor

而我们创建时,一般使用它的子类:ThreadPoolExecutor

public ThreadPoolExecutor(int corePoolSize,  
                             int maximumPoolSize,  
                             long keepAliveTime,  
                             TimeUnit unit,  
                             BlockingQueueRunnable> workQueue,  
                             ThreadFactory threadFactory,  
                             RejectedExecutionHandler handler)

这是其中最重要的一个构造方法,这个方法决定了创建出来的线程池的各种属性,下面依靠一张图来更好的理解线程池和这几个参数:

技术图片

线程池参数

线程池中的corePoolSize就是线程池中的核心线程数量,这几个核心线程,只是在没有用的时候,也不会被回收,maximumPoolSize就是线程池中可以容纳的最大线程的数量,而keepAliveTime,就是线程池中除了核心线程之外的其他的最长可以保留的时间,因为在线程池中,除了核心线程即使在无任务的情况下也不能被清除,其余的都是有存活时间的,意思就是非核心线程可以保留的最长的空闲时间,而util,就是计算这个时间的一个单位,workQueue,就是等待队列,任务可以储存在任务队列中等待被执行,执行的是FIFIO原则(先进先出)。threadFactory,就是创建线程的线程工厂,最后一个handler,是一种拒绝策略,我们可以在任务满了知乎,拒绝执行某些任务。

线程池的执行流程又是怎样的呢?

技术图片

有图我们可以看出,任务进来时,首先执行判断,判断核心线程是否处于空闲状态,如果不是,核心线程就先就执行任务,如果核心线程已满,则判断任务队列是否有地方存放该任务,若果有,就将任务保存在任务队列中,等待执行,如果满了,在判断最大可容纳的线程数,如果没有超出这个数量,就开创非核心线程执行任务,如果超出了,就调用handler实现拒绝策略。

handler的拒绝策略:

第一种AbortPolicy:不执行新任务,直接抛出异常,提示线程池已满
第二种DisCardPolicy:不执行新任务,也不抛出异常
第三种DisCardOldSetPolicy:将消息队列中的第一个任务替换为当前新进来的任务执行
第四种CallerRunsPolicy:直接调用execute来执行当前任务

四种常见的线程池:

CachedThreadPool:可缓存的线程池,该线程池中没有核心线程,非核心线程的数量为Integer.max_value,就是无限大,当有需要时创建线程来执行任务,没有需要时回收线程,适用于耗时少,任务量大的情况。
?
SecudleThreadPool:周期性执行任务的线程池,按照某种特定的计划执行线程中的任务,有核心线程,但也有非核心线程,非核心线程的大小也为无限大。适用于执行周期性的任务。
?
SingleThreadPool:只有一条线程来执行任务,适用于有顺序的任务的应用场景。
?
FixedThreadPool:定长的线程池,有核心线程,核心线程的即为最大的线程数量,没有非核心线程

CPU、内存、进程、线程原理

标签:广播   问题   process   char   mes   initial   dram   矩阵   很多   

原文地址:https://www.cnblogs.com/lpc-work/p/14590605.html


评论


亲,登录后才可以留言!