一、线程互斥
我们把多个线程能够看到的资源叫做共享资源,我们对共享资源进行保护,就是互斥
1.多线程访问问题
【示例】见一见多线程访问问题,下面是一个抢票的代码,共计票数10000张,4个线程去抢
之前我们展示过封装代码,这里我们直接使用
#include <iostream>
#include <vector>
#include <cstdio>
#include <unistd.h>
#include "mythread.hpp"
using namespace TreadMoudle;
int tickets = 10000;
void route(const std::string &name)
{
while (true)
{
if (tickets > 0)
{
// 抢票过程
usleep(1000); // 1ms -> 抢票花费的时间
printf("who: %s, get a ticket: %d\n", name.c_str(), tickets);
tickets--;
}
else
{
break;
}
}
}
int main()
{
Thread t1("thread-1", route);
Thread t2("thread-2", route);
Thread t3("thread-3", route);
Thread t4("thread-4", route);
t1.Start();
t2.Start();
t3.Start();
t4.Start();
t1.Join();
t2.Join();
t3.Join();
t4.Join();
}
【测试结果】:四个线程均抢完票,并且等待成功,回收成功;但是我们却发现一个问题,票一共只有1w张,理应最后一个线程得到的票号是1,这里确出现了负数,这是什么原因?
【解释】:计算机的运算类型有算数运算和逻辑运算,并且是CPU中寄存器进行运算,在CPU内,寄存器只有一套,但是寄存器里面的数据,可以有多套;这些数据属于线程私有,看起来放在了一套公共的寄存器中,但是属于线程私有,当他被切换的时候,他要带走自己的数据!回来的时候会恢复;
-
我们平常所用的一条代码,在汇编层上可能会对应很多汇编语句,比如一个简单的 tickets-- ,就牵涉到至少三条指令:1.重读数据,2.--数据,3.写回数据;
-
因此在进入抢票的过程中,看似就几行代码,到了汇编层就是很多代码,CPU是会进行线程切换,这样就会发生数据不一至的问题,如何理解?我们看图
如何解决这种问题?加锁!!
2.认识锁和它的接口
pthread_mutex_lock
pthread_mutex_lock
是一个在多线程编程中用于锁定互斥量(mutex)的函数。以下是关于 pthread_mutex_lock
的详细说明:
函数原型:该函数的原型定义如下:
参数:pthread_mutex_t是互斥锁的类型,任何时刻,只允许一个线程进行资源访问
功能描述:当调用 pthread_mutex_lock
时,它将尝试锁定 mutex
参数指向的互斥量。如果这个互斥量当前没有被锁定,它将被锁定,并且调用该函数的线程将成为互斥量的所有者,函数会立即返回。如果互斥量已经被其他线程锁定,那么调用该函数的线程将会阻塞,直到互斥量被解锁。
互斥量的状态:互斥量有两种状态:未锁定(此时不被任何线程拥有)和锁定(此时被一个线程拥有)。一个互斥量不能同时被两个不同的线程所拥有。如果一个线程尝试锁定一个已经被其他线程锁定的互斥量,它将会等待,直到那个线程解锁互斥量。
返回值:如果函数执行成功,返回值为 0。如果发生错误,例如尝试重新锁定已经被同一个线程锁定的互斥量,函数将返回一个错误码。
pthread_mutex_t
pthread_mutex_t
是 POSIX 线程(通常称为 pthreads)API 中定义的一个数据类型,用于表示互斥量(mutex)。互斥量是一种同步机制,用于防止多个线程同时访问共享资源,从而避免竞态条件。
定义:
注意,pthread_mutex_t
是一个不透明的数据类型,其内部结构对用户是隐藏的。用户不应该尝试直接访问或修改这个结构体的内容。
初始化: 在使用 pthread_mutex_t
之前,必须对其进行初始化。互斥量可以通过以下几种方式进行初始化:
静态初始化(锁是全局的或者静态的):可以在声明时直接使用宏 PTHREAD_MUTEX_INITIALIZER
进行初始化。
动态初始化:使用 pthread_mutex_init
函数进行初始化。
其中 attr
是一个指向 pthread_mutexattr_t
结构的指针,该结构用于设置互斥量的属性。如果 attr
为 NULL
,则互斥量将使用默认属性。
销毁: 当不再需要互斥量时,应该使用 pthread_mutex_destroy
函数来释放它所占用的资源。
在销毁一个互斥量之前,必须确保没有线程正在等待或持有该互斥量。
锁定与解锁:
-
使用
pthread_mutex_lock
尝试锁定互斥量。如果互斥量已被锁定,调用线程将阻塞直到互斥量被解锁。 -
使用
pthread_mutex_trylock
尝试锁定互斥量,但不会阻塞;如果互斥量已被锁定,则立即返回一个错误码。 -
使用
pthread_mutex_unlock
解锁互斥量。
属性: 互斥量可以有不同的属性,如类型(普通、递归、错误检查等),这些属性可以通过 pthread_mutexattr_t
结构来设置。
错误处理: 所有与互斥量相关的函数在出错时都会返回错误码,可以通过 strerror
函数或 perror
函数来获取错误信息。
pthread_mutex_lock / _unlock
pthread_mutex_lock
是 POSIX 线程(pthreads)库中的一个函数,用于锁定一个互斥量(mutex)。当一个线程调用 pthread_mutex_lock
尝试锁定一个互斥量时,以下情况可能会发生:
❍ 如果互斥量当前是未锁定的状态,调用线程会成功锁定该互斥量,并继续执行。
❍ 如果互斥量已经被另一个线程锁定,调用线程将会阻塞,直到该互斥量被解锁。
pthread_mutex_unlock
函数,这是一个 POSIX 线程(pthreads)库中的函数,用于解锁一个互斥量(mutex)。当一个线程完成了对临界区的访问后,它应该解锁互斥量,以便其他线程可以锁定并访问该临界区。
pthread_mutex_trylock
pthread_mutex_trylock
是 POSIX 线程(pthreads)库中的一个函数,用于尝试锁定一个互斥量(mutex),但它与 pthread_mutex_lock
的主要区别在于,如果互斥量已经被锁定,pthread_mutex_trylock
不会阻塞调用线程,而是立即返回一个错误码。
返回值:
-
成功时(互斥量被成功锁定),返回 0。
-
如果互斥量已经被锁定,返回
EBUSY
。 -
出现其他错误时,返回其他错误编号。
使用场景:
-
非阻塞互斥量锁定:当线程不希望因等待互斥量而阻塞时,可以使用
pthread_mutex_trylock
。 -
避免死锁:通过尝试锁定互斥量,线程可以决定是否继续执行或采取其他操作,从而避免死锁。
-
优先级继承:在某些实时系统中,为了避免优先级反转,可以使用
pthread_mutex_trylock
来尝试锁定互斥量。
注意事项
-
错误处理:调用
pthread_mutex_trylock
时,应检查返回值,并根据返回值做出相应的处理。 -
资源释放:如果
pthread_mutex_trylock
返回EBUSY
,线程应该释放已经持有的资源,避免资源泄露。 -
重试策略:通常在使用
pthread_mutex_trylock
时,如果返回EBUSY
,线程可能会在一段时间后重试锁定。
学会锁的基本使用后我们就可以修改我们自己实现的多线程,并且重新进行抢票
mythread.hpp
#pragma once
#include <iostream>
#include <string>
#include <pthread.h>
namespace ThreadMoudle
{
class ThreadData
{
public:
ThreadData(const std::string &name, pthread_mutex_t *lock):_name(name), _lock(lock)
{}
public:
std::string _name;
pthread_mutex_t *_lock;
};
// 线程要执行的方法,后面我们随时调整
typedef void (*func_t)(ThreadData *td); // 函数指针类型
class Thread
{
public:
void Excute()
{
std::cout << _name << " is running" << std::endl;
_isrunning = true;
_func(_td);
_isrunning = false;
}
public:
Thread(const std::string &name, func_t func, ThreadData *td):_name(name), _func(func), _td(td)
{
std::cout << "create " << name << " done" << std::endl;
}
static void *ThreadRoutine(void *args) // 新线程都会执行该方法!
{
Thread *self = static_cast<Thread*>(args); // 获得了当前对象
self->Excute();
return nullptr;
}
bool Start()
{
int n = ::pthread_create(&_tid, nullptr, ThreadRoutine, this);
if(n != 0) return false;
return true;
}
std::string Status()
{
if(_isrunning) return "running";
else return "sleep";
}
void Stop()
{
if(_isrunning)
{
::pthread_cancel(_tid);
_isrunning = false;
std::cout << _name << " Stop" << std::endl;
}
}
void Join()
{
::pthread_join(_tid, nullptr);
std::cout << _name << " Joined" << std::endl;
delete _td;
}
std::string Name()
{
return _name;
}
~Thread()
{
}
private:
std::string _name;
pthread_t _tid;
bool _isrunning;
func_t _func; // 线程要执行的回调函数
ThreadData *_td;
};
} // namespace ThreadModle
main.cc
#include <iostream>
#include <vector>
#include <cstdio>
#include <unistd.h>
#include "mythread.hpp"
using namespace ThreadMoudle;
int tickets = 10000; // 共享资源,造成了数据不一致的问题
pthread_mutex_t gmutex = PTHREAD_MUTEX_INITIALIZER;
static int threadnum = 4;
void route(ThreadData *td)
{
// std::cout <<td->_name << ": " << "mutex address: " << td->_lock << std::endl;
// sleep(1);
while (true)
{
pthread_mutex_lock(td->_lock);
if (tickets > 0)
{
// 抢票过程
usleep(1000); // 1ms -> 抢票花费的时间
printf("who: %s, get a ticket: %d\n", td->_name.c_str(), tickets);
tickets--;
pthread_mutex_unlock(td->_lock);
}
else
{
pthread_mutex_unlock(td->_lock);
break;
}
}
}
int main()
{
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, nullptr);
std::vector<Thread> threads;
for (int i = 0; i < threadnum; i++)
{
std::string name = "thread-" + std::to_string(i + 1);
ThreadData *td = new ThreadData(name, &mutex);
threads.emplace_back(name, route, td);
}
for (auto &thread : threads)
{
thread.Start();
}
for (auto &thread : threads)
{
thread.Join();
}
pthread_mutex_destroy(&mutex);
return 0;
}
【结果】:此时重新执行程序就不会出现数据不一致问题了
所谓的对临界资源进行保护,本质是对临界区代码进行保护
我们对所有资源进行访问,本质都是通过代码进行访问的!保护资源,本质就是把访问资源的代码保护起来
3.解决历史问题
-
所以,加锁的范围,粒度一定要尽量小
-
任何线程,要进行抢票,都得先申请锁,原则上不应该有例外
-
所有线程申请锁,前提是所有的线程都得看到这把锁,锁本身也是共享资源----加锁的过程,必须是原子的
-
原子性:要么不做,要做就做完,没有中间状态,就是原子性
-
如果线程申请锁失败了,我的线程要被阻塞
-
如果线程申请锁成功了,继续向后运行
-
如果线程申请锁成功了,执行临界区的代码了,执行临界区代码期间是可以发生切换的(比如时间片到了),但是即使切换了,其他线程无法进入!因为我虽然被切换了,但是我没有释放锁,我可以放心的执行完毕,没有人能打扰我
结论:所以对于其他线程,要么我没有申请锁,要么我释放了锁,对其他线程才有意义
4.原理角度理解这个锁
5.从实现角度理解锁
✸ 经过上面的例子,大家已经意识到单纯的i++或者++i 都不是原子的,有可能会有数据一致性问题
✸ 为了实现互斥锁的操作,大多数体系结构都提供了 swap 或 exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台访问内存的,总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
◉ CPU的寄存器只有一套,被所有的线程共享。但是寄存器里面的数据,属于执行流的上下文,属于执行流私有的数据
◉ CPU在执行代码的时候,一定要有对应的执行载体 -- 线程 && 进程
◉ 数据在内存中,被所有线程是共享的
结论:把数据从内存移动到CPU寄存器中,本质是把数据从共享,变成线程私有