软件构造课程的第7章(并发和分布式编程)是关于并发、线程、线程安全、锁、同步等知识的内容,因为之前没有编写过多线程的程序,所以这几周阅读了一些关于Java并发的内容(Java编程思想的第21章,MIT 6.031 2019Fall的Reading 19、20、21),希望通过这篇文章较详细地总结一下Java中关于并发的基础部分,在下一篇文章中再总结一些较难理解的高级部分。
第一次接触并发是在上计算机系统课的时候,当时对并发的定义就是在时间上重叠的逻辑控制流。如硬件的异常处理程序,进程,信号处理等等,在最后还有一章专门来讨论并发编程。
而软件构造这门课对并发的定义是在同一时间发生的多个计算,其实大体的含义都是差不多的。但需要注意并发与并行的区别,并行是指两个或者多个事件在同一时刻发生;而并发是指两个或多个事件在同一时间间隔内发生。对于单(核)处理器只能够实现并发,提供一种并发执行的假象,而对于多(核)处理器则可以将线程分配给不同的处理器,从而实现真正的并行执行。
下面是两种经典的并发编程中的并发通讯模型
并发的模块通过读写内存中的共享对象来实现通讯。
对于Java的并发线程来说,在这种模型中,不同的线程之间没有直接的联系,都是通过二者之间的共享对象这个"中间人"来实现相互通讯。当多个线程同时对某一个共享对象进行读写操作时,就必须要考虑共享对象的同步问题,这也是共享内存模型容易出错的原因。
并发的模块通过在信道上互相发送消息来实现通讯。
模块之间相互发送消息,而发送到每个模块的消息排队等待处理。而应用消息传递比较有名的模型之一就是actor模型。
进程:一个运行中的程序的实例。
Recall:计算机系统 CSAPP
进程提供给应用程序两个关键的抽象。
- 独立的逻辑控制流:提供一种假象,好像我们的程序在独占地使用处理器,通过进程之间的上下文切换来实现。
- 私有的地址空间:提供一种假象,好像我们的程序在独占地使用内存,通过虚拟内存来实现。
因而进程的抽象是一台虚拟的计算机,它使得我们的程序感觉自己独占地拥有整个的处理器和内存去运行。进程之间一般是不共享内存的,因而进程之间的通讯通常采用的是消息传递的模型(IPC机制)。
线程:运行在进程上下文中的一条顺序的逻辑控制流。
Recall:计算机系统 CSAPP
现代操作系统允许我们编写一个进程里同时运行多个线程的程序。线程由内核自动调度。每个线程都有自己的线程上下文,包括一个唯一的整数线程ID、栈、栈指针、程序计数器、通用目的寄存器和条件码。
所有运行在一个进程里的线程共享该进程的整个虚拟地址空间,其中包括进程的代码、数据、堆、共享库和打开的文件。
因而线程的抽象是一台虚拟计算机中的一个虚拟的处理器,它和同一台虚拟计算机中的所有虚拟处理器一样,都运行着相同的程序,共享着相同的内存。因此线程之间通常采用的是共享内存的模型,但有时候显式地设立消息传递模型也是必要的。
Java的Thread类官方API的spec指出了创建一个新的线程的两种方式:
下面是我在学习过程中编写的一个简单的测试程序及某次的运行结果,能够清晰地看出并发的执行效果。
public class ThreadTest {
public static void main(String[] args) {
MyThread myThread=new MyThread();
myThread.start();
for(int i=0;i<5;i++) {
System.out.println("main ----> "+i);
}
}
}
class MyThread extends Thread{
@Override public void run() {
for(int i=0;i<5;i++) {
System.out.println("run ----> "+i);
}
}
}
// result:
// main ----> 0
// main ----> 1
// run ----> 0
// main ----> 2
// run ----> 1
// main ----> 3
// run ----> 2
// main ----> 4
// run ----> 3
// run ----> 4
public class ThreadTest2 {
public static void main(String[] args) {
Thread myThread=new Thread(new Runnable() {
public void run() {
for(int i=0;i<5;i++) {
System.out.println("run ----> "+i);
}
}
});
myThread.start();
for(int i=0;i<5;i++) {
System.out.println("main ----> "+i);
}
}
}
当有多个线程在操作时,如果系统只有一个CPU,把CPU运行时间划分成若干个时间片,分配给各个线程执行,在一个时间段的线程代码运行时,其它线程处于挂起状态。
如果线程数不多于CPU核心数,会把各个线程都分配一个核心,不需分片,而当线程数多于CPU核心数时才会分片。
public class ThreadTest3 {
// suppose all the cash machines share a single bank account
private static int balance = 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 < 1000; ++i) {
deposit(); // put a dollar in
withdraw(); // take it back out
}
}
}).start();
}
public static void main(String[] args) {
cashMachine();
cashMachine();
cashMachine();
System.out.println("after:"+balance);
}
}
// result:
// after:7
上面的代码是我将MIT官网的代码拷贝下来,并添加了main方法后的程序及某一次的运行结果。
正常情况下,每一个现金取款机存一块钱,然后取一块钱,最终的账户余额应该为0,可是上边的结果显式最终的余额为7。
出现上述错误的原因正是因为语句之间出现了交织的情况。
例如:
deposit()方法中仅有的一条语句其实并不是原子的操作,还可以将它分解为底层的三步操作。
而可能会出现如下两个线程A,B同时读取同一账户的balance的情况:
在一开始时,A、B两个线程都读取到相同的账户余额值,然后二者都将读取到的余额值加一,而在最后写回时两个线程相互竞争,不管谁先写回修改后的值,另外一个线程总会将先前写回的值进行覆盖,从而出现了余额不为零的现象。
上述问题也反映了一个可见性问题:A写回了修改后的balance值,但B看到的还是未修改之前balance值,因而B进行写回时会将A写回的值进行错误的覆盖。
竞争条件就是说程序的正确性(满足每一个类的后置条件和不变量)依赖于特定线程不同操作之间的时序。