【Linux】基础:线程的同步与互斥

【Linux】基础:线程的同步与互斥

摘要:本文主要介绍线程的同步与互斥方面的内容,分为理论与实现两部分完成。首先从整体上介绍线程同步与互斥相关概念,在理解概念后对两者分开介绍。在互斥方面,主要介绍内容为互斥量的接口与实现原理,并引申为死锁和线程安全等拓展相关内容,在线程同步方面,主要介绍了条件变量与信号量的接口与实现,由于该部分篇目较大,对于同步与互斥的应用将另起一文,主要介绍为生产者消费者模型、线程池和读者写者问题。


一、相关概念

进程通信一文中,对相关概念进行了介绍,在本文中,再进行介绍一次:

  • 临界资源:方式被形成线程共享访问的资源都是临界资源,比如多线程/多进程数据打印到显示器中
  • 临界区:访问临界资源的代码,当然不是所有的代码都是访问临界资源的
  • 临界区保护:本质是对临界资源保护,通过互斥与同步的方式
  • 原子性:一件事要么做完,要么不做,不存在中间状态
  • 互斥:在任意时刻,只允许一个执行流访问某段代码(访问某部分资源),称之为互斥
  • 同步:一般而言,让访问临界资源的过程在安全的前提下(一般都是互斥和原子的),让访问资源具有一定的顺序性,具有合理性

二、线程互斥

2.1 概述

大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,即临界资源,可以通过数据的共享,完成线程之间的交互。多个线程并发的操作共享变量,会带来一些问题。此时可以通过使用互斥量来解决该问题,下面将会对其场景进行搭建,并介绍Linux下的各种接口。

2.2 场景模拟

在此通过一个抢票场景进行说明,如下代码为主线程创建五个子线程,并通过子线程抢占临界资源1000张票,观察抢票结果,代码示例如下:

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

using namespace std;

// 临界资源:1000张票
int tickets = 1000;
#define NUM 5

void* ThreadRoutine(void* args){
    int id = *(int*)args;
    delete (int*)args;

    while(true){
        if(tickets > 0){
            usleep(1000);
            cout<<"Thread NO."<< id << " get NO." << tickets << endl;
            tickets--;
        }
        else
            break;
    }
}

int main(){
    pthread_t tid[NUM];
    // 创建线程
    for(int i = 0; i < NUM; i++){
        int *id = new int(i);
        pthread_create(tid + i, nullptr , ThreadRoutine , (void*)id);
    }
    // 等待线程
    for(int i = 0; i < NUM; i++){
        pthread_join(tid[i],nullptr);
    }
    return 0;
}
......
Thread NO.2 get NO.1
Thread NO.0 get NO.0
Thread NO.3 get NO.-1
Thread NO.4 get NO.-2
Thread NO.1 get NO.-3

2.3 问题分析

可以发现无法的到正确的结果,实际上就是由于各个线程对于临界资源的错误访问导致的,在以上程序中主要由于线程在CPU中执行时是会设置时间片的,在判断成功后可能会发生线程的切换,在usleep()中是需要时间的,在业务过程中,有许多进程已经进入该代码段。而由于ticket--操作不是一个原子操作,因此是不安全的。

为此对于ticket--操作不是一个原子操作导致错误的过程进行解释,该语句转换为汇编语言可以将其转换为三个过程,分别为将ticket数据从内存写入到CPU的寄存器中,再将在寄存器中的数据进行减减操作,最后将寄存器的数据写回内存中。

可是在多线程的情况下,有线程AB两个线程。假设有线程A,在内存数据写入寄存器抢票后,时间片已到,发生线程切换,此时的ticket将在线程A得到数据结构中保持该上下文,即保存了线程A的ticket。此时将线程B竞争CPU成功,获取CPU资源,并不断进行着抢票操作,此时票数将会减少。可是当线程A的数据写入内存时,此时的ticket将会做出不一致的修改,这样就导致了售票的错误。示意图如下:

2.4 互斥量

因此,上述场景错误的原因就是在访问临界资源时,并没有进行原子访问,而在此介绍Linux中的互斥量,更具体为互斥锁,实现对临界资源的安全访问。

2.4.1 相关接口

互斥量初始化与销毁

头文件:

#include <pthread.h>

定义:

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

说明:

  • int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);函数是以动态方式创建互斥锁的,参数attr指定了新建互斥锁的属性。如果参数attr为NULL,则使用默认的互斥锁属性,默认属性为快速互斥锁 。互斥锁的属性在创建锁的时候指定,在LinuxThreads实现中仅有一个锁类型属性,不同的锁类型在试图对一个已经被锁定的互斥锁加锁时表现不同。
  • pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;POSIX定义了一个宏PTHREAD_MUTEX_INITIALIZER来静态初始化互斥锁。
  • 销毁互斥量需要注意:使用PTHREAD_ MUTEX_ INITIALIZER初始化的互斥量不需要销毁;不要销毁一个已经加锁的互斥量;已经销毁的互斥量,要确保后面不会有线程再尝试加锁。

互斥量加锁和解锁

头文件:#include <pthread.h>

定义:

int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

说明:

  • 调用 pthread_ lock 时,可能会遇到以下情况:互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。
  • 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
2.4.2 代码优化

通过互斥量,将抢票过程变成原子的过程,保证了线程的安全,同时使得访问临界资源时互斥的,程序逻辑示意图如下:

#include <iostream>
#include <pthread.h>
#include <unistd.h>
// 售票类
class Ticket{
private:
    int ticketNum;
    pthread_mutex_t mtx;
public:
    // 构造与析构函数
    Ticket():ticketNum(1000){
        // 互斥量初始化
        pthread_mutex_init(&mtx,nullptr);
    }

    ~Ticket(){
        // 互斥量销毁
        pthread_mutex_destroy(&mtx);
    }
    // 抢票
    bool GetTicket(){
        bool ret = true;
        // 加锁
        pthread_mutex_lock(&mtx);
        if(ticketNum > 0){
            usleep(1000);
            std::cout << "New Thread TIC:" << pthread_self() << " get NO." << ticketNum << " ticket"<<std::endl;
            ticketNum--;
        }
        else{
            std::cout<< "Ticket Empey" <<std::endl;
            ret = false;
        }
        // 解锁
        pthread_mutex_unlock(&mtx);
        return ret;
    }
};

void* ThreadRoutine(void *args){
    Ticket *t = (Ticket*)args;
    while(true){
        if(!t->GetTicket()){
            break;
        }
    }
}

int main(){
    Ticket *t = new Ticket();

    pthread_t tid[5];

    for(int i = 0;i < 5;i++){
        pthread_create(tid + i,nullptr,ThreadRoutine,(void*)t);
    }

    for(int i = 0;i < 5;i++){
        pthread_join(tid[i],nullptr);    
    }
    return 0;
}
New Thread TIC:140305938736896 get NO.4 ticket
New Thread TIC:140305938736896 get NO.3 ticket
New Thread TIC:140305938736896 get NO.2 ticket
New Thread TIC:140305938736896 get NO.1 ticket
Ticket Empey
Ticket Empey
Ticket Empey
Ticket Empey
Ticket Empey

在C++11也内置了mutex互斥量,在此不过多介绍,通过代码进行实例,有兴趣可以自行查阅资料,示例如下:

#include <iostream>
#include <pthread.h>
#include <unistd.h>
// C++ 11 mutex
#include <mutex>

// 售票类
class Ticket{
private:
    int ticketNum;
    // pthread_mutex_t mtx;
    std::mutex mtx;
public:
    // 构造与析构函数
    Ticket():ticketNum(1000){
        // 互斥量初始化
        // pthread_mutex_init(&mtx,nullptr);
    }

    ~Ticket(){
        // 互斥量销毁
        // pthread_mutex_destroy(&mtx);
    }
    // 抢票
    bool GetTicket(){
        bool ret = true;
        // 加锁
        // pthread_mutex_lock(&mtx);
        mtx.lock();
        if(ticketNum > 0){
            usleep(1000);
            std::cout << "New Thread TIC:" << pthread_self() << " get NO." << ticketNum << " ticket"<<std::endl;
            ticketNum--;
        }
        else{
            std::cout<< "Ticket Empey" <<std::endl;
            ret = false;
        }
        // 解锁
        // pthread_mutex_unlock(&mtx);
        mtx.unlock();
        return ret;
    }
};

void* ThreadRoutine(void *args){
    Ticket *t = (Ticket*)args;
    while(true){
        if(!t->GetTicket()){
            break;
        }
    }
}

int main(){
    Ticket *t = new Ticket();

    pthread_t tid[5];

    for(int i = 0;i < 5;i++){
        pthread_create(tid + i,nullptr,ThreadRoutine,(void*)t);
    }

    for(int i = 0;i < 5;i++){
        pthread_join(tid[i],nullptr);    
    }
    return 0;
}
2.4.3 互斥量原理

在上述代码中,访问了临界资源的ticket时,首先需要访问互斥量,前提是需要全部线程看见该互斥量,但是互斥量本身是否是临界资源呢,本身是否是安全的呢,实际锁的上锁和解锁都是原子的,为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性。即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。

以下通过相应的汇编伪代码进行说明:

lock:
	movb $0 %al
	xchgb %al ,mutex
	if(al寄存器的数据>0){
		return 0;
	}
	else{
		挂起等待;
	}
	goto lock;
unlock:
	movb $1 mutex
	唤醒等待Mutex的线程;
	return 0;

在CPU执行线程代码时,CPU内寄存器的数据,是线程私有的,为执行流的上下文数据。因此当互斥锁未上锁时,可以有线程将0写入对应CPU寄存器并与互斥锁寄存器进行交换,当线程时间片到时,线程保持上下文,同时将原来寄存器的互斥锁也会被该线程抱走,而此时CPU在寄存器的数据为0,因此其他线程必须挂起等待。

当发生解锁时,将原来mutex的值复原,让其它进程唤醒并竞争锁。

为此,互斥量的本质是通过一条汇编代码,将锁交换到直接的上下文中

示意图如下:

2.5 线程安全与可重入函数

2.5.1 线程安全

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

常见的线程不安全的情况

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

常见的线程安全的情况

  • 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
  • 类或者接口对于线程来说都是原子操作
  • 多个线程之间的切换不会导致该接口的执行结果存在二义性
2.5.2 可重入函数

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

常见不可重入的情况

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

常见可重入的情况

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

可重入与线程安全联系

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

可重入与线程安全区别

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

2.6 死锁

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

死锁四个必要条件

  • 互斥条件:一个资源每次只能被一个执行流使用
  • 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
  • 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
  • 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系

避免死锁

  • 破坏死锁的四个必要条件
  • 加锁顺序一致
  • 避免锁未释放的场景
  • 资源一次性分配

避免死锁算法

  • 死锁检测算法(了解)
  • 银行家算法(了解)

三、线程同步

同步,一般而言,让访问临界资源的过程在安全的前提下(一般都是互斥和原子的),让访问资源具有一定的顺序性,具有合理性。本文将会介绍两种主要方式,分别为条件变量与信号量机制。

3.1 条件变量

一般而言,在只有锁的情况下,是比较难察觉临界资源的状态,当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,无法继续进行,例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中,此时需要用到条件变量来完成线程的同步机制。

3.1.1 相应接口

初始化与销毁

头文件:#include <pthread.h>

定义:

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

说明:

  • pthread_cond_init:初始化一个条件变量,当参数cattr为空指针时,函数创建的是一个缺省的条件变量。否则条件变量的属性将由cattr中的属性值来决定。调用 pthread_cond_init函数时,参数cattr为空指针等价于cattr中的属性为缺省属性,只是前者不需要cattr所占用的内存开销。可以用宏PTHREAD_COND_INITIALIZER来初始化静态定义的条件变量,使其具有缺省属性。
  • pthread_cond_destroy:条件变量的销毁

等待

头文件:#include <pthread.h>

定义:int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);

参数:

  • cond:要在该条件变量上进行等待
  • mutex:互斥量,当进行等待时需要进行释放互斥量,当被唤醒是重新竞争互斥量

唤醒

头文件:#include <pthread.h>

定义:

int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);

说明:

  • pthread_cond_signal:发送一个信号给另外一个正在处于阻塞等待状态的线程,使其脱离阻塞状态,继续执行.如果没有线程处在阻塞等待状态,pthread_cond_signal也会成功返回。
  • pthread_cond_broadcast():函数会将所有等待该条件变量的线程解锁而不是仅仅解锁一个线程
3.1.2 示例

以下通过一个线程控制其他线程启停的示例,创建一个主线程以及三个子线程,主线程通过条件变量进行对三个子线程进行唤醒,让其进行工作,代码示例如下:

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

using namespace std;
#define NUM 3

pthread_mutex_t mtx;
pthread_cond_t cond;

void *ctrl(void* args){
    string name = (char*) args;
    while(true){
        // 唤醒
        cout << "Master Pthread ----> Worker work" << endl;
        pthread_cond_signal(&cond);
        sleep(1);
    }
}

void *work(void* args){
    // 工作与等待
    int number = *(int *)args;
    delete (int*)args;
    while(true){
        pthread_cond_wait(&cond,&mtx);
        cout << "NO." << number <<" Worker" << " is working" <<endl;
    }
}

int main(){
    pthread_t master;
    pthread_t worker[NUM];

    pthread_mutex_init(&mtx,nullptr);
    pthread_cond_init(&cond,nullptr);

    // 线程创建
    pthread_create(&master, nullptr,ctrl,(void *)"master");
    for(int i = 0; i < NUM ;i++){
        int *id = new int(i);
        pthread_create(worker + i, nullptr,work,(void *)id);
    }

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

    pthread_mutex_destroy(&mtx);
    pthread_cond_destroy(&cond);

    return 0;
}
[root@VM-12-7-centos Blog_cond]# ./test_cond 
Master Pthread ----> Worker work
NO.1 Worker is working
Master Pthread ----> Worker work
NO.2 Worker is working
Master Pthread ----> Worker work
NO.1 Worker is working
Master Pthread ----> Worker work
NO.0 Worker is working
Master Pthread ----> Worker work

3.2 信号量

3.2.1 概述

信号量的本质就是一种计数器,描述临界资源的大小,也可以认为是最多有多少资源可以分配给线程。可不仅如此,信号量还存在一种预定的功能,即临界资源可以划分为一个个小的资源,如果处理得当,可以让多个线程同时访问临界资源的不同区域,从而实现并发操作。也可以称为多线程预定资源的手。

对于信号量的操作可以分为P()V()操作,P操作为申请信号量操作,V操作为释放信号量,两者操作通过封装可以成为原子操作,伪代码实例如下:

// P()
start:
	lock();
	if(count <= 0){
		// 挂起
		unlock();
		goto start;
	}
	else{
		count--;
	}
	unlock();
// V()
start:
	lock();
	count++;
	unlock();
3.2.2 相关接口

头文件:#include <semaphore.h>

定义:

int sem_init(sem_t *sem, int pshared, unsigned int value);	// 初始化信号量
int sem_destroy(sem_t *sem);	// 信号量销毁
int sem_wait(sem_t *sem);	// 等待信号量
int sem_post(sem_t *sem);	// 发布信号量

说明:

  • sem_init:sem为指向信号量结构的一个指针,pshared不为0时此信号量在进程间共享,否则只能为当前进程的所有线程共享,value给出了信号量的初始值
  • sem_wait:等待信号量,会将信号量的值减1,相当于P()操作
  • sem_post:发布信号量,表示资源使用完毕,可以归还资源了,将信号量值加1,相当于V()操作

补充:

  1. 代码将会放到:Linux_Review: Linux博客与代码 (gitee.com) ,欢迎查看!
  2. 欢迎各位点赞、评论、收藏与关注,大家的支持是我更新的动力,我会继续不断地分享更多的知识!
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Fat one

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值