Linux线程安全

59 阅读17分钟

1.线程互斥前置概念

1)基本概念

在了解什么是线程互斥之前,我们先来了解几个基本概念。

  • 临界资源:多线程之间共享的资源叫做临界资源。
  • 临界区:每个线程内部,访问临界资源的代码,叫做临界区。
  • 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区访问临界资源,通常对临界资源起保护作用。
  • 原子性:不会被任何调度机制打断的操作,该操作只有两态--完成与未完成。

2)认识线程互斥

在进程中进行通信,首先要创造第三方资源,让不同进程看到同一份资源,在进程中,通信的第三方资源就是临界资源,代码就是临界区。

而在线程中,大部分代码都是共享的,所以线程间通信不需要这么费力。

接下来我们定义一个全局变量,让新线程每隔1s对该变量加1,主线程打印变量的值。

接下俩我们所有实验,都会使用这个makefile

test : test.c
	gcc -o $@ $^ -std=gnu11 -pthread
.PHONY:clean
clean:
	rm -f test
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<pthread.h>


int count = 0;
void* Routine()
{
    while(1)
    {
        count++;
        sleep(1);
    }
    pthread_exit((void*)0);
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, NULL, Routine, NULL);
    while(1)
    {
        printf("%d\n", count);
        sleep(1);
    }
    pthread_join(tid, NULL);
    return 0;
}

image-20231206202902699

此时我们就相当于完成了线程之间的通信,而count就是当前的临界资源,因为它被多个线程共享,而主线程和新线程执行的代码就是临界区。

接下俩,我们再做一个实验,模拟一个抢票系统,主线程创建4个新线程,新线程取抢票,当票被抢完时,线程自动退出。

#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<pthread.h>

int tickets = 1000;

void TicketsGrabbing(void* arg)
{
    const char* name = (char*)arg;
    while(1)
    {
        if(tickets > 0)
        {
            //线程挂起等待10ms,usleep比sleep更精细,能有效利用cpu的时间
            usleep(10000);
            printf("[%s] get a ticket, left: %d\n", name, --tickets);
        }
        else{
            break;
        }
    }
    printf("%s quit\n", name);
    pthread_exit((void*)0);
}

int main()
{
    pthread_t t1, t2, t3, t4;
    pthread_create(&t1, NULL, TicketsGrabbing, NULL);
    pthread_create(&t2, NULL, TicketsGrabbing, NULL);
    pthread_create(&t3, NULL, TicketsGrabbing, NULL);
    pthread_create(&t4, NULL, TicketsGrabbing, NULL);

    //线程等待,不等待的话会导致资源无法释放
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);

    return 0;
}

image-20231206205036039

可以看到,我们的票被抢成负数的了,为什么?

if语句中,代码可以并发地切换到其他线程,此时在休眠过程中,其他线程可能进去临界区,另外,我们的“--tickets”并不是一个原子性操作。

对tickets减减,实际上有三步:

  • a.load:将变量tickets从内存中加载到寄存器上。

  • b.update:更新寄存器的值,执行--操作

  • c.store:将新值从寄存器写回tickets的内存地址中。

既然要三步才能完成,那么线程1、2、3、4肯定有机会在过程中把值取走,并保存在它们的上下文数据中,此时对于4个进程中的tickets值,就都不是唯一的,当4个线程被执行时,会把保存在它们上下文中的tickets再写回内存,故最后的票数回减到负数。

既然临界资源的访问可能是不安全的,所以为了安全地使用临界资源,我们就需要线程互斥--即同一时间内有且仅有一个执行流进入临界区。

2.引入互斥量mutex

我们要解决上面的抢票问题,需要做到3点:

  • a.代码必须要有互斥行为:即当有代码进入临界区,其他线程不允许进入。
  • b.如果多个线程要进入临界区,且此时临界区没有线程在执行,也只允许一个线程进入临界区。
  • c.如果线程不在临界区中执行,那么该线程也不能阻止其他线程进入临界区。

Linux中为了做到这一点,设计了一个锁的机制,这把锁叫做互斥量。

3.互斥量接口

1)初始化互斥锁(或者叫创建)

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

参数说明:

  • mutex:要初始化的互斥锁

  • attr:初始化互斥锁的属性,一般设为NULL

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

以上初始化互斥锁的方法叫做动态分配,我们也可以静态分配:

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

2)销毁互斥锁

int pthread_mutex_destroy(pthread_mutex_t *mutex);

参数说明:

  • mutex:需要销毁的互斥锁

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

注意:

  • 静态分配创建的锁不需要销毁
  • 不能销毁一个已经加锁的互斥量
  • 已经销毁的互斥量要确保在后面不会有线程再尝试加锁。

3)加锁

int pthread_mutex_lock(pthread_mutex_t *mutex);

参数说明:

  • mutex:要加锁的互斥量。
  • 返回值:成功返回0,失败返回错误码。

注意:

  • 互斥量处于没有加锁的状态下,该函数被把互斥量锁定,同时返回0
  • 发起函数调用时,如果其他线程已经锁定,或者存在其他线程同时申请互斥量,但没有竞争到锁,那么该函数回阻塞,等待互斥量被解锁。

4)解锁

int pthread_mutex_unlock(pthread_mutex_t *mutex);

参数说明:

  • mutex:要解锁的互斥量
  • 成功返回0,失败返回错误码。

5)改造抢票实验

当我们了解了互斥锁的接口,现在我们来改造下刚刚写的实验。

//加锁
int tickets = 1000;
pthread_mutex_t mutex;

void* TicketsGrabbing(void* arg)
{
    const char* name = (char*)arg;
    while(1)
    {
        //加锁
        pthread_mutex_lock(&mutex);
        if(tickets > 0)
        {
            //线程挂起等待10ms,usleep比sleep更精细,能有效利用cpu的时间
            usleep(10000);
            printf("[%s] get a ticket, left: %d\n", name, --tickets);
            pthread_mutex_unlock(&mutex);
        
        }
        else{
            pthread_mutex_unlock(&mutex);
            break;
        }
    }
    printf("%s quit\n", name);
    pthread_exit((void*)0);
}

int main()
{
    pthread_mutex_init(&mutex, NULL);
    pthread_t t1, t2, t3, t4;
    pthread_create(&t1, NULL, TicketsGrabbing, "thread 1");
    pthread_create(&t2, NULL, TicketsGrabbing, "thread 2");
    pthread_create(&t3, NULL, TicketsGrabbing, "thread 3");
    pthread_create(&t4, NULL, TicketsGrabbing, "thread 4");

    //线程等待,不等待的话会导致资源无法释放
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);
    pthread_mutex_destroy(&mutex);
    return 0;
}

image-20231206221212821

现在我们的票数就不会变成负数了,感兴趣的可以运行下以上两段代码,大家会发现,加锁以后的程序会变得慢得多。

注意:

  • 绝大部分情况下,加锁都是很损耗性能的事,它让多线程中并行执行变成了串行,这难以避免

  • 为了减少加锁带来的性能消耗,我们需要在适当的位置加锁和解锁

4.互斥量原理

1)加锁以后的原子性体现在哪里

我们之前说过,单独“--tickets”是不具备原子性的,那我们加锁以后的原子性体现在哪里?

引入互斥量以后,当一个进程申请锁进入临界区时,在其他线程看来只有两个状态,要么是没有申请锁,要么是锁已经释放了,只有这两种状态对其他线程才是有意义的,比如:线程1申请了锁进入了临界区,线程2、3、4检测到这种状态就会因为没有锁而进入阻塞状态。

从这个视角上看,对于线程2、3、4来说,线程1这种操作就是原子性的。

2)临界区的线程可能被线程切换吗?

当然可以,但是对于其他线程来说,此时的临界区依旧是不可访问的,因为锁没有被释放,其他线程也就没办法申请锁,即无法访问。

3)锁是不是临界资源?

我们说过,多线程之间共享的资源叫做临界资源,锁也是共享的,那是不是意为着锁也是临界资源,也需要保护?

是的,锁同样是一种临界资源,也同样需要被保护,那谁保护锁呢?

答案是锁自己保护自己。

4)认识锁的原子性

  • 类似“--”和“++”的操作,都不是原子性操作,会导致数据的不一致,因此为了实现互斥锁原理,OS一般提供了swap或exchange指令,该指令的作用就是把寄存器和内存单元的数据相互交换。
  • 由于只有一条指令,所以即使在多处理器平台,访问内存的总线周期也有先后,一个处理器上要执行exchange,另一个处理器只能等待总线周期。
  • 什么是总线周期:简单地说,在OS中,CPU和内存这两个硬件要进行交互是用线链接起来的,这个线就叫做总线。总线只有一套,被所有操作总类共享,而总线是通过总线周期来分别当前传输的是哪种资源。(同样地,把内存和外设链接起来的线就做IO总线)

上图是锁原子性的一个简单模拟,当线程1来申请锁,那么a1(一个寄存器)就和和内存里的mutex的值交换,此时线程1的mutex = 1,即拥有了锁,而后续线程的寄存器再来交换的时候,它交换后的mutex还是0,即没有锁,还是无法访问临界区。

线程1得到锁的后续:

  • 当他执行完任务后,会把1放回内存,使得下一个线程可以申请锁。
  • 唤醒等待mutex的线程,因为我们说过,当有线程进入临界区,其他想要进入临界区的线程只能阻塞等待,此时就需要唤醒这些因为竞争失败而被挂起的线程,让它们继续竞争。

5)总结锁的原子性

申请锁的本质其实就是哪个线程先执行了交换指令。一个交换指令,就是一个汇编,对一个线程来说,要么执行了交换,要么没执行,这个过程就是原子性的。CPU内的寄存器是不会被所有线程共享的,不过每个线程都要一组自己的寄存器,同时内存中的数据是共享的,申请锁就是把内存中的mutex原子性地交换到自己的寄存器里。

5.认识线程安全

1)线程安全的概念

多个线程执行同一段代码时,不会出现不同的结果。通常对于全局变量或者静态变量进行操作,在没有锁的情况下,就很容易出现线程安全问题。

2)可重入的概念

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

即可重入函数是安全的,不可重入函数是相对不安全的。

3)常见线程不安全的类型

  • 不保护共享变量的函数。

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

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

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

4)常见线程安全的类型

  • 每个线程对全局变量只有读权限,没有写权限

  • 类或者函数接口对于线程来说,都是原子性操作

  • 多个线程之间的切换不会导致执行结果的二义性

5)不可重入的类型

  • 调用了malloc/free函数(malloc通过全局链表管理)
  • 调用了标准I/O库函数(使用了全局数据结构)
  • 使用了静态的数据结构

6)可重入的类型

  • 不使用,不返回全局或者静态变量
  • 不适用malloc或者new开辟的空间
  • 不调用不可重入函数

7)可重入和线程安全的联系

  • 可重入函数是线程安全函数的一种
  • 线程安全不一定可重入,但可重入一定安全
  • 对临界资源上锁,则是线程安全的,但如果这个可重入函数的锁还没有释放,则此时是不可重入的

6.认识常见锁类型

在我们认识了为什么要有线程安全,线程安全是什么,互斥锁是什么,同样的,OS也不会只设计互斥锁,接下来我们认识下常见的锁类型(除了互斥锁)。

6.1死锁

6.1.1死锁的概念

一个进程集合中的每一个进程,都在等待该集合中其他进程才能引发的事件(通常是资源),那么该进程集合就是死锁。

也可以换个通俗的说法,即一个进程集合里的所有进程都占有着不可抢占资源,同时竞争着已经被其他进程占用的不可抢占资源而导致的一种永久等待状态。

  • 可抢占资源:可以从拥有它的进程中抢占,但是没有副作用
  • 不可抢占资源:在不引发相关计算失败的前提下,无法把它从占有它的进程中抢占过来。

6.1.2死锁的条件

  • 1)互斥:一个资源要么是已经分配的,要么是可用的
  • 2)占有和等待:已经得到了某个资源的进程可以再请求新的资源
  • 3)不可抢占:已经分配给一个进程的的资源不能被强制性地抢占,只能被已经占有它的进程显式释放
  • 4)环路等待:发生死锁时,一定有两个或两个以上的进程组成一条环路,该环路里的每个进程都在等待下一个进程所占有的资源。

接下来,我们做一个实验,简单模拟死锁,感受下:

//简单地模拟死锁
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<pthread.h>

pthread_mutex_t mutex;

void* Routine(void* arg)
{
    pthread_mutex_lock(&mutex);
    pthread_mutex_lock(&mutex);

    pthread_exit((void*)0);
}

int main()
{
    pthread_t t1, t2;
    pthread_mutex_init(&mutex, NULL);
    pthread_create(&t1, NULL, Routine, "thread 1");
    pthread_create(&t2, NULL, Routine, "thread 2");

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);

    pthread_mutex_destroy(&mutex);
    return 0;
}

image-20231207205700411

我们可以看到,当我们运行起来时,整个进程下的线程都处于Sl+状态,S在进程中表示挂起,l就表示lock,即处于死锁状态(且该实验是完全满足死锁的四个条件的)。

6.1.3避免死锁和死锁检测

我们既然知道了死锁的条件,同样的,避免死锁也就是怎么破坏条件了。

  • 破坏死锁的4个条件
  • 加锁顺序保持一致
  • 避免锁未释放
  • 资源一次性分配
  • 鸵鸟算法:即发生了死锁,假装没看见,因为解决死锁的代价很大,因此在死锁不太影响性能,或者发生死锁的概率很低时,可以采用鸵鸟算法--不处理。
  • 银行家算法:即一个银行家,对一群客户承诺了一定的贷款额度,算法就是对请求的满足是否会进入不安全状态,是就拒绝,不是就分配资源。

死锁检测(对于死锁检测,稍微了解下就行):每种类型一个资源的死锁检测--检测有向图中是否存在环,即能否拓扑排序,有环就是存在死锁,没有就是不存在死锁。(简单了解)

6.1.4死锁的恢复

  • 利用抢占恢复:将进程挂起,强行夺走资源给另一个进程使用,用完再给他放回去
  • 利用回滚恢复:复位到更早的状态,那时进程还没有获得资源
  • 通过杀死进程恢复:简单粗暴,杀掉环中的一个或多个进程,牺牲掉一个环外进程。

6.2自旋锁

6.2.1自旋锁概念

自旋锁和互斥锁类似,都是为了保护共享资源而提出的一种锁机制,用于解决对某项资源的互斥占用。无论是互斥锁还是自旋锁,在任何时刻,都只能有一个持有者,但是在调度机制上略有不同。

对于互斥锁,如果资源被占用,那么其他申请者只能进入阻塞状态,但是自旋锁不会引起调用者睡眠,如果自旋锁已经被其他线程拿走,那么调用者就会一直循环观察自旋锁的持有者是否释放了锁,故名为“自选”。

6.2.2自旋锁一般原理(简单讲解)

自旋锁的原理和互斥锁类似,当有线程想访问被保护的临界资源,需要先得到锁,访问完以后必须释放。在获取自旋锁的时候,如果没有持有者,就会立马获得锁,反之会一直自旋。

其实,从这我们可以看出,自旋锁属于比较低级的保护临界资源的情况,除了会引发死锁以外,它也会过多占用CPU资源。

刚刚我们说过,自旋锁的缺点是过多占用CPU,而同样的,它的优点是效率极高,因为它时刻监视着锁的情况,因此自旋锁更使用在保持非常短的场景下使用

6.2.3自旋锁接口

1)初始化

spinlock_t my_lock = SPIN_LOCK_UNLOCKED; 	//静态分配
void spin_lock_init(spinlock_t *lock); 		//动态分配

2)加锁和解锁


void spin_lock(spinlock_t *lock);                                   //对指定锁加锁
void spin_unlock(spinlock_t *lock);                                 //释放指定的锁

3)销毁

int pthread_spin_destroy(pthread_spinlock_t *lock);
//以上函数的返回值,成功返回0,失败返回错误码

6.3读写锁

6.3.1读写锁概念

在我们使用mysql或者sqlserver等数据库时,对数据的读写时很频繁的,同时也不可能只有一个线程在读写,因此诸如此类的情况下,读写权限也是要保护的临界资源,读写锁就比较适合解决这种问题。

在对数据的读写操作中,更多的是读操作,写操作比较少,为了满足多个线程的读出,单一线程的写入需求,线程提供了读写锁实现。

6.3.2读写锁规则

读写锁主要分为读锁和写锁,规则如下:

  • 1.如果有线程申请了读锁(即读操作),则允许其他线程申请读锁,但不允许申请写锁。

  • 2.如果有其他线程申请写锁,则其他线程都不允许申请读锁和写锁。

6.3.3读写锁接口

1)初始化

#include <pthread.h>
//动态分配
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,
const pthread_rwlockattr_t *restrict attr);

//静态分配
* pthread_rwlock_t my_rwlock = PTHREAD_RWLOCK_INITIALIZER;

//参数上和互斥锁类似,rwlock用来初始化要指向的读写锁
//attr初始化读写锁属性,NULL表示默认属性

2)销毁读写锁

#include <pthread.h>
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);

3)读锁定

#include <pthread.h>
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);

//参数说明:1.以阻塞的方式获取读锁(读锁定)。
//2.没有写者持有该锁,且没有写者在该锁阻塞,则申请者立马获得读锁
//3.与互斥锁一致,申请者没能获得读锁,就一直阻塞,直到获得锁,但是一个线程可以多次执行写锁定
//4.可以调用n次该函数,但是后续必须调用n次对应的unlock函数解锁

int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock)//尝试以非阻塞方法获得读锁,一旦有任何人持有读锁或者在该锁阻塞,则立即返回失败

4)写锁定

int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);

int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);

//参数说明参考读锁定

5)解锁

#include<pthread.h>

int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);

//参数说明:无论读锁还是写锁,都可以通过该函数解锁,
//以上函数,成功返回0,失败返回错误码