个人主页:C++忠实粉丝
欢迎 点赞👍 收藏✨ 留言✉ 加关注💓本文由 C++忠实粉丝 原创Linux系统基础-多线程超详细讲解(3)_线程互斥同步和条件变量
收录于专栏[Linux学习]
本专栏旨在分享学习Linux的一点学习笔记,欢迎大家在评论区交流讨论💌
目录
1. Linux线程互斥
进程线程间互斥相关背景概念
1. 临界资源 : 多个线程执行流共享的资源就叫做临界资源
2. 临界区 : 每个线程内部, 访问临界资源的代码, 就叫做临界区
3. 互斥 : 任何时刻, 互斥保证有且只有一个执行流进入临界区, 访问临界资源, 通常对临界资源起保护作用
4. 原子性 : 不会被任何调度机制打断的操作, 该操作只有两态, 要么完成, 要么未完成
互斥量 mutex
1. 大部分情况, 线程使用的数据都是局部变量, 变量的地址空间在线程栈空间, 这种情况, 变量归属单个线程, 其他线程无法获得这种变量.
2. 但有时候, 很多变量都需要线程间共享, 这样的变量称为共享变量, 可以通过数据的共享, 完成线程之间的交互.
3. 多个线程并发操作共享变量, 会带来一些问题(如后面的实例代码)
实例代码 --- 模拟实现抢票系统
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 100;
void *route(void *arg)
{
char *id = (char*)arg;
while(1)
{
if(ticket > 0)
{
usleep(1000);
printf("%s sells ticket : %d\n", id, ticket);
ticket--;
}
else
{
break;
}
}
}
int main()
{
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, route, (void *)"thread 1");
pthread_create(&t2, NULL, route, (void *)"thread 2");
pthread_create(&t3, NULL, route, (void *)"thread 3");
pthread_create(&t4, NULL, route, (void *)"thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
}
结果展示 :
为什么会出现这样的结果?
1. if 语句判断条件为真以后, 代码可以并发的切换到其他线程
2. usleep 这个模拟漫长业务的过程, 在这个漫长的业务过程中, 可能有很多个线程会进入该代码段
3. -- ticket 操作本身就不是一个原子操作
取出ticket--部分的汇编代码
objdump -d a.out > test.objdump
152 40064b: 8b 05 e3 04 20 00 mov 0x2004e3(%rip),%eax # 600b34 <ticket>
153 400651: 83 e8 01 sub $0x1,%eax
154 400654: 89 05 da 04 20 00 mov %eax,0x2004da(%rip) # 600b34 <ticket>
我们可以发现, 这个代码实现的原抢票操作不是原子操作, 而是对应三条汇编指令 :
1. load: 将共享变量 ticket 从内存加载到寄存器中
2. update: 更新寄存器里面的值, 执行- 1操作
3. store: 将新值, 从寄存器写回共享变量 ticket 的内存地址
要解决以上问题,需要做到三点:
1. 代码必须要有互斥行为 : 当代码进入临界区执行时, 不允许其他线程进入该临界区
2. 如果多个线程同时要求执行临界区的代码, 并且临界区没有线程在执行, 那么只能允许一个线程进入临界区
3. 如果线程不在临界区中执行, 那么该线程不能阻止其他线程进入临界区
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。
互斥量接口
初始化互斥量
初始化互斥量有两种方法 :
方法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
销毁互斥量
销毁互斥量需要注意 :
1. 使用 PTHREAD_MUTEX_INITIALIZER 初始化的互斥量不需要销毁
2. 不要销毁一个已经加锁的互斥量
3. 已经销毁的互斥量, 要确保后面不会有线程再尝试加锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
互斥量加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号
调用 pthread_ lock 时,可能会遇到以下情况:
1. 互斥量处于未锁状态, 该函数会将互斥量锁定, 同时返回成功
2. 发起函数调用时, 其他线程已经锁定互斥量, 或者存在其他线程同时申请互斥量, 但没有竞争到互斥量, 那么 pthread_lock 调用会陷入阻塞 (执行流被挂起) , 等待互斥量解锁.
基于上面的描述改进之前的抢票系统:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <sched.h>
//改进抢票系统
int ticket = 100;
pthread_mutex_t mutex;
void *route(void *arg)
{
char *id = (char*)arg;
while(1)
{
pthread_mutex_lock(&mutex);
if(ticket > 0)
{
usleep(1000);
printf("%s sells ticket : %d\n", id, ticket);
ticket--;
pthread_mutex_unlock(&mutex);
}
else{
pthread_mutex_unlock(&mutex);
break;
}
}
return 0;
}
int main()
{
pthread_t t1, t2, t3, t4;
pthread_mutex_init(&mutex, NULL);
pthread_create(&t1, NULL, route, (void *)"thread 1");
pthread_create(&t2, NULL, route, (void *)"thread 2");
pthread_create(&t3, NULL, route, (void *)"thread 3");
pthread_create(&t4, NULL, route, (void *)"thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
pthread_mutex_destroy(&mutex);
}
结果展示 :
结果分析 :
1. 为什么需要在 else 里面加上 unlock
在 else 里面加上 pthead_mutex_unlock(&mutex) : 是为了确保即使在票数为零的情况下, 锁也能被释放, 具体来说 :
1. 防止死锁 : 如果在进入 else 语句时没有释放锁, 而某个线程有锁但不释放 (例如, 退出条件未触发), 其他线程将无法得到锁, 这样导致程序死锁
2. 确保资源释放: 无论条件如何, 都应该释放锁, 以确保其他线程能够继续执行
因此, 即使票数已经为零, 线程也需要释放锁, 以便其他线程能够继续执行
如果将上述代码中 else 释放锁的步骤去除, 在重新运行一下代码 :
线程1退出后, 并没有释放锁, 其他线程还在竞争锁, 导致死锁!
2. 为什么一直都是线程1在执行抢票任务
1. 线程调度问题 : 在某些情况下, 操作系统调度策略可能导致某一个线程获得了较多CPU时间, 这种现象在多线程中是正常的, 特别是在锁的使用场景下, 某些线程可能会反复获得锁, 导致其他线程的执行被延迟
2. usleep 的使用, 在锁内调用 usleep(1000), 可能导致线程在执行卖票操作时等待, 这样会导致线程1在释放之前多次执行 (当一个线程(例如线程1)在锁内执行 usleep(1000) 时,它会在此期间持有锁,不会释放锁。这样,其他线程(如线程2、线程3或线程4)在尝试获取锁时会被阻塞,直到线程1释放锁。)
所以我们可以将 usleep(1000) 放在锁外, 运行结果为 :
这样就不会让一个线程重复运行多次了~~
互斥量实现原理探究
1. 经过上面的例子, 大家已经意识到了单纯的 i++ 或者 ++i 都不是原子的, 有可能会有数据一致性问题
2. 为了实现互斥锁操作, 大多数体系结构都提供了 swap 或 exchange 指令, 该指令的作用是把寄存器和内存单元的数据相交换, 由于只有一条指令, 保证了原子性, 即使是多处理器平台, 访问内存的总线周期也有先后, 一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期, 现在我们把 lock 和 unlock 的伪代码改一下
2. 可重入和线程安全
概念
1. 线程安全 : 多个线程并发同一段代码, 不会出现不同结果, 常见对全局变量或者静态变量进行操作, 并且没有锁保护的情况下, 会出现该问题
2. 重入 : 同一个函数被不同的执行流调用, 当前一个流程还没执行完, 就有其他的执行流再次进入, 我们称之为重入, 一个函数在重入的情况下, 运行结果不会出现任何不同或者任何问题, 则该函数被称为可重入函数, 否则, 是不可重入函数
常见的线程不安全的情况
1. 不保护共享变量的函数
2. 函数状态随着被调用, 状态发生变化的函数
3. 返回指向静态变量指针的函数
4. 调用线程不安全函数的函数
常见线程安全的情况
1. 每个线程对全局变量或者静态变量只有读取的权限, 而没有写入的权限, 一般来说这些线程是安全的
2. 类或者接口对于线程来说都是原子操作
3. 多个线程之间的切换不会导致该接口的执行结果存在二义性
常见不可重入的情况
1. 调用了 malloc/free 函数, 因为 malloc 函数使用全局链表来管理堆的
2. 调用了标准 I/O 库函数, 标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构
3. 可重入函数体内使用了静态数据结构
常见可重入的情况
1. 不使用全局变量或静态变量
2. 不使用 malloc 或者 new 开辟的空间
3. 不调用不可重入函数
4. 不返回静态或全局数据, 所有数据都有函数的调用者提供
5. 使用本地数据, 或者通过全局数据的本地拷贝来保护全局数据
可重入与线程安全联系
1. 函数是可重入的, 那就是线程安全的
2. 函数是不可重入的, 那就不能由多个线程使用, 有可能引发线程安全的问题
3. 如果一个函数中有全局变量, 那么这个函数不是线程安全也不是可重入的
可重入与线程安全区别
1. 可重入函数是线程安全函数的一种
2. 线程安全不一定是可重入的, 而可重入函数则一定是线程安全的
3. 如果将对临界资源的访问加上锁, 则这个函数是线程安全的, 但如果这个重入函数若还未释放则会产生死锁, 因此是不可重入的.
3. 死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源, 但因互相申请被其他进程所占用不会释放资源而处于的一种永久等待状态
死锁的四个必要条件
1. 互斥条件 : 一个资源每次只能被一个执行流使用
2. 请求与保持条件 : 一个执行流因申请资源而阻塞时, 对己获得的资源保持不放
3. 不剥夺条件 : 一个执行流已获得的资源, 在未使用之前, 不能强行剥夺
4. 循环等待条件 : 若干执行流之间形成一种头尾相接的循环等待资源的关系
注意 : 这四个是必要条件, 也就是说, 这四个条件满足时不一定会有死锁, 但是造成了死锁一定满足这四个条件
避免死锁
破坏死锁的四个必要条件
加锁顺序一致
避免锁未释放的场景
资源一次性分配
避免死锁算法
死锁检测算法
银行家算法
4. Linux线程同步
条件变量
当一个线程互斥地访问某个变量时, 它可能发现在其他线程改变状态之前, 它什么也做不了
例如一个线程访问队列时, 发现队列为空, 它只能等待, 直到其他线程将一个节点添加到队列中, 这种情况就需要用到条件变量
同步概念与竞争条件
同步 : 在保证数据安全的前提下, 让线程能够按照某种特定的顺序访问临界资源, 从而避免饥饿问题, 叫做同步
竞争条件 : 因为时序问题, 而导致程序异常, 我们称之为竟态条件, 在线程场景下, 这种问题也不难理解
条件变量函数
初始化
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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <sched.h>
pthread_cond_t cond;
pthread_mutex_t mutex;
void *r1(void *arg)
{
while(1)
{
pthread_cond_wait(&cond, &mutex);
printf("活动\n");
}
}
void *r2(void *arg)
{
while(1)
{
pthread_cond_signal(&cond);
sleep(1);
}
}
int main()
{
pthread_t t1, t2;
pthread_cond_init(&cond, NULL);
pthread_mutex_init(&mutex, NULL);
pthread_create(&t1, NULL, r1, NULL);
pthread_create(&t2, NULL, r2, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&cond);
}
由于 r2 每秒发送一个信号, r1 会在收到信号后在打印 "活动", 然后继续等待, 这是一个典型的生产者 - 消费者 模式的实现, 其中 r2 是生产者, r1 是消费者 (后面会专门出文章重点讲解~~)
为什么 pthread_cond_wait 需要互斥量?
1. 条件等待是线程间同步的一种手段, 如果只有一个线程, 条件不满足, 一直等下去都不会满足, 所以必须要有一个线程通过某些操作, 改变共享变量, 使原先不满足条件变得满足, 并且友好的通知等待在条件变量上的线程
2. 条件不会无缘无故的突然变得满足了, 必然会牵扯到共享数据的变化, 所以一点要用互斥锁来保护, 没有互斥锁就无法安全的获取和修改共享数据
总结:
我们上面的代码不加上互斥锁, 可能会发生如下问题:
1. 数据竞争 : pthread_cond_wait 和 pthread_cond_signal 之间没有互斥锁保护, 可能导致线程在条件变量上不一致操作, 例如, r1 可能在 r2 发送信号时未能正确等待
2. 未定义行为 : 在没有互斥锁的情况下, r1 和 r2 可能同时访问条件变量, 导致程序崩溃或行为不可预测
3. 输出错误 : r1 可能不会在正确的时机打印 "活动", 因为信号可能在它等待之前被发送, 从而导致逻辑错误
按照上面的说法, 我们设计出如下的代码, 先上锁, 发现条件不满足, 解锁, 然后等待在条件变量上不变就行了, 如下代码:
// 错误的设计
pthread_mutex_lock(&mutex);
while (condition_is_false) {
pthread_mutex_unlock(&mutex);
//解锁之后,等待之前,条件可能已经满足,信号已经发出,但是该信号可能被错过
pthread_cond_wait(&cond);
pthread_mutex_lock(&mutex);
}
pthread_mutex_unlock(&mutex);
1. 由于解锁和等待不是原子性操作, 调用解锁之后, pthread_cond_wait 之前, 如果已经有其他线程获取到互斥量, 摒弃条件满足, 发送了信号, 那么pthread_cond_wait 错过这个信号, 可能会导致线程永远阻塞在这个 pthread_cond_wait , 所以解锁和等待必须是一个原子操作
2. int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex) 进入该函数后, 会看条件变量等于0不? 等于, 就把互斥量变成1, 直到cond_wait返回, 把条件量改成1, 把互斥量恢复成原样
条件变量使用规范
等待条件代码
pthread_mutex_lock(&mutex);
while (条件为假)
pthread_cond_wait(cond, mutex);
修改条件
pthread_mutex_unlock(&mutex);
给条件发送信号代码
pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(&mutex);
5. 封装属于自己的线程库
#pragma once
#include <iostream>
#include <string>
#include <pthread.h>
namespace ThreadMoudle
{
//线程要执行的方法, 后面可以随时调正
typedef void (*func_t)(const std::string &name); //函数指针类型
class Thread
{
public:
void Excute()
{
std::cout << _name << "is running" << std::endl;
_isrunning = true;
_func(_name);
_isrunning = false;
}
public:
Thread(const std::string &name, func_t func) :_name(name), _func(func)
{
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;
}
std::string Name()
{
return _name;
}
~Thread()
{
}
private:
std::string _name;
pthread_t _tid;
bool _isrunning;
func_t _func; //线程要执行的回调函数
};
}//namespace ThreadModle
typedef void (*func_t)(const std::string &name);
定义一个函数指针类型 func_t , 它指向接收 std::string 参数并返回 void 的函数, 这使得可以传入自定义线程执行函数
测试代码 --- 抢票系统
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "Thread.hpp"
#include <sched.h>
using namespace ThreadMoudle;
int tickets = 100;
void route(const std::string &name)
{
while(true)
{
if(tickets > 0)
{
//抢票过程
usleep(100); // 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();
}
由于没有加上锁和条件变量, 所以后面还需要改进
6. 总结
下一章内容 : 重点讲解 生产者 - 消费者模型
当然也会完善封装好自己的线程库, 还有就是实现 基于 BlockingQueue 的生产者消费者模型, 感兴趣的宝子们千万不要忘了点赞关注收藏走一波哦~~这是对我最大的鼓励!好了, 我们下期再见~~