共计 4594 个字符,预计需要花费 12 分钟才能阅读完成。
你开发了一套转账零碎,转账的流程没问题,通过了内部测试,上线后看起来也没问题。
然而,过了一段时间,用户竟然能够忽视余额,间接提现。眼看就要就业了,问题到底出在哪里呢?
通过一番查看,你发现每次出事的时候,用户都同时发动了好几笔订单,导致了并发问题。
什么是并发问题
并发,就是在很短的工夫内,有很多个申请同时发到了服务器上。这时候,你如果没有解决好,就呈现了并发 Bug。
并发 Bug 十分奇葩,经常会导致意想不到的状况。更让人抓狂的是,这些 Bug 常常莫名其妙地呈现,又莫名其妙地隐没,你很难重现和追踪。
比如说,在用户提现前,你明明做了余额校验,平时没问题,但访问量一大,这个性能就突然生效,用户余额不够,却胜利提现了。
遇到这种状况,你要想找到其中的 Bug,只能一行一行地查看代码。所以,疾速、精准发现并发问题,这是优良程序员的基本功,而要做到这点,你必须深刻理解并发 Bug 的源头。
并发 Bug 是计算机谋求高性能的代价。
程序的运行离不开 CPU、内存、IO 设施,但这么多年无论技术怎么迭代,始终有一个外围矛盾:这三者的速度差距。
CPU 的速度最快,内存的速度次之,IO 设施的速度最慢。它们之间的速度差距,只能说是离谱。
打个比方,CPU 是天上一天,内存是地上一年,而 IO 设施就是地上一万年。一条数据,从 IO 设施传到内存要一万年,内存再传到 CPU 要一年,而 CPU 只有解决一天。这一来一回,光 IO 设施就花去 2 万年。
所以,你怎么进步 CPU 性能都没用,程序的整体性能取决于最慢的操作—读写 IO 设施。
那怎么均衡这三者的速度差别呢?
各路大神们是从这几个方面动手的,别离是:计算机体系结构、操作系统、编译程序。
在计算机体系结构上,减少 CPU 缓存,均衡 CPU 和内存的速度差距。
在操作系统上,减少了线程,分时复用 CPU,均衡 CPU 和 IO 设施的速度差距。
在编译程序上,优化指令的执行程序,进步程序性能。
然而,天下没有收费的午餐,并发问题的本源就在这些优化上。
缓存带来的可见性问题
所谓可见性,就是一个线程对共享变量的批改,另外一个线程可能立即看到。
为了均衡 CPU 和内存的速度差别,在计算机体系结构上,大神们做了翻新—减少 CPU 缓存。但随着 CPU 从单核走向多核,可见性问题就呈现了。
单核 CPU 是不会有可见性问题的。因为 CPU 只有 1 个,缓存也只有 1 个,所有线程都运行在这一个 CPU 下面,整个构造非常简单。
一个线程无论怎么读写数据,对其它线程都是可见的,对后果都没有影响。
然而,在多核时代,每颗 CPU 都有本人的缓存,数据就没法保持一致了。
当两个线程运行在不同的 CPU 上时,这两个线程就在操作本人 CPU 上的缓存,对其它线程是不可见的。
你能够看上面这幅图,变量 V 加载到两颗 CPU 上,线程 A 在操作 CPU-1 上的缓存,而线程 B 在操作 CPU-2 上的缓存。它们都不晓得对方做了什么,变量 V 怎么算都不对。
咱们来看一个具体的例子。
public class Withdraw {
private long balance = 15000;
/**
* 提现
* @param amount 提现金额
* @return
*/
private void withdraw(int amount) {
/** 校验 **/
// 非法金额
if (amount <= 0) {return;}
// 余额有余
if (balance < amount) {return;}
/** 提现操作 **/
// 减余额
balance = balance - amount;
// 省略有数代码...
}
/*************** 测试函数 ****************/
private void withdraw10k() {for (int i = 0; i < 10000; i++) {this.withdraw(1);
}
}
private static long mockWithdraw() throws InterruptedException {
// 创立账户
final Withdraw account = new Withdraw();
// 模仿提现,发动 1 万笔订单,每次提现 1 块钱
Thread th1 = new Thread(() -> {account.withdraw10k();
});
Thread th2 = new Thread(() -> {account.withdraw10k();
});
// 启动两个线程
th1.start();
th2.start();
// 期待两个线程执行完结
th1.join();
th2.join();
return account.balance;
}
public static void main(String[] args) throws InterruptedException {long balance = mockWithdraw();
System.out.println("账户余额:" + balance + "元");
}
}
第一次执行后果 - 账户余额:823 元
第二次执行后果 - 账户余额:2274 元
第三次执行后果 - 账户余额:1525 元
用户有一万五千块钱,当初发动了两万笔提现订单,每笔订单一块钱。照理说,如果钱用完,就不能提现了,余额应该是 0。但连着执行三次,每次的余额都不一样。换句话说,用户忽视余额,超额提现。
其实,你想想看,如果线程一和线程二同时启动,都把 balance = 15000 读到缓存里,在执行完 balance = 15000 – 1 后,它们都把 balance = 14999 写到了内存。如果多运行几次,余额必定越来越离谱。
因而,并发编程的第一道坎,就是保障 CPU 缓存的可见性。
线程切换带来的原子性问题
所谓原子性,就是所有操作要么不间断地全副被执行,要么一个也没有执行。
为了均衡 CPU 和 IO 设施的速度差距,操作系统一直迭代,呈现了多线程技术。然而,多线程技术也有副作用,就是原子性问题。所以,咱们要想搞清楚原子性问题,必须先理解多线程技术。
在多线程呈现前,计算机是怎么运作的呢?
你设想一下这个场景,你只有一台电脑,当初想边敲代码,边听音乐,该怎么办呢?答案是,没得办。你要不就敲代码,要不就听音乐,两者只能二选一。
可是,多线程技术的呈现,让 CPU 能分时复用,从此扭转所有。
这是怎么做到的呢?
操作系统容许线程只运行一段时间。比如说,某个线程取得了 50 毫秒的“工夫片”,那这个线程就能执行 50 毫秒。过了 50 毫秒后,操作系统做“线程切换”,这个线程休眠,换另外一个线程执行。
你能够看上面这幅图,加深一下了解。
这带来了两个益处。
第一,CPU 的利用效率大幅提高。简略来说,CPU 能够全年无休地工作了。
比方,一个线程要进行 IO 操作,读硬盘的电影什么的,那这个线程就进入“休眠状态”,让出 CPU 的使用权,另一个线程运行。等电影读到内存后,操作系统再把这个线程从休眠中唤醒,让另一个线程休眠。
第二,IO 的利用效率大幅提高,这要从两个中央说起。
首先,线程在休眠的时候,利用闲暇工夫读写 IO。比方,一个线程没有失去 CPU 的使用权,那能够利用闲暇工夫,去做 IO 操作,像是读文件什么的。
而后,IO 操作减少了排队机制。一个线程在读文件,另一个线程也要读文件,那读文件的操作就会排队。比方,一个线程读完文件后,发现有排队的工作,就立刻启动下一个读操作。
这套组合拳下来,IO 的利用效率也上来了。
毫不夸大的说,多线程技术是一块里程碑,是操作系统历史上不可磨灭的一笔。
然而,这一切都是有代价的。
并发程序的实现离不开多线程,天然也会用到线程切换,但 线程切换正是很多诡异 Bug 的源头。
这是为什么呢?
因为咱们用的是高级编程语言。在高级编程语言里,一条语句往往会被拆成多个 CPU 指令。比方,Java 中自增运算 count++
,就至多被拆成了 3 个 CPU 指令:
- 读取内存,把 count 加载到 CPU;
- CPU 执行 count+1 操作;
- 把后果写入内存;
在这个过程中,任何一个 CPU 指令执行完后,都有可能产生线程切换。换句话说,在高级语言里,一条语句不肯定能被正确执行。
举个例子,假如 count = 0
,有 2 个线程执行 count++
,后果应该是 count = 2
。在咱们的设想中,count++
是这样的,就像上面这副图一样。
然而,CPU 的原子操作只是 CPU 指令级别的,而不是编程语言的操作符。一旦产生线程切换,最初的后果就是 count = 1
。实际上,count++
是这样的。
在这个例子,count++
没有保障原子性。起因在于,一条编程语句被拆成多个 CPU 指令,如果产生线程切换,原子性就被毁坏了。
所以,在高级语言层面,怎么控制线程切换,从而保障操作的原子性?这是并发编程的第二道坎。
编译优化带来的有序性问题
所谓有序性,就是程序按代码的先后顺序运行。
为了进步程序性能,编程语言有时会扭转代码的先后顺序。这很容易了解,有些代码更加重要,很多中央都要用到,但这些代码偏偏写在最初,其它中央想用就得始终等着,程序的性能必定好不了。
所以,编译器会扭转代码的先后顺序,优先执行更重要的代码。比如说,一段代码本来是这样的。
int count1 = 1;
int count2 = 2;
但编译器可能感觉这段代码的效率太低,得做编译优化。后果,代码就变成了这个样子:
int count2 = 2;
int count1 = 1;
然而,这也是有代价的。程序如果依照代码的先后顺序执行,确实是慢了点,可起码不会出错。但 通过了编译优化,可能会呈现一些意想不到的 Bug,这就是有序性问题。
最经典的例子就是通过双重查看,来创立单例对象。你在工作中,为了保障 ID 是惟一的,会用到一个惟一的 ID 生成器,而这个生成器往往是单例对象。
public class IdGen {
private static IdGen instance;
static IdGen getInstance() {if (instance == null) {synchronized (IdGen.class) {if (instance == null) {instance = new IdGen();
}
}
}
return instance;
}
}
你认真看 getInstance()
办法,如同没什么问题。一个线程获取 instance 对象
时,先在 6 行代码做第一重查看,判断 instance
是不是空。如果是空,7 行代码就加锁,禁止其它线程进来,执行初始化。
这时侯,就算有其它线程同时进来,也没关系。因为 7 行代码加了锁,只有拿到锁的线程能力进来,而且等其它线程拿到锁进来,还得再通过 8 行代码的第二重查看,判断 instance
是不是空。
整个过程看上去没什么,可一旦呈现编译优化,问题就来了。你注意第 9 行代码的 new 操作
,如果没有做编译优化,那么应该是这样的:
- 调配一块内存 M;
- 在内存 M 上初始化 IdGen 对象;
- 把 M 的地址赋值给 instance 变量;
但编译优化当前,就变成这样了:
- 调配一块内存 M;
- 把 M 的地址赋值给 instance 变量;
- 在内存 M 上初始化 IdGen 对象;
通过这一番优化后,一旦同时进来两个线程,就有可能呈现空指针异样。
因而,怎么管制编译优化,让程序能正确运行?这是并发编程的第三道坎。
写在最初
并发编程是优良程序员的标记,而要做到这点,你得深刻理解:并发 Bug 是计算机谋求高性能的代价。
为了进步性能,计算机界的大神们做了各种优化,但却毁坏了程序的可见性、原子性、有序性。CPU 缓存带来了可见性问题,线程切换带来了原子性问题,编译优化带来了有序性问题。
对于这些问题,大神们再也搞不定了,咱们只能本人想方法解决了。