系列综述:
💞目的:本系列是个人整理为了秋招项目
的,整理期间苛求每个知识点,平衡理解简易度与深入程度。
🥰来源:该项目源于国科大并发数据结构和多核编程
的课程作业
🤭结语:如果有帮到你的地方,就点个赞和关注一下呗,谢谢🎈🎄🌷!!!
🌈项目github链接🌈
🌈【C++】秋招&实习面经汇总篇🌈
一、实验要求【详见附录】
- 如何构建一个好的并发算法
- 数据结构要符合业务的模型,多读型(通常数组)、多写型(通常双链表)、读写平衡型
- 选择合适的锁算法
- 互斥锁mutex、乐观锁算法CAS(lock-free)、条件变量cv(生产者消费者模型的消息队列)、读写锁
二、实验概述
实验背景
本实验是并发数据结构与多核编程课程的实验作业,并以学习的并发编程相关知识为基础,完成了该实验作业项目。具体计划及完成时间为:
实验具体要求的研读 2021.11.26
实验相关并发知识的深入理解 11.27~11.28
理解项目并进行实验环境的搭建 11.29~11.30
非并发的单线程串行项目的实现 12.1
基于粗粒度锁的并发数据结构的实现 12.2
基于细粒度锁的并发数据结构的实现 12.3
基于乐观锁的并发数据结构的实现及实验报告的编写 12.4
最终,实验项目实现了基于乐观锁的并发数据结构,性能较细粒度锁提升不大,可能是受到笔记本性能的限制,但是吞吐率上下波动更小,并且在测试过程中,未出现并发线程增加而吞吐率下降的现象。
项目结构
-
座位类Seat
基础数据结构的选择
逻辑好+效率高
:使用3维静态数组
(车次*车厢*座位数*车站数
)或者一维静态数组+地址计算
。逻辑上,通过维度进行分层,使得逻辑清晰增加代码易读性。效率上,经过性能测试,因为行优先存储的N维数组或者一维数组,实际都是以地址(=*维度+偏移地址)直接访问的形式。使用一维数组+地址计算,地址计算可以使用类似段页式的实际地址 = 基地址+页号*页表项大小
,访问速度与N维静态数组相同。静态数组在堆上有专用的栈寄存器可以加速计算速度。逻辑最好+效率好
:使用类对象数组的方式进行存储,不能含有虚函数表等其他指针。逻辑上,可以通过N维类对象数组实现面向对象的设计。效率上,N维类对象数组的存储形式实际物理内存中,仍然是一个数组,但是动态存储的查找会多一个指针寻址的开销。(性能测试上,N维类对象存储=一维类对象存储=动态申请N维数组=动态申请一维数组,10亿访问比静态约差0.5%)。逻辑最差+效率最差
:使用一维数组+地址计算,地址计算中含有除法操作,会增加三倍的访问开销。- inux栈的默认大小是10M,要避免栈溢出([各种形式数组的访问速度比较])(https://blog.csdn.net/qq_43840665/article/details/132305051?spm=1001.2014.3001.5501))
-
车票数据系统类TicketingDS
- 继承TicketingSystem接口,并对其中的方法进行了重写
check方法
:完成对于输入【车次】【出发站】【终点站】正确性验证
inquiry方法
:进行列车具体到【车次】【出发站】【终点站】的余票查询
buyTicket方法
:进行列车具体【车次】【出发站】【终点站】的车票购买
,以及车票对象的返回refundTicket方法
:进行列车具体【车次】【出发站】【终点站】车票退回
-
列车类Train
-
跳表为什么是一个好的并发数据结构
-
优化方式:
- 局部性原理:买票时首先查看最近刚刚退掉票的座位。
- 采用随机选取座位
- 每个数字存储多个座位信息。 每个元素为long,假设有10个车站,则第1到第10个比特表示第一个座位的信息,第11到第20个比特表示第二个座位的信息。将数据进一步进行压缩。
锁的选择
- CAS锁
1、开销较小:不需要进入内核,不需要切换线程;
2、没有死锁:总线锁最长持续为一次read+write的时间;
3、只有写操作需要使用CAS,读操作与串行代码完全相同,可实现读写不互斥。
缺点:
1、编程非常复杂,两行代码之间可能发生任何事,很多常识性的假设都不成立。
2、CAS模型覆盖的情况非常少,无法用CAS实现原子的复数操作。- ABA问题以及解决方式
- X86平台上的CAS操作一般是通过CPU的CMPXCHG指令来完成的。CPU在执行此指令时会首先锁住CPU总线(拓展到缓存一致性问题),禁止其它核心对内存的访问,然后再查看或修改*ptr的值。简单的说CAS利用了CPU的硬件锁来实现对共享资源的串行使用。
- 单线程下CAS的开销大约为10次加法操作,mutex的上锁+解锁大约为20次加法操作,而readwrite lock的开销则更大一些。条件变量的消息队列主要用于同步问题
2、CAS的性能为固定值,而mutex则可以通过改变临界区的大小来调节性能;
3、如果临界区中真正的修改操作只占一小部分,那么用CAS可以获得更大的并发度。
4、多核CPU中线程调度成本较高,此时更适合用CAS。
java中的同步和互斥https://blog.csdn.net/Minimum_Time_Hour/article/details/104505406
trainInQuiry方法:对每个车次座位在区间空余信息的查询
trainBuy方法:判断座位是否空余,购买成功返回SeatID
trainRefund方法:判断退票信息正确性后进行退票操作
Seat
Seat类中的座位是细粒度锁实现的基本单位,具体对于抽象的列车座位的区间占用信息进行了修改,其中核心是使用二进制进行座位占用区间的标识,相比布尔数组极大的提高了性能,具体方法:
seatInquiry方法:使用二进制的与运算进行原座位占用区间与查询区间的信息对比
seatBuy方法:使用二进制的或运算进行座位占用区间的增加
seatRefund方法:使用二进制的与和非运算进行占用区间的置位
Test
Test类实现了对于该并发数据结构的多线程性能测试,具体依赖以下方法:
Runnable方法:线程实例化的匿名内部类,对于线程调用TicketingDS方法的随机测试
start和join方法:进行异步多线程的执行
System.currentTimeMillis方法:获取毫秒级的当前时间,效率比newTime方法高
随机测试下的平均买票时间、平均退票时间、平均查询时间以及吞吐率的计算
- 减少锁竞争手段
第一个是缩小锁的范围:将与锁无关代码移除同步代码块,尤其是那些可能发生阻塞的操作比如I/O;
第二个是减少锁的粒度:使用多个相互独立锁管理独立的状态变量,改变某个变量只用获取对应变量锁,而不用获取整体锁,其他线程仍然能使用其他变量。但是使用锁越多,那么发生死锁的风险也就越高。
第三个是锁分段:比如ConcurrentHashMap底层的链表数组,对数组中每一个数组元素进行加锁,数组长度是多少就有多少个锁,也就最大支持多少并发。不过在对数组扩张的时候就会更加复杂;
第四个是避免热点域:当一个操作要访问多个变量时,锁的粒度就很难减少了,一种解决方法是将这多个变量的计数结果缓存起来,都会引入一些“热点域”,这些热点域往往会限制可伸缩性。
- 多线程的额外开销
上下文切换:如果可运行的线程数大于CPU的数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其他线程能够使用CPU,这将导致一次上下文切换,这个过程将保存当前运行线程的执行上下文,并将新调度进来的线程的执行上下文设置为当前上下文。
内存同步:多线程开发一般都会有synchronized和volatile等保证资源的可见性,这些关键字可能会使用一些特殊的指令内存栅栏(Menory Barrier),这些指令可以刷新缓存,内存栅栏可能对性能带来影响,因为他们会抑制一些编译器优化操作,比如不能指令重排。
线程阻塞:一个线程可能会阻塞,尤其在存在锁竞争的情况,竞争失败的线程肯定会阻塞。线程发生阻塞JVM可能会使线程自旋或者挂起,如果对线程进行挂起,那么就会多两个额外的上下文切换,挂起和恢复。
基本模型
- 计算机使用状态机模型进行问题的解决
:计算机是按照冯诺依曼构型进行设计的,冯诺依曼构型可以形式化为一个状态机(第一性原理,每一次转换都有明确的形式化证明)
- 并发程序也是状态机问题
:冯诺依曼上运行的程序都可以形式化成一个状态机,并发程序也是程序。
- 并发系统在状态机模型上的形式化
- 历史(history):一个由方法/操作
组成的有序
有穷序列,每个方法由启用(invocation)和回应(response)事件两部分组成。
- 合法历史(Legal Histories):一个历史在时间轴上的完成点是满足顺序规约的
- 可线性化(Linearizability point)
- 通俗解释:方法/操作的每次调用都可以看作是在该次调用的启用和回应之间的某个时间点
发生的。
- 形式化解释:一个历史的延拓(含有未完成操作的历史)在完成后等价于合法历史。&& 合法历史的操作先后关系集合包含未完成的延拓历史中操作先后关系集合。(已完成的包含未完成的,未完成的完成后会成为已完成的)
即:在一个可线性化的系统中,有一个很重要的约束条件,在写操作开始和结束之间必然存在一个时间段,此时读到x的值会在旧值与新值之间跳变。但是,如果某个客户端的读请求返回了新值,那么即使这时写操作还未真正完成,后续的所有读请求也应该返回新值。
以下的例子进一步解释可线性化的操作,除了读写之外又引入另一种操作:
cas(x, old, new):表示一次原子的比较-设置操作(compare-and-set,简称CAS),如果此时x的值为old,则原子设置这个值为new;否则保留原有值不变,这个操作的返回值表示这次x原有的值是否为old,即设置操作是否发生。
三、项目结构
- TicketingDS
基于TicketingSystem接口,对其中的方法进行了重写,具体实现了以下方法:- inquiry方法:进行列车具体到【车次】【出发站】【终点站】的余票查询
- buyTicket方法:进行列车具体【车次】【出发站】【终点站】的购买以及车票对象的返回
- refundTicket方法:进行列车具体【车次】【出发站】【终点站】车票的退回
- check方法:完成对于输入【车次】【出发站】【终点站】正确性验证
- Train
TicketingDS类进行了Train类的实例化,而Train类的作用是列车座位和售票系统之间信息的交换,具体方法:- trainInQuiry方法:对每个车次座位在区间空余信息的查询
- trainBuy方法:判断座位是否空余,购买成功返回SeatID
- trainRefund方法:判断退票信息正确性后进行退票操作
- Seat
Seat类中的座位是细粒度锁实现的基本单位,具体对于抽象的列车座位的区间占用信息进行了修改,其中核心是使用二进制进行座位占用区间的标识,相比布尔数组极大的提高了性能,具体方法:- seatInquiry方法:使用二进制的与运算进行原座位占用区间与查询区间的信息对比
- seatBuy方法:使用二进制的或运算进行座位占用区间的增加
- seatRefund方法:使用二进制的与和非运算进行占用区间的置位
- Test
Test类实现了对于该并发数据结构的多线程性能测试,具体依赖以下方法:- Runnable方法:线程实例化的匿名内部类,对于线程调用TicketingDS方法的随机测试
- start和join方法:进行异步多线程的执行
- System.currentTimeMillis方法:获取毫秒级的当前时间,效率比newTime方法高
- 随机测试下的平均买票时间、平均退票时间、平均查询时间以及吞吐率的计算
四、实验分析
-
正确性分析
- 使用AtomicLong的数据结构,获取车票ID使用getAndIncrement方法,保证车票ID的原子性和唯一性
- 买票、退票和查询余票⽅法均需要通过check方法的验证,避免无效输入带来系统的错误
- 每个区段有余票时,系统一定可以满足票的购买,使用CAS进行余票的购买,必定有一个线程可以获得票
- 每个线程进行车票购买的时候需要进行余票的验证,如果没有余票将不能购买
- 如果查询没有余票那么将不能进行购买,并且保证了在进行购买时的并发性,不会出现错误的返回
-
流程分析
项目按照老师上课讲解的递进式开发,从最简单的锁一步一步进行高性能并发程序的编写:- 非并发数据结构:按照要求先进行基础售票系统的开发,并根据老师的要求进行符合要求的正确性检验
- 粗粒度锁:对的每个车次使用可重入锁进行加锁,但是性能表现很差
- 细粒度锁:对列车的座位进行加锁,具有良好的并发性,但由于对于座位的查询操作需要大量进行加锁和解锁操作,导致仍然具有性能的瓶颈
- 乐观锁:仍然是对列车的座位进行加锁,但是使用的是基于版本控制的CAS进行数据并发修改正确性的保证,具有良好的性能表现
-
并发数据结构锁的性质
-
deadlock-free
由于使用的是CAS,这是一种乐观锁算法,实质是基于版本的校验,并不对线程进行加锁,所以也不会导致死锁的现象。
-
starvation-free
线程执行CAS进行列车座位区间的修改,并不会导致出现无限等待,即每个线程一定会在有限步内完成,所以是无饥饿的。
-
lock-free
每个线程进行操作无论成功与否一定会在有限步内完成,进程之间的竞争也一定会有胜出者完成事务的处理,所以满足无锁
-
wait-free
系统中的所有线程,都会在有限时间内结束,无论如何也不可能出现饿死的情况,乐观锁保证了系统中的所有线程都能处于工作状态,没有线程会被饿死,只要时间够,所有线程都能结束。
-
五、实验总结
- 本机Test测试:
环境:R7-4800H (8核16线程)CPU,16G DDR4内存,512G PCI协议固态硬盘
-
教学服务器Trace测试
-
教学服务器Verify.sh测试
-
教学服务器Verify.sh测试100次,每个方法调用100000次,均Finished
附录:
数据结构说明
给定Ticket
类:
class Ticket{
long tid;
String passenger;
int route;
int coach;
int seat;
int departure;
int arrival;
}
其中,tid
是车票编号,passenger
是乘客名字,route
是列车车次,coach
是车厢号,seat
是座位号,departure
是出发站编号,arrival
是到达站编号。
给定TicketingSystem
接口:
public interface TicketingSystem {
Ticket buyTicket(String passenger, int route, int departure, int arrival);
int inquiry(int route, int departure, int arrival);
boolean refundTicket(Ticket ticket);
}
其中:
buyTicket
是购票方法,即乘客passenger
购买route
车次从departure
站到arrival
站的车票1张。若购票成功,返回有效的Ticket
对象;若失败(即无余票),返回无效的Ticket
对象(即return null
)。refundTicket
是退票方法,对有效的Ticket
对象返回true
,对错误或无效的Ticket
对象返回false
。inquriy
是查询余票方法,即查询route
车次从departure
站到arrival
站的余票数。
完成TicketingDS
类
完成一个用于列车售票的可线性化并发数据结构:TicketingDS
类:
- 实现
TicketingSystem
接口, - 提供
TicketingDS(routenum, coachnum, seatnum, stationnum, threadnum);
构造函数。
其中:
routenum
是车次总数(缺省为5个),coachnum
是列车的车厢数目(缺省为8个),seatnum
是每节车厢的座位数(缺省为100个),stationnum
是每个车次经停站的数量(缺省为10个,含始发站和终点站),threadnum
是并发购票的线程数(缺省为16个)。
为简单起见,假设每个车次的coachnum
、seatnum
和stationnum
都相同。
车票涉及的各项参数均从1开始计数,例如车厢从1到8号,车站从1到10编号等。
完成多线程测试程序
需编写多线程测试程序,在main
方法中用下述语句创建TicketingDS
类的一个实例。
final TicketingDS tds = new TicketingDS(routenum, coachnum, seatnum, stationnum, threadnum);
系统中同时存在threadnum
个线程(缺省为16个),每个线程是一个票务代理,需要:
- 按照60%查询余票,30%购票和10%退票的比率反复调用
TicketingDS
类的三种方法若干次 - 按照线程数为4,8,16,32,64个的情况分别调用。
需要最后给出:
- 给出每种方法调用的平均执行时间
- 同时计算系统的总吞吐率(单位时间内完成的方法调用总数)
正确性要求
需要保证以下正确性:
- 每张车票都有一个唯一的编号
tid
,不能重复。 - 每一个
tid
的车票只能出售一次。退票后,原车票的tid
作废。 - 每个区段有余票时,系统必须满足该区段的购票请求。
- 车票不能超卖,系统不能卖无座车票。
- 买票、退票和查询余票方法均需满足可线性化要求。
文件清单
所有Java程序放在ticketingsystem
目录中,trace.sh
文件放在ticketingsystem
目录的上层目录中。
如果程序有多重目录,那么将主Java程序放在ticketingsystem
目录中。
文件清单如下:
TicketingSystem.java
是规范文件,不能更改。Trace.java
是trace生成程序,用于正确性验证,不能更改。trace.sh
是trace生成脚本,用于正确性验证,不能更改。TicketingDS.java
是并发数据结构的实现。Test.java
实现多线程性能测试。
程序放在ticketingsystem
目录中。
文件清单如下:
TicketingSystem.java
是规范文件,不能更改。Trace.java
是trace生成程序,用于正确性验证,不能更改。trace.sh
是trace生成脚本,用于正确性验证,不能更改。TicketingDS.java
是并发数据结构的实现。Test.java
实现多线程性能测试。