哈尔滨工业大学软件构造课程笔记第七章第一节

7.1并发

1.什么是并发编程?

并发
并发:多个计算同时发生。
并发性在现代编程中无处不在:
网络上的多台计算机
一台计算机上的多个应用
一个CPU上的多核处理器
在这里插入图片描述并发在现代编程中是必不可少的:
多用户并发请求服务器的计算资源
App在手机端和在云端都有计算
GUI的前端用户操作和后台的计算同时进行
为什么“并发”?
摩尔定律失效了
“核”变得越来越多
为了充分利用多核和多处理器,需要将程序转化为并行执行
并发编程的两个模型
共享内存:并发模块在内存中读写共享数据
在这里插入图片描述消息传递:并发模块通过channel交换消息
在这里插入图片描述共享内存
共享内存的例子:
两个处理器,共享内存
同一台机器上的两个程序,共享文件系统
同一个Java程序内的两个线程,共享Java对象
消息传递
消息传递的例子:
网络上的两台计算机,通过网络连接通讯
浏览器和Web服务器,A请求页面,B发送页面数据给A
即时通讯软件的客户端和服务器
同一台计算机上的两个程序,通过管道连接进行通讯

2.进程、线程、时间分片

进程和线程
并发模块间的通讯是关键,如何定义模块?
并发模块的类型:进程和线程
进程:私有空间,彼此隔离
线程:程序内部的控制机制

(1)进程

进程抽象是一个虚拟计算机(一个自包含的执行环境,具有一组完整的、私有的基本运行时资源,特别是内存)。
进程:拥有整台计算机的资源
-它让程序感觉就像整个机器是独立的-就像一台新的计算机被创造出来,有新的内存,只是为了运行那个程序。

多进程之间不共享内存
进程之间通过消息传递进行协作
一般来说,进程=程序=应用
但一个应用中可能包含多个进程
OS支持的IPC机制(pipe/socket)支持进程间通信
不仅是本机的多个进程之间,也可以是不同机器的多个进程之间。
JVM通常运行单一进程,但也可以创建新的进程。

(2)线程

线程和多线程编程
进程=虚拟机;线程=虚拟CPU
程序共享、资源共享都隶属于进程

共享内存
很难获得线程私有的内存空间
通过创建消息队列在线程之间进行消息传递

线程与进程

线程进程
线程是轻量级的进程是重量级的
线程共享内存空间进程有自己的内存空间
线程需要同步(线程在改变对象时持有锁)进程不需要
杀死线程是不安全的杀死进程是安全的

在这里插入图片描述为什么使用线程?
面对阻塞活动时的表现
-考虑一个网络服务器
多处理器的性能
干净地处理自然并发
在Java中,线程是一个事实
-例如:垃圾收集器在自己的线程中运行
我们都是并发程序员
为了利用我们的多核处理器,我们必须编写多线程代码
好消息:很多都是为你写的
坏消息:你仍然必须了解基础知识
-有效利用图书馆
-调试使用它们的程序

(3)用Java启动一个线程

线程
多线程执行是Java平台的一个基本特性
每个应用至少有一个线程
主线程,可以创建其他的线程

创建线程的两种方法:
从Thread类派生子类;
从Runnable接口构造Thread对象

创建线程的方法:从Thread类派生子类
从Thread类派生子类
Thread类本身实现Runnable,但是它的run方法不做任何事情。应用程序可以子类化Thread,提供自己的run()实现。

public class HelloThread extends Thread {
  public void run() {
    System.out.println("Hello from a thread!");
  }
//----------启动该线程的两个方式
public static void main(String args[]) {
  HelloThread p = new HelloThread();
  p.start();
  (new HelloThread()).start();
}

除了必须要实现run()之后,和其他类一样,也可具有属性和方法

创建线程的方法:提供一个Runnable对象
提供一个Runnable对象

  • Runnable接口定义了一个单独的方法run(),用于包含在线程中执行的代码。
    -Runnable对象被传递给Thread构造函数。
public class HelloRunnable implements Runnable {
  public void run() {
    System.out.println("Hello from a thread!");
  }
  public static void main(String args[]) {
    (new Thread(new HelloRunnable())).start();
  } 
}

创建线程的方法
一个非常常见的习惯用法是用匿名启动线程
Runnable,消除了命名类:Runnable匿名类

new Thread(new Runnable() { 
  public void run() { 
    System.out.println("Hello"); 
  } 
}).start();
  1. 创建一个 Runnable对象
  2. 创建一个线程对象
  3. Thread.start ()被调用
  4. 线程开始运行
  5. “你好”被print
  6. 线程结束运行

使用lambda表达式创建线程(JDK 8+)

new Thread(() -> System.out.println("Hello from a thread!")).start();

匿名类
通常,当我们实现一个接口时,我们通过声明一个类来实现。例如,给定Java API中的接口比较器:
在这里插入图片描述我们可能会宣布:
在这里插入图片描述比较器的一个用途是排序

对于可排序集合,如果没有指定比较器,会采用所保存对象实现的compareTo方法(需要实现Comparable接口)
在这里插入图片描述比较器:
在这里插入图片描述如果我们只打算在这一个地方使用这个比较器,我们已经知道如何消除变量:
在这里插入图片描述匿名类声明一个未命名类,它实现一个接口,并立即创建该类的唯一实例。与以上代码比较:
在这里插入图片描述优点:
明确了使用范围
读者不需要寻找类的定义
缺点:
不能复用
代码过长时,影响理解

匿名类多用于短的一次性的方法实现

3.交错和竞争

(1)时间分片

时间分片
虽然有多线程,但只有一个核,每个时刻只能执行一个线程
通过时间分片,在多个进程/线程之间共享处理器
即使是多核CPU,进程/线程的数目也往往大于核的数目
在这里插入图片描述时间切片的一个例子
在具有两个实际处理器的机器上,可以对三个线程T1、T2和T3进行时间分割。
首先,一个处理器运行线程T1,另一个运行线程然后第二个处理器切换到运行线程T3。
线程T2只是暂停,直到它在同一处理器或另一个处理器上的下一个时间片。
在这里插入图片描述时间分片是由OS自动调度的

(2)线程间共享内存

共享内存的例子
线程之间共享内存可能会引发一些微妙的bug !!
示例:银行拥有使用共享内存模型的自动取款机,因此所有自动取款机都可以在内存中读写相同的帐户对象。
在这里插入图片描述将银行简化为一个单一账户,将美元余额存储在balance变量中,并进行存款和取款两项操作,只添加或删除一美元:
在这里插入图片描述客户使用自动提款机进行如下交易:
在这里插入图片描述
每笔交易只是存入一美元,然后再取出一次,所以账户中的余额应该保持不变。
-每台机器正在处理一系列的存款/取款交易。
在这里插入图片描述按理说,余额应该始终为0
但是如果我们运行这段代码,我们经常会发现一天结束时的余额不是0。如果多个自动提款机()调用同时运行——比如在同一台计算机的不同处理器上运行——那么一天结束时余额可能不会为零。为什么不呢?

交错
假设A和B两台提款机同时在存款上工作。
下面是deposit()步骤通常分解为低级处理器指令的方式:
当A和B并发运行时,这些低级指令相互交错。
在这里插入图片描述在这里插入图片描述在这里插入图片描述

(3)竞争条件

竞争条件
现在结余是1,A的钱损失了!
A和B都同时读取余额,分别计算期末余额,然后跑回去存储新余额——这没有考虑到对方的存款。

竞争条件:程序的正确性(后置条件和不变量的满足)取决于并发计算A和B中事件的相对时间。当这种情况发生时,我们说“A在和B竞争”。
某些交错事件可能是可以的,因为它们与单个非并发进程产生的结果是一致的,但其他交错事件会产生错误的结果——违反后置条件或不变量。

调整代码不会有任何帮助
在这里插入图片描述
所有这些版本的银行帐户代码都显示相同的竞争条件!
仅从Java代码中无法判断处理器将如何执行它。
你不知道原子操作是什么。
-它不是原子的,因为它是一行Java。
-它不会只碰一次balance,因为balance标识符在行中只出现一次。
是否原子,由JVM确定
典型的现代Java编译器会为这三个版本生成完全相同的代码!

重排序
银行账户的这个例子中的竞争情况,可以用顺序操作在不同处理器上的不同交叉执行解释
但是事实上,当使用多个变量和多个处理器时,甚至不能指望这些变量的变化以相同的顺序出现

看一下代码,答案是在ready之前设置的,所以一旦useAnswer将ready视为true,那么它可以合理地假设答案是42,对吧?不是这样的。
在这里插入图片描述出于优化目的,编译器和处理器会复制变量的临时副本在高速存储中,在存储回正式内存位置之前,基于临时副本工作

存储回内存的顺序,可能与代码中操作的变
量顺序不同

处理器有效地创建了两个临时变量,tmpr和tmpa,以操作字段就绪并回答:
在这里插入图片描述关键点:不能通过查看一个表达式来判断它是否在竞争条件下是安全的。

竞争条件也被称为“线程干扰”

(4)消息传递的例子

消息传递的例子
现在不仅是提款机模块,账户也是模块。

模块通过互相发送消息进行交互。
-传入的请求放在一个队列中,每次处理一个。
-发送方在等待请求回复时不会停止工作。它处理来自自己队列的更多请求。对其请求的响应最终作为另一条消息返回。
在这里插入图片描述消息传递可以解决竞争条件吗?
消息传递机制也无法解决竞争条件问题
-假设每个帐户支持get-balance和withdraw操作,并有相应的消息。
-在取款机A和B,两个用户都试图从同一个账户中取出一美元。
-他们首先会检查账户余额,以确保他们取的钱不会超过账户的持有量,因为透支会引发高额的银行罚款。

get-balance
if balance >= 1 then withdraw 1

问题还是交织,但这一次交织的是发送到银行帐户的消息,而不是由A和B执行的指令。
如果账户一开始就有一美元,那么是什么信息交错会欺骗A和B,让它们都认为可以取出一美元,从而透支账户?
在这里插入图片描述

(5)并发很难测试和调试

并发很难测试和调试
很难测试和调试因为竞争条件导致的bug
因为交错的存在,导致很难复现bug
-很难让同样的事情发生两次。
-指令或信息的交错依赖于受环境强烈影响的事件的相对时间。
-延迟是由其他运行的程序,其他网络流量,OS调度决策,处理器时钟速度的变化等造成的。
-每次你运行一个包含竞争条件的程序,你可能会得到不同的行为。

Heisenbugs和Bohrbugs
eisenbug是一种软件bug,当人们试图研究它时,它似乎会消失或改变其行为,不确定性和难以复制

顺序编程中几乎所有的bug都是bohrbugs。每当你看到它的时候,它就会重复出现。

增加print语句甚至导致这种bug消失!
原因是打印和调试比其他操作要慢得多,通常要慢100-1000倍,这就极大地改变了操作的时间和交错。
在这里插入图片描述平衡总是0,正如所期望的那样,错误似乎消失了。但它只是被蒙住了,并没有真正固定下来

(6)利用某些方法调用来主动影响线程之间的交错关系

Thread.sleep()
使用Thread .sleep(time)暂停执行:使当前线程暂停执行一段指定的时间。

将某个线程休眠,意味着其他线程得到更多的执行机会
进入休眠的线程不会失去对现有monitor或锁的所有权,从休眠中苏醒后可以继续执行(见7-3)

for (int i = 0; i < n; i++) {
  //Pause for 4 seconds
  Thread.sleep(4000);
  //Print a message
  System.out.println(msg[i]);
}

Thread.interrupt()
一个线程通过调用thread对象上的interrupt来发送一个中断,该线程使用interrupt()方法被中断
线程收到其他线程发来的中断信,并不意味
着一定要“停止”

t.interrupt() 在其他线程里向t发出中断信号

中断是一种协作机制。当一个线程中断另一个线程时,被中断的线程不一定要立即停止正在做的事情。中断是礼貌地请求另一个线程在它愿意并且方便的时候停止它正在做的事情。

有些方法,例如 Thread.sleep() 、Thread.join() 和 Object.wait()等 ,很认真地对待这样的请求(立即响应),但其他方法不是一定要对中断作出响应,可以不予理会。

当另一个线程通过调用 t.interrupt() 中断一个线程时,会出现以下两种情况之一:
如果被中断线程在执行一个低级可中断阻塞方法,例如Thread.sleep()、 Thread.join() 或Object.wait(),那么它将响应终端,抛出InterruptedException异常,程序捕获该异常后,可以做中断后的处理
否则,interrupt() 只是设置线程的中断状态,通知该线程有其他线程想终止它,让它自己决定是否终止
每个线程都有一个与之相关联的 Boolean 属性,用于表示线程的中断状态,中断状态初始时为 false
– t.interrupt() 执行后,t的中断状态设置为true
– t.isInterrupted() 检查t是否已在中断状态中,只是查询,不改变状态
– Thread.interrupted() 检查当前线程是否已在中断状态中,并重置状态为false
– 注意:interrupt()和isInterrupted()是实例方法,interrupted()是类方法

如果不处在sleep()/其他几个特定操作,线程无法检测到中断信号,相 当于对别人发来的中断信号“置之不理”——这“不友好”!

Thread.join()
让当前线程保持执行,直到其执行结束
一般不需要这种显式指定线程执行次序

4.总结

并发性:同时运行多个计算
共享内存和消息传递范例
进程和线程
-进程就像一台虚拟的计算机;线程就像一个虚拟处理器
竞争条件
-当结果(后置条件和不变量)的正确性取决于事件的相对时间
多线程共享相同的可变变量,而不协调他们正在做什么。
-这是不安全的,因为程序的正确性可能依赖于低级操作的时间事故。
这些想法与我们的好软件的关键特性联系在一起的方式大多是坏的。
并发是必要的,但它会导致严重的正确性问题:
-远离bug。并发错误是最难发现和修复的错误之一,需要仔细设计才能避免。
-很容易理解。对于程序员来说,预测并发代码如何与其他并发代码交织是非常困难的。最好的设计方式是让程序员完全不必考虑交叉。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值