进程、线程、协程

文章目录

  • executor
    • 进程、线程、协程
      • 并发模型
        • 1. 单进(线)程·循环处理请求
        • 2. 多进程
        • 4. 单线程·回调(callback)和事件轮询
          • Nginx
          • Node.js
        • 3. 多线程
        • 总结
        • PS:函数式编程
    • 线程
      • 1、如何创建并运行java线程
      • 2、线程的六种状态
      • 3、Java线程中断
        • **什么是中断?**
        • 中断的相关方法
        • **如何使用中断?**
          • **1.设置中断监听**
          • **2.触发中断**
        • **如何安全地停止线程?**
          • **1.循环标记变量**
          • **2.循环中断状态**
        • **如何处理中断?**
    • callable & future
      • callabe
      • Future常用方法
      • Future模式(FutureTask)
    • ForkJoin
      • 一、简介
      • 二、工作窃取算法
      • 三、使用示例
      • 四、核心组件
    • JUC类图

executor

进程、线程、协程

进程是系统进行资源分配的一个独立单位。这些资源包括:用户的地址空间,实现进程(线程)间同步和通信的机制,已打开的文件和已申请到的I/O设备,以及一张由核心进程维护的地址映射表。内核通过进程控制块(PCB,process control block)来感知进程。

线程是调度和分派的基本单位。内核通过线程控制块(TCB,thread control block)来感知线程。

线程本身不拥有系统资源,而是仅有一点必不可少的、能保证独立运行的资源,如TCB、程序计数器、局部变量、状态参数、返回地址等寄存器和堆栈。同一进程的所有线程具有相同的地址空间,线程可以访问进程拥有的资源。多个线程可并发执行,一个进程含有若干个相对独立的线程,但至少有一个线程。

线程的有不同的实现方式,分内核支持线程(KST,Kernel Supported Threads)和用户级线程(UST, User Supported Threads)。内核级线程的 TCB 保存在内核空间,其创建、阻塞、撤销、切换等活动也都是在内核空间实现的。用户级线程则是内核无关的,用户级线程的实现在用户空间,内核感知不到用户线程的存在。用户线程的调度算法可以是进程专用的,不会被内核调度,但同时,用户线程也无法利用多处理机的并行执行。而一个拥有多个用户线程的进程,一旦有一个线程阻塞,该进程所有的线程都会被阻塞。内核的切换需要转换到内核空间,而用户线程不需要,所以前者开销会更大。但用户线程也需要内核的支持,一般是通过运行时系统或内核控制线程来连接一个内核线程,有 1:1、1:n、n:m 的不同实现。

在分时操作系统中,处理机的调度一般基于时间片的轮转(RR, round robin),多个就绪线程排成队列,轮流执行时间片。而为保证交互性和实时性,线程都是以抢占的方式(Preemptive Mode)来获得处理机。而抢占方式的开销是比较大的。有抢占方式就有非抢占方式(Nonpreemptiv Mode),在非抢占式中,除非某正在运行的线程执行完毕、因系统调用(如 I/O 请求)发生阻塞或主动让出处理器,不会被调度或暂停。

协程(Coroutine)就是基于非抢占式的调度来实现的。进程、线程是操作系统级别的概念,而协程是编译器级别的,现在很多编程语言都支持协程,如 Erlang、Lua等。准确来说,协程只是一种用户态的轻量线程。它运行在用户空间,不受系统调度。它有自己的调度算法。在上下文切换的时候,协程在用户空间切换,而不是陷入内核做线程的切换,减少了开销。简单地理解,就是编译器提供一套自己的运行时系统(而非内核)来做调度,做上下文的保存和恢复,重新实现了一套“并发”机制。系统的并发是时间片的轮转,单处理器交互执行不同的执行流,营造不同线程同时执行的感觉;而协程的并发,是单线程内控制权的轮转。相比抢占式调度,协程是主动让权,实现协作。协程的优势在于,相比回调的方式,写的异步代码可读性更强。缺点在于,因为是用户级线程,利用不了多核机器的并发执行。

线程的出现,是为了分离进程的两个功能:资源分配和系统调度。让更细粒度、更轻量的线程来承担调度,减轻调度带来的开销。但线程还是不够轻量,因为调度是在内核空间进行的,每次线程切换都需要陷入内核,这个开销还是不可忽视的。协程则是把调度逻辑在用户空间里实现,通过自己(编译器运行时系统/程序员)模拟控制权的交接,来达到更加细粒度的控制。

并发模型

1. 单进(线)程·循环处理请求

单进程和单线程其实没有区别,因为一个进程至少有一个线程。循环处理请求应该是最初级的做法。当大量请求进来时,单线程一个一个处理请求,请求很容易就积压起来,得不到响应。这是无并发的做法。


2. 多进程

主进程监听和管理连接,当有客户请求的时候,fork 一个子进程来处理连接,父进程继续等待其他客户的请求。但是进程占用服务器资源是比较多的,服务器负载会很高。

Apache 是多进程服务器。有两种模式:

  1. Prefork MPM : 使用多个子进程,但每个子进程不包含多线程。每个进程只处理一个连接。在许多系统上它的速度和worker MPM一样快,但是需要更多的内存。这种无线程的设计在某些性况下优于 worker MPM,因为它可在应用于不具备线程安全的第三方模块上(如 PHP3/4/5),且在不支持线程调试的平台上易于调试,另外还具有比worker MPM更高的稳定性。
  2. Worker MPM : 使用多个子进程,每个子进程中又有多个线程。每个线程处理一个请求,该MPM通常对高流量的服务器是一个不错的选择。因为它比prefork MPM需要更少的内存且更具有伸缩性。

这种架构的最大的好处是隔离性,子进程万一 crash 并不会影响到父进程。缺点就是对系统的负担过重。

4. 单线程·回调(callback)和事件轮询

Nginx

Nginx 采用的是多进程(单线程) & 多路IO复用模型:

  1. Nginx 在启动后,会有一个 master 进程和多个相互独立的 worker 进程。
  2. 接收来自外界的信号,向各 worker 进程发送信号,每个进程都有可能来处理这个连接
  3. master 进程能监控 worker 进程的运行状态,当 worker 进程退出后(异常情况下),会自动启动新的 worker 进程。

主进程(master 进程)首先通过 socket() 来创建一个 sock 文件描述符用来监听,然后fork生成子进程(workers 进程),子进程将继承父进程的 sockfd(socket 文件描述符),之后子进程 accept() 后将创建已连接描述符(connected descriptor)),然后通过已连接描述符来与客户端通信。

存在惊群现象:当连接进来时,所有子进程都将收到通知并“争着”与它建立连接。

Nginx 在 accept 上加一把互斥锁来应对惊群现象。

在每个 worker 进程里,Nginx 调用内核 epoll()函数来实现 I/O 的多路复用。

Node.js

Node.js 也是单线程模型。Node.js中所有的逻辑都是事件的回调函数,所以 Node.js始终在事件循环中,程序入口就是事件循环第一个事件的回调函数。事件的回调函数中可能会发出I/O请求或直接发射( emit )事件,执行完毕后返回事件循环。事件循环会检查事件队列中有没有未处理的事件,直到程序结束。Node.js的事件循环对开发者不可见,由 libev 库实现,libev 不断检查是否有活动的、可供检测的事件监听器,直到检查不到时才退出事件循环,程序结束。

Node.js 单线程能够实现非阻塞,是因为其底层实现有另一个线程在轮询事件队列,对于上层的开发者,只需考虑单线程,没有权限去开新的线程,也不需要考虑线程同步之类的问题。

这种机制的缺点是,会造成大量回调函数的嵌套,代码可读性不佳。因为没有多线程,在多核的机器上,也没办法实现并行执行。

3. 多线程

和多进程的方式类似,只不过是替换成线程。主线程负责监听、accept()连接,子线程(工作线程)负责处理业务逻辑和流的读取。子线程阻塞,同一进程内的其他线程不会被阻塞。

缺点是:

  1. 会频繁地创建、销毁线程,这对系统也是个不小的开销。这个问题可以用线程池来解决。线程池是预先创建一部分线程,由线程池管理器来负责调度线程,达到线程复用的效果,避免了反复创建线程带来的性能开销,节省了系统的资源。
  2. 要处理同步的问题,当多个线程请求同一个资源时,需要用锁之类的手段来保证线程安全。同步处理不好会影响数据的安全性,也会拉低性能。
  3. 一个线程的崩溃会导致整个进程的崩溃。

多线程的适用场景是:提高响应速度,让IO和计算相互重叠,降低延时。虽然多线程不能提高绝对性能,但是可以提高平均响应性能。

这种其实是比较容易想到的,特别是对于刚刚学习多线程和操作系统的计算机学生而言。在请求量不高的时候,是足够的。来多少连接开多少线程,就看服务器的硬件性能能不能承受。但高并发并不是线性地堆砌硬件或加线程数就能达到的。100个线程也许能够达到1000的并发,但10000的并发下,线程数乘以10也许就不行,比如线程调度带来的开销、同步成为了瓶颈。

总结

高并发的关键在于实现异步非阻塞,更加高效地利用 CPU。多线程可以达到非阻塞,但占用资源多,切换开销大。协程用栈的动态增长、用户态的调度来避免多线程的两个问题。事件驱动用单线程的方式,避免了占用太多系统资源,不需要关心线程安全,但无法利用多核。具体要采用哪种模型,还是要看需求。模型或技术只是工具,条条大陆通罗马。

比较优雅的还是 CSP 和 Actor 模型,因为能够符合人的思维习惯,避免了锁的使用。个人觉得加锁和多线程的方式,很容易被滥用,这是一种从微观出发和线性的思维方式,不够高屋建瓴。不如用消息通信来的耦合性更低。

高并发编程很有必要性。一方面,很多应用都需要高并发支持,网络的用户越来越多,业务场景会越来越复杂,需要有稳定和高效的服务器支持。另一方面,现代的计算机性能都是比较高的,但如果软件设计得不够好,就不能够把性能都给发挥出来。这就很浪费了。

PS:函数式编程

函数式编程也是一个可以用来解决并发问题的模型。

命令式语言和函数式语言的抽象不同。

命令式编程是对计算机硬件的抽象,关心的是解决问题的步骤。函数式编程是对数学的抽象,把问题转化为数学表达式。

函数性语言两个特征:数据不可变,不依赖保存或检索状态的操作;无副作用,用相同的输入调用函数,总是返回相同的值。也因此,可以不依赖锁来做并发编程。

线程

Java中,创建线程一般有两种方式,一种是继承Thread类,一种是实现Runnable接口。

1、如何创建并运行java线程

Java线程类也是一个object类,它的实例都继承自java.lang.Thread或其子类。 可以用如下方式用java中创建一个线程:

Tread thread = new Thread();

执行该线程可以调用该线程的start()方法:



thread.start();

在上面的例子中,我们并没有为线程编写运行代码,因此调用该方法后线程就终止了。

编写线程运行时执行的代码有两种方式:一种是创建Thread子类的一个实例并重写run方法,第二种是创建类的时候实现Runnable接口。接下来我们会具体讲解这两种方法:

创建Thread的子类

创建Thread子类的一个实例并重写run方法,run方法会在调用start()方法之后被执行。例子如下:

public class MyThread extends Thread {
   public void run(){
     System.out.println("MyThread running");
   }
}

可以用如下方式创建并运行上述Thread子类

MyThread myThread = new MyThread();
myTread.start();

一旦线程启动后start方法就会立即返回,而不会等待到run方法执行完毕才返回。就好像run方法是在另外一个cpu上执行一样。当run方法执行后,将会打印出字符串MyThread running。

你也可以如下创建一个Thread的匿名子类:

Thread thread = new Thread(){
   public void run(){
     System.out.println("Thread Running");
   }
};
thread.start();

当新的线程的run方法执行以后,计算机将会打印出字符串”Thread Running”。

实现Runnable接口

​ 第二种编写线程执行代码的方式是新建一个实现了java.lang.Runnable接口的类的实例,实例中的方法可以被线程调用。下面给出例子:

public class MyRunnable implements Runnable {
   public void run(){
    System.out.println("MyRunnable running");
   }
}

为了使线程能够执行run()方法,需要在Thread类的构造函数中传入 MyRunnable的实例对象。示例如下

public class MyRunnable implements Runnable {
   public void run(){
    System.out.println("MyRunnable running");
   }
}

线程运行时,它将会调用实现了Runnable接口的run方法。上例中将会打印出”MyRunnable running”。

同样,也可以创建一个实现了Runnable接口的匿名类,如下所示:

Runnable myRunnable = new Runnable(){
   public void run(){
     System.out.println("Runnable running");
   }
}
Thread thread = new Thread(myRunnable);
thread.start();

创建子类还是实现Runnable接口?

对于这两种方式哪种好并没有一个确定的答案,它们都能满足要求。就我个人意见,我更倾向于实现Runnable接口这种方法。因为线程池可以有效的管理实现了Runnable接口的线程,如果线程池满了,新的线程就会排队等候执行,直到线程池空闲出来为止。而如果线程是通过实现Thread子类实现的,这将会复杂一些。

有时我们要同时融合实现Runnable接口和Thread子类两种方式。例如,实现了Thread子类的实例可以执行多个实现了Runnable接口的线程。一个典型的应用就是线程池。

**常见错误:调用run()**方法而非start()方法

创建并运行一个线程所犯的常见错误是调用线程的run()方法而非start()方法,如下所示:

Thread newThread = new Thread(MyRunnable());
newThread.run();  //should be start();

起初你并不会感觉到有什么不妥,因为run()方法的确如你所愿的被调用了。但是,事实上,run()方法并非是由刚创建的新线程所执行的,而是被创建新线程的当前线程所执行了。也就是被执行上面两行代码的线程所执行的。想要让创建的新线程执行run()方法,必须调用新线程的start方法。

线程名

当创建一个线程的时候,可以给线程起一个名字。它有助于我们区分不同的线程。例如:如果有多个线程写入System.out,我们就能够通过线程名容易的找出是哪个线程正在输出。例子如下:

MyRunnable runnable = new MyRunnable();
Thread thread = new Thread(runnable, "New Thread");
thread.start();
System.out.println(thread.getName());

需要注意的是,因为MyRunnable并非Thread的子类,所以MyRunnable类并没有getName()方法。可以通过以下方式得到当前线程的引用:

Thread.currentThread();

因此,通过如下代码可以得到当前线程的名字:

String threadName = Thread.currentThread().getName();

线程代码举例:
这里是一个小小的例子。首先输出执行main()方法线程名字。这个线程JVM分配的。然后开启10个线程,命名为1~10。每个线程输出自己的名字后就退出。

public class ThreadExample {
  public static void main(String[] args){
     System.out.println(Thread.currentThread().getName());
      for(int i=0; i<10; i++){
         new Thread("" + i){
            public void run(){
             System.out.println("Thread: " + getName() + "running");
            }
         }.start();
      }
  }
}

需要注意的是,尽管启动线程的顺序是有序的,但是执行的顺序并非是有序的。也就是说,1号线程并不一定是第一个将自己名字输出到控制台的线程。这是因为线程是并行执行而非顺序的。Jvm和操作系统一起决定了线程的执行顺序,他和线程的启动顺序并非一定是一致的。

2、线程的六种状态

线程状态枚举:

java.lang.Thread.State

  • NEW: 新建状态,线程对象已经创建,但尚未启动
  • RUNNABLE:就绪状态,可运行状态,调用了线程的start方法,已经在java虚拟机中执行,等待获取操作系统资源如CPU,操作系统调度运行。
  • BLOCKED:堵塞状态。线程等待锁的状态,等待获取锁进入同步块/方法或调用wait后重新进入需要竞争锁
  • WAITING:等待状态。等待另一个线程以执行特定的操作。调用以下方法进入等待状态。 Object.wait(), Thread.join(),LockSupport.park
  • TIMED_WAITING: 线程等待一段时间。调用带参数的Thread.sleep, objct.wait,Thread.join,LockSupport.parkNanos,LockSupport.parkUntil
  • TERMINATED:进程结束状态。

进程、线程、协程_第1张图片

其中,Thread.sleep(long)使线程暂停一段时间,进入TIMED_WAITING时间,并不会释放锁,在设定时间到或被interrupt后抛出InterruptedException后进入RUNNABLE状态; Thread.join是等待调用join方法的线程执行一段时间(join(long))或结束后再往后执行,被interrupt后也会抛出异常,join内部也是wait方式实现的。

wait方法是object的方法,线程释放锁,进入WAITING或TIMED_WAITING状态。等待时间到了或被notify/notifyall唤醒后,回去竞争锁,如果获得锁,进入RUNNABLE,否则进步BLOCKED状态等待获取锁。

3、Java线程中断

interrupted()是Java提供的一种中断机制,要把中断搞清楚,还是得先系统性了解下什么是中断机制。

什么是中断?

在Java中没有办法立即停止一条线程,然而停止线程却显得尤为重要,如取消一个耗时操作。因此,Java提供了一种用于停止线程的机制——中断。

  • 中断只是一种协作机制,Java没有给中断增加任何语法,中断的过程完全需要程序员自己实现。若要中断一个线程,你需要手动调用该线程的interrupted方法,该方法也仅仅是将线程对象的中断标识设成true;接着你需要自己写代码不断地检测当前线程的标识位;如果为true,表示别的线程要求这条线程中断,此时究竟该做什么需要你自己写代码实现。
  • 每个线程对象中都有一个标识,用于表示线程是否被中断;该标识位为true表示中断,为false表示未中断;
  • 通过调用线程对象的interrupt方法将该线程的标识位设为true;可以在别的线程中调用,也可以在自己的线程中调用。

中断的相关方法

  • public void interrupt()
    将调用者线程的中断状态设为true。
  • public boolean isInterrupted()
    判断调用者线程的中断状态。
  • public static boolean interrupted
    只能通过Thread.interrupted()调用。
    它会做两步操作:
  1. 返回当前线程的中断状态;
  2. 将当前线程的中断状态设为false;

如何使用中断?

要使用中断,首先需要在可能会发生中断的线程中不断监听中断状态,一旦发生中断,就执行相应的中断处理代码。
当需要中断线程时,调用该线程对象的interrupt函数即可。

1.设置中断监听
Thread t1 = new Thread( new Runnable(){
    public void run(){
        // 若未发生中断,就正常执行任务
        while(!Thread.currentThread.isInterrupted()){
            // 正常任务代码……
        }

        // 中断的处理代码……
        doSomething();
    }
} ).start();

正常的任务代码被封装在while循环中,每次执行完一遍任务代码就检查一下中断状态;一旦发生中断,则跳过while循环,直接执行后面的中断处理代码。

2.触发中断
t1.interrupt();

上述代码执行后会将t1对象的中断状态设为true,此时t1线程的正常任务代码执行完成后,进入下一次while循环前Thread.currentThread.isInterrupted()的结果为true,此时退出循环,执行循环后面的中断处理代码。

如何安全地停止线程?

stop函数停止线程过于暴力,它会立即停止线程,不给任何资源释放的余地,下面介绍两种安全停止线程的方法。

1.循环标记变量

自定义一个共享的boolean类型变量,表示当前线程是否需要中断。

  • 中断标识
volatile boolean interrupted = false;
  • 任务执行函数
Thread t1 = new Thread( new Runnable(){
    public void run(){
        while(!interrupted){
            // 正常任务代码……
        }
        // 中断处理代码……
        // 可以在这里进行资源的释放等操作……
    }
} );
  • 中断函数
Thread t2 = new Thread( new Runnable(){
    public void run(){
        interrupted = true;
    }
} );
2.循环中断状态
  • 中断标识
    由线程对象提供,无需自己定义。
  • 任务执行函数
Thread t1 = new Thread( new Runnable(){
    public void run(){
        while(!Thread.currentThread.isInterrupted()){
            // 正常任务代码……
        }
        // 中断处理代码……
        // 可以在这里进行资源的释放等操作……
    }
} );
  • 中断函数
t1.interrupt();

上述两种方法本质一样,都是通过循环查看一个共享标记为来判断线程是否需要中断,他们的区别在于:第一种方法的标识位是我们自己设定的,而第二种方法的标识位是Java提供的。除此之外,他们的实现方法是一样的。

上述两种方法之所以较为安全,是因为一条线程发出终止信号后,接收线程并不会立即停止,而是将本次循环的任务执行完,再跳出循环停止线程。此外,程序员又可以在跳出循环后添加额外的代码进行收尾工作。

如何处理中断?

上文都在介绍如何获取中断状态,那么当我们捕获到中断状态后,究竟如何处理呢?

  • Java类库中提供的一些可能会发生阻塞的方法都会抛InterruptedException异常,如:BlockingQueue#put、BlockingQueue#take、Object#wait、Thread#sleep。
  • 当你在某一条线程中调用这些方法时,这个方法可能会被阻塞很长时间,你可以在别的线程中调用当前线程对象的interrupt方法触发这些函数抛出InterruptedException异常。
  • 当一个函数抛出InterruptedException异常时,表示这个方法阻塞的时间太久了,别人不想等它执行结束了。
  • 当你的捕获到一个InterruptedException异常后,亦可以处理它,或者向上抛出。
  • 抛出时要注意???:当你捕获到InterruptedException异常后,当前线程的中断状态已经被修改为false(表示线程未被中断);此时你若能够处理中断,则不用理会该值;但如果你继续向上抛InterruptedException异常,你需要再次调用interrupt方法,将当前线程的中断状态设为true。
  • 注意:绝对不能“吞掉中断”!即捕获了InterruptedException而不作任何处理。这样违背了中断机制的规则,别人想让你线程中断,然而你自己不处理,也不将中断请求告诉调用者,调用者一直以为没有中断请求。

callable & future

callabe

Java中,创建线程一般有两种方式,一种是继承Thread类,一种是实现Runnable接口。然而,这两种方式的缺点是在线程任务执行结束后,无法获取执行结果。我们一般只能采用共享变量或共享存储区以及线程通信的方式实现获得任务结果的目的。
  
  不过,Java中,也提供了使用CallableFuture来实现获取任务结果的操作。Callable用来执行任务,产生结果,而Future用来获得结果。
  
  Callable接口与Runnable接口是否相似,查看源码,可知Callable接口的定义如下:

@FunctionalInterface
public interface Callable<V> {
    /**
     * Computes a result, or throws an exception if unable to do so.
     *
     * @return computed result
     * @throws Exception if unable to compute a result
     */
    V call() throws Exception;
}

可以看到,与Runnable接口不同之处在于,call方法带有泛型返回值V

Future常用方法

  • V get() :获取异步执行的结果,如果没有结果可用,此方法会阻塞直到异步计算完成。
  • V get(Long timeout , TimeUnit unit) :获取异步执行结果,如果没有结果可用,此方法会阻塞,但是会有时间限制,如果阻塞时间超过设定的timeout时间,该方法将抛出异常。
  • boolean isDone() :只要任务执行结束,都返回true。
  • boolean isCanceller() :只要任务完成前被取消,则返回true。
  • boolean cancel(boolean mayInterruptRunning) :如果任务还没开始,执行cancel(…)方法将返回false;如果任务已经启动,执行cancel(true)方法将以中断执行此任务线程的方式来试图停止任务,如果停止成功,返回true;当任务已经启动,执行cancel(false)方法将不会对正在执行的任务线程产生影响(让线程正常执行到完成),此时返回false;当任务已经完成,执行cancel(…)方法将返回false。
    • mayInterruptRunning 参数表示是否中断执行中的线程。

通过方法分析我们也知道实际上Future提供了3种功能:

  • 能够中断执行中的任务
  • 判断任务是否执行完成
  • 获取任务执行完成后的结果
public class client {

    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(4);
        //定义任务
        Callable<String> task = new Callable<String>() {

            @Override
            public String call() throws Exception {
                return "callable接口";
            }
        };
        // 提交任务并获得Future:
        Future<String> future = executor.submit(task);

        try {
            // 从Future获取异步执行返回的结果:
            String result = future.get();//可能阻塞
            System.out.println(result);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }

}

Future模式(FutureTask)

Future模式的核心在于:去除了主函数的等待时间,并使得原本需要等待的时间段可以用于处理其他业务逻辑。

Futrure模式:对于多线程,如果线程A要等待线程B的结果,那么线程A没必要等待B,直到B有结果,可以先拿到一个未来的Future,等B有结果是再取真实的结果。

Future模式有点类似于商品订单。在网上购物时,提交订单后,在收货的这段时间里无需一直在家里等候,可以先干别的事情。类推到程序设计中时,当提交请求时,期望得到答复时,如果这个答复可能很慢。传统的是一直持续等待直到这个答复收到之后再去做别的事情,但如果利用Future模式,其调用方式改为异步,而原先等待返回的时间段,在主调用函数中,则可以用于处理其他事务。

MyTask.java类

public class MyTask  implements Callable<Object>{    
    private String args1;
    private String args2;
    //构造函数,用来向task中传递任务的参数
    public  MyTask(String args1,String args2) {
        this.args1=args1;
        this.args2=args2;
    }
    //任务执行的动作
    @Override
    public Object call() throws Exception {
        
        for(int i=0;i<100;i++){
            System.out.println(args1+args2+i);
        }
        return true;
    }
}

FutureTask使用方法

public static void main(String[] args) {
        MyTask myTask = new MyTask("11", "22");//实例化任务,传递参数
        FutureTask<Object> futureTask = new FutureTask<>(myTask);//将任务放进FutureTask里
        //采用thread来开启多线程,futuretask继承了Runnable,可以放在线程池中来启动执行
        Thread thread = new Thread(futureTask);
        thread.start();
        
        try {
            //get():获取任务执行结果,如果任务还没完成则会阻塞等待直到任务执行完成。如果任务被取消则会抛出CancellationException异常,
            //如果任务执行过程发生异常则会抛出ExecutionException异常,如果阻塞等待过程中被中断则会抛出InterruptedException异常。
            boolean result = (boolean) futureTask.get();
            System.out.println(result);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

另外一种方式来开启线程

        ExecutorService executorService=Executors.newCachedThreadPool();
        executorService.submit(futureTask);
        executorService.shutdown();

多个任务,开启多线程去执行,并依次获取返回的执行结果

public static void main(String[] args) {    
        //创建一个FutureTask list来放置所有的任务
        List<FutureTask<Object>> futureTasks=new ArrayList<>();
        for(Integer i=0;i<10;i++){
            MyTask myTask=new MyTask(i.toString(), i.toString());
            futureTasks.add(new FutureTask<>(myTask));
        }
        
        //创建线程池后,依次的提交任务,执行
        ExecutorService executorService=Executors.newCachedThreadPool();
        for(FutureTask<Object> futureTask:futureTasks){
            executorService.submit(futureTask);
        }
        executorService.shutdown();
        
        //根据任务数,依次的去获取任务返回的结果,这里获取结果时会依次返回,若前一个没返回,则会等待,阻塞
        for(Integer i=0;i<10;i++){
            try {
                String flag=(String)futureTasks.get(i).get();
                System.out.println(flag);
            } catch (Exception e) {
                e.printStackTrace();
            }

        }
    }

ForkJoin

一、简介

算法领域有一种基本思想叫做“分治”,所谓“分治”就是将一个难以直接解决的大问题,分割成一些规模较小的子问题,以便各个击破,分而治之。

比如:对于一个规模为N的问题,若该问题可以容易地解决,则直接解决;否则将其分解为K个规模较小的子问题,这些子问题互相独立且与原问题性质相同,递归地解这些子问题,然后将各子问题的解合并得到原问题的解,这种算法设计策略叫做分治法。

许多基础算法都运用了“分治”的思想,比如二分查找、快速排序等等。

基于“分治”的思想,J.U.C在JDK1.7时引入了一套Fork/Join框架。Fork/Join框架的基本思想就是将一个大任务分解(Fork)成一系列子任务,子任务可以继续往下分解,当多个不同的子任务都执行完成后,可以将它们各自的结果合并(Join)成一个大结果,最终合并成大任务的结果:

进程、线程、协程_第2张图片

二、工作窃取算法

从上述Fork/Join框架的描述可以看出,我们需要一些线程来执行Fork出的任务,在实际中,如果每次都创建新的线程执行任务,对系统资源的开销会很大,所以Fork/Join框架利用了线程池来调度任务。

另外,这里可以思考一个问题,既然由线程池调度,必然存在两个要素:

  • 工作线程
  • 任务队列

一般的线程池只有一个任务队列,但是对于Fork/Join框架来说,由于Fork出的各个子任务其实是平行关系,为了提高效率,减少线程竞争,应该将这些平行的任务放到不同的队列中去,如上图中,大任务分解成三个子任务:子任务1、子任务2、子任务3,那么就创建三个任务队列,然后再创建3个工作线程与队列一一对应。

由于线程处理不同任务的速度不同,这样就可能存在某个线程先执行完了自己队列中的任务的情况,这时为了提升效率,我们可以让该线程去“窃取”其它任务队列中的任务,这就是所谓的*工作窃取算法*

“工作窃取”的示意图如下,当线程1执行完自身任务队列中的任务后,尝试从线程2的任务队列中“窃取”任务:

进程、线程、协程_第3张图片

对于一般的队列来说,入队元素都是在“队尾”,出队元素在“队首”,要满足“工作窃取”的需求,任务队列应该支持从“队尾”出队元素,这样可以减少与其它工作线程的冲突(因为正常情况下,其它工作线程从“队首”获取自己任务队列中的任务),满足这一需求的任务队列其实就是我们在juc-collections框架中LinkedBlockingDeque

当然,出于性能考虑,J.U.C中的Fork/Join框架并没有直接利用LinkedBlockingDeque作为任务队列,而是自己重新实现了一个。

三、使用示例

为了给接下来的分析F/J框架组件做铺垫,我们先通过一个简单示例看下Fork/Join框架的基本使用。

假设有个非常大的long[]数组,通过FJ框架求解数组所有元素的和。

任务类定义,因为需要返回结果,所以继承RecursiveTask,并覆写compute方法。任务的fork通过ForkJoinTask的fork方法执行,join方法方法用于等待任务执行后返回:

public class ArraySumTask extends RecursiveTask<Long> {
 
    private final int[] array;
    private final int begin;
    private final int end;
 
    private static final int THRESHOLD = 100;
 
    public ArraySumTask(int[] array, int begin, int end) {
        this.array = array;
        this.begin = begin;
        this.end = end;
    }
 
    @Override
    protected Long compute() {
        long sum = 0;
 
        if (end - begin + 1 < THRESHOLD) {      // 小于阈值, 直接计算
            for (int i = begin; i <= end; i++) {
                sum += array[i];
            }
        } else {
            int middle = (end + begin) / 2;
            ArraySumTask subtask1 = new ArraySumTask(this.array, begin, middle);
            ArraySumTask subtask2 = new ArraySumTask(this.array, middle + 1, end);
 
            subtask1.fork();
            subtask2.fork();
 
            long sum1 = subtask1.join();
            long sum2 = subtask2.join();
 
            sum = sum1 + sum2;
        }
        return sum;
    }
}

调用方如下:

public class Main {
    public static void main(String[] args) {
        ForkJoinPool executor = new ForkJoinPool();
        ArraySumTask task = new ArraySumTask(new int[10000], 0, 9999);
 
        ForkJoinTask future = executor.submit(task);
 
        // some time passed...
 
        if (future.isCompletedAbnormally()) {
            System.out.println(future.getException());
        }
 
        try {
            System.out.println("result: " + future.get());
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
 
    }
}

**注意:**ForkJoinTask在执行的时候可能会抛出异常,但是没办法在主线程里直接捕获异常,所以ForkJoinTask提供了isCompletedAbnormally()方法来检查任务是否已经抛出异常或已经被取消了,并且可以通过ForkJoinTask的getException方法获取异常.

四、核心组件

F/J框架的实现非常复杂,内部大量运用了位操作和无锁算法,撇开这些实现细节不谈,该框架主要涉及三大核心组件:ForkJoinPool(线程池)、ForkJoinTask(任务)、ForkJoinWorkerThread(工作线程),外加WorkQueue(任务队列):

  • ForkJoinPool:ExecutorService的实现类,负责工作线程的管理、任务队列的维护,以及控制整个任务调度流程;
  • ForkJoinTask:Future接口的实现类,fork是其核心方法,用于分解任务并异步执行;而join方法在任务结果计算完毕之后才会运行,用来合并或返回计算结果;
  • ForkJoinWorkerThread:Thread的子类,作为线程池中的工作线程(Worker)执行任务;
  • WorkQueue:任务队列,用于保存任务;

JUC类图

进程、线程、协程_第4张图片

你可能感兴趣的:(进程、线程、协程)