线程分离和线程同步互斥

1.线程分离

1.1 背景需求

回忆之前学习的进程等待的时候,进程等待有三种方式,分别是阻塞等待、非阻塞轮询和基于信号的等待。那么如果我们想主线程不进行阻塞等待应该怎么办?接下来介绍的线程分离就是为了处理这种情况

1.2 线程状态

可连接状态(线程被创建后的默认状态):线程终止后,它会进入一种"僵尸"状态。它的退出状态码和占用的系统资源(如栈内存、线程内核对象)会一直被保留,直到有另一个线程对它调用 pthread_join() 函数来"收割"它。

分离状态:线程结束后系统自动释放资源。(但需要注意主线程必须要最后退出,否则该线程会意外中止)其他线程无法获得其返回值!

1.3 线程分离接口

参数:需要被分离的线程id

返回值:成功返回零;失败返回错误码。

注意事项:

  1. 一旦分离就不能在join,分离后的线程不能被pthread_join阻塞等待,如果pthread_join去等待一个分离的线程会返回错误码EINVAL。
  2. 主线程退出问题。如果主线程先于分离线程退出,那么分离线程也会被强制终止。
  3. 线程同步问题。分离后无法通过pthread_join进行同步。
  4. 资源清理的时机不同。分离线程的资源在线程函数返回时立即释放。

2. 线程互斥

线程互斥(Mutual Exclusion)的必要性源于多个线程并发访问共享资源时可能引发的数据不一致问题 ,其根本目的是为了保证程序的正确性(Correctness)。

2.1 "奇怪的问题"

我们通过一个例子来看多线程访问时候没有线程互斥会导致的问题!

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <string>
#include <vector>
#define NUM 4

using namespace std;
int tickets = 1000;
class ThreadData
{
public:
    ThreadData(int number)
    {
        threadname_ = "thread-" + to_string(number);
    }

public:
    string threadname_;
};

void* getTicket(void* args)
{
    ThreadData* data = static_cast<ThreadData*>(args);
    const char* name = data->threadname_.c_str();
    while(true)
    {
        if(tickets > 0)
        {
            usleep(1000);
            printf("who=%s get a ticket:%d\n",name,tickets);
            tickets--;
        }
        else
        {
            break;
        }
    }
    printf("%s...quit\n",name);
    return nullptr;
}

int main()
{
    vector<pthread_t> tids;;
    vector<ThreadData*> ThreadDatas;
    for(int i = 0;i < NUM;i++)
    {
        pthread_t tid;
        ThreadData* data = new ThreadData(i);
        pthread_create(&tid, nullptr, getTicket, data);
        tids.push_back(tid);
        ThreadDatas.push_back(data);
    }
    for(auto tid : tids)
    {
        pthread_join(tid, nullptr);
    }
    for(auto data : ThreadDatas)
    {
        delete data;
    }   
    return 0;
}

我们发现打印的结果居然有负数,这是什么情况?

我们从硬件角度来仔细分析一下问题是如何产生的!

首先我们先了解票数是如何减少的,先需要将ticket的值从内存中读取将其传送到CPU的寄存器中,然后CPU对数据进行运算,最后将结果重新写回到内存中。但是进程在运行的时候可能在任何时机被切换,那么线程会保护它的上下文数据以便后续运行时候接着切换前的工作继续工作。例如有线程A进行抢票,在它的时间片中,此时票数只有5张了,A进程第一个if条件成立,进入到if语句的内部,此时时间片耗尽了,线程被切换。此时其他线程继续抢票直到ticket为零或负数了。当A线程恢复继续运行的时候,我们打印票数的结果(会重新访问内存中的ticket数据),此时ticket的结果和我们当初判断if时候的ticket的结果明显是不一样的!因此就导致了结果为负数的情况。

2.2 互斥锁

为了解决这个问题,我们需要一种机制来确保一个线程在完整执行"取票"这一系列指令时,另一个线程必须等待 。也就是说,我们要将这一系列非原子的指令变成一个临界区) ,并保证这个临界区是互斥访问的。

锁的初始化接口

int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr)

参数:第一个参数为指向 pthread_mutex_t 变量的指针 。该函数会将初始化好的锁存储在这个地址。第二个参数是指向属性对象 pthread_mutexattr_t 的指针 ,该对象定义了新锁的属性。如果如果传入 NULL,则使用所有默认属性(最常见用法);如果需要设置特殊属性(如进程共享、健壮性等),需要先创建并配置一个属性对象。

返回值:成功返回零,失败返回错误码。

锁还可以使用宏来初始化(静态初始化)。使用宏 PTHREAD_MUTEX_INITIALIZER 在声明锁的同时进行初始化。这种方法简单,但锁的属性是默认的。

优点:

1.无需手动管理初始化和销毁配对,编译的时候自动初始化和程序结束的时候自动清理。

2.避免多个线程去初始化一个锁

3.性能优势(初始化在编译时完成、没有函数调用开销、更快的程序启动)

锁的销毁接口

当一个动态初始化(pthread_mutex_init)的互斥锁不再需要时,必须调用该接口来销毁它,以释放其占用的资源。静态初始化的锁不需要,也无法用此函数销毁。

int pthread_mutex_destroy(pthread_mutex_t *mutex)

参数:一个指向需要被销毁锁的指针

返回值:成功返回零,失败返回错误码。

加锁接口

阻塞式加锁

int pthread_mutex_lock(pthread_mutex_t *mutex);

参数:指向要锁定的互斥锁对象的指针

返回值:成功返回零,失败返回错误码(EDEADLK:(对于错误检查互斥锁)当前线程已经持有该锁,再次加锁会导致死锁。)

注意:阻塞式调用!如果拿不到锁那么线程就会一直等待。

非阻塞式加锁

int pthread_mutex_trylock(pthread_mutex_t *mutex);

参数:指向要锁定的互斥锁对象的指针

返回值:成功返回零,失败,如果锁已经被占用返回EBUSY,其他返回错误码(EDEADLK:(对于错误检查互斥锁)当前线程已经持有该锁,再次加锁会导致死锁。)

注意:锁如果没有被占用就直接获得锁,如果被占用就返回EBUSY而不是一直等待!

解锁接口

int pthread_mutex_unlock(pthread_mutex_t *mutex);

参数:指向要解锁的互斥锁对象的指针

返回值:成功返回零,失败返回错误码。(常见错误EPERM:当前线程并不持有该互斥锁)

注意:谁加锁,谁解锁。加锁和解锁操作必须是同一个线程。

加锁情况下一定要解锁才能退出线程(pthread_exit()),不然其他线程无法申请锁成功!

临界区(Critical Section) 是指在多线程程序中,访问共享资源的代码段。在任何时刻,只能有一个线程执行临界区内的代码,以避免数据竞争和不一致性。

加锁的本质:用时间换安全

加锁的表现:线程对于临界区代码串行执行

加锁的原则:尽量的保证临界区代码越少越好(临界区执行的时间少,串行的时间减少,降低执行临界区代码时候被调度的可能性)

我们再回到那个"奇怪的问题",现在我们有了锁,我们就可以对申请票的代码进行加锁(对临界资源加锁)。

cpp 复制代码
include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <string>
#include <vector>
#define NUM 4

using namespace std;
int tickets = 1000;
class ThreadData
{
public:
    ThreadData(int number,pthread_mutex_t* lock)
    :lock_(lock)
    {
        threadname_ = "thread-" + to_string(number);
    }
public:
    string threadname_;
    pthread_mutex_t* lock_;
};

void* getTicket(void* args)
{
    ThreadData* data = static_cast<ThreadData*>(args);
    const char* name = data->threadname_.c_str();
    while(true)
{
   //线程对于锁的竞争能力不同
        pthread_mutex_lock(data->lock_);//申请成功才会往后执行,不成功则阻塞等待
        if(tickets > 0)
        {
            usleep(1000);
            printf("who=%s get a ticket:%d\n",name,tickets);
            tickets--;
            pthread_mutex_unlock(data->lock_);
        }
        else
        {
            pthread_mutex_unlock(data->lock_);
            break;
        }
    }
    printf("%s...quit\n",name);
    return nullptr;
}
int main()
{
    pthread_mutex_t lock;
    pthread_mutex_init(&lock, nullptr);
    vector<pthread_t> tids;
    vector<ThreadData*> ThreadDatas;
    for(int i = 0;i < NUM;i++)
    {
        pthread_t tid;
        ThreadData* data = new ThreadData(i,&lock);
        pthread_create(&tid, nullptr, getTicket, data);
        tids.push_back(tid);
        ThreadDatas.push_back(data);
    }
    for(auto tid : tids)
    {
        pthread_join(tid, nullptr);
    }
    for(auto data : ThreadDatas)
    {
        delete data;
    }   
    return 0;
}

我们为其加锁后发现全部的票全被一个线程抢完了!这又是为什么呢?

我们用一个小故事来比喻这种情况!试想一下有一间vip自习室只能容纳一个人,你早上很早就去抢到了这件自习室(其他人在门外排队),每当你刚迈出门想休息时候就后悔然后又回到自习室中(因为你离自习室更近因此你更容易得到自习室),这导致其他人无法使用这个自习室。这个现象和上述只有一个线程抢到票是一样的!

纯互斥环境如果锁分配不够合理,就会导致其他线程的饥饿问题!

根据我们的观察,我们做了一下两种规定:

  1. 外面的人进来必须要排队
  2. 出来的人不能立即重新申请锁,必须重新排队!(按照一定顺序来获取资源,同步!!!)

锁本身会被多个线程申请,因此锁也是临界资源!所以申请锁和释放锁的过程本身就是原子的!这样可以确保在同一时间只有一个线程申请和释放成功!

在临界区中,线程也可以被切换!(例如去vip自习室中自习中途去上厕所,但把自习室的钥匙拿在手中,其他人无法进入到自习室!)在线程被切换的时候,线程是持有锁被切换的,因此在该线程不在期间,没有任何的线程可以访问临界区资源!!!通过持有锁来确保对临界区资源操作是原子的!

加锁的底层实现

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

lock中汇编代码解释:清空寄存器al中的内容,将al中内容和mutex的内容进行互换,如果al寄存器的内容大于0那么就说明申请锁成功,返回0;否则就挂起等待,说明申请锁失败

unlock中汇编代码解释:将mutex置为1(说明解锁可以不单单是被加锁的线程,其他线程也可以解锁),唤醒等待锁的线程,返回成功。

注意:如果线程被切换,对应寄存器的内容会被保存(线程上下文),所以lock中的汇编语句没有运行完该线程就被切换也没有关系!寄存器 ≠ 寄存器内容

例如A进程运行Lock的汇编语句,当其运行完第一条语句的时候被切换,此时保存的上下文内容为清空al寄存器中的内容和下一条语句从交换al寄存器和mutex中的内容开始。此时B进程运行Lock的汇编语句,当其运行完第一、二条语句时候被切换,那么保存的上下文内容为al寄存器中的值为mutex原值(mutex中值为0)和下一条语句执行if。此时A线程继续执行,A进行交换后因为al内容为空(已经被B线程拿去)导致进入挂起等待状态,切换B线程继续执行。因为B线程恢复上下文数据,al寄存器中的内容不为0,那么B进程获得了该锁!

最重要的语句就是交换al寄存器和metux的值!谁先运行谁就获得了这把锁!

2.3 锁的封装

用临时对象去管理锁,RAII风格的锁!

cpp 复制代码
//mutex.hpp
#pragma once
#include <pthread.h>
#include <iostream>
using namespace std;

class MyMutex
{
public:
    MyMutex(pthread_mutex_t* lock):mutex_(lock)
    {
    }
    ~MyMutex()
    {
    }
    void lock()
    {
        pthread_mutex_lock(mutex_);
    }
    void unlock()
    {
        pthread_mutex_unlock(mutex_);
    }
private:
    pthread_mutex_t* mutex_;
};
class LockGuard
{
public:
    LockGuard(pthread_mutex_t* lock):mymutex_(lock)
    {
        mymutex_.lock();
    }
    ~LockGuard()
    {
        mymutex_.unlock();
    }
private:
    MyMutex mymutex_;
};
cpp 复制代码
//main.cpp
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <string>
#include <vector>
#define NUM 4
#include "mutex.hpp"
using namespace std;

int tickets = 1000;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
class ThreadData
{
public:
    ThreadData(int number, pthread_mutex_t *lock)
        : lock_(lock)
    {
        threadname_ = "thread-" + to_string(number);
    }
public:
    string threadname_;
    pthread_mutex_t *lock_;
};
void *getTicket(void *args)
{
    ThreadData *data = static_cast<ThreadData *>(args);
    const char *name = data->threadname_.c_str();
    while (true)
    {
        {//使用大括号显示锁的范围 临时变量管理锁 RAII风格
            LockGuard lockguard(&lock);
            if (tickets > 0)
            {
                usleep(1000);
                printf("who=%s get a ticket:%d\n", name, tickets);
                tickets--;
            }
            else
            {
                break;
            }
        }
        usleep(1000); // 模拟抢完票的后续动作
    }
    printf("%s...quit\n", name);
    return nullptr;
}
int main()
{
    pthread_mutex_t lock;
    pthread_mutex_init(&lock, nullptr);
    vector<pthread_t> tids;
    vector<ThreadData *> ThreadDatas;
    for (int i = 0; i < NUM; i++)
    {
        pthread_t tid;
        ThreadData *data = new ThreadData(i, &lock);
        pthread_create(&tid, nullptr, getTicket, data);
        tids.push_back(tid);
        ThreadDatas.push_back(data);
    }
    for (auto tid : tids)
    {
        pthread_join(tid, nullptr);
    }
    for (auto data : ThreadDatas)
    {
        delete data;
    }
    return 0;
}

3.可重入和线程安全概念辨析

3.1 概念

线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。

3.2 常见线程不安全的情况

不保护共享变量的函数
函数状态随着被调用,状态发生变化的函数
返回指向静态变量指针的函数
调用线程不安全函数的函数

3.3 常见线程安全的情况

不保护共享变量的函数
函数状态随着被调用,状态发生变化的函数
返回指向静态变量指针的函数
调用线程不安全函数的函数

3.4 常见不可重入情况

调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
可重入函数体内使用了静态的数据结构

3.5 常见可重入情况

不使用全局变量或静态变量
不使用用malloc或者new开辟出的空间
不调用不可重入函数
不返回静态或全局数据,所有数据都有函数的调用者提供
使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据

3.6 可重入和线程安全的联系:

函数是可重入的,那就是线程安全的。
函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。

3.7 可重入与线程安全区别

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

4. 常见锁的概念

死锁(Deadlock) 是指两个或多个线程(或进程)在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力干涉那它们都将无法推进下去。

死锁的四个必要条件(同时满足):

1.互斥条件:一个资源每次只能被一个执行流使用(前提)

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

3.不剥夺条件:一个执行流已获得的资源,在未使用完前,不能强行剥夺(原则2)

4.循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系(重要条件)
解决死锁问题的方法------破坏四个必要条件的一个或多个!

对于第二条,我们可以针对一个执行流申请资源时候如果失败就立即返回并释放自己持有的资源的方式来避免死锁问题。

pthread_mutex_trylock申请锁是非阻塞式等待,参数为尝试加锁的互斥对象。返回值:如果申请成功返回0,如果锁被占用返回EBUSY;如果发生其他错误则返回错误码。

对于第三条,在未使用完之前强行剥夺,也就是释放锁

对于第四条,按照顺序进行锁的申请
单一锁触发死锁的情况:

1.同一线程重复加锁

2.递归调用中重复加锁

3.函数调用链中重复加锁

4.异常处理中的锁泄漏导致死锁

5. 线程同步

5.1 什么是线程同步

线程同步就是协调多个线程的执行顺序,确保它们能够正确地、有序地访问共享资源(如内存、文件、变量等),从而防止出现数据不一致或其他不可预料的错误。

5.2 为什么需要线程同步

核心目的是为了避免竞争条件!

竞争条件是指两个或多个线程读写某些共享数据,而最后的结果取决于进程运行的精确时序。

5.3 如何实现线程同步

使用条件变量搭配锁的方式来解决!

条件变量(Condition Variable) 是一种线程同步机制,与锁一起使用从而实现线程间的等待和通知。它允许一个线程在某个条件未满足时进入等待状态,而另一个线程在条件满足时通知等待的线程继续执行。

5.4 函数接口介绍

int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict
attr);

参数:第一个参数是指向需要被初始化的条件变量的指针;第二个为条件变量的参数指针(默认为NULL)

返回值:成功返回零,失败返回错误码。
int pthread_cond_destroy(pthread_cond_t *cond)
参数:指向需要被销毁的条件变量的指针
返回值:成功返回零,失败返回错误码。
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
参数:第一个为指向条件变量的指针,第二个为指向锁的指针
返回值:成功返回零,失败返回错误码。
注意:
1.函数会自动释放当前线程传入的互斥锁,从而使其可以被其他线程使用
2.将当前线程投入睡眠,等待被唤醒(现代Linux默认是FIFO)

int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
参数:指向条件变量的指针
返回值:成功返回零,失败返回错误码。
第一个函数为批量唤醒,第二个则是默认唤醒队列的第一个。

5.5 效果演示

cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <stdio.h>
using namespace std;
int cnt = 0;

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
void *Count(void *args)
{
    pthread_detach(pthread_self());//分离线程
    uint64_t number = (uint64_t)args;
    printf("thread %d creaate success!\n",number);
    while(true)
    {
        //printf("thread %d,cnt %d\n",number,cnt++);
        pthread_mutex_lock(&mutex);
        //为了防止某一线程对锁的竞争过于激烈,导致线程饥饿现象的出现
        //我们需要线程按照一定的顺序去竞争锁
        pthread_cond_wait(&cond,&mutex);
        //为什么要将pthread_cond_wait放在这里而不是放在pthread_mutex_lock(&mutex)之前?(先加锁后等待?)
        //你拿着锁去队列等待,其他线程没有机会得到锁啊?这就是为什么pthread_cond_wait的参数带有锁的原因
        //pthread_cond_wait的作用是:让当前线程进入等待队列,并且在进入等待队列之前会自动释放传入的锁 
        cout << "thread " << number << ", cnt " << cnt++ << endl;
        pthread_mutex_unlock(&mutex);
        sleep(1);
    }
}
int main()
{
    for(uint64_t i = 0;i < 5;i++)
    {
        pthread_t tid;
        pthread_create(&tid, nullptr, Count, (void *)i);//此处传入i而不是传入i的地址是为了避免主线程和其他线程访问同一个i
    }
    sleep(3);
    //主线程去唤醒其他线程
    cout << "main thread wake up others" << endl;
    while(true)
    {
        pthread_cond_signal(&cond);//唤醒cond等待队列的一个线程,默认都是第一个
        sleep(1);
    }
    return 0;
}

上面的代码是对条件变量的使用演示,将创建的线程通过条件变量等待投入等待队列,然后由主线程唤醒,来完成任务。

我们怎么知道一个线程需要去休眠?一定是临界资源没有就绪的时候,这个想要去使用临界资源的线程才去休眠,也就是说临界资源也有状态!如何知道临界资源是就绪还是不就绪?我需要判断!那判断是访问临界资源吗?是的!!!也就是说判断是加锁之后进行!因此后面我们需要先加锁然后判断,发现临界资源没有就绪就进入等待队列,因此我们的pthread_cond_wait是在wpthread_mutex_lock的后面!




多线程编程的世界里充满了理论的陷阱与未知的竞态条件,正如脑海中的万千思绪,若只停留在设计的层面,我们永远会困于"死锁"、"饥饿"和"虚假唤醒"的忧虑之中。但当我们真正动手去写,去调试,去让线程在锁与条件变量的指挥下有序起舞时,那些抽象的概念便纷纷落地,化为一行行确凿的代码和一个个清晰的结果。最终的答案,从来不在完美的设想里,而在一次次勇敢的实践、试错与修正之中。所以,别再犹豫,现在就去编译运行你的想法,让程序本身告诉你最终的答案。

相关推荐
雯宝1 小时前
mnt_init 函数
linux
UIU1142 小时前
递归算法与汉诺塔
c++·学习·算法·c#·递归
Fruit_Caller2 小时前
static
开发语言·c++
律宏阔7 小时前
WSL Docker 端口明明空闲,但就是绑不上端口
linux·windows
律宏阔7 小时前
WSL 突然断网,无法 ping 内网或外网
linux·windows
穷人小水滴7 小时前
用容器编译 VirtualBox 虚拟机软件 (ArchLinux, podman)
linux·容器·virtualbox
乱码三千7 小时前
如何优雅地直连无公网 IP 的远程 GPU 服务器
linux·人工智能·程序员
yunwei377 小时前
使用 eBPF 跟踪 Nginx 请求
linux·后端·性能优化
yunwei377 小时前
使用 eBPF 跟踪 MySQL 查询
linux·后端·性能优化
yunwei377 小时前
eBPF 示例教程:使用 XDP 捕获 TCP 信息
linux·后端·性能优化