文章目录
- Linux线程
- 4. 线程互斥
- 4.1 线程互斥的概念
- 4.2 锁的概念
- 4.2.1 互斥锁的概念
- 4.2.2 互斥锁的使用
- 4.2.3 死锁
- 4.2.4 可重入和线程安全
- 5. 线程同步
- 5.1 条件变量的概念
- 5.2 条件变量的使用
Linux线程
4. 线程互斥
我们之前使用了线程函数实现了多线程的简单计算模拟器。
可以看到多线程可以很好的运行并且计算得到我们想要的结果。
那我们照猫画虎一样,看看可不可以实现多线程模拟抢票的过程:
#include <iostream>
#include <vector>
#include <string>
#include <cstring>
#include <unistd.h>
#define NUM 5 //线程数量
int tickets=100; //全局变量作为剩余的票数
//线程执行的模拟抢票函数
void *getTickets(void *args)
{
uint64_t id=(uint64_t)args;
while(true)
{
if(tickets>0)
{
std::cout<<"thread id: "<<id<<" remaining tickets: "<<tickets--<<std::endl;
}
else
{
break;
}
}
return nullptr;
}
int main()
{
//使用多线程模拟抢票过程
std::vector<pthread_t> threads;
for(int i=1;i<=5;i++)
{
pthread_t tid;
pthread_create(&tid,nullptr,&getTickets,(void*)(i));
threads.push_back(tid);
}
//销毁我们创建的线程资源
for(auto e:threads)
{
pthread_join(e,nullptr);
}
return 0;
}
我们发现问题了,剩余的票数竟然出现了负数,在现实中,抢到 -1 张票显然是不实现的事情。多个线程同时访问和修改临界资源(票的数量)会出现数据不一致问题。
这里就可以引出和线程互斥相关的概念了:
临界资源:多线程执行流共享的资源就叫做临界资源。
临界区:每个线程内部,访问临界资源的代码,就叫做临界区。
互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
4.1 线程互斥的概念
概念:线程互斥指的是在同一时刻,只允许一个线程访问特定的资源或执行特定的代码段,以避免多个线程同时操作导致的数据不一致、资源竞争等问题。
目的:确保线程在访问共享资源时的正确性和一致性。如果多个线程同时对共享资源进行读写操作,可能会出现不可预测的结果,例如数据被破坏、计算错误等。
实现方式:通常通过互斥锁(Mutex)、信号量(Semaphore)等机制来实现线程互斥。 以互斥锁为例,当一个线程获取到互斥锁后,其他试图获取该锁的线程会被阻塞,直到持有锁的线程释放锁。
大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
多个线程并发的操作共享变量,会带来一些问题。
回到上面的抢票代码出错的原因,是由于多线程同时并发操作共享资源,导致的数据错误,怎么解决这个问题?我们要引出锁这个概念了。
4.2 锁的概念
锁是用于控制对共享资源访问的机制,以确保多线程或多进程环境下数据的一致性和正确性。
锁的主要作用是防止多个线程或进程同时对共享资源进行读写操作,从而避免数据竞争、不一致性和错误的结果。
Linux 中的锁可以分为以下几种类型:
互斥锁(Mutex):确保在同一时刻只有一个线程或进程能够访问被保护的资源。
读写锁(Read-Write Lock):分为读锁和写锁。允许多个读线程同时获取读锁来读取资源,但在获取写锁进行写入时,会阻塞其他的读锁和写锁请求。
自旋锁(Spin Lock):当一个线程试图获取自旋锁而该锁已被占用时,线程会一直循环检测锁是否被释放,而不是进入阻塞状态。适用于锁被持有的时间较短的情况,避免了线程切换的开销。
我们在下面使用互斥锁解决我们的问题。
4.2.1 互斥锁的概念
互斥锁(Mutex)属于锁的一种类型。
概念:互斥锁用于保护共享资源,确保在同一时刻只有一个线程能够访问被其保护的临界区。
工作原理:当一个线程想要访问受互斥量保护的资源时,它首先需要获取互斥量。如果此时互斥量未被其他线程持有,该线程成功获取并可以进入临界区进行操作。如果互斥量已被其他线程持有,那么当前线程将被阻塞,直到持有互斥量的线程释放它。
优点:提供了简单而有效的方式来避免多线程对共享资源的并发访问冲突。确保了共享资源在多线程环境下的一致性和正确性。
缺点:可能导致线程阻塞和上下文切换,从而影响性能。如果使用不当,可能会引起死锁等问题。
注意:锁是一个广义的概念,互斥量是一种特定的锁,它的主要在同一时刻只允许一个线程拥有访问权。
4.2.2 互斥锁的使用
初始化互斥量
初始化互斥量有两种方法:
方法1,静态分配:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
方法2,动态分配:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数:mutex:要初始化的互斥量 attr:NULL
销毁互斥量
int pthread_mutex_destroy(pthread_mutex_t *mutex);
销毁互斥量需要注意:
使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
不要销毁一个已经加锁的互斥量
已经销毁的互斥量,要确保后面不会有线程再尝试加锁
互斥量加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号
调用 pthread_ lock 时,可能会遇到以下情况:
互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁
这样我们就可以解决负票的情况了。
4.2.3 死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
死锁四个必要条件
(1)互斥条件: 一个资源每次只能被一个执行流使用
(2)请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
(3)不剥夺条件: 一个执行流已获得的资源,在末使用完之前,不能强行剥夺
(4)循环等待条件: 若干执行流之间形成一种头尾相接的循环等待资源的关系
避免死锁
(1)破坏死锁的四个必要条件
(2)加锁顺序一致
(3)避免锁未释放的场景
(4)资源一次性分配
避免死锁算法
死锁检测算法:
死锁检测算法通过分析资源分配情况来判断系统是否处于死锁状态。常见的基于资源分配图,若图中存在资源请求的循环等待,则判定为死锁。
例如,进程 P1 等待 P2 占用的资源,P2 等待 P3 占用的资源,P3 又等待 P1 占用的资源,形成循环等待,即死锁。
银行家算法:
银行家算法模拟银行资金分配,用于决定资源分配是否安全,以避免死锁。
系统有多种资源和多个进程,算法记录每个进程已分配和还需的资源量。若为某进程分配资源后,系统仍能保证所有进程可完成并释放资源,就进行分配,否则拒绝。
比如,资源有限,进程 P1 申请资源,算法判断分配给 P1 后,其他进程能否顺利完成,能则分配,不能则拒绝。
4.2.4 可重入和线程安全
可重入和线程安全概念:
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
可重入与线程安全联系:
(1)函数是可重入的,那就是线程安全的
(2)函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
(3)如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的
5. 线程同步
之前我们负票的问题使用了互斥量得到了解决,但是再仔细看看,好像所有的票,都是由第五号线程进行操作的,所以线程和线程之间对资源的竞争能力不同。
我们对于线程竞争能力稍加限制,可以看到我们的五个线程有相同的竞争能力了。
除了稍加等待这个操作,操作系统也为我们提出了线程同步这个概念和实现的方法:
线程同步是指多个线程在协同工作时,通过特定的机制来协调它们的执行顺序和对共享资源的访问,以确保线程之间能够正确、有序地协作,避免出现数据不一致、竞态条件等问题。
在多线程环境中,由于线程的执行是并发的,如果不对线程的操作进行同步控制,可能会导致以下情况:
数据竞争:多个线程同时读写同一个共享数据,导致结果不可预测。
不一致的状态:线程对共享资源的部分修改可能会被其他线程打断,导致资源处于不一致的状态。
线程同步的常见方法包括使用条件变量、信号量等。
5.1 条件变量的概念
当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。
同步概念与竞态条件:
同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步
竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解
5.2 条件变量的使用
条件变量函数:
初始化
int pthread_cond_init(pthread_cond_t *restrict cond,
const pthread_condattr_t *restrict attr);
参数:
cond:要初始化的条件变量 attr:NULL
销毁
int pthread_cond_destroy(pthread_cond_t *cond)
等待条件满足
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
参数:
cond:要在这个条件变量上等待
mutex:互斥锁
唤醒等待
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
现在即使没有对线程稍加限制,我们也可以实现多线程之间的公平竞争了。
#include <iostream>
#include <vector>
#include <string>
#include <cstring>
#include <unistd.h>
#define NUM 5 //线程数量
int tickets=100; //全局变量作为剩余的票数
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; //全局变量初始化锁
pthread_cond_t cond = PTHREAD_COND_INITIALIZER; //初始化条件变量
//线程执行的模拟抢票函数
void *getTickets(void *args)
{
uint64_t id=(uint64_t)args;
while(true)
{
pthread_mutex_lock(&mutex); //申请锁成功,才能往后执行,不成功,阻塞等待
if(tickets>0)
{
pthread_cond_wait(&cond, &mutex); //等待条件变量
if(tickets>0) //唤醒了条件变量还要对ticket进行检查
{
std::cout<<"thread id: "<<id<<" remaining tickets: "<<tickets--<<std::endl;
}
pthread_mutex_unlock(&mutex);
}
else
{
pthread_mutex_unlock(&mutex);
break;
}
//usleep(10); //对于抢了票的线程进行一点等待,稍加限制
}
return nullptr;
}
int main()
{
pthread_mutex_init(&mutex, nullptr);
//使用多线程模拟抢票过程
std::vector<pthread_t> threads;
for(int i=1;i<=5;i++)
{
pthread_t tid;
pthread_create(&tid,nullptr,&getTickets,(void*)(i));
threads.push_back(tid);
}
usleep(10);
while(1)
{
//pthread_cond_signal(&cond); //唤醒等待队列中的第一个线程
pthread_cond_broadcast(&cond);
if(tickets<=0) break; //主线程只有一个执行流不需要加锁
}
return 0;
}