首页 > 编程语言 >2.多线程编程的目标与挑战

2.多线程编程的目标与挑战

时间:2023-01-11 14:47:25浏览次数:34  
标签:执行 变量 挑战 编程 线程 处理器 操作 共享 多线程

一.串行、并行与并发

 串行:单工作者依次执行多个任务(一个任务执行完成再执行下一个任务)。

 并行:多工作者同时执行多个任务(每个工作者执行一个任务)。

 并发:单工作者交替执行多个任务(一个任务执行一部分再执行另一个任务的一部分)。

 从软件的角度,要以并发的方式完成几个任务往往需要借助多个线程。其次,软件世界中的并发也未必就比串行的处理效率更高或者效率提高得那么明显。

  • “要以并发的方式完成几个任务往往需要借助多个线程”,我理解是一个线程无法交替的执行多个任务,即使是在线程池中的单个工作者线程,也是一个任务执行完成再执行下一个任务。所以需要用多个线程来封装多个任务,然后由线程调度器来实现并发。

 从硬件的角度,在一个处理器一次只能够运行一个线程的情况下,由于处理器可以使用时间片(Time-slice)分配的技术来实现在同一段时间内运行多个线程,因此一个处理器就能实现并发。而并行则需要靠多个处理器在同一时刻各自运行一个线程来实现。

二.竞态

 竞态(Race Condition):计算的正确性依赖于相对时间顺序(Relative Timing)或者线程的交错(Interleaving)。

  • 竞态不一定导致计算结果的不正确,它只是不排除计算结果时而正确时而错误的可能。
  • 竞态往往伴随着读取脏数据(Dirty Read)问题,即线程读取到一个过时的数据;丢失更新(Lost Update)问题,即一个线程对数据所做的更新没有体现在后续其他线程对该数据的读取上。

2-1 竞态的模式与竞态产生的条件

1.竞态的模式

 竞态模式可分为:read-modify-write(读-改-写)和check-then-act(检测而后行动)。

 read-modify-write(读-改-写)操作,可被细分为①读取一个共享变量的值(read)②根据该值做一些计算(modify)③更新该共享变量的值(write)这三个步骤。例如,自增操作"sequence++"就是就是read-modify-write模式的一个实例。"sequence++"实际上相当于如下几个伪代码表示的几个指令的组合。

load(sequence, r1); // 指令①read:从内存将sequence的值读到寄存器r1(读取共享变量值)
increment(r1); // 指令②modify:将寄存器r1的值增加1(根据共享变量值做一些计算)
store(sequence, r1); // 指令③write:将寄存器r1的内容写入变量sequence所对应的内存空间(更新共享变量)

 一个线程在执行完指令①之后到开始(或正在)执行②这段时间内其他线程可能已经更新了共享变量(sequence)的值,这就使得该线程在执行指令②时使用的是共享变量的旧值(读脏数据)。接着,该线程把根据这个旧值计算出来的结果更新到共享变量,而这又使得其他线程对该共享变量所做的更新被“覆盖”,即造成了更新丢失

 check-then-act(检测而后行动)操作,可被细分为①读取某个共享变量的值②根据该变量决定下一步的动作是什么。例如下if-else示例:

if (sequence >= 999) { // 子操作①check:检测共享变量的值
    sequence = 0; // 子操作②act:下一步的操作
} else {
    sequence++;
}

 一个线程在执行完子操作①到开始(或者正在)执行子操作②的这段时间内,其他线程可能已经更新了共享变量的值而使得if语句中的条件变为不成立,那么此时该线程仍然会执行子操作②,尽管这个子操作所需的前提(if语句中的条件)实际上并未成立!

2.竞态产生的条件

 两个或两个以上的线程同时对共享变量进行写-写读-写操作就会出现竞态。

 如果存在多个线程会同时进行某个read-modify-write操作或者check-then-act操作,就会出现竞态。

 对于局部变量(包括形式参数和方法体内定义的变量),由于不同的线程各自访问的是各自的那一份局部变量,因此局部变量的使用不会导致竞态。例如下的nextSequence()的方法同时具备了check-then-act模式于read-modify-write模式的代码结构,但是由于其使用的变量sequence是一个局部变量(形式参数),因此它不会导致竞态。

  • 但要注意形式参数如果是引用类型的话可能指向的是一个共享变量(对象)
public class NoRaceCondition {

    public int nextSequence(int sequence) {

        // 以下语句使用的是局部变量而非共享变量,不不会产生竞态
        if (sequence >= 9999) {
            sequence = 0;
        } else {
            sequence++;
        }
        return sequence;
    }
}

三.线程安全

 一般而言,如果一个类在单线程环境下能够运行正常,并且在多线程环境下,在其使用方不必为其做任何改变的情况下也能运作正常,就称其是线程安全(Thread-safe)的,相应地我们称这个类具有线程安全性(Thread Safety)。因此一个类如果能够导致竞态,那么它就是非线程安全的;而一个类如果是线程安全的,那么它就不会导致竞态。

 Java标准库中的一些类如ArrayList、HashMap和SimpleDateFormat都是非线程安全的。多线程环境下共享一个HashMap实例(而不采取任何控制措施)可能导致死循环(表现为主机的某个处理器使用率一致为100%)和内存泄漏(最后可能导致Java虚拟机崩溃)。

 线程安全问题表现为三个方面:原子性、可见性和有序性。

四.原子性

 原子(Atomic)的字面意思是不可分割的(Indivisible)。对于涉及共享变量访问的操作,若该操作从其执行线程以外的任意线程来看是不可分割的,那么该操作就是原子操作,相应地称该操作具有原子性。

 不可分割的含义

  • 访问(读、写)某个共享变量的操作从其执行线程以外的任何线程来看,该操作要么已经执行结束要么尚未发生,即其他线程不会“看到”该操作执行了部分 的中间效果。
  • 设O1和O2是访问共享变量V的两个原子操作,这两个操作并非都是读操作。那么一个线程执行O1期间(开始执行而未执行完毕),其他线程无法执行O2。也就是说,访问同一组共享变量的原子操作是不能够被交错的(O1执行一部分,O2执行一部分),这就排除了一个线程执行一个操作期间另一个线程读取或者更新该操作所访问的共享变量而导致的干扰(读脏数据)和冲突(丢失更新)的可能。

 理解原子操作这个概念还需要注意以下两点:

  • 原子操作是针对共享变量操作而言的。仅涉及局部变量访问的操作无所谓是否是原子的,或者干脆把这一类操作看成是原子操作。
  • 原子操作时从该操作的执行线程以外的线程来描述的,也就是说他只有在多线程环境下有意义。换言之,单线程环境下一个操作无所谓原子性,或者干脆把这一类操作都看成原子操作。

 Java中有两种方式来实现原子性。

  • 一种是使用锁(Lock)。锁具有排他性,即它能够保障一个共享变量在任意时刻只能够被一个线程访问。这就排除了多个线程在同一时刻访问同一个共享变量而导致干扰和冲突的可能,即消除了竞态。
  • 另一种是利用处理器提供的专门CAS(Compare-and-Swap)指令,CAS指令实现原子性的方式与锁实现原子性的方式实质上是相同的,差别在于锁通常是在软件这一层次实现的,而CAS是直接在硬件(处理器和内存)这一层次实现的,可被看作“硬件锁”。

 在Java语言中,long型和double型以外的任何类型的变量的写操作都是原子操作,即对基础类型(long/double除外,仅包括byte, short, int, float, boolean, char)的变量和引用类型变量的写操作都是原子的。这点由Java语言规范(JLS, Java Language Specification)规定,由Java虚拟机具体实现。

  • Java语言中针对任何变量的读操作都是原子操作。
public class AtomicityExample {

    private HostInfo hostInfo;

    /**
     * 一个线程更新主机信息时,可能刚执行完语句①时,其他线程就调用了connectToHost方法而读取到了不成对的ip和port信息(脏数据)
     */
    public void updateHostInfo(String ip, int port) {
        // 更新操作由以下两个子操作构成,所以更新操作不是原子操作
        hostInfo.setIp(ip); // 语句①
        hostInfo.setPort(port); // 语句②
    }

    /**
     * 改进updateHostInfo方法
     * 利用Java语言对变量(long/double型变量除外)写操作的原子性的保障,解决原子操作问题。
     */
    public void updateHostInfoAtomic(String ip, int port) {
        HostInfo newHostInfo = new HostInfo();
        newHostInfo.setIp(ip);
        newHostInfo.setPort(port);
        this.hostInfo = newHostInfo;
    }

    public void connectToHost() {
        String ip = hostInfo.getIp();
        int port = hostInfo.getPort();
        connectToHost(ip, port);
    }

    public void connectToHost(String ip, int port) {
        // ...
    }


    public static class HostInfo {
        private String ip;
        private int port;

        // ...
    }
}

 Java语言规范特别地规定对于volatile关键字修饰的long/double型变量的写操作具有原子性。但volatile关键字进能够保障变量写操作(赋值)的原子性,它并不能保障其他操作(比如read-modify-write操作和check-then-act操作)的原子性。

 竞态模式中的read-modify-write模式和check-then-act操作本身不是原子操作,可以使用Java语言提供的锁等机制使其具有原子性。

五.可见性

 可见性就是指一个线程对共享变量的更新的结果对于读取相应共享变量的线程而言是否可见的问题。多线程程序在可见性方面存在问题则意味着某些线程读取到了旧数据(Stale Data)。

5-1 编译器优化导致的可见性问题

可见性问题Demo

 假设要执行一个比较耗时的任务,如果这个任务在指定时间内仍然没有结束,那么就主动将其取消。

public class VisibilityDemo {

    public static void main(String[] args) throws InterruptedException {
        TimeConsumingTask timeConsumingTask = new TimeConsumingTask();
        Thread thread = new Thread(timeConsumingTask);
        thread.start();

        // 指定的时间内任务没有结束的话,就将其取消
        TimeUnit.SECONDS.sleep(10);
        timeConsumingTask.cancel();
    }

    static class TimeConsumingTask implements Runnable {
        private boolean toCancel = false;

        @Override
        public void run() {
            while (!toCancel) {
                System.out.println("executing...");
                Tools.randomPause(50);
            }
        }

        public void cancel() {
            toCancel = true;
            System.out.println(this + " canceled.");
        }
    }
}

 这个程序在Java虚拟机以server模式(而不是client模式)运行的情况下会一直运行而不结束,这说明main线程执行TimeConsumingTask的cancel方法并没有达到其目标——使子线程停止。这说明子线程所读到的toCancel变量始终是false,尽管某个时刻main线程会将攻共享变量toCancel的值更新为true。这就产生了可见性问题。

  • 我在启动时加了-server虚拟机参数还是能正常停止。启动方式不对?

 这个例子中的可见性问题是因为代码没有给JIT编译器足够的提示而使得其认为状态变量toCancel只有一个线程对其进行访问,从而导致JIT编译器为了避免重复读取状态变量toCancel以提高代码的运行效率,而将TimeConsumingTask的run方法中的while循环优化成如下代码等效的本地代码(机器码):

if (!toCancel) {
    while (true) {
        System.out.println("executing...");
        Tools.randomPause(50);
    }
}

 这种优化被称为循环不变表达式外提(Loop-invariant Code Motion),也称循环提升(Loop Hoisting),这导致了死循环。

解决方案

 在TimeConsumingTask的实例变量toCancel的声明中添加一个volatile关键字即可,即:

private volatile boolean toCancel = false;

 volatile关键字所起的一个作用:

  • 提示JIT编译器被修饰的变量可能被多个线程共享,以阻止JIT编译器做出可能导致程序运行不正常的优化。
  • 读取一个volatile关键字修饰的变量会使相应的处理器执行刷新处理器缓存的动作,从而保障了可见性(详见5-2)。

5-2 计算机存储系统导致的可见性问题

1.寄存器

 程序中的变量可能会被分配到寄存器(Register)而不是主内存中进行存储。每个处理器都有其寄存器,而一个处理器无法读取另一个处理器上的寄存器的内容。因此,如果两个线程分别运行在不同的处理器上,而这两个线程所共享的变量却被分配到寄存器上进行存储,那么可见性问题就会产生。

2.主内存

 即便某个共享变量是被分配到主内存中进行存储的,也不能保证该变量的可见性。因为处理器对主内存的访问并不是直接访问,而是通过其高速缓存(Cache)进行的。一个处理器上运行的线程对变量的更新可能只是更新到该处理器的写缓冲器(Store Buffer)中,还没有达到该处理器的高速缓存中,更不用说到主内存中了。

图 5-2-1 处理器结构图(根据描述所绘的草图,与真实结构可能有偏差)         

 一个处理器的写缓冲器中的内容无法被另一个处理器读取,因此运行在另一个处理器上的线程无法看到这个线程对某个共享变量的更新。

3.无效化队列

 即便一个处理器上运行的线程对共享变量的更新结果被写入该处理器的高速缓存,由于该处理器将这个变量更新的结果通知给其他处理器的时候,其他处理器可能仅仅将这个更新通知的内容存入无效化队列(Invalidate Queue)中,而没有直接根据更新通知的内容更新器高速缓存的相应内容,这就导致了其他处理器上运行的线程后续再读取相应的共享变量时,从相应处理器的高速缓存中读取到的变量值是一个过时的值。

处理器并不是直接与主内存(RAM)打交道而执行内存的读、写操作,而是通过寄存器(Register)、高速缓存(Cache)、写缓冲器(Store Buffer)和无效化队列(Invalidate Queue)等部件执行内存的读、写操作的。从这个角度来看,这些部件相当于主内存的副本,因此这里将这些部件统称为处理器对主内存的缓存,简称处理器缓存

解决方案

 虽然一个处理器的高速缓存中的内容不能被另一个处理器直接读取,但是一个处理器可以通过缓存一致性协议(Cache Coherence Protocol)来读取其他处理器的高速缓存中的数据,并将读到的数据更新到该处理器的高速缓存中。这种一个处理器从其自身处理器缓存以外的其他存储部件读取数据并将其反映(更新)到该处理器的高速缓存的过程,称之为缓存同步

 缓存同步使得一个处理器(上运行的线程)可以读取到另一个处理器(上运行的线程)对共享变量所做的更新,即保障了可见性。

 为了保障可见性,必须使一个处理器对共享变量所做的更新最终被写入该处理器的高速缓存或者主内存中(而不是始终停留在其写缓存中),这个过程被称为冲刷处理器缓存。并且,一个处理器在读取共享变量的时候,如果其他处理器在此之前已经更新了该变量,那么该处理器必须从其他处理器的高速缓存或者主内存中对相应的变量进行缓存同步,这个过程被称为刷新处理器缓存

 因此,可见性的保障是通过使更新共享变量的处理器执行冲刷处理器缓存的动作,并使读取共享变量的处理器执行刷新处理器缓存的动作来实现的。

5-3 共享变量的相对新值和最新值

 对于同一个共享变量而言,一个线程更新了该变量的值后,其他线程能够读到这个更新后的值,那么这个值就被称为该变量的相对新值。如果读取到这个变量的线程在读取使用该变量的时候其他线程无法更新该变量的值,那么该线程读取到的相对新值就被称为该变量的最新值

 可见性的保障仅仅意味着一个线程能够读取到共享变量的相对新值,而不能保障该线程能够读到相应变量的最新值(需要用锁来保障)。

5-4 单处理器系统的可见性问题

 单处理器系统中实现多线程编程也可能出现可见性问题。

 在目标运行环境是单处理器的情况下,多线程并发执行实际上是通过时间片(Time Slice)分配实现的。此时虽然多个线程是运行在同一个处理器上的,但是由于发生上下文切换(Context Switch)的时候,一个线程对寄存器变量的修改会被作为该线程的线程上下文保存起来,这就导致另外一个线程无法“看到”该线程对这个变量的修改。

5-5 可见性与原子性的联系与区别

 原子性描述的是一个线程对共享变量的更新,从另外一个线程的角度来看,这个更新要么完成了要么尚未发生,而不是进行中的一种状态。因此,原子性可以保证一个线程所读取到的共享变量的值要么是该变量的初始值要么是该变量的相对新值,而不是更新过程中的一个相当于“半成品”的值。

 设Processor0, Processor1, Processor2上的3个线程按照如表5-4-1所示的线程交替执行。

表5-4-1 展示原子操作的一个示例线程交错顺序

处理器

时间

Processor0 Processor1 Procerssor2
t1 a=1 (执行其他操作) (执行其他操作)
t2 (执行其他操作) a=2 (执行其他操作)
t3 (执行其他操作) (执行其他操作) b=a+1
a为int型共享变量,其初始值为0

 原子性可以保证在t3时刻Processor2上的线程读取到的共享变量a的值要么为0,要么为1或者2,但是它并不能保证该值是0、1或2。但如果共享变量a的类型为long或者double,此时由于Java语言规范不保证对这种类型变量写操作的原子性,因此t3时刻Processor2上的线程读取到的a的值可能为0、1、2,也可能是其他看起来在代码中从来不存在的一个值(中间值)。

 可见性描述的是一个线程对共享变量的更新对于另外一个线程而言是否可见的问题。保障可见性意味着一个线程可以可以读取到上一次更新的值(可能是结果值,也可能是中间值)。例如,上述例子中保障可见性可以保证t3时刻Processor2上的线程读取到的共享变量a的值为上次更新(Processor1上的线程在t2时刻做的a=2的更新)的值,可能是2,也可能是一个代码中从来不存在的值(中间值)。(这里对可见性的理解是自己的理解,与书上内容不一致。书上可能默认说的是这里的int型变量,默认保证了原子性)

总结来说:

原子性保证整个更新过程完成,可见性保证上次的更新(可能是更新结果,也可能是更新中间值)可见。如果只有原子性没有可见性,那么其他线程可能看不见更新结果;如果只有可见性没有原子性,那么其他线程看见的可能是更新过程中的中间值。

5-6 线程的启动、停止与可见性

 Java语言规范(JLS, Java Language Specification)保证,父线程在启动子线程之前对共享变量的更新对于子线程来说是可见的。

  • 父线程在子线程启动之后对共享变量的更新对于子线程来说可能是可见的也可能是不可见的(没有保证)。

 Java语言规范保证一个线程终止后,该线程对共享变量的更新对于调用该线程的join方法的线程而言是可见的。

总结起来就是时间顺序的可见性,后面的过程能看到前面过程对共享变量的更新。

六.有序性

 有序性(Ordering)关注的是在什么情况下一个处理器上运行的一个线程所执行的内存访问操作在另一个处理器上运行的其他线程看来是乱序的(Out of order)。

 乱序是指内存访问操作的顺序看起来像是发生了变化。

有序性的讨论中假定每个线程都是运行在各自的处理器上,不考虑一个处理器基于时间片(Time Slice)分时而实现的多线程。

6-1 重排序的概念

 顺序结构是结构化编程中一种基本结构,它表示希望某个操作必须先于另外一个操作执行。两个操作即便可以用任意一种顺序执行,但是反映在代码上这两个操作也总是有一个固定的先后顺序。但是在多核处理器环境下,这种操作执行顺序可能是没有保障的:编译器可能改变两个操作的先后顺序;处理器可能不是完全依照程序的目标代码所指定的指令顺序执行指令;另外,一个处理器上执行多个操作,从其他处理器的角度来看其顺序可能与目标代码所指定的顺序不一致。这种现象叫做重排序(Reordering)。

 重排序是对内存访问有关的操作(读和写)所做的一些优化,可以在不影响,它可以在不影响单线程正确性的情况下提升程序的性能。但是,它可能对多线程程序的正确性产生影响,即它可能导致线程安全问题。

 重排序的来源包括编译器(在Java平台基本是指JIT编译器)、处理器和存储子系统(包括写缓冲器Store Buffer、高速缓存Cache)。

 源代码顺序(Source Code):源代码中所指定的内存访问操作顺序;

 程序顺序(Program Order):在给定处理器上运行的目标代码(Object Code)所指定的内存访问操作顺序。尽管Java虚拟机执行Java代码有两种方式:解释执行(被执行的是字节码Byte Code)和编译执行(被执行的是机器码)。这里仅将目标代码定义为字节码(javac静态编译的结果.class文件)

 执行顺序(Execution Order):内存访问操作在给定处理器上的实际执行顺序

 感知顺序(Perceived Order):给定处理器所感知到(看到)的该处理器及其他处理器的内存访问操作发生的顺序。

 将重排序划分为指令重排序(Instruction Reorder)和存储子系统排序两种,如表6-1-1所示。

表2-3 重排序类型
重排序类型 重排序表现 重排序来源(主体)
指令重排序 程序顺序与源代码顺序不一致 编译器(javac)一般不会发生
执行顺序与程序顺序不一致 JIT编译器、处理器
存储子系统重排序 源代码顺序、程序顺序和执行顺序这三者保持一致,但是感知顺序与执行顺序不一致 高速缓存、写缓冲器

 重排序的特征:

  • 重排序可能导致线程安全问题;
  • 重排序不是必然出现的。

6-2 指令重排序

 在源代码顺序与程序顺序不一致,或者程序顺序与执行顺序不一致的情况下,就认为发生了指令重排序(Instruction Reorder)。指令重排序是一种动作,它确确实实地对指令的顺序做了调整,其重排序的对象是指令。

1.JIT编译器指令重排序

Java平台包含两种编译器:静态编译器(javac)和动态编译器(JIT编译器)。前者的作用是将Java源代码(.java文本文件)编译为字节码(.class二进制文件),它是在代码编译阶段介入的。后者的作用是将字节码动态编译为Java虚拟机宿主机的本地代码(机器码),它是在Java程序运行过程中介入的。

 静态编译器(Javac)基本上不会执行指令重排序,而动态编译器(JIT编译器)则可能执行指令重排序。

JIT编译器指令重排序Demo

public class JITReorderingDemo {
    private final int externalData = 1;

    private Helper helper;

    /**
     * 类似于生产者线程执行的任务:将共享变量helper更新为一个新创建的Helper实例
     */
    public void createHelper() {
        // 共享变量helper是引用类型,能保证赋值的原子性,但不能保证可见性
        helper = new Helper(externalData);
    }

    /**
     * 类似于消费者线程执行的任务:读取共享变量helper所引用的实例,并计算该实例的所有字段(payloadA-payloadD)的值之和作为其返回值
     */
    public int consume() {
        int sum = 0;

        /*
         * 由于未对共享变量helper进行任何处理(比如采用volatile关键字修饰该变量),
         * 因此,这里可能存在可见性问题,即消费者线程读取到的变量值可能为null
         */
        final Helper observedHelper = helper;
        if (null == observedHelper) {
            sum = -1;
        } else {
            sum = observedHelper.payloadA + observedHelper.payloadB + observedHelper.payloadC + observedHelper.payloadD;
        }

        return sum;
    }

    static class Helper {
        int payloadA;
        int payloadB;
        int payloadC;
        int payloadD;

        public Helper(int externalData) {
            this.payloadA = externalData;
            this.payloadB = externalData;
            this.payloadC = externalData;
            this.payloadD = externalData;
        }
    }

 分别使用多个线程并发地执行createHelper方法和consume方法,并统计consume方法多次执行的返回值。由于createHelper方法创建Helper实例的时候使用的构造参数externalData值为1,因此这样看来consume方法的返回值“理所当然”地应该是4,然而在20万次调用consume方法的结果中:

表6-1-1 JIT编译器指令重排序Demo
sum的值 发生的次数 含义
-1 8 consume读取到的共享变量helper为null
0 2 helper不为null,但是未被初始化
1 0 helper只有1个字段被初始化
2 1 helper只有2个字段被初始化
3 4 helper只有3个字段被初始化
4 199985 helper被完整初始化

原因

 createHelper中唯一的语句:

helper = new Helper(externalData);

可以被分解为以下几个子操作(伪代码表示):

// 子操作①:分配Helper实例所需的内存空间,并获得一个指向该空间的引用
objRef = allocate(Helper.class);
// 子操作②:调用Helper类的构造器初始化objRef引用指向的Helper实例
invokeConstructor(objRef);
// 子操作③:将Helper实例引用objRef赋值给实例(共享)变量helper
helper = objRef;

JIT编译器编译字节码的时候并不是每次都按照上述源代码顺序生成相应的机器码(汇编代码):JIT编译器将子操作③相应的指令重排到子操作②相应的指令之前,即JIT编译器在初始化Helper实例之前可能已经将对该实例的引用写入helper实例变量。这就导致了其他线程(consume方法的执行线程)看到helper实例变量(不为null)的时候,该实例变量所引用的对象可能还没有被初始化或者未初始化完毕(即相应构造器中的代码未执行结束)

2.处理器指令重排序

顺序读取-乱序执行-顺序提交

 处理器也可能执行指令重排序,这使得执行顺序与程序顺序不一致。处理器对指令进行重排序也被称为处理器的乱序执行(Out-of-order Execution)。现代处理器为了提高指令执行效率,往往不是按照程序顺序逐一执行指令的,而是动态调整指令的顺序,做到哪条指令先就绪就先执行哪条指令(例如一条指令所需操作数都已经准备好),这就是处理器的乱序执行。在乱序执行的处理器中,指令是一条一条按照程序顺序被处理器读取的(亦称“顺序读取”),然后指令中哪条指令先就绪了哪条就会被先执行,而不是完全按照程序顺序执行(亦“乱序执行”)。这些指令的执行结果(要进行写寄存器或者写内存的操作)会被先存入重排序缓冲器(ROB, Reorder Buffer),而不是直接写入寄存器或者主内存。重排序缓冲器会将各个指令的执行结果按照相应指令被处理器读取的顺序提交(Commit,即写入)到寄存器或者内存中去(亦称“顺序提交”)。

 在乱序执行的情况下,尽管指令的执行顺序可能没有完全依照程序顺序,但是由于指令的执行结果的提交(即反映到寄存器和内存中)仍然是按照程序顺序来的,因此处理器的指令重排序并不会对单线程程序的正确性产生影响。

猜测执行

 处理器的乱序执行还采用了一种猜测执行(Speculation)的技术。猜测执行技术就好比开车遇到岔路口的情形:如果不确定其中哪条路能够能够通往目的地,但是可以凭猜测走其中一条路,万一猜错了就调头回到岔路口走另外一条路。

 猜测执行能够造成if语句的语句体先于其条件语句被执行的效果,即可能导致指令重排序。例如,对于如下代码中的语句③(if语句)和语句⑤,处理器可能会先逐一读取数组data中的各元素,并计算这些元素的和sum,将其临时存放到ROB中,接着再去读取变量ready的值。如果ready为true,那么再将ROB中临时存放的sum的值写到主内存中(通过高速缓存)。相反,如果ready的值为false,那么通过丢弃ROB中临时存放的sum的值就可以实现该if语句的语句体(语句⑤)没有被执行到的效果。虽然这不会对单线程程序的正确性产生影响,但是它可能导致多线程程序出现非预期的结果:在writer和reader方法由不同线程并发执行的情况下,语句⑤先于语句③被执行使得data数据的内容被提前读取,此时数据的内容可能是其初始值([1,2,3,4,5,6,7,8]),而reader方法在源代码中先判断ready变量的值再读取data数组(程序顺序)的目的时希望能够读到writer方法执行线程更新后的数组内容([1,1,1,1,1,1,1,1])。因此猜测执行可能使这个多线程程序出现非预期的结果。

public class SpeculativeLoadExample {

    // 共享变量ready, data未加volatile,可能导致可见性问题
    private boolean ready = false;
    private int[] data = new int[]{1, 2, 3, 4, 5, 6, 7, 8};

    public void writer() {
        int[] newData = new int[]{1, 2, 3, 4, 5, 6, 7, 8};
        for (int i = 0; i < newData.length; i++) {

            // 此处包含读内存操作
            newData[i] = newData[i] - i;
        }
        data = newData; // 语句①

        // 此处包含写内存操作
        ready = true; // 语句②
    }

    public int reader() {
        int sum = 0;
        int[] snapshot;

        if (ready) { // 语句③(if语句)
            snapshot = data;
            for (int i = 0; i < snapshot.length; i++) { // 语句④(for循环)
                sum += snapshot[i]; // 语句⑤
            }
        }

        return sum;
    }
}

6-3 保证内存访问的顺序性

 保证感知顺序与源代码顺序一致,即有序性。

 有序性的保障可以理解为从逻辑上部分禁止重排序,但这并不意味着从物理上禁止重排序而使得处理器完全按照源代码的顺序执行指令,那样性能太低。

 从底层的角度来说,禁止从排序是通过调用处理器提供的相应指令(内存屏障Memory Barrier)来实现的。volatile关键字、synchronized关键字都能够实现有序性。

6-4 可见性与有序性的联系与区别

 可见性是有序性的基础。可见性描述的是一个线程对共享变量的更新对于另一个线程是否可见,或者说什么情况下可见的问题。有序性描述的是,一个处理器上运行的线程对于共享变量所做的更新,在其他处理器上运行的线程是以什么样的顺序观察到这些更新的。

可见性保证多次更新中的每次更新对其他线程是可见的,有序性决定其他线程看到的多次更新是什么样的顺序。

 有序性影响可见性。由于重排序的作用,一个线程对共享变量的更新对于另外一个线程而言可能变得不可见。例如,假如JIT编译器将父线程中对共享变量data的更新语句重排序到子线程的启动语句(thread.start())之后,那么父线程对共享变量data的更新对于子线程thread而言的可见性就无法保证了(事实上Java语言规范不允许这样的重排序)。

七.上下文切换及其产生的原因

 上线文切换(这里仅讨论线程的上下文切换)(Context Switch)在某种程度上可以看作多个线程共享同一个处理器的产物。

7-1 上下文切换机器产生的原因

 在单处理器(Uni-processor)上也能够以多线程的方式实现并发,即一个处理器可以在同一时间段内运行多个线程。单处理器上的多线程是通过时间片分配的方式实现的。时间片决定了一个线程可以占用处理器运行的时间长度。当一个进程中的一个线程①由于其时间片用完或者②其自身的原因(比如,它需要稍后再继续运行)被迫或者主动暂停其运行时,另外一个线程(可能是同一个进程或者其他进程中的一个线程)可以被操作系统(线程调度器)选中占用处理器开始或者继续其运行。这种一个线程被暂停,即被剥夺处理器的使用权,另外一个线程被选中开始或者继续运行的过程就叫做线程上下文切换。相应地,一个线程被剥夺处理器使用权而被暂停运行就被称为切出(Switch Out);一个线程被操作系统选中占用处理器而开始或者继续其运行就被称为切入(Switch In)

 我们看着是连续运行的线程,实际上是以断断续续运行的方式使其任务进展的。这种方式意味着在切出和切入的时候操作系统需要保存和恢复相应线程的进度信息,即切入和切出那一刻相应线程所执行的任务进行到什么程度了(如①计算的中间结果②执行到了哪条指令)。这个进度信息就被称为上下文(Context)。它一般包括通用寄存器(General Purpose Register)的内容和程序计数器(Program Counter)的内容。在切出时,操作系统需要将上下文保存到内存中,以便被切出的线程稍后占用处理器继续其运行时能够在此基础上进展。在切入时,操作系统需要从内存中加载(恢复)被选中线程的上下文,以在之前运行的基础上继续进展。

 从Java应用的角度来看,一个线程的生命周期状态在RUNNING状态和非RUNNING状态(包括READY, BLOCKED, WAITING, TIMED_WAITING中任意一个子状态)之间切换的过程就是一个上下文切换的过程。

  • 当一个线程的生命周期状态由RUNNING转为非RUNNING状态时,线程被暂停即被切出;
  • 当一个线程的生命周期状态由非RUNNABLE状态进入READY状态时,线程被唤醒。线程被唤醒被操作系统选中占用处理器继续运行时(READY->RUNNNING),操作系统会恢复之前为该线程保留的上下文,即切入。

                     图 7-1-1 线程生命周期状态切换与切出切入的关系

7-2 上下文切换的分类及具体诱因

 按照导致上下文切换的因素划分,可以将上下文切换分为自发性上下文切换(Voluntary Context Switch)和非自发性上下文切换(Involuntary Context Switch)。

自发性上下文切换

 自发性上下文切换指线程由于自身因素导致的切出。从Java平台的角度看,一个线程在其运行过程中执行下列任意一个方法都会引起自发性上下文切换。

  • Thread.sleep(long milis);(RUNNING->TIMED_WAITING)
  • Object.wait()/wait(long timeout)/wait(long timeout, int nanos)(RUNNING->TIMED_WAITING)
  • Thread.yield()(RUNNING->READY)也可能不会导致上下文切换,具体取决于线程调度器
  • Thread.join()/Thread.join(long timeout)(RUNNING->WAITING)
  • LockSupport.park()(RUNNING->BLOCKED)
  • 阻塞式I/O或者等待其他线程持有的锁(RUNNING->BLOCKED)

非自发性上下文切换

 非自发性上下文切换指线程由于线程调度器的原因被迫切出。导致非自发性上下文切换的常见因素包括:

  • 线程的时间片用完
  • 有一个比被切成线程优先级更高的线程需要被运行

 从Java平台的角度看,Java虚拟机的垃圾回收(Garbage Collect)动作也可能导致非自发性上下文切换。这是因为垃圾回收器在执行垃圾回收的过程中可能需要暂停所有应用线程(Stop-the-world)才能完成其工作,比如在主要回收(Major Collection)过程中,垃圾回收器在对Java虚拟机堆内存区域进行整理(Compact)的时候需要先停止所有应用线程。

 7-3 上下文切换的开销

 上下文切换是必要的。即使是在多核处理器系统中上下文切换也是必要的,因为一个系统上需要运行的线程的数量相对于这个系统所拥有的处理器数量是要多得多。

 上下文切换的开销包括直接开销和间接开销。

 多线程编程相比于单线程编程来说,意味着更多的上下文切换。因此,多线程编程不一定比单线程编程的计算效率更高。

直接开销

 操作系统保存和恢复上下文所需的开销,这主要是处理器时间开销。

 线程调度器进行线程调度的开销(比如,按照一定的规则决定哪个线程会占用处理器运行)。

间接开销

 处理器高速缓存重新加载的开销。一个被切出的线程可能稍后在另外一个处理器上被切入继续运行。由于这个处理器之前可能未运行过该线程,那么这个线程在其继续运行过程中需要访问的变量仍然需要被该处理器重新从主内存或者通过缓存一致性协议从其他处理器加载到高速缓存之中。这有一定时间消耗。

 上下文切换也可能导致整个一级高速缓存中的内容被冲刷(Flush),即一级高速缓存中的内容会被写入下一级高速缓存(如二级高速缓存)或者主内存(RAM)中。

 八.资源调度策略

 资源调度的一种常见的策略就是排队。资源调度器(负责资源调度)内部维护一个等待队列,在存在资源争用的情况下,申请失败(即没有获得资源独占权)的资源申请者(线程)会被存入该队列。通常,被存入等待队列的线程会被暂停(WAITING状态?)。当相应的资源被其持有线程释放时,等待队列中的一个线程会被选中并被唤醒WAITING->READY->RUNNING)而获得再次申请资源的机会。被唤醒的线程如果申请到资源的独占权,那么该线程就会从等待队列中移除;否则,该线程仍然会停留在等待队列中等待再次申请的机会,即该线程会再次被暂停(RUNNING->WAITING)。因此,等待队列中的等待线程可能经历若干次暂停与唤醒才能获得相应资源的独占权。

 抢占资源成功的申请者获得相应资源得独占权,而抢占失败的申请者会进入等待队列。公平调度策略中的资源申请者总是按照先来后到的顺序来获得资源的独占权。而非公平的调度策略则允许插队现象,即资源持有者线程释放其资源独占权的时候,等待队列中的一个线程会被唤醒再次申请相应的资源(WAITING->READY->RUNNING->申请资源),而在这个过程中另外一个申请该资源的活跃线程(RUNNABLE,不在资源等待队列中)可以与这个被唤醒的线程共同参与相应资源的抢占。因此,非公平调度策略中被唤醒的线程不一定就能够成功申请到资源。由此可见,在极端的情况下非公平调度策略可能导致等待队列中的线程永远无法获得其所需的资源,即出现饥饿(Starvation)现象,而公平调度策略则可以避免线程饥饿现象。

8-1 公平调度策略与非公平调度策略的比较

1.非公平调度策略

 一般来说,非公平调度策略的吞吐率较高,即单位时间内它可以为更多的申请者调配资源。其缺点是,从申请者个体的角度来看这些申请者获得相应资源的独占权所需时间的偏差可能较大,即有的线程很快就申请到资源而有的线程则要经历若干次暂停与唤醒才能成功申请到资源。

2.公平调度策略

 公平调度策略的吞吐率较低,这是其维护资源独占权的授予顺序的开销比较大(主要是线程的暂停与唤醒导致的上下文切换,而非公平调度策略允许一些RUNNING状态的线程参与抢占资源,这些线程避免了上下文切换)的结果。其优点是,从申请者个体的角度来看这些申请者获得相应资源的独占权所需时间的偏差可能比较小,即每个申请者申请到资源所需的时间(资源持有者线程释放资源->申请者获取到资源这段时间)基本相同。

8-2 调度策略的选择

 在非公平调度策略中,资源的持有线程释放该资源的时候等待队列中的一个线程会被唤醒,而该线程从被唤醒到其继续运行(WATING->READY->RUNNING)可能需要一段时间。在该段时间内,新来的线程(活跃线程)可以先被授予该资源的独占权。如果这个新来的占用该资源的时间不长,那么它完全可能在被唤醒继续其运行前释放相应的资源,从而不影响该被唤醒的线程申请资源。这种情形下,非公平调度策略带来两个好处——①它可能减少上下文切换的次数(例如,前面例子中新来的线程无需被暂停和唤醒就申请到资源),②它可能提高吞吐率(例如,前面例子中的等待线程在被唤醒到其继续运行期间,新来一个线程获得独占权->完成操作->释放相应资源)。相反,如果多数(或者每个)线程占用资源的时间相当长,那么允许新的线程抢占被释放的资源丝毫不会带来任何好处,反而会导致被唤醒的线程需要再次经历暂停和唤醒,从而增加了上下文切换。

 因此,多数(或者每个)线程占用资源的时间相当长的情况下不适合使用非公平调度策略。因此,在没有特别需要的情况下,默认选择非公平调度策略即可。在资源的持有线程占用资源的时间相对长的情况下,或者对资源申请所需的时间偏差有所要求(即时间偏差要求较小)的情况下,可以考虑使用公平调度策略。

标签:执行,变量,挑战,编程,线程,处理器,操作,共享,多线程
From: https://www.cnblogs.com/certainTao/p/17017965.html

相关文章

  • 场景编程集锦 - BMI指数与健身达人
    1.场景描述  BMI指数(身体质量指数,英文BodyMassIndex)是用体重公斤数除以身高米数的平方得出的数字,是目前国际上通用的衡量人体胖瘦程度以及是否健康的一个标准。“身......
  • 不用VS,使用NET 7.0 SDK (v7.0.101)编程c#控制台应用程序方法
    摘要:如果没有vs环境,也可以编程c#控制台应用程序学习c#,方法步骤有下面几个步骤。1、下载NET7.0SDK(v7.0.101)安装网址https://dotnet.microsoft.com/zh-cn/download/do......
  • 强哥说Java--网络编程
    网络编程​​网络编程概述​​​​网络通信要素概述​​​​通信要素1:IP和端口号​​​​ip地址​​​​端口号​​​​InetAddress类​​​​实例​​​​通信要素2:网......
  • 强哥说Java--Java多线程
    Java多线程​​前言​​​​总目录​​​​一、基本概念​​​​1.程序​​​​2.进程​​​​3.线程​​​​3.实例理解​​​​4.单核CPU和多核CPU的理解​​​​5.并行和......
  • 极客编程python入门-图形界面
    图形界面Python支持多种图形界面的第三方库,包括:Tk、wxWidgets、Qt、GTK等等。第一步是导入Tkinter包的所有内容:fromtkinterimport*第二步是从​​Frame​​​派生一个​......
  • 读编程与类型系统笔记04_类型安全
    1. 避免基本类型偏执1.1. 把值声明为基本类型,并对其意义做一些隐含的假定时1.1.1. 例如:使用number表示邮编1.1.2. 例如:使用string表示电话号码1.2. 定义类型来显......
  • mt_Day3:编程案例练习
    编程练习1.买机票publicclasstest1{publicstaticvoidmain(String[]args){//1.用户输入月份,票价,仓位类型System.out.println("请输入月......
  • 场景编程集锦 - 捏紧你的钱袋子
    1.场景描述下面是一通电话的通话内容:陌生人:“李总,最近还好吗?”李总:“您是哪一位?”陌生人:“我的声音听不出来啦?”李总:“有点耳熟,想不起来。”陌生人:“猜猜我是谁?”......
  • 【c&c++】socket编程中的 htons()
      在刚刚接触socket时,遇到了htons()函数,就直接懵逼了,这是什么东西,有什么用?就查了一些资料。  htons()是网络字节序与主机字节序之间转换的函数。用生活中的例子来......
  • Java网络编程
    Java网络编程P617-627网络通信要素IP和端口号网络通信协议InetAddress类importjava.net.InetAddress;importjava.net.UnknownHostException;/***一、网......