7.1 并发
一. 并发性程序
并行是同一 CPU
上的并发执行,并发是指多个任务在时间上有重叠,这 n
个任务称为并发地执行,其概念与处理器等概念、数目无关。
并发程序的通信,共有以下两种方式:
- 共享内存:在程序使用的内存中读写共享数据,只适合用于线程,不能用于进程。
- 两个处理器,共享内存
- 同一台机器上的两个程序,共享文件系统
- 同一个
Java
程序内的两个线程,共享Java
对象
- 消息传递:通过
channel
交换消息。
- 网络上的两台计算机,通过网络连接通讯
- 浏览器和
Web
服务器,A
请求页面,B
发送页面数据给A
- 即时通讯软件的客户端和服务器
- 同一台计算机上的两个程序,通过管道连接进行通讯
二. 进程,线程,时间切片
并发模块的类型:
- 进程:私有空间,彼此隔离
- 线程:程序内部的控制机制
1. 进程 Process
进程拥有整台计算机的资源,但多进程不共享内存,进程之间通过消息传递进行协作。
一般来说,进程==程序==应用
,但一个应用中可能包含多个进程。
OS
支持的IPC
机制 (pipe
/socket
)支持进程间(不仅是本机的多个进程之间,也可以是不同机器的多个进程之间)通信。
JVM
通常运行单一进程,但也可以
创建新的进程。
2. 线程 Thread
进程=虚拟机;线程=虚拟 CPU
- 程序共享、资源共享,都隶属于进程
进程间可共享内存
- 很难获得线程私有的内存空间 (how about thread)
- 通过创建消息队列在线程之间进行消息传递(即两种方式通信:消息传递、共享内存)
使用多线程使得程序运行速度更快。
3. Java
创建 Thread
首先明确:
- 每个应用至少有一个线程
- 主线程可以创建其他的线程
Java
中创建 Thread
的方法:
- (少用)从
Thread
类派生子类 - (一般使用)从
Runnable
接口构造Thread
对象
第一种 Thread
创建方式:继承,重写 run()
方法,使用 start()
方法开始线程。
//public class Thread extends Object implements Runnable
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();
}
//----------启动该线程的两个方式
public static void main(String args[]) {
(new HelloThread()).start();
}
}
但 Thread
中大多数方法无需使用,并且会形成复杂的继承树。
第二种方法,继承 Runnable
接口,重写 run()
方法,将对象作为 Thread
的参数传递到 Thread
构造方法中,使用 start()
方法启动线程:
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();
}
}
可以用匿名类的方式简要编写为
new Thread(new Runnable() {
public void run() {
System.out.println("Hello");
}
}).start();
其执行流程:
- 创建
Runnable
对象 - 创建
Thread
对象 - 调用
Thread.start()
- 线程开始运行
- 打印
Hello
- 线程结束运行
Runnable
接口实际上为:
public interface Runnable {
void run();
}
注:main
也是线程之一,于是如下程序共 3
个线程:
public class Moirai {
public static void main(String[] args) {
Thread clotho = new Thread(new Runnable() {
public void run() { System.out.println("spinning"); };
});
clotho.start();
new Thread(new Runnable() {
public void run() { System.out.println("measuring"); };
}).start();
new Thread(new Runnable() {
public void run() { System.out.println ("cutting"); };
});
}
}
三. 交错和竞争
1. 时间分片
虽然有多线程,但只有一个核,每个时刻只能执行一个线程。
- 通过时间分片,在多个进程/线程之间共享处理器
即使是多核CPU
,进程/线程的数目也往往大于核的数目 - 时间分片
无论单核还是多核,时间分片均由OS
自动调度的。有可能造成程序在不同时间执行结果不同。
2. Thread
之间的共享内存
例如银行 ATM
,类似于共享内存:
简化其操作,模拟共享内存:
// suppose all the cash machines share a single bank account
private static int balabce = 0;
private static void deposit() {
balance = balance + 1;
}
private static void withdraw() {
balance = balance - 1;
}
// each ATM does a bunch of transactions that
// modify balance, but leave it unchanged afterward
public static void cashMachine() {
new Thread(new Runnable() {
public void run() {
for (int i = 0; i < TRANSACTION_PER_MACHINE; ++i)
deposit(); // put a dollar in
withdraw(); //take it back out
}
}
}).start();
}
按理,上述程序余额结果应该为 0
。但在很多情况下结果可能不为 0
。
例如我们将 deposit()
划分为三个阶段
get balance
(balance = 0
)add 1
write back the result
(balance = 1
)
例如其结果正确为 2
:
而其结果不正确,为 1
:
这种执行方式称为 Interleaving
(交错)。与时间等等因素有关。根本原因是切片具有不确定性。然而程序员无法决定,这是由操作系统等等决定的。
3. 竞争条件
如上例
A
和 B
称为 Race condition
(竞争)或线程干扰 (Thread Interference
)。这是由于单行、单条语句不一定是原子的,是否是原子的,对 Java
来说,是由 JVM
决定的。
线程的执行顺序与代码顺序无关。
4. 消息传递
多个线程通过多个消息队列传递消息。
以银行 ATM
为例:
消息传递机制也无法解决竞争问题,这是因为消息队列的每个操作不一定是原子的,可能转换为多个消息。如:
5. 线程难以调试与测试
很难测试和调试因为竞争条件导致的 bug
。因为 interleaving
的存在,导致很难复现 bug
。
bug
共两种:
Heisenbugs
:随机性bug
,有时出现有事不出现Bohrbugs
:确定性bug
,有输入会引起错误,则该输入一定会引起错误
增添 print
语句可能导致 bug
消失,这是因为 print
语句执行时间长(普通语句的 100 - 1000
倍),改变了时间切片方式。
6. 利用某些方法调用来主动影响线程之间的 interleaving
关系
6.1 Thread.sleep()
Thread.sleep()
是一种j静态的线程的休眠方法。
- 将某个线程休眠,意味着其他进程得到更多的执行机会
- 进入休眠的线程不会失去对现有
monitor
或锁的所有权
for (int i = 0; i < n ; i++) {
//Pause for 4 seconds
Thread.sleep(4000);
//Print a message
System.out.println(msg[i]);
}
6.2 Thread.interrupt()
Thread.interrupt()
向进程发出中断信号
t.interrupt()
在其他线程里向t
发出中断信号(出于安全性等方面的考虑,该操作实质上是打上标识符,然后程序是否中断即“停止”是由该线程自己决定,见后面)
检查线程是否被中断
t.isInterrupted()
检查t
是否已在中断状态中,但不对中断状态位修改t.interrupted()
检查t
是否已在中断状态中,并对中断状态位复位
当某个线程被中断后,一般来说应停止其 run()
中的执行,取决于程序员在 run()
中处理
- 一般来说,线程在收到中断信号时应该中断,直接终止,但出于安全性等方面的考虑,该操作实质上是打上标识符,然后程序是否中断即“停止”是由该线程自己决定
class Task implements Runnable{
private double d = 0.0;
public void run() {
try{
while (true) {
for (int i = 0; i < 900000; i++)
d = d + (Math.PI + Math.E) / d;
Thread.sleep(500);
}
} catch(InterruptedException e) {return;}
}
//...
Thread t = new Thread(new Task());
t.start();
Thread.sleep(100); //当前线程休眠
t.interrupt(); //试图中断 t 线程
}
上一程序 sleep()
检测标识位。sleep()
与 interrupt()
相互配合。
class Task implements Runnable{
private double d = 0.0;
public void run() {
try {
//检查线程是否收到中断信号
while (!Thread.interrupted()) {
Thread.sleep(20);
for (int i = 0; i < 900000; i++)
d = d + (Math.PI + Math.E) / d;
}
} catch (InterruptedException e) {
System.out.println("Exiting by Exception");
}
}
}
上述程序有两种方式退出线程的执行。实质上上述程序 sleep()
操作去掉也可以终止运行。
也可以不使用 sleep()
方式,如下:
if (Thread.interrupted()) {
throw new InterruptedException;
}
6.3 Thread.join()
让当前线程保持执行,直到其执行结束。
- 一般不需要这种显式指定线程执行次序
指定次序方法如下:
public class JoinExample {
public static void main(String[] args)
Thread th1 = new Thread(new MyClass (), "th1");
Thread th2 = new Thread(new MyClass (), "th2");
Thread th3 = new Thread(new MyClass (), "th3");
th1.start();
try {
th1.join();
} catch (InterruptedException ie) {}
th2.start();
try {
th2.join();
} catch (InterruptedException ie) {}
th3.start();
try {
th3.join();
} catch (InterruptedException ie) {}
}
}