【Linux】Linux线程互斥与同步

发布时间:2023年12月24日

一、Linux线程互斥

1.进程线程间的互斥相关背景概念

临界资源:多线程执行流共享的资源就叫做临界资源

临界区:每个线程内部,访问临界资源的代码,就叫做临界区

互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用

原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成

大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。

但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。

多个线程并发的操作共享变量,会带来一些问题。

我们来看下面抢票的代码:

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

int tickets = 10000;

void *getTicket(void *args)
{
    std::string threadname = static_cast<const char *>(args);
    while (true)
    {
        if (tickets > 0)
        {
            usleep(1000);
            std::cout << threadname << " 正在进行抢票: " << tickets << std::endl;
            tickets--;
        }
        else
        {
            break;
        }
    }

    return nullptr;
}

int main()
{
    pthread_t t1, t2, t3, t4;
    pthread_create(&t1, nullptr, getTicket, (void *)"thread 1");
    pthread_create(&t2, nullptr, getTicket, (void *)"thread 2");
    pthread_create(&t3, nullptr, getTicket, (void *)"thread 3");
    pthread_create(&t4, nullptr, getTicket, (void *)"thread 4");

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);
    pthread_join(t4, nullptr);
    return 0;
}

在这里插入图片描述

我们发现最终票的数量是负数,这显然是不合理的

为什么可能无法获得争取结果?

if 语句判断条件为真以后,代码可以并发的切换到其他线程

usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段

–ticket 操作本身就不是一个原子操作

取出ticket--部分的汇编代码
objdump -d a.out > test.objdump
152 40064b: 8b 05 e3 04 20 00 mov 0x2004e3(%rip),%eax # 600b34 <ticket>
153 400651: 83 e8 01 sub $0x1,%eax
154 400654: 89 05 da 04 20 00 mov %eax,0x2004da(%rip) # 600b34 <ticket>

– 操作并不是原子操作,而是对应三条汇编指令:

load :将共享变量ticket从内存加载到寄存器中

update : 更新寄存器里面的值,执行-1操作

store :将新值,从寄存器写回共享变量ticket的内存地址

对变量进行++,或者–,在C、C++上,看起来只有一条语句,但是汇编之后至少是三条语句:

1.从内存读取数据到CPU寄存器中

⒉.在寄存器中让CPU进行对应的算逻运算

3.写回新的结果到内存中变量的位置

要解决以上问题,需要做到三点:

代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。

如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。

如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。

要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。

在这里插入图片描述

我们定义的全局变量,在没有保护的时候,往往是不安全的,像上面多个线程在交替执行造成的数据安全问题,发生了数据不一致问题!

2.互斥量的接口

初始化互斥量

初始化互斥量有两种方法:

方法1,静态分配:

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER

方法2,动态分配:

int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrictattr);
参数:
mutex:要初始化的互斥量
attr:NULL

销毁互斥量

销毁互斥量需要注意:

使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁

不要销毁一个已经加锁的互斥量

已经销毁的互斥量,要确保后面不会有线程再尝试加锁

互斥量加锁和解锁

int pthread_mutex_lock(pthread_mutex_t *mutex);

int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号

调用 pthread_ lock 时,可能会遇到以下情况:

互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功

发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。

1.使用静态分配的锁–全局的锁

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

int tickets = 10000;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

void *getTicket(void *args)
{
    std::string threadname = static_cast<const char *>(args);
    while (true)
    {
        pthread_mutex_lock(&mutex);
        if (tickets > 0)
        {
            usleep(1000);
            std::cout << threadname << " 正在进行抢票: " << tickets << std::endl;
            tickets--;
            pthread_mutex_unlock(&mutex);
        }
        else
        {
            pthread_mutex_unlock(&mutex);
            break;
        }
    }

    return nullptr;
}

int main()
{
    pthread_t t1, t2, t3, t4;
    pthread_create(&t1, nullptr, getTicket, (void *)"thread 1");
    pthread_create(&t2, nullptr, getTicket, (void *)"thread 2");
    pthread_create(&t3, nullptr, getTicket, (void *)"thread 3");
    pthread_create(&t4, nullptr, getTicket, (void *)"thread 4");

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);
    pthread_join(t4, nullptr);
    return 0;
}

在这里插入图片描述

2.使用动态分配的锁

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

int tickets = 10000;

class threadData
{
public:
    threadData(const std::string &threadname, pthread_mutex_t *mutex_p)
        : _threadname(threadname), _mutex_p(mutex_p)
    {
    }
    ~threadData() {}

public:
    std::string _threadname;
    pthread_mutex_t *_mutex_p;
};

void *getTicket(void *args)
{
    threadData *td = static_cast<threadData *>(args);
    while (true)
    {
        pthread_mutex_lock(td->_mutex_p);
        if (tickets > 0)
        {
            usleep(1000);
            std::cout << td->_threadname << " 正在进行抢票: " << tickets << std::endl;
            tickets--;
            pthread_mutex_unlock(td->_mutex_p);
        }
        else
        {
            pthread_mutex_unlock(td->_mutex_p);
            break;
        }
    }

    return nullptr;
}

int main()
{
#define NUM 4
    pthread_mutex_t lock;
    pthread_mutex_init(&lock, nullptr);

    std::vector<pthread_t> tids(NUM);
    for (int i = 0; i < NUM; i++)
    {
        char buffer[64];
        snprintf(buffer, sizeof buffer, "thread %d", i + 1);
        threadData *td = new threadData(buffer, &lock);
        pthread_create(&tids[i], nullptr, getTicket, td);
    }

    for (int i = 0; i < NUM; i++)
    {
        pthread_join(tids[i], nullptr);
    }
    return 0;
}

在这里插入图片描述

我们发现在我们进行加锁之后,程序运行变慢了,这是因为在临界区中程序都是串行执行的

加锁和解锁的过程多个线程串行执行的,程序变慢了!
锁只规定互斥访问,没有规定必须让谁优先执行
锁就是真是的让多个执行流进行竞争的结果

  1. 如何看待锁
a. 锁,本身就是一个共享资源!全局的变量是要被保护的,锁是用来保护全局的资源的,锁本身也是全局资源,锁的安全谁来保护呢?
b. pthread_mutex_lock、pthread_mutex_unlock:加锁的过程必须是安全的!加锁的过程其实是原子的!
c. 如果申请成功,就继续向后执行,如果申请暂时没有成功,执行流会阻塞!
d. 谁持有锁,谁进入临界区!
  1. 如何理解加锁和解锁的本质 — 加锁的过程是原子的!

如果线程1,申请锁成功,进入临界资源,正在访问临界资源期间,其他线程在做什么?﹖阻塞等待

如果线程1,申请锁成功,进入临界资源,正在访问临界资源期间,我可不可以被切换呢??绝对可以的!

当持有锁的线程被切走的时候,是抱着锁被切走的,即便自己被切走了,其他线程依旧无法申请锁成功,也便无法向后执行!直到我最终释放这个锁!

所以,对于其他线程而言,有意义的锁的状态,无非两种1.申请锁前⒉释放锁后

站在其他线程的角度,看待当前线程持有锁的过程,就是原子的!!

未来我们在使用锁的时候,一定要尽量保证临界区的粒度要非常小! 粒度:锁中间保护代码的多少

3.互斥量实现原理

经过上面的例子,大家已经意识到单纯的 sticket++ 或者sticket-- 都不是原子的,有可能会有数据一致性问题

为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。 现在我们把lock和unlock的伪代码改一下

在这里插入图片描述

1.CPU内寄存器只有一套被所有执行流共享

2.CPU内寄存器的内容,是每个执行流私有的,运行时上下文

假如线程1先执行,先申请锁,将0放到寄存器中,然后将锁的值和寄存器中的值进行交换,即使现在进行被切换,那么寄存器中的值作为上下文数据也会被线程1带走,那么下一个线程来的时候,将寄存器中的值与内存中锁的值进行交换,那么寄存器中的值还是0,线程就会挂起等待,只有线程1执行完毕之后释放锁,其他线程才能够申请到锁,就保存了申请锁的原子性。交换的本质:共享的数据,交换到我得上下文中!!!一条汇编完成I

释放锁的时候,将1赋值给锁的值,唤醒其他线程,就完成了释放锁的功能。

4.可重入VS线程安全

4.1.可重入和线程安全的概念

线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。

重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。

4.2常见的线程不安全的情况

不保护共享变量的函数

函数状态随着被调用,状态发生变化的函数

返回指向静态变量指针的函数

调用线程不安全函数的函数

4.3常见的线程安全的情况

每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的

类或者接口对于线程来说都是原子操作

多个线程之间的切换不会导致该接口的执行结果存在二义性

4.4常见不可重入的情况

调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的

调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构

可重入函数体内使用了静态的数据结构

4.5常见可重入的情况

不使用全局变量或静态变量

不使用用malloc或者new开辟出的空间

不调用不可重入函数

不返回静态或全局数据,所有数据都有函数的调用者提供

使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据

4.6可重入与线程安全联系

函数是可重入的,那就是线程安全的

函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题

如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。

4.7可重入与线程安全区别

可重入函数是线程安全函数的一种

线程安全不一定是可重入的,而可重入函数则一定是线程安全的。

如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。

5.死锁

5.1死锁的概念

死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。

5.2死锁四个必要条件

互斥条件:一个资源每次只能被一个执行流使用

请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放

不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺

循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系

5.3如何避免死锁

破坏死锁的四个必要条件

加锁顺序一致

避免锁未释放的场景

资源一次性分配

5.4避免死锁算法

死锁检测算法(了解)

银行家算法(了解)

总结:

谈谈死锁:在多把锁的场景下,我们持有自己的锁不释放,还要对方的锁,对方也是如此,此时就容易造成死锁!1.一把锁,有可能死锁吗?不可能

2.为什么会有死锁?逻辑链条

一定是你用了锁<–为什么你要用锁呢<–保证临界资源的安全<-多钱程访问我们可能出现数据不一致的问题<–多线程&全局资源<–多战程大部分资源(全局的)是共享的<–多线程的特性

任何技术都有自己的边界,是解决问题的,但有可能在解决问题的同时,一定会可能引入新的问题!

二、 Linux线程同步

1.同步概念与竞态条件

同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步

竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解

2.条件变量

当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。

例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情

况就需要用到条件变量。

3.条件变量函数

初始化

int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict
attr);
参数:
cond:要初始化的条件变量
attr:NULL

销毁

int pthread_cond_destroy(pthread_cond_t *cond)

等待条件满足

int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
参数:
cond:要在这个条件变量上等待
mutex:互斥量

唤醒等待

int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
#include <iostream>
#include <string>
#include <unistd.h>
#include <pthread.h>

int tickets = 1000;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

void *start_routine(void *args)
{
    std::string name = static_cast<const char *>(args);
    while (true)
    {
        pthread_mutex_lock(&mutex);
        pthread_cond_wait(&cond, &mutex); 
        // 判断暂时省略
        std::cout << name << " -> " << tickets << std::endl;
        tickets--;
        pthread_mutex_unlock(&mutex);
    }
}

int main()
{
    // 通过条件变量控制线程的执行
    pthread_t t[5];
    for (int i = 0; i < 5; i++)
    {
        char *name = new char[64];
        snprintf(name, 64, "thread %d", i + 1);
        pthread_create(t+i, nullptr, start_routine, name);
    }

    while (true)
    {
        sleep(1);
        pthread_cond_signal(&cond);
        std::cout << "main thread wakeup one thread..." << std::endl;
    }
    for (int i = 0; i < 5; i++)
    {
        pthread_join(t[i], nullptr);
    }

    return 0;
}

在这里插入图片描述

pthread_cond_wait函数,线程会进入阻塞状态,并且会自动释放已经持有的互斥锁。这样其他线程就可以获取到互斥锁,并继续执行。等待条件变量的线程被唤醒后,它会重新获取互斥锁,然后继续往下执行。

在主线程中,通过调用pthread_cond_signal函数,会唤醒一个等待条件变量的线程。每个线程都会尝试重新获取互斥锁并继续执行。这样就实现了多个线程同时竞争互斥锁的机制。

因此,虽然只有一个线程能够获取到互斥锁并进入临界区,但其他线程也有机会竞争互斥锁,而不是一直被阻塞。这样可以提高并发性能,让多个线程能够同时执行而不是串行执行。

我们也可以一次唤醒所有的线程:

pthread_cond_broadcast(pthread_cond_t *cond);

结果运行如下:

在这里插入图片描述

为什么 pthread_cond_wait 需要互斥量?

条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。

条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据。

在这里插入图片描述

按照上面的说法,我们设计出如下的代码:先上锁,发现条件不满足,解锁,然后等待在条件变量上不就行了,如下代码

// 错误的设计
pthread_mutex_lock(&mutex);
while (condition_is_false)
{
	pthread_mutex_unlock(&mutex);
	//解锁之后,等待之前,条件可能已经满足,信号已经发出,但是该信号可能被错过
	pthread_cond_wait(&cond);
	pthread_mutex_lock(&mutex);
}
pthread_mutex_unlock(&mutex);

由于解锁和等待不是原子操作。调用解锁之后, pthread_cond_wait 之前,如果已经有其他线程获取到互斥量,摒弃条件满足,发送了信号,那么 pthread_cond_wait 将错过这个信号,可能会导致线程永远阻塞在这个 pthread_cond_wait 。所以解锁和等待必须是一个原子操作。int pthread_cond_wait(pthread_cond_ t *cond,pthread_mutex_ t * mutex); 进入该函数后,会去看条件量等于0不?等于,就把互斥量变成1,直到cond_ wait返回,把条件量改成1,把互斥量恢复成原样。

pthread_cond_wait函数的操作可以分为以下几步:

  1. 当线程调用pthread_cond_wait函数时,它会首先将自己加入到条件变量的等待队列中,并释放已经持有的互斥锁。这样其他线程就能够获取到互斥锁并继续执行,而不会因为互斥锁一直被该线程占用而导致死锁问题。
  2. 当条件变量发出信号(如通过pthread_cond_signalpthread_cond_broadcast函数)唤醒一个或多个等待在条件变量上的线程时,被唤醒的线程会尝试重新获取互斥锁。
  3. 一旦线程获取到互斥锁,它就可以继续执行临界区的代码。

如果没有互斥锁的配合,pthread_cond_wait函数无法正确释放和重新获取互斥锁,这可能导致死锁或竞态条件问题。通过使用互斥锁,pthread_cond_wait函数可以安全地释放和重新获取互斥锁,确保线程之间的同步和顺序执行。因此,在使用条件变量时,通常需要与互斥锁配对使用以确保正确的同步机制。

4.条件变量使用规范

等待条件代码

pthread_mutex_lock(&mutex);
while (条件为假)
pthread_cond_wait(cond, mutex);
修改条件
pthread_mutex_unlock(&mutex);
  1. 线程调用pthread_mutex_lock函数获取互斥锁,如果互斥锁已经被其他线程占用,则当前线程将进入阻塞状态,直到能够获取到互斥锁为止。
  2. 然后线程进入一个while循环,判断是否满足特定的条件。如果不满足,则线程调用pthread_cond_wait函数进入阻塞状态,并且会自动释放已经持有的互斥锁。等待条件变量的线程被唤醒后,它会重新获取互斥锁并继续往下执行。
  3. 当线程被唤醒时,它会再次检查while循环的条件是否满足。如果不满足,则线程会继续阻塞等待。
  4. 如果while循环的条件满足,则线程会执行修改条件的代码,然后调用pthread_mutex_unlock函数释放互斥锁。

通过这种方式,线程可以等待某些特定的条件满足后再继续执行。同时,它也可以避免竞态条件和死锁问题的出现。

给条件发送信号代码

pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(&mutex);
  1. 线程调用pthread_mutex_lock函数获取互斥锁,如果互斥锁已经被其他线程占用,则当前线程将进入阻塞状态,直到能够获取到互斥锁为止。
  2. 然后设置条件为真,即满足了特定的条件。
  3. 接着,线程调用pthread_cond_signal函数,向等待在条件变量上的一个线程发出信号,告诉它可以继续执行了。
  4. 最后,线程调用pthread_mutex_unlock函数释放互斥锁。

通过这样的操作,线程在满足特定条件后,可以通过条件变量和互斥锁来实现与其他线程之间的同步。其中,pthread_cond_signal函数用于通知等待在条件变量上的一个线程,而pthread_cond_broadcast函数可以通知所有等待在条件变量上的线程。这样可以确保等待在条件变量上的线程能够及时被唤醒并继续执行。

文章来源:https://blog.csdn.net/qq_67582098/article/details/135181446
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。