三种语言的线程实现机制
C/C++
不同Linux系统下提供的底层线程有好几种,其中POSIX类型的线程最为常用。POSIX线程是pthread开头的一系列C语言API,数据结构包括mutex,condition等。C语言多线程不是必须的,只在一些场合适用,而例如单线程的服务器如redis和nginx也能达到较高的并发量。
python
由于python的GIL锁,提供的thread都是实质上单核的多线程,但是在IO密集型应用中仍然可以酌情使用。异步模型的普及也影响了python的特性,至少在3.4+版本,python就有了coroutine,而3.5又多了两个新的关键字async,await,用来代替之前的coroutine装饰器和yield from写法。
Java
Java很适合编写网络编程、多线程应用,它的多线程是原生的。基于Java的多线程和NIO,有知名的Netty框架。Java里常见的底层多线程结构包括Thread,Condition,Semaphore,Lock。Java的synchronized也是一种互斥机制的实现,叫做管程(monitor)。而且它实质上是一个可重入锁,即当前线程嵌套使用synchronized的时候不会发生死锁。顺带一提,Spark应用级别也有await和ssc.await的API。类似于Python,由于Node.js原来也是单线程的,吞吐会受到耗时计算的影响,Node.js v10.5.0 发布之前就是这种情况,在这一版本增加了对多线程的支持。
并发问题模型
生产者-消费者模型
生产者-消费者模型,它抽象了两个实体,生产者负责生产多个资源,而消费者会消费生产者生产的资源。例如,在一种优化的日志处理模式中,主线程负责接收数据,而日志线程负责取出缓冲区的数据,写入磁盘。这里就是一个生产者和消费者。一般的,生产者和消费者不能单独存在,因为生产者会遇到一个瓶颈,就是它生产的东西已经占满了空间,不能再生产了,同理消费者也会受到限制。
生产者-消费者模型的问题解决要用互斥量。
此外,还有哲学家就餐问题,读者-写者模型
哲学家就餐问题延伸出信号量(Semaphore)。
读者-写者模型延伸出读写锁。
数据库中的加锁问题
1. 两段锁协议会导致死锁吗?
两阶段锁协议,整个事务分为两个阶段,前一个阶段为加锁,后一个阶段为解锁。在加锁阶段,事务只能加锁,也可以操作数据,但不能解锁,直到事务释放第一个锁,就进入解锁阶段,此过程中事务只能解锁,也可以操作数据,不能再加锁。两阶段锁协议使得事务具有较高的并发度,因为解锁不必发生在事务结尾。它的不足是没有解决死锁的问题,因为它在加锁阶段没有顺序要求。如两个事务分别申请了A, B锁,接着又申请对方的锁,此时进入死锁状态。
现代的数据库系统中,预防死锁的方法包括了这一方法:一次封锁法。一次封锁法要求事务必须一次性将所有要使用的数据全部加锁,否则就不能继续执行。因此,一次封锁法遵守两段锁协议,但两段锁并不要求事务必须一次性将所有要使用的数据全部加锁,这一点与一次性封锁不同,这就是遵守两段锁协议仍可能发生死锁的原因所在。
两段协议中的死锁:
A访问S1,S2
B访问S2,S1
在A:S1->S2和B:S2->S1的中间重叠的时候会发生死锁。
解决方法是构造一个树形图(怀疑能不能构造)
读写锁相容性矩阵
解决锁粒度的问题
在进行锁授予判断时,采用如下的相容性矩阵进行判断。
IS IX S X
IS Y Y Y N
IX Y Y N N
S Y N Y N
X N N N N
2.
树形协议(Tree Protocol),假设数据项的集合满足一个偏序关系,访问数据项必须按此偏序关系的先后进行。如di->dj,则要想访问dj,必须先访问di。这种偏序关系导出一个有向无环图(DAG),因此称为树形协议。树形协议的规则有:
树形协议只有独占锁;
事务T第一次加锁可以对任何数据项进行;
此后,事务T对数据项Q的加锁前提是持有Q的父亲数据项的锁;
对数据项的解锁可以随时进行;
数据项被事务T加锁并解锁之后,就不能再被事务T加锁。
树形协议的优点是并发度好,因为可以较早地解锁。并且没有死锁,因为其加锁都是顺序进行的。
缺点是对不需要访问的数据进行不必要的加锁。
3.多版本机制
锁是针对集中式数据管理设计的,缺点是降低了事务的并发,并且锁本身有开销。在分布式系统,尤其是读多写少的系统中,采用多版本机制更合适。每个数据项都有多个副本,每个副本都有一个时间戳,根据多版本并发控制协议(MVCC)维护各个版本。([1])
4.乐观锁与悲观锁
MVCC又称为乐观锁,它在读取数据项时,不加锁;在更新数据项时,直到最后要提交时,才会加锁。这与CAS(Compare and Swap)的机制很类似,为了提高并发度,它更新数据前,会将数据拷贝一份,进行一系列修改,并且拷贝的同时,会记录当前的版本号(时间戳),当修改完毕,即将提交时,再检查此时的版本号是否与刚才记录的一致,如果不一致,则表明数据项被其他事务修改,当前事务的修改被取消。否则,正式提交修改,并增加版本号。
与MVCC相对,基于锁的并发控制机制称为悲观锁,因为它认为其他事务修改自己正在使用的数据项的概率很高,因此对数据项加锁以阻塞其他事务的读和写。
参见:
不考虑并发的情况下,更新库存代码如下:
/** * 更新库存(不考虑并发) * @param productId * @return */ public boolean updateStockRaw(Long productId){ ProductStock product = query("SELECT * FROM tb_product_stock WHERE product_id=#{productId}", productId); if (product.getNumber() > 0) { int updateCnt = update("UPDATE tb_product_stock SET number=number-1 WHERE product_id=#{productId}", productId); if(updateCnt > 0){ //更新库存成功 return true; } } return false; }
多线程并发情况下,会存在超卖的可能。
悲观锁
/** * 更新库存(使用悲观锁) * @param productId * @return */ public boolean updateStock(Long productId){ //先锁定商品库存记录 ProductStock product = query("SELECT * FROM tb_product_stock WHERE product_id=#{productId} FOR UPDATE", productId); if (product.getNumber() > 0) { int updateCnt = update("UPDATE tb_product_stock SET number=number-1 WHERE product_id=#{productId}", productId); if(updateCnt > 0){ //更新库存成功 return true; } } return false; }
乐观锁
/** * 下单减库存 * @param productId * @return */ public boolean updateStock(Long productId){ int updateCnt = 0; while (updateCnt == 0) { ProductStock product = query("SELECT * FROM tb_product_stock WHERE product_id=#{productId}", productId); if (product.getNumber() > 0) { updateCnt = update("UPDATE tb_product_stock SET number=number-1 WHERE product_id=#{productId} AND number=#{number}", productId, product.getNumber()); if(updateCnt > 0){ //更新库存成功 return true; } } else { //卖完啦 return false; } } return false; }
引用自[2].
5.
使用sleep的时候虽然不会占用CPU时间,但是它是对线程资源的浪费,因为对线程本身而言,它本来可以用来处理其他的事情,却堵在这个线程这里没有做任何事情
References:
[1] 两阶段锁协议 https://www.cnblogs.com/zszmhd/p/3365220.html
[2] FX_SKY https://www.jianshu.com/p/f5ff017db62a
[3] 后台学习:分布式与存储 https://zhuanlan.zhihu.com/c_1068210975941074944