经典并发问题的深度分析与实现【c++与golang】【万字分析】

文章目录

  • 前言
  • 一、生产者-消费者问题
    • 1、c++版本
    • 2、golang版本
  • 二、哲学家就餐问题
    • c++代码实现
      • 1、同时拿起左右的叉子
      • 2、控制哲学家就餐数量
      • 3、限定就餐策略
    • golang
  • 三、读者-写者问题
    • c++
    • go


前言

前置知识点:
锁与信号量
经典的多线程并发问题,需要考虑线程之间的同步和互斥,常用的解决方法包括互斥锁、条件变量、信号量等。针对不同的问题,需要选择合适的解决方法,保证线程之间的正确同步。
经典的并发问题有以下这几个:
生产者-消费者问题:有一组生产者线程/进程和一组消费者线程/进程,它们共享一个有限容量的缓冲区。生产者负责将数据项放入缓冲区,消费者则从缓冲区中取出数据项进行处理
哲学家就餐问题:涉及到多个哲学家和多个餐叉,每个哲学家需要持有两个餐叉才能进餐。当多个哲学家同时想进餐时,可能会出现死锁。
读者-写者问题:涉及到多个线程对共享数据的读取和写入操作。读者线程可以并发地读取共享数据,写者线程需要独占地写入共享数据,读者和写者之间需要保持互斥。

本文分别
利用两种语言分别来解决这些并发问题。

一、生产者-消费者问题

生产者消费者问题是一个经典的并发问题,用于描述多线程或多进程间的同步和互斥问题。在生产者消费者问题中,有一组生产者线程/进程和一组消费者线程/进程,它们共享一个有限容量的缓冲区。生产者负责将数据项放入缓冲区,消费者则从缓冲区中取出数据项进行处理。问题的核心在于如何实现对共享缓冲区的同步访问,确保数据不会丢失或被重复处理。
生产者消费者问题的一些关键点:
同步:需要确保当缓冲区为空时,消费者不能从中取出数据;当缓冲区已满时,生产者不能向其中添加数据。这需要使用同步原语(如互斥锁、信号量或条件变量)来实现。
**互斥:**多个生产者和消费者线程/进程需要互斥地访问共享缓冲区,防止同时修改缓冲区导致的数据不一致问题。这通常使用互斥锁(Mutex)来实现。
缓冲区管理:需要实现一个适当的数据结构来存储缓冲区中的数据项,例如队列、栈或循环缓冲区。同时,需要考虑缓冲区的容量限制。

C++ 和 Golang 都提供了多种同步机制,可以用来实现生产者-消费者问题。C++ 中常用的同步设施包括互斥锁、条件变量、信号量等;Golang 中常用的同步设施包括 chan、sync.Mutex、sync.WaitGroup等。
在 C++ 中,需要使用 std::mutex 和 std::condition_variable 实现生产者-消费者问题。当生产者向队列中添加数据时,需要获取互斥锁;当消费者从队列中取出数据时,也需要获取互斥锁,并且需要使用条件变量来等待生产者添加数据的通知。
在 Golang 中,可以使用 chan 实现生产者-消费者问题。当生产者向 chan 中添加数据时,会自动阻塞,直到有消费者从 chan 中取出数据;当消费者从 chan 中取出数据时,会自动阻塞,直到有生产者向 chan 中添加数据

1、c++版本

使用互斥锁 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;
}

2、golang版本

在 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语言对后面三种方法进行复现。

c++代码实现

1、同时拿起左右的叉子

由于每个哲学家在拿起叉子之前都需要申请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;
};

2、控制哲学家就餐数量

允许四个哲学家都拿起同一边的筷子,这样会保证一位哲学家可以得到两边筷子完成进食,释放临界资源,保证别的哲学家可以进行相应的线程。
要新增一个信号量,控制拿起同一边筷子的哲学家数量

#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;
};


3、限定就餐策略

规定奇数号的哲学家先拿起他左边的筷子,然后再去拿他右边的筷子;而偶数号的哲学家则先拿起他右边的筷子,然后再去拿他左边的筷子。

#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;
};


golang

实现最复杂的限定就餐策略
规定奇数号的哲学家先拿起他左边的筷子,然后再去拿他右边的筷子;而偶数号的哲学家则先拿起他右边的筷子,然后再去拿他左边的筷子

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()
}

三、读者-写者问题

c++

使用了互斥锁和条件变量来解决读者-写者问题。当有读者时,读者可以直接读取数据,但当有写者时,需要等待写者写入完成后才能读取数据。当有写者时,写者需要等待当前没有读者和写者时才能写入数据,写入完成后则通知所有读者有数据可读。

#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;
}

go

同样使用了互斥锁和条件变量来解决读者-写者问题。当有读者时,读者可以直接读取数据,但当有写者时,需要等待写者写入完成后才能读取数据。当有写者时,写者需要等待当前没有读者和写者时才能写入数据,写入完成后则通知所有读者有数据可读。

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)
    }
}

你可能感兴趣的:(高性能网络框架,c++与golang,c++,golang,linux,系统架构)