程序员的自我修养---链接、装载与库(学习笔记一)

一、温故而知新

        对于系统程序软件开发者而言,只需要关注的就是计算机最主要的三大部件中央处理器(CPU)内存(Memory)I/O接口(输入输出接口)

        早期计算机的CPU核心频率不高,基本跟内存频率是一样的,因此每一个I/O设备与CPU以及内存都是挂在一条总线(Bus)上的,为了协调I/O设备与总线之间的速度,也为了让CPU可以和I/O设备进行通信,一般每个设备都会有对应的I/O设备控制器。

        随着CPU核心频率的提高,导致内存跟不上CPU的速度,于是就产生了与内存频率一致的系统总线,而CPU采用倍频的方式与系统总线进行通信,随着图形化的操作系统的普及,使得图形化设备芯片需要与CPU以及内存进行大量的数据交互,为了协调CPU、内存和高速图形设备通信,人们专门设计了一个高速的北桥(Northbridge)芯片。

        由于北桥的运行速度很快,相对低速的设备如果都挂在北桥上,意味着北桥既需要处理高速设备也需要处理低速设备,进而导致设计上会很复杂。于是人们有设计了专门处理低速设备的南桥(Southbridge),将低速设备在南桥上汇总之后再连接到北桥上。20世纪90年代PC机的系统总线采用的是PCI结构,而低速设备采用的是ISA总线,具体PCI/ISA硬件架构如下。

        可以看到上面CPU与Memory以及一些高速设备(USB)都是通过PCI Bus连接、而一些低速设备(Sound Card)都是通过ISA总线进行汇总到ISA bridge之后与PCI总线进行相连。

SMP(对称多处理器)与多核处理器

        由于CPU工艺方面已经到达物理极限,除非CPU的工艺有本质上的突破,否则CPU的频率将会一直被目前4GHz的“天花板”所限制,既然没有办法通过提高CPU的速度来提高性能,那就只能通过增加CPU的数量来提升。其中最常见的方式就是多对称处理器,理论上每一个处理器所处的地位与所发挥的功能都是一样的下图所示。但是由于SMP的成本较高,一般都是用在大型数据库、网路服务器上,它们要同时处理大量的请求,而这些请求往往也是相互独立的,所以多处理器就可以最大效能的发挥威力。

        在个人PC上使用SMP成本太高,于是处理器厂商开始考虑将多个处理器合并在一起打包出售,这些被打包的处理器之间共享比较昂贵的缓存部件,最保留多个核心,并且以一个处理的外包进出售,售价比单核心的处理器只贵了一点点,这个就是多核心处理器(Multi-core Processor)如下图所示,多核处理器可以看成SMP的简化版,对于程序员来说,逻辑上它们是完全相同的,只是多核和SMP在缓存共享方面有细微差别,可以这样说,除非把CPU的每一滴油水都榨干,否则SMP和多核基本没有什么区别。

计算机系统软件体系结构

传统意义上一般将用于管理计算机本身的软件称为系统软件。

系统软件可以分为两块:

  • 平台性:操作系统内核、设备驱动、运行库以及数以千计的系统工具
  • 程序开发:编译器、汇编器、链接器等开发工具和开发库

计算机软件体系结构采用一种层的结构,有人说过一句名言: 

                “计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决

        上图中一共分为四个层次: 应用层、运行库、操作系统内核、硬件,每层之间都需要进行通信才可以将数据传递给最后的硬件,这个进行数据传递的就是接口(interface)。

        我们的软件体系中最上层的是应用程序,比如我们平时使用的QQ、微信以及王者荣耀。从整个层次结构来看,开发工具和应用程序是属于同一个层次的,因为它们都使用同一个接口--应用程序编程接口,应用程序接口的提供者是运行库,不同操作系统使用的运行库也不一样,例如Linux下的Glibc库提供POSIX库(可移植操作系统接口 Portable Operating System Interface of UNIX)Windows的运行库提供Windows API,最常见的32位Windows提供的API又被称为Win32。运行库使用操作系统提供的系统调用接口(system call interface),系统调用接口在实现中往往以软件中断(Software interrupt)的方式提供,比如Linux使用0x80中断号作为系统调用接口,Windows使用0x2E号中断作为系统调用接口。操作系统内核层对于硬件层来说是硬件接口的使用者,而硬件本身是接口的定义者,而这种硬件本身的接口往往被叫做硬件规格(Hardware Specification),操作系统和驱动程序开发工程师通过厂商提供的硬件规格来编写内核程序接口去操作硬件。

充分压榨CPU的油水

        在计算机发展前期,CPU资源十分昂贵,如果一个CPU只能运行一个程序,那么当程序读写磁盘时,CPU就空闲下来了,这无非就是对CPU资源的浪费,于是人们很快编写一个监控程序,当一个程序暂时不需要CPU的时候,监控程序就把另外一个需要CPU的程序启动,是CPU能够从分的利用起来,这种方法就被称为多道程序(Multiprogramming),但是这种方法存在的问题是调度策略态粗糙,程序之间没有轻重缓急,如果有些程序急需使用CPU资源来完成一些任务(用户交互任务),那么很有可能很长时间才有机会分配到CPU,因此对于一些响应优先级比较高的程序来说是非常致命的,想象一下你在Windows点击以下鼠标等了10分钟系统才有反应,那会是一件多么可怕的事情。

        对上面的方法稍微改进一下,即每个程序在CPU上运行一段时间之后,都会主动的让出CPU给其他程序使用,使得在一段时间内每个程序都有机会运行一小段,这样当你点击鼠标的时候就会很快可以得到系统回应,这种程序协作的模式叫做分时系统(Time-Sharing System)。而此前在高端领域出现了一种比分时系统更先进的操作系统模式叫做多任务系统(Multi-Tasking),操作系统接管了所有的硬件资源,所有的应用程序都以进程(Process)的方式运行在比操作系统权限更低的级别,每个进程都有自己的地址空间,使得进程之间的地址空间相互隔离。CPU由操作系统统一进行分配,每个进程根据进程优先级的高低都有机会分配到CPU,如果某个进程运行超出了一定时间,CPU会暂停该进程,将CPU分配给其他等待运行的进程,这种CPU分配方式就是所谓的抢占式(Preemptive),按照这种方法,如果操作系统分配给每个进程可以运行的时间都很短,即CPU可以在多个进程之间来回切换,从而造成很多进程都在同时运行的假象。目前一些主流的操作系统,Linux、UNIX、WindowsNT以及Mac OS X等流行的操作系统。

内存不够怎么办

        在早期的计算机中,程序是直接运行在物理地址上的,如果一个程序要求的内存大小不超过内存空间大小,那就不会有问题,但事实上为了有效利用硬件资源,我们必须同时运行多个程序,正如多道程序、分时系统和多任务一样,当我们能够同时运行多个程序时,CPU的利用率就会很高,那就会出现一个很明显的问题,如何将计算机上有限的物理内存分配给多个程序使用。

        假如我们计算机有128M内存,程序A运行需要10MB,程序B运行需要100MB,程序C运行需要20M,如果我们同时运行A和B,那么可以直接将内存的前10MB分配给A,10MB~110MB分配给B,这样就能实现A和B两个程序同时运行,但是这种简单的内存分配策略问题很多。

  • 地址空间不隔离 由于所有的程序都是直接访问物理地址,程序之间使用的内存空间不是相互隔离的,那么恶意的程序很容易修改程序数据或者非恶意的bug不小心修改了其他程序的数据,这都可能导致正常运行的程序崩溃
  • 内存使用率低 由于没有有效的内存管理机制,通常一个程序执行时,监控程序需要将整个程序装入内存然后开始执行。如果我们突然运行程序C,但是此时的内存已经不够用了,就需要将B程序或者A程序由内存换出到磁盘,等需要用到的时候再从磁盘中读回来,而这整个过程有大量的数据换入换出,导致效率十分低下。
  • 程序运行的地址不确定 因为程序每次需要装入运行时,都需要在内存中给它分配一段空间,而这段空间地址是不确定的,但是每个程序的访问数据和指令跳转时的地址都是确定的,这就涉及到了重定位问题。

        解决这个问题的办法就是使用一个中间层,中间层就是将程序的地址和执行时的程序存放的物理地址之间做一个映射,这个程序的地址就可以看作是虚拟地址(Virtual Address),而这个映射就是CPU访问该虚拟地址的时候会通过这个映射关系找到真实的物理地址。这样,只要我们能够妥善地控制这个虚拟地址到物理地址的映射过程,就可以保证任意一个程序所能够访问的物理内存区跟另外一个程序互相不重叠,以达到地址空间隔离的效果。

        虚拟地址空间是指虚拟的、人们想象出来的地址空间,其实它并不存在,每个进程都有自己独立的虚拟空间、而且每个进行只能访问自己的地址空间,这样就有效的做到了进程的隔离。那虚拟地址具体是如何映射到物理地址的,具体有两种机制分别是:分段机制和分页机制

分段机制

基本思路是将程序所需内存空间大小的虚拟地址空间映射到某个地址空间。比如程序A需要10M的内存,那我们假设有一个地址空间从0x00000000到0x00A00000的10M大小的虚拟地址空间,然后我们从实际的物理内存上分配一个同样大小的物理地址、假设物理地址是0x00100000到0x00B00000的一块空间,然后我们对这两块相同大小的地址空间进行一一映射,即虚拟地址空间中的每一个字节对应物理空间中的每一个字节,这个映射的数据结构有软件来完成,实际的地址转换有硬件完成,比如当程序A中访问地址0x00001000时,CPU会将这个地址转换成实际的物理地址0x00101000。下图为程序A和程序B在运行时,它们的虚拟空间和物理空间的映射关系。

        分段的方法基本解决了地址不隔离与程序执行地址的不确定,首先是地址隔离,因为程序A和程序B分别映射到了不同的物理地址空间中,如果程序A访问虚拟地址的空间超过了0x00A00000这个范围,那么硬件就会判断这个是一个非法访问,从而拒绝这个请求。再者,对于每一个程序只需关系对应的虚拟地址,具体的物理地址会映射到内存中的哪一块位置,这个是有操作系统决定的,而对于程序员来说都是透明的,他们不需要关系物理地址的变化,只需要按照从地址0x00000000到0x00A00000来编写程序、放置变量,所以程序不需要进行重定位。

        但是分段这种方法没有解决内存使用效率问题,分段对程序的映射还是按照程序为单位,当内存空间不足的时候,被换入换出到磁盘的都是整个程序,这样同样会产生大量的磁盘访问操作,严重影响运行速度,这种方法显得粗糙,粒度(可以理解为占用的内存大小)比较大。实际程序在运行得时候它只是频繁得用到了一小部分数据,其他大部分数据是在一段时间内都不会被用到得,从而人们想到了更小粒度得内存分割和映射方法,使得程序得局部性原理得到充分得利用,大大提高内存得使用使用率。这种方法就是分页(Paging)

分页机制

分页机制就是将磁盘空间、虚拟地址空间、物理地址空间人为地分成相同的固定大小的页。磁盘分成得页叫磁盘页(DP,Disk Page)、物理地址空间的页叫物理页(PP,Physical Page)、虚拟地址空间的页叫虚拟页(VP,Virtual Page)。这个页大小对于操作系统来说一般使用都是4KB每页或者4MB每页,假如我们PC机使用的是32位操作系统,虚拟地址空间最大可以有4GB,如果按照每个虚拟页4KB,那么4GB的空间总共有1048576页。物理地址空间和磁盘空间也是同样的分法。

        下面我们来看一个简单的例子,如下图,每个虚拟空间有8个页,每个页大小假设为1KB,那么虚拟地址空间就是8KB,假设我们有13条地址线,那么可以最大可以寻址的物理地址空间是8KB,但是由于某些原因资金不足,只能买6KB的物理内存,所以目前物理地址只有6KB,也就对应了6个物理页。

        当我们把虚拟地址空间按页分割,我们把常用的数据和代码所在的页放到内存中,不常用的数据和代码放到磁盘中,当需要用到的时候直接从磁盘中取出即可。假设我们有两个进程Process1和Process2,它们进程中的部分虚拟页面被映射到物理页面,比如Process1的VP2\VP3\VP7映射到PP0\PP2\PP3,而有些部分却在磁盘中,比如VP4\VP5就在磁盘页的DP0\DP1中,另外有些虚拟页可能未被用或者为被访问到,它们暂时还处于未使用的状态,从下图我们也可以看到Process1的VP7和Process2的VP7映射到了同一个物理页上,这样就可以实现进程直接的内存共享,也是进程通信的一种方法。

        下图中,Process2的VP4\VP5虚拟页对应的物理页都不在内存中,当进程需要用到这两个页的时候,操作系统就会产生页错误(Page Fault),然后操作系统负责接管进程将VP4\VP5从磁盘中读入内存,然后将VP4\VP5与分配的两个物理内存页建立映射关系,最后操作系统将进程归还后进程可以正常访问之前存放在磁盘中的数据。

        保护也是页映射的目的之一,简单说就是每个页都可以设置权限属性,谁可以访问页内容,谁可以修改页内容,而只有操作系统有权限修改这些页的权限属性。

        虚拟地址的实现需要依靠硬件的支持,对于不同的CPU来说是不同的,但是几乎所有的硬件都采用一个叫MMU(Memory Management Unit)的部件来进行页映射。 在页映射模式下CPU发出的是虚拟地址,即我们程序看到的是虚拟地址。经过MMU转换之后就变成了物理地址。一般MMU都集成在CPU内部,不会以独立的部件存在。

线程基础

        线程(Thread),也叫轻量级进程(Lightweight Process LWP),是程序执行流的最小单元。一个标准的线程有线程ID、当前指令指针(PC)、寄存器集合和堆栈组成。通常一个进程有一个到多个线程组成,各个线程之间共享程序的内存空间(代码段、数据段、堆)及一些进程级的资源。

多线程与单线程对比有如下优点

  • 单线程进入休眠会导致整个进程陷入等待状态,而多线程可以在一个线程休眠的时候切换到另外一个线程。
  • 多线程可以让一个线程负责交互,另一个线程负责计算。
  • SMP或者多核处理器本身就具有执行多个线程的条件,如果只有一个线程那整个CPU资源会有很大的浪费。
  • 相对于多进程应用,多线程在数据共享方面效率要高很多。

线程的访问权限

线程局部存储是某些操作系统为线程单独提供的私有空间,但通常只有很有限的容量。、

寄存器(包括PC寄存器),寄存器是执行流的基本数据,因此为线程私有。

线程调度与优先级

        在单处理器或者多处理器上,操作系统会让多线程轮流执行,每次每个线程仅执行一小段时间,这样每个线程就看起来是同时执行的,这样的一个不断在处理器上切换不同线程的行为称之为线程调度(Thread Schedule)

在线程调度中,线程拥有三种状态,分别是:

  • 运行(Running):此时线程正在执行。
  • 就绪(Ready):此时线程可以立即运行,但CPU已经被占用。
  • 等待(Waiting):此时线程正在等待某一事件发生,无法执行。

上面三个状态之间之间的转换关系如下图: 

线程调度主流的有如下两种:

  • 轮转法:让每个线程轮流的执行一小段时间。
  • 优先级调度:每个线程拥有自己的线程优先级,高优先级的总是比低优先级的先执行。

IO密集型线程:频繁等待的线程,很少使用CPU的。

CPU密集型线程: 频繁使用CPU的,很少等待的。

        优先级调用的线程调度有一个饿死的现象,就是低优先级的线程一直等不到CPU资源。为了避免线程饿死,调度策略的提升会将那些低优先级的等待了很长时间的线程的优先级提升,直到提高到足够让它执行的程度。

在优先级调度环境下,线程的优先级改变一般有三种方式:

  • 用户指定优先级
  • 根据进入等待状态的频繁程度提升或者降低优先级
  • 长时间得不到执行而被提升优先级

可抢占线程和不可抢占线程 

        我们之前讨论的线程调度有一个特点,那就是线程在用尽时间片之后会被强制剥夺继续执行的权利,而进入就绪状态,这个过程叫做抢占(Preemption),即之后执行的别的线程抢占了当前线程。在早期的一些系统里,线程是不可抢占的。线程必须手动发出一个放弃执行的命令,才能让其他的线程得到执行。在这样的调度模型下,线程必须主动进入就绪状态,而不是靠时间片用尽来被强制进入。如果线程始终拒绝进入就绪状态,并且也不进行任何的等待操作,那么其他的线程将永远无法执行。在不可抢占线程中,线程主动放弃执行无非两种情况。

  • 当线程试图等待某事件时(I/O等)。
  • 线程主动放弃时间片。

        因此,在不可抢占线程执行的时候,有一个显著的特点,那就是线程调度的时机是确定的,线程调度只会发生在线程主动放弃执行或线程等待某事件的时候。这样可以避免一些因为抢占式线程里调度时机不确定而产生的问题。但即使如此,非抢占式线程在今日已经十分少见。

Linux的多线程

        Linux对多线程的支持颇为贫乏,事实上,在Linux内核中并不存在真正意义上的线程概念。Linux将所有的执行实体(无论是线程还是进程)都称为任务(Task),每一个任务概念上都类似于一个单线程的进程,具有内存空间、执行实体、文件资源等。不过,Linux下不同的任务之间可以选择共享内存空间,因而在实际意义上,共享了同一个内存空间的多个任务构成了一个进程,这些任务也就成了这个进程里的线程。在Linux下,用以下方法可以创建一个新的任务。

fork函数产生一个和当前进程完全一样的新进程,并和当前进程一样从fork函数里返回。例如如下代码:
pid_t pid;
if (pid = fork())
{
    ….
}

        在fork函数调用之后,新的任务将启动并和本任务一起从fork函数返回。但不同的是本任务的fork将返回新任务pid,而新任务的fork将返回0。
        fork产生新任务的速度非常快,因为fork并不复制原任务的内存空间,而是和原任务一起共享一个写时复制(Copy on Write, COW)的内存空间。所谓写时复制,指的是两个任务可以同时自由地读取内存,但任意一个任务试图对内存进行修改时,内存就会复制一份提供给修改方单独使用,以免影响到其他的任务使用。
fork只能够产生本任务的镜像,因此须要使用exec配合才能够启动别的新任务。exec可以用新的可执行映像替换当前的可执行映像,因此在fork产生了一个新任务之后,新任务可以调用exec来执行新的可执行文件。fork和exec通常用于产生新任务,而如果要产生新线程,则可以使用clone。

clone函数的原型如下:
int clone(int (*fn)(void*), void* child_stack, int flags, void* arg);

        使用clone可以产生一个新的任务,从指定的位置开始执行,并且(可选的)共享当前进程的内存空间和文件等。如此就可以在实际效果上产生一个线程。

线程安全

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

竞争与原子操作

        多个线程同时访问一个共享数据,可能造成很恶劣的后果。下面是一个著名的例子,假设有两个线程分别要执行如下表的C代码。

在许多体系结构上,++i的实现方法会如下:

  • 读取i到某个寄存器X。
  • X++。
  • 将X的内容存储回i。

        由于线程1和线程2并发执行,因此两个线程的执行序列很可能如下(注意,寄存器X的内容在不同的线程中是不一样的,这里用X[1]和X[2]分别表示线程1和线程2中的X)如下表所示。

        从程序逻辑来看,两个线程都执行完毕之后,i的值应该为1,但从之前的执行序列可以看到,i得到的值是0。实际上这两个线程如果同时执行的话,i的结果有可能是0或1或2。可见,两个程序同时读写同一个共享数据会导致意想不到的后果

        很明显,自增(++)操作在多线程环境下会出现错误是因为这个操作被编译为汇编代码之后不止一条指令,因此在执行的时候可能执行了一半就被调度系统打断,去执行别的代码。我们把单指令的操作称为原子的(Atomic),因为无论如何,单条指令的执行是不会被打断的。尽管原子操作指令非常方便,但是它们仅适用于比较简单特定的场合。在复杂的场合下,比如我们要保证一个复杂的数据结构更改的原子性,原子操作指令就力不从心了。这里我们需要更加通用的手段:

同步与锁

        为了避免多个线程同时读写同一个数据而产生不可预料的后果,我们需要将各个线程对同一个数据的访问同步(Synchronization)。所谓同步,既是指在一个线程访问数据未结束的时候,其他线程不得对同一个数据进行访问。如此,对数据的访问被原子化了。同步的最常见方法是使用锁(Lock)。锁是一种非强制机制,每一个线程在访问数据或资源之前首先试图获取(Acquire)锁,并在访问结束之后释放(Release)锁。在锁已经被占用的时候试图获取锁时,线程会等待,直到锁重新可用

  • 二元信号量:该锁只有两种状态:占用与非占用,它适合只能被唯一一个线程独占访问的资源信号量处于非占用状态时,第一个试图获取该二元信号量的线程会获得该锁,并将二元信号量置为占用状态,此后其他的所有试图获取该二元信号量的线程将会等待,直到该锁被释放。
  • 信号量:多元信号量简称信号量,一个初始值为N的信号量允许N个线程并发访问。线程访问资源的时候首先获取信号量,进行如下操作:将信号量的值减1。如果信号量的值小于0,则进入等待状态,否则继续执行。访问完资源之后,线程释放信号量,进行如下操作:将信号量的值加1。如果信号量的值小于1,唤醒一个等待中的线程
  • 互斥量:互斥量和二元信号量很类似,资源仅同时允许一个线程访问,但和信号量不同的是,信号量在整个系统可以被任意线程获取并释放,也就是说,同一个信号量可以被系统中的一个线程获取之后由另一个线程释放。而互斥量则要求哪个线程获取了互斥量,哪个线程就要负责释放这个锁,其他线程越俎代庖去释放互斥量是无效的
  • 临界区:把临界区的锁的获取称为进入临界区,而把锁的释放称为离开临界区。临界区和互斥量与信号量的区别在于,互斥量和信号量在系统的任何进程里都是可见的,也就是说,一个进程创建了一个互斥量或信号量,另一个进程试图去获取该锁是合法的。然而,临界区的作用范围仅限于本进程,其他的进程无法获取该锁。
  • 读写锁:对于一段数据,多个线程同时读取总是没有问题的,但假设操作都不是原子型,只要有任何一个线程试图对这个数据进行修改,就必须使用同步手段来避免出错。如果我们使用上述信号量、互斥量或临界区中的任何一种来进行同步,尽管可以保证程序正确,但对于读取频繁,而仅仅偶尔写入的情况,会显得非常低效。读写锁可以避免这个问题。对于同一个锁,读写锁有两种获取方式,共享的(Shared)或独占的(Exclusive)。当锁处于自由的状态时,试图以任何一种方式获取锁都能成功,并将锁置于对应的状态。如果锁处于共享状态,其他线程以共享的方式获取锁仍然会成功,此时这个锁分配给了多个线程。然而,如果其他线程试图以独占的方式获取已经处于共享状态的锁,那么它将必须等待锁被所有的线程释放。相应地,处于独占状态的锁将阻止任何其他线程获取该锁,不论它们试图以哪种方式获取。

可重入与线程安全

一个函数被重入,表示这个函数没有执行完成,由于外部因素或内部调用,又一次进入该函数执行,一个函数要被重入,只有两种情况:

  • 多个线程同时执行这个函数。
  • 函数自身(可能是经过多层调用之后)调用自身。

一个函数要成为可重入的,必须具有如下几个特点

  • 不使用任何(局部)静态或全局的非const变量。
  • 不返回任何(局部)静态或全局的非const变量的指针。
  • 仅依赖于调用方提供的参数。
  • 不依赖任何单个资源的锁(mutex等)。
  • 不调用任何不可重入的函数。

可重入是并发安全的强力保障,一个可重入的函数可以在多线程环境下放心使用。

过度优化

        线程安全是一个非常烫手的山芋,因为即使合理地使用了锁,也不一定能保证线程安全,这是源于落后的编译器技术已经无法满足日益增长的并发需求。很多看似无错的代码在优化和并发面前又产生了麻烦。最简单的例子,让我们看看如下代码:

x = 0;
Thread1    Thread2
lock();        lock();
x++;           x++;
unlock();   unlock();

        由于有lock和unlock的保护,x++的行为不会被并发所破坏,那么x的值似乎必然是2了。然而,如果编译器为了提高x的访问速度,把x放到了某个寄存器里,那么我们知道不同线程的寄存器是各自独立的,因此如果Thread1先获得锁,则程序的执行可能会呈现如下的情况:

  • [Thread1]读取x的值到某个寄存器R[1](R[1]=0)。
  • [Thread1]R[1]++(由于之后可能还要访问x,因此Thread1暂时不将R[1]写回x)。
  • [Thread2]读取x的值到某个寄存器R[2](R[2]=0)。
  • [Thread2]R[2]++(R[2]=1)。
  • [Thread2]将R[2]写回至x(x=1)。
  • [Thread1](很久以后)将R[1]写回至x(x=1)。

可见在这样的情况下即使正确地加锁,也不能保证多线程安全。下面是另一个例子:

x = y = 0;
Thread1   Thread2
x = 1;       y = 1;
r1 = y;      r2 = x;

很显然,r1和r2至少有一个为1,逻辑上不可能同时为0。然而,事实上r1=r2=0的情况确实可能发生。原因在于早在几十年前,CPU就发展出了动态调度,在执行程序的时候为了提高效率有可能交换指令的顺序。同样,编译器在进行优化的时候,也可能为了效率而交换毫不相干的两条相邻指令(如x=1和r1=y)的执行顺序。也就是说,以上代码执行的时候可能是这样的:
x = y = 0;
Thread1  Thread2
r1 = y;      y = 1;
x = 1;       r2 = x;

那么r1=r2=0就完全可能了。我们可以使用volatile关键字试图阻止过度优化,volatile基本可以做到两件事情:

  • 阻止编译器为了提高速度将一个变量缓存到寄存器内而不写回。
  • 阻止编译器调整操作volatile变量的指令顺序。

可见volatile可以完美地解决第一个问题,但是volatile是否也能解决第二个问题呢?答案是不能。因为即使volatile能够阻止编译器调整顺序,也无法阻止CPU动态调度换序。

多线程内部情况

        线程的并发执行是由多处理器或操作系统调度来实现的。但实际情况要更为复杂一些:大多数操作系统,包括Windows和Linux,都在内核里提供线程的支持,内核线程(注:这里的内核线程和Linux内核里的kernel_thread并不是一回事)和我们之前讨论的一样,由多处理器或调度来实现并发。然而用户实际使用的线程并不是内核线程,而是存在于用户态的用户线程用户态线程并不一定在操作系统内核里对应同等数量的内核线程,例如某些轻量级的线程库,对用户来说如果有三个线程在同时执行,对内核来说很可能只有一个线程。本节我们将详细介绍用户态多线程库的实现方式。

一对一模型

对于直接支持线程的系统,一对一模型始终是最为简单的模型。对一对一模型来说,一个用户使用的线程就唯一对应一个内核使用的线程(但反过来不一定,一个内核里的线程在用户态不一定有对应的线程存在),如下图所示。

        这样用户线程就具有了和内核线程一致的优点,线程之间的并发是真正的并发,一个线程因为某原因阻塞时,其他线程执行不会受到影响。此外,一对一模型也可以让多线程程序在多处理器的系统上有更好的表现。一般直接使用API或系统调用创建的线程均为一对一的线程。例如在Linux里使用clone(带有CLONE_VM参数)产生的线程就是一个一对一线程,因为此时在内核有一个唯一的线程与之对应。

一对一线程缺点有两个:

  • 由于许多操作系统限制了内核线程的数量,因此一对一线程会让用户的线程数量受到限制。
  • 许多操作系统内核线程调度时,上下文切换的开销较大,导致用户线程的执行效率下降。

多对一模型: 

多对一模型将多个用户线程映射到一个内核线程上,线程之间的切换由用户态的代码来进行,因此相对于一对一模型,多对一模型的线程切换要快速许多。多对一的模型示意图如下图所示。

多对一模型一大问题是,如果其中一个用户线程阻塞,那么所有的线程都将无法执行,因为此时内核里的线程也随之阻塞了。另外,在多处理器系统上,处理器的增多对多对一模型的线程性能也不会有明显的帮助。但同时,多对一模型得到的好处是高效的上下文切换和几乎无限制的线程数量。 

多对多线程模型

多对多模型结合了多对一模型和一对一模型的特点,将多个用户线程映射到少数但不止一个内核线程上,如下图所示。

在多对多模型中,一个用户线程阻塞并不会使得所有的用户线程阻塞,因为此时还有别的线程可以被调度来执行。另外,多对多模型对用户线程的数量也没什么限制,在多处理器系统上,多对多模型的线程也能得到一定的性能提升,不过提升的幅度不如一对一模型高。

  • 21
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论
程序员自我修养:链接,装载》是一本由林锐、郭晓东、郑蕾等人合著的计算机技术书籍,在该书中,作者从程序员的视角出发,对链接装载等概念进行了深入的阐述和解析。 在计算机编程中,链接是指将各个源文件中的代码模块组合成一个可执行的程序的过程。链接可以分为静态链接和动态链接两种方式。静态链接是在编译时将所有代码模块合并成一个独立的可执行文件,而动态链接是在运行时根据需要加载相应的代码模块。 装载是指将一个程序从磁盘上加载到内存中准备执行的过程。在装载过程中,操作系统会为程序分配内存空间,并将程序中的各个模块加载到相应的内存地址上。装载过程中还包括解析模块之间的引用关系,以及进行地址重定位等操作。 是指一组可重用的代码模块,通过链接装载的方式被程序调用。可以分为静态和动态。静态是在编译时将的代码链接到程序中,使程序与的代码合并为一个可执行文件。动态则是在运行时通过动态链接的方式加载并调用。 《程序员自我修养:链接,装载》对于理解链接装载的原理和机制具有极大的帮助。通过学习这些概念,程序员可以更好地优化代码结构和组织,提高程序的性能和可维护性。同时,了解链接装载的工作原理也对于进行调试和故障排除具有重要意义。 总之,链接装载是计算机编程中的重要概念,对于程序员来说掌握这些知识是非常必要的。《程序员自我修养:链接,装载》这本书提供了深入浅出的解释和实例,对于想要学习和掌握这些知识的程序员来说是一本非常有价值的参考书籍。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

新柯兰永久

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值