【C/C++】多线程编程需要注意的问题|多进程

多线程编程需要注意的问题

多线程三个经典问题

一、生产者-消费者问题

二、读者-写者问题

三、哲学家进餐问题

多线程编程需要注意的问题

其他注意实现

fork()的子进程和父进程有什么不同,子进程可以从父进程那里能够继承什么,又不能继承什么

互斥锁

一篇文章轻松搞懂 Linux 多线程同步!

三、线程同步的常见问题?

四、同步工具集锦:全家福


多线程编程需要注意的问题

多线程三个经典问题

一、生产者-消费者问题

问题描述:

一组生产者进程和一组消费者进程共享一个初始为空、大小为 n 的缓冲区,缓冲区没满时消息才可放入,缓冲区不空时才可取出消息,否则必须等待。由于缓冲区是临界资源,它只允许一个生产者放入消息,或者一个消费者从中取出消息。

分析:

  1. 关系分析:生产者和消费者是互斥关系,同时也是同步关系(生产者生产之后,消费者才能消费)。

  2. 整理思路:这里比较简单,只有生产者和消费者两个进程,它们存在着互斥关系和同步关系,所以解决的是互斥和同步的PV操作的位置。

  3. 信号量设置:

  4. 作为互斥信号量mutex,用于控制互斥访问缓冲池,初值为1;

  5. 信号量full用于记录当前缓冲池中“满”缓冲区数,初值为 0;信号量empty用于记录当前缓冲池中“空”缓冲区数,初值为n。

二、读者-写者问题

问题描述:

当两个或以上的读线程同时访问共享数据时不会产生副作用,但若某个写线程和其他线程(读线程或写线程)同时访问共享数据时则可能导致数据不一致的错误。因此要求:

  • 允许多个读者可以同时对文件执行读操作;
  • 只允许一个写者往文件中写信息;
  • 任一写者在完成写操作之前不允许其他读者或写者工作;
  • 写者执行写操作前,应让已有的读者和写者全部退出。

分析:

  1. 关系分析:

    1. 写者和读者、写者都是是互斥,而读者和读者不存在互斥问题。

  2. 整理思路:

    1. 写者是比较简单的,它与任何线程互斥,用互斥信号量的 PV 操作即可解决。读者的问题比较复杂,它必须实现与写者的互斥,多个读者还可以同时读。所以,在这里用到了一个计数器,用它来判断当前是否有读者读文件。当有读者的时候写者是无法写文件的,此时读者会一直占用文件,当没有读者的时候写者才可以写文件。同时,不同的读者对计数器的访问也应该是互斥的。

  3. 信号量设置:

    1. 首先设置一个计数器count,用来记录当前的读者数量,初值为0;

    2. 设置互斥信号量mutex,用于保护更新 count 变量时的互斥;设置互斥信号量rw用于保证读者和写者的互斥访问。

三、哲学家进餐问题

问题描述:

一张圆桌上坐着 5 名哲学家,桌子上每两个哲学家之间摆了一根筷子,桌子的中间是一碗米饭,如图所示:


哲学家们倾注毕生精力用于思考和进餐,哲学家在思考时,并不影响他人。只有当哲学家饥饿的时候,才试图拿起左、右两根筷子(一根一根拿起)。如果筷子已在他人手上,则需等待。饥饿的哲学家只有同时拿到了两根筷子才可以开始进餐,当进餐完毕后,放下筷子继续思考。

分析:

  1. 关系分析:5名哲学家与左右邻居对其中间筷子的访问是互斥关系。

  2. 整理思路:显然这里有 5 个线程,那么要如何让一个哲学家拿到左右两个筷子而不造成死锁或饥饿现象?解决方法有两个,一个是让他们同时拿两个筷子;二是对每个哲学家的动作制定规则,避免饥饿或死锁现象的发生。

  3. 信号量设置:定义互斥信号量数组chopstick[5] = {1,1,1,1,1}用于对 5 根筷子的互斥访问。

示例代码:

semaphore chopstick[5] = {1,1,1,1,1}  // 信号量数组
Pi()                                  // i号哲学家的线程
{
    do
    {
        P(chopstick[i]);              // 取左边筷子
        P(chopstick[(i+1)%5]);        // 取右边筷子
        eat;                          // 进餐
        V(chopstick[i]);              // 放回左边筷子
        V(chopstick[(i+1)%5]);        // 放回右边筷子
        think;                        // 思考
    }while(1);
}

上面的伪代码存在一个问题:当五个哲学家都想要进餐,分别拿起他们左边筷子的时候(都恰好执行完P(chopstick[i])),筷子已经被拿光了,等到他们再想拿右边的筷子的时候,就全被阻塞了,这就出现了死锁。

为了防止死锁的发生,可以对哲学家线程施加一些限制条件,比如:

  • 至多允许四个哲学家同时进餐;
  • 仅当一个哲学家左右两边的筷子都可用时才允许他抓起筷子;
  • 对哲学家顺序编号,要求奇数号哲学家先抓左边的筷子,然后再抓他右边的筷子,而偶数号哲学家刚好相反。

这里,我们采用第二种方法来改进上面的算法,即当一个哲学家左右两边的筷子都可用时,才允许他抓起筷子。

多线程三个经典问题完整内容见:

https://blog.csdn.net/lisonglisonglisong/article/details/45390227

多线程编程需要注意的问题

在编写多线程程序时,需要注意以下几个关键问题以确保程序的正确性和效率:

1. **数据竞争(Race Conditions)**:
   - 当多个线程尝试同时访问和修改共享数据时,可能会导致不一致的状态。使用同步机制如互斥锁(mutex)、读写锁、信号量等来保护共享资源。

2. **死锁(Deadlock)**:
   - 死锁发生在两个或更多的线程相互等待对方释放资源的情况下。设计时应考虑如何避免或检测死锁,比如遵循严格的加锁顺序或者使用超时机制。

3. **活锁(Livelock)**:
   - 类似于死锁,但不同之处在于涉及的线程并没有被阻塞,而是不断地改变状态试图完成操作却始终无法进展。合理的设计和适当的重试策略可以帮助缓解这个问题。

4. **优先级反转(Priority Inversion)**:
   - 高优先级线程可能因为需要等待低优先级线程持有的资源而被延迟执行。可以采用优先级继承协议等方法来解决这个问题。

5. **线程饥饿(Starvation)**:
   - 某些线程可能永远得不到足够的CPU时间或其他资源来完成它们的任务。公平的调度算法和合理的资源分配策略有助于防止线程饥饿。

6. **上下文切换开销**:
   - 频繁的线程切换会导致额外的CPU开销。尽量减少不必要的线程创建,并考虑使用线程池复用现有线程。

7. **原子性与不可分割的操作**:
   - 对于一些简单的变量更新操作,如果能保证其原子性,则无需额外加锁。了解并利用语言提供的原子操作特性。

8. **线程安全的库函数**:
   - 确保使用的第三方库函数是线程安全的,特别是在处理全局变量或静态数据时。

9. **异常处理**:
   - 在多线程环境中,必须小心处理异常,确保即使发生错误也能正确地清理资源和解锁,避免资源泄漏或死锁。

10. **调试困难**:
    - 多线程程序难以调试,因为问题往往取决于特定的时间序列才会显现。使用专门的工具和技术来进行并发程序的调试非常重要。

通过关注上述方面,可以有效地减少多线程编程中常见的问题,提高程序的稳定性和性能。不过,每个多线程应用都有其独特性,因此还需要根据具体的应用场景灵活调整应对策略。

其他注意实现

可重入函数与不可重入函数

保证函数的可重入性的方法:

1)在写函数时候尽量使用局部变量(例如寄存器、堆栈中的变量);

2)对于要使用的全局变量要加以保护(如采取关中断、信号量等互斥方法),这样构成的函数就一定是一个可重入的函数。

满足下列条件的函数多数是不可重入(不安全)的:

1)函数体内使用了静态的数据结构;

2)函数体内调用了malloc() 或者 free() 函数;

3)函数体内调用了标准 I/O 函数。

如何将一个不可重入的函数改写成可重入函数呢?

1)不要使用全局变量。因为别的代码很可能改变这些变量值。

2)在和硬件发生交互的时候,切记执行类似 disinterrupt() 之类的操作,就是关闭硬件中断。完成交互记得打开中断,在有些系列上,这叫做“进入/ 退出核心”。

3)不能调用其它任何不可重入的函数。

4)谨慎使用堆栈。

浅谈可重入函数与不可重入函数【转】_shareinfo2018-CSDN博客_可重入函数

fork()的子进程和父进程有什么不同,子进程可以从父进程那里能够继承什么,又不能继承什么

《UNIX系统编程》第24章进程的创建

fork之后父子进程同时从fork点开始执行、

父子进程共享代码抄段,数据段都是完全拷贝,相互之间的更改zhidao不会影响。

(fork之后exec之前两个进程用的是相同的物理空间(内存区),子进程的代码段、数据段、堆栈都是指向父进程的物理空间,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。)

但是值得注意的是socket是共享的

互斥锁

std::mutex mu;
// 使用锁保护
void shared_print(string msg, int id) {
    mu.lock(); // 上锁
    cout << msg << id << endl;
    mu.unlock(); // 解锁
}

有一个隐藏着的问题,如果mu.lock()mu.unlock()之间的语句发生了异常,会发生什么?unlock()语句没有机会执行!导致导致mu一直处于锁着的状态,其他使用shared_print()函数的线程就会阻塞。

void shared_print(string msg, int id) {
    //构造的时候帮忙上锁,析构的时候释放锁
    std::lock_guard<std::mutex> guard(mu);
    //mu.lock(); // 上锁
    cout << msg << id << endl;
    //mu.unlock(); // 解锁
}

RAII技术,即获取资源即初始化(Resource Acquisition Is Initialization)技术,这是c++中管理资源的常用方式。 //构造的时候帮忙上锁,(函数退出)析构的时候释放锁

join的必要:
举个例子,现在有 A, B, C 三件事情,只有做完 A 和 B 才能去做 C,而 A 和 B 可以并行完成。

int main(){
    thread t = new thread(A);
    B();  // 此时 A 与 B 并行进行
    t.join();  // 确保 A 完成
    C();
}
 

一篇文章轻松搞懂 Linux 多线程同步!

别再被多线程搞晕了!一篇文章轻松搞懂 Linux 多线程同步! - https://www.cnblogs.com/xiaokang-coding/p/18530955

别再被多线程搞晕了!一篇文章轻松搞懂 Linux 多线程同步! 
合集 - Linux编程(4)
1.
Linux 系统编程从入门到进阶 学习指南
02-21
2.
还在为慢速数据传输苦恼?Linux 零拷贝技术来帮你!
11-06
3.
Linux 网络编程从入门到进阶 学习指南
02-21
4.
别再被多线程搞晕了!一篇文章轻松搞懂 Linux 多线程同步!
11-06


前言
大家有没有遇到过,代码跑着跑着,线程突然抢资源抢疯了?其实,这都是“多线程同步”在作怪。多线程同步是个老生常谈的话题,可每次真正要处理时还是让人头疼。这篇文章,带你从头到尾掌握 Linux 的多线程同步,把概念讲成大白话,让你看了不再迷糊,还能拿出来装一装逼!不管是“锁”、“信号量”,还是“条件变量”,我们都一网打尽,赶紧点赞收藏,一文搞懂!

一、什么是线程同步?——“排队来操作,按规矩走”
线程同步的核心,就是控制多个线程的访问顺序,让它们在访问共享资源时有序、稳定。你可以把它想象成大家排队进电影院,每个线程都是观众,排好队才能有序进场。如果大家一拥而上,不仅容易出事,还谁也看不成电影。

简单来说,线程同步就是一个“排队工具”,让线程们按顺序、按规则去操作资源,避免混乱、出错。

二、 为什么需要多线程同步?——不想大家打架就得“排好队”
简单来说,多线程同步就是为了控制多个线程之间的访问顺序,保证数据的一致性,防止线程“打架”。
比如你有多个线程在“抢”同一个变量,它们随时会互相影响,最终导致程序结果错得一塌糊涂,甚至程序崩溃。这时候就像几个朋友围在一桌,大家都想夹最后一块肉,结果谁也夹不到,甚至还打起来了!在计算机中,这个场景会导致资源冲突或者死锁。

三、线程同步的常见问题?


为什么多线程容易“打架”?因为线程是独立的执行单元,它们的执行顺序不确定。几个常见的问题:

竞争条件: 多个线程同时抢着用同一个资源,结果数据出错、搞乱了。
死锁: 线程互相等待彼此的资源,谁也不让谁,最后都卡在那儿不动了。
活锁: 线程为了避免冲突,不停地让来让去,结果谁也没法继续工作,任务一直停滞着。
所以,为了保证程序的正确性、数据一致性,Linux 提供了各种同步工具。可以理解为“排队工具”,让线程一个一个地来,用完再走,大家和平共处。

四、同步工具集锦:全家福


在 Linux 中,常用的同步工具主要有七类:

互斥锁(Mutex):一人一次,谁拿到谁操作,别抢!
条件变量(Condition Variable):有人负责通知,其他人等信号,一喊开工就一哄而上。
信号量(Semaphore):有限名额,控制同时访问资源的线程数量,适合多线程限流。
读写锁(Reader-Writer Lock):有读有写,读可以多人一起看,写得自己来。
自旋锁(Spin Lock):不停地检查锁,忙等。适合短时间锁定场景。
屏障(Barrier):所有线程到这儿集合,等到齐了一起开始下一步。
原子操作(Atomic Operations):小数据更新直接操作,不加锁,速度快,适合简单计数和标志位更新。
这些工具看起来好像有点复杂,但咱们一个一个来,保你一学就懂!

五、互斥锁(Mutex):谁拿到,谁先操作
互斥锁是多线程同步的基础。顾名思义,互斥锁(mutex)是一种独占机制,即一次只允许一个线程访问共享资源。要理解互斥锁的作用,可以想象一下“厕所上锁”的场景:假设家里有一个卫生间,进门时必须锁上,完事出来再开锁,以防别人误闯。

常见接口:
在 POSIX 线程库中,互斥锁通过 pthread_mutex_t 类型实现,提供了以下常见接口:

pthread_mutex_init(&mutex, nullptr):初始化互斥锁
pthread_mutex_lock(&mutex):加锁,若已被其他线程锁定,则阻塞等待
pthread_mutex_trylock(&mutex):尝试加锁,若锁已被占用,则立即返回错误而不阻塞
pthread_mutex_unlock(&mutex):解锁,释放互斥锁,允许其他线程加锁
pthread_mutex_destroy(&mutex):销毁互斥锁,释放相关资源
简单代码示例:
这段代码展示了如何使用互斥锁(mutex)来确保多个线程对共享变量 counter 的安全访问。

#include <pthread.h>
#include <iostream>

pthread_mutex_t mutex;  // 声明互斥锁

int counter = 0;

void* increment(void* arg) {
    pthread_mutex_lock(&mutex);  // 加锁
    counter++;
    pthread_mutex_unlock(&mutex);  // 解锁
    return nullptr;
}

int main() {
    pthread_t t1, t2;
    pthread_mutex_init(&mutex, nullptr);  // 初始化互斥锁

    pthread_create(&t1, nullptr, increment, nullptr);
    pthread_create(&t2, nullptr, increment, nullptr);

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);

    std::cout << "Final counter value: " << counter << std::endl;

    pthread_mutex_destroy(&mutex);  // 销毁互斥锁
    return 0;
}
代码解释:
increment 函数:每个线程调用此函数,对 counter 变量进行加 1 操作。为了防止多个线程同时修改 counter,使用了互斥锁:

pthread_mutex_lock(&mutex):加锁,确保只有一个线程可以修改 counter
counter++:增加 counter 的值
pthread_mutex_unlock(&mutex):解锁,允许其他线程访问
主函数 main:

pthread_mutex_init(&mutex, nullptr):初始化互斥锁
创建两个线程 t1 和 t2,它们都执行 increment 函数
pthread_join 等待 t1 和 t2 结束
打印 counter 的最终值
pthread_mutex_destroy(&mutex):销毁互斥锁,释放资源
通过互斥锁的加锁和解锁,代码确保了两个线程不会同时修改 counter,从而保证数据安全。

优缺点
优点:

简单高效:互斥锁的加锁和解锁操作非常简单,运行效率高,适合需要短时间锁定资源的场合。
数据安全:互斥锁可以保证同一时刻只有一个线程访问共享资源,避免数据冲突,保证数据的一致性。
防止资源争抢:互斥锁确保资源不被多个线程同时访问,从而避免竞争带来的数据错误或程序崩溃。
缺点:

阻塞其他线程:一旦资源被锁定,其他线程只能等待,这可能导致系统效率降低,尤其是锁定时间较长时。
存在死锁风险:如果两个线程互相等待对方释放锁,就可能导致死锁。因此设计锁的使用顺序时需要格外小心。
不适合长时间锁定:互斥锁适合短期操作,锁定时间过长会影响程序的并发性,因为其他线程在等待锁时会被阻塞,降低系统性能。
应用场景:
互斥锁适合那些需要独占资源访问的情况,比如多个线程同时需要修改同一个变量、更新配置文件、写文件等操作。互斥锁确保这些操作不会被打断,资源在操作时“锁”住,保证访问的有序和安全性。

六、条件变量(Condition Variable): 有信号才行动
条件变量有点像“等通知”。一个线程负责等信号,另一个线程发出信号。比如生产者和消费者,消费者要等到有货了才能继续;生产者一旦备好了货,就发个信号给消费者,“来吧,过来取,货到齐了!”

常见接口:
在 POSIX 线程库中,条件变量通过 pthread_cond_t 类型实现,配合互斥锁使用,常见接口包括以下几种:

pthread_cond_init(&cond, nullptr):初始化条件变量。
pthread_cond_wait(&cond, &mutex):等待条件变量。需要持有互斥锁,当条件不满足时自动释放锁并进入等待状态,直到接收到信号或被唤醒。
pthread_cond_signal(&cond):发送信号,唤醒一个正在等待的线程。适用于通知单个等待线程的情况。
pthread_cond_broadcast(&cond):广播信号,唤醒所有正在等待的线程。
pthread_cond_destroy(&cond):销毁条件变量,释放相关资源。
简单代码示例:
这段代码展示了如何使用 条件变量(Condition Variable) 和 互斥锁(Mutex) 来协调两个线程之间的同步。代码中有两个线程,一个线程在等待信号,另一个线程发送信号。

#include <pthread.h>
#include <iostream>

pthread_mutex_t mutex; // 声明互斥锁
pthread_cond_t cond;   // 声明条件变量
bool ready = false;

void* waitForSignal(void* arg) {
    pthread_mutex_lock(&mutex);
    while (!ready) {
        pthread_cond_wait(&cond, &mutex);  // 等待条件变量的信号
    }
    std::cout << "Signal received!" << std::endl;
    pthread_mutex_unlock(&mutex);
    return nullptr;
}

void* sendSignal(void* arg) {
    pthread_mutex_lock(&mutex);
    ready = true;
    pthread_cond_signal(&cond);  // 发送信号
    pthread_mutex_unlock(&mutex);
    return nullptr;
}

int main() {
    pthread_t t1, t2;
    pthread_mutex_init(&mutex, nullptr); // 初始化互斥锁
    pthread_cond_init(&cond, nullptr);   // 初始化条件变量

    pthread_create(&t1, nullptr, waitForSignal, nullptr);
    pthread_create(&t2, nullptr, sendSignal, nullptr);

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);

    pthread_mutex_destroy(&mutex);   // 销毁条件变量
    pthread_cond_destroy(&cond);     // 销毁条件变量
    return 0;
}
代码解释:
waitForSignal 函数:等待信号的线程,加锁后检查ready的状态。如果ready为false,线程会调用pthread_cond_wait进入等待状态,直到收到sendSignal线程的信号才继续执行。
sendSignal 函数:发送信号的线程,先加锁,将ready设为true,然后调用pthread_cond_signal通知等待线程可以继续。最后解锁,让waitForSignal线程继续执行。
主函数 main: 初始化互斥锁和条件变量,创建两个线程t1和t2,分别执行等待和发送信号的任务,最后等待线程完成并销毁互斥锁和条件变量。
优缺点:
优点:

减少忙等:使用条件变量可以让线程进入等待状态,不消耗 CPU 资源,等待到达信号再继续执行,提升效率。
多线程协作更有序:条件变量使线程之间的配合更有序,避免资源的无效争抢。
支持多线程唤醒:条件变量的广播功能可以一次唤醒多个线程,非常适合需要同步的多线程场景。
缺点:

需要互斥锁配合:条件变量不能单独使用,必须与互斥锁一起使用,增加了编写的复杂度。
可能出现虚假唤醒:pthread_cond_wait 可能会出现“虚假唤醒”情况,因此需要在循环中反复检查条件是否满足。
编程复杂度增加:对于新手来说,条件变量与互斥锁的搭配使用会增加多线程编程的难度。
应用场景:
条件变量适用于生产者-消费者模型等场景,非常适合一个线程需要等待另一个线程完成某些操作的情况,比如等待任务完成、资源释放、数据处理等。通过条件变量,一个线程可以在等待条件达成时自动暂停,等收到信号后再继续执行。

七、信号量(Semaphore):谁来谁得,有限名额
信号量就像门口的限流器。允许一定数量的线程同时进入“临界区”(共享资源区),超过这个数量的线程就得在门口等着。比如限量版奶茶店,一次只能进五个人,想喝就得排队!

常见接口:
在 POSIX 线程库中,信号量通过 sem_t 类型实现,接口主要包括:

sem_init(&semaphore, 0, count):初始化信号量,count 是信号量初始值,表示同时允许进入的线程数量。
sem_wait(&semaphore):请求资源。当信号量大于零时,减一并进入临界区;若信号量为零,则线程阻塞,直到其他线程释放资源。
sem_post(&semaphore):释放资源,增加信号量值,允许其他等待的线程继续。
sem_destroy(&semaphore):销毁信号量,释放资源。
简单代码示例:
下面的代码展示了如何使用信号量来控制多个线程对资源的访问权限。在这个例子中,信号量初始值为 1,确保同一时间只有一个线程能进入临界区。

#include <semaphore.h>
#include <pthread.h>
#include <iostream>

sem_t semaphore;

void* accessResource(void* arg) {
    sem_wait(&semaphore);  // 请求资源
    std::cout << "Thread accessing resource!" << std::endl;
    sem_post(&semaphore);  // 释放资源
    return nullptr;
}

int main() {
    pthread_t t1, t2;
    sem_init(&semaphore, 0, 1);  // 初始化信号量,允许1个线程访问资源

    pthread_create(&t1, nullptr, accessResource, nullptr);
    pthread_create(&t2, nullptr, accessResource, nullptr);

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);

    sem_destroy(&semaphore);  // 销毁信号量
    return 0;
}
代码解释:
sem_wait(&semaphore);:请求访问资源,信号量减一。如果信号量为零,线程将等待。
sem_post(&semaphore);:释放资源,信号量加一,让其他等待的线程可以进入。
主函数中,两个线程 t1 和 t2 会分别调用 accessResource。信号量初始值设为 1,保证同一时刻只有一个线程访问资源,避免冲突。

优缺点
优点:

控制并发量:信号量允许多个线程同时进入,特别适合一些允许并行读的场景,比如文件读写或数据库连接池。
灵活性强:信号量不仅支持单线程进入,还支持多线程进入。
缺点:

不易编程和调试:由于信号量的计数器机制,容易导致逻辑混乱,编程复杂且调试较难。
不能识别优先级:信号量没有内置的优先级队列,某些等待时间长的线程可能会“饿死”。
应用场景:
限流:例如数据库连接池中限制同时连接数,通过信号量控制最大连接数。
读写分离:读操作允许多个线程同时进行,而写操作需要独占访问。
共享资源管理:如资源池、任务队列等,有固定容量的资源池中允许多个线程访问,但超过容量则需等待。
信号量在限制并发时非常实用,能够灵活控制线程数量,特别适合一些读写分离或限流场景,是多线程同步中的“好帮手”。

八、读写锁(Reader-Writer Lock):读可以一起,写得单独
读写锁的作用顾名思义,就是让“读”操作更轻松。在多线程场景中,多个线程可以同时读取资源(共享查看),但写操作必须独占,确保不会在读取时被其他线程修改内容。这就像图书馆的书,大家可以一起看,但如果有人要修改书的内容,就得把书借走,防止其他人读到一半内容突然变了。

常见接口 :
pthread_rwlock_init(&rwlock, nullptr):初始化读写锁。在使用读写锁之前必须初始化,可以选择设置锁的属性(用 nullptr 表示默认属性)。
pthread_rwlock_rdlock(&rwlock):加读锁。多个线程可以同时持有读锁,但如果有线程持有写锁,调用线程会被阻塞,直到写锁释放。
pthread_rwlock_wrlock(&rwlock):加写锁。加写锁时,线程需独占读写锁。持有写锁期间,所有其他的读锁或写锁请求都会被阻塞,直到写锁被释放。
pthread_rwlock_unlock(&rwlock):解锁。无论是读锁还是写锁,都可以使用该接口解锁。若当前持有读锁,则释放一个读锁;若持有写锁,则释放写锁,允许其他线程加锁。
pthread_rwlock_destroy(&rwlock):销毁读写锁。在不再需要使用读写锁时销毁它,释放相关的资源。
简单代码示例:
这段代码展示了读写锁(rwlock)的基本用法,目的是让多个线程同时访问共享变量 counter,并确保读取和写入操作的安全性。

#include <pthread.h>
#include <iostream>

pthread_rwlock_t rwlock;  // 声明读写锁

int counter = 0;

void* readCounter(void* arg) {
    pthread_rwlock_rdlock(&rwlock);  // 加读锁
    std::cout << "Counter: " << counter << std::endl;
    pthread_rwlock_unlock(&rwlock);  // 解锁
    return nullptr;
}

void* writeCounter(void* arg) {
    pthread_rwlock_wrlock(&rwlock);  // 加写锁
    counter++;
    pthread_rwlock_unlock(&rwlock);  // 解锁
    return nullptr;
}

int main() {
    pthread_t t1, t2, t3;
    pthread_rwlock_init(&rwlock, nullptr);  // 初始化读写锁

    pthread_create(&t1, nullptr, readCounter, nullptr);
    pthread_create(&t2, nullptr, writeCounter, nullptr);
    pthread_create(&t3, nullptr, readCounter, nullptr);

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);

    pthread_rwlock_destroy(&rwlock);  // 销毁读写锁
    return 0;
}
代码解释:
readCounter 函数:获取读锁 pthread_rwlock_rdlock,读取 counter 的值并打印,然后释放读锁。多个线程可以同时获取读锁,允许并发读取。
writeCounter 函数:获取写锁 pthread_rwlock_wrlock,增加 counter 的值,然后释放写锁。写锁是独占的,同一时间只有一个线程可以写入 counter。
main 函数:创建了三个线程 t1、t2 和 t3,两个线程进行读取操作(readCounter),一个线程进行写入操作(writeCounter)。读写锁 rwlock 确保了读取和写入时的线程安全。
优缺点
优点:

高效的读操作:多个线程可以同时读取资源,不会互相阻塞,避免了因互斥锁导致的效率低下。
写操作安全:写操作独占锁,确保数据不会因为读写交叉而出错。
缺点:

可能导致“写饥饿”:如果一直有线程在读取,写线程可能一直无法获取锁,导致写操作被延迟。
不适合频繁写的场景:在写操作多的情况下,读写锁的优势不明显,反而因为锁的开销影响性能。
应用场景:
日志和配置读取: 日志内容可以被多个线程同时读取,但在写日志或更新配置时需要独占。
缓存系统:例如计数器等共享资源,多线程环境中读多写少的缓存操作特别适合读写锁。
统计数据系统: 数据读取频繁而写入较少的统计系统中,读写锁能提供更高的读取效率。
九、自旋锁(Spinlock):等不到就原地打转
自旋锁是种“忙等”锁,不获取到锁,它就原地打转,一直“自旋”等待。自旋锁适合短时间加锁的场景,时间一长就耗CPU了,所以常用于等待时间极短的资源。因此,自旋锁经常用于等待时间非常短的资源访问场景。

常见接口 :
pthread_spin_init(pthread_spinlock_t* lock, int pshared)
初始化自旋锁,参数pshared指定自旋锁是否在进程间共享(0表示仅在进程内使用)。如果成功返回0,否则返回错误代码。
pthread_spin_lock(pthread_spinlock_t* lock)
加锁操作,尝试获取自旋锁。如果锁已经被占用,当前线程会一直循环等待,直到获取锁。
pthread_spin_unlock(pthread_spinlock_t* lock)
解锁操作,释放自旋锁,让其他线程可以继续尝试获取锁。
pthread_spin_destroy(pthread_spinlock_t* lock)
销毁自旋锁,释放资源。调用此函数后不能再使用该锁,除非重新初始化。
简单代码示例:
下面的代码展示了如何使用自旋锁在两个线程间进行资源访问控制,确保 counter 的安全递增。

#include <pthread.h>
#include <iostream>

pthread_spinlock_t spinlock;  // 声明自旋锁
int counter = 0;

void* increment(void* arg) {
    pthread_spin_lock(&spinlock);  // 加锁
    counter++;
    pthread_spin_unlock(&spinlock);  // 解锁
    return nullptr;
}

int main() {
    pthread_t t1, t2;

    pthread_spin_init(&spinlock, 0);  // 初始化自旋锁

    // 创建两个线程,分别执行 increment 函数
    pthread_create(&t1, nullptr, increment, nullptr);
    pthread_create(&t2, nullptr, increment, nullptr);

    // 等待两个线程执行完毕
    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);

    std::cout << "Final counter value: " << counter << std::endl;

    pthread_spin_destroy(&spinlock);  // 销毁自旋锁
    return 0;
}
代码解释:
increment 函数:
每个线程调用此函数,对counter进行加1操作。为了确保线程安全,使用了自旋锁spinlock:

pthread_spin_lock(&spinlock):加锁,使当前线程独占访问counter。
counter++:增加counter的值。
pthread_spin_unlock(&spinlock):解锁,让其他线程可以访问counter。
主函数 main:

pthread_create(&t1, nullptr, increment, nullptr) 和 pthread_create(&t2, nullptr, increment, nullptr):创建两个线程t1和t2,分别执行increment函数。
pthread_join(t1, nullptr) 和 pthread_join(t2, nullptr):等待t1和t2执行完毕。
通过自旋锁,这段代码确保了两个线程不会同时修改counter,保证了数据安全。

优缺点
优点:

减少上下文切换:自旋锁不会让线程进入“阻塞等待”,而是让线程“忙等”来获取锁。这样避免了线程进入“睡眠-唤醒”的过程(即“上下文切换”),使得等待过程更快。
适合短时间锁定:自旋锁适合那些等待时间极短的情况,因为在这种情况下,等待时间和“忙等”的成本低于切换上下文的开销。
缺点:

在高竞争环境下性能下降:如果多个线程同时竞争同一个锁,自旋锁的“忙等”会导致大量线程占用 CPU,最终让 CPU 资源被浪费,导致性能下降。
不适合长时间锁定:如果持有锁的时间较长,线程会在等待期间不断占用 CPU,造成资源浪费。因此,自旋锁只适合持锁时间非常短的场景。
应用场景:
适合短时、高频锁的情况:在多核 CPU 上,自旋锁非常适合那些“锁定时间极短但加锁频繁”的情况,比如快速更新某个标志位、计数器等。这种操作速度快、锁的持有时间短,因此用自旋锁可以减少阻塞带来的上下文切换开销。

十、屏障(Barrier):到齐了就开工
屏障的作用是让一组线程都到达某个集合点,然后再一起继续。可以把它看作一个“集合点”,每个线程到这儿后必须等一等,直到所有线程都到齐,然后才能一起“放行”。这在需要同步的多线程任务中特别有用,比如并行的数据处理:每一阶段的数据处理需要多个线程完成,各自到达指定点后,才能一起进入下一个阶段。

常见接口:
在 POSIX 线程库中,屏障通过 pthread_barrier_t 类型实现,常用接口包括以下几个:

pthread_barrier_destroy(&barrier):销毁屏障,释放资源,通常在程序结束时调用。
pthread_barrier_init(pthread_barrier_t* barrier, const pthread_barrierattr_t* attr, unsigned count) :初始化屏障,count参数指定屏障需要等待的线程数量。到达count个线程后,屏障会放行所有等待的线程。
pthread_barrier_wait(pthread_barrier_t* barrier) :线程调用此函数后进入等待状态,直到所有线程都调用了这个函数,屏障才会释放线程进入下一步操作。
pthread_barrier_destroy(pthread_barrier_t* barrier) :销毁屏障,释放相关资源。
简单代码示例:屏障同步
下面的代码展示了如何使用屏障让三个线程同步等待,等到三个线程都到达屏障点后再继续执行。这样可以确保每个线程都在同一个步骤上同步。

#include <pthread.h>
#include <iostream>

pthread_barrier_t barrier;  // 声明屏障

void* waitAtBarrier(void* arg) {
    std::cout << "Thread waiting at barrier..." << std::endl;
    pthread_barrier_wait(&barrier);  // 等待屏障
    std::cout << "Thread passed the barrier!" << std::endl;
    return nullptr;
}

int main() {
    pthread_t t1, t2, t3;

    // 初始化屏障,3个线程需要同步
    pthread_barrier_init(&barrier, nullptr, 3);

    // 创建线程
    pthread_create(&t1, nullptr, waitAtBarrier, nullptr);
    pthread_create(&t2, nullptr, waitAtBarrier, nullptr);
    pthread_create(&t3, nullptr, waitAtBarrier, nullptr);

    // 等待线程结束
    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);

    pthread_barrier_destroy(&barrier);  // 销毁屏障
    return 0;
}
代码解释:
waitAtBarrier 函数:每个线程在此函数中执行,先打印“Thread waiting at barrier...”表示到达屏障,然后调用 pthread_barrier_wait(&barrier) 在屏障处等待,直到所有线程都到达,之后才继续执行并打印“Thread passed the barrier!”。

主函数 main:

pthread_barrier_init(&barrier, nullptr, 3);:初始化屏障,要求 3 个线程同步到达。
创建了 3 个线程(t1, t2, t3),它们都调用 waitAtBarrier 函数。
pthread_join 等待所有线程完成。
pthread_barrier_destroy(&barrier);:销毁屏障,释放资源。
这段代码的效果是:3 个线程都会在屏障处等待,直到全部线程到达后再一起通过,确保同步执行。

优缺点
优点:

简化阶段性同步:屏障特别适合多线程任务中的分阶段同步,比如大规模数据分批处理,每批数据处理完,所有线程集齐后再进入下一阶段。
简单易用:在需要多个线程同步的场景中,屏障提供了一个简单的方案,避免了手动计数和锁的复杂性。
缺点:

不灵活:屏障初始化时需要指定同步的线程数,在运行中无法动态更改,这在一些线程数变化的场景中可能不够灵活。
资源浪费:屏障需要等待所有线程到齐才能继续,若某些线程执行速度慢,会导致其他线程在等待时浪费 CPU 资源。
容易形成死锁:如果有线程没有到达屏障点,其他线程会一直等待,可能导致整个系统的线程死锁。
应用场景:
屏障适用于需要同步阶段的场合,尤其是以下几种:

分步数据处理:在数据处理中,有些步骤需要所有线程同步完成后再进入下一步。
阶段性任务同步:对于一些分阶段的任务,每一步都需要多个线程协同完成,比如并行计算中的同步步骤。
多线程计算汇合:比如科学计算、数据聚合等任务,每个线程完成部分任务后需要在屏障点集合汇总。
十一、原子操作(Atomic Operations):小块更新,快准狠
原子操作是一种“小而快”的多线程操作。它直接对数据进行“独占式”的更新,操作不可分割,不需要加锁,因为它的操作是原子的:要么全做,要么全不做。适合用于快速更新小数据,比如计数、标志位等场景。在多线程环境中使用原子操作,可以避免加锁带来的性能开销,因此更新简单共享资源时,非常高效。

常见接口:
在C++的标准库中,原子操作接口非常简单,常用的有以下几种:

std::atomic<T>
声明一个原子类型的变量T,常用于简单数据类型,如int、bool等。std::atomic<int> counter(0);表示一个整型原子变量counter,初始值为0。
fetch_add() 和 fetch_sub()
分别用于原子加和原子减操作,例如counter.fetch_add(1);会安全地加1,同时返回旧值。
load() 和 store()
load()用于原子地读取变量值,store()用于原子地存储值,确保数据的一致性。
简单代码示例:原子操作实现计数器
下面的代码展示了如何使用 std::atomic 来安全地对共享数据 counter 进行递增操作。此处无需加锁,原子操作自动确保了线程安全。

#include <atomic>
#include <pthread.h>
#include <iostream>

std::atomic<int> counter(0);  // 使用原子类型

void* increment(void* arg) {
    for (int i = 0; i < 100000; ++i) {
        counter++;  // 原子操作,自动保证线程安全
    }
    return nullptr;
}

int main() {
    pthread_t t1, t2;

    // 创建两个线程
    pthread_create(&t1, nullptr, increment, nullptr);
    pthread_create(&t2, nullptr, increment, nullptr);

    // 等待两个线程执行完毕
    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);

    std::cout << "Final counter value: " << counter << std::endl;
    return 0;
}
代码解释:
std::atomic<int> counter(0);:使用原子类型 std::atomic 声明计数器 counter。所有对 counter 的操作都是线程安全的。
counter++:原子递增操作,无需加锁,在多线程环境下也能保证数据的一致性。
通过原子操作,我们避免了加锁带来的性能开销,代码简洁、高效,特别适合对小数据的频繁更新。

优缺点
优点:

无需加锁:原子操作是天然的线程安全操作,不需要额外的锁机制。
性能高:原子操作减少了锁开销,性能更高,特别适合小范围的更新操作。
代码简单:使用 std::atomic 可以直接更新共享数据,代码更简洁。
缺点:

只适合简单数据:原子操作适用于小数据的单个操作,无法用于复杂的数据结构或多步操作。
不支持复杂同步:原子操作仅适合简单的同步需求,比如计数、标志位等,无法处理复杂的并发控制。
可能影响可读性:如果不熟悉原子操作的语义,代码的可读性可能较低。
应用场景:
原子操作非常适合以下几种场合:

计数器:在多线程环境中,对计数器的增减操作非常高效,比如线程池中的任务计数。
标志位更新:更新多线程任务中的状态标志,比如任务是否完成、资源是否可用等。
快速计数统计:在需要频繁更新的场合,原子操作可以避免锁带来的性能开销,提高统计速度。
总结:
今天我们一起探讨了 Linux 中的多线程同步方式,从互斥锁到条件变量,再到信号量、读写锁以及自旋锁、还有屏障和原子操作,逐一解锁了每种同步方式的应用场景和优缺点。学会这些技巧后,写多线程程序就不再让人头疼了! 同步其实并不神秘,只要掌握好这些基础工具,你也能写出流畅又安全的多线程程序 。

如果觉得有帮助,别忘了点赞和分享,关注我,我们一起学更多有趣的编程知识!已经掌握了这些同步方式? 那恭喜你!如果还没完全弄明白,没关系,欢迎在评论区留言,我们一起讨论,确保你都能搞懂!

想更快找到我? 直接微信搜索 「跟着小康学编程」,或者扫描下方二维码关注,和一群爱学习的编程小伙伴一起成长吧!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值