前置知识点:
锁与信号量
经典的多线程并发问题,需要考虑线程之间的同步和互斥,常用的解决方法包括互斥锁、条件变量、信号量等。针对不同的问题,需要选择合适的解决方法,保证线程之间的正确同步。
经典的并发问题有以下这几个:
生产者-消费者问题:有一组生产者线程/进程和一组消费者线程/进程,它们共享一个有限容量的缓冲区。生产者负责将数据项放入缓冲区,消费者则从缓冲区中取出数据项进行处理
哲学家就餐问题:涉及到多个哲学家和多个餐叉,每个哲学家需要持有两个餐叉才能进餐。当多个哲学家同时想进餐时,可能会出现死锁。
读者-写者问题:涉及到多个线程对共享数据的读取和写入操作。读者线程可以并发地读取共享数据,写者线程需要独占地写入共享数据,读者和写者之间需要保持互斥。
本文分别
利用两种语言分别来解决这些并发问题。
生产者消费者问题是一个经典的并发问题,用于描述多线程或多进程间的同步和互斥问题。在生产者消费者问题中,有一组生产者线程/进程和一组消费者线程/进程,它们共享一个有限容量的缓冲区。生产者负责将数据项放入缓冲区,消费者则从缓冲区中取出数据项进行处理。问题的核心在于如何实现对共享缓冲区的同步访问,确保数据不会丢失或被重复处理。
生产者消费者问题的一些关键点:
同步:需要确保当缓冲区为空时,消费者不能从中取出数据;当缓冲区已满时,生产者不能向其中添加数据。这需要使用同步原语(如互斥锁、信号量或条件变量)来实现。
**互斥:**多个生产者和消费者线程/进程需要互斥地访问共享缓冲区,防止同时修改缓冲区导致的数据不一致问题。这通常使用互斥锁(Mutex)来实现。
缓冲区管理:需要实现一个适当的数据结构来存储缓冲区中的数据项,例如队列、栈或循环缓冲区。同时,需要考虑缓冲区的容量限制。
C++ 和 Golang 都提供了多种同步机制,可以用来实现生产者-消费者问题。C++ 中常用的同步设施包括互斥锁、条件变量、信号量等;Golang 中常用的同步设施包括 chan、sync.Mutex、sync.WaitGroup等。
在 C++ 中,需要使用 std::mutex 和 std::condition_variable 实现生产者-消费者问题。当生产者向队列中添加数据时,需要获取互斥锁;当消费者从队列中取出数据时,也需要获取互斥锁,并且需要使用条件变量来等待生产者添加数据的通知。
在 Golang 中,可以使用 chan 实现生产者-消费者问题。当生产者向 chan 中添加数据时,会自动阻塞,直到有消费者从 chan 中取出数据;当消费者从 chan 中取出数据时,会自动阻塞,直到有生产者向 chan 中添加数据
使用互斥锁 mtx 和条件变量 cv 实现了一个生产者-消费者模型
生产者线程 t1 负责向队列 q 中添加数据,消费者线程 t2 和 t3 负责从队列 q 中取出数据。当队列 q 中有数据时,消费者线程可以立即消费;当队列 q 中没有数据时,消费者线程需要等待条件变量 cv,直到生产者线程添加数据并通知条件变量。
#include
#include
#include
#include
#include
std::mutex mtx;
std::condition_variable cv;
std::queue<int> q;
void producer(int id) {
for (int i = 0; i < 10; i++) {
std::unique_lock<std::mutex> lock(mtx);
q.push(i);
std::cout << "Producer " << id << " produced " << i << std::endl;
cv.notify_one();
}
}
void consumer(int id) {
for (int i = 0; i < 10; i++) {
std::unique_lock<std::mutex> lock(mtx);
while (q.empty()) {
cv.wait(lock);
}
int val = q.front();
q.pop();
std::cout << "Consumer " << id << " consumed " << val << std::endl;
}
}
int main() {
std::thread t1(producer, 1);
std::thread t2(consumer, 1);
std::thread t3(consumer, 2);
t1.join();
t2.join();
t3.join();
return 0;
}
在 Golang 中,可以使用 chan 实现生产者-消费者问题。
使用 chan int 类型实现了一个生产者-消费者模型。生产者协程 producer 负责向 ch 中添加数据,消费者协程 consumer 负责从 ch 中取出数据。生产者协程和消费者协程之间通过 ch 通信。当 ch 中有数据时,消费者协程可以立即消费;当 ch 中没有数据时,消费者协程会阻塞,直到生产者协程添加数据。
package main
import "fmt"
func producer(id int, ch chan<- int) {
for i := 0; i < 10; i++ {
ch <- i
fmt.Printf("Producer %d produced %d\n", id, i)
}
}
func consumer(id int, ch <-chan int) {
for i := 0; i < 10; i++ {
val := <-ch
fmt.Printf("Consumer %d consumed %d\n", id, val)
}
}
func main() {
ch := make(chan int)
go producer(1, ch)
go consumer(1, ch)
go consumer(2, ch)
select {}
}
有五位哲学家围坐在一个圆桌上,他们之间共享五根筷子。哲学家的生活包括两个行为:思考和进餐。当哲学家饿了,他们需要拿起左右两边的筷子才能开始进餐,进餐完毕后放下筷子继续思考。问题的关键在于如何设计一个并发算法,使得哲学家们能够同时进餐而不发生死锁或饿死的情况。
本文主要是对leedcode关于这个问题的题解展开。
leedcode哲学家进餐问题
首先我们要知道死锁的四个条件;
互斥条件:指进程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程占用。
请求与保持条件:指进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他进程占用,此时请求进程阻塞,但又对自己已获得的其它资源保持不放。
不剥夺条件:指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
循环等待条件:指若干进程间形成一种头尾相接的循环等待资源的关系。
解决办法:
1、同一时间只允许一个人进餐,想要进餐得获得互斥锁 (破坏请求与保持条件)
从效率角度上,这不是最好的解决方案。
2、同时拿起左右的叉子 (破坏请求与保持条件)
仅当哲学家的左,右两只筷子均可用时,才允许他拿起筷子进餐。
3、控制哲学家就餐数量 (这里有五个哲学家, 所以允许最多4个哲学可以进餐) (破坏循环等待条件)
4、限定就餐策略 (破坏死锁的循环等待条件)
规定奇数号的哲学家先拿起他左边的筷子,然后再去拿他右边的筷子;而偶数号的哲学家则先拿起他右边的筷子,然后再去拿他左边的筷子。
由于第一种方法效率过低。下面使用c++与go语言对后面三种方法进行复现。
由于每个哲学家在拿起叉子之前都需要申请mutex信号量,因此可以保证同一时间只有一个哲学家可以拿起叉子。并且是同时拿起左右两个叉子。
#include
class DiningPhilosophers {
public:
DiningPhilosophers() {
fork = vector<sem_t>(5);
for(int i = 0;i < 5;i++){
sem_init(&(fork[i]),0,1);
}
sem_init(&mutex,0,1);
}
void wantsToEat(int philosopher,
function<void()> pickLeftFork,
function<void()> pickRightFork,
function<void()> eat,
function<void()> putLeftFork,
function<void()> putRightFork) {
sem_wait(&mutex);
sem_wait(&(fork[philosopher]));
sem_wait(&(fork[(philosopher+1)%5]));
pickLeftFork();
pickRightFork();
sem_post(&mutex);
eat();
putRightFork();
putLeftFork();
sem_post(&(fork[(philosopher+1)%5]));
sem_post(&(fork[philosopher]));
}
vector<sem_t> fork;
sem_t mutex;
};
允许四个哲学家都拿起同一边的筷子,这样会保证一位哲学家可以得到两边筷子完成进食,释放临界资源,保证别的哲学家可以进行相应的线程。
要新增一个信号量,控制拿起同一边筷子的哲学家数量
#include
class DiningPhilosophers {
public:
DiningPhilosophers() {
fork = vector<sem_t>(5);
sem_init(&room,0,4);
for(int i = 0;i < 5;i++){
sem_init(&(fork[i]),0,1);
}
}
void wantsToEat(int philosopher,
function<void()> pickLeftFork,
function<void()> pickRightFork,
function<void()> eat,
function<void()> putLeftFork,
function<void()> putRightFork) {
sem_wait(&room);
sem_wait(&(fork[philosopher]));
sem_wait(&(fork[(philosopher+1)%5]));
pickLeftFork();
pickRightFork();
eat();
putRightFork();
putLeftFork();
sem_post(&(fork[(philosopher+1)%5]));
sem_post(&(fork[philosopher]));
sem_post(&room);
}
vector<sem_t> fork;
sem_t room;
};
规定奇数号的哲学家先拿起他左边的筷子,然后再去拿他右边的筷子;而偶数号的哲学家则先拿起他右边的筷子,然后再去拿他左边的筷子。
#include
class DiningPhilosophers {
public:
DiningPhilosophers() {
fork = vector<sem_t>(5);
for(int i = 0;i < 5;i++){
sem_init(&(fork[i]),0,1);
}
}
void wantsToEat(int philosopher,
function<void()> pickLeftFork,
function<void()> pickRightFork,
function<void()> eat,
function<void()> putLeftFork,
function<void()> putRightFork) {
if(philosopher % 2 == 0){
sem_wait(&(fork[philosopher]));
sem_wait(&(fork[(philosopher+1)%5]));
pickLeftFork();
pickRightFork();
eat();
putRightFork();
putLeftFork();
sem_post(&(fork[(philosopher+1)%5]));
sem_post(&(fork[philosopher]));
}
else{
sem_wait(&(fork[(philosopher+1)%5]));
sem_wait(&(fork[philosopher]));
pickLeftFork();
pickRightFork();
eat();
putRightFork();
putLeftFork();
sem_post(&(fork[philosopher]));
sem_post(&(fork[(philosopher+1)%5]));
}
}
vector<sem_t> fork;
};
实现最复杂的限定就餐策略
规定奇数号的哲学家先拿起他左边的筷子,然后再去拿他右边的筷子;而偶数号的哲学家则先拿起他右边的筷子,然后再去拿他左边的筷子
package main
import (
"fmt"
"sync"
)
type DiningPhilosophers struct {
getkey []chan int
endsSgnal *sync.WaitGroup
}
func pickLeftFork(i int){
fmt.Printf("philosopher %d pickLeftFork\n",i)
}
func pickRightFork(i int){
fmt.Printf("philosopher %d pickRightFork\n",i)
}
func eat(i int){
fmt.Printf("philosopher %d eat\n",i)
}
func putLeftFork(i int){
fmt.Printf("philosopher %d putLeftFork\n",i)
}
func putRightFork(i int){
fmt.Printf("philosopher %d putRightFork\n",i)
}
func (s *DiningPhilosophers)wantsToEat(philosopher int, pickLeftFork func(int), pickRightFork func(int), eat func(int), putLeftFork func(int), putRightFork func(int)) {
if philosopher % 2 == 0 {
<- s.getkey[philosopher]
pickLeftFork(philosopher)
<- s.getkey[(philosopher+1)%5]
pickRightFork(philosopher)
eat(philosopher)
putRightFork(philosopher)
s.getkey[(philosopher+1)%5] <- 1
putLeftFork(philosopher)
s.getkey[philosopher] <- 1
} else {
<- s.getkey[(philosopher+1)%5]
pickRightFork(philosopher)
<- s.getkey[philosopher]
pickLeftFork(philosopher)
eat(philosopher)
putLeftFork(philosopher)
s.getkey[philosopher] <- 1
putRightFork(philosopher)
s.getkey[(philosopher+1)%5] <- 1
}
s.endsSgnal.Done()
}
func main() {
s := &DiningPhilosophers{
getkey: make([]chan int,5),
endsSgnal: &sync.WaitGroup{},
}
for i:= 0;i<len(s.getkey);i++ {
s.getkey[i] = make(chan int,1)
s.getkey[i] <- 1
}
n:= 100
for i :=0;i < n;i++{
s.endsSgnal.Add(5)
go s.wantsToEat(0,pickLeftFork,pickRightFork,eat,putLeftFork,putRightFork)
go s.wantsToEat(1,pickLeftFork,pickRightFork,eat,putLeftFork,putRightFork)
go s.wantsToEat(2,pickLeftFork,pickRightFork,eat,putLeftFork,putRightFork)
go s.wantsToEat(3,pickLeftFork,pickRightFork,eat,putLeftFork,putRightFork)
go s.wantsToEat(4,pickLeftFork,pickRightFork,eat,putLeftFork,putRightFork)
}
s.endsSgnal.Wait()
}
使用了互斥锁和条件变量来解决读者-写者问题。当有读者时,读者可以直接读取数据,但当有写者时,需要等待写者写入完成后才能读取数据。当有写者时,写者需要等待当前没有读者和写者时才能写入数据,写入完成后则通知所有读者有数据可读。
#include
#include
#include
#include
int reader_count = 0; // 当前读者数量
bool writing = false; // 是否有写者在写入
std::mutex mtx; // 互斥锁
std::condition_variable cv_reader, cv_writer; // 条件变量
void read() {
std::unique_lock<std::mutex> lk(mtx);
while (writing) { // 有写者在写入,则等待
cv_reader.wait(lk);
}
reader_count++; // 当前读者数量加 1
lk.unlock(); // 解锁
std::cout << "reader " << std::this_thread::get_id() << " is reading" << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(500)); // 模拟读取耗时
lk.lock(); // 上锁
reader_count--; // 当前读者数量减 1
if (reader_count == 0) { // 如果当前没有读者了,则通知写者
cv_writer.notify_one();
}
lk.unlock(); // 解锁
}
void write() {
std::unique_lock<std::mutex> lk(mtx);
while (writing || reader_count > 0) { // 有读者在读取或有写者在写入,则等待
cv_writer.wait(lk);
}
writing = true; // 标记有写者正在写入
lk.unlock(); // 解锁
std::cout << "writer " << std::this_thread::get_id() << " is writing" << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(1000)); // 模拟写入耗时
lk.lock(); // 上锁
writing = false; // 标记写入完成
cv_reader.notify_all(); // 通知所有读者有数据可读
cv_writer.notify_one(); // 通知下一个写者可以写入
lk.unlock(); // 解锁
}
int main() {
std::thread readers[5];
std::thread writers[2];
for (int i = 0; i < 5; ++i) {
readers[i] = std::thread(read);
}
for (int i = 0; i < 2; ++i) {
writers[i] = std::thread(write);
}
for (int i = 0; i < 5; ++i) {
readers[i].join();
}
for (int i = 0; i < 2; ++i) {
writers[i].join();
}
return 0;
}
同样使用了互斥锁和条件变量来解决读者-写者问题。当有读者时,读者可以直接读取数据,但当有写者时,需要等待写者写入完成后才能读取数据。当有写者时,写者需要等待当前没有读者和写者时才能写入数据,写入完成后则通知所有读者有数据可读。
package main
import (
"fmt"
"sync"
"time"
)
var reader_count = 0 // 当前读者数量
var writing = false // 是否有写者在写入
var mtx sync.Mutex // 互斥锁
var cv_reader = sync.NewCond(&mtx) // 读者条件变量
var cv_writer = sync.NewCond(&mtx) // 写者条件变量
func read() {
mtx.Lock()
for writing {
cv_reader.Wait()
}
reader_count++
fmt.Printf("reader %d is reading\n", reader_count)
time.Sleep(time.Millisecond * 500) // 模拟读取耗时
reader_count--
if reader_count == 0 {
cv_writer.Signal()
}
mtx.Unlock()
}
func write() {
mtx.Lock()
for writing || reader_count > 0 {
cv_writer.Wait()
}
writing = true
fmt.Printf("writer %d is writing\n", time.Now().Unix())
time.Sleep(time.Millisecond * 1000) // 模拟写入耗时
writing = false
cv_reader.Broadcast()
cv_writer.Signal()
mtx.Unlock()
}
func main() {
for i := 0; i < 5; i++ {
go read()
}
for i := 0; i < 2; i++ {
go write()
}
for {
time.Sleep(time.Second)
}
}