线程的互斥与同步
💫 概念引入
在并发编程中,临界资源、临界区和互斥是关键概念,用于处理多个线程或进程对共享资源的访问问题。
⭐️临界资源(Critical Resource):
== 临界资源是指在多线程或多进程环境中,被多个线程或进程共享的资源 ==,例如共享内存、全局变量、文件等。由于多个线程或进程可以同时访问临界资源,如果不加以控制,可能会导致数据的不一致或错误的结果。因此,需要通过临界区和互斥来保护临界资源的访问。
🌟临界区(Critical Section):
== 临界区是指访问临界资源的代码段或区域 ==,在临界区中,对临界资源的访问必须是原子的,即同一时间只能有一个线程或进程在执行临界区的代码。临界区的目的是保证在任意时刻只有一个线程或进程在访问共享的临界资源,从而避免竞争条件(Race Condition)和数据不一致。
✨互斥(Mutex):
互斥是一种用于保护临界资源的同步机制。互斥是一个信号量,它在任意时刻只能被一个线程或进程持有。当一个线程或进程进入临界区时,它会尝试获取互斥锁,如果互斥锁没有被其他线程或进程持有,那么该线程或进程将获得互斥锁,并进入临界区执行代码。其他线程或进程在尝试获取互斥锁时会被阻塞,直到互斥锁被释放。当线程或进程执行完临界区的代码后,会释放互斥锁,允许其他线程或进程获取互斥锁,进入临界区执行代码。
通过使用临界区和互斥,可以确保在任意时刻只有一个线程或进程在访问临界资源,从而避免了竞争条件和数据不一致问题。在多线程或多进程的并发编程中,正确使用临界区和互斥是确保程序正确运行的重要手段。
⚡️结合代码看互斥
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <pthread.h>
#include<iostream>
#include <sys/syscall.h>
using namespace std;
int tickets=10000; //票数总量
//抢票逻辑
void *getTicket(void *args)
{
//用于将args转换为const char*类型,并将转换结果赋值给name变量。
const char *name = static_cast<const char *>(args);
while (true)
{
if(tickets>0)
{
usleep(1000);
cout << name << " 抢到了票, 票的编号: " << tickets << endl;
tickets--;
}
else
{
cout << name << "] 已经放弃抢票了,因为没有了..." << endl;
break;
}
}
}
int main()
{
pthread_t td1;
pthread_t td2;
pthread_t td3;
pthread_t td4;
//创建4个线程 执行抢票
pthread_create(&td1,nullptr,getTicket,(void*)"td1");
pthread_create(&td1,nullptr,getTicket,(void*)"td2");
pthread_create(&td1,nullptr,getTicket,(void*)"td3");
pthread_create(&td1,nullptr,getTicket,(void*)"td4");
pthread_join(td1,nullptr);
pthread_join(td2,nullptr);
pthread_join(td3,nullptr);
pthread_join(td4,nullptr);
return 0;
}
☄️ 代码逻辑
这段代码是一个简单的多线程程序,用于模拟多人抢票的场景。代码中使用了pthread库来创建和管理线程。
tickets是一个全局变量,表示票的总数量,初始值为10000。
getTicket函数是抢票的逻辑。每个线程执行此函数,表示一个人在抢票。在函数内部,通过循环不断判断是否还有票,如果有,则输出当前抢到的票的编号,并将票数减1;如果没有了,则输出该线程已经放弃抢票。
pthread_t是一个数据类型,表示线程的ID。
pthread_create函数用于创建线程。它接受四个参数:pthread_t *thread表示接收新创建线程的ID,const pthread_attr_t *attr表示线程的属性,void *(*start_routine) (void *)表示线程的入口函数,void *arg表示传递给入口函数的参数。
pthread_join函数用于等待线程结束。它接受两个参数:pthread_t thread表示要等待的线程ID,void **retval表示线程的返回值。在此代码中,使用nullptr表示不关心线程的返回值。
usleep函数用于让线程睡眠一段时间,模拟抢票过程中的延迟。
在main函数中,创建了4个线程,分别执行getTicket函数来模拟4个人同时抢票的情况。通过pthread_create函数创建线程,并通过pthread_join函数等待线程结束,确保所有线程都执行完毕后程序才结束。
💥运行结果
🔥分析
其实代码是存在Bug的
我们试想一下,== tickets-- == 这段代码执行需要几步?
在上一篇博客中有介绍到,线程的运行可能会产生临时数据放在内存,这里的tickets就是如此,我们知道执行–操作是要经过CPU的,那么 执行该代码最少需要3步:
- 把tickets拷贝到CPU
- CPU执行–操作
- 把–后的值放回给对象!
而在此多线程执行抢票的过程中,随时可能进行线程的切换(由OS调度的),那么就会出现下面的情况:
线程A在执行–操作 (还没执行完,假设此时A已经–到50张票了,还没把结果放回给对象),此时进行线程切换,B进来执行–操作,但是B并没有拿到最新的余票信息(A没有返回),此时B拿到的就是上一次的余票信息,那么就会出现 == 资源不一致 ==问题 ,该问题是很严重的!!!所以我们要有解决的措施!!!!
- 解决方案
保证临界区的原子性 可以通过加锁来实现
🌪 pthread_mutex_t线程互斥锁
pthread_mutex_t是一个线程互斥锁,用于实现线程间的互斥访问共享资源,防止多个线程同时访问造成数据竞争和错误。
互斥锁的作用是在多线程环境下保护临界区(共享资源),只允许一个线程访问临界区,其他线程必须等待互斥锁的释放。一旦一个线程获得了互斥锁,其他线程就无法同时获得该互斥锁,只能等待。
使用pthread_mutex_t需要以下几个步骤:
初始化互斥锁:在使用互斥锁之前,需要对其进行初始化。可以使用pthread_mutex_init函数来完成初始化,也可以使用静态初始化宏PTHREAD_MUTEX_INITIALIZER。
上锁:当一个线程需要访问临界区时,首先要尝试获取互斥锁,即上锁。如果互斥锁已被其他线程占用,则当前线程会阻塞,直到互斥锁被释放。
解锁:当一个线程完成对临界区的访问后,应该释放互斥锁,即解锁。这样其他线程就可以尝试获取互斥锁并访问临界区。
销毁互斥锁:在不再需要使用互斥锁时,应该将其销毁以释放系统资源。可以使用pthread_mutex_destroy函数来销毁互斥锁。
使用互斥锁可以有效地防止多线程访问共享资源时的竞态条件问题,保证程序的正确性和稳定性。但是要注意,滥用互斥锁可能导致线程之间的竞争和性能下降,因此在设计多线程程序时需要慎重考虑互斥锁的使用。
- 加锁后代码
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <pthread.h>
#include<iostream>
#include <sys/syscall.h>
using namespace std;
int tickets=10000; //票数总量
pthread_mutex_t mutex; //创建互斥锁
//抢票逻辑
void *getTicket(void *args)
{
//用于将args转换为const char*类型,并将转换结果赋值给name变量。
const char *name = static_cast<const char *>(args);
while (true)
{
pthread_mutex_lock(&mutex);//临界区加锁
if(tickets>0)
{
usleep(1000);
cout << name << " 抢到了票, 票的编号: " << tickets << endl;
tickets--;
pthread_mutex_unlock(&mutex); //解锁
}
else
{
cout << name << "] 已经放弃抢票了,因为没有了..." << endl;
break;
pthread_mutex_unlock(&mutex); //解锁
}
}
}
int main()
{
pthread_mutex_init(&mutex, nullptr); //初始化
pthread_t td1;
pthread_t td2;
pthread_t td3;
pthread_t td4;
//创建4个线程 执行抢票
pthread_create(&td1,nullptr,getTicket,(void*)"td1");
pthread_create(&td1,nullptr,getTicket,(void*)"td2");
pthread_create(&td1,nullptr,getTicket,(void*)"td3");
pthread_create(&td1,nullptr,getTicket,(void*)"td4");
pthread_join(td1,nullptr);
pthread_join(td2,nullptr);
pthread_join(td3,nullptr);
pthread_join(td4,nullptr);
pthread_mutex_destroy(&mutex);//销毁锁
return 0;
}
- 运行
这时候就解决了~
🌈 注意事项
- 临界区,只要对临界区加锁,而且加锁的粒度约细越好
- 加锁的本质是让线程执行临界区代码串行化
- 加锁是一套规范,通过临界区对临界资源进行访问的时候,要加就都要加
- 锁保护的是临界区, 任何线程执行临界区代码访问临界资源,都必须先申请锁,前提是都必须先看到锁!
- 这把锁,本身不就也是临界资源吗?锁的设计者早就想到了 pthread_mutex_lock: 竞争和申请锁的过程,就是原子的!
Q:为什么在if和else后面都要 执行: pthread_mutex_unlock(&mutex);来释放 直接放在循环的最后会有什么问题?
A:会造成其他在锁外的线程无线在阻塞等待 … 不解锁 不结束!
☀️如何理解加锁
== Q:在临界区(多行代码)加锁,就不是执行多行代码的概念了吗?如果是执行多行代码那是不是也就可以被别的线程切换呢? ==
A:在加锁之后执行的代码也完全可以被切换,因为线程执行的加锁解锁等操作对象也是代码,线程可以在任意的代码中进行切换,但是有一点:线程的加锁是原子性的!== 换句话说就是执行了加锁操作后只有两个结果:1.拿到了锁 2.没有拿到锁 ==
设置了互斥锁之后,每个线程进入临界区之前都必须申请锁,然后才能进入临界区,假设A申请了锁进入临界区,这时候B线程切进来,要申请锁,就申请不到,可以理解为,A线程切走的时候把锁带走了,B进来拿不到锁只能阻塞等待线程! 也就是说,一旦一个线程持有了锁,该线程根本不需要担心切换问题,因为对于线程而言,一个线程一旦申请了锁,访问临界区,只有没有进入和使用完毕两种状态,这就是锁的原子性!!!
- 看下面这张图就是锁的内核实现
当一个线程申请到锁之后,被切换,另外的线程进入就申请不到,al被设置成0(申请锁的底层交换 ,CPU和内存进行交互数据,把内存的al和CPU的al(0)交换) 此时就满足不了条件,就执行else语句,挂起等待!!!
代码改进 锁的使用
- Log.hpp
#pragma once
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <pthread.h>
#include<iostream>
#include <sys/syscall.h>
//锁
class Mutex
{
public:
Mutex()
{
//初始化
pthread_mutex_init(&lock_, nullptr);
}
void lock()
{
//加锁
pthread_mutex_lock(&lock_);
}
void unlock()
{
//解锁
pthread_mutex_unlock(&lock_);
}
~Mutex()
{
//析构 释放锁
pthread_mutex_destroy(&lock_);
}
private:
pthread_mutex_t lock_ ;
};
//看门警卫 维护锁
class LockGuard
{
public:
LockGuard(Mutex *mutex) : mutex_(mutex)
{
mutex_->lock();
std::cout << "加锁成功..." << std::endl;
}
~LockGuard()
{
mutex_->unlock();
std::cout << "解锁成功...." << std::endl;
}
private:
Mutex *mutex_;
};
Mutex 类:这是一个互斥锁的类,用于实现多线程间的互斥访问。在构造函数中,通过调用 pthread_mutex_init 函数进行初始化;在 lock() 函数中,通过调用 pthread_mutex_lock 函数进行加锁;在 unlock() 函数中,通过调用 pthread_mutex_unlock 函数进行解锁;在析构函数中,通过调用 pthread_mutex_destroy 函数进行锁的销毁。
LockGuard 类:这是一个锁保护器类,用于在其生命周期内自动加锁并在作用域结束时自动解锁。在构造函数中,通过传入 Mutex 对象的指针进行加锁,并输出加锁成功的信息;在析构函数中,自动解锁该锁,并输出解锁成功的信息。这样可以确保在使用 LockGuard 对象时,无需手动去调用加锁和解锁操作,从而避免了忘记解锁的问题。
在多线程编程中,使用互斥锁和锁保护器可以有效地保护共享资源,防止多个线程同时访问和修改,从而避免出现数据竞争和不确定的结果。
- mypthread.cc
#include"Log.hpp"
using namespace std;
// 票数
int tikets=10000;
Mutex mymutex;
void* GetTicket(void * args)
{
const char *name = static_cast<const char *>(args);
while (true)
{
LockGuard L(&mymutex); //调用警卫 创建对象自动执行 因为是个局部对象,出了函数作用就会自动释放(销毁锁)
if(tikets>0)
{
//执行抢票
cout << name << " 抢到了票, 票的编号: " << tikets << endl;
tikets--;
}
else{
//没票了
cout << name << " 没抢到票因为没票了 ...." << endl;
break;
}
}
}
int main()
{
pthread_t td1;
pthread_t td2;
pthread_t td3;
pthread_t td4;
pthread_create(&td1,nullptr,GetTicket,(void*)"td1");
pthread_create(&td2,nullptr,GetTicket,(void*)"td2");
pthread_create(&td3,nullptr,GetTicket,(void*)"td3");
pthread_create(&td4,nullptr,GetTicket,(void*)"td4");
pthread_join(td1, nullptr);
pthread_join(td2, nullptr);
pthread_join(td3, nullptr);
pthread_join(td4, nullptr);
return 0;
}
- Makefile
test:mypthread.cc
g++ -Wall -o test mypthread.cc -lpthread -std=c++11
.PHONY:clean
rm -f test
- 运行结果
⛅️总结
设置一个警卫类,解决手动加锁和解锁的操作,让代码使用更高效安全~
🌤常见线程问题
🌥可重入VS线程安全
概念
- 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
- 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
常见的线程不安全的情况
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
☁️常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
🌦常见不可重入的情况
调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
可重入函数体内使用了静态的数据结构
🌧常见可重入的情况
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
⛈可重入与线程安全联系
函数是可重入的,那就是线程安全的
函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
🌩可重入与线程安全区别
可重入函数是线程安全函数的一种
线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生
死锁,因此是不可重入的。
🌨 ❄️ ☃️ ⛄️ 🌬 💨 💧 💦 ☔️ ☂️ 🌊 🌫