《七周七并发模型》笔记

1 概述

1.1 并发并行

并发是同一时间应对(dealing with)多件事情的能力;
并行是同一时间动手做(doing)多件事情的能力。
在这里插入图片描述

1.2 并行架构

  • 位级(bit-level)并行
    两个32位数加法,8位计算机必须进行多次8位计算,32位计算机可以一步完成。
    由位升级带来的性能改善是存在瓶颈的。
  • 指令级(instruction-level)并行
    现代CPU的并行度很高,其中使用的技术包括流水线、乱序执行和猜测执行等。
  • 数据级(data)并行
    数据级并行(亦称“单指令多数据”,SIMD)架构,可以并行地在大量数据上施加同一操作。
    图形处理(GPU)中如此应用很多。
  • 任务级(task-level)并行
    多处理器级并行。多处理器架构最明显的分类特征是其内存模型(共享内存模型或分布式内存模型)。
    对于共享内存的多处理器系统,每个处理器都能访问整个内存,处理器之间的通信主要通过内存进行,如图1.1。
    在这里插入图片描述
    对于分布式内存的多处理器系统,每个处理器都有自己的内存,处理器之间的通信主要通过网络进行。
    在这里插入图片描述
    通过内存通信比通过网络通信更简单快速。当处理器个数逐渐增多,共享内存就会遭遇性能瓶颈–需要转向分布式内存。要开发一个容错系统,就要使用多台计算机以规避硬件故障对系统的影响,此时必须借助分布式内存。

1.3 并发:不只是多核

正确使用并发,程序将:及时响应、高效、容错、简单

并发的世界,并发的软件

世界是并发的,为了与其有效地交互,软件也应是并发的。

分布式的世界,分布式的软件

有时,我们要解决地理分布型问题。软件在多台计算机上分布式地运行,其本质是并发。
分布式软件具有容错性,如同大佬不做同一架飞机

不可预测的世界,容错性强的软件

为了增强软件的容错性,并发代码的关键是独立性和故障检测。
串行程序的容错性远不如并发程序。

复杂的世界,简单的软件

用串行方案解决一个并发问题往往需要付出额外的代价,而且解决方案会晦涩难懂。如果能并发,就不需要创建一个复杂的线程来处理问题中的多个任务,只需要用多个简单的线程分别处理不同的任务即可。十倍围之胜过以一敌百

1.4 七个模型

  • 线程与锁
    线程与锁有很多众所周知的不足,但仍是其他模型的技术基础,也是很多并发软件开发的首选。
  • 函数式编程
    函数式编程消除了可变状态,所以从根本上是线程安全的,而且易于并行执行。
  • Clojure之道–分离标识与状态
    编程语言Clojure是一种指令式编程和函数式编程的混搭方案。
  • Actor
    该模型适用性很广,适用于共享内存模型和分布式内存模型,也适应解决地理分布型问题,能提供强大的容错性。
  • 通信顺序进程(CSP)
    CSP模型与actor模型很相似,两者都基于消息传递。CSP侧重于传递信息的通道,而actor侧重于通道两端的实体。
  • 数据并行
    GPU利用了数据级并行,不仅可以图像处理,也可以进行有限元分析、流体力学计算或其他大量数学计算。
  • Lambda架构
    大数据时代的到来离不开并行–现在我们只需要增加计算资源,就能具有处理TB级数据的能力。Lambda架构综合了MapReduce和流式处理的特点,是一种可以处理多种大数据问题的架构。

2 线程与锁

线程与锁模型有众所周知的缺点,不过仍然是并发编程的首选技术,也是其他并发技术的支撑。
线程与锁模型其实是对底层硬件运行过程的形式化。

2.1 互斥与内存模型

互斥指的是用锁保证某一时间仅有一个线程可以访问数据。不过副作用就是会带来竞态条件和死锁。
在直觉上,编译器、硬件等都不会改动代码逻辑。实际在近几年提升运行效率,尤其引入共享内存架构后,编译器、硬件等都会通过乱序执行来优化性能。这就是优化的副作用。
让多线程代码安全运行的方法只能是让所有的方法都同步。不过这样会导致大多数线程频繁阻塞,最终效率底下,程序失去并发的意义。多把锁又会带来死锁风险。

哲学家进餐问题

在这里插入图片描述

  • 5位哲学家(五个进程)围坐圆桌,面对5支筷子(五个临界资源),对应“思考”和“饥饿”2种状态。同时进餐时,都拿起左手边的筷子,都在等待右手边哲学家放下筷子,这就是死锁。
  • 5只筷子为临界资源,因此设置5个信号量即可。
  • 解决办法:
    1)法一:至多只允许有四位哲学家同时去拿左边的筷子,最终能保证至少有一位哲学家能够进餐,并在用餐完毕后能释放他占用的筷子,从而使别的哲学家能够进餐;
    2)法二:仅当哲学家的左、右两支筷子可用时,才允许他拿起筷子;
    3)法三:规定奇数号哲学家先拿他左边的筷子,然后再去拿右边的筷子;而偶数号哲学家则相反。

面对规模较大的程序,使用锁的地方比较零散,各处都遵守这个顺序就变得不太实际。
规模较大的程序常用监听器模式(listener)来解耦模块。【观察者模式】

2.1 互斥与内存模型

2.2 超越内置锁

  • Java内置锁的限制:
    一个线程因为等待内置锁而进入阻塞之后,就无法中断该线程了
    尝试获取内置锁时,无法设置超时;获得内置锁,必须使用synchronized块。
synchronized(object) {
	<<使用共享资源>>
}

这种用法的限制是获取锁和释放锁的代码必须严格嵌在同一方法中
ReentrantLock提供了显式的lock和unlock方法,可以突破上述几个限制。

Lock lock = new ReentrantLock();
lock.lock();
try {
	<<使用共享资源>>
} finally {
	lock.unlock();
}

可中断的锁

使用内置锁时,由于阻塞的线程无法被中断,程序不可能从死锁中恢复。
终止线程的最终手段是让run()函数返回(可能是通过抛出InterruptedException)。
不过,如果你的线程由于等待内置锁而陷入死锁,且不能中断其等待锁的状态,那么要终止死锁线程就只剩下终止JVM运行这条路了

{
	......
	final ReentrantLock l1 = new ReentrantLock();
	final ReentrantLock l2 = new ReentrantLock();

	Thread t1 = new Thread() {
		public void run() {
			l1.lockInterruptibly();
			Thread.sleep(1000);
			l2.lockInterruptibly();
		} catch (InterruptedException e) { System.out.println("t1 interrupted"); }
	}
}

这一次Thread.interrupt()可以让线程终止。

超时

ReentrantLock突破了内置锁的另一个限制:可以为获取锁的操作设置超时时间。
tryLock()相比lock()在获取锁失败时有超时机制。tryLock()避免了无尽地死锁,不过依然不能避免死锁。会产生活锁现象,即所有死锁线程同时超时,极有可能再次陷入死锁。该死锁不会永远持续,但对资源的争夺没有改善。通过设置不同线程的超时时间,可减少同时超时的几率。

交替锁

在链表中插入一个节点,
方法一是用锁保护整个链表,但是链表加锁时其他使用者无法访问链表。
方法二是只锁住链表的一部分,允许不涉及被锁部分的其他线程自由访问链表。

条件变量

并发编程经常需要等待某个事件发生。如从队列删除元素前需要等待队列非空、向缓存添加数据前
需要等待缓存有足够的空间。条件变量就是为这种情况而设计的。

ReentrantLock lock = new ReentrantLock();
Condition condition = lock.newCondition();

lock.lock();
try {
	while (!<<条件为真>>)
		condition.await();
	<<使用共享资源>>
} finally { lock.unlock(); }

一个条件变量需要与一把锁关联,线程在开始等待条件之前必须获取该锁。获锁后,如果为真,线程解锁并继续执行。如果等待的条件不为真,线程会调用await(),来解锁并阻塞等待。当另一个线程调用signal()或signalAll(),对应的条件可能为真,await()将恢复运行并重新加锁。

//条件变量解决哲学家就餐问题
public class PhilosopherWithCondition extends Thread{
   private boolean eating;
   private PhilosopherWithCondition left,right;
   private ReentrantLock table;
   private Condition condition;
   private Random random;
   public PhilosopherWithCondition(ReentrantLock table){
       eating = false;
       this.table = table;
       condition = table.newCondition();
       random = new Random();
   }

   public void setLeft(PhilosopherWithCondition left){this.left = left;}
   public void setRight(PhilosopherWithCondition right){this.right = right;}

    @Override
    public void run() {
        try {
            while (true){
                think();
                eat();
            }
        }catch (InterruptedException e){}
    }

    private void think()throws InterruptedException{
       table.lock();
       try {
           eating = false;
           left.condition.signal();
           right.condition.signal();
       }finally {
           table.unlock();
       }
       Thread.sleep(1000);
    }

    private void eat() throws InterruptedException{
       table.lock();
       try{
           while (left.eating || right.eating){
               condition.await();
           }
           eating = true;
       }finally {
           table.unlock();
       }
       Thread.sleep(1000);
    }
}

只使用一把锁(table),且没有Chopsticks类。仅当哲学家左右邻居都没有进餐时,才能进餐。

原子变量

与锁相比,使用原子变量有诸多好处。不会引发死锁,不会出现因锁操作不对产生同步问题。
原子变量是无锁(lock-free)非阻塞(non-blocking)算法的基础,该算法可不用锁和阻塞来达到同步的目的。
Java变量标记volatile。这样可保证变量的读写不被乱序执行。随着JVM的不断优化,atomic可以代替volatile,
并开销更小。

Tips:关于重入锁和原子变量的总结

  • 重入锁可以在线程获取锁时中断它
  • 设置线程获取锁的超时时间
  • 按照任意顺序获取和释放锁
  • 用条件变量等待某个条件为真
  • 使用原子变量来避免锁的使用

2.3 站在巨人的肩膀上

java.util.concurrent包不仅提供了比内置锁更好用的锁,还提供了一些通用、高效、bug少的并发数据结构和工具。

线程池

影响线程池最优大小的因素有很多个,例如硬件的性能,线程任务是CPU密集型还是IO密集型,是否有其他任务在同时运行,还有很多其他原因也会产生影响。存在一个经验法则:对于一个CPU密集型的任务,线程池大小应该接近于可用核个数,对于IO密集型的任务,线程池应该设置的更大一些。当然,最佳的方法还是建立一个真实环境下的压力测试来衡量性能。

public class EchoServer {
  public static void main(String[] args) throws IOException {
    class ConnectionHandler implements Runnable {
      InputStream in; OutputStream out;
      ConnectionHandler(Socket socket) throws IOException {
        in = socket.getInputStream();
        out = socket.getOutputStream();
      }

      public void run() {
        try {
          int n;
          byte[] buffer = new byte[1024];
          while((n = in.read(buffer)) != -1) {
            out.write(buffer, 0, n);
            out.flush();
          }
        } catch (IOException e) {}
      }
    }

    ServerSocket server = new ServerSocket(4567);
    while (true) {
      Socket socket = server.accept();
      Thread handler = new Thread(new ConnectionHandler(socket));
      handler.start();
    }
  }
}

该代码采用的设计是,接受一个连接请求并创建一个处理线程。
该设计隐患一:每个连接都花费一个线程代价;
该设计隐患二:请求连接速度高于处理速度时,线程数量激增,服务器将停止服务甚至崩溃。
可用线程池解决。

int threadPoolSize = Runtime.getRuntime().availableProcessors() * 2;
    ExecutorService executor = Executors.newFixedThreadPool(threadPoolSize);
    while (true) {
      Socket socket = server.accept();
      executor.execute(new ConnectionHandler(socket));
    }

一个完整的程序(生产者-消费者 | 观察者)

查找Wikipedia上出现频率最高的词,先下载XML dump文件,然后写一个程序解析它们计算出词频。

public class WordCount {
  private static final HashMap<String, Integer> counts = 
    new HashMap<String, Integer>();

  public static void main(String[] args) throws Exception {
    long start = System.currentTimeMillis();
    Iterable<Page> pages = new Pages(100000, "enwiki.xml");
    for(Page page: pages) {
      Iterable<String> words = new Words(page.getText());
      for (String word: words)
        countWord(word);
    }
    long end = System.currentTimeMillis();
    System.out.println("Elapsed time: " + (end - start) + "ms");

    // for (Map.Entry<String, Integer> e: counts.entrySet()) {
    //   System.out.println(e);
    // }
  }

  private static void countWord(String word) {
    Integer currentCount = counts.get(word);
    if (currentCount == null)
      counts.put(word, 1);
    else
      counts.put(word, currentCount + 1);
  }
}

主循环的每一次循环都完成了两个任务–首先解析XML并构造一个Page,然后“消费”这个Page,对Page中的内容统计词频。这是经典的生产者-消费者模式相比只用一个线程自产自销,我们可以创建两个线程:一个生产者和一个消费者。Java.util.concurrent包中的ArrayBlockingQueue是一个并发队列,非常适应实现生产者-消费者模式。其提供了高效的并发方法put()和take(),这些方法会必要时阻塞:当对一个空队列调用take()时,程序会阻塞知道队列变为非空;当对一个满队列调用put()时,程序会阻塞直到队列有足够空间。相对之前由105s提升到95s。

class Parser implements Runnable {
  private BlockingQueue<Page> queue;

  public Parser(BlockingQueue<Page> queue) {
    this.queue = queue;
  }
  
  public void run() {
    try {
      Iterable<Page> pages = new Pages(100000, "enwiki.xml");
      for (Page page: pages)
        queue.put(page);
    } catch (Exception e) { e.printStackTrace(); }
  }
}

class Counter implements Runnable {
  private BlockingQueue<Page> queue;
  private Map<String, Integer> counts;
  
  public Counter(BlockingQueue<Page> queue,
                 Map<String, Integer> counts) {
    this.queue = queue;
    this.counts = counts;
  }

  public void run() {
    try {
      while(true) {
        Page page = queue.take();
        if (page.isPoisonPill())
          break;

        Iterable<String> words = new Words(page.getText());
        for (String word: words)
          countWord(word);
      }
    } catch (Exception e) { e.printStackTrace(); }
  }

  private void countWord(String word) {
    Integer currentCount = counts.get(word);
    if (currentCount == null)
      counts.put(word, 1);
    else
      counts.put(word, currentCount + 1);
  }
}

public class WordCount {

  public static void main(String[] args) throws Exception {
    ArrayBlockingQueue<Page> queue = new ArrayBlockingQueue<Page>(100);
    HashMap<String, Integer> counts = new HashMap<String, Integer>();

    Thread counter = new Thread(new Counter(queue, counts));
    Thread parser = new Thread(new Parser(queue));
    long start = System.currentTimeMillis();
	
    counter.start();
    parser.start();
    parser.join();
    queue.put(new PoisonPill());
    counter.join();
    long end = System.currentTimeMillis();
    System.out.println("Elapsed time: " + (end - start) + "ms");

    // for (Map.Entry<String, Integer> e: counts.entrySet()) {
    //   System.out.println(e);
    // }
  }
}

在这里插入图片描述
过多的线程尝试同时使用一个共享资源会产生过渡竞争。如果使用HashMap,HashMap不提供原子的读-改-写的方法。最终统计时间会更长。如果使用ConcurrentHashMap,ConcurrentHashMap提供了原子的读-改-写方法,还使用了更高级的并发访问(锁分段(lock striping))技术。最终效果较好。

Tips:

  • 使用线程池,而不是直接创建线程;
  • 使用CopyOnWriteArrayList让监听器相关的代码更简单高效;
  • 使用ArrayBlockingQueue让生产者和消费者之间高效协作;
  • ConcurrentHashMap提供了更好的并发访问。

3 函数式编程

在这里插入图片描述

  • 函数式编程与命令式编程不同。命令式编程的代码由一系列改变全局状态的语句构成,而函数式编程则是将计算过程抽象成表达式求值。这些表达式由纯数学函数构成,而这些数学函数是第一类对象并且没有副作用。由于没有副作用,函数式编程可以更容易做到线程安全,因此特别适合于并发编程。
  • 函数式编程没有可变状态,所以不会遇到由共享可变状态带来的种种问题。
  • Clojure是动态类型语言,作为Ruby或Python程序员会有接触。

3.1 并行化一个函数式算法

可变状态的风险

  • 隐藏的可变状态 SimpleDateFormat
  • 逃逸的可变状态
user=> (max 3 5)
5
user=> (+ 1 (* 2 3))
7
user=> (def meaning-of-life 42)
#'user/meaning-of-life
user=> meaning-of-life
42
user=> (if(< meaning-of-life 0) "negative" "non-negative")
"non-negative"
user=> (def droids["huey""Dewey""Louie"])
#'user/droids
user=> (count droids)
3
user=> (droids 0)
"huey"
user=> (def me {:name "Paul" :age 45 :sex :male})
#'user/me
user=> (:age me)
45
user=> (:sex me)
:male
user=>

java示例:accumulator是可变的,因此这段代码不是函数式的

public int sum(int[] numbers){
    int accumulator = 0;
    for(int n : numbers){
        accumulator += n;
    }
    return accumulator;
}

clojure示例:

//方式一
(defn recursive-sum [numbers]
    (if (empty? numbers)
        0
        (+ (first numbers) (recursive-sum (rest numbers)))
    )
)

user=> (recursive-sum [1,2,3])
6

方式二
(defn reduce-sum [numbers]
    (reduce (fn [acc x] (+ acc x)) 0 numbers)
)
user=> (reduce-sum [1,2,3])
6

方式三: 
(defn sum [numbers]
    (reduce + numbers)
)
user=> (sum [1,2,3])
6

3.2 Clojure的reducer框架

3.3 利用future模型和promise模型创建一个并发的函数式Web服务

4 Clojure之道–分离标识与状态

混合函数式编程和可变状态,平衡两者的优点,成为并发编程的利器。

Clojure 精心设计了用于并发的语义,从而保留了共享可变状态。

  • 优点:持久化数据结构将可变量的表示与状态分离开,解决了使用锁的方案的大部分缺点。
  • 缺点:不支持分布式编程。

5 Actor

Actor 模型保留了可变状态,只是不进行共享。Actor 对象是一个比线程还要轻量级的“进程”,通常不需要依赖线程池技术。

消息和信箱

  • 异步地发送消息
    消息发送到信箱,只需要关心发送消息时的并发问题,而不需要关心处理消息时的并发问题
  • 函数是第一类对象
    和数据相同:可以绑定到变量上,可以作为函数的参数

6 通信顺序进程

7 数据并行

基于图形处理器的通用计算(General-Purpose computing on the GPU)

GPU 编程

  • 流水线
  • 多 ALU

OpenCL 程序:

  • 创建上下文,获取设备信息,创建命令队列,内核和命令队列都运行在上下文中
  • 编译内核,将内核编译成可以在设备上运行的代码
  • 创建缓冲区,内核使用的都是缓冲区中的数据,并将数据读入到输入缓冲区
  • 设置内核参数,执行工作项,让每个工作项都运行一次内核程序
  • 获取结果,并清理现场

8 Lambda架构

9 圆满结束

参考

1、《七周七并发模型》Paul Butcher
2、经典的进程同步问题-----哲学家进餐问题详解
3、七周七并发模型源码
4、七周七并发读书笔记 第三章 函数式编程
5、七周七并发之函数式编程
6、读书笔记:《七周七并发模型》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

worthsen

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

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

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

打赏作者

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

抵扣说明:

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

余额充值