1、并发与并行的区别?
并发(Concurrency)
并发是指一个时间段内同时处理多个任务的能力。它强调的是任务之间的独立性,以及它们是如何在一个处理器或单个核心上交替执行的。并发的目标是在有限的资源下实现任务之间的有效调度,从而最大限度地提高系统的响应事件。并发并不意味着任务在同一时刻被执行,而是指它们在同一时间段内得到处理。
并行(Parallelism)
并行是指在同一时刻执行多个任务的能力。它要求系统具备多个处理器或多个计算核心,这样就可以同时处理多个任务。并行的目标是加速任务的完成速度,通过将任务分解为更小的部分并在多个处理器或核心上同时执行,以实现更快的任务执行速度。
区别
- 并发关注在一个时间段内处理多个任务,而并行关注在同一时刻执行多个任务。
- 并发适用于单处理器或单核心系统,通过任务调度实现多任务处理;并行则依赖于多处理器或多核心系统来实现任务的同时执行。
- 并发主要用于提高系统的响应速度和吞吐量,而并行则旨在加速任务的完成速度。
2、同步和互斥
同步指的是在不同任务或线程间按照特定顺序执行程序片段的机制;
互斥指的是确保同一资源在同一时间只被一个任务或线程访问的机制。
3、讲一讲死锁,死锁怎么处理?
死锁是指在多线程或多进程环境中,一组或多组线程/进程互相等待彼此持有的资源,导致这些线程/进程无法继续执行的情况。当死锁发生时,受影响的线程/进程无法完成任务,系统性能和吞吐量可能会受到严重影响。
死锁条件
- 互斥条件(Mutual Exclusion):一个资源在同一时间只能被一个线程或进程占用。
- 占有且等待条件(Hold and Wait):一个线程或进程在持有至少一个资源的同时,仍然尝试获取其他线程或进程所持有的资源。
- 不可抢占条件(No Preemption):资源不能被强行从一个线程或进程中抢占,只能由占有它的线程或进程主动释放。
- 循环等待条件(Circular Wait):存在一个线程/进程等待序列,其中每个线程/进程都在等待下一个线程/进程所持有的资源。
处理死锁
分为预防、避免和检测恢复三种策略:
预防死锁: 预防死锁的方法是破坏死锁产生的四个条件中的一个或多个。例如:
- 破坏占有且等待条件:要求线程/进程在请求资源之前释放所有已经持有的资源,或者一次性请求所有需要的资源。
- 破坏循环等待条件:为所有资源分配一个全局的顺序,并要求线程/进程按照这个顺序请求资源。
避免死锁: 避免死锁的方法是在运行时动态地检查资源分配情况,以确保系统不会进入不安全状态。银行家算法是一种著名的避免死锁的算法,通过模拟资源分配过程来判断是否会产生死锁,如果会产生死锁,则拒绝分配资源。
检测和恢复死锁: 检测和恢复死锁的方法是允许系统进入死锁状态,然后定期检测死锁,并在发现死锁后采取措施解决。常见的检测方法包括资源分配图(Resource Allocation Graph)和检测算法。恢复死锁的方法通常包括以下几种:
- 终止线程/进程:强制终止一个或多个死锁中的线程/进程,从而释放其持有的资源。这种方法可能会导致数据丢失或不一致,因此需要谨慎使用。
- 回滚线程/进程:将死锁中的线程/进程回滚到之前的某个状态,然后重新执行。这种方法需要系统支持事务和恢复功能,并且可能会影响系统性能。
- 动态资源分配:在检测到死锁后,尝试动态地分配资源,以解除死锁。例如,可以向系统请求更多资源,或者在不影响整体性能的情况下调整资源分配策略。
- 等待和重试:在某些情况下,可以让死锁中的线程/进程等待一段时间,以便其他线程/进程释放资源。等待一段时间后,重新尝试请求资源。这种方法可能会导致线程/进程长时间处于等待状态,从而影响系统性能。
4、信号量是如何实现的?
信号量(Semaphore)是一种同步原语,用于实现多线程和多进程之间的同步和互斥。信号量的本质是一个整数计数器,通常用于限制对共享资源的访问数量。信号量的实现涉及到两个关键操作:wait(或称为P操作)和post(或称为V操作)。
基本实现原理
- 初始化:信号量在创建时需要进行初始化,通常将计数器设置为允许同时访问共享资源的最大数量。
- Wait(P)操作:当一个线程或进程想要访问共享资源时,会执行wait操作。在wait操作中,信号量的计数器减1。如果计数器的值为负数,表示没有可用的资源,执行wait操作的线程/进程将被阻塞,直到有资源可用。
- Post(V)操作:当一个线程或进程完成对共享资源的访问后,会执行post操作。在post操作中,信号量的计数器加1。如果计数器的值小于等于0,表示有等待的线程/进程,此时会唤醒一个被阻塞的线程/进程。
信号量的实现依赖于底层操作系统原语,以保证wait和post操作的原子性。在Linux系统中,信号量有两种实现方式:System V信号量和POSIX信号量。
- System V信号量:System V信号量使用一组系统调用(如semget、semop和semctl)实现。这些系统调用提供了原子性操作,以保证信号量的正确性。System V信号量具有更强的跨进程特性,可以在不相关的进程之间使用。
- POSIX信号量:POSIX信号量使用sem_t数据结构,并通过一组函数(如sem_init、sem_wait、sem_trywait、sem_post和sem_destroy)提供信号量操作。POSIX信号量在现代Linux系统中较为常用,因为它们具有较好的可移植性和性能。
在实际使用中,信号量可以帮助程序员控制对共享资源的访问,防止竞争条件和实现同步。
5、生产者消费者问题?
生产者消费者问题(英语:Producer-consumer problem),也称有限缓冲问题(英语:Bounded-buffer problem),是一个多线程同步问题的经典案例。在生产者消费者问题中,有一组生产者线程/进程和一组消费者线程/进程,它们共享一个有限容量的缓冲区。生产者负责将数据项放入缓冲区,消费者则从缓冲区中取出数据项进行处理。问题的核心在于如何实现对共享缓冲区的同步访问,确保数据不会丢失或被重复处理。
以下是生产者消费者问题的一些关键点:
- 同步:需要确保当缓冲区为空时,消费者不能从中取出数据;当缓冲区已满时,生产者不能向其中添加数据。这需要使用同步原语(如互斥锁、信号量或条件变量)来实现。
- 互斥:多个生产者和消费者线程/进程需要互斥地访问共享缓冲区,防止同时修改缓冲区导致的数据不一致问题。这通常使用互斥锁(Mutex)来实现。
- 缓冲区管理:需要实现一个适当的数据结构来存储缓冲区中的数据项,例如队列、栈或循环缓冲区。同时,需要考虑缓冲区的容量限制。
解决生产者消费者问题的常用方法是使用信号量和互斥锁。以下是一种典型的解决方案:
-
初始化两个信号量:一个表示空闲缓冲区的数量(初始值为缓冲区的最大容量),另一个表示已使用的缓冲区数量(初始值为0)。
-
初始化一个互斥锁,用于保护对共享缓冲区的访问。
-
生产者在生产数据时:
a. 执行wait操作,等待空闲缓冲区信号量。
b. 获取互斥锁,保护共享缓冲区的访问。
c. 将数据项放入缓冲区。
d. 释放互斥锁。
e. 执行post操作,增加已使用缓冲区信号量。
4. 消费者在消费数据时:
a. 执行wait操作,等待已使用缓冲区信号量。
b. 获取互斥锁,保护共享缓冲区的访问。
c. 从缓冲区中取出数据项。
d. 释放互斥锁。
e. 执行post操作,增加空闲缓冲区信号量。
伪代码如下:
//items表示缓冲区已使用资源,space表示缓冲区的可用资源,mutex表示互斥锁
var items = 0, space = 10, mutex = 1;
var in = 0, out = 0;//in、out代表第一个资源和最后一个资源
item buf[10] = { NULL };//buf[10] 代表缓冲区,其内容类型为item
producer {
while( true ) {
wait( space ); // 等待缓冲区有空闲位置, 在使用PV操作时,条件变量需要在互斥锁之前
wait( mutex ); // 保证在product时不会有其他线程访问缓冲区
// product
buf.push( item, in ); // 将新资源放到buf[in]位置
in = ( in + 1 ) % 10;
signal( mutex ); // 唤醒的顺序可以不同
signal( items ); // 通知consumer缓冲区有资源可以取走
}
}
consumer {
while( true ) {
wait( items ); // 等待缓冲区有资源可以使用
wait( mutex ); // 保证在consume时不会有其他线程访问缓冲区
// consume
buf.pop( out ); // 将buf[out]位置的的资源取走
out = ( out + 1 ) % 10;
signal( mutex ); // 唤醒的顺序可以不同
signal( space ); // 通知缓冲区有空闲位置
}
}
通过这种方法,生产者和消费者可以实现对共享缓冲区的同步和互斥访问,从而解决生产者消费者问题。
另一种常见的解决方案是使用条件变量和互斥锁:
-
初始化一个互斥锁,用于保护对共享缓冲区的访问。
-
初始化两个条件变量:一个表示缓冲区非空(可以供消费者取出数据),另一个表示缓冲区非满(可以供生产者放入数据)。
-
生产者在生产数据时:
a. 获取互斥锁,保护共享缓冲区的访问。
b. 当缓冲区已满时,等待缓冲区非满的条件变量。
c. 将数据项放入缓冲区。
d. 通知缓冲区非空的条件变量,表示有数据可供消费者取出。
e. 释放互斥锁。
-
消费者在消费数据时:
a. 获取互斥锁,保护共享缓冲区的访问。
b. 当缓冲区为空时,等待缓冲区非空的条件变量。
c. 从缓冲区中取出数据项。
d. 通知缓冲区非满的条件变量,表示有空间可供生产者放入数据。
e. 释放互斥锁。
使用条件变量的方案更直接地表达了生产者和消费者之间的同步关系,且在某些情况下可能比使用信号量的方案具有更好的性能。
6、哲学家进餐问题?
哲学家进餐问题用于描述多线程间的同步和互斥问题。问题设定为有五位哲学家围坐在一个圆桌上,他们之间共享五根筷子。哲学家的生活包括两个行为:思考和进餐。当哲学家饿了,他们需要拿起左右两边的筷子才能开始进餐,进餐完毕后放下筷子继续思考。问题的关键在于如何设计一个并发算法,使得哲学家们能够同时进餐而不发生死锁或饿死的情况。
以下是哲学家进餐问题的一些解决方案:
- 顺序加锁:每个哲学家按照顺序先拿起左边的筷子,再拿起右边的筷子。这种方法可能导致死锁,因为每个哲学家都拿起了左边的筷子,却等不到右边的筷子。
- 资源分级:为每根筷子分配一个优先级,每个哲学家总是先拿起优先级较高的筷子,再拿起优先级较低的筷子。这种方法可以避免死锁,因为至少有一个哲学家可以拿到两根筷子。
- 限制同时进餐人数:限制同时进餐的哲学家数量,例如最多允许四位哲学家同时进餐。这种方法可以避免死锁,因为总是至少有一位哲学家能够拿到两根筷子。
semaphore chopstick[5]={1,1,1,1,1}; semaphore count=4; // 设置一个count,最多有四个哲学家可以进餐 void philosopher(int i) { while(true) { think(); wait(count); //请求进餐 当count为0时 不能允许哲学家再进来了 wait(chopstick[i]); //请求左手边的筷子 wait(chopstick[(i+1)%5]); //请求右手边的筷子 eat(); signal(chopstick[i]); //释放左手边的筷子 signal(chopstick[(i+1)%5]); //释放右手边的筷子 signal(count); //结束进餐释放信号量 } }
- 奇偶分组:
规定奇数号的哲学家先拿起他左边的筷子,然后再去拿他右边的筷子;而偶数号的哲学家则先拿起他右边的筷子,然后再去拿他左边的筷子。按此规定,将是1、2号哲学家竞争1号筷子,3、4号哲学家竞争3号筷子。即五个哲学家都竞争奇数号筷子,获得后,再去竞争偶数号筷子,最后总会有一个哲学家能获得两支筷子而进餐。比如,1号哲学家拿了左手筷子即5号筷子,2号正好也拿了右手筷子即2号筷子,1号筷子被二者竞争(不存在2号哲学家没有拿到右手筷子就占着左手筷子的情况,因为偶数哲学家必须先拿到右手筷子才有资格去拿左手筷子),此时,不管谁拿到了,都能进餐完成,不会死锁,其余相邻哲学家同理。
-
semaphore chopstick[5]={1,1,1,1,1}; void philosopher(int i) { while(true) { think(); if(i%2 == 0) //偶数哲学家,先右后左。 { wait (chopstick[(i + 1)%5]) ; wait (chopstick[i]) ; eat(); signal (chopstick[(i + 1)%5]) ; signal (chopstick[i]) ; } else //奇数哲学家,先左后右。 { wait (chopstick[i]) ; wait (chopstick[(i + 1)%5]) ; eat(); signal (chopstick[i]) ; signal (chopstick[(i + 1)%5]) ; } } }
- 使用信号量:仅当哲学家的左右两支筷子都可用时,才允许他拿起筷子进餐。可以利用AND 型信号量机制实现,也可以利用信号量的保护机制实现。利用信号量的保护机制实现的思想是通过记录型信号量mutex对取左侧和右侧筷子的操作进行保护,使之成为一个原子操作,这样可以防止死锁的出现。
semaphore mutex = 1; // 这个过程需要判断两根筷子是否可用,并保护起来 semaphore chopstick[5]={1,1,1,1,1}; void philosopher(int i) { while(true) { /* 这个过程中可能只能由一个人在吃饭,效率低下,有五只筷子,其实是可以达到两个人同时吃饭 */ think(); wait(mutex); // 保护信号量 wait(chopstick[(i+1)%5]); // 请求右手边的筷子 wait(chopstick[i]); // 请求左手边的筷子 signal(mutex); // 释放保护信号量 eat(); signal(chopstick[(i+1)%5]); // 释放右手边的筷子 signal(chopstick[i]); // 释放左手边的筷子 } }
哲学家进餐问题提供了一个很好的模型,用于研究并发编程中的死锁、饥饿和同步问题。实际上,哲学家进餐问题可以推广到更广泛的多线程或多进程同步问题,特别是涉及到多个共享资源的情况。
上述解决方案可以避免死锁,但并不一定能完全解决饥饿问题。例如,当某些哲学家持续拿不到筷子时,他们可能饿死。要解决饥饿问题,可以采用公平调度策略,例如轮询或优先级调度。此外,可以使用其他同步原语,如读写锁或监视器,来进一步优化解决方案。