Linux锁的使用

时间:2024-04-09 12:53:20

一、临界资源与临界区

多线程会共享例如全局变量等资源,我们把会被多个执行流访问的资源称为临界资源,我们是通过代码访问临界资源的,而我们访问临界资源的那部分代码称为临界区。

实现一个抢票系统

只有一个线程抢票时

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

#include "Thread.hpp"


int num = 10000; 

std::string GetThreadName()
{
    static int num = 1;
    char name[64];
    snprintf(name, sizeof(name), "thread-%d", num++);
    return name;
}

void Ticket(std::string name)
{
    while(true)
    {
        if(num > 0)
        {
            usleep(1000);
            printf("%s get ticket: %d\n", name.c_str(), num);
            num--;
        }
        else
        {
            break;
        }
    }
}

int main()
{
    std::string name1 = GetThreadName();
    Thread<std::string> t1(name1, Ticket, name1);
    
    t1.Start();
    t1.Join();
    
    return 0;
}

正常输出,最终票数为0时退出。

但是当我们启动多个线程同时抢票时,num就是临界资源,使用num的那部分代码就是临界区

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

#include "Thread.hpp"


int num = 10000; 

std::string GetThreadName()
{
    static int num = 1;
    char name[64];
    snprintf(name, sizeof(name), "thread-%d", num++);
    return name;
}

void Ticket(std::string name)
{
    while(true)
    {
        if(num > 0)
        {
            usleep(1000);
            printf("%s get ticket: %d\n", name.c_str(), num);
            num--;
        }
        else
        {
            break;
        }
    }
}

int main()
{
    std::string name1 = GetThreadName();
    Thread<std::string> t1(name1, Ticket, name1);
    std::string name2 = GetThreadName();
    Thread<std::string> t2(name2, Ticket, name2);
    std::string name3 = GetThreadName();
    Thread<std::string> t3(name3, Ticket, name3);
    std::string name4 = GetThreadName();
    Thread<std::string> t4(name4, Ticket, name4);

    t1.Start();
    t2.Start();
    t3.Start();
    t4.Start();

    t1.Join();
    t2.Join();
    t3.Join();
    t4.Join();
    
    return 0;
}

可以看到出现了0和负数的票数,这是因为当票数只剩1时,有多个执行流在同一时间通过了if判断,使得能继续进行减票操作。

vs下自减操作的反汇编,分为三步:先从内存拿数据,再把数据减1,最后把数据拷贝到内存

多个执行流同时访问临界资源例如自减操作,由于--操作不是原子性的(我们认为一条汇编指令是原子性的,是不会被中断的。但--操作转为汇编指令后,需要多条指令才能完成),当--操作执行到一半切换到其他线程会导致数据不一致的问题。这种情况下需要通过锁把临界区保护起来,每次只让一个执行流访问临界资源,避免数据不一致问题。

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

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

二、使用锁的方法

1.创建锁

如果定义一个全局的锁,直接使用pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER用宏初始化。

如果定义一个局部锁,要使用pthread_mutex_init方法创建,参数attr设为nullptr

2.加锁解锁

使用pthread_mutex_lock加锁,传递锁的地址,

解锁用pthread_mutex_unlock

当我们使用锁后,就能保证每次只有一个执行流能访问临界资源。

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

#include "Thread.hpp"


int num = 10000;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; //定义一个全局锁

std::string GetThreadName()
{
    static int num = 1;
    char name[64];
    snprintf(name, sizeof(name), "thread-%d", num++);
    return name;
}

void Ticket(std::string name)
{
    while(true)
    {
        pthread_mutex_lock(&mutex); //加锁
        if(num > 0)
        {
            usleep(1000);
            printf("%s get ticket: %d\n", name.c_str(), num);
            num--;
            pthread_mutex_unlock(&mutex); //解锁
        }
        else
        {
            pthread_mutex_unlock(&mutex); //解锁
            break;
        }
    }
}

int main()
{
    std::string name1 = GetThreadName();
    Thread<std::string> t1(name1, Ticket, name1);
    std::string name2 = GetThreadName();
    Thread<std::string> t2(name2, Ticket, name2);
    std::string name3 = GetThreadName();
    Thread<std::string> t3(name3, Ticket, name3);
    std::string name4 = GetThreadName();
    Thread<std::string> t4(name4, Ticket, name4);

    t1.Start();
    t2.Start();
    t3.Start();
    t4.Start();

    t1.Join();
    t2.Join();
    t3.Join();
    t4.Join();

    return 0;
}

结果正常,但是速度慢了很多,因为要不断申请锁和释放锁

加锁解锁的过程是安全的

三、可重入和线程安全

1.概念

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

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

2.常见的线程不安全的情况

1.不保护共享变量的函数

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

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

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

3.常见的线程安全的情况

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

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

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

4.常见可重入的情况

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

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

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

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

5.可重入与线程安全联系

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

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

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

6.可重入与线程安全区别

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

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

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

四、死锁

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

1.死锁四个必要条件

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

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

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

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

2.避免死锁

1.破坏死锁的四个必要条件

2.加锁顺序一致

3.避免锁未释放的场景

4.资源一次性分配

3.避免死锁算法

1.死锁检测算法

2.银行家算法

一个锁会造成死锁吗?

答案是会的,当一个线程申请完一个锁,访问完临界资源后,接下来该释放锁了,但是代码却写成了加锁,这就会导致死锁问题。