Linux多线程(中)
- 1.Linux线程互斥
- 1.1互斥量的接口
- 1.1.1初始化互斥量
- 1.1.2销毁互斥量
- 1.1.3互斥量加锁和解锁
- 1.2修改代码
- 1.3互斥量实现原理
- 2.可重入VS线程安全
- 3.死锁
- 4.Linux线程同步
- 5.生产者消费者模型
🌟🌟hello,各位读者大大们你们好呀🌟🌟
🚀🚀系列专栏:【Linux的学习】
📝📝本篇内容:Linux线程互斥;可重入VS线程安全;死锁;Linux线程同步;生产者消费者模型
⬆⬆⬆⬆上一篇:Linux多线程(上)
💖💖作者简介:轩情吖,请多多指教(>> •̀֊•́ ) ̖́-
1.Linux线程互斥
#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(void)
{
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, route,(char*)"thread 1");
pthread_create(&t2, NULL, route,(char*)"thread 2");
pthread_create(&t3, NULL, route,(char*)"thread 3");
pthread_create(&t4, NULL, route,(char*)"thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
}
观察上面的代码,可以发现我们的抢票功能会出现问题,具体原因如下
if 语句判断条件为真以后,代码可以并发的切换到其他线程
usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
–ticket 操作本身就不是一个原子操作
– 操作并不是原子操作,而是对应三条汇编指令:
load :将共享变量ticket从内存加载到寄存器中
update : 更新寄存器里面的值,执行-1操作
store :将新值,从寄存器写回共享变量ticket的内存地址
因此我们需要做到以下三点:
代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临
界区。
如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
1.1互斥量的接口
1.1.1初始化互斥量
一共分为两种方法:
①静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
②动态分配
参数:
mutex:要初始化的互斥量
attr:NULL
1.1.2销毁互斥量
在销毁互斥量的时候要注意两个点:①使用宏定义的初始化的互斥量不需要销毁;②不要销毁一个已经加锁的互斥量
函数:
1.1.3互斥量加锁和解锁
调用 pthread_ lock 时,可能会遇到以下情况:
互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁
1.2修改代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 100;
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;//使用锁
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);
//这里也需要,因为假设有线程正好碰到ticket为0了,而它后面还有线程,那就需要解锁,让后面的线程进入临界区,不能一直挂起着
break;
}
}
}
int main(void)
{
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, route,(char*)"thread 1");
pthread_create(&t2, NULL, route,(char*)"thread 2");
pthread_create(&t3, NULL, route,(char*)"thread 3");
pthread_create(&t4, NULL, route,(char*)"thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
pthread_mutex_destroy(&mutex);
}
现在就能正常运行而不出现bug
1.3互斥量实现原理
其实在我们的C/C++中,val- -是一条语句,但是实际上它在汇编层面有三条语句,对全局变量做自减,没有任何保护的话,会存在并发的访问问题,进而导致数据不一致的问题
单纯的i++或者++i都不是原子的,都有可能出现数据一致性的问题,为了实现互斥锁的原理,大多数的体系结构都提供了swap和exchange命令,该指令的作用是把寄存器和内存单元的数据相交换,只有一条指令,保证了原子性
我们的线程会执行加锁和解锁的代码,我们电脑的寄存器硬件只有一套,但是寄存器内部的数据(执行流上下文)是每个数据都是线程各自的。执行xchgb%al,mutex就是交换,本质上是将共享数据交换到自己私有的上下文当中,就其实就是加锁,而且加锁只有一条汇编,因此加锁是原子性的
①凡是访问同一个临界资源的线程,都要进行加锁保护,而且是同一把锁;
②每一个线程访问临界区之前,得先加锁,加锁本质山是给临界区加锁,加锁的粒度要细一点;
③线程访问临界区的时候,需要先加锁,因此所有的线程都必须要先看到同一把锁,我们的锁本身就是共享资源公共资源,因此我们保证加锁和解锁是原子的;
④临界区可以是一行代码也可以是一批代码;我们的线程可能在临界区的时候被切换,我们不能特殊化加锁和解锁,还有临界区的代码;在切换的时候不会造成数据一致性的问题,因为当线程不在的时候,任何线程都没有办法进入临界区,因为其他线程没有办法申请到锁,锁已经被前面的线程拿走了
⑤其实这也体现了互斥的概念,线程有意义的状态有两种:持有锁(锁被我申请了),不持有锁(锁被我释放了),原子性的体现
2.可重入VS线程安全
线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
可重入与线程安全联系:
函数是可重入的,那就是线程安全的
函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入与线程安全区别:
可重入函数是线程安全函数的一种
线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
3.死锁
死锁指的是一组进程中的各个线程占有不会释放的资源,但因为互相申请被其他线程占用不会释放的资源而处于一种永久等待状态
死锁的四个必要条件:
互斥条件:一个资源每次只能被一个执行流使用
请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
不剥夺条件:一个执行流已获得的资源在未使用完前,不能被强行剥夺
循环等待条件:若干执行流之间形成了头尾相接的循环等待资源的关系、
避免死锁:(破坏死锁的四个必要条件)
①不加锁
②主动释放锁
③按顺序申请锁
④控制线程统一释放锁
4.Linux线程同步
同步:在保证数据安全的前提下,让线程能够按照某种特定顺序访问临界资源,从而有效避免饥饿问题(让多线程进行协同工作)
条件变量能够允许多线程在cond中队列式等待(就是一种顺序)
上述就是条件变量的函数,具体使用我们在生产消费模型来具体演示
5.生产者消费者模型
我们的阻塞队列是必须先被我们的生产者和消费者线程看到,因此一定是一个会被多线程并发访问的公共区域,为了保护共享资源的安全,要维护线程互斥同步的关系.
这样一来我们的生产者消费者模型会有这几个优点:支持并发;支持忙闲不均;效率高;解耦
对于我们的生产者消费者模型可以使用321原则来记忆:
3种关系:
生产者和生产者→互斥 消费者和消费者→互斥 生产者消费者→互斥和同步
2种角色:
生产者,消费者
一个交易场所:
通常是缓冲区
接下来是一个生产者消费者模型的代码演示:我们的需求是productor生产Task,customer消费Task,Task是基本运算
//main.cc
#include <iostream>
#include <unistd.h>
#include <string>
#include <pthread.h>
#include "Task.hpp"
#include "BlockQueue.hpp"
using namespace std;
void *ProductorRun(void *arg) // 生产者线程要使用的函数
{
BlockQueue<Task> *bq = static_cast<BlockQueue<Task> *>(arg);
while (1)
{
// 创建Task任务
char arr[] = "+-*/%";
int x = rand() % 10;
int y = rand() % 20;
char c = arr[rand() % 5];
Task t(x, y, c);
bq->push(t);
cout << t.to_productor() << endl;
}
return nullptr;
}
void *CustomerRun(void *arg) // 消费者线程要使用的函数
{
BlockQueue<Task> *bq = static_cast<BlockQueue<Task> *>(arg);
while (1)
{
sleep(3);
Task t;
bq->pop(&t);
t();
cout << t.to_customer() << endl;
}
return nullptr;
}
int main()
{
srand((uint64_t)time(0));
BlockQueue<Task> bq;
pthread_t customer[3];
pthread_t productor[3];
pthread_create(&customer[0],nullptr,CustomerRun,&bq);
pthread_create(&customer[1],nullptr,CustomerRun,&bq);
pthread_create(&customer[2],nullptr,CustomerRun,&bq);
pthread_create(&productor[0],nullptr,ProductorRun,&bq);
pthread_create(&productor[1],nullptr,ProductorRun,&bq);
pthread_create(&productor[2],nullptr,ProductorRun,&bq);
//join
pthread_join(customer[0],nullptr);
pthread_join(customer[1],nullptr);
pthread_join(customer[2],nullptr);
pthread_join(productor[0],nullptr);
pthread_join(productor[1],nullptr);
pthread_join(productor[2],nullptr);
return 0;
}
//BlockQueue.hpp
#pragma once
#include <iostream>
#include <unistd.h>
#include <string>
#include <pthread.h>
#include <queue>
using namespace std;
#define SIZE 5
template<class T=int>
class BlockQueue
{
public:
BlockQueue()
{
pthread_mutex_init(&_mutex,nullptr);//锁初始化
pthread_cond_init(&_productor,nullptr);//条件变量的初始化
pthread_cond_init(&_customer,nullptr);
}
bool IsFull()
{
return _q.size()==_capacity;
}
bool IsEmpty()
{
return _q.size()==0;
}
void push(const T& data)
{
pthread_mutex_lock(&_mutex);//能够保证我们的队列一次只能有一个消费者或生产者使用
while(IsFull())//这边不能使用if,因为有可能后续把全部的生产者唤醒,if的话只能判断一次
{
pthread_cond_wait(&_productor,&_mutex);//当线程进行wait时,mutex会被释放;当线程被signal或broadcast后,线程会重新获得mutex
}
_q.push(data);//生产
pthread_cond_signal(&_customer);//唤醒一个消费者
pthread_mutex_unlock(&_mutex);
}
void pop(T* data)
{
pthread_mutex_lock(&_mutex);
while(IsEmpty())
{
pthread_cond_wait(&_customer,&_mutex);
}
*data=_q.front();//消费
_q.pop();
pthread_cond_signal(&_productor);
pthread_mutex_unlock(&_mutex);
}
private:
queue<T> _q;
pthread_mutex_t _mutex;
pthread_cond_t _productor;
pthread_cond_t _customer;
int _capacity=SIZE;
};
//Task.hpp
#pragma once
#include <iostream>
#include <unistd.h>
#include<string>
using namespace std;
//阻塞队列中的内容,生产者生产,消费者消费
class Task
{
public:
Task()
{}
Task(int x,int y,char op)
:_x(x),_y(y),_op(op)
{}
void operator()()
{
switch(_op)
{
case '+':
_result=_x+_y;
break;
case '-':
_result=_x-_y;
break;
case '/':
if(_y==0)
{
_code=-1;
break;
}
_result=_x/_y;
break;
case '*':
_result=_x*_y;
break;
case '%':
if(_y==0)
{
_code=-2;
break;
}
_result=_x%_y;
break;
default:
_code=-3;
break;
}
}
string to_productor()//打印productor生产的内容是什么
{
return to_string(_x)+_op+to_string(_y)+"=?";
}
string to_customer()//打印customer消费后的结果为什么
{
return to_string(_x)+_op+to_string(_y)+"="+to_string(_result)+";code="+to_string(_code);
}
private:
int _x;//操作数
int _y;//操作数
char _op;//计算方法
int _result=0;//计算结果
int _code=0;//计算后的返回码
};
//_code:
//-1 -> /出错
//-2 -> %出错
//-3 -> _op出错
# makefile
main:main.cc
g++ -o main main.cc -std=c++11 -lpthread
.PHONY:clean
clean:
rm -rf main
注意细节:
①我们一定要保证,在任何一个时候,都是符合条件的,才进行一个生产(所以不能用if)
②我们只能在临界区内部判断临界资源是否就绪,注定了此时我们一定是持有锁的
③要让线程进行休眠等待,不能持有锁等待,不然会造成死锁,,因此pthread_cond_wait要有释放锁的能力
④当线程在pthread_cond_wait中会进行休眠,当醒来的时候,继续从临界区内部继续运行,继续在函数处向后运行,并重新申请锁,申请成功才会彻底返回
⑤对于这个模型的高效指的是当生产者在获取数据时,而消费者在阻塞队列中获取数据;当消费者处理数据时,而生产者在放数据到阻塞队列中
⑦只用一把锁是因为生产和消费访问的是同一个queue,queue被当成一个整体使用
🌸🌸Linux多线程(中)的知识大概就讲到这里啦,博主后续会继续更新更多Linux的相关知识,干货满满,如果觉得博主写的还不错的话,希望各位小伙伴不要吝啬手中的三连哦!你们的支持是博主坚持创作的动力!💪💪