《程序员的自我修养》—— 一、简介:简单的计算机发展史、系统软件、操作系统、线程一些基础知识

       该分栏博客为本人读完《程序员的自我修养》后一些简单而浅显的理解,大部分为书上摘录,自认为重要的一些基础知识简单的总结,为以后的学习与复习使用,如有错误还望指正。

为什么读这本书?读完这本书后会收获什么?
       读这本书是为了更好的了解系统软件的运行机制和原理,明白在Windows和Linux两个系统平台下,一个应用程序在编译、链接和运行时刻所发生的各种事项。

一、简单的计算机发展史

       早期的计算机没有很复杂的图形功能,CPU的核心频率也不高,跟内存的频率一样,它们都是直接连接在同一个总线的,后来由于CPU核心频率提升,导致内存跟不上CPU的速度,于是产生了与内存频率一致的系统总线,CPU采用倍频的方式与系统总线进行通信。紧接着图形化的操作系统普及,使得图形芯片需要跟CPU和内存之间大量交换数据,慢速的I/O总线已经无法满足图形设备的巨大需求。因此后来人们设计来了北桥芯片南桥芯片。再后来人们希望计算机越来越快,基本上每18个月频率就会翻倍直到达到物理极限,于是又从CPU的数量上下功夫,出现了多对称处理器(SMP,Symmetrical Multi-Processing)多核处理器,逻辑上SMP与多核是完全相同的,只是多核与SMP在缓存共享上有细微差别。
北桥芯片:为了协调CPU、内存和高速的图形设备之间能够高速地交换数据。
南桥芯片:北桥运行的速度非常高,若所有低俗设备连接在北桥上会使设计十分复杂,因此又设计了专门处理低速设备的南桥芯片。磁盘、USB、键盘、等设备连接在南桥芯片,由南桥芯片将他们汇总后连接到北桥上。
多对称处理器:每个CPU在系统中所处的地位和所发挥的功能都是一样的,是相互对称的。
多核处理器:将多个处理打包在一起,被打包的处理器之间共享较昂贵的缓存部件,只保留多个核心,并且以一个处理器的外包装进行出售,售价也比单核处理器只贵一点。
 

二、系统软件

系统软件:一般将用于管理计算机本身的软件称为系统软件,以区别普通的应用程序。 系统软件可分为平台性与用于程序开发的。
在这里插入图片描述
接口:系统软件体系结构中用于通信的协议。接口下面那层是接口的提供者,定义接口;接口上面那层是接口的使用者,使用该接口来实现所需要的功能。

软件体系结构:
在这里插入图片描述
       最上层为应用程序:如网络浏览器、Email客户端、多媒体播放器、图片浏览器等。开发工具与应用程序同属于最上层层次,他们都使用同一个接口:操作系统应用程序编程接口。应用程序接口的提供者是运行库,什么样的运行库提供什么样的API如:Linux下的Glibc库提供POSIX的API;Windows的运行库提供Windows API。
       运行库使用操作系统的系统调用接口系统调用接口在实现中往往以软件中断的方式提供,如Linux使用0x80中断作为系统调用接口,Windows使用0x2E中断作为系统调用接口。
       操作系统内核层对于硬件层来说是硬件接口的使用者,而硬件是接口的定义者,硬件的接口定义决定了操作系统内核,具体讲就是驱动程序如何操作硬件,如何与硬件进行通信。
 

三、操作系统

操作系统的功能:提供抽象的接口与管理硬件资源。
在这里插入图片描述
       一个计算机中的资源主要分为:CPU、存储器(包括内存和磁盘)和I/O设备,如何去高效利用它们呢?
       计算机发展初期,CPU资源十分昂贵,如果一个CPU只能运行一个程序,那么当程序读写磁盘时,CPU就空闲下来造成资源浪费。于是人们编写了一个监控程序,当某个程序无须使用CPU时,监控程序就把另外的正在等待CPU资源的程序启动,使得CPU能够充分利用起来,这就被称为多道程序。不过这种原始的多道程序技术存在的最大问题是程序之间的调度策略太粗糙,程序之间不分轻重缓急。经过改进,程序运行模式变成了一种协作的模式,每个程序运行一段时间以后都主动让出CPU给其他程序,使得一段时间内每个程序都有机会运行一小段时间,这种程序协作模式叫做分时系统。再后来,出现了多任务系统操作系统接管了所有的硬件资源,并且本身运行在一个受硬件保护的级别。所有的应用程序都以进程的方式运行在比操作系统权限更低的级别,每个进程都有自己独立的地址空间,使得进程之间的地址空间相互隔离。CPU由操作系统进行统一分配,每个进程根据进程优先级的高低都有机会得到CPU,但如果运行时间超过了一定时间,操作系统就会暂停该进程,将CPU资源分配给其他等待运行的进程,这种CPU的分配方式就是抢占式操作系统可以强制剥夺CPU资源并且分配给它认为目前最需要的进程。
       设备驱动: 操作系统作为硬件层的上层,它是对硬件的管理与抽象。对操作系统上面的运行库和应用程序来说,它们希望看到的是一个统一的硬件访问模式;作为程序的开发者,我们又不希望在开发应用程序的时候直接读写硬件端口、处理硬件中断等这些繁琐的事情。经过发展成熟的操作系统出现后,硬件逐渐被抽象成一系列概念。在UNIX种,硬件设备的访问形式跟访问普通文件形式一样;在Windows系统种,图形硬件被抽象成了GDI,声音和多媒体设备被抽象成了DirectX对象;磁盘被抽象成了普通文件系统等等,程序员逐渐从硬件细节中解放出来,可以更多地关注应用程序本身的开发,这些繁琐点硬件细节全部交给了操作系统中的硬件驱动来完成。驱动程序可以可以看作是操作系统的一部分,它往往跟操作系统内核一起运行在特权级,但它又与操作系统内核间有一定的独立性,使得驱动程序有比较好的灵活性。因为PC的硬件多如牛毛,操作系统开发者不可能为每个硬件开发一个驱动程序,这些驱动程序的开发工作通常由硬件生产厂商完成。操作系统开发者为硬件生产厂商提供了一系列接口和框架,凡是按照这个接口和框架开发的驱动程序都可以在该操作系统上使用。
       文件系统: 文件系统为操作系统中最重要的组成之一,文件系统管理着磁盘中文件的存储方式,例:我们在Linux系统下有一文件"/home/user/test.dat",长度为8000个字节。那么我们在创建这个文件时,Linux的ext3文件系统有可能以下述方式将这个文件存储到磁盘中:文件的前4096字节存储在磁盘的1000号扇区到1007号扇区,每个扇区512字节,8个扇区刚好4096字节;文件的第4097个字节到第8000字节共3904字节存储在磁盘的2000号扇区到2007号扇区,8个扇区实际上也是4096个字节,只不过只存储了3904个有效的字节,剩下的192个字节无效。如果将这个文件的存储方式看作为一个链状结构,如图:
在这里插入图片描述
文件系统保存了这些文件的存储结构,负责维护这些数据结构并且保证磁盘中的扇区能够有效地组织和利用。 当我们在Linux操作系统中,要读取这个文件的前4096个字节时,会使用一个read的系统调用来实现。文件系统收到read请求之后,判断出文件的前4096个字节位于磁盘的1000号逻辑扇区到1007号逻辑扇区,然后文件系统就向硬盘驱动发出一个读取逻辑扇区为1000号开始的8个扇区的请求,磁盘驱动程序收到这个请求以后就向硬盘发出硬件命令。在硬盘收到这个命令以后,它就会执行相应的操作,并且将数据读取到事先设置好的内存地址中。
 

四、内存问题

       早期的计算机中,程序是直接运行在物理内存上的,程序运行时所访问的地址都是物理地址。当然,如果一个计算机同时只运行一个程序,只要程序要求的内存空间不超过物理内存的大小,就不会有问题。但事实上为了更有效地利用硬件资源,我们必须同时运行多个程序,同时CPU的利用率将会比较高,就要求我们如何将计算机上有限的物理内存分配给多个程序使用
       先来看一个例子:我们的计算机有128MB内存,程序A运行需要10MB,程序B运行需要100MB,程序C运行需要20MB。如果我们需要同时运行程序A和B,那么将内存前10MB分配给程序A,10MB—110MB分配给B即可完成。但是这样会给我们带来许多问题:
       ● 地址空间不隔离:所有程序都是直接访问物理地址,程序所使用的内存空间不是相互隔离的。恶意的程序可以很容易改写其他程序的内存数据,以达到破环目的。
       ● 内存使用效率低:由于没有有效的内存管理机制,通常需要一个程序执行时,监控程序就将整个程序装入内存中然后开始执行,可能会有大量的数据换入换出导致效率十分低下。
       ● 程序运行的地址不确定:程序每次需要装入运行时,都需要给它从内存中分配一块足够大的空闲区域,所以这个空闲区域的位置不确定,给程序的编写造成一定困扰。
       解决这些问题的法宝增加中间层,即使用一种间接的地址访问方法。把程序给出的地址看作是一种虚拟地址,然后通过某些映射的方法,将这个虚拟地址转换成实际的物理地址。 这样只要我们能够妥善地控制这个虚拟地址到物理地址的映射过程,就可以保证任意一个程序所能够访问的物理内存区域跟另外一个程序相互不重叠,以达到地址空间隔离的效果。
 
       隔离为普通程序运行时,提供一个简单的执行环境,让普通程序运行时有一个单一的地址空间、有自己的CPU,好像整个程序占有整个计算机而不用关系其他的程序(程序间通信的部分除外,这是程序主动要求跟其他程序通信和联系)。地址空间分为两种:虚拟地址空间和物理地址空间,通过使用虚拟地址空间来有效地做到了进程的隔离。
在这里插入图片描述
 
       分段最开始人们使用的是一种叫做分段的方法,把一段与程序所需要的内存空间大小的虚拟空间映射到某个地址空间。例如:程序A需要10MB内存,那么我们假设有一个地址从0x00000000到0x00A00000的10MB大小的一个假象的空间(即虚拟空间),然后我们从实际的物理内存中分配一个相同大小的物理地址,假设是物理地址0x00100000开始到0x00B00000结束的一块空间。然后我们把这两块相同大小的地址空间一一映射,即虚拟空间中的每个字节相对于物理空间中的每个字节。映射过程由软件来设置,比如操作系统来设置这个映射函数,实际的地址转换由硬件完成。比如当程序A中访问地址0x00001000时,CPU会将这个地址转换成实际的物理地址0x00101000。那么程序A和程序B在运行时,它们的虚拟空间和物理空间映射关系可能如图所示:
在这里插入图片描述
       分段的方法解决了地址空间不隔离与程序运行的地址不确定问题。 首先它做到了地址隔离,因为程序A和程序B被映射到了两块不同的物理空间区域,它们之间没有任何重叠,如果程序A访问虚拟空间的地址超出了0x00A00000这个范围,那么硬件就会判断这是一个非法的访问,拒绝这个地址请求,并将这个请求报告给操作系统或监控程序,由它来决定如何处理。其次,对于程序来说无论它们被分配到物理地址的哪一个区域,对于程序来说都是透明的,它们不需要关心物理地址的变化,它们只需要按照从地址0x00000000到0x00A00000来编写程序、放置变量;所以程序不需要再重定位。 但是分段的方法还是没有解决内存使用效率的问题,分段对内存区域的映射还是按照程序为单位,如果内存不足,被换入换出到磁盘的都是整个程序,势必会造成大量的磁盘访问操作,从而严重影响速度,这种方法还是显得粗糙,粒度比较大。因此人们想到了更小粒度的内存分割和映射的方法,使得程序的局部性原理得到充分的利用,大大提高了内存的使用率,即分页。
 
       分页把地址空间人为地等分成固定大小的页,每一页的大小由硬件决定,或硬件支持多种大小的页,也有操作系统选举决定页的大小。页是固定大小的,目前几乎所有的PC上的操作系统都使用4KB大小的页。若使用的PC机是32位的虚拟地址空间,即4GB,那么以4KB每页分的话,总共由1048576个页,物理空间也是同样的分法。
       下面我们举一个例子:
在这里插入图片描述
       如图,每个虚拟空间有8页,每页大小为1KB,虚拟空间大小即为8KB。假设该计算机有13条地址线,即拥有2^13的物理寻址能力,理论上物理空间可以多达8KB,但由于资金不够只买了6KB的内存,所以真正有效的物理空间只有前6KB。
       当我们把进程的虚拟地址空间按页分割,把常用的数据和代码页装载到内存中,不常用的代码和数据保存在磁盘里,需要时取出即可。 假设有两个进程Process1和Process2,它们进程中的部分虚拟页面被映射到了物理页面,比如VP0、VP1和VP7映射到PP0、PP2和PP3;而有部分页面却在磁盘中,如VP2和VP3位于磁盘的DP0和DP1中;另外还有一些页面如VP4、VP5和VP6可能尚未被用到或访问到,他们暂时处于未使用的状态。这里,我们把虚拟空间中的页叫虚拟页,物理内存中的页叫做物理页,把磁盘中的页叫做磁盘页。我们看到虚拟空间的有些页被映射到同一个物理页,这样就可以实现内存共享。图中Process1的VP2和VP3不在内存中,但是当进程需要用到这两个页的时候,硬件会捕获到这个消息,就是所谓的页错误,然后操作系统接管进程,负责将VP2和VP3从磁盘中读出来并且装入内存,然后将内存中的这两个页与VP2和VP3之间建立映射关系。
       虚拟存储的实现需要依靠硬件的支持,对于不同CPU来说是不同的。几乎所有的的硬件都采用一个叫MMU(Memory Management Unit)的部件进行页映射。如图:
在这里插入图片描述
在页映射模式下,CPU发出的是Virtual Address,即我们的程序看到的是虚拟地址,经过MMU转换以后就变成了Physical Address。一般MMU都集成在CPU内部,不会以独立的部件存在。
 

五、线程问题

5.1线程基础

       线程(Thread)线程有时被称为轻量级进程,是程序执行流程的最小单元。 一个标准的线程由线程ID、当前指令指针(PC)、寄存器集合和堆栈组成。

       进程与线程的关系通常,一个进程由一到多个线程组成,各个线程之间共享程序的内存空间(包括代码段、数据段、堆等)及一些进程级的资源(如打开文件和信号)。下图为进程中包含的线程,是一个经典的线程与进程的关系图。
在这里插入图片描述
       使用多线程的优点及其原因:大多数软件应用中,线程的数量都不止一个。多线程可以互不干扰地并发执行,并共享进程的全局变量和堆中的数据,其主要与原因如下:
       ● 某个操作可能长时间等待,等待的线程会进入睡眠状态,无法继续执行。而多线程可以有效利用等待的时间。
       ● 某个操作(常常是计算)会消耗大量的时间,如果只有一个线程,程序和用户之间的交互会中断。而多线程可以让一个线程负责交互,另一个线程负责计算。
       ● 程序逻辑本身就要求并发操作。
       ● 多CPU或多核计算机,本身具备同时执行多个线程的能力,因此单线程程序无法全面地发挥计算机的全部计算能力。
       ● 相对于多进程应用,多线程在数据共享方面效率提高了许多。

        线程的访问权限线程的访问非常自由,可以访问进程内存中的所有数据,甚至包括其他线程的堆栈(如果它知道其他线程的堆栈地址,很少见),但实际运用中线程也拥有自己的私有存储空间。
       ● :(尽管并非完全无法被其他线程访问,但一般情况下仍然可认为是私有的数据)。
       ● 线程局部存储:线程局部存储是某些操作系统为线程单独提供的私有空间,但通常只具有很有限的容量。
       ● 寄存器(包括PC寄存器):寄存器是执行流的基本数据,因为为线程私有。从C程序员来看,线程是否私有取决于下表所示:
在这里插入图片描述
       线程调度与优先级:无论是在多处理器还是单处理器的计算机上,线程总是并发执行的。当线程数量小于等于处理器的数量时(操作系统支持多处理器),线程的并发是真正的并发,不同线程运行在不同的处理器上,互不相干。但对于线程数量远大于处理器数量的情况,线程并发会受到一些阻碍,因此此时至少有一个处理器运行多个线程。在单处理器对应多线程下,并发是一种模拟出来的状态。操作系统会让这些多线程程序轮流执行,每次仅执行一小段时间,这样每个线程看起来是在同时执行。这样的一个不断在处理器上切换不同的线程的行为称之为线程调度。线程调度中至少拥有三种状态:
       ●运行:此时线程正在执行
       ●就绪:此时线程可以立刻运行,但CPU已被占用
       ●等待:此时线程正在等待某一事件(通常为I/O或同步)发生,无法执行
       处于运行中的线程拥有一段可执行时间,这段时间称为时间片。*时间片用尽时,该进程将进入就绪状态。若在时间片用尽之前就开始等待某事件,那么它将进入等待状态。每当一个线程离开运行状态时,调度系统就会选择一个其他的就绪线程继续执行。在一个处于等待状态的线程所等待的事件发生之后,该线程将进入就绪状态。(更多详细状态请查阅关于操作系统的书籍,这里只是简单总结)。其状态如图所示:
在这里插入图片描述
       线程调度方法主流的调度方式虽各不相同,但都带有优先级调度轮转法
轮转法:让各个线程轮流执行一小段时间,决定了线程之间交错执行的特点。
优先级调度法:在具有优先级调度的系统中,线程都拥有各自的线程优先级,具有高优先级会更早执行。低优先级的线程需等待系统中已无高优先级的线程存在时才可执行。
       线程优先级改变三种方式
       1.用户手动指定优先级:Linux下与线程相关的操作可通过pthread库来实现;Windows中可使用:BOOL WINAPI SetThreadPriority(HANDLE hThread, int nPriority);来设置线程优先级;
       2.根据等待状态的频繁程度提升或降低优先级:系统会根据不同线程的表现自动调整优先级以使得调度更有效率。例如:频繁进入等待状态的线程比频繁进行大量计算、以至于每次都要把时间片全部用尽的线程要受欢迎的多。频繁进入等待的线程通常只占用很少的时间,CPU也喜欢先捏软柿子。一般把频繁等待的线程称为IO密集型线程,而把很少等待的线程称为CPU密集型线程,IO密集型线程总是比CPU密集型线程容易得到优先级的提升。
       3.长时间得不到执行而被提升优先级在优先级调度下,存在一种饿死现象,一个线程被饿死,是说它的优先级较低,在它执行之前总是有较高级的线程试图执行,因此这个低优先级线程始终无法执行。 因此为了避免饿死现象,调度系统常常会逐步提升那些等待了过长时间得不到执行的线程的优先级。
可抢占线程:线程在用尽时间片之后会被强制剥夺继续执行的权利,而进入就绪状态。
不可抢占线程:在一些早期系统(Windows 3.1)里,线程是不可抢占的。线程必须手动发出一个放弃执行的命令,才能让其他的线程得到执行。这样的模式下,线程必须主动进入就绪状态,而不是靠时间片用尽来被强制进入。如果线程是中国拒绝进入就绪状态,并且也不进行任何的等待操作,那么其他线程将永远无法执行。线程主动放弃执行无非两种情况:
       ●当线程试图等待某事件时(I/O)等
       ●线程主动放弃时间片
       因此,不可抢占线程执行的时候,线程调度的时机是确定的,线程调度只会发生在线程主动放弃执行或线程等待某事件的时候。可以避免一些因为抢占式线程里调度时机不确定而产生的问题。

       Linux中的多线程:由于Windows内核有明确的线程和进程的概念。在Windows API中,可以明确的API:CreatProcess和CreatThread来创建进程和线程,并有一系列的API来操纵他们。而Linux内核中并不存在真正意义上的线程概念。Linux将所有的执行实体(无论进程还是线程)都称为任务,每一个任务概念上都类似于一个单线程的进程,具有内存空间、执行实体、文件资源等。Linux下不同的任务之间可以选择共享内存空间,实际上共享了同一个内存空间的多个任务构成了一个进程,这些任务也就构成了这个任务里的线程。Linux下可用如下方法创建一个新任务:
在这里插入图片描述
       fork函数产生一个和当前进程完全一样的新进程,并和当前进程一样从fork函数里返回。在fork函数调用之后,新的任务将启动并和本任务一起从fork函数返回。但不同的是本任务的fork将返回新任务的pid,而新任务的fork将返回0(具体更加详细的了解可以参考有关Linux相关的书籍)。如下代码:

pid_t pid;
if (pid = fork())
{
	...
}

fork产生新任务的速度非常快。fork并不复制原任务的内存空间,而是和原任务一起共享一个写时复制的内存空间:即两个任务可以同时自由地读取内存,但任意一个任务试图对内存进行修改时,内存就会复制一份提供给修改方单独使用,以免影响到其他的任务使用。 fork只能够产生本任务的镜像,因此还需要exec配合才能够启动别的新任务。exec可以用新的可执行映像替换当前的可执行映像,因此在fork产生了一个新任务之后,新任务可以调用exec来执行新的可执行文件。fork和exec通常用于产生新任务,如果要产生新线程,则可使用clone,原型:int clone(int (* fn)(void*), void* child_stack, int flags, void* arg) ;使用clone可以产生一个新任务,从指定的位置开始执行,并且共享当前进程的内存空间和文件等。
 

5.2线程安全

       多线程程序处于一个多变的环境中,可访问的全局变量和堆数据随时都可能被其他的线程改变。因此多线程程序在并发时数据的一致性变得非常重要。

5.2.1竞争与原子操作

       竞争与原子操作多个线程同时访问一个共享数据,可能会造成很恶劣的后果。 自增(++)操作在多线程环境下会出现错误,这个操作被编译为汇编代码之后不止一条指令。因此在执行的时候可能执行了一半就被调度系统打断去执行别的代码。我们把单指令的操作称为原子的,单条指令的执行是不会被打断的。为了避免出错,很多体系结构都提供了一些常用操作的原子指令。如Windows里专门有一套API进行一些原子操作,被称为Interlocked API。使用这些操作时不用担心出现问题。尽管原子操作指令非常方便,但它们仅适用于比较简单特定的场合。
在这里插入图片描述

5.2.2同步与锁

       同步与锁:为了避免多个线程同时读写同一个数据而产生不可预料的后果,需要将各个线程对同一个数据的访问同步。同步,即是指在一个线程访问数据未结束的时候,其他线程不得对同一个数据进行访问。 同步最常见方法是使用锁。锁是一种非强制机制,每一个线程在访问数据或资源之前首先试图获取锁,并在访问结束之后释放锁。在锁已经被占用的时候试图获取锁时,线程会等待知道锁重新可用。
       二元信号量是最简单的一种锁,只有两种状态:占用与非占用的。它适合只能被唯一一个线程独占访问的资源。当二元信号量处于非占用时,第一个试图获取该二元信号量的线程会获得该锁,并将二元信号量置为占用状态,此后其他的所有试图获取该二元信号量的线程将会等待,直到该锁被释放。
       多元信号量多元信号量简称信号量。一个初始值为N的信号量允许N个线程并发访问。线程访问资源的时候首先获取信号量,进行如下操作:
       ● 将信号量的值减1
       ● 如果信号量的值小于0,则进入等待状态,否则继续执行。访问完资源之后,线程释
       信放信号量,进行如下操作:
       ● 将信号量的值加1
       ● 如果信号量的值小于1,唤醒一个等待中的线程
       互斥量互斥量和二元信号量很类似,资源仅同时允许一个线程访问,但和信号量不同的是,信号量在整个系统可以被任意线程获取并释放,同一个信号量可以被系统中的一个线程获取之后由另一个线程释放。而互斥量则要求哪个线程获取了互斥量,哪个线程就要负责释放这个锁。
       临界区:比互斥量更加严格的同步手段。把临界区的锁的获取称为进入临界区,而把锁的释放称为离开临界区。临界区和互斥量与信号量的区别在于,互斥量和信号量在系统的任何进程里都是可见的,也就是说一个进程创建了一个互斥量或信号量,另一个进程试图去获取该锁是合法的。临界区的作用范围仅限于本进程,其他进程无法获取该锁。除此之外,临界区具有和互斥量相同的性质。
       读写锁:对于一段数据,多个线程同时读取总是没有问题的,但假设操作都不是原子型,只要有任何一个线程试图对这个数据进行修改,就必须使用同步手段来避免出错。如果我们使用上述信号量、互斥量或临界区来进行同步尽管可以保证程序正确,但对于读取频繁的情况则低效,读写锁可以解决这一问题。读写锁有两种获取方式:共享的或独占的锁处于自由的状态时,试图以任何一种方式获取锁都能成功,并将锁置于对应的状态。如果锁处于共享状态,其他线程以共享的方式获取锁仍然会成功,此时这个锁分配给了多个线程。然而,如果其他线程试图以独占的方式获取已经处于共享状态的锁,那么它将必须等待锁被所有的线程释放。处于独占状态的锁将阻止任何其他线程获取该锁。读写锁的行为可总结如下:
在这里插入图片描述
       条件变量:作为一种同步手段,作用类似于一个栅栏。对于条件变量,线程可以有两种操作,首先线程可以等待条件变量,一个条件变量可以被多个线程等待。其次,线程可以唤醒条件变量,此时某个或所有等待此条件变量的线程都会被唤醒并继续支持。使用条件变量可以让许多线程一起等待某个事件的发生,当事件发生时,所有的线程可以一起恢复执行。

5.2.3可重入与线程安全

       可重入一个函数被重入,表示这个函数没有执行完成,由于外部因素或内部调用,又一次进入该函数执行。可重入是并发安全的强力保障,一个可重入的函数可以在多线程环境下放心使用。一个函数要被重入,只有两种情况:
       (1) 多个线程同时执行这个函数
       (2) 函数自身(可能经过多层调用之后)调用自身
       一个函数被称为可重入的,表明该函数被重入之后不会产生任何不良后果。
       一个函数要成为可重入的,必须具有如下几个特点:
            ● 不使用任何(局部)静态或全局的非const变量
            ● 不返回任何(局部)静态或全局的非const变量的指针
            ● 仅依赖于调用方提供的参数
            ● 不依赖任何单个资源的锁(mutex)等
            ● 不调用任何不可重入的函数

5.2.4过度优化

       线程安全是一个非常烫手的山芋,即使合理使用了锁,也不一定保证线程安全。源于落后的编译器技术已经无法满足日益增长的并发需求,很多看似无错的代码在优化和并发面前又产生了麻烦。编译器会对我们的程序进行优化,不同的编译器有所区别,有时候编译器的优化以及CPU的乱序执行会给我们带来一些错误,尤其是多线程的时候。
       阻止过度优化volatile关键字volatile关键字用来阻止过度优化,volatile基本可以做到两件事情:
(1) 阻止编译器为了提高速度将一个变量缓存到寄存器内而不写回。
(2) 阻止编译器调整操作volatile变量的指令顺序。
       barrier:一条barrier指令会阻止CPU将该指令之前的指令交换到barrier之后,即barrier指令类似于一个拦水坝,阻止换序“穿透”该大坝(组织CPU乱序)。
 

5.3多线程内部情况

为什么要了解三种线程模型?
       线程的并发执行是由多处理器或操作系统来实现的。大多数操作系统,包括Windows和Linux都在内核里提供线程的支持,内核线程由多处理器或调度来实现并发。然而用户在实际使用的线程并不是内核线程,而是存在于用户态的用户线程。用户态线程并不一定在操作系统内核里对应同等数量的内核线程。下面我们将从三种情况来观察多线程内部:
(1)一对一模型对于直接支持线程的系统,一对一模型是最简单的模型。一个用户使用的线程就唯一对应一个内核使用的线程(但反过来不一定,一个内核里的线程在用户态不一定有对应的线程存在)。一般直接使用API或系统调用创建的线程均为一对一线程,如:Linux里使用clone产生的线程,Windows里使用API Create Thread也可创建一个一对一线程。
优点:用户线程就具有了和内核线程一致的优点,线程之间的并发是真正的并发,一个线程因为某原因阻塞时,其他线程执行不会受到影响。此外一对一模型也可以让多线程程序在多处理器的系统上有更好的表现。
缺点
①许多操作系统限制了内核线程的数量,因此一对一线程会让用户的线程数量受到限制。
②许多操作系统内核线程调度时,上下文切换的开销大,导致用户线程的执行效率下降。
在这里插入图片描述
(2)多对一模型: 将多个用户线程映射到一个内核线程上,线程之间的切换由用户态的代码来进行,相对于一对一模型,多对一模型的线程切换快速许多。
优点:拥有高效的上下文切换和几乎无限制的线程数量。
缺点:如果其中一个用户阻塞,那么所有的线程都将无法执行,此时内核里的线程也随之阻塞了。此外,在多处理器系统上,处理器的增多对多对一模型的线程性能也不会有明显的帮助。
在这里插入图片描述
(3)多对多模型: 结合了前两者的优点,将多个用户线程映射到少数但不止一个内核线程上。多对多模型中,一个用户线程阻塞并不会使得所有的用户线程阻塞,此时还有别的线程可以被调度来执行。多对多模型对用户线程的数量也没什么限制,在多处理器系统上,多对多模型的线程也能得到一定的性能提升。
在这里插入图片描述

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值