Linux内核同步

2019-07-12 19:47发布

主要内容

    1、内核请求何时以交错(interleave)的方式执行以及交错程度如何。     2、内核所实现的基本同步机制。     3、通常情况下如何使用内核提供的同步机制。


内核如何为不同的请求服务

    哪些服务?     ====>>>     为了更好地理解内核是如何执行的,我们把内核看做必须满足两种请求的侍者:一种请求来自顾客,另一种请求来自数量有限的几个不同的老板。对于不同的请求,侍者采用如下的策略:
    1、老板提出请求时,如果侍者空闲,则侍者开始为老板服务。     2、如果老板提出请求时侍者正在为顾客服务,那么侍者停止为顾客服务,开始为老板提供服务。     3、如果一个老板提出请求时侍者正在为另一个老板服务,那么侍者停止为第一个老板提供服务,而开始为第二个老板服务,服务完毕后再继续为第二个老板服务。     4、一个老板可能命令侍者停止正在为顾客提供的服务。侍者在完成对老板最近请求的服务之后,可能暂时不理会原来的顾客而去为新选中的顾客服务。     这里,将其对应到内核中的功能:
    侍者提供的服务 <<<————>>> CPU处于内核态时所执行的代码和程序。如果CPU在用户态执行,则侍者被认为处于空闲状态。     老板的请求 <<<————>>> 中断。     顾客的请求 <<<————>>> 用户态进程发出的系统调用或异常。     ====>>>
    1、2、3对应于中断和异常处理程序的嵌套执行,4对应于内核抢占(kernel preemption)。

内核抢占与非内核抢占

    简单定义,如果进程正执行内核函数时,即它在内核态运行时,允许发生内核切换(被替换的进程是正执行内核函数的进程),那么这个内核就是抢占的。     * 无论在抢占内核还是非抢占内核中,运行在内核态的进程都可以自动放弃CPU,例如,其可能的原因是,进程由于等待资源而不得不转入睡眠状态。我们把这种进程切换称为计划性进程切换。但是,抢占式内核在响应引起进程切换的异步事件(例如唤醒高优先权的中断处理程序)的方式与非抢占式内核是有着极大差别的,我们将这种进程切换称为强制性进程切换。
    * 所有的进程切换都由宏(switch_to)来完成。在抢占式内核和非抢占式内核中,当进程执行完某些具有内核功能的线程,而且调度程序被调用后,就发生进程切换。不过,在非抢占内核中,当前进程是不可能被替换的,除非它打算切换到用户态。
    Linux 2.6版本提出的可抢占式内核是指内核抢占,即当进程位于内核空间时,有一个更高优先级的任务出现时,如果当前内核允许抢占,则可以将当前任务挂起,执行优先级更高的进程。在2.5版本及之前,Linux内核是不可抢占的,高优先级的进程不能中止正在内核中运行的低优先级的进程而抢占CPU运行。进程一旦处于核心态(例如用户进程执行系统调用),则除非进程自愿放弃CPU,否则该进程将一直运行下去,直至完成或退出内核。与此相反,一个可抢占的Linux内核可以让Linux内核如同用户空间一样允许被抢占。当一个高优先级的进程到达时,不管当前进程处于用户态还是核心态,如果当前允许抢占,可抢占内核的Linux都会调度高优先级的进程运行。     现在,总结一下抢占式内核与非抢占式内核的特点与区别:
    1、非抢占式内核
    非抢占式内核是由任务主动放弃CPU的使用权。非抢占式调度法也称为合作型多任务,各个任务彼此共享一个CPU。异步事件由中断服务处理。中断服务可以使一个高优先级的任务由挂起状态转为就绪状态。但中断服务以后的控制权还是回到原来被中断了的那个任务,直到该任务主动放弃CPU的使用权时,那个高优先级的任务才能获得CPU的使用权。非抢占式内核如下图。          非抢占式内核的优点:
    * 中断响应快(与抢占式内核相比);     * 允许使用不可重入函数;     * 几乎不需要使用信号量保护共享数据。运行的任务占有CPU,不必担心被其他任务抢占。     非抢占式内核的缺点:
    * 任务相应时间慢。高优先级的任务已经进入就绪态,但还不能运行,要等到当前运行着的任务释放CPU后才能进行任务执行。     * 非抢占式内核的任务级响应时间是不确定的,最高优先级的任务获得CPU的控制权的时间,完全取决于已经运行进程何时释放CPU。     2、抢占式内核
    使用抢占式内核可以保障系统响应时间。最高优先级的任务一旦就绪,总能得到CPU的使用权。当一个运行着的任务使一个比它优先级高的任务进入了就绪态,当前任务的CPU使用权就会被剥夺,或者说被挂起了,那个高优先级的任务便会立刻得到CPU的控制权。如果是中断服务子程序使一个高优先级的任务进入就绪状态,中断完成时,中断了的任务就会被挂起,优先级高的任务便开始控制CPU。抢占式内核如下图:
    
    抢占式内核的优点:
    * 使用抢占式内核,最高优先级的任务能够得到最快程度的相应,高优先级任务肯定能够获得CPU使用权。抢占式内核使得任务优先级相应时间机制得以最优化。     * 使内核可抢占的目的是减少用户态进程的分派延迟(dispatch latency),即从进程变为可执行状态到它实际开始运行之间的时间间隔。内核抢占对执行及时被调度的任务(如硬件控制器,环境监视器,电影播放器等)的进程确实是由好处的,因为它降低了这种进程被另一个运行在内核态的进程延迟的风险。     抢占式内核的缺点:
    * 不能直接使用不可重入型函数。调用不可重入函数时,要满足互斥条件,可以使用互斥性信号量来实现。如果调用不可重入型函数时,对于低优先级的任务,其CPU使用权会被高优先级任务剥夺,不可重入型函数中的数据可能会被破坏。     3、内核态抢占的设计:     首先,需要做何种改进才能支持内核可抢占性呢?
    只有当内核正在执行异常处理程序(尤其是系统调用),而且内核抢占没有被显式地禁用时,才可能抢占内核。此外,由从中断和异常中返回的知识,本地CPU必须打开本地中断,否则无法完成内核抢占。     另外,Linux2.6独具特 {MOD}的允许用户在编译内核时通过设置选项来禁用或启用内核抢占,当然,通过内核内部,也可以显式地禁用内核抢占。那么,应该如何设置来禁止内核抢占呢?
    由从中断和异常中返回可知,当被current_thread_info()宏所引用的thread_info描述符的preempt_count字段大于0时,就禁止内核抢占。     这样,我们可以通过控制以下三个不同情况来控制内核抢占禁用:a、内核正在执行中断服务例程;b、可延迟函数被禁止;c、通过把抢占计数器设置为正数而显式地禁用内核抢占。     关于preempt_count字段,有如下操作宏:
宏 说明 preempt_count() 在thread_info描述符中选择preempt_count字段 preempt_disable() 使抢占计数器的值加1 preempt_enable_no_resched() 使抢占计数器的值减1 preempt_enable() 使抢占计数器的值减1,并在     对于preempt_enable宏递减抢占计数器,然后检查TIF_NEED_RESCHED标志是否被设置。在这种情况下,进程切换请求是挂起的,因此宏调用preempt_schedule()函数,preempt_schedule()函数本质执行下面代码: if (!current_thread_info->preempt_count && !irqs_disabled()){ current_thread_info->preempt_count = PREEMPT_ACTIVE; schedule(); current_thread_info->preempt_count = 0;}     该函数检查是否允许本地中断,以及当前进程的preempt_count是否为0,如果两个条件都为真,就调用schedule()函数选择另一个进程来运行。因此,内核抢占可能在结束内核控制路径时发生,也可能在异常处理程序调用preempt_enable()重新允许内核抢占发生。

    其次,要满足什么条件时,其他的内核态任务才可以抢占已运行任务的内核态呢?
    * 没有持有锁(lock)。锁用于保护临界区,不能被抢占。     * 内核态任务代码可重入(code reentrant)。     那么,如何判断当前上下文(context)(中断处理例程,系统调用,内核线程等)是没有持有锁的?
    我们在前面已经提及过thread_info中的preempt_count可以通过设置正数来显式地禁用内核抢占。这里,通过控制此变量即可实现持有锁机制,preempt_count初始为0,当加锁时便执行加1操作,当解锁时便执行减1操作,由此可以实现控制内核抢占的目的。
    另外,这里需要补充一些关于可重入函数的知识。     所谓可重入是指一个可以被多个任务调用的过程,任务在调用时不必担心数据是否会出错。不可重入函数在实时系统设计中被视为不安全函数。     若一个函数是可重入的,则该函数必须满足以下必要条件:      * 不能含有静态(全局)非常量数据。     不能返回静态(全局)非常量数据的地址。      只能处理由调用者提供的数据。     作为可重入函数的输入参数,只能由调用者提供,而且所提供的输入数据必须满足下面三点要求。     * 不能依赖于单实例模式资源的锁。      不能调用不可重入的函数。      * 在函数内部,尽量不能用 malloc 和 free 之类的方法进行内存分配和释放,如果使用,一般情况下会造成该函数的不可重入。      可重入函数主要用于多任务环境中。一个可重入的函数简单来说就是可以被中断的函数,也就是说,可以在这个函数执行的任何时刻中断它,转入OS调度下去执行另外一段代码,而返回控制时不会出现什么错误。     不可重入的函数由于使用了一些系统资源,比如全局变量区,中断向量表等,所以它如果被中断的话,可能会出现问题,这类函数是不能运行在多任务环境下的。      可重入函数也可以这样理解,重入即表示重复进入,首先它意味着这个函数可以被中断,其次意味着它除了使用自己栈上的变量以外不依赖于任何环境(包括 static),这样的函数就是purecode(纯代码)可重入,可以允许有该函数的多个副本在运行,由于它们使用的是分离的栈,所以不会互相干扰。
    再则,我们来讨论一下关于内核态需要抢占的触发条件:     内核提供了一个need_resched标志(这个标志在任务结构thread_info中,其返回的是TIF_NEED_RESCHED)来表明是否需要重新执行调度。当执行调度程序时,内核抢占会根据内核抢占是否禁止来进行内核抢占操作。     在触发内核抢占及重新调度时,有以下几个重要的函数:
    set_tsk_need_resched():设置指定进程中的need_resched标志;     clear_tsk_need_resched():清除指定进程中的need_resched标志;     need_resched():检查need_resched标志的值:如果被设置就返回真,否则返回假。     那么,何时触发重新调度呢?
    * 时钟中断处理例程检查当前任务的时间片,当任务的时间片消耗完时,scheduler_tick()函数就会设置need_resched标志;     * 信号量、等待队列等机制唤醒时都是基于等待队列(waitqueue)的,而等待队列的唤醒函数为default_wake_function,其调用try_to_wake_up将被唤醒的任务更改为就绪状态并设置need_resched标志;     * 设置用户进程的nice值时,可能会使高优先级的任务进入就绪状态;     * 改变任务的优先级时,可能会使高优先级的任务进入就绪状态;     * 对CPU(SMP)进行负载均衡时,当前任务可能需要移动至另外一个CPU上运行。     另外,抢占发生的时机:
    * 当一个中断处理例程退出,在返回到内核态时,此时隐式调用schedule()函数,当前任务没有主动放弃CPU使用权,而是被剥夺了CPU使用权。     * 当内核代码(程序)从不可抢占状态变为可抢占状态时(preemptible),也就是preempt_count从正数变为0时,此时同样隐式调用schedule()函数。     * 一个任务在内核态中,显式的调用schedule()函数,任务主动放弃CPU使用权。     * 一个任务在内核态中被阻塞,导致需要调用schedule()函数,任务主动放弃CPU使用权。     那些时候不允许内核抢占呢?
    * 内核正在进行中断处理。在Linux内核中不能抢占中断(中断只能被其他中断中止和抢占,进程不能中止和抢占中断,内核抢占是被进程抢占和中止),在中断例程中不允许进行进程调度。进程调度函数schedule()会对此做出判断,如果是在中断中调用,会打印错误。     * 内核正在进行中断上下文的下半部处理时,硬件中断返回前会执行软中断,此时仍然处于中断上下文中,所以此时无法进行内核抢占。     * 内核的代码段正持有自旋锁(spinlock)、读写锁(writelock/readlock)时,内核代码段处于锁保护状态。此时,内核不能被抢占,否则由于抢占将导致其他CPU长期不能获得锁而出现死锁状态。     * 内核正在对每CPU私有的数据结构(Per-CPU data structures)进行操作。在SMP(对称多处理器)中,对于每CPU数据结构并未采用自旋锁进行保护,因为这些数据结构隐含地被保护了(不同的CPU上有不同的每CPU数据,其他CPU上运行的进程不能访问另一个CPU的每CPU数据)。在这种情况下,虽然并未采用锁机制,同样不能进行内核抢占,因为如果允许内核抢占,一个进程被抢占后重新调度,有可能调度到其他的CPU上去,这时定义的每CPU数据变量就会发生错位。因此,对于每CPU数据访问时,同样也无法进行内核抢占。


同步原语

    如何避免由于对共享数据的不安全访问导致的数据崩溃?     内核使用的各种同步技术:
技术 说明 适用范围 每CPU变量 在CPU之间复制数据结构 所有CPU 原子操作 对一个计数器原子地“读-修改-写”的指令 所有CPU 内存屏障 避免指令重新排序 本地CPU或所有CPU 自旋锁 加锁时忙等 所有CPU 信号量 加锁时阻塞等待 所有CPU 顺序锁 基于访问计数器的锁 所有CPU 本地中断的禁止 禁止单个CPU上的中断处理 本地CPU 本地软中断的禁止 禁止单个CPU上的可延迟函数处理 本地CPU 读-复制-更新(RCU) 通过指针而不是锁来访问共享数据结构 所有CPU


每CPU变量

    最好的同步技术是把设计不需要同步的临界资源放在首位,这是一种思维方法,因为每一种显式的同步原语都有不容忽视的性能开销。最简单也是最重要的同步技术包括把内核变量或数据结构声明为每CPU变量(per-cpu variable)。每CPU变量主要是数据结构的数组,系统的每个CPU对应数组的一个元素。     多核情况下,CPU是同时并发运行的,但是它们共同使用其他的硬件资源,因此我们需要解决多个CPU之间的同步问题。每CPU变量(per-cpu-variable)是内核中一种重要的同步机制。顾名思义,每CPU变量就是为每个CPU构造一个变量的副本,这样多个CPU相互操作各自的副本,互不干涉。比如我们标识当前进程的变量current_task就被声明为每CPU变量。     一个CPU不应该访问与其他CPU对应的数组元素,另外,它可以随意读或修改它自己的元素而不用担心出现竞争条件,因为它是唯一有资格这么做的CPU。但是,这也意味着每CPU变量基本上只能在特殊情况下使用,也就是当它确定在系统的CPU上的数据在逻辑上是独立的时候。
    每CPU变量的特点:     1、用于多个CPU之间的同步,如果是单核结构,每CPU变量没有任何用处。     2、每CPU变量不能用于多个CPU相互协作的场景(每个CPU的副本都是独立的)。     3、每CPU变量不能解决由中断或延迟函数导致的同步问题。     4、访问每CPU变量的时候,一定要确保关闭内核抢占,否则一个进程被抢占后可能会更换CPU运行,这会导致每CPU变量的引用错误。     我们可以用数组来实现每CPU变量吗?比如,我们要保护变量var,我们可以声明int var[NR_CPUS],CPU num就访问var[num]不就可以了吗?     显然,每CPU变量的实现不会这么简单。理由:我们知道为了加快内存访问,处理器中设计了硬件高速缓存(也就是CPU的cache),每个处理器都会有一个硬件高速缓存。如果每CPU变量用数组来实现,那么任何一个CPU修改了其中的内容,都会导致其他CPU的高速缓存中对应的块失效,而频繁的失效会导致性能急剧的下降。因此,每CPU的数组元素在主存中被排列以使每个数据结构存放在硬件高速缓存的不同行,这样,对每CPU数组的并发访问不会导致高速缓存行的窃用和失效(这种操作会带来昂贵的系统开销)。     虽然每CPU变量为来自不同CPU的并发访问提供保护,但对来自异步函数(中断处理程序和可延迟函数)的访问不提供保护,在这种情况下需要另外的同步技术。     每CPU变量分为静态和动态两种,静态的每CPU变量使用DEFINE_PER_CPU声明,在编译的时候分配空间;而动态的使用alloc_percpu和free_percpu来分配回收存储空间。     每CPU变量的函数和宏:     每CPU变量的定义在includelinuxpercpu.h以及includeasm-genericpercpu.h中。这些文件中定义了单核和多核情况下的每CPU变量的操作,这是为了代码的统一设计的,实际上只有在多核情况下(定义了CONFIG_SMP)每CPU变量才有意义。     常见的操作和含义如下: 函数名 说明 DECLARE_PER_CPU(type, name) 声明每CPU变量name,类型为type DEFINE_PER_CPU(type, name) 静态分配一个每CPU数组,数组名为name,类型为type alloc_percpu(type) 动态为type类型的每CPU变量分配空间,并返回它的地址 free_percpu(pointer) 释放为动态分配的每CPU变量的空间,pointer是起始地址 per_cpu(name, cpu) 获取编号cpu的处理器上面的变量name的副本 get_cpu_var(name) 获取本处理器上面的变量name的副本,该函数禁用内核抢占,主要由__get_cpu_var来完成具体的访问 get_cpu_ptr(name)  获取本处理器上面的变量name的副本的指针,该函数禁用内核抢占,主要由__get_cpu_var来完成具体的访问 put_cpu_var(name) & put_cpu_ptr(name) 表示每CPU变量的访问结束,启用内核抢占(不使用name) __get_cpu_var(name)  获取本处理器上面的变量name的副本,该函数不禁用内核抢占


原子操作

    若干汇编语言指令具有“读-修改-写”类型——也就是说,它们访问存储器单元两次,第一次读原值,第二次写新值。     假定运行在两个CPU上的两个内核控制路径试图通过执行非原子操作来同时“读-修改-写”同一存储器单元,首先,两个CPU都试图读同一单元,但是存储器仲裁器(对访问RAM芯片的操作进行串行化的硬件电路)插手,只允许其中的一个访问而让另一个延迟,然而,当第一个读操作已经完成后,延迟的CPU从那个存储器单元正好读到同一个值(旧值)。然后,两个CPU都试图向那个存储器单元写一新值,总线存储器访问再一次被存储器仲裁器串行化,最终,两个写操作都成功。但是,全局的结果是不正确的,因为两个CPU写入同一(新)值。因此,两个交错的“读-修改-写”操作成了一个单独的操作。     避免由于“读-修改-写”指令引起的竞争条件的最容易的办法,就是确保这样的操作在芯片上是原子的。任何一个这样的操作都必须以单个指令执行,中间不能中断,且避免其他的CPU访问同一存储单元。这些很小的原子操作(atomic operations)可以建立在其他更灵活机制的基础之上以创建临界区。     原子操作可以保证指令以原子的方式执行,执行过程不被打断。它通过把读取和修改变量的行为包含在一个单步中执行,从而防止了竞争的发生,保证操作结果总是一致的。     例如:     int i=9;     线程1:   i++;
    ===>>>   i=9 OR i=8     线程2:   i–-;
    ===>>>   i=9 OR i=8     两个线程并发的执行,导致结果不确定性。原子操作的作用和信号量机制是一样,都是为了防止同时访问临界资源,保证结果的一致性。大多数硬件体系结构要么本来就支持简单的原子操作,要么就提供了锁内在总线的指令,例如x86平台上,就支持CPU锁总线操作,汇编指令前缀“LOCK”就可以将总线锁作,直到指令结束时锁打开;而有些硬件体系结构本身就不太支持原子操作,比如SPARC,但是Linux内核通过一些方法,做到了原子操作。     原子操作在Linux内核里分为原子整数操作原子位操作,下面我们来看看这两个操作用法。     原子整数操作:     针对整数的原子操作只能对atomic_t类型的数据进行处理,之所以没有用C语言的int类型,主要有三个原因:     1、让原子函数只接受atomic_t类型的操作数,可以确保原子操作只与这种特殊类型数据一起使用,防止该类型数据不会传给其它非原子操作。     2、使用atomic_t类型确保编译器不对相应的值进行访问优化。     3、在不同体系结构上实现原子操作的时候,使用atomic_t可以屏蔽其间的差异。     在Linux内核中提供了一系统的原子整数操作函数。 原子整数操作 描述 ATOMIC_INIT(int i) 在声明一个atmoic_t变量时,将它初始化为i int atmoic_read(atmoic_t *v) 原子地读取整数变量v void atmoic_set(atmoic_t *v, int i) 原子地设置v值为i void atmoic_add(atmoic_t *v, int i) 原子地从v值加i void atmoic_sub(atmoic_t *v, int i) 原子地从v值减i void atmoic_inc(atmoic_t *v)  原子地从v值加1 void atmoic_dec(atmoic_t *v) 原子地从v值减1 int atmoic_sub_and_test(int i,atmoic_t *v)  原子地从v值减i,如果结果等于0返回真,否则返回假 int atmoic_add_negative(int i,atmoic_t *v) 原子地从v值减i,如果结果是负数返回真,否则返回假 int atmoic_dec_and_test(atmoic_t *v) 原子地给v减1,如果结果等于0返回真,否则返回假 int atmoic_inc_and_test(atmoic_t *v)  原子地给v加1,如果结果等于0返回真,否则返回假     原子操作最常见的用途就是实现计数器,使用复杂的锁机制来保护一个单纯的计数是很笨拙的,原子操作比起复杂的同步方法来说,给系统带来的开销小,对高速缓存行的影响也小。     原子位操作:
    除了原子整数操作外,内核还提供了一组针对位这一级数据进行操作的函数,位操作函数是对普通的内在地址进行操作的,它的参数是一个指针和一个位号。由于是对普通的指针进程操作,所以没有像atomic_t这样的类型约束。
原子位操作 描述 void set_bit(int nr, void *addr) 原子地设置addr所指对象的第nr位 void clear_bit(int nr, void *addr) 原子地清空addr所指对象的第nr位 void change_bit(int nr, void *addr)  原子地翻转addr所指对象的第nr位 int test_and_set_bit(int nr, void *addr)  原子地设置addr所指对象的第nr位,并返回原先的值 int test_and_clear_bit(int nr, void *addr)  原子地清空addr所指对象的第nr位,并返回原先的值 int test_and_change_bit(int nr, void *addr) 原子地翻转addr所指对象的第nr位,并返回原先的值 int test_bit(int nr, void *addr)  原子地返回addr所指对象的第nr位 void atomic_clear_mask(void *mask, void *addr) 清零mask指定的*addr的所有位 void atomic_set_mask(void *mask, void *addr) 设置mask指定的*addr的所有位

优化和内存屏障

    当使用优化的编译器是,指令并不会严格地按照它们在源代码中出现的顺序执行。例如,编译器可能重新安排汇编语言指令以使寄存器以最优的方式使用。此外,现代CPU通常并行地执行若干条指令,且可能重现安排内存访问,这种重新排序可能极大地加速程序的执行。
    然而,当处理同步时,必须避免指令重新排序,如果放在同步原语之后的一条指令在同步原语本身之前执行,事情很快就会变得失控。事实上,所有的同步原语起优化和内存屏障的作用。
    优化屏障(optimization barrier)原语保证编译程序不会混淆放在原语操作之前的汇编语言指令和放在原语操作之后的汇编语言指令,这些汇编语言指令在C中都由对应的语句。在Linux中,优化屏障就是barrier()宏。     内存屏障(memory barrier)原语确保,在原语之后的操作开始执行之前,原语之前的操作已经完成。因此,内存屏障类似于防火墙,让任何汇编语言指令都不能通过。     在《独辟蹊径品内核》一书中,如此定义内存屏障:为了防止编译器和硬件的不正确优化,使得对存储器的访问顺序(其实就是变量)和书写程序时的访问顺序不一致而提出的一种解决办法。 内存屏障不是一种错误的现象,而是一种对错误现象提出的一种解决方法。
    前面概述了内存屏障,现在我们进行一些详细说明:
    1、为什么会乱序执行?     现在的CPU一般采用流水线来执行指令。一个指令的执行被分划成:取指、译码、访存、执行、写回等若干个阶段。然后,多条指令可以同时存在于流水线中,同时被执行。     指令流水线并不是串行的,并不会因为一个耗时很长的指令在“执行”阶段呆很长时间,而导致后续的指令都卡在“执行”之前的阶段上。相反,流水线是并行的,多个指令可以同时处于同一个阶段,只要CPU内部相应的处理部件未被占满即可。比如说CPU有一个加法器和一个除法器,那么一条加法指令和一条除法指令就可能同时处于“执行”阶段,而两条加法指令在“执行”阶段就只能串行工作。     可见,相比于串行+阻塞的方式,流水线像这样并行的工作,效率是非常高的。     然而,这样一来,乱序可能就产生了。比如一条加法指令原本出现在一条除法指令的后面,但是由于除法的执行时间很长,在它执行完之前,加法可能先执行完了。再比如两条访存指令,可能由于第二条指令命中了cache而导致它先于第一条指令完成。     一般情况下,指令乱序并不是CPU在执行指令之前刻意去调整顺序。CPU总是顺序的去内存里面取指令,然后将其顺序的放入指令流水线。但是指令执行时的各种条件,指令与指令之间的相互影响,可能导致顺序放入流水线的指令,最终乱序执行完成。这就是所谓的“顺序流入,乱序流出”。     指令流水线除了在资源不足的情况下会阻塞之外(如前所述的一个加法器应付两条加法指令的情况),指令之间的相关性也是导致流水线阻塞的重要原因。     CPU的乱序执行并不是任意的乱序,而是以保证程序上下文因果关系为前提的。有了这个前提,CPU执行的正确性才有保证。比如: a++; b=f(a); c--;     由于b=f(a)这条指令依赖于前一条指令a++的执行结果,所以b=f(a)将在“执行”阶段之前被阻塞,直到a++的执行结果被生成出来;而c--跟前面没有依赖,它可能在b=f(a)之前就能执行完。(注意,这里的f(a)并不代表一个以a为参数的函数调用,而是代表以a为操作数的指令。C语言的函数调用是需要若干条指令才能实现的,情况要更复杂些。)     像这样有依赖关系的指令如果挨得很近,后一条指令必定会因为等待前一条执行的结果,而在流水线中阻塞很久,占用流水线的资源。而编译器的乱序,作为编译优化的一种手段,则试图通过指令重排将这样的两条指令拉开一定的距离,以至于后一条指令进入CPU的时候,前一条指令结果已经得到了,那么也就不再需要阻塞等待了。比如将指令重排为: a++; c--; b=f(a);     相比于CPU的乱序,编译器的乱序才是真正对指令顺序做了调整。但是编译器的乱序也必须保证程序上下文的因果关系不发生改变。     2、乱序的后果     乱序执行,有了“保证上下文因果关系”这一前提,一般情况下是不会有问题的。因此,在绝大多数情况下,我们写程序都不会去考虑乱序所带来的影响。     但是,有些程序逻辑,单纯从上下文是看不出它们的因果关系的。比如: *addr=5; val=*data;     从表面上看,addr和data是没有什么联系的,完全可以放心的去乱序执行。但是如果这是在某设备驱动程序中,这两个变量却可能对应到设备的地址端口和数据端口。并且,这个设备规定了,当你需要读写设备上的某个寄存器时,先将寄存器编号设置到地址端口,然后就可以通过对数据端口的读写而操作到对应的寄存器。那么,对前面那两条指令的乱序执行就可能造成错误。     对于这样的逻辑,我们姑且将其称作隐式的因果关系;而指令与指令之间直接的输入输出依赖,也姑且称作显式的因果关系。CPU或者编译器的乱序是以保持显式的因果关系不变为前提的,但是它们都无法识别