目录
1、互斥相关概念
2、代码体现互斥重要性
3、互斥锁
3.1 初始化锁
3.2 申请、释放锁
3.3 加锁的思想
3.4 实现加锁
3.5 锁的原子性
4、线程安全
4.1 可重入函数
4.2 死锁
5、线程同步
5.1 条件变量初始化
5.2 条件变量等待队列
5.3 唤醒等待队列
5.4 实现线程同步
结语
前言:
在Linux下,线程是一个很重要的概念,他可以提高多执行流的并发度,而同步与互斥是对线程的一种约束行为,比如当多个线程都访问同一个资源时,若不对该资源加以保护则会导致意料之外的错误。具体的保护措施是让线程访问共享资源时具有互斥性,即当一个线程访问时别的线程无法访问,通常用互斥锁来实现。而同步是为了让多个线程具有一定的顺序来访问共享内存,保障每个线程访问资源的机会是一样的。
1、互斥相关概念
线程之所以需要互斥,是因为多线程在访问共享资源时,可能该资源只允许被修改一次,但是其他线程在修改的时候“刹不住车”,导致该资源被修改多次,原因就是多个线程同时访问了该资源,如下图所示:
在概念层面上,通常把共享资源叫做临界资源。在代码层面,把访问共享资源的代码叫做临界区。
当线程有了互斥约束后,就不会出现上述a=0时继续访问a的情况,如下图:
2、代码体现互斥重要性
在实际生活中,某些有限的物品是不能出现负数的情况的,比如抢票,票为0时是不能继续抢票的,但是当实现多线程抢票时,若没有互斥的约束,则很容易发生票为0时还在抢票,模拟抢票的代码如下:
#include <iostream>
#include <cstdio>
#include <cstring>
#include <vector>
#include <unistd.h>
#include <pthread.h>
using namespace std;
int tickets = 1000; // 用多线程模拟抢票
class threadData
{
public:
threadData(int number)
{
threadname = "线程-" + to_string(number);
}
public:
string threadname;
};
void *getTicket(void *args)
{
threadData *td = static_cast<threadData *>(args);
const char *name = td->threadname.c_str();
while (true)
{
if(tickets > 0)
{
usleep(1000);
printf("%s, 抢到一张票: %d\n", name, tickets);
tickets--;
}
else
break;
}
printf("%s 退出\n", name);
return nullptr;
}
int main()
{
vector<pthread_t> tids;
vector<threadData *> thread_datas;
for (int i = 1; i <= 4; i++)
{
pthread_t tid;
threadData *td = new threadData(i);
thread_datas.push_back(td);
pthread_create(&tid, nullptr, getTicket, thread_datas[i - 1]);
tids.push_back(tid);
}
//等待线程
for (auto thread : tids)
{
pthread_join(thread, nullptr);
}
//释放空间资源
for (auto td : thread_datas)
{
delete td;
}
return 0;
}
运行结果:
从结果可以看到,发生了负数票的情况,原因就是上面多线程代码没有任何互斥的约束。
对上面代码进行分析找出其临界区,全局变量ticket是临界资源,因此代码中对ticket的访问就是临界区,如下图所示:
为了解决上面的问题,只能使用互斥约束多线程,而互斥就必须用到互斥锁。
3、互斥锁
实现互斥锁的步骤:
1、创建一个锁变量。
2、使用接口初始化该变量。
3、在临界区处申请该锁。
4、临界区代码执行完后释放锁。
5、销毁锁。
值得注意的是:只能用一把锁限制对临界区的访问,即线程要想访问临界区,则必须申请到该锁才能访问,没有申请到锁的线程就无法访问临界区。
申请锁的示意图如下:
3.1 初始化锁
初始化锁用到的接口介绍如下:
#include <pthread.h>
//销毁锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
//初始化锁,方式1
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
//restrict mutex表示要初始化的锁
//restrict attr表示初始化的属性
//初始化锁,方式2
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
//定义在全局,则mutex锁就已经被初始化了
pthead_mutex_t是库提供的数据类型,用于定义一个锁。方式2是一个全局变量初始化锁,若用方式2初始化一个锁则无需对该锁进行destroy。注意:若用方式2进行锁的初始化则该锁必须是全局的。
3.2 申请、释放锁
锁的初始化工作完成后,接下来就是申请锁,申请锁的接口介绍如下:
#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);//申请mutex锁
int pthread_mutex_trylock(pthread_mutex_t *mutex);//申请mutex锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);//释放mutex锁
pthread_mutex_lock申请不到锁会阻塞在该函数处,而pthread_mutex_trylock申请不到锁不会阻塞,会继续执行下面代码。
3.3 加锁的思想
申请锁就是加锁,加锁的本质是用时间换来线程安全,让线程访问临界资源时串开访问,对临界区进行加锁时尽量缩小临界区的代码量,因为临界区的代码越少,执行的速度越快,则进程被cpu挂起的概念就越低,被cpu挂起的概念低了则可以减少其他线程等的时间,因为当申请到锁的线程被挂起了,那么其他的线程就算被cpu调度了也不能执行临界区的代码(因为其他线程没有持有锁),只能干等。
3.4 实现加锁
对上述代码实现加锁,代码如下:
#include <iostream>
#include <cstdio>
#include <cstring>
#include <vector>
#include <unistd.h>
#include <pthread.h>
using namespace std;
int tickets = 1000; // 用多线程模拟抢票
class threadData
{
public:
threadData(int number , pthread_mutex_t *mutex)
{
threadname = "线程-" + to_string(number);
lock = mutex;
}
public:
string threadname;
pthread_mutex_t *lock;
};
void *getTicket(void *args)
{
threadData *td = static_cast<threadData *>(args);
const char *name = td->threadname.c_str();
while (true)
{
pthread_mutex_lock(td->lock); //申请锁
if(tickets > 0)
{
usleep(1000);
printf("%s, 抢到一张票: %d\n", name, tickets);
tickets--;
pthread_mutex_unlock(td->lock);//释放锁
}
else{
pthread_mutex_unlock(td->lock);//释放锁
break;
}
//usleep(12); //先把此处的usleep屏蔽,观察抢票现象
}
printf("%s 退出\n", name);
return nullptr;
}
int main()
{
pthread_mutex_t lock;//定义一个锁
pthread_mutex_init(&lock, nullptr);//对该锁进行初始化
vector<pthread_t> tids;
vector<threadData *> thread_datas;
for (int i = 1; i <= 3; i++)
{
pthread_t tid;
threadData *td = new threadData(i ,&lock);
thread_datas.push_back(td);
//要将锁也传给线程
pthread_create(&tid, nullptr, getTicket, thread_datas[i - 1]);
tids.push_back(tid);
}
//等待线程
for (auto thread : tids)
{
pthread_join(thread, nullptr);
}
//释放空间资源
for (auto td : thread_datas)
{
delete td;
}
pthread_mutex_destroy(&lock);
return 0;
}
运行结果:
从结果看,虽然没有出现负票的情况,但是发现只有一个线程在抢票,原因很简单,肯定是只有该进程申请到锁了,其他线程没申请到,那么为什么只有该线程能申请到,而其他线程申请不到呢?是因为这个线程刚释放完锁后他就立马再进行申请锁的动作了,他之所以可以比其他线程更快申请到锁的原因是“他离锁最近”,具体示意图如下:
所以在一个线程释放锁后,可以手动对该线程进行sleep,让其他线程有机会去申请到锁,因此把上述代码中释放锁后面的usleep放开,就可以让其他线程申请到锁了,运行结果如下:
3.5 锁的原子性
从上文可以得知,当多线程访问共享资源时,若没有互斥约束,则会发生错误,所以对线程进行加锁的操作,但是锁本身也是共享资源,因为多线程都能看到锁并且申请他,那么申请锁的时候不好导致同样的问题吗?
答案是不会,多线程访问共享资源之所以会发生意料之外的错误,是因为多线程对共享资源做修改操作的时候,这些修改操作在底层被转换成汇编语句,虽然上层看到的修改操作只有一句代码,但是在底层转换成两三句汇编指令,而cpu一次只能运算一句汇编指令,这就导致同一个操作没有真正被cpu执行完就被切换走了,等到下次继续执行该操作时,从内存中读取的数据可能已经被别的线程修改了,这就导致了意料之外的错误。而申请锁的动作只有一句汇编指令,他的状态只有两种:1、要么没申请到锁,2、要么申请到锁。不存在执行一半被切走的可能,通常把这种状态叫做原子性,因此锁是具有原子性的。
4、线程安全
线程安全指的是在多线程的并行下,访问某些资源时,不会导致该资源的数据损坏或出现意料之外的错误,线程与线程之间不会互相干扰对方的操作,多线程能够安全的执行下去,把这叫做线程安全。
4.1 可重入函数
可重入函数值得是当同一个函数被多个线程调用时,调用的结果不会产生任何的问题,比如不会导致数据损坏或者资源泄漏,则该函数被称为可重入函数,否则,是不可重入函数。
4.2 死锁
死锁指的是当线程申请锁时造成了循环申请,也就是说线程1要申请线程2的锁,而线程2要申请线程1的锁,造成死循环称之为死锁,具体示意图如下:
造成死锁的四个必要条件:
1、互斥条件:一把锁只能被一个线程申请。2、请求与保持条件:多线程之间互相申请对方的锁,但是对方就是不释放该锁。3、不剥夺条件 :不释放对方的锁,即使要申请的锁在对方手里也不主动释放。4、循环等待条件 : 多线程循环等待彼此的资源。
只要不满足上面4个条件是任何一个,则就造成不了死锁。
5、线程同步
线程同步的目的是让每个线程申请锁的能力是有顺序性的,即每个线程都可以公平的申请到锁,通常是定义一个条件变量,然后将线程放入等待队列中(申请的前提是该线程必须持有锁),申请到锁的线程就能够进入等待队列中等待了,进入等待队列时线程会自动释放锁,目的是让下一个线程申请锁然后也入队,因此条件变量必须搭配锁才能使用。
将线程放入等待队列的示意图如下:
唤醒等待队列里的线程去申请锁:
等待队列申请锁的逻辑:首先需要唤醒该等待队列,然后队列里的第一个线程可以重新去申请锁,访问临界资源结束后,释放锁的线程会回到队列的末尾,如此逻辑就能够实现线程同步了。
5.1 条件变量初始化
条件变量的初始化逻辑和锁的初始化逻辑相似,都是有两种初始化方式,具体接口如下:
#include <pthread.h>
//销毁条件变量
int pthread_cond_destroy(pthread_cond_t *cond);
//条件变量初始化方式1
int pthread_cond_init(pthread_cond_t *restrict cond,
const pthread_condattr_t *restrict attr);
//restrict cond表示要初始化的条件变量的地址
//attr表示条件变量初始化的属性设置
//条件变量初始化方式2
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;//在全局定义完成初始化
5.2 条件变量等待队列
在条件变量完成初始化后,需要将线程放入条件变量的等待队列中, 这个过程只需要调用函数pthread_cond_wait即可完成,但是要注意调用该函数时当前线程必须是持有锁的,所以使用条件变量必须依赖锁,pthread_cond_wait函数介绍如下:
#include <pthread.h>
int pthread_cond_wait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex);
//cond表示将该线程放入哪个条件变量的队列
//mutex表示等待队列被唤醒后可申请的锁
当线程调用此函数时,会释放已经申请的锁然后在等待队列中排队,所以线程的执行流会阻塞在该函数处。
5.3 唤醒等待队列
当调用,唤醒函数介绍如下:
#include <pthread.h>
int pthread_cond_broadcast(pthread_cond_t *cond);
//cond表示要唤醒的条件变量等待队列,该函数被调用一次则整个队列都被唤醒
int pthread_cond_signal(pthread_cond_t *cond);
//cond表示要唤醒的条件变量等待队列,该函数被调用一次则只唤醒队头的线程
5.4 实现线程同步
上文中抢票代码的逻辑是线程释放锁后对该线程进行sleep,这么做让其他线程有了申请锁的机会,其实这也是同步的一种方法,只不过sleep的时间不好控制,而现在我们无需对线程进行sleep也可以实现同步,即使用条件变量进行同步,让系统去维护同步机制,可以更好的控制同步。
实现线程同步的代码如下:
#include <iostream>
#include <cstdio>
#include <cstring>
#include <vector>
#include <unistd.h>
#include <pthread.h>
using namespace std;
int tickets = 1000; // 用多线程模拟抢票
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;//全局初始化
class threadData
{
public:
threadData(int number , pthread_mutex_t *mutex)
{
threadname = "线程-" + to_string(number);
lock = mutex;
}
public:
string threadname;
pthread_mutex_t *lock;
};
void *getTicket(void *args)
{
threadData *td = static_cast<threadData *>(args);
const char *name = td->threadname.c_str();
while (true)
{
pthread_mutex_lock(td->lock); //申请锁
pthread_cond_wait(&cond,td->lock);//将线程放入等待队列
if(tickets > 0)
{
//usleep(1000);
printf("%s, 抢到一张票: %d\n", name, tickets);
tickets--;
pthread_mutex_unlock(td->lock);//释放锁
}
else{
pthread_mutex_unlock(td->lock);//释放锁
break;
}
}
printf("%s 退出\n", name);
return nullptr;
}
int main()
{
pthread_mutex_t lock;//定义一个锁
pthread_mutex_init(&lock, nullptr);//对该锁进行初始化
vector<pthread_t> tids;
vector<threadData *> thread_datas;
for (int i = 1; i <= 3; i++)//创建3个线程
{
pthread_t tid;
threadData *td = new threadData(i ,&lock);
thread_datas.push_back(td);
//要将锁也传给线程
pthread_create(&tid, nullptr, getTicket, thread_datas[i - 1]);
tids.push_back(tid);
}
sleep(2);//目的是让线程全部都放入队列中,然后再进行唤醒
//唤醒队列
while(true)
{
pthread_cond_signal(&cond);
}
//等待线程
for (auto thread : tids)
{
pthread_join(thread, nullptr);
}
//释放空间资源
for (auto td : thread_datas)
{
delete td;
}
pthread_mutex_destroy(&lock);
return 0;
}
运行结果:
从结果可以看到,没有出现负票的情况,并且所有线程都在抢票。这里注意pthread_mutex_lock和pthread_cond_wait两个函数对锁的申请和释放逻辑,调用pthread_mutex_lock时线程会申请锁,然后调用pthread_cond_wait时,线程会释放锁,并且阻塞在该函数处等待被唤醒,被唤醒后该线程又重新申请锁,申请成功后执行临界区代码。
结语
以上就是关于线程的同步与互斥讲解,若使用多线程进行并发式的执行程序,那么同步和互斥是必不可少的保护措施,他保障了多线程并发执行时线程的安全,防止出现意料之外的错误,因此对临界资源进行同步和互斥是多线程执行时非常重要的一步。
最后如果本文有遗漏或者有误的地方欢迎大家在评论区补充,谢谢大家!!